mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
test: reduce remaining clone seams
This commit is contained in:
@@ -10,6 +10,19 @@ import {
|
||||
describe("telegram stickers", () => {
|
||||
const STICKER_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000;
|
||||
|
||||
async function createStaticStickerHarness() {
|
||||
const proxyFetch = vi.fn().mockResolvedValue(
|
||||
new Response(Buffer.from(new Uint8Array([0x52, 0x49, 0x46, 0x46])), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/webp" },
|
||||
}),
|
||||
);
|
||||
const handlerContext = await createBotHandlerWithOptions({
|
||||
proxyFetch: proxyFetch as unknown as typeof fetch,
|
||||
});
|
||||
return { proxyFetch, ...handlerContext };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cacheStickerSpy.mockClear();
|
||||
getCachedStickerSpy.mockClear();
|
||||
@@ -23,15 +36,7 @@ describe("telegram stickers", () => {
|
||||
it.skip(
|
||||
"downloads static sticker (WEBP) and includes sticker metadata",
|
||||
async () => {
|
||||
const proxyFetch = vi.fn().mockResolvedValue(
|
||||
new Response(Buffer.from(new Uint8Array([0x52, 0x49, 0x46, 0x46])), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/webp" },
|
||||
}),
|
||||
);
|
||||
const { handler, replySpy, runtimeError } = await createBotHandlerWithOptions({
|
||||
proxyFetch: proxyFetch as unknown as typeof fetch,
|
||||
});
|
||||
const { handler, proxyFetch, replySpy, runtimeError } = await createStaticStickerHarness();
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
@@ -74,15 +79,7 @@ describe("telegram stickers", () => {
|
||||
it.skip(
|
||||
"refreshes cached sticker metadata on cache hit",
|
||||
async () => {
|
||||
const proxyFetch = vi.fn().mockResolvedValue(
|
||||
new Response(Buffer.from(new Uint8Array([0x52, 0x49, 0x46, 0x46])), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/webp" },
|
||||
}),
|
||||
);
|
||||
const { handler, replySpy, runtimeError } = await createBotHandlerWithOptions({
|
||||
proxyFetch: proxyFetch as unknown as typeof fetch,
|
||||
});
|
||||
const { handler, proxyFetch, replySpy, runtimeError } = await createStaticStickerHarness();
|
||||
|
||||
getCachedStickerSpy.mockReturnValue({
|
||||
fileId: "old_file_id",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
|
||||
@@ -7,37 +6,22 @@ import {
|
||||
clearPluginManifestRegistryCache,
|
||||
type PluginManifestRegistry,
|
||||
} from "../plugins/manifest-registry.js";
|
||||
import {
|
||||
cleanupTrackedTempDirs,
|
||||
makeTrackedTempDir,
|
||||
mkdirSafeDir,
|
||||
} from "../plugins/test-helpers/fs-fixtures.js";
|
||||
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
|
||||
import { validateConfigObject } from "./validation.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function chmodSafeDir(dir: string) {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
fs.chmodSync(dir, 0o755);
|
||||
}
|
||||
|
||||
function mkdtempSafe(prefix: string) {
|
||||
const dir = fs.mkdtempSync(prefix);
|
||||
chmodSafeDir(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function mkdirSafe(dir: string) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
chmodSafeDir(dir);
|
||||
}
|
||||
|
||||
function makeTempDir() {
|
||||
const dir = mkdtempSafe(path.join(os.tmpdir(), "openclaw-plugin-auto-enable-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
return makeTrackedTempDir("openclaw-plugin-auto-enable", tempDirs);
|
||||
}
|
||||
|
||||
function writePluginManifestFixture(params: { rootDir: string; id: string; channels: string[] }) {
|
||||
mkdirSafe(params.rootDir);
|
||||
mkdirSafeDir(params.rootDir);
|
||||
fs.writeFileSync(
|
||||
path.join(params.rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
@@ -122,9 +106,7 @@ function applyWithBluebubblesImessageConfig(extra?: {
|
||||
afterEach(() => {
|
||||
clearPluginDiscoveryCache();
|
||||
clearPluginManifestRegistryCache();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
cleanupTrackedTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
describe("applyPluginAutoEnable", () => {
|
||||
@@ -306,7 +288,7 @@ describe("applyPluginAutoEnable", () => {
|
||||
it("uses env-scoped catalog metadata for preferOver auto-enable decisions", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const catalogPath = path.join(stateDir, "plugins", "catalog.json");
|
||||
mkdirSafe(path.dirname(catalogPath));
|
||||
mkdirSafeDir(path.dirname(catalogPath));
|
||||
fs.writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
|
||||
@@ -55,6 +55,24 @@ function makeMockMessage(role: "user" | "assistant" = "user", text = "hello"): A
|
||||
return { role, content: text, timestamp: Date.now() } as AgentMessage;
|
||||
}
|
||||
|
||||
function registerPromptTrackingEngine(engineId: string) {
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
registerContextEngine(engineId, () => ({
|
||||
info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" },
|
||||
async ingest() {
|
||||
return { ingested: false };
|
||||
},
|
||||
async assemble(params) {
|
||||
calls.push({ ...params });
|
||||
return { messages: params.messages, estimatedTokens: 0 };
|
||||
},
|
||||
async compact() {
|
||||
return { ok: true, compacted: false };
|
||||
},
|
||||
}));
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** A minimal mock engine that satisfies the ContextEngine interface. */
|
||||
class MockContextEngine implements ContextEngine {
|
||||
readonly info: ContextEngineInfo = {
|
||||
@@ -700,20 +718,7 @@ describe("LegacyContextEngine parity", () => {
|
||||
describe("assemble() prompt forwarding", () => {
|
||||
it("forwards prompt to the underlying engine", async () => {
|
||||
const engineId = `prompt-fwd-${Date.now().toString(36)}`;
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
registerContextEngine(engineId, () => ({
|
||||
info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" },
|
||||
async ingest() {
|
||||
return { ingested: false };
|
||||
},
|
||||
async assemble(params) {
|
||||
calls.push({ ...params });
|
||||
return { messages: params.messages, estimatedTokens: 0 };
|
||||
},
|
||||
async compact() {
|
||||
return { ok: true, compacted: false };
|
||||
},
|
||||
}));
|
||||
const calls = registerPromptTrackingEngine(engineId);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
await engine.assemble({
|
||||
@@ -728,20 +733,7 @@ describe("assemble() prompt forwarding", () => {
|
||||
|
||||
it("omits prompt when not provided", async () => {
|
||||
const engineId = `prompt-omit-${Date.now().toString(36)}`;
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
registerContextEngine(engineId, () => ({
|
||||
info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" },
|
||||
async ingest() {
|
||||
return { ingested: false };
|
||||
},
|
||||
async assemble(params) {
|
||||
calls.push({ ...params });
|
||||
return { messages: params.messages, estimatedTokens: 0 };
|
||||
},
|
||||
async compact() {
|
||||
return { ok: true, compacted: false };
|
||||
},
|
||||
}));
|
||||
const calls = registerPromptTrackingEngine(engineId);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
await engine.assemble({
|
||||
@@ -758,20 +750,7 @@ describe("assemble() prompt forwarding", () => {
|
||||
// is undefined — JavaScript keeps the key present with value undefined,
|
||||
// which breaks engines that guard with `'prompt' in params`.
|
||||
const engineId = `prompt-undef-${Date.now().toString(36)}`;
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
registerContextEngine(engineId, () => ({
|
||||
info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" },
|
||||
async ingest() {
|
||||
return { ingested: false };
|
||||
},
|
||||
async assemble(params) {
|
||||
calls.push({ ...params });
|
||||
return { messages: params.messages, estimatedTokens: 0 };
|
||||
},
|
||||
async compact() {
|
||||
return { ok: true, compacted: false };
|
||||
},
|
||||
}));
|
||||
const calls = registerPromptTrackingEngine(engineId);
|
||||
|
||||
const engine = await resolveContextEngine(configWithSlot(engineId));
|
||||
// Simulate the attempt.ts call-site pattern: conditional spread
|
||||
|
||||
@@ -4,22 +4,48 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import { runStartupMatrixMigration } from "./server-startup-matrix-migration.js";
|
||||
|
||||
async function seedLegacyMatrixState(home: string) {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
|
||||
}
|
||||
|
||||
function makeMatrixStartupConfig(includeCredentials = true) {
|
||||
return {
|
||||
channels: {
|
||||
matrix: includeCredentials
|
||||
? {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
}
|
||||
: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function createSuccessfulMatrixMigrationDeps() {
|
||||
return {
|
||||
maybeCreateMatrixMigrationSnapshot: vi.fn(async () => ({
|
||||
created: true,
|
||||
archivePath: "/tmp/snapshot.tar.gz",
|
||||
markerPath: "/tmp/migration-snapshot.json",
|
||||
})),
|
||||
autoMigrateLegacyMatrixState: vi.fn(async () => ({
|
||||
migrated: true,
|
||||
changes: [],
|
||||
warnings: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runStartupMatrixMigration", () => {
|
||||
it("creates a snapshot before actionable startup migration", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
|
||||
const maybeCreateMatrixMigrationSnapshotMock = vi.fn(async () => ({
|
||||
created: true,
|
||||
archivePath: "/tmp/snapshot.tar.gz",
|
||||
markerPath: "/tmp/migration-snapshot.json",
|
||||
}));
|
||||
const autoMigrateLegacyMatrixStateMock = vi.fn(async () => ({
|
||||
migrated: true,
|
||||
changes: [],
|
||||
warnings: [],
|
||||
}));
|
||||
await seedLegacyMatrixState(home);
|
||||
const deps = createSuccessfulMatrixMigrationDeps();
|
||||
const autoPrepareLegacyMatrixCryptoMock = vi.fn(async () => ({
|
||||
migrated: false,
|
||||
changes: [],
|
||||
@@ -27,50 +53,34 @@ describe("runStartupMatrixMigration", () => {
|
||||
}));
|
||||
|
||||
await runStartupMatrixMigration({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: makeMatrixStartupConfig(),
|
||||
env: process.env,
|
||||
deps: {
|
||||
maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock,
|
||||
autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock,
|
||||
maybeCreateMatrixMigrationSnapshot: deps.maybeCreateMatrixMigrationSnapshot,
|
||||
autoMigrateLegacyMatrixState: deps.autoMigrateLegacyMatrixState,
|
||||
autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock,
|
||||
},
|
||||
log: {},
|
||||
});
|
||||
|
||||
expect(maybeCreateMatrixMigrationSnapshotMock).toHaveBeenCalledWith(
|
||||
expect(deps.maybeCreateMatrixMigrationSnapshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ trigger: "gateway-startup" }),
|
||||
);
|
||||
expect(autoMigrateLegacyMatrixStateMock).toHaveBeenCalledOnce();
|
||||
expect(deps.autoMigrateLegacyMatrixState).toHaveBeenCalledOnce();
|
||||
expect(autoPrepareLegacyMatrixCryptoMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips snapshot creation when startup only has warning-only migration state", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
|
||||
await seedLegacyMatrixState(home);
|
||||
const maybeCreateMatrixMigrationSnapshotMock = vi.fn();
|
||||
const autoMigrateLegacyMatrixStateMock = vi.fn();
|
||||
const autoPrepareLegacyMatrixCryptoMock = vi.fn();
|
||||
const info = vi.fn();
|
||||
|
||||
await runStartupMatrixMigration({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: makeMatrixStartupConfig(false),
|
||||
env: process.env,
|
||||
deps: {
|
||||
maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock as never,
|
||||
@@ -91,9 +101,7 @@ describe("runStartupMatrixMigration", () => {
|
||||
|
||||
it("skips startup migration when snapshot creation fails", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
|
||||
await seedLegacyMatrixState(home);
|
||||
const maybeCreateMatrixMigrationSnapshotMock = vi.fn(async () => {
|
||||
throw new Error("backup failed");
|
||||
});
|
||||
@@ -102,15 +110,7 @@ describe("runStartupMatrixMigration", () => {
|
||||
const warn = vi.fn();
|
||||
|
||||
await runStartupMatrixMigration({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: makeMatrixStartupConfig(),
|
||||
env: process.env,
|
||||
deps: {
|
||||
maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock,
|
||||
@@ -130,19 +130,8 @@ describe("runStartupMatrixMigration", () => {
|
||||
|
||||
it("downgrades migration step failures to warnings so startup can continue", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
|
||||
const maybeCreateMatrixMigrationSnapshotMock = vi.fn(async () => ({
|
||||
created: true,
|
||||
archivePath: "/tmp/snapshot.tar.gz",
|
||||
markerPath: "/tmp/migration-snapshot.json",
|
||||
}));
|
||||
const autoMigrateLegacyMatrixStateMock = vi.fn(async () => ({
|
||||
migrated: true,
|
||||
changes: [],
|
||||
warnings: [],
|
||||
}));
|
||||
await seedLegacyMatrixState(home);
|
||||
const deps = createSuccessfulMatrixMigrationDeps();
|
||||
const autoPrepareLegacyMatrixCryptoMock = vi.fn(async () => {
|
||||
throw new Error("disk full");
|
||||
});
|
||||
@@ -150,27 +139,19 @@ describe("runStartupMatrixMigration", () => {
|
||||
|
||||
await expect(
|
||||
runStartupMatrixMigration({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
cfg: makeMatrixStartupConfig(),
|
||||
env: process.env,
|
||||
deps: {
|
||||
maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock,
|
||||
autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock,
|
||||
maybeCreateMatrixMigrationSnapshot: deps.maybeCreateMatrixMigrationSnapshot,
|
||||
autoMigrateLegacyMatrixState: deps.autoMigrateLegacyMatrixState,
|
||||
autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock,
|
||||
},
|
||||
log: { warn },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(maybeCreateMatrixMigrationSnapshotMock).toHaveBeenCalledOnce();
|
||||
expect(autoMigrateLegacyMatrixStateMock).toHaveBeenCalledOnce();
|
||||
expect(deps.maybeCreateMatrixMigrationSnapshot).toHaveBeenCalledOnce();
|
||||
expect(deps.autoMigrateLegacyMatrixState).toHaveBeenCalledOnce();
|
||||
expect(autoPrepareLegacyMatrixCryptoMock).toHaveBeenCalledOnce();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"gateway: legacy Matrix encrypted-state preparation failed during Matrix migration; continuing startup: Error: disk full",
|
||||
|
||||
@@ -120,6 +120,40 @@ describe("resolveAllowAlwaysPatterns", () => {
|
||||
expect(second.allowlistSatisfied).toBe(true);
|
||||
}
|
||||
|
||||
function expectPositionalArgvCarrierResult(params: {
|
||||
command: string;
|
||||
expectPersisted: boolean;
|
||||
}) {
|
||||
const dir = makeTempDir();
|
||||
const touch = makeExecutable(dir, "touch");
|
||||
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
const marker = path.join(dir, "marker");
|
||||
const command = params.command.replaceAll("{marker}", marker);
|
||||
|
||||
const { persisted } = resolvePersistedPatterns({
|
||||
command,
|
||||
dir,
|
||||
env,
|
||||
safeBins,
|
||||
});
|
||||
if (params.expectPersisted) {
|
||||
expect(persisted).toEqual([touch]);
|
||||
} else {
|
||||
expect(persisted).not.toContain(touch);
|
||||
}
|
||||
|
||||
const second = evaluateShellAllowlist({
|
||||
command,
|
||||
allowlist: [{ pattern: touch }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(second.allowlistSatisfied).toBe(params.expectPersisted);
|
||||
}
|
||||
|
||||
it("returns direct executable paths for non-shell segments", () => {
|
||||
const exe = path.join("/tmp", "openclaw-tool");
|
||||
const patterns = resolveAllowAlwaysPatterns({
|
||||
@@ -268,88 +302,31 @@ describe("resolveAllowAlwaysPatterns", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const touch = makeExecutable(dir, "touch");
|
||||
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
|
||||
const { persisted } = resolvePersistedPatterns({
|
||||
command: `sh -lc 'exec -- "$0" "$1"' touch ${path.join(dir, "marker")}`,
|
||||
dir,
|
||||
env,
|
||||
safeBins,
|
||||
expectPositionalArgvCarrierResult({
|
||||
command: `sh -lc 'exec -- "$0" "$1"' touch {marker}`,
|
||||
expectPersisted: true,
|
||||
});
|
||||
expect(persisted).toEqual([touch]);
|
||||
|
||||
const second = evaluateShellAllowlist({
|
||||
command: `sh -lc 'exec -- "$0" "$1"' touch ${path.join(dir, "second-marker")}`,
|
||||
allowlist: [{ pattern: touch }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(second.allowlistSatisfied).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects positional argv carriers when $0 is single-quoted", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const touch = makeExecutable(dir, "touch");
|
||||
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
const marker = path.join(dir, "marker");
|
||||
|
||||
const { persisted } = resolvePersistedPatterns({
|
||||
command: `sh -lc "'$0' "$1"" touch ${marker}`,
|
||||
dir,
|
||||
env,
|
||||
safeBins,
|
||||
expectPositionalArgvCarrierResult({
|
||||
command: `sh -lc "'$0' "$1"" touch {marker}`,
|
||||
expectPersisted: false,
|
||||
});
|
||||
expect(persisted).not.toContain(touch);
|
||||
|
||||
const second = evaluateShellAllowlist({
|
||||
command: `sh -lc "'$0' "$1"" touch ${marker}`,
|
||||
allowlist: [{ pattern: touch }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(second.allowlistSatisfied).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects positional argv carriers when exec is separated from $0 by a newline", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const touch = makeExecutable(dir, "touch");
|
||||
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
const marker = path.join(dir, "marker");
|
||||
|
||||
const { persisted } = resolvePersistedPatterns({
|
||||
expectPositionalArgvCarrierResult({
|
||||
command: `sh -lc "exec
|
||||
$0 \\"$1\\"" touch ${marker}`,
|
||||
dir,
|
||||
env,
|
||||
safeBins,
|
||||
$0 \\"$1\\"" touch {marker}`,
|
||||
expectPersisted: false,
|
||||
});
|
||||
expect(persisted).not.toContain(touch);
|
||||
|
||||
const second = evaluateShellAllowlist({
|
||||
command: `sh -lc "exec
|
||||
$0 \\"$1\\"" touch ${marker}`,
|
||||
allowlist: [{ pattern: touch }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(second.allowlistSatisfied).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects positional argv carriers when inline command contains extra shell operations", () => {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { runMessageAction } from "./message-action-runner.js";
|
||||
|
||||
type ChannelActionHandler = NonNullable<NonNullable<ChannelPlugin["actions"]>["handleAction"]>;
|
||||
|
||||
function createAlwaysConfiguredPluginConfig(account: Record<string, unknown> = { enabled: true }) {
|
||||
return {
|
||||
listAccountIds: () => ["default"],
|
||||
@@ -15,6 +17,36 @@ function createAlwaysConfiguredPluginConfig(account: Record<string, unknown> = {
|
||||
};
|
||||
}
|
||||
|
||||
function createPollForwardingPlugin(params: {
|
||||
pluginId: string;
|
||||
label: string;
|
||||
blurb: string;
|
||||
handleAction: ChannelActionHandler;
|
||||
}): ChannelPlugin {
|
||||
return {
|
||||
id: params.pluginId,
|
||||
meta: {
|
||||
id: params.pluginId,
|
||||
label: params.label,
|
||||
selectionLabel: params.label,
|
||||
docsPath: `/channels/${params.pluginId}`,
|
||||
blurb: params.blurb,
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: createAlwaysConfiguredPluginConfig(),
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
looksLikeId: () => true,
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
describeMessageTool: () => ({ actions: ["poll"] }),
|
||||
supportsAction: ({ action }) => action === "poll",
|
||||
handleAction: params.handleAction,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("runMessageAction plugin dispatch", () => {
|
||||
describe("alias-based plugin action dispatch", () => {
|
||||
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
|
||||
@@ -382,28 +414,12 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const telegramPollPlugin: ChannelPlugin = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram poll forwarding test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: createAlwaysConfiguredPluginConfig(),
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
looksLikeId: () => true,
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
describeMessageTool: () => ({ actions: ["poll"] }),
|
||||
supportsAction: ({ action }) => action === "poll",
|
||||
handleAction,
|
||||
},
|
||||
};
|
||||
const telegramPollPlugin = createPollForwardingPlugin({
|
||||
pluginId: "telegram",
|
||||
label: "Telegram",
|
||||
blurb: "Telegram poll forwarding test plugin.",
|
||||
handleAction,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(
|
||||
@@ -489,28 +505,12 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const discordPollPlugin: ChannelPlugin = {
|
||||
id: "discord",
|
||||
meta: {
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
selectionLabel: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "Discord plugin-owned poll test plugin.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: createAlwaysConfiguredPluginConfig(),
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
looksLikeId: () => true,
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
describeMessageTool: () => ({ actions: ["poll"] }),
|
||||
supportsAction: ({ action }) => action === "poll",
|
||||
handleAction,
|
||||
},
|
||||
};
|
||||
const discordPollPlugin = createPollForwardingPlugin({
|
||||
pluginId: "discord",
|
||||
label: "Discord",
|
||||
blurb: "Discord plugin-owned poll test plugin.",
|
||||
handleAction,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(
|
||||
|
||||
@@ -126,6 +126,31 @@ function loadSingleCandidateRegistry(params: {
|
||||
]);
|
||||
}
|
||||
|
||||
function loadRegistryForMinHostVersionCase(params: {
|
||||
rootDir: string;
|
||||
minHostVersion: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}) {
|
||||
return loadPluginManifestRegistry({
|
||||
cache: false,
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
candidates: [
|
||||
createPluginCandidate({
|
||||
idHint: "synology-chat",
|
||||
rootDir: params.rootDir,
|
||||
packageDir: params.rootDir,
|
||||
origin: "global",
|
||||
packageManifest: {
|
||||
install: {
|
||||
npmSpec: "@openclaw/synology-chat",
|
||||
minHostVersion: params.minHostVersion,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function hasUnsafeManifestDiagnostic(registry: ReturnType<typeof loadPluginManifestRegistry>) {
|
||||
return registry.diagnostics.some((diag) => diag.message.includes("unsafe plugin manifest path"));
|
||||
}
|
||||
@@ -263,23 +288,10 @@ describe("loadPluginManifestRegistry", () => {
|
||||
const dir = makeTempDir();
|
||||
writeManifest(dir, { id: "synology-chat", configSchema: { type: "object" } });
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
cache: false,
|
||||
const registry = loadRegistryForMinHostVersionCase({
|
||||
rootDir: dir,
|
||||
minHostVersion: ">=2026.3.22",
|
||||
env: { OPENCLAW_VERSION: "2026.3.21" },
|
||||
candidates: [
|
||||
createPluginCandidate({
|
||||
idHint: "synology-chat",
|
||||
rootDir: dir,
|
||||
packageDir: dir,
|
||||
origin: "global",
|
||||
packageManifest: {
|
||||
install: {
|
||||
npmSpec: "@openclaw/synology-chat",
|
||||
minHostVersion: ">=2026.3.22",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(registry.plugins).toEqual([]);
|
||||
@@ -294,22 +306,9 @@ describe("loadPluginManifestRegistry", () => {
|
||||
const dir = makeTempDir();
|
||||
writeManifest(dir, { id: "synology-chat", configSchema: { type: "object" } });
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
cache: false,
|
||||
candidates: [
|
||||
createPluginCandidate({
|
||||
idHint: "synology-chat",
|
||||
rootDir: dir,
|
||||
packageDir: dir,
|
||||
origin: "global",
|
||||
packageManifest: {
|
||||
install: {
|
||||
npmSpec: "@openclaw/synology-chat",
|
||||
minHostVersion: "2026.3.22",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
const registry = loadRegistryForMinHostVersionCase({
|
||||
rootDir: dir,
|
||||
minHostVersion: "2026.3.22",
|
||||
});
|
||||
|
||||
expect(registry.plugins).toEqual([]);
|
||||
@@ -324,23 +323,10 @@ describe("loadPluginManifestRegistry", () => {
|
||||
const dir = makeTempDir();
|
||||
writeManifest(dir, { id: "synology-chat", configSchema: { type: "object" } });
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
cache: false,
|
||||
const registry = loadRegistryForMinHostVersionCase({
|
||||
rootDir: dir,
|
||||
minHostVersion: ">=2026.3.22",
|
||||
env: { OPENCLAW_VERSION: "unknown" },
|
||||
candidates: [
|
||||
createPluginCandidate({
|
||||
idHint: "synology-chat",
|
||||
rootDir: dir,
|
||||
packageDir: dir,
|
||||
origin: "global",
|
||||
packageManifest: {
|
||||
install: {
|
||||
npmSpec: "@openclaw/synology-chat",
|
||||
minHostVersion: ">=2026.3.22",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(registry.plugins).toEqual([]);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
resolvePluginSdkAliasFile,
|
||||
shouldPreferNativeJiti,
|
||||
} from "./sdk-alias.js";
|
||||
import { makeTrackedTempDir, mkdirSafeDir } from "./test-helpers/fs-fixtures.js";
|
||||
|
||||
type CreateJiti = typeof import("jiti").createJiti;
|
||||
|
||||
@@ -24,30 +24,13 @@ async function getCreateJiti() {
|
||||
return createJitiPromise;
|
||||
}
|
||||
|
||||
function chmodSafeDir(dir: string) {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
fs.chmodSync(dir, 0o755);
|
||||
}
|
||||
|
||||
function mkdtempSafe(prefix: string) {
|
||||
const dir = fs.mkdtempSync(prefix);
|
||||
chmodSafeDir(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function mkdirSafe(dir: string) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
chmodSafeDir(dir);
|
||||
}
|
||||
|
||||
const fixtureRoot = mkdtempSafe(path.join(os.tmpdir(), "openclaw-sdk-alias-"));
|
||||
const fixtureTempDirs: string[] = [];
|
||||
const fixtureRoot = makeTrackedTempDir("openclaw-sdk-alias-root", fixtureTempDirs);
|
||||
let tempDirIndex = 0;
|
||||
|
||||
function makeTempDir() {
|
||||
const dir = path.join(fixtureRoot, `case-${tempDirIndex++}`);
|
||||
mkdirSafe(dir);
|
||||
mkdirSafeDir(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -73,8 +56,8 @@ function createPluginSdkAliasFixture(params?: {
|
||||
const root = makeTempDir();
|
||||
const srcFile = path.join(root, "src", "plugin-sdk", params?.srcFile ?? "index.ts");
|
||||
const distFile = path.join(root, "dist", "plugin-sdk", params?.distFile ?? "index.js");
|
||||
mkdirSafe(path.dirname(srcFile));
|
||||
mkdirSafe(path.dirname(distFile));
|
||||
mkdirSafeDir(path.dirname(srcFile));
|
||||
mkdirSafeDir(path.dirname(distFile));
|
||||
const trustedRootIndicatorMode =
|
||||
params?.trustedRootIndicatorMode ??
|
||||
(params?.trustedRootIndicators === false ? "none" : "bin+marker");
|
||||
@@ -111,8 +94,8 @@ function createExtensionApiAliasFixture(params?: { srcBody?: string; distBody?:
|
||||
const root = makeTempDir();
|
||||
const srcFile = path.join(root, "src", "extensionAPI.ts");
|
||||
const distFile = path.join(root, "dist", "extensionAPI.js");
|
||||
mkdirSafe(path.dirname(srcFile));
|
||||
mkdirSafe(path.dirname(distFile));
|
||||
mkdirSafeDir(path.dirname(srcFile));
|
||||
mkdirSafeDir(path.dirname(distFile));
|
||||
fs.writeFileSync(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
|
||||
@@ -128,8 +111,8 @@ function createPluginRuntimeAliasFixture(params?: { srcBody?: string; distBody?:
|
||||
const root = makeTempDir();
|
||||
const srcFile = path.join(root, "src", "plugins", "runtime", "index.ts");
|
||||
const distFile = path.join(root, "dist", "plugins", "runtime", "index.js");
|
||||
mkdirSafe(path.dirname(srcFile));
|
||||
mkdirSafe(path.dirname(distFile));
|
||||
mkdirSafeDir(path.dirname(srcFile));
|
||||
mkdirSafeDir(path.dirname(distFile));
|
||||
fs.writeFileSync(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
|
||||
@@ -148,6 +131,23 @@ function createPluginRuntimeAliasFixture(params?: { srcBody?: string; distBody?:
|
||||
return { root, srcFile, distFile };
|
||||
}
|
||||
|
||||
function createUserInstalledPluginSdkAliasFixture() {
|
||||
const fixture = createPluginSdkAliasFixture({
|
||||
srcFile: "channel-runtime.ts",
|
||||
distFile: "channel-runtime.js",
|
||||
packageExports: {
|
||||
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
|
||||
},
|
||||
});
|
||||
const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs");
|
||||
fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8");
|
||||
const externalPluginRoot = path.join(makeTempDir(), ".openclaw", "extensions", "demo");
|
||||
const externalPluginEntry = path.join(externalPluginRoot, "index.ts");
|
||||
mkdirSafeDir(externalPluginRoot);
|
||||
fs.writeFileSync(externalPluginEntry, 'export const plugin = "demo";\n', "utf-8");
|
||||
return { externalPluginEntry, externalPluginRoot, fixture, sourceRootAlias };
|
||||
}
|
||||
|
||||
function resolvePluginSdkAlias(params: {
|
||||
srcFile: string;
|
||||
distFile: string;
|
||||
@@ -470,19 +470,8 @@ describe("plugin sdk alias helpers", () => {
|
||||
});
|
||||
|
||||
it("resolves plugin-sdk aliases for user-installed plugins via the running openclaw argv hint", () => {
|
||||
const fixture = createPluginSdkAliasFixture({
|
||||
srcFile: "channel-runtime.ts",
|
||||
distFile: "channel-runtime.js",
|
||||
packageExports: {
|
||||
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
|
||||
},
|
||||
});
|
||||
const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs");
|
||||
fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8");
|
||||
const externalPluginRoot = path.join(makeTempDir(), ".openclaw", "extensions", "demo");
|
||||
const externalPluginEntry = path.join(externalPluginRoot, "index.ts");
|
||||
mkdirSafe(externalPluginRoot);
|
||||
fs.writeFileSync(externalPluginEntry, 'export const plugin = "demo";\n', "utf-8");
|
||||
const { externalPluginEntry, externalPluginRoot, fixture, sourceRootAlias } =
|
||||
createUserInstalledPluginSdkAliasFixture();
|
||||
|
||||
const aliases = withCwd(externalPluginRoot, () =>
|
||||
withEnv({ NODE_ENV: undefined }, () =>
|
||||
@@ -499,19 +488,8 @@ describe("plugin sdk alias helpers", () => {
|
||||
});
|
||||
|
||||
it("resolves plugin-sdk aliases for user-installed plugins via moduleUrl hint", () => {
|
||||
const fixture = createPluginSdkAliasFixture({
|
||||
srcFile: "channel-runtime.ts",
|
||||
distFile: "channel-runtime.js",
|
||||
packageExports: {
|
||||
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
|
||||
},
|
||||
});
|
||||
const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs");
|
||||
fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8");
|
||||
const externalPluginRoot = path.join(makeTempDir(), ".openclaw", "extensions", "demo");
|
||||
const externalPluginEntry = path.join(externalPluginRoot, "index.ts");
|
||||
mkdirSafe(externalPluginRoot);
|
||||
fs.writeFileSync(externalPluginEntry, 'export const plugin = "demo";\n', "utf-8");
|
||||
const { externalPluginEntry, externalPluginRoot, fixture, sourceRootAlias } =
|
||||
createUserInstalledPluginSdkAliasFixture();
|
||||
|
||||
// Simulate loader.ts passing its own import.meta.url as the moduleUrl hint.
|
||||
// This covers installations where argv1 does not resolve to the openclaw root
|
||||
@@ -585,8 +563,8 @@ describe("plugin sdk alias helpers", () => {
|
||||
const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "discord");
|
||||
const copiedSourceDir = path.join(copiedExtensionRoot, "src");
|
||||
const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk");
|
||||
mkdirSafe(copiedSourceDir);
|
||||
mkdirSafe(copiedPluginSdkDir);
|
||||
mkdirSafeDir(copiedSourceDir);
|
||||
mkdirSafeDir(copiedPluginSdkDir);
|
||||
const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs");
|
||||
fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8");
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -96,6 +96,10 @@ const formatPluginCompatibilityNotice = vi.hoisted(() =>
|
||||
vi.fn((notice: PluginCompatibilityNotice) => `${notice.pluginId} ${notice.message}`),
|
||||
);
|
||||
|
||||
function getWizardNoteCalls(note: WizardPrompter["note"]) {
|
||||
return (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
|
||||
}
|
||||
|
||||
vi.mock("../commands/onboard-channels.js", () => ({
|
||||
setupChannels,
|
||||
}));
|
||||
@@ -398,7 +402,7 @@ describe("runSetupWizard", () => {
|
||||
prompter,
|
||||
);
|
||||
|
||||
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
|
||||
const calls = getWizardNoteCalls(note);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
expect(calls.some((call) => call?.[1] === "Web search")).toBe(true);
|
||||
} finally {
|
||||
@@ -488,7 +492,7 @@ describe("runSetupWizard", () => {
|
||||
prompter,
|
||||
);
|
||||
|
||||
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
|
||||
const calls = getWizardNoteCalls(note);
|
||||
expect(calls.some((call) => call?.[1] === "Plugin compatibility")).toBe(true);
|
||||
expect(
|
||||
calls.some((call) => {
|
||||
|
||||
Reference in New Issue
Block a user