mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 01:03:29 +00:00
test(integration): dedupe messaging, secrets, and plugin test suites
This commit is contained in:
@@ -78,6 +78,21 @@ describe("pw-tools-core", () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectAtomicDownloadSave(params: {
|
||||||
|
saveAs: ReturnType<typeof vi.fn>;
|
||||||
|
targetPath: string;
|
||||||
|
tempDir: string;
|
||||||
|
content: string;
|
||||||
|
}) {
|
||||||
|
const savedPath = params.saveAs.mock.calls[0]?.[0];
|
||||||
|
expect(typeof savedPath).toBe("string");
|
||||||
|
expect(savedPath).not.toBe(params.targetPath);
|
||||||
|
expect(path.dirname(String(savedPath))).toBe(params.tempDir);
|
||||||
|
expect(path.basename(String(savedPath))).toContain(".openclaw-output-");
|
||||||
|
expect(path.basename(String(savedPath))).toContain(".part");
|
||||||
|
expect(await fs.readFile(params.targetPath, "utf8")).toBe(params.content);
|
||||||
|
}
|
||||||
|
|
||||||
it("waits for the next download and atomically finalizes explicit output paths", async () => {
|
it("waits for the next download and atomically finalizes explicit output paths", async () => {
|
||||||
await withTempDir(async (tempDir) => {
|
await withTempDir(async (tempDir) => {
|
||||||
const harness = createDownloadEventHarness();
|
const harness = createDownloadEventHarness();
|
||||||
@@ -104,13 +119,7 @@ describe("pw-tools-core", () => {
|
|||||||
harness.trigger(download);
|
harness.trigger(download);
|
||||||
|
|
||||||
const res = await p;
|
const res = await p;
|
||||||
const savedPath = saveAs.mock.calls[0]?.[0];
|
await expectAtomicDownloadSave({ saveAs, targetPath, tempDir, content: "file-content" });
|
||||||
expect(typeof savedPath).toBe("string");
|
|
||||||
expect(savedPath).not.toBe(targetPath);
|
|
||||||
expect(path.dirname(String(savedPath))).toBe(tempDir);
|
|
||||||
expect(path.basename(String(savedPath))).toContain(".openclaw-output-");
|
|
||||||
expect(path.basename(String(savedPath))).toContain(".part");
|
|
||||||
expect(await fs.readFile(targetPath, "utf8")).toBe("file-content");
|
|
||||||
expect(res.path).toBe(targetPath);
|
expect(res.path).toBe(targetPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -146,13 +155,7 @@ describe("pw-tools-core", () => {
|
|||||||
harness.trigger(download);
|
harness.trigger(download);
|
||||||
|
|
||||||
const res = await p;
|
const res = await p;
|
||||||
const savedPath = saveAs.mock.calls[0]?.[0];
|
await expectAtomicDownloadSave({ saveAs, targetPath, tempDir, content: "report-content" });
|
||||||
expect(typeof savedPath).toBe("string");
|
|
||||||
expect(savedPath).not.toBe(targetPath);
|
|
||||||
expect(path.dirname(String(savedPath))).toBe(tempDir);
|
|
||||||
expect(path.basename(String(savedPath))).toContain(".openclaw-output-");
|
|
||||||
expect(path.basename(String(savedPath))).toContain(".part");
|
|
||||||
expect(await fs.readFile(targetPath, "utf8")).toBe("report-content");
|
|
||||||
expect(res.path).toBe(targetPath);
|
expect(res.path).toBe(targetPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createServer, type AddressInfo } from "node:net";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { getFreePort } from "./test-port.js";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
controlPort: 0,
|
controlPort: 0,
|
||||||
@@ -12,12 +12,13 @@ const mocks = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("../config/config.js", async (importOriginal) => {
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
|
const browserConfig = {
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
loadConfig: () => ({
|
loadConfig: () => ({
|
||||||
browser: {
|
browser: browserConfig,
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -58,17 +59,6 @@ vi.mock("./pw-ai-state.js", () => ({
|
|||||||
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
|
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
|
||||||
await import("./server.js");
|
await import("./server.js");
|
||||||
|
|
||||||
async function getFreePort(): Promise<number> {
|
|
||||||
const probe = createServer();
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
probe.once("error", reject);
|
|
||||||
probe.listen(0, "127.0.0.1", () => resolve());
|
|
||||||
});
|
|
||||||
const addr = probe.address() as AddressInfo;
|
|
||||||
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
|
||||||
return addr.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("browser control auth bootstrap failures", () => {
|
describe("browser control auth bootstrap failures", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mocks.controlPort = await getFreePort();
|
mocks.controlPort = await getFreePort();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createServer, type AddressInfo } from "node:net";
|
|
||||||
import { fetch as realFetch } from "undici";
|
import { fetch as realFetch } from "undici";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { getFreePort } from "./test-port.js";
|
||||||
|
|
||||||
let testPort = 0;
|
let testPort = 0;
|
||||||
let prevGatewayPort: string | undefined;
|
let prevGatewayPort: string | undefined;
|
||||||
@@ -68,17 +68,6 @@ vi.mock("./server-context.js", async (importOriginal) => {
|
|||||||
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
|
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
|
||||||
await import("./server.js");
|
await import("./server.js");
|
||||||
|
|
||||||
async function getFreePort(): Promise<number> {
|
|
||||||
const probe = createServer();
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
probe.once("error", reject);
|
|
||||||
probe.listen(0, "127.0.0.1", () => resolve());
|
|
||||||
});
|
|
||||||
const addr = probe.address() as AddressInfo;
|
|
||||||
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
|
||||||
return addr.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("browser control evaluate gating", () => {
|
describe("browser control evaluate gating", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
testPort = await getFreePort();
|
testPort = await getFreePort();
|
||||||
|
|||||||
@@ -127,6 +127,17 @@ describe("memory index", () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireManager(
|
||||||
|
result: Awaited<ReturnType<typeof getMemorySearchManager>>,
|
||||||
|
missingMessage = "manager missing",
|
||||||
|
): MemoryIndexManager {
|
||||||
|
expect(result.manager).not.toBeNull();
|
||||||
|
if (!result.manager) {
|
||||||
|
throw new Error(missingMessage);
|
||||||
|
}
|
||||||
|
return result.manager as MemoryIndexManager;
|
||||||
|
}
|
||||||
|
|
||||||
async function getPersistentManager(cfg: TestCfg): Promise<MemoryIndexManager> {
|
async function getPersistentManager(cfg: TestCfg): Promise<MemoryIndexManager> {
|
||||||
const storePath = cfg.agents?.defaults?.memorySearch?.store?.path;
|
const storePath = cfg.agents?.defaults?.memorySearch?.store?.path;
|
||||||
if (!storePath) {
|
if (!storePath) {
|
||||||
@@ -139,17 +150,26 @@ describe("memory index", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||||
expect(result.manager).not.toBeNull();
|
const manager = requireManager(result);
|
||||||
if (!result.manager) {
|
|
||||||
throw new Error("manager missing");
|
|
||||||
}
|
|
||||||
const manager = result.manager as MemoryIndexManager;
|
|
||||||
managersByStorePath.set(storePath, manager);
|
managersByStorePath.set(storePath, manager);
|
||||||
managersForCleanup.add(manager);
|
managersForCleanup.add(manager);
|
||||||
resetManagerForTest(manager);
|
resetManagerForTest(manager);
|
||||||
return manager;
|
return manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectHybridKeywordSearchFindsMemory(cfg: TestCfg) {
|
||||||
|
const manager = await getPersistentManager(cfg);
|
||||||
|
const status = manager.status();
|
||||||
|
if (!status.fts?.available) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.sync({ reason: "test" });
|
||||||
|
const results = await manager.search("zebra");
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
expect(results[0]?.path).toContain("memory/2026-01-12.md");
|
||||||
|
}
|
||||||
|
|
||||||
it("indexes memory files and searches", async () => {
|
it("indexes memory files and searches", async () => {
|
||||||
const cfg = createCfg({
|
const cfg = createCfg({
|
||||||
storePath: indexMainPath,
|
storePath: indexMainPath,
|
||||||
@@ -178,26 +198,19 @@ describe("memory index", () => {
|
|||||||
const cfg = createCfg({ storePath: indexStatusPath });
|
const cfg = createCfg({ storePath: indexStatusPath });
|
||||||
|
|
||||||
const first = await getMemorySearchManager({ cfg, agentId: "main" });
|
const first = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||||
expect(first.manager).not.toBeNull();
|
const firstManager = requireManager(first);
|
||||||
if (!first.manager) {
|
await firstManager.sync?.({ reason: "test" });
|
||||||
throw new Error("manager missing");
|
await firstManager.close?.();
|
||||||
}
|
|
||||||
await first.manager.sync?.({ reason: "test" });
|
|
||||||
await first.manager.close?.();
|
|
||||||
|
|
||||||
const statusOnly = await getMemorySearchManager({
|
const statusOnly = await getMemorySearchManager({
|
||||||
cfg,
|
cfg,
|
||||||
agentId: "main",
|
agentId: "main",
|
||||||
purpose: "status",
|
purpose: "status",
|
||||||
});
|
});
|
||||||
expect(statusOnly.manager).not.toBeNull();
|
const statusManager = requireManager(statusOnly, "status manager missing");
|
||||||
if (!statusOnly.manager) {
|
const status = statusManager.status();
|
||||||
throw new Error("status manager missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = statusOnly.manager.status();
|
|
||||||
expect(status.dirty).toBe(false);
|
expect(status.dirty).toBe(false);
|
||||||
await statusOnly.manager.close?.();
|
await statusManager.close?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reindexes sessions when source config adds sessions to an existing index", async () => {
|
it("reindexes sessions when source config adds sessions to an existing index", async () => {
|
||||||
@@ -244,31 +257,25 @@ describe("memory index", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const first = await getMemorySearchManager({ cfg: firstCfg, agentId: "main" });
|
const first = await getMemorySearchManager({ cfg: firstCfg, agentId: "main" });
|
||||||
expect(first.manager).not.toBeNull();
|
const firstManager = requireManager(first);
|
||||||
if (!first.manager) {
|
await firstManager.sync?.({ reason: "test" });
|
||||||
throw new Error("manager missing");
|
const firstStatus = firstManager.status();
|
||||||
}
|
|
||||||
await first.manager.sync?.({ reason: "test" });
|
|
||||||
const firstStatus = first.manager.status();
|
|
||||||
expect(
|
expect(
|
||||||
firstStatus.sourceCounts?.find((entry) => entry.source === "sessions")?.files ?? 0,
|
firstStatus.sourceCounts?.find((entry) => entry.source === "sessions")?.files ?? 0,
|
||||||
).toBe(0);
|
).toBe(0);
|
||||||
await first.manager.close?.();
|
await firstManager.close?.();
|
||||||
|
|
||||||
const second = await getMemorySearchManager({ cfg: secondCfg, agentId: "main" });
|
const second = await getMemorySearchManager({ cfg: secondCfg, agentId: "main" });
|
||||||
expect(second.manager).not.toBeNull();
|
const secondManager = requireManager(second);
|
||||||
if (!second.manager) {
|
await secondManager.sync?.({ reason: "test" });
|
||||||
throw new Error("manager missing");
|
const secondStatus = secondManager.status();
|
||||||
}
|
|
||||||
await second.manager.sync?.({ reason: "test" });
|
|
||||||
const secondStatus = second.manager.status();
|
|
||||||
expect(secondStatus.sourceCounts?.find((entry) => entry.source === "sessions")?.files).toBe(
|
expect(secondStatus.sourceCounts?.find((entry) => entry.source === "sessions")?.files).toBe(
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
secondStatus.sourceCounts?.find((entry) => entry.source === "sessions")?.chunks ?? 0,
|
secondStatus.sourceCounts?.find((entry) => entry.source === "sessions")?.chunks ?? 0,
|
||||||
).toBeGreaterThan(0);
|
).toBeGreaterThan(0);
|
||||||
await second.manager.close?.();
|
await secondManager.close?.();
|
||||||
} finally {
|
} finally {
|
||||||
if (previousStateDir === undefined) {
|
if (previousStateDir === undefined) {
|
||||||
delete process.env.OPENCLAW_STATE_DIR;
|
delete process.env.OPENCLAW_STATE_DIR;
|
||||||
@@ -302,13 +309,10 @@ describe("memory index", () => {
|
|||||||
},
|
},
|
||||||
agentId: "main",
|
agentId: "main",
|
||||||
});
|
});
|
||||||
expect(first.manager).not.toBeNull();
|
const firstManager = requireManager(first);
|
||||||
if (!first.manager) {
|
await firstManager.sync?.({ reason: "test" });
|
||||||
throw new Error("manager missing");
|
|
||||||
}
|
|
||||||
await first.manager.sync?.({ reason: "test" });
|
|
||||||
const callsAfterFirstSync = embedBatchCalls;
|
const callsAfterFirstSync = embedBatchCalls;
|
||||||
await first.manager.close?.();
|
await firstManager.close?.();
|
||||||
|
|
||||||
const second = await getMemorySearchManager({
|
const second = await getMemorySearchManager({
|
||||||
cfg: {
|
cfg: {
|
||||||
@@ -326,15 +330,12 @@ describe("memory index", () => {
|
|||||||
},
|
},
|
||||||
agentId: "main",
|
agentId: "main",
|
||||||
});
|
});
|
||||||
expect(second.manager).not.toBeNull();
|
const secondManager = requireManager(second);
|
||||||
if (!second.manager) {
|
await secondManager.sync?.({ reason: "test" });
|
||||||
throw new Error("manager missing");
|
|
||||||
}
|
|
||||||
await second.manager.sync?.({ reason: "test" });
|
|
||||||
expect(embedBatchCalls).toBeGreaterThan(callsAfterFirstSync);
|
expect(embedBatchCalls).toBeGreaterThan(callsAfterFirstSync);
|
||||||
const status = second.manager.status();
|
const status = secondManager.status();
|
||||||
expect(status.files).toBeGreaterThan(0);
|
expect(status.files).toBeGreaterThan(0);
|
||||||
await second.manager.close?.();
|
await secondManager.close?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reuses cached embeddings on forced reindex", async () => {
|
it("reuses cached embeddings on forced reindex", async () => {
|
||||||
@@ -351,40 +352,22 @@ describe("memory index", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("finds keyword matches via hybrid search when query embedding is zero", async () => {
|
it("finds keyword matches via hybrid search when query embedding is zero", async () => {
|
||||||
const cfg = createCfg({
|
await expectHybridKeywordSearchFindsMemory(
|
||||||
storePath: indexMainPath,
|
createCfg({
|
||||||
hybrid: { enabled: true, vectorWeight: 0, textWeight: 1 },
|
storePath: indexMainPath,
|
||||||
});
|
hybrid: { enabled: true, vectorWeight: 0, textWeight: 1 },
|
||||||
const manager = await getPersistentManager(cfg);
|
}),
|
||||||
|
);
|
||||||
const status = manager.status();
|
|
||||||
if (!status.fts?.available) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await manager.sync({ reason: "test" });
|
|
||||||
const results = await manager.search("zebra");
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results[0]?.path).toContain("memory/2026-01-12.md");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves keyword-only hybrid hits when minScore exceeds text weight", async () => {
|
it("preserves keyword-only hybrid hits when minScore exceeds text weight", async () => {
|
||||||
const cfg = createCfg({
|
await expectHybridKeywordSearchFindsMemory(
|
||||||
storePath: indexMainPath,
|
createCfg({
|
||||||
minScore: 0.35,
|
storePath: indexMainPath,
|
||||||
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
|
minScore: 0.35,
|
||||||
});
|
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
|
||||||
const manager = await getPersistentManager(cfg);
|
}),
|
||||||
|
);
|
||||||
const status = manager.status();
|
|
||||||
if (!status.fts?.available) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await manager.sync({ reason: "test" });
|
|
||||||
const results = await manager.search("zebra");
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results[0]?.path).toContain("memory/2026-01-12.md");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports vector availability after probe", async () => {
|
it("reports vector availability after probe", async () => {
|
||||||
|
|||||||
@@ -13,6 +13,51 @@ describe("memory manager readonly recovery", () => {
|
|||||||
let indexPath = "";
|
let indexPath = "";
|
||||||
let manager: MemoryIndexManager | null = null;
|
let manager: MemoryIndexManager | null = null;
|
||||||
|
|
||||||
|
function createMemoryConfig(): OpenClawConfig {
|
||||||
|
return {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
workspace: workspaceDir,
|
||||||
|
memorySearch: {
|
||||||
|
provider: "openai",
|
||||||
|
model: "mock-embed",
|
||||||
|
store: { path: indexPath },
|
||||||
|
sync: { watch: false, onSessionStart: false, onSearch: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
list: [{ id: "main", default: true }],
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createManager() {
|
||||||
|
manager = await getRequiredMemoryIndexManager({ cfg: createMemoryConfig(), agentId: "main" });
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSyncSpies(instance: MemoryIndexManager) {
|
||||||
|
const runSyncSpy = vi.spyOn(
|
||||||
|
instance as unknown as {
|
||||||
|
runSync: (params?: { reason?: string; force?: boolean }) => Promise<void>;
|
||||||
|
},
|
||||||
|
"runSync",
|
||||||
|
);
|
||||||
|
const openDatabaseSpy = vi.spyOn(
|
||||||
|
instance as unknown as { openDatabase: () => DatabaseSync },
|
||||||
|
"openDatabase",
|
||||||
|
);
|
||||||
|
return { runSyncSpy, openDatabaseSpy };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectReadonlyRecoveryStatus(lastError: string) {
|
||||||
|
expect(manager?.status().custom?.readonlyRecovery).toEqual({
|
||||||
|
attempts: 1,
|
||||||
|
successes: 1,
|
||||||
|
failures: 0,
|
||||||
|
lastError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
resetEmbeddingMocks();
|
resetEmbeddingMocks();
|
||||||
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-readonly-"));
|
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-readonly-"));
|
||||||
@@ -30,124 +75,39 @@ describe("memory manager readonly recovery", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reopens sqlite and retries once when sync hits SQLITE_READONLY", async () => {
|
it("reopens sqlite and retries once when sync hits SQLITE_READONLY", async () => {
|
||||||
const cfg = {
|
const currentManager = await createManager();
|
||||||
agents: {
|
const { runSyncSpy, openDatabaseSpy } = createSyncSpies(currentManager);
|
||||||
defaults: {
|
|
||||||
workspace: workspaceDir,
|
|
||||||
memorySearch: {
|
|
||||||
provider: "openai",
|
|
||||||
model: "mock-embed",
|
|
||||||
store: { path: indexPath },
|
|
||||||
sync: { watch: false, onSessionStart: false, onSearch: false },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
list: [{ id: "main", default: true }],
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
|
|
||||||
|
|
||||||
const runSyncSpy = vi.spyOn(
|
|
||||||
manager as unknown as {
|
|
||||||
runSync: (params?: { reason?: string; force?: boolean }) => Promise<void>;
|
|
||||||
},
|
|
||||||
"runSync",
|
|
||||||
);
|
|
||||||
runSyncSpy
|
runSyncSpy
|
||||||
.mockRejectedValueOnce(new Error("attempt to write a readonly database"))
|
.mockRejectedValueOnce(new Error("attempt to write a readonly database"))
|
||||||
.mockResolvedValueOnce(undefined);
|
.mockResolvedValueOnce(undefined);
|
||||||
const openDatabaseSpy = vi.spyOn(
|
|
||||||
manager as unknown as { openDatabase: () => DatabaseSync },
|
|
||||||
"openDatabase",
|
|
||||||
);
|
|
||||||
|
|
||||||
await manager.sync({ reason: "test" });
|
await currentManager.sync({ reason: "test" });
|
||||||
|
|
||||||
expect(runSyncSpy).toHaveBeenCalledTimes(2);
|
expect(runSyncSpy).toHaveBeenCalledTimes(2);
|
||||||
expect(openDatabaseSpy).toHaveBeenCalledTimes(1);
|
expect(openDatabaseSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(manager.status().custom?.readonlyRecovery).toEqual({
|
expectReadonlyRecoveryStatus("attempt to write a readonly database");
|
||||||
attempts: 1,
|
|
||||||
successes: 1,
|
|
||||||
failures: 0,
|
|
||||||
lastError: "attempt to write a readonly database",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reopens sqlite and retries when readonly appears in error code", async () => {
|
it("reopens sqlite and retries when readonly appears in error code", async () => {
|
||||||
const cfg = {
|
const currentManager = await createManager();
|
||||||
agents: {
|
const { runSyncSpy, openDatabaseSpy } = createSyncSpies(currentManager);
|
||||||
defaults: {
|
|
||||||
workspace: workspaceDir,
|
|
||||||
memorySearch: {
|
|
||||||
provider: "openai",
|
|
||||||
model: "mock-embed",
|
|
||||||
store: { path: indexPath },
|
|
||||||
sync: { watch: false, onSessionStart: false, onSearch: false },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
list: [{ id: "main", default: true }],
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
|
|
||||||
|
|
||||||
const runSyncSpy = vi.spyOn(
|
|
||||||
manager as unknown as {
|
|
||||||
runSync: (params?: { reason?: string; force?: boolean }) => Promise<void>;
|
|
||||||
},
|
|
||||||
"runSync",
|
|
||||||
);
|
|
||||||
runSyncSpy
|
runSyncSpy
|
||||||
.mockRejectedValueOnce({ message: "write failed", code: "SQLITE_READONLY" })
|
.mockRejectedValueOnce({ message: "write failed", code: "SQLITE_READONLY" })
|
||||||
.mockResolvedValueOnce(undefined);
|
.mockResolvedValueOnce(undefined);
|
||||||
const openDatabaseSpy = vi.spyOn(
|
|
||||||
manager as unknown as { openDatabase: () => DatabaseSync },
|
|
||||||
"openDatabase",
|
|
||||||
);
|
|
||||||
|
|
||||||
await manager.sync({ reason: "test" });
|
await currentManager.sync({ reason: "test" });
|
||||||
|
|
||||||
expect(runSyncSpy).toHaveBeenCalledTimes(2);
|
expect(runSyncSpy).toHaveBeenCalledTimes(2);
|
||||||
expect(openDatabaseSpy).toHaveBeenCalledTimes(1);
|
expect(openDatabaseSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(manager.status().custom?.readonlyRecovery).toEqual({
|
expectReadonlyRecoveryStatus("write failed");
|
||||||
attempts: 1,
|
|
||||||
successes: 1,
|
|
||||||
failures: 0,
|
|
||||||
lastError: "write failed",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not retry non-readonly sync errors", async () => {
|
it("does not retry non-readonly sync errors", async () => {
|
||||||
const cfg = {
|
const currentManager = await createManager();
|
||||||
agents: {
|
const { runSyncSpy, openDatabaseSpy } = createSyncSpies(currentManager);
|
||||||
defaults: {
|
|
||||||
workspace: workspaceDir,
|
|
||||||
memorySearch: {
|
|
||||||
provider: "openai",
|
|
||||||
model: "mock-embed",
|
|
||||||
store: { path: indexPath },
|
|
||||||
sync: { watch: false, onSessionStart: false, onSearch: false },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
list: [{ id: "main", default: true }],
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
|
|
||||||
|
|
||||||
const runSyncSpy = vi.spyOn(
|
|
||||||
manager as unknown as {
|
|
||||||
runSync: (params?: { reason?: string; force?: boolean }) => Promise<void>;
|
|
||||||
},
|
|
||||||
"runSync",
|
|
||||||
);
|
|
||||||
runSyncSpy.mockRejectedValueOnce(new Error("embedding timeout"));
|
runSyncSpy.mockRejectedValueOnce(new Error("embedding timeout"));
|
||||||
const openDatabaseSpy = vi.spyOn(
|
|
||||||
manager as unknown as { openDatabase: () => DatabaseSync },
|
|
||||||
"openDatabase",
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(manager.sync({ reason: "test" })).rejects.toThrow("embedding timeout");
|
await expect(currentManager.sync({ reason: "test" })).rejects.toThrow("embedding timeout");
|
||||||
expect(runSyncSpy).toHaveBeenCalledTimes(1);
|
expect(runSyncSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(openDatabaseSpy).toHaveBeenCalledTimes(0);
|
expect(openDatabaseSpy).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,27 @@ async function withStateDir<T>(stateDir: string, fn: () => Promise<T>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writePluginPackageManifest(params: {
|
||||||
|
packageDir: string;
|
||||||
|
packageName: string;
|
||||||
|
extensions: string[];
|
||||||
|
}) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(params.packageDir, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: params.packageName,
|
||||||
|
openclaw: { extensions: params.extensions },
|
||||||
|
}),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectEscapesPackageDiagnostic(diagnostics: Array<{ message: string }>) {
|
||||||
|
expect(diagnostics.some((entry) => entry.message.includes("escapes package directory"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
try {
|
try {
|
||||||
@@ -95,14 +116,11 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
const globalExt = path.join(stateDir, "extensions", "pack");
|
const globalExt = path.join(stateDir, "extensions", "pack");
|
||||||
fs.mkdirSync(path.join(globalExt, "src"), { recursive: true });
|
fs.mkdirSync(path.join(globalExt, "src"), { recursive: true });
|
||||||
|
|
||||||
fs.writeFileSync(
|
writePluginPackageManifest({
|
||||||
path.join(globalExt, "package.json"),
|
packageDir: globalExt,
|
||||||
JSON.stringify({
|
packageName: "pack",
|
||||||
name: "pack",
|
extensions: ["./src/one.ts", "./src/two.ts"],
|
||||||
openclaw: { extensions: ["./src/one.ts", "./src/two.ts"] },
|
});
|
||||||
}),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(globalExt, "src", "one.ts"),
|
path.join(globalExt, "src", "one.ts"),
|
||||||
"export default function () {}",
|
"export default function () {}",
|
||||||
@@ -128,14 +146,11 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
const globalExt = path.join(stateDir, "extensions", "voice-call-pack");
|
const globalExt = path.join(stateDir, "extensions", "voice-call-pack");
|
||||||
fs.mkdirSync(path.join(globalExt, "src"), { recursive: true });
|
fs.mkdirSync(path.join(globalExt, "src"), { recursive: true });
|
||||||
|
|
||||||
fs.writeFileSync(
|
writePluginPackageManifest({
|
||||||
path.join(globalExt, "package.json"),
|
packageDir: globalExt,
|
||||||
JSON.stringify({
|
packageName: "@openclaw/voice-call",
|
||||||
name: "@openclaw/voice-call",
|
extensions: ["./src/index.ts"],
|
||||||
openclaw: { extensions: ["./src/index.ts"] },
|
});
|
||||||
}),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(globalExt, "src", "index.ts"),
|
path.join(globalExt, "src", "index.ts"),
|
||||||
"export default function () {}",
|
"export default function () {}",
|
||||||
@@ -155,14 +170,11 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
const packDir = path.join(stateDir, "packs", "demo-plugin-dir");
|
const packDir = path.join(stateDir, "packs", "demo-plugin-dir");
|
||||||
fs.mkdirSync(packDir, { recursive: true });
|
fs.mkdirSync(packDir, { recursive: true });
|
||||||
|
|
||||||
fs.writeFileSync(
|
writePluginPackageManifest({
|
||||||
path.join(packDir, "package.json"),
|
packageDir: packDir,
|
||||||
JSON.stringify({
|
packageName: "@openclaw/demo-plugin-dir",
|
||||||
name: "@openclaw/demo-plugin-dir",
|
extensions: ["./index.js"],
|
||||||
openclaw: { extensions: ["./index.js"] },
|
});
|
||||||
}),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
fs.writeFileSync(path.join(packDir, "index.js"), "module.exports = {}", "utf-8");
|
fs.writeFileSync(path.join(packDir, "index.js"), "module.exports = {}", "utf-8");
|
||||||
|
|
||||||
const { candidates } = await withStateDir(stateDir, async () => {
|
const { candidates } = await withStateDir(stateDir, async () => {
|
||||||
@@ -178,14 +190,11 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
const outside = path.join(stateDir, "outside.js");
|
const outside = path.join(stateDir, "outside.js");
|
||||||
fs.mkdirSync(globalExt, { recursive: true });
|
fs.mkdirSync(globalExt, { recursive: true });
|
||||||
|
|
||||||
fs.writeFileSync(
|
writePluginPackageManifest({
|
||||||
path.join(globalExt, "package.json"),
|
packageDir: globalExt,
|
||||||
JSON.stringify({
|
packageName: "@openclaw/escape-pack",
|
||||||
name: "@openclaw/escape-pack",
|
extensions: ["../../outside.js"],
|
||||||
openclaw: { extensions: ["../../outside.js"] },
|
});
|
||||||
}),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
fs.writeFileSync(outside, "export default function () {}", "utf-8");
|
fs.writeFileSync(outside, "export default function () {}", "utf-8");
|
||||||
|
|
||||||
const result = await withStateDir(stateDir, async () => {
|
const result = await withStateDir(stateDir, async () => {
|
||||||
@@ -193,9 +202,7 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.candidates).toHaveLength(0);
|
expect(result.candidates).toHaveLength(0);
|
||||||
expect(
|
expectEscapesPackageDiagnostic(result.diagnostics);
|
||||||
result.diagnostics.some((diag) => diag.message.includes("escapes package directory")),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects package extension entries that escape via symlink", async () => {
|
it("rejects package extension entries that escape via symlink", async () => {
|
||||||
@@ -212,23 +219,18 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(
|
writePluginPackageManifest({
|
||||||
path.join(globalExt, "package.json"),
|
packageDir: globalExt,
|
||||||
JSON.stringify({
|
packageName: "@openclaw/pack",
|
||||||
name: "@openclaw/pack",
|
extensions: ["./linked/escape.ts"],
|
||||||
openclaw: { extensions: ["./linked/escape.ts"] },
|
});
|
||||||
}),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const { candidates, diagnostics } = await withStateDir(stateDir, async () => {
|
const { candidates, diagnostics } = await withStateDir(stateDir, async () => {
|
||||||
return discoverOpenClawPlugins({});
|
return discoverOpenClawPlugins({});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false);
|
expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false);
|
||||||
expect(diagnostics.some((entry) => entry.message.includes("escapes package directory"))).toBe(
|
expectEscapesPackageDiagnostic(diagnostics);
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects package extension entries that are hardlinked aliases", async () => {
|
it("rejects package extension entries that are hardlinked aliases", async () => {
|
||||||
@@ -252,23 +254,18 @@ describe("discoverOpenClawPlugins", () => {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(
|
writePluginPackageManifest({
|
||||||
path.join(globalExt, "package.json"),
|
packageDir: globalExt,
|
||||||
JSON.stringify({
|
packageName: "@openclaw/pack",
|
||||||
name: "@openclaw/pack",
|
extensions: ["./escape.ts"],
|
||||||
openclaw: { extensions: ["./escape.ts"] },
|
});
|
||||||
}),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const { candidates, diagnostics } = await withStateDir(stateDir, async () => {
|
const { candidates, diagnostics } = await withStateDir(stateDir, async () => {
|
||||||
return discoverOpenClawPlugins({});
|
return discoverOpenClawPlugins({});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false);
|
expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false);
|
||||||
expect(diagnostics.some((entry) => entry.message.includes("escapes package directory"))).toBe(
|
expectEscapesPackageDiagnostic(diagnostics);
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores package manifests that are hardlinked aliases", async () => {
|
it("ignores package manifests that are hardlinked aliases", async () => {
|
||||||
|
|||||||
@@ -158,6 +158,19 @@ function expectPluginFiles(result: { targetDir: string }, stateDir: string, plug
|
|||||||
expect(fs.existsSync(path.join(result.targetDir, "dist", "index.js"))).toBe(true);
|
expect(fs.existsSync(path.join(result.targetDir, "dist", "index.js"))).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectSuccessfulArchiveInstall(params: {
|
||||||
|
result: Awaited<ReturnType<typeof installPluginFromArchive>>;
|
||||||
|
stateDir: string;
|
||||||
|
pluginId: string;
|
||||||
|
}) {
|
||||||
|
expect(params.result.ok).toBe(true);
|
||||||
|
if (!params.result.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(params.result.pluginId).toBe(params.pluginId);
|
||||||
|
expectPluginFiles(params.result, params.stateDir, params.pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
function setupPluginInstallDirs() {
|
function setupPluginInstallDirs() {
|
||||||
const tmpDir = makeTempDir();
|
const tmpDir = makeTempDir();
|
||||||
const pluginDir = path.join(tmpDir, "plugin-src");
|
const pluginDir = path.join(tmpDir, "plugin-src");
|
||||||
@@ -200,6 +213,30 @@ async function installFromDirWithWarnings(params: { pluginDir: string; extension
|
|||||||
return { result, warnings };
|
return { result, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupManifestInstallFixture(params: { manifestId: string }) {
|
||||||
|
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
|
||||||
|
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(pluginDir, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: "@openclaw/cognee-openclaw",
|
||||||
|
version: "0.0.1",
|
||||||
|
openclaw: { extensions: ["./dist/index.js"] },
|
||||||
|
}),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8");
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(pluginDir, "openclaw.plugin.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
id: params.manifestId,
|
||||||
|
configSchema: { type: "object", properties: {} },
|
||||||
|
}),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
return { pluginDir, extensionsDir };
|
||||||
|
}
|
||||||
|
|
||||||
async function expectArchiveInstallReservedSegmentRejection(params: {
|
async function expectArchiveInstallReservedSegmentRejection(params: {
|
||||||
packageName: string;
|
packageName: string;
|
||||||
outName: string;
|
outName: string;
|
||||||
@@ -281,12 +318,7 @@ describe("installPluginFromArchive", () => {
|
|||||||
archivePath,
|
archivePath,
|
||||||
extensionsDir,
|
extensionsDir,
|
||||||
});
|
});
|
||||||
expect(result.ok).toBe(true);
|
expectSuccessfulArchiveInstall({ result, stateDir, pluginId: "voice-call" });
|
||||||
if (!result.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(result.pluginId).toBe("voice-call");
|
|
||||||
expectPluginFiles(result, stateDir, "voice-call");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects installing when plugin already exists", async () => {
|
it("rejects installing when plugin already exists", async () => {
|
||||||
@@ -324,13 +356,7 @@ describe("installPluginFromArchive", () => {
|
|||||||
archivePath,
|
archivePath,
|
||||||
extensionsDir,
|
extensionsDir,
|
||||||
});
|
});
|
||||||
|
expectSuccessfulArchiveInstall({ result, stateDir, pluginId: "zipper" });
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
if (!result.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(result.pluginId).toBe("zipper");
|
|
||||||
expectPluginFiles(result, stateDir, "zipper");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows updates when mode is update", async () => {
|
it("allows updates when mode is update", async () => {
|
||||||
@@ -515,26 +541,9 @@ describe("installPluginFromDir", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses openclaw.plugin.json id as install key when it differs from package name", async () => {
|
it("uses openclaw.plugin.json id as install key when it differs from package name", async () => {
|
||||||
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
|
const { pluginDir, extensionsDir } = setupManifestInstallFixture({
|
||||||
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
|
manifestId: "memory-cognee",
|
||||||
fs.writeFileSync(
|
});
|
||||||
path.join(pluginDir, "package.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
name: "@openclaw/cognee-openclaw",
|
|
||||||
version: "0.0.1",
|
|
||||||
openclaw: { extensions: ["./dist/index.js"] },
|
|
||||||
}),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8");
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(pluginDir, "openclaw.plugin.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
id: "memory-cognee",
|
|
||||||
configSchema: { type: "object", properties: {} },
|
|
||||||
}),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const infoMessages: string[] = [];
|
const infoMessages: string[] = [];
|
||||||
const res = await installPluginFromDir({
|
const res = await installPluginFromDir({
|
||||||
@@ -559,26 +568,9 @@ describe("installPluginFromDir", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes scoped manifest ids to unscoped install keys", async () => {
|
it("normalizes scoped manifest ids to unscoped install keys", async () => {
|
||||||
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
|
const { pluginDir, extensionsDir } = setupManifestInstallFixture({
|
||||||
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
|
manifestId: "@team/memory-cognee",
|
||||||
fs.writeFileSync(
|
});
|
||||||
path.join(pluginDir, "package.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
name: "@openclaw/cognee-openclaw",
|
|
||||||
version: "0.0.1",
|
|
||||||
openclaw: { extensions: ["./dist/index.js"] },
|
|
||||||
}),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8");
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(pluginDir, "openclaw.plugin.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
id: "@team/memory-cognee",
|
|
||||||
configSchema: { type: "object", properties: {} },
|
|
||||||
}),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await installPluginFromDir({
|
const res = await installPluginFromDir({
|
||||||
dirPath: pluginDir,
|
dirPath: pluginDir,
|
||||||
|
|||||||
@@ -132,6 +132,70 @@ function expectTelegramLoaded(registry: ReturnType<typeof loadOpenClawPlugins>)
|
|||||||
expect(registry.channels.some((entry) => entry.plugin.id === "telegram")).toBe(true);
|
expect(registry.channels.some((entry) => entry.plugin.id === "telegram")).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useNoBundledPlugins() {
|
||||||
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRegistryFromSinglePlugin(params: {
|
||||||
|
plugin: TempPlugin;
|
||||||
|
pluginConfig?: Record<string, unknown>;
|
||||||
|
includeWorkspaceDir?: boolean;
|
||||||
|
options?: Omit<Parameters<typeof loadOpenClawPlugins>[0], "cache" | "workspaceDir" | "config">;
|
||||||
|
}) {
|
||||||
|
const pluginConfig = params.pluginConfig ?? {};
|
||||||
|
return loadOpenClawPlugins({
|
||||||
|
cache: false,
|
||||||
|
...(params.includeWorkspaceDir === false ? {} : { workspaceDir: params.plugin.dir }),
|
||||||
|
...params.options,
|
||||||
|
config: {
|
||||||
|
plugins: {
|
||||||
|
load: { paths: [params.plugin.file] },
|
||||||
|
...pluginConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWarningLogger(warnings: string[]) {
|
||||||
|
return {
|
||||||
|
info: () => {},
|
||||||
|
warn: (msg: string) => warnings.push(msg),
|
||||||
|
error: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEscapingEntryFixture(params: { id: string; sourceBody: string }) {
|
||||||
|
const pluginDir = makeTempDir();
|
||||||
|
const outsideDir = makeTempDir();
|
||||||
|
const outsideEntry = path.join(outsideDir, "outside.js");
|
||||||
|
const linkedEntry = path.join(pluginDir, "entry.js");
|
||||||
|
fs.writeFileSync(outsideEntry, params.sourceBody, "utf-8");
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(pluginDir, "openclaw.plugin.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
id: params.id,
|
||||||
|
configSchema: EMPTY_PLUGIN_SCHEMA,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
return { pluginDir, outsideEntry, linkedEntry };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPluginSdkAliasFixture() {
|
||||||
|
const root = makeTempDir();
|
||||||
|
const srcFile = path.join(root, "src", "plugin-sdk", "index.ts");
|
||||||
|
const distFile = path.join(root, "dist", "plugin-sdk", "index.js");
|
||||||
|
fs.mkdirSync(path.dirname(srcFile), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(distFile), { recursive: true });
|
||||||
|
fs.writeFileSync(srcFile, "export {};\n", "utf-8");
|
||||||
|
fs.writeFileSync(distFile, "export {};\n", "utf-8");
|
||||||
|
return { root, srcFile, distFile };
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
if (prevBundledDir === undefined) {
|
if (prevBundledDir === undefined) {
|
||||||
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||||
@@ -327,7 +391,7 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("loads plugins when source and root differ only by realpath alias", () => {
|
it("loads plugins when source and root differ only by realpath alias", () => {
|
||||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
useNoBundledPlugins();
|
||||||
const plugin = writePlugin({
|
const plugin = writePlugin({
|
||||||
id: "alias-safe",
|
id: "alias-safe",
|
||||||
body: `export default { id: "alias-safe", register() {} };`,
|
body: `export default { id: "alias-safe", register() {} };`,
|
||||||
@@ -337,14 +401,10 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registry = loadOpenClawPlugins({
|
const registry = loadRegistryFromSinglePlugin({
|
||||||
cache: false,
|
plugin,
|
||||||
workspaceDir: plugin.dir,
|
pluginConfig: {
|
||||||
config: {
|
allow: ["alias-safe"],
|
||||||
plugins: {
|
|
||||||
load: { paths: [plugin.file] },
|
|
||||||
allow: ["alias-safe"],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -353,21 +413,17 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("denylist disables plugins even if allowed", () => {
|
it("denylist disables plugins even if allowed", () => {
|
||||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
useNoBundledPlugins();
|
||||||
const plugin = writePlugin({
|
const plugin = writePlugin({
|
||||||
id: "blocked",
|
id: "blocked",
|
||||||
body: `export default { id: "blocked", register() {} };`,
|
body: `export default { id: "blocked", register() {} };`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const registry = loadOpenClawPlugins({
|
const registry = loadRegistryFromSinglePlugin({
|
||||||
cache: false,
|
plugin,
|
||||||
workspaceDir: plugin.dir,
|
pluginConfig: {
|
||||||
config: {
|
allow: ["blocked"],
|
||||||
plugins: {
|
deny: ["blocked"],
|
||||||
load: { paths: [plugin.file] },
|
|
||||||
allow: ["blocked"],
|
|
||||||
deny: ["blocked"],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -376,22 +432,18 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fails fast on invalid plugin config", () => {
|
it("fails fast on invalid plugin config", () => {
|
||||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
useNoBundledPlugins();
|
||||||
const plugin = writePlugin({
|
const plugin = writePlugin({
|
||||||
id: "configurable",
|
id: "configurable",
|
||||||
body: `export default { id: "configurable", register() {} };`,
|
body: `export default { id: "configurable", register() {} };`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const registry = loadOpenClawPlugins({
|
const registry = loadRegistryFromSinglePlugin({
|
||||||
cache: false,
|
plugin,
|
||||||
workspaceDir: plugin.dir,
|
pluginConfig: {
|
||||||
config: {
|
entries: {
|
||||||
plugins: {
|
configurable: {
|
||||||
load: { paths: [plugin.file] },
|
config: "nope" as unknown as Record<string, unknown>,
|
||||||
entries: {
|
|
||||||
configurable: {
|
|
||||||
config: "nope" as unknown as Record<string, unknown>,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -403,7 +455,7 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("registers channel plugins", () => {
|
it("registers channel plugins", () => {
|
||||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
useNoBundledPlugins();
|
||||||
const plugin = writePlugin({
|
const plugin = writePlugin({
|
||||||
id: "channel-demo",
|
id: "channel-demo",
|
||||||
body: `export default { id: "channel-demo", register(api) {
|
body: `export default { id: "channel-demo", register(api) {
|
||||||
@@ -428,14 +480,10 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
} };`,
|
} };`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const registry = loadOpenClawPlugins({
|
const registry = loadRegistryFromSinglePlugin({
|
||||||
cache: false,
|
plugin,
|
||||||
workspaceDir: plugin.dir,
|
pluginConfig: {
|
||||||
config: {
|
allow: ["channel-demo"],
|
||||||
plugins: {
|
|
||||||
load: { paths: [plugin.file] },
|
|
||||||
allow: ["channel-demo"],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -444,7 +492,7 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("registers http handlers", () => {
|
it("registers http handlers", () => {
|
||||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
useNoBundledPlugins();
|
||||||
const plugin = writePlugin({
|
const plugin = writePlugin({
|
||||||
id: "http-demo",
|
id: "http-demo",
|
||||||
body: `export default { id: "http-demo", register(api) {
|
body: `export default { id: "http-demo", register(api) {
|
||||||
@@ -452,14 +500,10 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
} };`,
|
} };`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const registry = loadOpenClawPlugins({
|
const registry = loadRegistryFromSinglePlugin({
|
||||||
cache: false,
|
plugin,
|
||||||
workspaceDir: plugin.dir,
|
pluginConfig: {
|
||||||
config: {
|
allow: ["http-demo"],
|
||||||
plugins: {
|
|
||||||
load: { paths: [plugin.file] },
|
|
||||||
allow: ["http-demo"],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -470,7 +514,7 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("registers http routes", () => {
|
it("registers http routes", () => {
|
||||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
useNoBundledPlugins();
|
||||||
const plugin = writePlugin({
|
const plugin = writePlugin({
|
||||||
id: "http-route-demo",
|
id: "http-route-demo",
|
||||||
body: `export default { id: "http-route-demo", register(api) {
|
body: `export default { id: "http-route-demo", register(api) {
|
||||||
@@ -478,14 +522,10 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
} };`,
|
} };`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const registry = loadOpenClawPlugins({
|
const registry = loadRegistryFromSinglePlugin({
|
||||||
cache: false,
|
plugin,
|
||||||
workspaceDir: plugin.dir,
|
pluginConfig: {
|
||||||
config: {
|
allow: ["http-route-demo"],
|
||||||
plugins: {
|
|
||||||
load: { paths: [plugin.file] },
|
|
||||||
allow: ["http-route-demo"],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -644,7 +684,7 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("warns when plugins.allow is empty and non-bundled plugins are discoverable", () => {
|
it("warns when plugins.allow is empty and non-bundled plugins are discoverable", () => {
|
||||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
useNoBundledPlugins();
|
||||||
const plugin = writePlugin({
|
const plugin = writePlugin({
|
||||||
id: "warn-open-allow",
|
id: "warn-open-allow",
|
||||||
body: `export default { id: "warn-open-allow", register() {} };`,
|
body: `export default { id: "warn-open-allow", register() {} };`,
|
||||||
@@ -652,11 +692,7 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
loadOpenClawPlugins({
|
loadOpenClawPlugins({
|
||||||
cache: false,
|
cache: false,
|
||||||
logger: {
|
logger: createWarningLogger(warnings),
|
||||||
info: () => {},
|
|
||||||
warn: (msg) => warnings.push(msg),
|
|
||||||
error: () => {},
|
|
||||||
},
|
|
||||||
config: {
|
config: {
|
||||||
plugins: {
|
plugins: {
|
||||||
load: { paths: [plugin.file] },
|
load: { paths: [plugin.file] },
|
||||||
@@ -669,7 +705,7 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("warns when loaded non-bundled plugin has no install/load-path provenance", () => {
|
it("warns when loaded non-bundled plugin has no install/load-path provenance", () => {
|
||||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
useNoBundledPlugins();
|
||||||
const stateDir = makeTempDir();
|
const stateDir = makeTempDir();
|
||||||
withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => {
|
withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => {
|
||||||
const globalDir = path.join(stateDir, "extensions", "rogue");
|
const globalDir = path.join(stateDir, "extensions", "rogue");
|
||||||
@@ -684,11 +720,7 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const registry = loadOpenClawPlugins({
|
const registry = loadOpenClawPlugins({
|
||||||
cache: false,
|
cache: false,
|
||||||
logger: {
|
logger: createWarningLogger(warnings),
|
||||||
info: () => {},
|
|
||||||
warn: (msg) => warnings.push(msg),
|
|
||||||
error: () => {},
|
|
||||||
},
|
|
||||||
config: {
|
config: {
|
||||||
plugins: {
|
plugins: {
|
||||||
allow: ["rogue"],
|
allow: ["rogue"],
|
||||||
@@ -708,28 +740,12 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects plugin entry files that escape plugin root via symlink", () => {
|
it("rejects plugin entry files that escape plugin root via symlink", () => {
|
||||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
useNoBundledPlugins();
|
||||||
const pluginDir = makeTempDir();
|
const { outsideEntry, linkedEntry } = createEscapingEntryFixture({
|
||||||
const outsideDir = makeTempDir();
|
id: "symlinked",
|
||||||
const outsideEntry = path.join(outsideDir, "outside.js");
|
sourceBody:
|
||||||
const linkedEntry = path.join(pluginDir, "entry.js");
|
'export default { id: "symlinked", register() { throw new Error("should not run"); } };',
|
||||||
fs.writeFileSync(
|
});
|
||||||
outsideEntry,
|
|
||||||
'export default { id: "symlinked", register() { throw new Error("should not run"); } };',
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(pluginDir, "openclaw.plugin.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
id: "symlinked",
|
|
||||||
configSchema: EMPTY_PLUGIN_SCHEMA,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
fs.symlinkSync(outsideEntry, linkedEntry);
|
fs.symlinkSync(outsideEntry, linkedEntry);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -755,28 +771,12 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
useNoBundledPlugins();
|
||||||
const pluginDir = makeTempDir();
|
const { outsideEntry, linkedEntry } = createEscapingEntryFixture({
|
||||||
const outsideDir = makeTempDir();
|
id: "hardlinked",
|
||||||
const outsideEntry = path.join(outsideDir, "outside.js");
|
sourceBody:
|
||||||
const linkedEntry = path.join(pluginDir, "entry.js");
|
'export default { id: "hardlinked", register() { throw new Error("should not run"); } };',
|
||||||
fs.writeFileSync(
|
});
|
||||||
outsideEntry,
|
|
||||||
'export default { id: "hardlinked", register() { throw new Error("should not run"); } };',
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(pluginDir, "openclaw.plugin.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
id: "hardlinked",
|
|
||||||
configSchema: EMPTY_PLUGIN_SCHEMA,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
fs.linkSync(outsideEntry, linkedEntry);
|
fs.linkSync(outsideEntry, linkedEntry);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -802,13 +802,7 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prefers dist plugin-sdk alias when loader runs from dist", () => {
|
it("prefers dist plugin-sdk alias when loader runs from dist", () => {
|
||||||
const root = makeTempDir();
|
const { root, distFile } = createPluginSdkAliasFixture();
|
||||||
const srcFile = path.join(root, "src", "plugin-sdk", "index.ts");
|
|
||||||
const distFile = path.join(root, "dist", "plugin-sdk", "index.js");
|
|
||||||
fs.mkdirSync(path.dirname(srcFile), { recursive: true });
|
|
||||||
fs.mkdirSync(path.dirname(distFile), { recursive: true });
|
|
||||||
fs.writeFileSync(srcFile, "export {};\n", "utf-8");
|
|
||||||
fs.writeFileSync(distFile, "export {};\n", "utf-8");
|
|
||||||
|
|
||||||
const resolved = __testing.resolvePluginSdkAliasFile({
|
const resolved = __testing.resolvePluginSdkAliasFile({
|
||||||
srcFile: "index.ts",
|
srcFile: "index.ts",
|
||||||
@@ -819,13 +813,7 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prefers src plugin-sdk alias when loader runs from src in non-production", () => {
|
it("prefers src plugin-sdk alias when loader runs from src in non-production", () => {
|
||||||
const root = makeTempDir();
|
const { root, srcFile } = createPluginSdkAliasFixture();
|
||||||
const srcFile = path.join(root, "src", "plugin-sdk", "index.ts");
|
|
||||||
const distFile = path.join(root, "dist", "plugin-sdk", "index.js");
|
|
||||||
fs.mkdirSync(path.dirname(srcFile), { recursive: true });
|
|
||||||
fs.mkdirSync(path.dirname(distFile), { recursive: true });
|
|
||||||
fs.writeFileSync(srcFile, "export {};\n", "utf-8");
|
|
||||||
fs.writeFileSync(distFile, "export {};\n", "utf-8");
|
|
||||||
|
|
||||||
const resolved = withEnv({ NODE_ENV: undefined }, () =>
|
const resolved = withEnv({ NODE_ENV: undefined }, () =>
|
||||||
__testing.resolvePluginSdkAliasFile({
|
__testing.resolvePluginSdkAliasFile({
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|||||||
import { runSecretsApply } from "./apply.js";
|
import { runSecretsApply } from "./apply.js";
|
||||||
import type { SecretsApplyPlan } from "./plan.js";
|
import type { SecretsApplyPlan } from "./plan.js";
|
||||||
|
|
||||||
|
const OPENAI_API_KEY_ENV_REF = {
|
||||||
|
source: "env",
|
||||||
|
provider: "default",
|
||||||
|
id: "OPENAI_API_KEY",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ApplyFixture = {
|
||||||
|
rootDir: string;
|
||||||
|
stateDir: string;
|
||||||
|
configPath: string;
|
||||||
|
authStorePath: string;
|
||||||
|
authJsonPath: string;
|
||||||
|
envPath: string;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
};
|
||||||
|
|
||||||
function stripVolatileConfigMeta(input: string): Record<string, unknown> {
|
function stripVolatileConfigMeta(input: string): Record<string, unknown> {
|
||||||
const parsed = JSON.parse(input) as Record<string, unknown>;
|
const parsed = JSON.parse(input) as Record<string, unknown>;
|
||||||
const meta =
|
const meta =
|
||||||
@@ -20,404 +36,322 @@ function stripVolatileConfigMeta(input: string): Record<string, unknown> {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
||||||
|
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOpenAiProviderConfig(apiKey: unknown = "sk-openai-plaintext") {
|
||||||
|
return {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
apiKey,
|
||||||
|
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFixturePaths(rootDir: string) {
|
||||||
|
const stateDir = path.join(rootDir, ".openclaw");
|
||||||
|
return {
|
||||||
|
rootDir,
|
||||||
|
stateDir,
|
||||||
|
configPath: path.join(stateDir, "openclaw.json"),
|
||||||
|
authStorePath: path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"),
|
||||||
|
authJsonPath: path.join(stateDir, "agents", "main", "agent", "auth.json"),
|
||||||
|
envPath: path.join(stateDir, ".env"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApplyFixture(): Promise<ApplyFixture> {
|
||||||
|
const paths = buildFixturePaths(
|
||||||
|
await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-apply-")),
|
||||||
|
);
|
||||||
|
await fs.mkdir(path.dirname(paths.configPath), { recursive: true });
|
||||||
|
await fs.mkdir(path.dirname(paths.authStorePath), { recursive: true });
|
||||||
|
return {
|
||||||
|
...paths,
|
||||||
|
env: {
|
||||||
|
OPENCLAW_STATE_DIR: paths.stateDir,
|
||||||
|
OPENCLAW_CONFIG_PATH: paths.configPath,
|
||||||
|
OPENAI_API_KEY: "sk-live-env",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedDefaultApplyFixture(fixture: ApplyFixture): Promise<void> {
|
||||||
|
await writeJsonFile(fixture.configPath, {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
openai: createOpenAiProviderConfig(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeJsonFile(fixture.authStorePath, {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
"openai:default": {
|
||||||
|
type: "api_key",
|
||||||
|
provider: "openai",
|
||||||
|
key: "sk-openai-plaintext",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeJsonFile(fixture.authJsonPath, {
|
||||||
|
openai: {
|
||||||
|
type: "api_key",
|
||||||
|
key: "sk-openai-plaintext",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fs.writeFile(
|
||||||
|
fixture.envPath,
|
||||||
|
"OPENAI_API_KEY=sk-openai-plaintext\nUNRELATED=value\n",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPlanAndReadConfig<T>(
|
||||||
|
fixture: ApplyFixture,
|
||||||
|
plan: SecretsApplyPlan,
|
||||||
|
): Promise<T> {
|
||||||
|
const result = await runSecretsApply({ plan, env: fixture.env, write: true });
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
return JSON.parse(await fs.readFile(fixture.configPath, "utf8")) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectInvalidTargetPath(
|
||||||
|
fixture: ApplyFixture,
|
||||||
|
target: SecretsApplyPlan["targets"][number],
|
||||||
|
): Promise<void> {
|
||||||
|
const plan = createPlan({ targets: [target] });
|
||||||
|
await expect(runSecretsApply({ plan, env: fixture.env, write: false })).rejects.toThrow(
|
||||||
|
"Invalid plan target path",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlan(params: {
|
||||||
|
targets: SecretsApplyPlan["targets"];
|
||||||
|
options?: SecretsApplyPlan["options"];
|
||||||
|
providerUpserts?: SecretsApplyPlan["providerUpserts"];
|
||||||
|
providerDeletes?: SecretsApplyPlan["providerDeletes"];
|
||||||
|
}): SecretsApplyPlan {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
protocolVersion: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
generatedBy: "manual",
|
||||||
|
targets: params.targets,
|
||||||
|
...(params.options ? { options: params.options } : {}),
|
||||||
|
...(params.providerUpserts ? { providerUpserts: params.providerUpserts } : {}),
|
||||||
|
...(params.providerDeletes ? { providerDeletes: params.providerDeletes } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOpenAiProviderTarget(params?: {
|
||||||
|
path?: string;
|
||||||
|
pathSegments?: string[];
|
||||||
|
providerId?: string;
|
||||||
|
}): SecretsApplyPlan["targets"][number] {
|
||||||
|
return {
|
||||||
|
type: "models.providers.apiKey",
|
||||||
|
path: params?.path ?? "models.providers.openai.apiKey",
|
||||||
|
...(params?.pathSegments ? { pathSegments: params.pathSegments } : {}),
|
||||||
|
providerId: params?.providerId ?? "openai",
|
||||||
|
ref: OPENAI_API_KEY_ENV_REF,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOneWayScrubOptions(): NonNullable<SecretsApplyPlan["options"]> {
|
||||||
|
return {
|
||||||
|
scrubEnv: true,
|
||||||
|
scrubAuthProfilesForProviderTargets: true,
|
||||||
|
scrubLegacyAuthJson: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("secrets apply", () => {
|
describe("secrets apply", () => {
|
||||||
let rootDir = "";
|
let fixture: ApplyFixture;
|
||||||
let stateDir = "";
|
|
||||||
let configPath = "";
|
|
||||||
let authStorePath = "";
|
|
||||||
let authJsonPath = "";
|
|
||||||
let envPath = "";
|
|
||||||
let env: NodeJS.ProcessEnv;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-apply-"));
|
fixture = await createApplyFixture();
|
||||||
stateDir = path.join(rootDir, ".openclaw");
|
await seedDefaultApplyFixture(fixture);
|
||||||
configPath = path.join(stateDir, "openclaw.json");
|
|
||||||
authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
|
|
||||||
authJsonPath = path.join(stateDir, "agents", "main", "agent", "auth.json");
|
|
||||||
envPath = path.join(stateDir, ".env");
|
|
||||||
env = {
|
|
||||||
OPENCLAW_STATE_DIR: stateDir,
|
|
||||||
OPENCLAW_CONFIG_PATH: configPath,
|
|
||||||
OPENAI_API_KEY: "sk-live-env",
|
|
||||||
};
|
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
||||||
await fs.mkdir(path.dirname(authStorePath), { recursive: true });
|
|
||||||
|
|
||||||
await fs.writeFile(
|
|
||||||
configPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
models: {
|
|
||||||
providers: {
|
|
||||||
openai: {
|
|
||||||
baseUrl: "https://api.openai.com/v1",
|
|
||||||
api: "openai-completions",
|
|
||||||
apiKey: "sk-openai-plaintext",
|
|
||||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
await fs.writeFile(
|
|
||||||
authStorePath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
version: 1,
|
|
||||||
profiles: {
|
|
||||||
"openai:default": {
|
|
||||||
type: "api_key",
|
|
||||||
provider: "openai",
|
|
||||||
key: "sk-openai-plaintext",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
await fs.writeFile(
|
|
||||||
authJsonPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
openai: {
|
|
||||||
type: "api_key",
|
|
||||||
key: "sk-openai-plaintext",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
await fs.writeFile(envPath, "OPENAI_API_KEY=sk-openai-plaintext\nUNRELATED=value\n", "utf8");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await fs.rm(rootDir, { recursive: true, force: true });
|
await fs.rm(fixture.rootDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preflights and applies one-way scrub without plaintext backups", async () => {
|
it("preflights and applies one-way scrub without plaintext backups", async () => {
|
||||||
const plan: SecretsApplyPlan = {
|
const plan = createPlan({
|
||||||
version: 1,
|
targets: [createOpenAiProviderTarget()],
|
||||||
protocolVersion: 1,
|
options: createOneWayScrubOptions(),
|
||||||
generatedAt: new Date().toISOString(),
|
});
|
||||||
generatedBy: "manual",
|
|
||||||
targets: [
|
|
||||||
{
|
|
||||||
type: "models.providers.apiKey",
|
|
||||||
path: "models.providers.openai.apiKey",
|
|
||||||
providerId: "openai",
|
|
||||||
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
options: {
|
|
||||||
scrubEnv: true,
|
|
||||||
scrubAuthProfilesForProviderTargets: true,
|
|
||||||
scrubLegacyAuthJson: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const dryRun = await runSecretsApply({ plan, env, write: false });
|
const dryRun = await runSecretsApply({ plan, env: fixture.env, write: false });
|
||||||
expect(dryRun.mode).toBe("dry-run");
|
expect(dryRun.mode).toBe("dry-run");
|
||||||
expect(dryRun.changed).toBe(true);
|
expect(dryRun.changed).toBe(true);
|
||||||
|
|
||||||
const applied = await runSecretsApply({ plan, env, write: true });
|
const applied = await runSecretsApply({ plan, env: fixture.env, write: true });
|
||||||
expect(applied.mode).toBe("write");
|
expect(applied.mode).toBe("write");
|
||||||
expect(applied.changed).toBe(true);
|
expect(applied.changed).toBe(true);
|
||||||
|
|
||||||
const nextConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
const nextConfig = JSON.parse(await fs.readFile(fixture.configPath, "utf8")) as {
|
||||||
models: { providers: { openai: { apiKey: unknown } } };
|
models: { providers: { openai: { apiKey: unknown } } };
|
||||||
};
|
};
|
||||||
expect(nextConfig.models.providers.openai.apiKey).toEqual({
|
expect(nextConfig.models.providers.openai.apiKey).toEqual(OPENAI_API_KEY_ENV_REF);
|
||||||
source: "env",
|
|
||||||
provider: "default",
|
|
||||||
id: "OPENAI_API_KEY",
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextAuthStore = JSON.parse(await fs.readFile(authStorePath, "utf8")) as {
|
const nextAuthStore = JSON.parse(await fs.readFile(fixture.authStorePath, "utf8")) as {
|
||||||
profiles: { "openai:default": { key?: string; keyRef?: unknown } };
|
profiles: { "openai:default": { key?: string; keyRef?: unknown } };
|
||||||
};
|
};
|
||||||
expect(nextAuthStore.profiles["openai:default"].key).toBeUndefined();
|
expect(nextAuthStore.profiles["openai:default"].key).toBeUndefined();
|
||||||
expect(nextAuthStore.profiles["openai:default"].keyRef).toBeUndefined();
|
expect(nextAuthStore.profiles["openai:default"].keyRef).toBeUndefined();
|
||||||
|
|
||||||
const nextAuthJson = JSON.parse(await fs.readFile(authJsonPath, "utf8")) as Record<
|
const nextAuthJson = JSON.parse(await fs.readFile(fixture.authJsonPath, "utf8")) as Record<
|
||||||
string,
|
string,
|
||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
expect(nextAuthJson.openai).toBeUndefined();
|
expect(nextAuthJson.openai).toBeUndefined();
|
||||||
|
|
||||||
const nextEnv = await fs.readFile(envPath, "utf8");
|
const nextEnv = await fs.readFile(fixture.envPath, "utf8");
|
||||||
expect(nextEnv).not.toContain("sk-openai-plaintext");
|
expect(nextEnv).not.toContain("sk-openai-plaintext");
|
||||||
expect(nextEnv).toContain("UNRELATED=value");
|
expect(nextEnv).toContain("UNRELATED=value");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is idempotent on repeated write applies", async () => {
|
it("is idempotent on repeated write applies", async () => {
|
||||||
const plan: SecretsApplyPlan = {
|
const plan = createPlan({
|
||||||
version: 1,
|
targets: [createOpenAiProviderTarget()],
|
||||||
protocolVersion: 1,
|
options: createOneWayScrubOptions(),
|
||||||
generatedAt: new Date().toISOString(),
|
});
|
||||||
generatedBy: "manual",
|
|
||||||
targets: [
|
|
||||||
{
|
|
||||||
type: "models.providers.apiKey",
|
|
||||||
path: "models.providers.openai.apiKey",
|
|
||||||
providerId: "openai",
|
|
||||||
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
options: {
|
|
||||||
scrubEnv: true,
|
|
||||||
scrubAuthProfilesForProviderTargets: true,
|
|
||||||
scrubLegacyAuthJson: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const first = await runSecretsApply({ plan, env, write: true });
|
const first = await runSecretsApply({ plan, env: fixture.env, write: true });
|
||||||
expect(first.changed).toBe(true);
|
expect(first.changed).toBe(true);
|
||||||
const configAfterFirst = await fs.readFile(configPath, "utf8");
|
const configAfterFirst = await fs.readFile(fixture.configPath, "utf8");
|
||||||
const authStoreAfterFirst = await fs.readFile(authStorePath, "utf8");
|
const authStoreAfterFirst = await fs.readFile(fixture.authStorePath, "utf8");
|
||||||
const authJsonAfterFirst = await fs.readFile(authJsonPath, "utf8");
|
const authJsonAfterFirst = await fs.readFile(fixture.authJsonPath, "utf8");
|
||||||
const envAfterFirst = await fs.readFile(envPath, "utf8");
|
const envAfterFirst = await fs.readFile(fixture.envPath, "utf8");
|
||||||
|
|
||||||
// Second apply should be a true no-op and avoid file writes entirely.
|
await fs.chmod(fixture.configPath, 0o400);
|
||||||
await fs.chmod(configPath, 0o400);
|
await fs.chmod(fixture.authStorePath, 0o400);
|
||||||
await fs.chmod(authStorePath, 0o400);
|
|
||||||
|
|
||||||
const second = await runSecretsApply({ plan, env, write: true });
|
const second = await runSecretsApply({ plan, env: fixture.env, write: true });
|
||||||
expect(second.mode).toBe("write");
|
expect(second.mode).toBe("write");
|
||||||
const configAfterSecond = await fs.readFile(configPath, "utf8");
|
const configAfterSecond = await fs.readFile(fixture.configPath, "utf8");
|
||||||
expect(stripVolatileConfigMeta(configAfterSecond)).toEqual(
|
expect(stripVolatileConfigMeta(configAfterSecond)).toEqual(
|
||||||
stripVolatileConfigMeta(configAfterFirst),
|
stripVolatileConfigMeta(configAfterFirst),
|
||||||
);
|
);
|
||||||
await expect(fs.readFile(authStorePath, "utf8")).resolves.toBe(authStoreAfterFirst);
|
await expect(fs.readFile(fixture.authStorePath, "utf8")).resolves.toBe(authStoreAfterFirst);
|
||||||
await expect(fs.readFile(authJsonPath, "utf8")).resolves.toBe(authJsonAfterFirst);
|
await expect(fs.readFile(fixture.authJsonPath, "utf8")).resolves.toBe(authJsonAfterFirst);
|
||||||
await expect(fs.readFile(envPath, "utf8")).resolves.toBe(envAfterFirst);
|
await expect(fs.readFile(fixture.envPath, "utf8")).resolves.toBe(envAfterFirst);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies targets safely when map keys contain dots", async () => {
|
it("applies targets safely when map keys contain dots", async () => {
|
||||||
await fs.writeFile(
|
await writeJsonFile(fixture.configPath, {
|
||||||
configPath,
|
models: {
|
||||||
`${JSON.stringify(
|
providers: {
|
||||||
{
|
"openai.dev": createOpenAiProviderConfig(),
|
||||||
models: {
|
|
||||||
providers: {
|
|
||||||
"openai.dev": {
|
|
||||||
baseUrl: "https://api.openai.com/v1",
|
|
||||||
api: "openai-completions",
|
|
||||||
apiKey: "sk-openai-plaintext",
|
|
||||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
null,
|
},
|
||||||
2,
|
});
|
||||||
)}\n`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const plan: SecretsApplyPlan = {
|
const plan = createPlan({
|
||||||
version: 1,
|
|
||||||
protocolVersion: 1,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
generatedBy: "manual",
|
|
||||||
targets: [
|
targets: [
|
||||||
{
|
createOpenAiProviderTarget({
|
||||||
type: "models.providers.apiKey",
|
|
||||||
path: "models.providers.openai.dev.apiKey",
|
path: "models.providers.openai.dev.apiKey",
|
||||||
pathSegments: ["models", "providers", "openai.dev", "apiKey"],
|
pathSegments: ["models", "providers", "openai.dev", "apiKey"],
|
||||||
providerId: "openai.dev",
|
providerId: "openai.dev",
|
||||||
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
}),
|
||||||
},
|
|
||||||
],
|
],
|
||||||
options: {
|
options: {
|
||||||
scrubEnv: false,
|
scrubEnv: false,
|
||||||
scrubAuthProfilesForProviderTargets: false,
|
scrubAuthProfilesForProviderTargets: false,
|
||||||
scrubLegacyAuthJson: false,
|
scrubLegacyAuthJson: false,
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const result = await runSecretsApply({ plan, env, write: true });
|
const nextConfig = await applyPlanAndReadConfig<{
|
||||||
expect(result.changed).toBe(true);
|
|
||||||
|
|
||||||
const nextConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
|
||||||
models?: {
|
models?: {
|
||||||
providers?: Record<string, { apiKey?: unknown }>;
|
providers?: Record<string, { apiKey?: unknown }>;
|
||||||
};
|
};
|
||||||
};
|
}>(fixture, plan);
|
||||||
expect(nextConfig.models?.providers?.["openai.dev"]?.apiKey).toEqual({
|
expect(nextConfig.models?.providers?.["openai.dev"]?.apiKey).toEqual(OPENAI_API_KEY_ENV_REF);
|
||||||
source: "env",
|
|
||||||
provider: "default",
|
|
||||||
id: "OPENAI_API_KEY",
|
|
||||||
});
|
|
||||||
expect(nextConfig.models?.providers?.openai).toBeUndefined();
|
expect(nextConfig.models?.providers?.openai).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("migrates skills entries apiKey targets alongside provider api keys", async () => {
|
it("migrates skills entries apiKey targets alongside provider api keys", async () => {
|
||||||
await fs.writeFile(
|
await writeJsonFile(fixture.configPath, {
|
||||||
configPath,
|
models: {
|
||||||
`${JSON.stringify(
|
providers: {
|
||||||
{
|
openai: createOpenAiProviderConfig(),
|
||||||
models: {
|
},
|
||||||
providers: {
|
},
|
||||||
openai: {
|
skills: {
|
||||||
baseUrl: "https://api.openai.com/v1",
|
entries: {
|
||||||
api: "openai-completions",
|
"qa-secret-test": {
|
||||||
apiKey: "sk-openai-plaintext",
|
enabled: true,
|
||||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
apiKey: "sk-skill-plaintext",
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
skills: {
|
|
||||||
entries: {
|
|
||||||
"qa-secret-test": {
|
|
||||||
enabled: true,
|
|
||||||
apiKey: "sk-skill-plaintext",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
null,
|
},
|
||||||
2,
|
});
|
||||||
)}\n`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const plan: SecretsApplyPlan = {
|
const plan = createPlan({
|
||||||
version: 1,
|
|
||||||
protocolVersion: 1,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
generatedBy: "manual",
|
|
||||||
targets: [
|
targets: [
|
||||||
{
|
createOpenAiProviderTarget({ pathSegments: ["models", "providers", "openai", "apiKey"] }),
|
||||||
type: "models.providers.apiKey",
|
|
||||||
path: "models.providers.openai.apiKey",
|
|
||||||
pathSegments: ["models", "providers", "openai", "apiKey"],
|
|
||||||
providerId: "openai",
|
|
||||||
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "skills.entries.apiKey",
|
type: "skills.entries.apiKey",
|
||||||
path: "skills.entries.qa-secret-test.apiKey",
|
path: "skills.entries.qa-secret-test.apiKey",
|
||||||
pathSegments: ["skills", "entries", "qa-secret-test", "apiKey"],
|
pathSegments: ["skills", "entries", "qa-secret-test", "apiKey"],
|
||||||
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
ref: OPENAI_API_KEY_ENV_REF,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
options: {
|
options: createOneWayScrubOptions(),
|
||||||
scrubEnv: true,
|
});
|
||||||
scrubAuthProfilesForProviderTargets: true,
|
|
||||||
scrubLegacyAuthJson: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await runSecretsApply({ plan, env, write: true });
|
const nextConfig = await applyPlanAndReadConfig<{
|
||||||
expect(result.changed).toBe(true);
|
|
||||||
|
|
||||||
const nextConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
|
||||||
models: { providers: { openai: { apiKey: unknown } } };
|
models: { providers: { openai: { apiKey: unknown } } };
|
||||||
skills: { entries: { "qa-secret-test": { apiKey: unknown } } };
|
skills: { entries: { "qa-secret-test": { apiKey: unknown } } };
|
||||||
};
|
}>(fixture, plan);
|
||||||
expect(nextConfig.models.providers.openai.apiKey).toEqual({
|
expect(nextConfig.models.providers.openai.apiKey).toEqual(OPENAI_API_KEY_ENV_REF);
|
||||||
source: "env",
|
expect(nextConfig.skills.entries["qa-secret-test"].apiKey).toEqual(OPENAI_API_KEY_ENV_REF);
|
||||||
provider: "default",
|
|
||||||
id: "OPENAI_API_KEY",
|
|
||||||
});
|
|
||||||
expect(nextConfig.skills.entries["qa-secret-test"].apiKey).toEqual({
|
|
||||||
source: "env",
|
|
||||||
provider: "default",
|
|
||||||
id: "OPENAI_API_KEY",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rawConfig = await fs.readFile(configPath, "utf8");
|
const rawConfig = await fs.readFile(fixture.configPath, "utf8");
|
||||||
expect(rawConfig).not.toContain("sk-openai-plaintext");
|
expect(rawConfig).not.toContain("sk-openai-plaintext");
|
||||||
expect(rawConfig).not.toContain("sk-skill-plaintext");
|
expect(rawConfig).not.toContain("sk-skill-plaintext");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects plan targets that do not match allowed secret-bearing paths", async () => {
|
it.each([
|
||||||
const plan: SecretsApplyPlan = {
|
createOpenAiProviderTarget({
|
||||||
version: 1,
|
path: "models.providers.openai.baseUrl",
|
||||||
protocolVersion: 1,
|
pathSegments: ["models", "providers", "openai", "baseUrl"],
|
||||||
generatedAt: new Date().toISOString(),
|
}),
|
||||||
generatedBy: "manual",
|
{
|
||||||
targets: [
|
type: "skills.entries.apiKey",
|
||||||
{
|
path: "skills.entries.__proto__.apiKey",
|
||||||
type: "models.providers.apiKey",
|
pathSegments: ["skills", "entries", "__proto__", "apiKey"],
|
||||||
path: "models.providers.openai.baseUrl",
|
ref: OPENAI_API_KEY_ENV_REF,
|
||||||
pathSegments: ["models", "providers", "openai", "baseUrl"],
|
} satisfies SecretsApplyPlan["targets"][number],
|
||||||
providerId: "openai",
|
])("rejects invalid target path: %s", async (target) => {
|
||||||
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
await expectInvalidTargetPath(fixture, target);
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(runSecretsApply({ plan, env, write: false })).rejects.toThrow(
|
|
||||||
"Invalid plan target path",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects plan targets with forbidden prototype-like path segments", async () => {
|
|
||||||
const plan: SecretsApplyPlan = {
|
|
||||||
version: 1,
|
|
||||||
protocolVersion: 1,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
generatedBy: "manual",
|
|
||||||
targets: [
|
|
||||||
{
|
|
||||||
type: "skills.entries.apiKey",
|
|
||||||
path: "skills.entries.__proto__.apiKey",
|
|
||||||
pathSegments: ["skills", "entries", "__proto__", "apiKey"],
|
|
||||||
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(runSecretsApply({ plan, env, write: false })).rejects.toThrow(
|
|
||||||
"Invalid plan target path",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies provider upserts and deletes from plan", async () => {
|
it("applies provider upserts and deletes from plan", async () => {
|
||||||
await fs.writeFile(
|
await writeJsonFile(fixture.configPath, {
|
||||||
configPath,
|
secrets: {
|
||||||
`${JSON.stringify(
|
providers: {
|
||||||
{
|
envmain: { source: "env" },
|
||||||
secrets: {
|
fileold: { source: "file", path: "/tmp/old-secrets.json", mode: "json" },
|
||||||
providers: {
|
},
|
||||||
envmain: { source: "env" },
|
},
|
||||||
fileold: { source: "file", path: "/tmp/old-secrets.json", mode: "json" },
|
models: {
|
||||||
},
|
providers: {
|
||||||
},
|
openai: {
|
||||||
models: {
|
baseUrl: "https://api.openai.com/v1",
|
||||||
providers: {
|
api: "openai-completions",
|
||||||
openai: {
|
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||||
baseUrl: "https://api.openai.com/v1",
|
|
||||||
api: "openai-completions",
|
|
||||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
null,
|
},
|
||||||
2,
|
});
|
||||||
)}\n`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const plan: SecretsApplyPlan = {
|
const plan = createPlan({
|
||||||
version: 1,
|
|
||||||
protocolVersion: 1,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
generatedBy: "manual",
|
|
||||||
providerUpserts: {
|
providerUpserts: {
|
||||||
filemain: {
|
filemain: {
|
||||||
source: "file",
|
source: "file",
|
||||||
@@ -427,16 +361,13 @@ describe("secrets apply", () => {
|
|||||||
},
|
},
|
||||||
providerDeletes: ["fileold"],
|
providerDeletes: ["fileold"],
|
||||||
targets: [],
|
targets: [],
|
||||||
};
|
});
|
||||||
|
|
||||||
const result = await runSecretsApply({ plan, env, write: true });
|
const nextConfig = await applyPlanAndReadConfig<{
|
||||||
expect(result.changed).toBe(true);
|
|
||||||
|
|
||||||
const nextConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
|
||||||
secrets?: {
|
secrets?: {
|
||||||
providers?: Record<string, unknown>;
|
providers?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
};
|
}>(fixture, plan);
|
||||||
expect(nextConfig.secrets?.providers?.fileold).toBeUndefined();
|
expect(nextConfig.secrets?.providers?.fileold).toBeUndefined();
|
||||||
expect(nextConfig.secrets?.providers?.filemain).toEqual({
|
expect(nextConfig.secrets?.providers?.filemain).toEqual({
|
||||||
source: "file",
|
source: "file",
|
||||||
|
|||||||
@@ -4,126 +4,142 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { runSecretsAudit } from "./audit.js";
|
import { runSecretsAudit } from "./audit.js";
|
||||||
|
|
||||||
describe("secrets audit", () => {
|
type AuditFixture = {
|
||||||
let rootDir = "";
|
rootDir: string;
|
||||||
let stateDir = "";
|
stateDir: string;
|
||||||
let configPath = "";
|
configPath: string;
|
||||||
let authStorePath = "";
|
authStorePath: string;
|
||||||
let authJsonPath = "";
|
authJsonPath: string;
|
||||||
let envPath = "";
|
envPath: string;
|
||||||
let env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
||||||
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-audit-"));
|
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||||
stateDir = path.join(rootDir, ".openclaw");
|
}
|
||||||
configPath = path.join(stateDir, "openclaw.json");
|
|
||||||
authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
|
function resolveRuntimePathEnv(): string {
|
||||||
authJsonPath = path.join(stateDir, "agents", "main", "agent", "auth.json");
|
if (typeof process.env.PATH === "string" && process.env.PATH.trim().length > 0) {
|
||||||
envPath = path.join(stateDir, ".env");
|
return process.env.PATH;
|
||||||
env = {
|
}
|
||||||
|
return "/usr/bin:/bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFinding(
|
||||||
|
report: Awaited<ReturnType<typeof runSecretsAudit>>,
|
||||||
|
predicate: (entry: { code: string; file: string }) => boolean,
|
||||||
|
): boolean {
|
||||||
|
return report.findings.some((entry) => predicate(entry as { code: string; file: string }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAuditFixture(): Promise<AuditFixture> {
|
||||||
|
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-audit-"));
|
||||||
|
const stateDir = path.join(rootDir, ".openclaw");
|
||||||
|
const configPath = path.join(stateDir, "openclaw.json");
|
||||||
|
const authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
|
||||||
|
const authJsonPath = path.join(stateDir, "agents", "main", "agent", "auth.json");
|
||||||
|
const envPath = path.join(stateDir, ".env");
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||||
|
await fs.mkdir(path.dirname(authStorePath), { recursive: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootDir,
|
||||||
|
stateDir,
|
||||||
|
configPath,
|
||||||
|
authStorePath,
|
||||||
|
authJsonPath,
|
||||||
|
envPath,
|
||||||
|
env: {
|
||||||
OPENCLAW_STATE_DIR: stateDir,
|
OPENCLAW_STATE_DIR: stateDir,
|
||||||
OPENCLAW_CONFIG_PATH: configPath,
|
OPENCLAW_CONFIG_PATH: configPath,
|
||||||
OPENAI_API_KEY: "env-openai-key",
|
OPENAI_API_KEY: "env-openai-key",
|
||||||
...(typeof process.env.PATH === "string" && process.env.PATH.trim().length > 0
|
PATH: resolveRuntimePathEnv(),
|
||||||
? { PATH: process.env.PATH }
|
},
|
||||||
: { PATH: "/usr/bin:/bin" }),
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
async function seedAuditFixture(fixture: AuditFixture): Promise<void> {
|
||||||
await fs.mkdir(path.dirname(authStorePath), { recursive: true });
|
const seededProvider = {
|
||||||
await fs.writeFile(
|
openai: {
|
||||||
configPath,
|
baseUrl: "https://api.openai.com/v1",
|
||||||
`${JSON.stringify(
|
api: "openai-completions",
|
||||||
{
|
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||||
models: {
|
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||||
providers: {
|
},
|
||||||
openai: {
|
};
|
||||||
baseUrl: "https://api.openai.com/v1",
|
const seededProfiles = new Map<string, Record<string, string>>([
|
||||||
api: "openai-completions",
|
[
|
||||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
"openai:default",
|
||||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
{
|
||||||
},
|
type: "api_key",
|
||||||
},
|
provider: "openai",
|
||||||
},
|
key: "sk-openai-plaintext",
|
||||||
},
|
},
|
||||||
null,
|
],
|
||||||
2,
|
]);
|
||||||
)}\n`,
|
await writeJsonFile(fixture.configPath, {
|
||||||
"utf8",
|
models: { providers: seededProvider },
|
||||||
);
|
});
|
||||||
await fs.writeFile(
|
await writeJsonFile(fixture.authStorePath, {
|
||||||
authStorePath,
|
version: 1,
|
||||||
`${JSON.stringify(
|
profiles: Object.fromEntries(seededProfiles),
|
||||||
{
|
});
|
||||||
version: 1,
|
await fs.writeFile(fixture.envPath, "OPENAI_API_KEY=sk-openai-plaintext\n", "utf8");
|
||||||
profiles: {
|
}
|
||||||
"openai:default": {
|
|
||||||
type: "api_key",
|
describe("secrets audit", () => {
|
||||||
provider: "openai",
|
let fixture: AuditFixture;
|
||||||
key: "sk-openai-plaintext",
|
|
||||||
},
|
beforeEach(async () => {
|
||||||
},
|
fixture = await createAuditFixture();
|
||||||
},
|
await seedAuditFixture(fixture);
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
await fs.writeFile(envPath, "OPENAI_API_KEY=sk-openai-plaintext\n", "utf8");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await fs.rm(rootDir, { recursive: true, force: true });
|
await fs.rm(fixture.rootDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports plaintext + shadowing findings", async () => {
|
it("reports plaintext + shadowing findings", async () => {
|
||||||
const report = await runSecretsAudit({ env });
|
const report = await runSecretsAudit({ env: fixture.env });
|
||||||
expect(report.status).toBe("findings");
|
expect(report.status).toBe("findings");
|
||||||
expect(report.summary.plaintextCount).toBeGreaterThan(0);
|
expect(report.summary.plaintextCount).toBeGreaterThan(0);
|
||||||
expect(report.summary.shadowedRefCount).toBeGreaterThan(0);
|
expect(report.summary.shadowedRefCount).toBeGreaterThan(0);
|
||||||
expect(report.findings.some((entry) => entry.code === "REF_SHADOWED")).toBe(true);
|
expect(hasFinding(report, (entry) => entry.code === "REF_SHADOWED")).toBe(true);
|
||||||
expect(report.findings.some((entry) => entry.code === "PLAINTEXT_FOUND")).toBe(true);
|
expect(hasFinding(report, (entry) => entry.code === "PLAINTEXT_FOUND")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not mutate legacy auth.json during audit", async () => {
|
it("does not mutate legacy auth.json during audit", async () => {
|
||||||
await fs.rm(authStorePath, { force: true });
|
await fs.rm(fixture.authStorePath, { force: true });
|
||||||
await fs.writeFile(
|
await writeJsonFile(fixture.authJsonPath, {
|
||||||
authJsonPath,
|
openai: {
|
||||||
`${JSON.stringify(
|
type: "api_key",
|
||||||
{
|
key: "sk-legacy-auth-json",
|
||||||
openai: {
|
},
|
||||||
type: "api_key",
|
});
|
||||||
key: "sk-legacy-auth-json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const report = await runSecretsAudit({ env });
|
const report = await runSecretsAudit({ env: fixture.env });
|
||||||
expect(report.findings.some((entry) => entry.code === "LEGACY_RESIDUE")).toBe(true);
|
expect(hasFinding(report, (entry) => entry.code === "LEGACY_RESIDUE")).toBe(true);
|
||||||
await expect(fs.stat(authJsonPath)).resolves.toBeTruthy();
|
await expect(fs.stat(fixture.authJsonPath)).resolves.toBeTruthy();
|
||||||
await expect(fs.stat(authStorePath)).rejects.toMatchObject({ code: "ENOENT" });
|
await expect(fs.stat(fixture.authStorePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports malformed sidecar JSON as findings instead of crashing", async () => {
|
it("reports malformed sidecar JSON as findings instead of crashing", async () => {
|
||||||
await fs.writeFile(authStorePath, "{invalid-json", "utf8");
|
await fs.writeFile(fixture.authStorePath, "{invalid-json", "utf8");
|
||||||
await fs.writeFile(authJsonPath, "{invalid-json", "utf8");
|
await fs.writeFile(fixture.authJsonPath, "{invalid-json", "utf8");
|
||||||
|
|
||||||
const report = await runSecretsAudit({ env });
|
const report = await runSecretsAudit({ env: fixture.env });
|
||||||
expect(report.findings.some((entry) => entry.file === authStorePath)).toBe(true);
|
expect(hasFinding(report, (entry) => entry.file === fixture.authStorePath)).toBe(true);
|
||||||
expect(report.findings.some((entry) => entry.file === authJsonPath)).toBe(true);
|
expect(hasFinding(report, (entry) => entry.file === fixture.authJsonPath)).toBe(true);
|
||||||
expect(report.findings.some((entry) => entry.code === "REF_UNRESOLVED")).toBe(true);
|
expect(hasFinding(report, (entry) => entry.code === "REF_UNRESOLVED")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("batches ref resolution per provider during audit", async () => {
|
it("batches ref resolution per provider during audit", async () => {
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const execLogPath = path.join(rootDir, "exec-calls.log");
|
const execLogPath = path.join(fixture.rootDir, "exec-calls.log");
|
||||||
const execScriptPath = path.join(rootDir, "resolver.mjs");
|
const execScriptPath = path.join(fixture.rootDir, "resolver.mjs");
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
execScriptPath,
|
execScriptPath,
|
||||||
[
|
[
|
||||||
@@ -137,47 +153,39 @@ describe("secrets audit", () => {
|
|||||||
{ encoding: "utf8", mode: 0o700 },
|
{ encoding: "utf8", mode: 0o700 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await fs.writeFile(
|
await writeJsonFile(fixture.configPath, {
|
||||||
configPath,
|
secrets: {
|
||||||
`${JSON.stringify(
|
providers: {
|
||||||
{
|
execmain: {
|
||||||
secrets: {
|
source: "exec",
|
||||||
providers: {
|
command: execScriptPath,
|
||||||
execmain: {
|
jsonOnly: true,
|
||||||
source: "exec",
|
timeoutMs: 20_000,
|
||||||
command: execScriptPath,
|
noOutputTimeoutMs: 10_000,
|
||||||
jsonOnly: true,
|
|
||||||
timeoutMs: 20_000,
|
|
||||||
noOutputTimeoutMs: 10_000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
models: {
|
|
||||||
providers: {
|
|
||||||
openai: {
|
|
||||||
baseUrl: "https://api.openai.com/v1",
|
|
||||||
api: "openai-completions",
|
|
||||||
apiKey: { source: "exec", provider: "execmain", id: "providers/openai/apiKey" },
|
|
||||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
|
||||||
},
|
|
||||||
moonshot: {
|
|
||||||
baseUrl: "https://api.moonshot.cn/v1",
|
|
||||||
api: "openai-completions",
|
|
||||||
apiKey: { source: "exec", provider: "execmain", id: "providers/moonshot/apiKey" },
|
|
||||||
models: [{ id: "moonshot-v1-8k", name: "moonshot-v1-8k" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
null,
|
},
|
||||||
2,
|
models: {
|
||||||
)}\n`,
|
providers: {
|
||||||
"utf8",
|
openai: {
|
||||||
);
|
baseUrl: "https://api.openai.com/v1",
|
||||||
await fs.rm(authStorePath, { force: true });
|
api: "openai-completions",
|
||||||
await fs.writeFile(envPath, "", "utf8");
|
apiKey: { source: "exec", provider: "execmain", id: "providers/openai/apiKey" },
|
||||||
|
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||||
|
},
|
||||||
|
moonshot: {
|
||||||
|
baseUrl: "https://api.moonshot.cn/v1",
|
||||||
|
api: "openai-completions",
|
||||||
|
apiKey: { source: "exec", provider: "execmain", id: "providers/moonshot/apiKey" },
|
||||||
|
models: [{ id: "moonshot-v1-8k", name: "moonshot-v1-8k" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fs.rm(fixture.authStorePath, { force: true });
|
||||||
|
await fs.writeFile(fixture.envPath, "", "utf8");
|
||||||
|
|
||||||
const report = await runSecretsAudit({ env });
|
const report = await runSecretsAudit({ env: fixture.env });
|
||||||
expect(report.summary.unresolvedRefCount).toBe(0);
|
expect(report.summary.unresolvedRefCount).toBe(0);
|
||||||
|
|
||||||
const callLog = await fs.readFile(execLogPath, "utf8");
|
const callLog = await fs.readFile(execLogPath, "utf8");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { resolveSecretRefString, resolveSecretRefValue } from "./resolve.js";
|
import { resolveSecretRefString, resolveSecretRefValue } from "./resolve.js";
|
||||||
|
|
||||||
@@ -12,28 +12,92 @@ async function writeSecureFile(filePath: string, content: string, mode = 0o600):
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("secret ref resolver", () => {
|
describe("secret ref resolver", () => {
|
||||||
let fixtureRoot = "";
|
const cleanupRoots: string[] = [];
|
||||||
let caseId = 0;
|
const execRef = { source: "exec", provider: "execmain", id: "openai/api-key" } as const;
|
||||||
|
const fileRef = { source: "file", provider: "filemain", id: "/providers/openai/apiKey" } as const;
|
||||||
|
|
||||||
const createCaseDir = async (label: string): Promise<string> => {
|
function isWindows(): boolean {
|
||||||
const dir = path.join(fixtureRoot, `${label}-${caseId++}`);
|
return process.platform === "win32";
|
||||||
await fs.mkdir(dir, { recursive: true });
|
}
|
||||||
return dir;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
async function createTempRoot(prefix: string): Promise<string> {
|
||||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
});
|
cleanupRoots.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProviderConfig(
|
||||||
|
providerId: string,
|
||||||
|
provider: Record<string, unknown>,
|
||||||
|
): OpenClawConfig {
|
||||||
|
return {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
[providerId]: provider,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWithProvider(params: {
|
||||||
|
ref: Parameters<typeof resolveSecretRefString>[0];
|
||||||
|
providerId: string;
|
||||||
|
provider: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
return await resolveSecretRefString(params.ref, {
|
||||||
|
config: createProviderConfig(params.providerId, params.provider),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExecProvider(
|
||||||
|
command: string,
|
||||||
|
overrides?: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
source: "exec",
|
||||||
|
command,
|
||||||
|
passEnv: ["PATH"],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectExecResolveRejects(
|
||||||
|
provider: Record<string, unknown>,
|
||||||
|
message: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
resolveWithProvider({
|
||||||
|
ref: execRef,
|
||||||
|
providerId: "execmain",
|
||||||
|
provider,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSymlinkedPlainExecCommand(
|
||||||
|
root: string,
|
||||||
|
targetRoot = root,
|
||||||
|
): Promise<{ scriptPath: string; symlinkPath: string }> {
|
||||||
|
const scriptPath = path.join(targetRoot, "resolver-target.mjs");
|
||||||
|
const symlinkPath = path.join(root, "resolver-link.mjs");
|
||||||
|
await writeSecureFile(
|
||||||
|
scriptPath,
|
||||||
|
["#!/usr/bin/env node", "process.stdout.write('plain-secret');"].join("\n"),
|
||||||
|
0o700,
|
||||||
|
);
|
||||||
|
await fs.symlink(scriptPath, symlinkPath);
|
||||||
|
return { scriptPath, symlinkPath };
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
while (cleanupRoots.length > 0) {
|
||||||
|
const root = cleanupRoots.pop();
|
||||||
afterAll(async () => {
|
if (!root) {
|
||||||
if (!fixtureRoot) {
|
continue;
|
||||||
return;
|
}
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves env refs via implicit default env provider", async () => {
|
it("resolves env refs via implicit default env provider", async () => {
|
||||||
@@ -49,10 +113,10 @@ describe("secret ref resolver", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves file refs in json mode", async () => {
|
it("resolves file refs in json mode", async () => {
|
||||||
if (process.platform === "win32") {
|
if (isWindows()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const root = await createCaseDir("file");
|
const root = await createTempRoot("openclaw-secrets-resolve-file-");
|
||||||
const filePath = path.join(root, "secrets.json");
|
const filePath = path.join(root, "secrets.json");
|
||||||
await writeSecureFile(
|
await writeSecureFile(
|
||||||
filePath,
|
filePath,
|
||||||
@@ -65,30 +129,23 @@ describe("secret ref resolver", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const value = await resolveSecretRefString(
|
const value = await resolveWithProvider({
|
||||||
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" },
|
ref: fileRef,
|
||||||
{
|
providerId: "filemain",
|
||||||
config: {
|
provider: {
|
||||||
secrets: {
|
source: "file",
|
||||||
providers: {
|
path: filePath,
|
||||||
filemain: {
|
mode: "json",
|
||||||
source: "file",
|
|
||||||
path: filePath,
|
|
||||||
mode: "json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
expect(value).toBe("sk-file-value");
|
expect(value).toBe("sk-file-value");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves exec refs with protocolVersion 1 response", async () => {
|
it("resolves exec refs with protocolVersion 1 response", async () => {
|
||||||
if (process.platform === "win32") {
|
if (isWindows()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const root = await createCaseDir("exec");
|
const root = await createTempRoot("openclaw-secrets-resolve-exec-");
|
||||||
const scriptPath = path.join(root, "resolver.mjs");
|
const scriptPath = path.join(root, "resolver.mjs");
|
||||||
await writeSecureFile(
|
await writeSecureFile(
|
||||||
scriptPath,
|
scriptPath,
|
||||||
@@ -102,30 +159,23 @@ describe("secret ref resolver", () => {
|
|||||||
0o700,
|
0o700,
|
||||||
);
|
);
|
||||||
|
|
||||||
const value = await resolveSecretRefString(
|
const value = await resolveWithProvider({
|
||||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
ref: execRef,
|
||||||
{
|
providerId: "execmain",
|
||||||
config: {
|
provider: {
|
||||||
secrets: {
|
source: "exec",
|
||||||
providers: {
|
command: scriptPath,
|
||||||
execmain: {
|
passEnv: ["PATH"],
|
||||||
source: "exec",
|
|
||||||
command: scriptPath,
|
|
||||||
passEnv: ["PATH"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
expect(value).toBe("value:openai/api-key");
|
expect(value).toBe("value:openai/api-key");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports non-JSON single-value exec output when jsonOnly is false", async () => {
|
it("supports non-JSON single-value exec output when jsonOnly is false", async () => {
|
||||||
if (process.platform === "win32") {
|
if (isWindows()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const root = await createCaseDir("exec-plain");
|
const root = await createTempRoot("openclaw-secrets-resolve-exec-plain-");
|
||||||
const scriptPath = path.join(root, "resolver-plain.mjs");
|
const scriptPath = path.join(root, "resolver-plain.mjs");
|
||||||
await writeSecureFile(
|
await writeSecureFile(
|
||||||
scriptPath,
|
scriptPath,
|
||||||
@@ -133,104 +183,57 @@ describe("secret ref resolver", () => {
|
|||||||
0o700,
|
0o700,
|
||||||
);
|
);
|
||||||
|
|
||||||
const value = await resolveSecretRefString(
|
const value = await resolveWithProvider({
|
||||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
ref: execRef,
|
||||||
{
|
providerId: "execmain",
|
||||||
config: {
|
provider: {
|
||||||
secrets: {
|
source: "exec",
|
||||||
providers: {
|
command: scriptPath,
|
||||||
execmain: {
|
passEnv: ["PATH"],
|
||||||
source: "exec",
|
jsonOnly: false,
|
||||||
command: scriptPath,
|
|
||||||
passEnv: ["PATH"],
|
|
||||||
jsonOnly: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
expect(value).toBe("plain-secret");
|
expect(value).toBe("plain-secret");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects symlink command paths unless allowSymlinkCommand is enabled", async () => {
|
it("rejects symlink command paths unless allowSymlinkCommand is enabled", async () => {
|
||||||
if (process.platform === "win32") {
|
if (isWindows()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const root = await createCaseDir("exec-link-reject");
|
const root = await createTempRoot("openclaw-secrets-resolve-exec-link-");
|
||||||
const scriptPath = path.join(root, "resolver-target.mjs");
|
const { symlinkPath } = await createSymlinkedPlainExecCommand(root);
|
||||||
const symlinkPath = path.join(root, "resolver-link.mjs");
|
await expectExecResolveRejects(
|
||||||
await writeSecureFile(
|
createExecProvider(symlinkPath, { jsonOnly: false }),
|
||||||
scriptPath,
|
"must not be a symlink",
|
||||||
["#!/usr/bin/env node", "process.stdout.write('plain-secret');"].join("\n"),
|
|
||||||
0o700,
|
|
||||||
);
|
);
|
||||||
await fs.symlink(scriptPath, symlinkPath);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
resolveSecretRefString(
|
|
||||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
|
||||||
{
|
|
||||||
config: {
|
|
||||||
secrets: {
|
|
||||||
providers: {
|
|
||||||
execmain: {
|
|
||||||
source: "exec",
|
|
||||||
command: symlinkPath,
|
|
||||||
passEnv: ["PATH"],
|
|
||||||
jsonOnly: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).rejects.toThrow("must not be a symlink");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows symlink command paths when allowSymlinkCommand is enabled", async () => {
|
it("allows symlink command paths when allowSymlinkCommand is enabled", async () => {
|
||||||
if (process.platform === "win32") {
|
if (isWindows()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const root = await createCaseDir("exec-link-allow");
|
const root = await createTempRoot("openclaw-secrets-resolve-exec-link-");
|
||||||
const scriptPath = path.join(root, "resolver-target.mjs");
|
const { symlinkPath } = await createSymlinkedPlainExecCommand(root);
|
||||||
const symlinkPath = path.join(root, "resolver-link.mjs");
|
|
||||||
await writeSecureFile(
|
|
||||||
scriptPath,
|
|
||||||
["#!/usr/bin/env node", "process.stdout.write('plain-secret');"].join("\n"),
|
|
||||||
0o700,
|
|
||||||
);
|
|
||||||
await fs.symlink(scriptPath, symlinkPath);
|
|
||||||
const trustedRoot = await fs.realpath(root);
|
const trustedRoot = await fs.realpath(root);
|
||||||
|
|
||||||
const value = await resolveSecretRefString(
|
const value = await resolveWithProvider({
|
||||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
ref: execRef,
|
||||||
{
|
providerId: "execmain",
|
||||||
config: {
|
provider: createExecProvider(symlinkPath, {
|
||||||
secrets: {
|
jsonOnly: false,
|
||||||
providers: {
|
allowSymlinkCommand: true,
|
||||||
execmain: {
|
trustedDirs: [trustedRoot],
|
||||||
source: "exec",
|
}),
|
||||||
command: symlinkPath,
|
});
|
||||||
passEnv: ["PATH"],
|
|
||||||
jsonOnly: false,
|
|
||||||
allowSymlinkCommand: true,
|
|
||||||
trustedDirs: [trustedRoot],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(value).toBe("plain-secret");
|
expect(value).toBe("plain-secret");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles Homebrew-style symlinked exec commands with args only when explicitly allowed", async () => {
|
it("handles Homebrew-style symlinked exec commands with args only when explicitly allowed", async () => {
|
||||||
if (process.platform === "win32") {
|
if (isWindows()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = await createCaseDir("homebrew");
|
const root = await createTempRoot("openclaw-secrets-resolve-homebrew-");
|
||||||
const binDir = path.join(root, "opt", "homebrew", "bin");
|
const binDir = path.join(root, "opt", "homebrew", "bin");
|
||||||
const cellarDir = path.join(root, "opt", "homebrew", "Cellar", "node", "25.0.0", "bin");
|
const cellarDir = path.join(root, "opt", "homebrew", "Cellar", "node", "25.0.0", "bin");
|
||||||
await fs.mkdir(binDir, { recursive: true });
|
await fs.mkdir(binDir, { recursive: true });
|
||||||
@@ -254,89 +257,54 @@ describe("secret ref resolver", () => {
|
|||||||
const trustedRoot = await fs.realpath(root);
|
const trustedRoot = await fs.realpath(root);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
resolveSecretRefString(
|
resolveWithProvider({
|
||||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
ref: execRef,
|
||||||
{
|
providerId: "execmain",
|
||||||
config: {
|
provider: {
|
||||||
secrets: {
|
source: "exec",
|
||||||
providers: {
|
command: symlinkCommand,
|
||||||
execmain: {
|
args: ["brew"],
|
||||||
source: "exec",
|
passEnv: ["PATH"],
|
||||||
command: symlinkCommand,
|
|
||||||
args: ["brew"],
|
|
||||||
passEnv: ["PATH"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
),
|
}),
|
||||||
).rejects.toThrow("must not be a symlink");
|
).rejects.toThrow("must not be a symlink");
|
||||||
|
|
||||||
const value = await resolveSecretRefString(
|
const value = await resolveWithProvider({
|
||||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
ref: execRef,
|
||||||
{
|
providerId: "execmain",
|
||||||
config: {
|
provider: {
|
||||||
secrets: {
|
source: "exec",
|
||||||
providers: {
|
command: symlinkCommand,
|
||||||
execmain: {
|
args: ["brew"],
|
||||||
source: "exec",
|
allowSymlinkCommand: true,
|
||||||
command: symlinkCommand,
|
trustedDirs: [trustedRoot],
|
||||||
args: ["brew"],
|
|
||||||
allowSymlinkCommand: true,
|
|
||||||
trustedDirs: [trustedRoot],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
expect(value).toBe("brew:openai/api-key");
|
expect(value).toBe("brew:openai/api-key");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks trustedDirs against resolved symlink target", async () => {
|
it("checks trustedDirs against resolved symlink target", async () => {
|
||||||
if (process.platform === "win32") {
|
if (isWindows()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const root = await createCaseDir("exec-link-trusted");
|
const root = await createTempRoot("openclaw-secrets-resolve-exec-link-");
|
||||||
const outside = await createCaseDir("exec-outside");
|
const outside = await createTempRoot("openclaw-secrets-resolve-exec-out-");
|
||||||
const scriptPath = path.join(outside, "resolver-target.mjs");
|
const { symlinkPath } = await createSymlinkedPlainExecCommand(root, outside);
|
||||||
const symlinkPath = path.join(root, "resolver-link.mjs");
|
await expectExecResolveRejects(
|
||||||
await writeSecureFile(
|
createExecProvider(symlinkPath, {
|
||||||
scriptPath,
|
jsonOnly: false,
|
||||||
["#!/usr/bin/env node", "process.stdout.write('plain-secret');"].join("\n"),
|
allowSymlinkCommand: true,
|
||||||
0o700,
|
trustedDirs: [root],
|
||||||
|
}),
|
||||||
|
"outside trustedDirs",
|
||||||
);
|
);
|
||||||
await fs.symlink(scriptPath, symlinkPath);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
resolveSecretRefString(
|
|
||||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
|
||||||
{
|
|
||||||
config: {
|
|
||||||
secrets: {
|
|
||||||
providers: {
|
|
||||||
execmain: {
|
|
||||||
source: "exec",
|
|
||||||
command: symlinkPath,
|
|
||||||
passEnv: ["PATH"],
|
|
||||||
jsonOnly: false,
|
|
||||||
allowSymlinkCommand: true,
|
|
||||||
trustedDirs: [root],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).rejects.toThrow("outside trustedDirs");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects exec refs when protocolVersion is not 1", async () => {
|
it("rejects exec refs when protocolVersion is not 1", async () => {
|
||||||
if (process.platform === "win32") {
|
if (isWindows()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const root = await createCaseDir("exec-protocol");
|
const root = await createTempRoot("openclaw-secrets-resolve-exec-protocol-");
|
||||||
const scriptPath = path.join(root, "resolver-protocol.mjs");
|
const scriptPath = path.join(root, "resolver-protocol.mjs");
|
||||||
await writeSecureFile(
|
await writeSecureFile(
|
||||||
scriptPath,
|
scriptPath,
|
||||||
@@ -347,31 +315,14 @@ describe("secret ref resolver", () => {
|
|||||||
0o700,
|
0o700,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expectExecResolveRejects(createExecProvider(scriptPath), "protocolVersion must be 1");
|
||||||
resolveSecretRefString(
|
|
||||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
|
||||||
{
|
|
||||||
config: {
|
|
||||||
secrets: {
|
|
||||||
providers: {
|
|
||||||
execmain: {
|
|
||||||
source: "exec",
|
|
||||||
command: scriptPath,
|
|
||||||
passEnv: ["PATH"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).rejects.toThrow("protocolVersion must be 1");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects exec refs when response omits requested id", async () => {
|
it("rejects exec refs when response omits requested id", async () => {
|
||||||
if (process.platform === "win32") {
|
if (isWindows()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const root = await createCaseDir("exec-missing-id");
|
const root = await createTempRoot("openclaw-secrets-resolve-exec-id-");
|
||||||
const scriptPath = path.join(root, "resolver-missing-id.mjs");
|
const scriptPath = path.join(root, "resolver-missing-id.mjs");
|
||||||
await writeSecureFile(
|
await writeSecureFile(
|
||||||
scriptPath,
|
scriptPath,
|
||||||
@@ -382,31 +333,17 @@ describe("secret ref resolver", () => {
|
|||||||
0o700,
|
0o700,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expectExecResolveRejects(
|
||||||
resolveSecretRefString(
|
createExecProvider(scriptPath),
|
||||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
'response missing id "openai/api-key"',
|
||||||
{
|
);
|
||||||
config: {
|
|
||||||
secrets: {
|
|
||||||
providers: {
|
|
||||||
execmain: {
|
|
||||||
source: "exec",
|
|
||||||
command: scriptPath,
|
|
||||||
passEnv: ["PATH"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).rejects.toThrow('response missing id "openai/api-key"');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects exec refs with invalid JSON when jsonOnly is true", async () => {
|
it("rejects exec refs with invalid JSON when jsonOnly is true", async () => {
|
||||||
if (process.platform === "win32") {
|
if (isWindows()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const root = await createCaseDir("exec-invalid-json");
|
const root = await createTempRoot("openclaw-secrets-resolve-exec-json-");
|
||||||
const scriptPath = path.join(root, "resolver-invalid-json.mjs");
|
const scriptPath = path.join(root, "resolver-invalid-json.mjs");
|
||||||
await writeSecureFile(
|
await writeSecureFile(
|
||||||
scriptPath,
|
scriptPath,
|
||||||
@@ -415,58 +352,44 @@ describe("secret ref resolver", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
resolveSecretRefString(
|
resolveWithProvider({
|
||||||
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
ref: execRef,
|
||||||
{
|
providerId: "execmain",
|
||||||
config: {
|
provider: {
|
||||||
secrets: {
|
source: "exec",
|
||||||
providers: {
|
command: scriptPath,
|
||||||
execmain: {
|
passEnv: ["PATH"],
|
||||||
source: "exec",
|
jsonOnly: true,
|
||||||
command: scriptPath,
|
|
||||||
passEnv: ["PATH"],
|
|
||||||
jsonOnly: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
),
|
}),
|
||||||
).rejects.toThrow("returned invalid JSON");
|
).rejects.toThrow("returned invalid JSON");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports file singleValue mode with id=value", async () => {
|
it("supports file singleValue mode with id=value", async () => {
|
||||||
if (process.platform === "win32") {
|
if (isWindows()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const root = await createCaseDir("file-single-value");
|
const root = await createTempRoot("openclaw-secrets-resolve-single-value-");
|
||||||
const filePath = path.join(root, "token.txt");
|
const filePath = path.join(root, "token.txt");
|
||||||
await writeSecureFile(filePath, "raw-token-value\n");
|
await writeSecureFile(filePath, "raw-token-value\n");
|
||||||
|
|
||||||
const value = await resolveSecretRefString(
|
const value = await resolveWithProvider({
|
||||||
{ source: "file", provider: "rawfile", id: "value" },
|
ref: { source: "file", provider: "rawfile", id: "value" },
|
||||||
{
|
providerId: "rawfile",
|
||||||
config: {
|
provider: {
|
||||||
secrets: {
|
source: "file",
|
||||||
providers: {
|
path: filePath,
|
||||||
rawfile: {
|
mode: "singleValue",
|
||||||
source: "file",
|
|
||||||
path: filePath,
|
|
||||||
mode: "singleValue",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
expect(value).toBe("raw-token-value");
|
expect(value).toBe("raw-token-value");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("times out file provider reads when timeoutMs elapses", async () => {
|
it("times out file provider reads when timeoutMs elapses", async () => {
|
||||||
if (process.platform === "win32") {
|
if (isWindows()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const root = await createCaseDir("file-timeout");
|
const root = await createTempRoot("openclaw-secrets-resolve-timeout-");
|
||||||
const filePath = path.join(root, "secrets.json");
|
const filePath = path.join(root, "secrets.json");
|
||||||
await writeSecureFile(
|
await writeSecureFile(
|
||||||
filePath,
|
filePath,
|
||||||
@@ -491,23 +414,16 @@ describe("secret ref resolver", () => {
|
|||||||
}) as typeof fs.readFile);
|
}) as typeof fs.readFile);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
resolveSecretRefString(
|
resolveWithProvider({
|
||||||
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" },
|
ref: fileRef,
|
||||||
{
|
providerId: "filemain",
|
||||||
config: {
|
provider: {
|
||||||
secrets: {
|
source: "file",
|
||||||
providers: {
|
path: filePath,
|
||||||
filemain: {
|
mode: "json",
|
||||||
source: "file",
|
timeoutMs: 5,
|
||||||
path: filePath,
|
|
||||||
mode: "json",
|
|
||||||
timeoutMs: 5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
),
|
}),
|
||||||
).rejects.toThrow('File provider "filemain" timed out');
|
).rejects.toThrow('File provider "filemain" timed out');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -516,15 +432,7 @@ describe("secret ref resolver", () => {
|
|||||||
resolveSecretRefValue(
|
resolveSecretRefValue(
|
||||||
{ source: "exec", provider: "default", id: "abc" },
|
{ source: "exec", provider: "default", id: "abc" },
|
||||||
{
|
{
|
||||||
config: {
|
config: createProviderConfig("default", { source: "env" }),
|
||||||
secrets: {
|
|
||||||
providers: {
|
|
||||||
default: {
|
|
||||||
source: "env",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
).rejects.toThrow('has source "env" but ref requests "exec"');
|
).rejects.toThrow('has source "env" but ref requests "exec"');
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { SessionEntry } from "../config/sessions.js";
|
import type { SessionEntry } from "../config/sessions.js";
|
||||||
import { applyModelOverrideToSessionEntry } from "./model-overrides.js";
|
import { applyModelOverrideToSessionEntry } from "./model-overrides.js";
|
||||||
|
|
||||||
|
function applyOpenAiSelection(entry: SessionEntry) {
|
||||||
|
return applyModelOverrideToSessionEntry({
|
||||||
|
entry,
|
||||||
|
selection: {
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-5.2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectRuntimeModelFieldsCleared(entry: SessionEntry, before: number) {
|
||||||
|
expect(entry.providerOverride).toBe("openai");
|
||||||
|
expect(entry.modelOverride).toBe("gpt-5.2");
|
||||||
|
expect(entry.modelProvider).toBeUndefined();
|
||||||
|
expect(entry.model).toBeUndefined();
|
||||||
|
expect((entry.updatedAt ?? 0) > before).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
describe("applyModelOverrideToSessionEntry", () => {
|
describe("applyModelOverrideToSessionEntry", () => {
|
||||||
it("clears stale runtime model fields when switching overrides", () => {
|
it("clears stale runtime model fields when switching overrides", () => {
|
||||||
const before = Date.now() - 5_000;
|
const before = Date.now() - 5_000;
|
||||||
@@ -17,23 +35,13 @@ describe("applyModelOverrideToSessionEntry", () => {
|
|||||||
fallbackNoticeReason: "provider temporary failure",
|
fallbackNoticeReason: "provider temporary failure",
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = applyModelOverrideToSessionEntry({
|
const result = applyOpenAiSelection(entry);
|
||||||
entry,
|
|
||||||
selection: {
|
|
||||||
provider: "openai",
|
|
||||||
model: "gpt-5.2",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.updated).toBe(true);
|
expect(result.updated).toBe(true);
|
||||||
expect(entry.providerOverride).toBe("openai");
|
expectRuntimeModelFieldsCleared(entry, before);
|
||||||
expect(entry.modelOverride).toBe("gpt-5.2");
|
|
||||||
expect(entry.modelProvider).toBeUndefined();
|
|
||||||
expect(entry.model).toBeUndefined();
|
|
||||||
expect(entry.fallbackNoticeSelectedModel).toBeUndefined();
|
expect(entry.fallbackNoticeSelectedModel).toBeUndefined();
|
||||||
expect(entry.fallbackNoticeActiveModel).toBeUndefined();
|
expect(entry.fallbackNoticeActiveModel).toBeUndefined();
|
||||||
expect(entry.fallbackNoticeReason).toBeUndefined();
|
expect(entry.fallbackNoticeReason).toBeUndefined();
|
||||||
expect((entry.updatedAt ?? 0) > before).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears stale runtime model fields even when override selection is unchanged", () => {
|
it("clears stale runtime model fields even when override selection is unchanged", () => {
|
||||||
@@ -47,20 +55,10 @@ describe("applyModelOverrideToSessionEntry", () => {
|
|||||||
modelOverride: "gpt-5.2",
|
modelOverride: "gpt-5.2",
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = applyModelOverrideToSessionEntry({
|
const result = applyOpenAiSelection(entry);
|
||||||
entry,
|
|
||||||
selection: {
|
|
||||||
provider: "openai",
|
|
||||||
model: "gpt-5.2",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.updated).toBe(true);
|
expect(result.updated).toBe(true);
|
||||||
expect(entry.providerOverride).toBe("openai");
|
expectRuntimeModelFieldsCleared(entry, before);
|
||||||
expect(entry.modelOverride).toBe("gpt-5.2");
|
|
||||||
expect(entry.modelProvider).toBeUndefined();
|
|
||||||
expect(entry.model).toBeUndefined();
|
|
||||||
expect((entry.updatedAt ?? 0) > before).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retains aligned runtime model fields when selection and runtime already match", () => {
|
it("retains aligned runtime model fields when selection and runtime already match", () => {
|
||||||
|
|||||||
@@ -21,6 +21,45 @@ function createClient() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeSlackFileInfo(overrides?: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
id: "F123",
|
||||||
|
name: "image.png",
|
||||||
|
mimetype: "image/png",
|
||||||
|
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeResolvedSlackMedia() {
|
||||||
|
return {
|
||||||
|
path: "/tmp/image.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
placeholder: "[Slack file: image.png]",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectNoMediaDownload(result: Awaited<ReturnType<typeof downloadSlackFile>>) {
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(resolveSlackMedia).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectResolveSlackMediaCalledWithDefaults() {
|
||||||
|
expect(resolveSlackMedia).toHaveBeenCalledWith({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
id: "F123",
|
||||||
|
name: "image.png",
|
||||||
|
mimetype: "image/png",
|
||||||
|
url_private: undefined,
|
||||||
|
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
token: "xoxb-test",
|
||||||
|
maxBytes: 1024,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("downloadSlackFile", () => {
|
describe("downloadSlackFile", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resolveSlackMedia.mockReset();
|
resolveSlackMedia.mockReset();
|
||||||
@@ -48,20 +87,9 @@ describe("downloadSlackFile", () => {
|
|||||||
it("downloads via resolveSlackMedia using fresh files.info metadata", async () => {
|
it("downloads via resolveSlackMedia using fresh files.info metadata", async () => {
|
||||||
const client = createClient();
|
const client = createClient();
|
||||||
client.files.info.mockResolvedValueOnce({
|
client.files.info.mockResolvedValueOnce({
|
||||||
file: {
|
file: makeSlackFileInfo(),
|
||||||
id: "F123",
|
|
||||||
name: "image.png",
|
|
||||||
mimetype: "image/png",
|
|
||||||
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
resolveSlackMedia.mockResolvedValueOnce([
|
resolveSlackMedia.mockResolvedValueOnce([makeResolvedSlackMedia()]);
|
||||||
{
|
|
||||||
path: "/tmp/image.png",
|
|
||||||
contentType: "image/png",
|
|
||||||
placeholder: "[Slack file: image.png]",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await downloadSlackFile("F123", {
|
const result = await downloadSlackFile("F123", {
|
||||||
client,
|
client,
|
||||||
@@ -70,36 +98,14 @@ describe("downloadSlackFile", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(client.files.info).toHaveBeenCalledWith({ file: "F123" });
|
expect(client.files.info).toHaveBeenCalledWith({ file: "F123" });
|
||||||
expect(resolveSlackMedia).toHaveBeenCalledWith({
|
expectResolveSlackMediaCalledWithDefaults();
|
||||||
files: [
|
expect(result).toEqual(makeResolvedSlackMedia());
|
||||||
{
|
|
||||||
id: "F123",
|
|
||||||
name: "image.png",
|
|
||||||
mimetype: "image/png",
|
|
||||||
url_private: undefined,
|
|
||||||
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
token: "xoxb-test",
|
|
||||||
maxBytes: 1024,
|
|
||||||
});
|
|
||||||
expect(result).toEqual({
|
|
||||||
path: "/tmp/image.png",
|
|
||||||
contentType: "image/png",
|
|
||||||
placeholder: "[Slack file: image.png]",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when channel scope definitely mismatches file shares", async () => {
|
it("returns null when channel scope definitely mismatches file shares", async () => {
|
||||||
const client = createClient();
|
const client = createClient();
|
||||||
client.files.info.mockResolvedValueOnce({
|
client.files.info.mockResolvedValueOnce({
|
||||||
file: {
|
file: makeSlackFileInfo({ channels: ["C999"] }),
|
||||||
id: "F123",
|
|
||||||
name: "image.png",
|
|
||||||
mimetype: "image/png",
|
|
||||||
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
|
||||||
channels: ["C999"],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await downloadSlackFile("F123", {
|
const result = await downloadSlackFile("F123", {
|
||||||
@@ -109,24 +115,19 @@ describe("downloadSlackFile", () => {
|
|||||||
channelId: "C123",
|
channelId: "C123",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expectNoMediaDownload(result);
|
||||||
expect(resolveSlackMedia).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when thread scope definitely mismatches file share thread", async () => {
|
it("returns null when thread scope definitely mismatches file share thread", async () => {
|
||||||
const client = createClient();
|
const client = createClient();
|
||||||
client.files.info.mockResolvedValueOnce({
|
client.files.info.mockResolvedValueOnce({
|
||||||
file: {
|
file: makeSlackFileInfo({
|
||||||
id: "F123",
|
|
||||||
name: "image.png",
|
|
||||||
mimetype: "image/png",
|
|
||||||
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
|
||||||
shares: {
|
shares: {
|
||||||
private: {
|
private: {
|
||||||
C123: [{ ts: "111.111", thread_ts: "111.111" }],
|
C123: [{ ts: "111.111", thread_ts: "111.111" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await downloadSlackFile("F123", {
|
const result = await downloadSlackFile("F123", {
|
||||||
@@ -137,27 +138,15 @@ describe("downloadSlackFile", () => {
|
|||||||
threadId: "222.222",
|
threadId: "222.222",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expectNoMediaDownload(result);
|
||||||
expect(resolveSlackMedia).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps legacy behavior when file metadata does not expose channel/thread shares", async () => {
|
it("keeps legacy behavior when file metadata does not expose channel/thread shares", async () => {
|
||||||
const client = createClient();
|
const client = createClient();
|
||||||
client.files.info.mockResolvedValueOnce({
|
client.files.info.mockResolvedValueOnce({
|
||||||
file: {
|
file: makeSlackFileInfo(),
|
||||||
id: "F123",
|
|
||||||
name: "image.png",
|
|
||||||
mimetype: "image/png",
|
|
||||||
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
resolveSlackMedia.mockResolvedValueOnce([
|
resolveSlackMedia.mockResolvedValueOnce([makeResolvedSlackMedia()]);
|
||||||
{
|
|
||||||
path: "/tmp/image.png",
|
|
||||||
contentType: "image/png",
|
|
||||||
placeholder: "[Slack file: image.png]",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await downloadSlackFile("F123", {
|
const result = await downloadSlackFile("F123", {
|
||||||
client,
|
client,
|
||||||
@@ -167,11 +156,8 @@ describe("downloadSlackFile", () => {
|
|||||||
threadId: "222.222",
|
threadId: "222.222",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual(makeResolvedSlackMedia());
|
||||||
path: "/tmp/image.png",
|
|
||||||
contentType: "image/png",
|
|
||||||
placeholder: "[Slack file: image.png]",
|
|
||||||
});
|
|
||||||
expect(resolveSlackMedia).toHaveBeenCalledTimes(1);
|
expect(resolveSlackMedia).toHaveBeenCalledTimes(1);
|
||||||
|
expectResolveSlackMediaCalledWithDefaults();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,44 +1,35 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { registerSlackMemberEvents } from "./members.js";
|
import { registerSlackMemberEvents } from "./members.js";
|
||||||
import {
|
import {
|
||||||
createSlackSystemEventTestHarness,
|
createSlackSystemEventTestHarness as initSlackHarness,
|
||||||
type SlackSystemEventTestOverrides,
|
type SlackSystemEventTestOverrides as MemberOverrides,
|
||||||
} from "./system-event-test-harness.js";
|
} from "./system-event-test-harness.js";
|
||||||
|
|
||||||
const enqueueSystemEventMock = vi.fn();
|
const memberMocks = vi.hoisted(() => ({
|
||||||
const readAllowFromStoreMock = vi.fn();
|
enqueue: vi.fn(),
|
||||||
|
readAllow: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../../infra/system-events.js", () => ({
|
vi.mock("../../../infra/system-events.js", () => ({
|
||||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
enqueueSystemEvent: memberMocks.enqueue,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../pairing/pairing-store.js", () => ({
|
vi.mock("../../../pairing/pairing-store.js", () => ({
|
||||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
readChannelAllowFromStore: memberMocks.readAllow,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type SlackMemberHandler = (args: {
|
type MemberHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||||
event: Record<string, unknown>;
|
|
||||||
body: unknown;
|
|
||||||
}) => Promise<void>;
|
|
||||||
|
|
||||||
function createMembersContext(params?: {
|
type MemberCaseArgs = {
|
||||||
overrides?: SlackSystemEventTestOverrides;
|
event?: Record<string, unknown>;
|
||||||
|
body?: unknown;
|
||||||
|
overrides?: MemberOverrides;
|
||||||
|
handler?: "joined" | "left";
|
||||||
trackEvent?: () => void;
|
trackEvent?: () => void;
|
||||||
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
||||||
}) {
|
};
|
||||||
const harness = createSlackSystemEventTestHarness(params?.overrides);
|
|
||||||
if (params?.shouldDropMismatchedSlackEvent) {
|
|
||||||
harness.ctx.shouldDropMismatchedSlackEvent = params.shouldDropMismatchedSlackEvent;
|
|
||||||
}
|
|
||||||
registerSlackMemberEvents({ ctx: harness.ctx, trackEvent: params?.trackEvent });
|
|
||||||
return {
|
|
||||||
getJoinedHandler: () =>
|
|
||||||
harness.getHandler("member_joined_channel") as SlackMemberHandler | null,
|
|
||||||
getLeftHandler: () => harness.getHandler("member_left_channel") as SlackMemberHandler | null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMemberEvent(overrides?: { user?: string; channel?: string }) {
|
function makeMemberEvent(overrides?: { channel?: string; user?: string }) {
|
||||||
return {
|
return {
|
||||||
type: "member_joined_channel",
|
type: "member_joined_channel",
|
||||||
user: overrides?.user ?? "U1",
|
user: overrides?.user ?? "U1",
|
||||||
@@ -47,106 +38,90 @@ function makeMemberEvent(overrides?: { user?: string; channel?: string }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMemberHandlers(params: {
|
||||||
|
overrides?: MemberOverrides;
|
||||||
|
trackEvent?: () => void;
|
||||||
|
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
||||||
|
}) {
|
||||||
|
const harness = initSlackHarness(params.overrides);
|
||||||
|
if (params.shouldDropMismatchedSlackEvent) {
|
||||||
|
harness.ctx.shouldDropMismatchedSlackEvent = params.shouldDropMismatchedSlackEvent;
|
||||||
|
}
|
||||||
|
registerSlackMemberEvents({ ctx: harness.ctx, trackEvent: params.trackEvent });
|
||||||
|
return {
|
||||||
|
joined: harness.getHandler("member_joined_channel") as MemberHandler | null,
|
||||||
|
left: harness.getHandler("member_left_channel") as MemberHandler | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMemberCase(args: MemberCaseArgs = {}): Promise<void> {
|
||||||
|
memberMocks.enqueue.mockClear();
|
||||||
|
memberMocks.readAllow.mockReset().mockResolvedValue([]);
|
||||||
|
const handlers = getMemberHandlers({
|
||||||
|
overrides: args.overrides,
|
||||||
|
trackEvent: args.trackEvent,
|
||||||
|
shouldDropMismatchedSlackEvent: args.shouldDropMismatchedSlackEvent,
|
||||||
|
});
|
||||||
|
const key = args.handler ?? "joined";
|
||||||
|
const handler = handlers[key];
|
||||||
|
expect(handler).toBeTruthy();
|
||||||
|
await handler!({
|
||||||
|
event: (args.event ?? makeMemberEvent()) as Record<string, unknown>,
|
||||||
|
body: args.body ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("registerSlackMemberEvents", () => {
|
describe("registerSlackMemberEvents", () => {
|
||||||
it("enqueues DM member events when dmPolicy is open", async () => {
|
it.each([
|
||||||
enqueueSystemEventMock.mockClear();
|
{
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
name: "enqueues DM member events when dmPolicy is open",
|
||||||
const { getJoinedHandler } = createMembersContext({ overrides: { dmPolicy: "open" } });
|
args: { overrides: { dmPolicy: "open" } },
|
||||||
const joinedHandler = getJoinedHandler();
|
calls: 1,
|
||||||
expect(joinedHandler).toBeTruthy();
|
},
|
||||||
|
{
|
||||||
await joinedHandler!({
|
name: "blocks DM member events when dmPolicy is disabled",
|
||||||
event: makeMemberEvent(),
|
args: { overrides: { dmPolicy: "disabled" } },
|
||||||
body: {},
|
calls: 0,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
name: "blocks DM member events for unauthorized senders in allowlist mode",
|
||||||
});
|
args: {
|
||||||
|
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
||||||
it("blocks DM member events when dmPolicy is disabled", async () => {
|
event: makeMemberEvent({ user: "U1" }),
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getJoinedHandler } = createMembersContext({ overrides: { dmPolicy: "disabled" } });
|
|
||||||
const joinedHandler = getJoinedHandler();
|
|
||||||
expect(joinedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await joinedHandler!({
|
|
||||||
event: makeMemberEvent(),
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks DM member events for unauthorized senders in allowlist mode", async () => {
|
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getJoinedHandler } = createMembersContext({
|
|
||||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
|
||||||
});
|
|
||||||
const joinedHandler = getJoinedHandler();
|
|
||||||
expect(joinedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await joinedHandler!({
|
|
||||||
event: makeMemberEvent({ user: "U1" }),
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows DM member events for authorized senders in allowlist mode", async () => {
|
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getLeftHandler } = createMembersContext({
|
|
||||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U1"] },
|
|
||||||
});
|
|
||||||
const leftHandler = getLeftHandler();
|
|
||||||
expect(leftHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await leftHandler!({
|
|
||||||
event: {
|
|
||||||
...makeMemberEvent({ user: "U1" }),
|
|
||||||
type: "member_left_channel",
|
|
||||||
},
|
},
|
||||||
body: {},
|
calls: 0,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
name: "allows DM member events for authorized senders in allowlist mode",
|
||||||
});
|
args: {
|
||||||
|
handler: "left" as const,
|
||||||
it("blocks channel member events for users outside channel users allowlist", async () => {
|
overrides: { dmPolicy: "allowlist", allowFrom: ["U1"] },
|
||||||
enqueueSystemEventMock.mockClear();
|
event: { ...makeMemberEvent({ user: "U1" }), type: "member_left_channel" },
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getJoinedHandler } = createMembersContext({
|
|
||||||
overrides: {
|
|
||||||
dmPolicy: "open",
|
|
||||||
channelType: "channel",
|
|
||||||
channelUsers: ["U_OWNER"],
|
|
||||||
},
|
},
|
||||||
});
|
calls: 1,
|
||||||
const joinedHandler = getJoinedHandler();
|
},
|
||||||
expect(joinedHandler).toBeTruthy();
|
{
|
||||||
|
name: "blocks channel member events for users outside channel users allowlist",
|
||||||
await joinedHandler!({
|
args: {
|
||||||
event: makeMemberEvent({ channel: "C1", user: "U_ATTACKER" }),
|
overrides: {
|
||||||
body: {},
|
dmPolicy: "open",
|
||||||
});
|
channelType: "channel",
|
||||||
|
channelUsers: ["U_OWNER"],
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
},
|
||||||
|
event: makeMemberEvent({ channel: "C1", user: "U_ATTACKER" }),
|
||||||
|
},
|
||||||
|
calls: 0,
|
||||||
|
},
|
||||||
|
])("$name", async ({ args, calls }) => {
|
||||||
|
await runMemberCase(args);
|
||||||
|
expect(memberMocks.enqueue).toHaveBeenCalledTimes(calls);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not track mismatched events", async () => {
|
it("does not track mismatched events", async () => {
|
||||||
const trackEvent = vi.fn();
|
const trackEvent = vi.fn();
|
||||||
const { getJoinedHandler } = createMembersContext({
|
await runMemberCase({
|
||||||
trackEvent,
|
trackEvent,
|
||||||
shouldDropMismatchedSlackEvent: () => true,
|
shouldDropMismatchedSlackEvent: () => true,
|
||||||
});
|
|
||||||
const joinedHandler = getJoinedHandler();
|
|
||||||
expect(joinedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await joinedHandler!({
|
|
||||||
event: makeMemberEvent(),
|
|
||||||
body: { api_app_id: "A_OTHER" },
|
body: { api_app_id: "A_OTHER" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,14 +130,7 @@ describe("registerSlackMemberEvents", () => {
|
|||||||
|
|
||||||
it("tracks accepted member events", async () => {
|
it("tracks accepted member events", async () => {
|
||||||
const trackEvent = vi.fn();
|
const trackEvent = vi.fn();
|
||||||
const { getJoinedHandler } = createMembersContext({ trackEvent });
|
await runMemberCase({ trackEvent });
|
||||||
const joinedHandler = getJoinedHandler();
|
|
||||||
expect(joinedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await joinedHandler!({
|
|
||||||
event: makeMemberEvent(),
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(trackEvent).toHaveBeenCalledTimes(1);
|
expect(trackEvent).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,23 +5,26 @@ import {
|
|||||||
type SlackSystemEventTestOverrides,
|
type SlackSystemEventTestOverrides,
|
||||||
} from "./system-event-test-harness.js";
|
} from "./system-event-test-harness.js";
|
||||||
|
|
||||||
const enqueueSystemEventMock = vi.fn();
|
const messageQueueMock = vi.fn();
|
||||||
const readAllowFromStoreMock = vi.fn();
|
const messageAllowMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../../infra/system-events.js", () => ({
|
vi.mock("../../../infra/system-events.js", () => ({
|
||||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
enqueueSystemEvent: (...args: unknown[]) => messageQueueMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../pairing/pairing-store.js", () => ({
|
vi.mock("../../../pairing/pairing-store.js", () => ({
|
||||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
readChannelAllowFromStore: (...args: unknown[]) => messageAllowMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type SlackMessageHandler = (args: {
|
type MessageHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||||
event: Record<string, unknown>;
|
|
||||||
body: unknown;
|
|
||||||
}) => Promise<void>;
|
|
||||||
|
|
||||||
function createMessagesContext(overrides?: SlackSystemEventTestOverrides) {
|
type MessageCase = {
|
||||||
|
overrides?: SlackSystemEventTestOverrides;
|
||||||
|
event?: Record<string, unknown>;
|
||||||
|
body?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createMessageHandlers(overrides?: SlackSystemEventTestOverrides) {
|
||||||
const harness = createSlackSystemEventTestHarness(overrides);
|
const harness = createSlackSystemEventTestHarness(overrides);
|
||||||
const handleSlackMessage = vi.fn(async () => {});
|
const handleSlackMessage = vi.fn(async () => {});
|
||||||
registerSlackMessageEvents({
|
registerSlackMessageEvents({
|
||||||
@@ -29,7 +32,7 @@ function createMessagesContext(overrides?: SlackSystemEventTestOverrides) {
|
|||||||
handleSlackMessage,
|
handleSlackMessage,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
getMessageHandler: () => harness.getHandler("message") as SlackMessageHandler | null,
|
handler: harness.getHandler("message") as MessageHandler | null,
|
||||||
handleSlackMessage,
|
handleSlackMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -40,14 +43,8 @@ function makeChangedEvent(overrides?: { channel?: string; user?: string }) {
|
|||||||
type: "message",
|
type: "message",
|
||||||
subtype: "message_changed",
|
subtype: "message_changed",
|
||||||
channel: overrides?.channel ?? "D1",
|
channel: overrides?.channel ?? "D1",
|
||||||
message: {
|
message: { ts: "123.456", user },
|
||||||
ts: "123.456",
|
previous_message: { ts: "123.450", user },
|
||||||
user,
|
|
||||||
},
|
|
||||||
previous_message: {
|
|
||||||
ts: "123.450",
|
|
||||||
user,
|
|
||||||
},
|
|
||||||
event_ts: "123.456",
|
event_ts: "123.456",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -73,113 +70,78 @@ function makeThreadBroadcastEvent(overrides?: { channel?: string; user?: string
|
|||||||
subtype: "thread_broadcast",
|
subtype: "thread_broadcast",
|
||||||
channel: overrides?.channel ?? "D1",
|
channel: overrides?.channel ?? "D1",
|
||||||
user,
|
user,
|
||||||
message: {
|
message: { ts: "123.456", user },
|
||||||
ts: "123.456",
|
|
||||||
user,
|
|
||||||
},
|
|
||||||
event_ts: "123.456",
|
event_ts: "123.456",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runMessageCase(input: MessageCase = {}): Promise<void> {
|
||||||
|
messageQueueMock.mockClear();
|
||||||
|
messageAllowMock.mockReset().mockResolvedValue([]);
|
||||||
|
const { handler } = createMessageHandlers(input.overrides);
|
||||||
|
expect(handler).toBeTruthy();
|
||||||
|
await handler!({
|
||||||
|
event: (input.event ?? makeChangedEvent()) as Record<string, unknown>,
|
||||||
|
body: input.body ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("registerSlackMessageEvents", () => {
|
describe("registerSlackMessageEvents", () => {
|
||||||
it("enqueues message_changed system events when dmPolicy is open", async () => {
|
it.each([
|
||||||
enqueueSystemEventMock.mockClear();
|
{
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
name: "enqueues message_changed system events when dmPolicy is open",
|
||||||
const { getMessageHandler } = createMessagesContext({ dmPolicy: "open" });
|
input: { overrides: { dmPolicy: "open" }, event: makeChangedEvent() },
|
||||||
const messageHandler = getMessageHandler();
|
calls: 1,
|
||||||
expect(messageHandler).toBeTruthy();
|
},
|
||||||
|
{
|
||||||
await messageHandler!({
|
name: "blocks message_changed system events when dmPolicy is disabled",
|
||||||
event: makeChangedEvent(),
|
input: { overrides: { dmPolicy: "disabled" }, event: makeChangedEvent() },
|
||||||
body: {},
|
calls: 0,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
name: "blocks message_changed system events for unauthorized senders in allowlist mode",
|
||||||
});
|
input: {
|
||||||
|
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
||||||
it("blocks message_changed system events when dmPolicy is disabled", async () => {
|
event: makeChangedEvent({ user: "U1" }),
|
||||||
enqueueSystemEventMock.mockClear();
|
},
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
calls: 0,
|
||||||
const { getMessageHandler } = createMessagesContext({ dmPolicy: "disabled" });
|
},
|
||||||
const messageHandler = getMessageHandler();
|
{
|
||||||
expect(messageHandler).toBeTruthy();
|
name: "blocks message_deleted system events for users outside channel users allowlist",
|
||||||
|
input: {
|
||||||
await messageHandler!({
|
overrides: {
|
||||||
event: makeChangedEvent(),
|
dmPolicy: "open",
|
||||||
body: {},
|
channelType: "channel",
|
||||||
});
|
channelUsers: ["U_OWNER"],
|
||||||
|
},
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
event: makeDeletedEvent({ channel: "C1", user: "U_ATTACKER" }),
|
||||||
});
|
},
|
||||||
|
calls: 0,
|
||||||
it("blocks message_changed system events for unauthorized senders in allowlist mode", async () => {
|
},
|
||||||
enqueueSystemEventMock.mockClear();
|
{
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
name: "blocks thread_broadcast system events without an authenticated sender",
|
||||||
const { getMessageHandler } = createMessagesContext({
|
input: {
|
||||||
dmPolicy: "allowlist",
|
overrides: { dmPolicy: "open" },
|
||||||
allowFrom: ["U2"],
|
event: {
|
||||||
});
|
...makeThreadBroadcastEvent(),
|
||||||
const messageHandler = getMessageHandler();
|
user: undefined,
|
||||||
expect(messageHandler).toBeTruthy();
|
message: { ts: "123.456" },
|
||||||
|
|
||||||
await messageHandler!({
|
|
||||||
event: makeChangedEvent({ user: "U1" }),
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks message_deleted system events for users outside channel users allowlist", async () => {
|
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getMessageHandler } = createMessagesContext({
|
|
||||||
dmPolicy: "open",
|
|
||||||
channelType: "channel",
|
|
||||||
channelUsers: ["U_OWNER"],
|
|
||||||
});
|
|
||||||
const messageHandler = getMessageHandler();
|
|
||||||
expect(messageHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await messageHandler!({
|
|
||||||
event: makeDeletedEvent({ channel: "C1", user: "U_ATTACKER" }),
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks thread_broadcast system events without an authenticated sender", async () => {
|
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getMessageHandler } = createMessagesContext({ dmPolicy: "open" });
|
|
||||||
const messageHandler = getMessageHandler();
|
|
||||||
expect(messageHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await messageHandler!({
|
|
||||||
event: {
|
|
||||||
...makeThreadBroadcastEvent(),
|
|
||||||
user: undefined,
|
|
||||||
message: {
|
|
||||||
ts: "123.456",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
body: {},
|
calls: 0,
|
||||||
});
|
},
|
||||||
|
])("$name", async ({ input, calls }) => {
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
await runMessageCase(input);
|
||||||
|
expect(messageQueueMock).toHaveBeenCalledTimes(calls);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes regular message events to the message handler", async () => {
|
it("passes regular message events to the message handler", async () => {
|
||||||
enqueueSystemEventMock.mockClear();
|
messageQueueMock.mockClear();
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
messageAllowMock.mockReset().mockResolvedValue([]);
|
||||||
const { getMessageHandler, handleSlackMessage } = createMessagesContext({
|
const { handler, handleSlackMessage } = createMessageHandlers({ dmPolicy: "open" });
|
||||||
dmPolicy: "open",
|
expect(handler).toBeTruthy();
|
||||||
});
|
|
||||||
const messageHandler = getMessageHandler();
|
|
||||||
expect(messageHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await messageHandler!({
|
await handler!({
|
||||||
event: {
|
event: {
|
||||||
type: "message",
|
type: "message",
|
||||||
channel: "D1",
|
channel: "D1",
|
||||||
@@ -191,6 +153,6 @@ describe("registerSlackMessageEvents", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(handleSlackMessage).toHaveBeenCalledTimes(1);
|
expect(handleSlackMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
expect(messageQueueMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +1,32 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { registerSlackPinEvents } from "./pins.js";
|
import { registerSlackPinEvents } from "./pins.js";
|
||||||
import {
|
import {
|
||||||
createSlackSystemEventTestHarness,
|
createSlackSystemEventTestHarness as buildPinHarness,
|
||||||
type SlackSystemEventTestOverrides,
|
type SlackSystemEventTestOverrides as PinOverrides,
|
||||||
} from "./system-event-test-harness.js";
|
} from "./system-event-test-harness.js";
|
||||||
|
|
||||||
const enqueueSystemEventMock = vi.fn();
|
const pinEnqueueMock = vi.hoisted(() => vi.fn());
|
||||||
const readAllowFromStoreMock = vi.fn();
|
const pinAllowMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("../../../infra/system-events.js", () => ({
|
|
||||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
vi.mock("../../../infra/system-events.js", () => {
|
||||||
|
return { enqueueSystemEvent: pinEnqueueMock };
|
||||||
|
});
|
||||||
vi.mock("../../../pairing/pairing-store.js", () => ({
|
vi.mock("../../../pairing/pairing-store.js", () => ({
|
||||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
readChannelAllowFromStore: pinAllowMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type SlackPinHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
type PinHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||||
|
|
||||||
function createPinContext(params?: {
|
type PinCase = {
|
||||||
overrides?: SlackSystemEventTestOverrides;
|
body?: unknown;
|
||||||
|
event?: Record<string, unknown>;
|
||||||
|
handler?: "added" | "removed";
|
||||||
|
overrides?: PinOverrides;
|
||||||
trackEvent?: () => void;
|
trackEvent?: () => void;
|
||||||
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
||||||
}) {
|
};
|
||||||
const harness = createSlackSystemEventTestHarness(params?.overrides);
|
|
||||||
if (params?.shouldDropMismatchedSlackEvent) {
|
|
||||||
harness.ctx.shouldDropMismatchedSlackEvent = params.shouldDropMismatchedSlackEvent;
|
|
||||||
}
|
|
||||||
registerSlackPinEvents({ ctx: harness.ctx, trackEvent: params?.trackEvent });
|
|
||||||
return {
|
|
||||||
getAddedHandler: () => harness.getHandler("pin_added") as SlackPinHandler | null,
|
|
||||||
getRemovedHandler: () => harness.getHandler("pin_removed") as SlackPinHandler | null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makePinEvent(overrides?: { user?: string; channel?: string }) {
|
function makePinEvent(overrides?: { channel?: string; user?: string }) {
|
||||||
return {
|
return {
|
||||||
type: "pin_added",
|
type: "pin_added",
|
||||||
user: overrides?.user ?? "U1",
|
user: overrides?.user ?? "U1",
|
||||||
@@ -42,110 +34,92 @@ function makePinEvent(overrides?: { user?: string; channel?: string }) {
|
|||||||
event_ts: "123.456",
|
event_ts: "123.456",
|
||||||
item: {
|
item: {
|
||||||
type: "message",
|
type: "message",
|
||||||
message: {
|
message: { ts: "123.456" },
|
||||||
ts: "123.456",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installPinHandlers(args: {
|
||||||
|
overrides?: PinOverrides;
|
||||||
|
trackEvent?: () => void;
|
||||||
|
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
||||||
|
}) {
|
||||||
|
const harness = buildPinHarness(args.overrides);
|
||||||
|
if (args.shouldDropMismatchedSlackEvent) {
|
||||||
|
harness.ctx.shouldDropMismatchedSlackEvent = args.shouldDropMismatchedSlackEvent;
|
||||||
|
}
|
||||||
|
registerSlackPinEvents({ ctx: harness.ctx, trackEvent: args.trackEvent });
|
||||||
|
return {
|
||||||
|
added: harness.getHandler("pin_added") as PinHandler | null,
|
||||||
|
removed: harness.getHandler("pin_removed") as PinHandler | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPinCase(input: PinCase = {}): Promise<void> {
|
||||||
|
pinEnqueueMock.mockClear();
|
||||||
|
pinAllowMock.mockReset().mockResolvedValue([]);
|
||||||
|
const { added, removed } = installPinHandlers({
|
||||||
|
overrides: input.overrides,
|
||||||
|
trackEvent: input.trackEvent,
|
||||||
|
shouldDropMismatchedSlackEvent: input.shouldDropMismatchedSlackEvent,
|
||||||
|
});
|
||||||
|
const handlerKey = input.handler ?? "added";
|
||||||
|
const handler = handlerKey === "removed" ? removed : added;
|
||||||
|
expect(handler).toBeTruthy();
|
||||||
|
const event = (input.event ?? makePinEvent()) as Record<string, unknown>;
|
||||||
|
const body = input.body ?? {};
|
||||||
|
await handler!({
|
||||||
|
body,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("registerSlackPinEvents", () => {
|
describe("registerSlackPinEvents", () => {
|
||||||
it("enqueues DM pin system events when dmPolicy is open", async () => {
|
it.each([
|
||||||
enqueueSystemEventMock.mockClear();
|
["enqueues DM pin system events when dmPolicy is open", { overrides: { dmPolicy: "open" } }, 1],
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
[
|
||||||
const { getAddedHandler } = createPinContext({ overrides: { dmPolicy: "open" } });
|
"blocks DM pin system events when dmPolicy is disabled",
|
||||||
const addedHandler = getAddedHandler();
|
{ overrides: { dmPolicy: "disabled" } },
|
||||||
expect(addedHandler).toBeTruthy();
|
0,
|
||||||
|
],
|
||||||
await addedHandler!({
|
[
|
||||||
event: makePinEvent(),
|
"blocks DM pin system events for unauthorized senders in allowlist mode",
|
||||||
body: {},
|
{
|
||||||
});
|
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
||||||
|
event: makePinEvent({ user: "U1" }),
|
||||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks DM pin system events when dmPolicy is disabled", async () => {
|
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getAddedHandler } = createPinContext({ overrides: { dmPolicy: "disabled" } });
|
|
||||||
const addedHandler = getAddedHandler();
|
|
||||||
expect(addedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await addedHandler!({
|
|
||||||
event: makePinEvent(),
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks DM pin system events for unauthorized senders in allowlist mode", async () => {
|
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getAddedHandler } = createPinContext({
|
|
||||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
|
||||||
});
|
|
||||||
const addedHandler = getAddedHandler();
|
|
||||||
expect(addedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await addedHandler!({
|
|
||||||
event: makePinEvent({ user: "U1" }),
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows DM pin system events for authorized senders in allowlist mode", async () => {
|
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getAddedHandler } = createPinContext({
|
|
||||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U1"] },
|
|
||||||
});
|
|
||||||
const addedHandler = getAddedHandler();
|
|
||||||
expect(addedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await addedHandler!({
|
|
||||||
event: makePinEvent({ user: "U1" }),
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks channel pin events for users outside channel users allowlist", async () => {
|
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getAddedHandler } = createPinContext({
|
|
||||||
overrides: {
|
|
||||||
dmPolicy: "open",
|
|
||||||
channelType: "channel",
|
|
||||||
channelUsers: ["U_OWNER"],
|
|
||||||
},
|
},
|
||||||
});
|
0,
|
||||||
const addedHandler = getAddedHandler();
|
],
|
||||||
expect(addedHandler).toBeTruthy();
|
[
|
||||||
|
"allows DM pin system events for authorized senders in allowlist mode",
|
||||||
await addedHandler!({
|
{
|
||||||
event: makePinEvent({ channel: "C1", user: "U_ATTACKER" }),
|
overrides: { dmPolicy: "allowlist", allowFrom: ["U1"] },
|
||||||
body: {},
|
event: makePinEvent({ user: "U1" }),
|
||||||
});
|
},
|
||||||
|
1,
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
],
|
||||||
|
[
|
||||||
|
"blocks channel pin events for users outside channel users allowlist",
|
||||||
|
{
|
||||||
|
overrides: {
|
||||||
|
dmPolicy: "open",
|
||||||
|
channelType: "channel",
|
||||||
|
channelUsers: ["U_OWNER"],
|
||||||
|
},
|
||||||
|
event: makePinEvent({ channel: "C1", user: "U_ATTACKER" }),
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
])("%s", async (_name, args: PinCase, expectedCalls: number) => {
|
||||||
|
await runPinCase(args);
|
||||||
|
expect(pinEnqueueMock).toHaveBeenCalledTimes(expectedCalls);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not track mismatched events", async () => {
|
it("does not track mismatched events", async () => {
|
||||||
const trackEvent = vi.fn();
|
const trackEvent = vi.fn();
|
||||||
const { getAddedHandler } = createPinContext({
|
await runPinCase({
|
||||||
trackEvent,
|
trackEvent,
|
||||||
shouldDropMismatchedSlackEvent: () => true,
|
shouldDropMismatchedSlackEvent: () => true,
|
||||||
});
|
|
||||||
const addedHandler = getAddedHandler();
|
|
||||||
expect(addedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await addedHandler!({
|
|
||||||
event: makePinEvent(),
|
|
||||||
body: { api_app_id: "A_OTHER" },
|
body: { api_app_id: "A_OTHER" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,14 +128,7 @@ describe("registerSlackPinEvents", () => {
|
|||||||
|
|
||||||
it("tracks accepted pin events", async () => {
|
it("tracks accepted pin events", async () => {
|
||||||
const trackEvent = vi.fn();
|
const trackEvent = vi.fn();
|
||||||
const { getAddedHandler } = createPinContext({ trackEvent });
|
await runPinCase({ trackEvent });
|
||||||
const addedHandler = getAddedHandler();
|
|
||||||
expect(addedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await addedHandler!({
|
|
||||||
event: makePinEvent(),
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(trackEvent).toHaveBeenCalledTimes(1);
|
expect(trackEvent).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,39 +5,33 @@ import {
|
|||||||
type SlackSystemEventTestOverrides,
|
type SlackSystemEventTestOverrides,
|
||||||
} from "./system-event-test-harness.js";
|
} from "./system-event-test-harness.js";
|
||||||
|
|
||||||
const enqueueSystemEventMock = vi.fn();
|
const reactionQueueMock = vi.fn();
|
||||||
const readAllowFromStoreMock = vi.fn();
|
const reactionAllowMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../../infra/system-events.js", () => ({
|
vi.mock("../../../infra/system-events.js", () => {
|
||||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
return {
|
||||||
}));
|
enqueueSystemEvent: (...args: unknown[]) => reactionQueueMock(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("../../../pairing/pairing-store.js", () => ({
|
vi.mock("../../../pairing/pairing-store.js", () => {
|
||||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
return {
|
||||||
}));
|
readChannelAllowFromStore: (...args: unknown[]) => reactionAllowMock(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
type SlackReactionHandler = (args: {
|
type ReactionHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||||
event: Record<string, unknown>;
|
|
||||||
body: unknown;
|
|
||||||
}) => Promise<void>;
|
|
||||||
|
|
||||||
function createReactionContext(params?: {
|
type ReactionRunInput = {
|
||||||
|
handler?: "added" | "removed";
|
||||||
overrides?: SlackSystemEventTestOverrides;
|
overrides?: SlackSystemEventTestOverrides;
|
||||||
|
event?: Record<string, unknown>;
|
||||||
|
body?: unknown;
|
||||||
trackEvent?: () => void;
|
trackEvent?: () => void;
|
||||||
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
||||||
}) {
|
};
|
||||||
const harness = createSlackSystemEventTestHarness(params?.overrides);
|
|
||||||
if (params?.shouldDropMismatchedSlackEvent) {
|
|
||||||
harness.ctx.shouldDropMismatchedSlackEvent = params.shouldDropMismatchedSlackEvent;
|
|
||||||
}
|
|
||||||
registerSlackReactionEvents({ ctx: harness.ctx, trackEvent: params?.trackEvent });
|
|
||||||
return {
|
|
||||||
getAddedHandler: () => harness.getHandler("reaction_added") as SlackReactionHandler | null,
|
|
||||||
getRemovedHandler: () => harness.getHandler("reaction_removed") as SlackReactionHandler | null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeReactionEvent(overrides?: { user?: string; channel?: string }) {
|
function buildReactionEvent(overrides?: { user?: string; channel?: string }) {
|
||||||
return {
|
return {
|
||||||
type: "reaction_added",
|
type: "reaction_added",
|
||||||
user: overrides?.user ?? "U1",
|
user: overrides?.user ?? "U1",
|
||||||
@@ -51,123 +45,100 @@ function makeReactionEvent(overrides?: { user?: string; channel?: string }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createReactionHandlers(params: {
|
||||||
|
overrides?: SlackSystemEventTestOverrides;
|
||||||
|
trackEvent?: () => void;
|
||||||
|
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
|
||||||
|
}) {
|
||||||
|
const harness = createSlackSystemEventTestHarness(params.overrides);
|
||||||
|
if (params.shouldDropMismatchedSlackEvent) {
|
||||||
|
harness.ctx.shouldDropMismatchedSlackEvent = params.shouldDropMismatchedSlackEvent;
|
||||||
|
}
|
||||||
|
registerSlackReactionEvents({ ctx: harness.ctx, trackEvent: params.trackEvent });
|
||||||
|
return {
|
||||||
|
added: harness.getHandler("reaction_added") as ReactionHandler | null,
|
||||||
|
removed: harness.getHandler("reaction_removed") as ReactionHandler | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeReactionCase(input: ReactionRunInput = {}) {
|
||||||
|
reactionQueueMock.mockClear();
|
||||||
|
reactionAllowMock.mockReset().mockResolvedValue([]);
|
||||||
|
const handlers = createReactionHandlers({
|
||||||
|
overrides: input.overrides,
|
||||||
|
trackEvent: input.trackEvent,
|
||||||
|
shouldDropMismatchedSlackEvent: input.shouldDropMismatchedSlackEvent,
|
||||||
|
});
|
||||||
|
const handler = handlers[input.handler ?? "added"];
|
||||||
|
expect(handler).toBeTruthy();
|
||||||
|
await handler!({
|
||||||
|
event: (input.event ?? buildReactionEvent()) as Record<string, unknown>,
|
||||||
|
body: input.body ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("registerSlackReactionEvents", () => {
|
describe("registerSlackReactionEvents", () => {
|
||||||
it("enqueues DM reaction system events when dmPolicy is open", async () => {
|
it.each([
|
||||||
enqueueSystemEventMock.mockClear();
|
{
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
name: "enqueues DM reaction system events when dmPolicy is open",
|
||||||
const { getAddedHandler } = createReactionContext({ overrides: { dmPolicy: "open" } });
|
args: { overrides: { dmPolicy: "open" } },
|
||||||
const addedHandler = getAddedHandler();
|
expectedCalls: 1,
|
||||||
expect(addedHandler).toBeTruthy();
|
},
|
||||||
|
{
|
||||||
await addedHandler!({
|
name: "blocks DM reaction system events when dmPolicy is disabled",
|
||||||
event: makeReactionEvent(),
|
args: { overrides: { dmPolicy: "disabled" } },
|
||||||
body: {},
|
expectedCalls: 0,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
name: "blocks DM reaction system events for unauthorized senders in allowlist mode",
|
||||||
});
|
args: {
|
||||||
|
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
||||||
it("blocks DM reaction system events when dmPolicy is disabled", async () => {
|
event: buildReactionEvent({ user: "U1" }),
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getAddedHandler } = createReactionContext({ overrides: { dmPolicy: "disabled" } });
|
|
||||||
const addedHandler = getAddedHandler();
|
|
||||||
expect(addedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await addedHandler!({
|
|
||||||
event: makeReactionEvent(),
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks DM reaction system events for unauthorized senders in allowlist mode", async () => {
|
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getAddedHandler } = createReactionContext({
|
|
||||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U2"] },
|
|
||||||
});
|
|
||||||
const addedHandler = getAddedHandler();
|
|
||||||
expect(addedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await addedHandler!({
|
|
||||||
event: makeReactionEvent({ user: "U1" }),
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows DM reaction system events for authorized senders in allowlist mode", async () => {
|
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getAddedHandler } = createReactionContext({
|
|
||||||
overrides: { dmPolicy: "allowlist", allowFrom: ["U1"] },
|
|
||||||
});
|
|
||||||
const addedHandler = getAddedHandler();
|
|
||||||
expect(addedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await addedHandler!({
|
|
||||||
event: makeReactionEvent({ user: "U1" }),
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enqueues channel reaction events regardless of dmPolicy", async () => {
|
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getRemovedHandler } = createReactionContext({
|
|
||||||
overrides: { dmPolicy: "disabled", channelType: "channel" },
|
|
||||||
});
|
|
||||||
const removedHandler = getRemovedHandler();
|
|
||||||
expect(removedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await removedHandler!({
|
|
||||||
event: {
|
|
||||||
...makeReactionEvent({ channel: "C1" }),
|
|
||||||
type: "reaction_removed",
|
|
||||||
},
|
},
|
||||||
body: {},
|
expectedCalls: 0,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
name: "allows DM reaction system events for authorized senders in allowlist mode",
|
||||||
});
|
args: {
|
||||||
|
overrides: { dmPolicy: "allowlist", allowFrom: ["U1"] },
|
||||||
it("blocks channel reaction events for users outside channel users allowlist", async () => {
|
event: buildReactionEvent({ user: "U1" }),
|
||||||
enqueueSystemEventMock.mockClear();
|
|
||||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
|
||||||
const { getAddedHandler } = createReactionContext({
|
|
||||||
overrides: {
|
|
||||||
dmPolicy: "open",
|
|
||||||
channelType: "channel",
|
|
||||||
channelUsers: ["U_OWNER"],
|
|
||||||
},
|
},
|
||||||
});
|
expectedCalls: 1,
|
||||||
const addedHandler = getAddedHandler();
|
},
|
||||||
expect(addedHandler).toBeTruthy();
|
{
|
||||||
|
name: "enqueues channel reaction events regardless of dmPolicy",
|
||||||
await addedHandler!({
|
args: {
|
||||||
event: makeReactionEvent({ channel: "C1", user: "U_ATTACKER" }),
|
handler: "removed" as const,
|
||||||
body: {},
|
overrides: { dmPolicy: "disabled", channelType: "channel" },
|
||||||
});
|
event: {
|
||||||
|
...buildReactionEvent({ channel: "C1" }),
|
||||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
type: "reaction_removed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedCalls: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blocks channel reaction events for users outside channel users allowlist",
|
||||||
|
args: {
|
||||||
|
overrides: {
|
||||||
|
dmPolicy: "open",
|
||||||
|
channelType: "channel",
|
||||||
|
channelUsers: ["U_OWNER"],
|
||||||
|
},
|
||||||
|
event: buildReactionEvent({ channel: "C1", user: "U_ATTACKER" }),
|
||||||
|
},
|
||||||
|
expectedCalls: 0,
|
||||||
|
},
|
||||||
|
])("$name", async ({ args, expectedCalls }) => {
|
||||||
|
await executeReactionCase(args);
|
||||||
|
expect(reactionQueueMock).toHaveBeenCalledTimes(expectedCalls);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not track mismatched events", async () => {
|
it("does not track mismatched events", async () => {
|
||||||
const trackEvent = vi.fn();
|
const trackEvent = vi.fn();
|
||||||
const { getAddedHandler } = createReactionContext({
|
await executeReactionCase({
|
||||||
trackEvent,
|
trackEvent,
|
||||||
shouldDropMismatchedSlackEvent: () => true,
|
shouldDropMismatchedSlackEvent: () => true,
|
||||||
});
|
|
||||||
const addedHandler = getAddedHandler();
|
|
||||||
expect(addedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await addedHandler!({
|
|
||||||
event: makeReactionEvent(),
|
|
||||||
body: { api_app_id: "A_OTHER" },
|
body: { api_app_id: "A_OTHER" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,14 +147,7 @@ describe("registerSlackReactionEvents", () => {
|
|||||||
|
|
||||||
it("tracks accepted message reactions", async () => {
|
it("tracks accepted message reactions", async () => {
|
||||||
const trackEvent = vi.fn();
|
const trackEvent = vi.fn();
|
||||||
const { getAddedHandler } = createReactionContext({ trackEvent });
|
await executeReactionCase({ trackEvent });
|
||||||
const addedHandler = getAddedHandler();
|
|
||||||
expect(addedHandler).toBeTruthy();
|
|
||||||
|
|
||||||
await addedHandler!({
|
|
||||||
event: makeReactionEvent(),
|
|
||||||
body: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(trackEvent).toHaveBeenCalledTimes(1);
|
expect(trackEvent).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -189,6 +189,73 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
|||||||
return prepareMessageWith(ctx, createThreadAccount(), createThreadReplyMessage(overrides));
|
return prepareMessageWith(ctx, createThreadAccount(), createThreadReplyMessage(overrides));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDmScopeMainSlackCtx(): SlackMonitorContext {
|
||||||
|
const slackCtx = createInboundSlackCtx({
|
||||||
|
cfg: {
|
||||||
|
channels: { slack: { enabled: true } },
|
||||||
|
session: { dmScope: "main" },
|
||||||
|
} as OpenClawConfig,
|
||||||
|
});
|
||||||
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
|
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||||
|
// Simulate API returning correct type for DM channel
|
||||||
|
slackCtx.resolveChannelName = async () => ({ name: undefined, type: "im" as const });
|
||||||
|
return slackCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMainScopedDmMessage(overrides: Partial<SlackMessageEvent>): SlackMessageEvent {
|
||||||
|
return createSlackMessage({
|
||||||
|
channel: "D0ACP6B1T8V",
|
||||||
|
user: "U1",
|
||||||
|
text: "hello from DM",
|
||||||
|
ts: "1.000",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectMainScopedDmClassification(
|
||||||
|
prepared: Awaited<ReturnType<typeof prepareSlackMessage>>,
|
||||||
|
options?: { includeFromCheck?: boolean },
|
||||||
|
) {
|
||||||
|
expect(prepared).toBeTruthy();
|
||||||
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
|
expectInboundContextContract(prepared!.ctxPayload as any);
|
||||||
|
expect(prepared!.isDirectMessage).toBe(true);
|
||||||
|
expect(prepared!.route.sessionKey).toBe("agent:main:main");
|
||||||
|
expect(prepared!.ctxPayload.ChatType).toBe("direct");
|
||||||
|
if (options?.includeFromCheck) {
|
||||||
|
expect(prepared!.ctxPayload.From).toContain("slack:U1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReplyToAllSlackCtx(params?: {
|
||||||
|
groupPolicy?: "open";
|
||||||
|
defaultRequireMention?: boolean;
|
||||||
|
asChannel?: boolean;
|
||||||
|
}): SlackMonitorContext {
|
||||||
|
const slackCtx = createInboundSlackCtx({
|
||||||
|
cfg: {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
enabled: true,
|
||||||
|
replyToMode: "all",
|
||||||
|
...(params?.groupPolicy ? { groupPolicy: params.groupPolicy } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig,
|
||||||
|
replyToMode: "all",
|
||||||
|
...(params?.defaultRequireMention === undefined
|
||||||
|
? {}
|
||||||
|
: { defaultRequireMention: params.defaultRequireMention }),
|
||||||
|
});
|
||||||
|
// oxlint-disable-next-line typescript/no-explicit-any
|
||||||
|
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||||
|
if (params?.asChannel) {
|
||||||
|
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||||
|
}
|
||||||
|
return slackCtx;
|
||||||
|
}
|
||||||
|
|
||||||
it("produces a finalized MsgContext", async () => {
|
it("produces a finalized MsgContext", async () => {
|
||||||
const message: SlackMessageEvent = {
|
const message: SlackMessageEvent = {
|
||||||
channel: "D123",
|
channel: "D123",
|
||||||
@@ -331,179 +398,34 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("classifies D-prefix DMs correctly even when channel_type is wrong", async () => {
|
it("classifies D-prefix DMs correctly even when channel_type is wrong", async () => {
|
||||||
const slackCtx = createSlackMonitorContext({
|
const prepared = await prepareMessageWith(
|
||||||
cfg: {
|
createDmScopeMainSlackCtx(),
|
||||||
channels: { slack: { enabled: true } },
|
createSlackAccount(),
|
||||||
session: { dmScope: "main" },
|
createMainScopedDmMessage({
|
||||||
} as OpenClawConfig,
|
// Bug scenario: D-prefix channel but Slack event says channel_type: "channel"
|
||||||
accountId: "default",
|
channel_type: "channel",
|
||||||
botToken: "token",
|
}),
|
||||||
app: { client: {} } as App,
|
);
|
||||||
runtime: {} as RuntimeEnv,
|
|
||||||
botUserId: "B1",
|
|
||||||
teamId: "T1",
|
|
||||||
apiAppId: "A1",
|
|
||||||
historyLimit: 0,
|
|
||||||
sessionScope: "per-sender",
|
|
||||||
mainKey: "main",
|
|
||||||
dmEnabled: true,
|
|
||||||
dmPolicy: "open",
|
|
||||||
allowFrom: [],
|
|
||||||
allowNameMatching: false,
|
|
||||||
groupDmEnabled: true,
|
|
||||||
groupDmChannels: [],
|
|
||||||
defaultRequireMention: true,
|
|
||||||
groupPolicy: "open",
|
|
||||||
useAccessGroups: false,
|
|
||||||
reactionMode: "off",
|
|
||||||
reactionAllowlist: [],
|
|
||||||
replyToMode: "off",
|
|
||||||
threadHistoryScope: "thread",
|
|
||||||
threadInheritParent: false,
|
|
||||||
slashCommand: {
|
|
||||||
enabled: false,
|
|
||||||
name: "openclaw",
|
|
||||||
sessionPrefix: "slack:slash",
|
|
||||||
ephemeral: true,
|
|
||||||
},
|
|
||||||
textLimit: 4000,
|
|
||||||
ackReactionScope: "group-mentions",
|
|
||||||
mediaMaxBytes: 1024,
|
|
||||||
removeAckAfterReply: false,
|
|
||||||
});
|
|
||||||
// oxlint-disable-next-line typescript/no-explicit-any
|
|
||||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
|
||||||
// Simulate API returning correct type for DM channel
|
|
||||||
slackCtx.resolveChannelName = async () => ({ name: undefined, type: "im" as const });
|
|
||||||
|
|
||||||
const account: ResolvedSlackAccount = {
|
expectMainScopedDmClassification(prepared, { includeFromCheck: true });
|
||||||
accountId: "default",
|
|
||||||
enabled: true,
|
|
||||||
botTokenSource: "config",
|
|
||||||
appTokenSource: "config",
|
|
||||||
userTokenSource: "none",
|
|
||||||
config: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bug scenario: D-prefix channel but Slack event says channel_type: "channel"
|
|
||||||
const message: SlackMessageEvent = {
|
|
||||||
channel: "D0ACP6B1T8V",
|
|
||||||
channel_type: "channel",
|
|
||||||
user: "U1",
|
|
||||||
text: "hello from DM",
|
|
||||||
ts: "1.000",
|
|
||||||
} as SlackMessageEvent;
|
|
||||||
|
|
||||||
const prepared = await prepareSlackMessage({
|
|
||||||
ctx: slackCtx,
|
|
||||||
account,
|
|
||||||
message,
|
|
||||||
opts: { source: "message" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(prepared).toBeTruthy();
|
|
||||||
// oxlint-disable-next-line typescript/no-explicit-any
|
|
||||||
expectInboundContextContract(prepared!.ctxPayload as any);
|
|
||||||
// Should be classified as DM, not channel
|
|
||||||
expect(prepared!.isDirectMessage).toBe(true);
|
|
||||||
// DM with dmScope: "main" should route to the main session
|
|
||||||
expect(prepared!.route.sessionKey).toBe("agent:main:main");
|
|
||||||
// ChatType should be "direct", not "channel"
|
|
||||||
expect(prepared!.ctxPayload.ChatType).toBe("direct");
|
|
||||||
// From should use user ID (DM pattern), not channel ID
|
|
||||||
expect(prepared!.ctxPayload.From).toContain("slack:U1");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("classifies D-prefix DMs when channel_type is missing", async () => {
|
it("classifies D-prefix DMs when channel_type is missing", async () => {
|
||||||
const slackCtx = createSlackMonitorContext({
|
const message = createMainScopedDmMessage({});
|
||||||
cfg: {
|
delete message.channel_type;
|
||||||
channels: { slack: { enabled: true } },
|
const prepared = await prepareMessageWith(
|
||||||
session: { dmScope: "main" },
|
createDmScopeMainSlackCtx(),
|
||||||
} as OpenClawConfig,
|
createSlackAccount(),
|
||||||
accountId: "default",
|
// channel_type missing — should infer from D-prefix.
|
||||||
botToken: "token",
|
|
||||||
app: { client: {} } as App,
|
|
||||||
runtime: {} as RuntimeEnv,
|
|
||||||
botUserId: "B1",
|
|
||||||
teamId: "T1",
|
|
||||||
apiAppId: "A1",
|
|
||||||
historyLimit: 0,
|
|
||||||
sessionScope: "per-sender",
|
|
||||||
mainKey: "main",
|
|
||||||
dmEnabled: true,
|
|
||||||
dmPolicy: "open",
|
|
||||||
allowFrom: [],
|
|
||||||
allowNameMatching: false,
|
|
||||||
groupDmEnabled: true,
|
|
||||||
groupDmChannels: [],
|
|
||||||
defaultRequireMention: true,
|
|
||||||
groupPolicy: "open",
|
|
||||||
useAccessGroups: false,
|
|
||||||
reactionMode: "off",
|
|
||||||
reactionAllowlist: [],
|
|
||||||
replyToMode: "off",
|
|
||||||
threadHistoryScope: "thread",
|
|
||||||
threadInheritParent: false,
|
|
||||||
slashCommand: {
|
|
||||||
enabled: false,
|
|
||||||
name: "openclaw",
|
|
||||||
sessionPrefix: "slack:slash",
|
|
||||||
ephemeral: true,
|
|
||||||
},
|
|
||||||
textLimit: 4000,
|
|
||||||
ackReactionScope: "group-mentions",
|
|
||||||
mediaMaxBytes: 1024,
|
|
||||||
removeAckAfterReply: false,
|
|
||||||
});
|
|
||||||
// oxlint-disable-next-line typescript/no-explicit-any
|
|
||||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
|
||||||
// Simulate API returning correct type for DM channel
|
|
||||||
slackCtx.resolveChannelName = async () => ({ name: undefined, type: "im" as const });
|
|
||||||
|
|
||||||
const account: ResolvedSlackAccount = {
|
|
||||||
accountId: "default",
|
|
||||||
enabled: true,
|
|
||||||
botTokenSource: "config",
|
|
||||||
appTokenSource: "config",
|
|
||||||
userTokenSource: "none",
|
|
||||||
config: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// channel_type missing — should infer from D-prefix
|
|
||||||
const message: SlackMessageEvent = {
|
|
||||||
channel: "D0ACP6B1T8V",
|
|
||||||
user: "U1",
|
|
||||||
text: "hello from DM",
|
|
||||||
ts: "1.000",
|
|
||||||
} as SlackMessageEvent;
|
|
||||||
|
|
||||||
const prepared = await prepareSlackMessage({
|
|
||||||
ctx: slackCtx,
|
|
||||||
account,
|
|
||||||
message,
|
message,
|
||||||
opts: { source: "message" },
|
);
|
||||||
});
|
|
||||||
|
|
||||||
expect(prepared).toBeTruthy();
|
expectMainScopedDmClassification(prepared);
|
||||||
// oxlint-disable-next-line typescript/no-explicit-any
|
|
||||||
expectInboundContextContract(prepared!.ctxPayload as any);
|
|
||||||
expect(prepared!.isDirectMessage).toBe(true);
|
|
||||||
expect(prepared!.route.sessionKey).toBe("agent:main:main");
|
|
||||||
expect(prepared!.ctxPayload.ChatType).toBe("direct");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets MessageThreadId for top-level messages when replyToMode=all", async () => {
|
it("sets MessageThreadId for top-level messages when replyToMode=all", async () => {
|
||||||
const slackCtx = createInboundSlackCtx({
|
|
||||||
cfg: {
|
|
||||||
channels: { slack: { enabled: true, replyToMode: "all" } },
|
|
||||||
} as OpenClawConfig,
|
|
||||||
replyToMode: "all",
|
|
||||||
});
|
|
||||||
// oxlint-disable-next-line typescript/no-explicit-any
|
|
||||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
|
||||||
|
|
||||||
const prepared = await prepareMessageWith(
|
const prepared = await prepareMessageWith(
|
||||||
slackCtx,
|
createReplyToAllSlackCtx(),
|
||||||
createSlackAccount({ replyToMode: "all" }),
|
createSlackAccount({ replyToMode: "all" }),
|
||||||
createSlackMessage({}),
|
createSlackMessage({}),
|
||||||
);
|
);
|
||||||
@@ -513,17 +435,8 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("respects replyToModeByChatType.direct override for DMs", async () => {
|
it("respects replyToModeByChatType.direct override for DMs", async () => {
|
||||||
const slackCtx = createInboundSlackCtx({
|
|
||||||
cfg: {
|
|
||||||
channels: { slack: { enabled: true, replyToMode: "all" } },
|
|
||||||
} as OpenClawConfig,
|
|
||||||
replyToMode: "all",
|
|
||||||
});
|
|
||||||
// oxlint-disable-next-line typescript/no-explicit-any
|
|
||||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
|
||||||
|
|
||||||
const prepared = await prepareMessageWith(
|
const prepared = await prepareMessageWith(
|
||||||
slackCtx,
|
createReplyToAllSlackCtx(),
|
||||||
createSlackAccount({ replyToMode: "all", replyToModeByChatType: { direct: "off" } }),
|
createSlackAccount({ replyToMode: "all", replyToModeByChatType: { direct: "off" } }),
|
||||||
createSlackMessage({}), // DM (channel_type: "im")
|
createSlackMessage({}), // DM (channel_type: "im")
|
||||||
);
|
);
|
||||||
@@ -534,19 +447,12 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("still threads channel messages when replyToModeByChatType.direct is off", async () => {
|
it("still threads channel messages when replyToModeByChatType.direct is off", async () => {
|
||||||
const slackCtx = createInboundSlackCtx({
|
|
||||||
cfg: {
|
|
||||||
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
|
|
||||||
} as OpenClawConfig,
|
|
||||||
replyToMode: "all",
|
|
||||||
defaultRequireMention: false,
|
|
||||||
});
|
|
||||||
// oxlint-disable-next-line typescript/no-explicit-any
|
|
||||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
|
||||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
|
||||||
|
|
||||||
const prepared = await prepareMessageWith(
|
const prepared = await prepareMessageWith(
|
||||||
slackCtx,
|
createReplyToAllSlackCtx({
|
||||||
|
groupPolicy: "open",
|
||||||
|
defaultRequireMention: false,
|
||||||
|
asChannel: true,
|
||||||
|
}),
|
||||||
createSlackAccount({ replyToMode: "all", replyToModeByChatType: { direct: "off" } }),
|
createSlackAccount({ replyToMode: "all", replyToModeByChatType: { direct: "off" } }),
|
||||||
createSlackMessage({ channel: "C123", channel_type: "channel" }),
|
createSlackMessage({ channel: "C123", channel_type: "channel" }),
|
||||||
);
|
);
|
||||||
@@ -557,17 +463,8 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("respects dm.replyToMode legacy override for DMs", async () => {
|
it("respects dm.replyToMode legacy override for DMs", async () => {
|
||||||
const slackCtx = createInboundSlackCtx({
|
|
||||||
cfg: {
|
|
||||||
channels: { slack: { enabled: true, replyToMode: "all" } },
|
|
||||||
} as OpenClawConfig,
|
|
||||||
replyToMode: "all",
|
|
||||||
});
|
|
||||||
// oxlint-disable-next-line typescript/no-explicit-any
|
|
||||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
|
||||||
|
|
||||||
const prepared = await prepareMessageWith(
|
const prepared = await prepareMessageWith(
|
||||||
slackCtx,
|
createReplyToAllSlackCtx(),
|
||||||
createSlackAccount({ replyToMode: "all", dm: { replyToMode: "off" } }),
|
createSlackAccount({ replyToMode: "all", dm: { replyToMode: "off" } }),
|
||||||
createSlackMessage({}), // DM
|
createSlackMessage({}), // DM
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { pruneStickerMediaFromContext } from "./bot-message-dispatch.js";
|
import { pruneStickerMediaFromContext } from "./bot-message-dispatch.js";
|
||||||
|
|
||||||
|
type MediaCtx = {
|
||||||
|
MediaPath?: string;
|
||||||
|
MediaUrl?: string;
|
||||||
|
MediaType?: string;
|
||||||
|
MediaPaths?: string[];
|
||||||
|
MediaUrls?: string[];
|
||||||
|
MediaTypes?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function expectSingleImageMedia(ctx: MediaCtx, mediaPath: string) {
|
||||||
|
expect(ctx.MediaPath).toBe(mediaPath);
|
||||||
|
expect(ctx.MediaUrl).toBe(mediaPath);
|
||||||
|
expect(ctx.MediaType).toBe("image/jpeg");
|
||||||
|
expect(ctx.MediaPaths).toEqual([mediaPath]);
|
||||||
|
expect(ctx.MediaUrls).toEqual([mediaPath]);
|
||||||
|
expect(ctx.MediaTypes).toEqual(["image/jpeg"]);
|
||||||
|
}
|
||||||
|
|
||||||
describe("pruneStickerMediaFromContext", () => {
|
describe("pruneStickerMediaFromContext", () => {
|
||||||
it("preserves appended reply media while removing primary sticker media", () => {
|
it("preserves appended reply media while removing primary sticker media", () => {
|
||||||
const ctx = {
|
const ctx: MediaCtx = {
|
||||||
MediaPath: "/tmp/sticker.webp",
|
MediaPath: "/tmp/sticker.webp",
|
||||||
MediaUrl: "/tmp/sticker.webp",
|
MediaUrl: "/tmp/sticker.webp",
|
||||||
MediaType: "image/webp",
|
MediaType: "image/webp",
|
||||||
@@ -14,16 +32,11 @@ describe("pruneStickerMediaFromContext", () => {
|
|||||||
|
|
||||||
pruneStickerMediaFromContext(ctx);
|
pruneStickerMediaFromContext(ctx);
|
||||||
|
|
||||||
expect(ctx.MediaPath).toBe("/tmp/replied.jpg");
|
expectSingleImageMedia(ctx, "/tmp/replied.jpg");
|
||||||
expect(ctx.MediaUrl).toBe("/tmp/replied.jpg");
|
|
||||||
expect(ctx.MediaType).toBe("image/jpeg");
|
|
||||||
expect(ctx.MediaPaths).toEqual(["/tmp/replied.jpg"]);
|
|
||||||
expect(ctx.MediaUrls).toEqual(["/tmp/replied.jpg"]);
|
|
||||||
expect(ctx.MediaTypes).toEqual(["image/jpeg"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears media fields when sticker is the only media", () => {
|
it("clears media fields when sticker is the only media", () => {
|
||||||
const ctx = {
|
const ctx: MediaCtx = {
|
||||||
MediaPath: "/tmp/sticker.webp",
|
MediaPath: "/tmp/sticker.webp",
|
||||||
MediaUrl: "/tmp/sticker.webp",
|
MediaUrl: "/tmp/sticker.webp",
|
||||||
MediaType: "image/webp",
|
MediaType: "image/webp",
|
||||||
@@ -43,7 +56,7 @@ describe("pruneStickerMediaFromContext", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not prune when sticker media is already omitted from context", () => {
|
it("does not prune when sticker media is already omitted from context", () => {
|
||||||
const ctx = {
|
const ctx: MediaCtx = {
|
||||||
MediaPath: "/tmp/replied.jpg",
|
MediaPath: "/tmp/replied.jpg",
|
||||||
MediaUrl: "/tmp/replied.jpg",
|
MediaUrl: "/tmp/replied.jpg",
|
||||||
MediaType: "image/jpeg",
|
MediaType: "image/jpeg",
|
||||||
@@ -54,11 +67,6 @@ describe("pruneStickerMediaFromContext", () => {
|
|||||||
|
|
||||||
pruneStickerMediaFromContext(ctx, { stickerMediaIncluded: false });
|
pruneStickerMediaFromContext(ctx, { stickerMediaIncluded: false });
|
||||||
|
|
||||||
expect(ctx.MediaPath).toBe("/tmp/replied.jpg");
|
expectSingleImageMedia(ctx, "/tmp/replied.jpg");
|
||||||
expect(ctx.MediaUrl).toBe("/tmp/replied.jpg");
|
|
||||||
expect(ctx.MediaType).toBe("image/jpeg");
|
|
||||||
expect(ctx.MediaPaths).toEqual(["/tmp/replied.jpg"]);
|
|
||||||
expect(ctx.MediaUrls).toEqual(["/tmp/replied.jpg"]);
|
|
||||||
expect(ctx.MediaTypes).toEqual(["image/jpeg"]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ const createTelegramBotSpy = vi.hoisted(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const WEBHOOK_POST_TIMEOUT_MS = process.platform === "win32" ? 20_000 : 8_000;
|
const WEBHOOK_POST_TIMEOUT_MS = process.platform === "win32" ? 20_000 : 8_000;
|
||||||
|
const TELEGRAM_TOKEN = "tok";
|
||||||
|
const TELEGRAM_SECRET = "secret";
|
||||||
|
const TELEGRAM_WEBHOOK_PATH = "/hook";
|
||||||
|
|
||||||
vi.mock("grammy", async (importOriginal) => {
|
vi.mock("grammy", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("grammy")>();
|
const actual = await importOriginal<typeof import("grammy")>();
|
||||||
@@ -202,96 +205,175 @@ function sha256(text: string): string {
|
|||||||
return createHash("sha256").update(text).digest("hex");
|
return createHash("sha256").update(text).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StartWebhookOptions = Omit<
|
||||||
|
Parameters<typeof startTelegramWebhook>[0],
|
||||||
|
"token" | "port" | "abortSignal"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type StartedWebhook = Awaited<ReturnType<typeof startTelegramWebhook>>;
|
||||||
|
|
||||||
|
function getServerPort(server: StartedWebhook["server"]): number {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("no addr");
|
||||||
|
}
|
||||||
|
return address.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webhookUrl(port: number, webhookPath: string): string {
|
||||||
|
return `http://127.0.0.1:${port}${webhookPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withStartedWebhook<T>(
|
||||||
|
options: StartWebhookOptions,
|
||||||
|
run: (ctx: { server: StartedWebhook["server"]; port: number }) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const abort = new AbortController();
|
||||||
|
const started = await startTelegramWebhook({
|
||||||
|
token: TELEGRAM_TOKEN,
|
||||||
|
port: 0,
|
||||||
|
abortSignal: abort.signal,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await run({ server: started.server, port: getServerPort(started.server) });
|
||||||
|
} finally {
|
||||||
|
abort.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSingleNearLimitUpdate(params: {
|
||||||
|
seenUpdates: Array<{ update_id: number; message: { text: string } }>;
|
||||||
|
expected: { update_id: number; message: { text: string } };
|
||||||
|
}) {
|
||||||
|
expect(params.seenUpdates).toHaveLength(1);
|
||||||
|
expect(params.seenUpdates[0]?.update_id).toBe(params.expected.update_id);
|
||||||
|
expect(params.seenUpdates[0]?.message.text.length).toBe(params.expected.message.text.length);
|
||||||
|
expect(sha256(params.seenUpdates[0]?.message.text ?? "")).toBe(
|
||||||
|
sha256(params.expected.message.text),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNearLimitPayloadTest(mode: "single" | "random-chunked"): Promise<void> {
|
||||||
|
const seenUpdates: Array<{ update_id: number; message: { text: string } }> = [];
|
||||||
|
webhookCallbackSpy.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
vi.fn(
|
||||||
|
(
|
||||||
|
update: unknown,
|
||||||
|
reply: (json: string) => Promise<void>,
|
||||||
|
_secretHeader: string | undefined,
|
||||||
|
_unauthorized: () => Promise<void>,
|
||||||
|
) => {
|
||||||
|
seenUpdates.push(update as { update_id: number; message: { text: string } });
|
||||||
|
void reply("ok");
|
||||||
|
},
|
||||||
|
) as unknown as typeof handlerSpy,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { payload, sizeBytes } = createNearLimitTelegramPayload();
|
||||||
|
expect(sizeBytes).toBeLessThan(1_024 * 1_024);
|
||||||
|
expect(sizeBytes).toBeGreaterThan(256 * 1_024);
|
||||||
|
const expected = JSON.parse(payload) as { update_id: number; message: { text: string } };
|
||||||
|
|
||||||
|
await withStartedWebhook(
|
||||||
|
{
|
||||||
|
secret: TELEGRAM_SECRET,
|
||||||
|
path: TELEGRAM_WEBHOOK_PATH,
|
||||||
|
},
|
||||||
|
async ({ port }) => {
|
||||||
|
const response = await postWebhookPayloadWithChunkPlan({
|
||||||
|
port,
|
||||||
|
path: TELEGRAM_WEBHOOK_PATH,
|
||||||
|
payload,
|
||||||
|
secret: TELEGRAM_SECRET,
|
||||||
|
mode,
|
||||||
|
timeoutMs: WEBHOOK_POST_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expectSingleNearLimitUpdate({ seenUpdates, expected });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("startTelegramWebhook", () => {
|
describe("startTelegramWebhook", () => {
|
||||||
it("starts server, registers webhook, and serves health", async () => {
|
it("starts server, registers webhook, and serves health", async () => {
|
||||||
initSpy.mockClear();
|
initSpy.mockClear();
|
||||||
createTelegramBotSpy.mockClear();
|
createTelegramBotSpy.mockClear();
|
||||||
webhookCallbackSpy.mockClear();
|
webhookCallbackSpy.mockClear();
|
||||||
const runtimeLog = vi.fn();
|
const runtimeLog = vi.fn();
|
||||||
const abort = new AbortController();
|
|
||||||
const cfg = { bindings: [] };
|
const cfg = { bindings: [] };
|
||||||
const { server } = await startTelegramWebhook({
|
await withStartedWebhook(
|
||||||
token: "tok",
|
|
||||||
secret: "secret",
|
|
||||||
accountId: "opie",
|
|
||||||
config: cfg,
|
|
||||||
port: 0, // random free port
|
|
||||||
abortSignal: abort.signal,
|
|
||||||
runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() },
|
|
||||||
});
|
|
||||||
expect(createTelegramBotSpy).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
accountId: "opie",
|
|
||||||
config: expect.objectContaining({ bindings: [] }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const address = server.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
throw new Error("no address");
|
|
||||||
}
|
|
||||||
const url = `http://127.0.0.1:${address.port}`;
|
|
||||||
|
|
||||||
const health = await fetch(`${url}/healthz`);
|
|
||||||
expect(health.status).toBe(200);
|
|
||||||
expect(initSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(setWebhookSpy).toHaveBeenCalled();
|
|
||||||
expect(webhookCallbackSpy).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
api: expect.objectContaining({
|
|
||||||
setWebhook: expect.any(Function),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
"callback",
|
|
||||||
{
|
{
|
||||||
secretToken: "secret",
|
secret: TELEGRAM_SECRET,
|
||||||
onTimeout: "return",
|
accountId: "opie",
|
||||||
timeoutMilliseconds: 10_000,
|
config: cfg,
|
||||||
|
runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() },
|
||||||
|
},
|
||||||
|
async ({ port }) => {
|
||||||
|
expect(createTelegramBotSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
accountId: "opie",
|
||||||
|
config: expect.objectContaining({ bindings: [] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const health = await fetch(`http://127.0.0.1:${port}/healthz`);
|
||||||
|
expect(health.status).toBe(200);
|
||||||
|
expect(initSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setWebhookSpy).toHaveBeenCalled();
|
||||||
|
expect(webhookCallbackSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
api: expect.objectContaining({
|
||||||
|
setWebhook: expect.any(Function),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
"callback",
|
||||||
|
{
|
||||||
|
secretToken: TELEGRAM_SECRET,
|
||||||
|
onTimeout: "return",
|
||||||
|
timeoutMilliseconds: 10_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(runtimeLog).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("webhook local listener on http://127.0.0.1:"),
|
||||||
|
);
|
||||||
|
expect(runtimeLog).toHaveBeenCalledWith(expect.stringContaining("/telegram-webhook"));
|
||||||
|
expect(runtimeLog).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("webhook advertised to telegram on http://"),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(runtimeLog).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("webhook local listener on http://127.0.0.1:"),
|
|
||||||
);
|
|
||||||
expect(runtimeLog).toHaveBeenCalledWith(expect.stringContaining("/telegram-webhook"));
|
|
||||||
expect(runtimeLog).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("webhook advertised to telegram on http://"),
|
|
||||||
);
|
|
||||||
|
|
||||||
abort.abort();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes webhook handler on matching path", async () => {
|
it("invokes webhook handler on matching path", async () => {
|
||||||
handlerSpy.mockClear();
|
handlerSpy.mockClear();
|
||||||
createTelegramBotSpy.mockClear();
|
createTelegramBotSpy.mockClear();
|
||||||
const abort = new AbortController();
|
|
||||||
const cfg = { bindings: [] };
|
const cfg = { bindings: [] };
|
||||||
const { server } = await startTelegramWebhook({
|
await withStartedWebhook(
|
||||||
token: "tok",
|
{
|
||||||
secret: "secret",
|
secret: TELEGRAM_SECRET,
|
||||||
accountId: "opie",
|
|
||||||
config: cfg,
|
|
||||||
port: 0,
|
|
||||||
abortSignal: abort.signal,
|
|
||||||
path: "/hook",
|
|
||||||
});
|
|
||||||
expect(createTelegramBotSpy).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
accountId: "opie",
|
accountId: "opie",
|
||||||
config: expect.objectContaining({ bindings: [] }),
|
config: cfg,
|
||||||
}),
|
path: TELEGRAM_WEBHOOK_PATH,
|
||||||
|
},
|
||||||
|
async ({ port }) => {
|
||||||
|
expect(createTelegramBotSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
accountId: "opie",
|
||||||
|
config: expect.objectContaining({ bindings: [] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const payload = JSON.stringify({ update_id: 1, message: { text: "hello" } });
|
||||||
|
const response = await postWebhookJson({
|
||||||
|
url: webhookUrl(port, TELEGRAM_WEBHOOK_PATH),
|
||||||
|
payload,
|
||||||
|
secret: TELEGRAM_SECRET,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(handlerSpy).toHaveBeenCalled();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const addr = server.address();
|
|
||||||
if (!addr || typeof addr === "string") {
|
|
||||||
throw new Error("no addr");
|
|
||||||
}
|
|
||||||
const payload = JSON.stringify({ update_id: 1, message: { text: "hello" } });
|
|
||||||
const response = await postWebhookJson({
|
|
||||||
url: `http://127.0.0.1:${addr.port}/hook`,
|
|
||||||
payload,
|
|
||||||
secret: "secret",
|
|
||||||
});
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(handlerSpy).toHaveBeenCalled();
|
|
||||||
abort.abort();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects startup when webhook secret is missing", async () => {
|
it("rejects startup when webhook secret is missing", async () => {
|
||||||
@@ -305,34 +387,26 @@ describe("startTelegramWebhook", () => {
|
|||||||
it("registers webhook using the bound listening port when port is 0", async () => {
|
it("registers webhook using the bound listening port when port is 0", async () => {
|
||||||
setWebhookSpy.mockClear();
|
setWebhookSpy.mockClear();
|
||||||
const runtimeLog = vi.fn();
|
const runtimeLog = vi.fn();
|
||||||
const abort = new AbortController();
|
await withStartedWebhook(
|
||||||
const { server } = await startTelegramWebhook({
|
{
|
||||||
token: "tok",
|
secret: TELEGRAM_SECRET,
|
||||||
secret: "secret",
|
path: TELEGRAM_WEBHOOK_PATH,
|
||||||
port: 0,
|
runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() },
|
||||||
abortSignal: abort.signal,
|
},
|
||||||
path: "/hook",
|
async ({ port }) => {
|
||||||
runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() },
|
expect(port).toBeGreaterThan(0);
|
||||||
});
|
expect(setWebhookSpy).toHaveBeenCalledTimes(1);
|
||||||
try {
|
expect(setWebhookSpy).toHaveBeenCalledWith(
|
||||||
const addr = server.address();
|
webhookUrl(port, TELEGRAM_WEBHOOK_PATH),
|
||||||
if (!addr || typeof addr === "string") {
|
expect.objectContaining({
|
||||||
throw new Error("no addr");
|
secret_token: TELEGRAM_SECRET,
|
||||||
}
|
}),
|
||||||
expect(addr.port).toBeGreaterThan(0);
|
);
|
||||||
expect(setWebhookSpy).toHaveBeenCalledTimes(1);
|
expect(runtimeLog).toHaveBeenCalledWith(
|
||||||
expect(setWebhookSpy).toHaveBeenCalledWith(
|
`webhook local listener on ${webhookUrl(port, TELEGRAM_WEBHOOK_PATH)}`,
|
||||||
`http://127.0.0.1:${addr.port}/hook`,
|
);
|
||||||
expect.objectContaining({
|
},
|
||||||
secret_token: "secret",
|
);
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(runtimeLog).toHaveBeenCalledWith(
|
|
||||||
`webhook local listener on http://127.0.0.1:${addr.port}/hook`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
abort.abort();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps webhook payload readable when callback delays body read", async () => {
|
it("keeps webhook payload readable when callback delays body read", async () => {
|
||||||
@@ -342,32 +416,23 @@ describe("startTelegramWebhook", () => {
|
|||||||
await reply(JSON.stringify(update));
|
await reply(JSON.stringify(update));
|
||||||
});
|
});
|
||||||
|
|
||||||
const abort = new AbortController();
|
await withStartedWebhook(
|
||||||
const { server } = await startTelegramWebhook({
|
{
|
||||||
token: "tok",
|
secret: TELEGRAM_SECRET,
|
||||||
secret: "secret",
|
path: TELEGRAM_WEBHOOK_PATH,
|
||||||
port: 0,
|
},
|
||||||
abortSignal: abort.signal,
|
async ({ port }) => {
|
||||||
path: "/hook",
|
const payload = JSON.stringify({ update_id: 1, message: { text: "hello" } });
|
||||||
});
|
const res = await postWebhookJson({
|
||||||
try {
|
url: webhookUrl(port, TELEGRAM_WEBHOOK_PATH),
|
||||||
const addr = server.address();
|
payload,
|
||||||
if (!addr || typeof addr === "string") {
|
secret: TELEGRAM_SECRET,
|
||||||
throw new Error("no addr");
|
});
|
||||||
}
|
expect(res.status).toBe(200);
|
||||||
|
const responseBody = await res.text();
|
||||||
const payload = JSON.stringify({ update_id: 1, message: { text: "hello" } });
|
expect(JSON.parse(responseBody)).toEqual(JSON.parse(payload));
|
||||||
const res = await postWebhookJson({
|
},
|
||||||
url: `http://127.0.0.1:${addr.port}/hook`,
|
);
|
||||||
payload,
|
|
||||||
secret: "secret",
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const responseBody = await res.text();
|
|
||||||
expect(JSON.parse(responseBody)).toEqual(JSON.parse(payload));
|
|
||||||
} finally {
|
|
||||||
abort.abort();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps webhook payload readable across multiple delayed reads", async () => {
|
it("keeps webhook payload readable across multiple delayed reads", async () => {
|
||||||
@@ -380,38 +445,29 @@ describe("startTelegramWebhook", () => {
|
|||||||
};
|
};
|
||||||
handlerSpy.mockImplementationOnce(delayedHandler).mockImplementationOnce(delayedHandler);
|
handlerSpy.mockImplementationOnce(delayedHandler).mockImplementationOnce(delayedHandler);
|
||||||
|
|
||||||
const abort = new AbortController();
|
await withStartedWebhook(
|
||||||
const { server } = await startTelegramWebhook({
|
{
|
||||||
token: "tok",
|
secret: TELEGRAM_SECRET,
|
||||||
secret: "secret",
|
path: TELEGRAM_WEBHOOK_PATH,
|
||||||
port: 0,
|
},
|
||||||
abortSignal: abort.signal,
|
async ({ port }) => {
|
||||||
path: "/hook",
|
const payloads = [
|
||||||
});
|
JSON.stringify({ update_id: 1, message: { text: "first" } }),
|
||||||
try {
|
JSON.stringify({ update_id: 2, message: { text: "second" } }),
|
||||||
const addr = server.address();
|
];
|
||||||
if (!addr || typeof addr === "string") {
|
|
||||||
throw new Error("no addr");
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloads = [
|
for (const payload of payloads) {
|
||||||
JSON.stringify({ update_id: 1, message: { text: "first" } }),
|
const res = await postWebhookJson({
|
||||||
JSON.stringify({ update_id: 2, message: { text: "second" } }),
|
url: webhookUrl(port, TELEGRAM_WEBHOOK_PATH),
|
||||||
];
|
payload,
|
||||||
|
secret: TELEGRAM_SECRET,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
for (const payload of payloads) {
|
expect(seenPayloads.map((x) => JSON.parse(x))).toEqual(payloads.map((x) => JSON.parse(x)));
|
||||||
const res = await postWebhookJson({
|
},
|
||||||
url: `http://127.0.0.1:${addr.port}/hook`,
|
);
|
||||||
payload,
|
|
||||||
secret: "secret",
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(seenPayloads.map((x) => JSON.parse(x))).toEqual(payloads.map((x) => JSON.parse(x)));
|
|
||||||
} finally {
|
|
||||||
abort.abort();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("processes a second request after first-request delayed-init data loss", async () => {
|
it("processes a second request after first-request delayed-init data loss", async () => {
|
||||||
@@ -434,237 +490,110 @@ describe("startTelegramWebhook", () => {
|
|||||||
) as unknown as typeof handlerSpy,
|
) as unknown as typeof handlerSpy,
|
||||||
);
|
);
|
||||||
|
|
||||||
const secret = "secret";
|
await withStartedWebhook(
|
||||||
const abort = new AbortController();
|
{
|
||||||
const { server } = await startTelegramWebhook({
|
secret: TELEGRAM_SECRET,
|
||||||
token: "tok",
|
path: TELEGRAM_WEBHOOK_PATH,
|
||||||
secret,
|
},
|
||||||
port: 0,
|
async ({ port }) => {
|
||||||
abortSignal: abort.signal,
|
const firstPayload = JSON.stringify({ update_id: 100, message: { text: "first" } });
|
||||||
path: "/hook",
|
const secondPayload = JSON.stringify({ update_id: 101, message: { text: "second" } });
|
||||||
});
|
const firstResponse = await postWebhookPayloadWithChunkPlan({
|
||||||
|
port,
|
||||||
|
path: TELEGRAM_WEBHOOK_PATH,
|
||||||
|
payload: firstPayload,
|
||||||
|
secret: TELEGRAM_SECRET,
|
||||||
|
mode: "single",
|
||||||
|
timeoutMs: WEBHOOK_POST_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const secondResponse = await postWebhookPayloadWithChunkPlan({
|
||||||
|
port,
|
||||||
|
path: TELEGRAM_WEBHOOK_PATH,
|
||||||
|
payload: secondPayload,
|
||||||
|
secret: TELEGRAM_SECRET,
|
||||||
|
mode: "single",
|
||||||
|
timeoutMs: WEBHOOK_POST_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
expect(firstResponse.statusCode).toBe(200);
|
||||||
const address = server.address();
|
expect(secondResponse.statusCode).toBe(200);
|
||||||
if (!address || typeof address === "string") {
|
expect(seenUpdates).toEqual([JSON.parse(firstPayload), JSON.parse(secondPayload)]);
|
||||||
throw new Error("no addr");
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
const firstPayload = JSON.stringify({ update_id: 100, message: { text: "first" } });
|
|
||||||
const secondPayload = JSON.stringify({ update_id: 101, message: { text: "second" } });
|
|
||||||
const firstResponse = await postWebhookPayloadWithChunkPlan({
|
|
||||||
port: address.port,
|
|
||||||
path: "/hook",
|
|
||||||
payload: firstPayload,
|
|
||||||
secret,
|
|
||||||
mode: "single",
|
|
||||||
timeoutMs: WEBHOOK_POST_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
const secondResponse = await postWebhookPayloadWithChunkPlan({
|
|
||||||
port: address.port,
|
|
||||||
path: "/hook",
|
|
||||||
payload: secondPayload,
|
|
||||||
secret,
|
|
||||||
mode: "single",
|
|
||||||
timeoutMs: WEBHOOK_POST_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(firstResponse.statusCode).toBe(200);
|
|
||||||
expect(secondResponse.statusCode).toBe(200);
|
|
||||||
expect(seenUpdates).toEqual([JSON.parse(firstPayload), JSON.parse(secondPayload)]);
|
|
||||||
} finally {
|
|
||||||
abort.abort();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles near-limit payload with random chunk writes and event-loop yields", async () => {
|
it("handles near-limit payload with random chunk writes and event-loop yields", async () => {
|
||||||
const seenUpdates: Array<{ update_id: number; message: { text: string } }> = [];
|
await runNearLimitPayloadTest("random-chunked");
|
||||||
webhookCallbackSpy.mockImplementationOnce(
|
|
||||||
() =>
|
|
||||||
vi.fn(
|
|
||||||
(
|
|
||||||
update: unknown,
|
|
||||||
reply: (json: string) => Promise<void>,
|
|
||||||
_secretHeader: string | undefined,
|
|
||||||
_unauthorized: () => Promise<void>,
|
|
||||||
) => {
|
|
||||||
seenUpdates.push(update as { update_id: number; message: { text: string } });
|
|
||||||
void reply("ok");
|
|
||||||
},
|
|
||||||
) as unknown as typeof handlerSpy,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { payload, sizeBytes } = createNearLimitTelegramPayload();
|
|
||||||
expect(sizeBytes).toBeLessThan(1_024 * 1_024);
|
|
||||||
expect(sizeBytes).toBeGreaterThan(256 * 1_024);
|
|
||||||
const expected = JSON.parse(payload) as { update_id: number; message: { text: string } };
|
|
||||||
|
|
||||||
const secret = "secret";
|
|
||||||
const abort = new AbortController();
|
|
||||||
const { server } = await startTelegramWebhook({
|
|
||||||
token: "tok",
|
|
||||||
secret,
|
|
||||||
port: 0,
|
|
||||||
abortSignal: abort.signal,
|
|
||||||
path: "/hook",
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const address = server.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
throw new Error("no addr");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await postWebhookPayloadWithChunkPlan({
|
|
||||||
port: address.port,
|
|
||||||
path: "/hook",
|
|
||||||
payload,
|
|
||||||
secret,
|
|
||||||
mode: "random-chunked",
|
|
||||||
timeoutMs: WEBHOOK_POST_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
expect(seenUpdates).toHaveLength(1);
|
|
||||||
expect(seenUpdates[0]?.update_id).toBe(expected.update_id);
|
|
||||||
expect(seenUpdates[0]?.message.text.length).toBe(expected.message.text.length);
|
|
||||||
expect(sha256(seenUpdates[0]?.message.text ?? "")).toBe(sha256(expected.message.text));
|
|
||||||
} finally {
|
|
||||||
abort.abort();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles near-limit payload written in a single request write", async () => {
|
it("handles near-limit payload written in a single request write", async () => {
|
||||||
const seenUpdates: Array<{ update_id: number; message: { text: string } }> = [];
|
await runNearLimitPayloadTest("single");
|
||||||
webhookCallbackSpy.mockImplementationOnce(
|
|
||||||
() =>
|
|
||||||
vi.fn(
|
|
||||||
(
|
|
||||||
update: unknown,
|
|
||||||
reply: (json: string) => Promise<void>,
|
|
||||||
_secretHeader: string | undefined,
|
|
||||||
_unauthorized: () => Promise<void>,
|
|
||||||
) => {
|
|
||||||
seenUpdates.push(update as { update_id: number; message: { text: string } });
|
|
||||||
void reply("ok");
|
|
||||||
},
|
|
||||||
) as unknown as typeof handlerSpy,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { payload, sizeBytes } = createNearLimitTelegramPayload();
|
|
||||||
expect(sizeBytes).toBeLessThan(1_024 * 1_024);
|
|
||||||
expect(sizeBytes).toBeGreaterThan(256 * 1_024);
|
|
||||||
const expected = JSON.parse(payload) as { update_id: number; message: { text: string } };
|
|
||||||
|
|
||||||
const secret = "secret";
|
|
||||||
const abort = new AbortController();
|
|
||||||
const { server } = await startTelegramWebhook({
|
|
||||||
token: "tok",
|
|
||||||
secret,
|
|
||||||
port: 0,
|
|
||||||
abortSignal: abort.signal,
|
|
||||||
path: "/hook",
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const address = server.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
throw new Error("no addr");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await postWebhookPayloadWithChunkPlan({
|
|
||||||
port: address.port,
|
|
||||||
path: "/hook",
|
|
||||||
payload,
|
|
||||||
secret,
|
|
||||||
mode: "single",
|
|
||||||
timeoutMs: WEBHOOK_POST_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
expect(seenUpdates).toHaveLength(1);
|
|
||||||
expect(seenUpdates[0]?.update_id).toBe(expected.update_id);
|
|
||||||
expect(seenUpdates[0]?.message.text.length).toBe(expected.message.text.length);
|
|
||||||
expect(sha256(seenUpdates[0]?.message.text ?? "")).toBe(sha256(expected.message.text));
|
|
||||||
} finally {
|
|
||||||
abort.abort();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects payloads larger than 1MB before invoking webhook handler", async () => {
|
it("rejects payloads larger than 1MB before invoking webhook handler", async () => {
|
||||||
handlerSpy.mockClear();
|
handlerSpy.mockClear();
|
||||||
const abort = new AbortController();
|
await withStartedWebhook(
|
||||||
const { server } = await startTelegramWebhook({
|
{
|
||||||
token: "tok",
|
secret: TELEGRAM_SECRET,
|
||||||
secret: "secret",
|
path: TELEGRAM_WEBHOOK_PATH,
|
||||||
port: 0,
|
},
|
||||||
abortSignal: abort.signal,
|
async ({ port }) => {
|
||||||
path: "/hook",
|
const responseOrError = await new Promise<
|
||||||
});
|
| { kind: "response"; statusCode: number; body: string }
|
||||||
|
| { kind: "error"; code: string | undefined }
|
||||||
try {
|
>((resolve) => {
|
||||||
const address = server.address();
|
const req = request(
|
||||||
if (!address || typeof address === "string") {
|
{
|
||||||
throw new Error("no addr");
|
hostname: "127.0.0.1",
|
||||||
}
|
port,
|
||||||
|
path: TELEGRAM_WEBHOOK_PATH,
|
||||||
const responseOrError = await new Promise<
|
method: "POST",
|
||||||
| { kind: "response"; statusCode: number; body: string }
|
headers: {
|
||||||
| { kind: "error"; code: string | undefined }
|
"content-type": "application/json",
|
||||||
>((resolve) => {
|
"content-length": String(1_024 * 1_024 + 2_048),
|
||||||
const req = request(
|
"x-telegram-bot-api-secret-token": TELEGRAM_SECRET,
|
||||||
{
|
},
|
||||||
hostname: "127.0.0.1",
|
|
||||||
port: address.port,
|
|
||||||
path: "/hook",
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
"content-length": String(1_024 * 1_024 + 2_048),
|
|
||||||
"x-telegram-bot-api-secret-token": "secret",
|
|
||||||
},
|
},
|
||||||
},
|
(res) => {
|
||||||
(res) => {
|
const chunks: Buffer[] = [];
|
||||||
const chunks: Buffer[] = [];
|
res.on("data", (chunk: Buffer | string) => {
|
||||||
res.on("data", (chunk: Buffer | string) => {
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
||||||
});
|
|
||||||
res.on("end", () => {
|
|
||||||
resolve({
|
|
||||||
kind: "response",
|
|
||||||
statusCode: res.statusCode ?? 0,
|
|
||||||
body: Buffer.concat(chunks).toString("utf-8"),
|
|
||||||
});
|
});
|
||||||
});
|
res.on("end", () => {
|
||||||
},
|
resolve({
|
||||||
);
|
kind: "response",
|
||||||
req.on("error", (error: NodeJS.ErrnoException) => {
|
statusCode: res.statusCode ?? 0,
|
||||||
resolve({ kind: "error", code: error.code });
|
body: Buffer.concat(chunks).toString("utf-8"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", (error: NodeJS.ErrnoException) => {
|
||||||
|
resolve({ kind: "error", code: error.code });
|
||||||
|
});
|
||||||
|
req.end("{}");
|
||||||
});
|
});
|
||||||
req.end("{}");
|
|
||||||
});
|
|
||||||
|
|
||||||
if (responseOrError.kind === "response") {
|
if (responseOrError.kind === "response") {
|
||||||
expect(responseOrError.statusCode).toBe(413);
|
expect(responseOrError.statusCode).toBe(413);
|
||||||
expect(responseOrError.body).toBe("Payload too large");
|
expect(responseOrError.body).toBe("Payload too large");
|
||||||
} else {
|
} else {
|
||||||
expect(responseOrError.code).toBeOneOf(["ECONNRESET", "EPIPE"]);
|
expect(responseOrError.code).toBeOneOf(["ECONNRESET", "EPIPE"]);
|
||||||
}
|
}
|
||||||
expect(handlerSpy).not.toHaveBeenCalled();
|
expect(handlerSpy).not.toHaveBeenCalled();
|
||||||
} finally {
|
},
|
||||||
abort.abort();
|
);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("de-registers webhook when shutting down", async () => {
|
it("de-registers webhook when shutting down", async () => {
|
||||||
deleteWebhookSpy.mockClear();
|
deleteWebhookSpy.mockClear();
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
await startTelegramWebhook({
|
await startTelegramWebhook({
|
||||||
token: "tok",
|
token: TELEGRAM_TOKEN,
|
||||||
secret: "secret",
|
secret: TELEGRAM_SECRET,
|
||||||
port: 0,
|
port: 0,
|
||||||
abortSignal: abort.signal,
|
abortSignal: abort.signal,
|
||||||
path: "/hook",
|
path: TELEGRAM_WEBHOOK_PATH,
|
||||||
});
|
});
|
||||||
|
|
||||||
abort.abort();
|
abort.abort();
|
||||||
|
|||||||
@@ -12,62 +12,63 @@ const createSelector = () => {
|
|||||||
return selector;
|
return selector;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createShellHarness(params?: {
|
||||||
|
spawnCommand?: typeof import("node:child_process").spawn;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
const messages: string[] = [];
|
||||||
|
const chatLog = {
|
||||||
|
addSystem: (line: string) => {
|
||||||
|
messages.push(line);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const tui = { requestRender: vi.fn() };
|
||||||
|
const openOverlay = vi.fn();
|
||||||
|
const closeOverlay = vi.fn();
|
||||||
|
let lastSelector: ReturnType<typeof createSelector> | null = null;
|
||||||
|
const createSelectorSpy = vi.fn(() => {
|
||||||
|
lastSelector = createSelector();
|
||||||
|
return lastSelector;
|
||||||
|
});
|
||||||
|
const spawnCommand = params?.spawnCommand ?? vi.fn();
|
||||||
|
const { runLocalShellLine } = createLocalShellRunner({
|
||||||
|
chatLog,
|
||||||
|
tui,
|
||||||
|
openOverlay,
|
||||||
|
closeOverlay,
|
||||||
|
createSelector: createSelectorSpy,
|
||||||
|
spawnCommand,
|
||||||
|
...(params?.env ? { env: params.env } : {}),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
openOverlay,
|
||||||
|
createSelectorSpy,
|
||||||
|
spawnCommand,
|
||||||
|
runLocalShellLine,
|
||||||
|
getLastSelector: () => lastSelector,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("createLocalShellRunner", () => {
|
describe("createLocalShellRunner", () => {
|
||||||
it("logs denial on subsequent ! attempts without re-prompting", async () => {
|
it("logs denial on subsequent ! attempts without re-prompting", async () => {
|
||||||
const messages: string[] = [];
|
const harness = createShellHarness();
|
||||||
const chatLog = {
|
|
||||||
addSystem: (line: string) => {
|
|
||||||
messages.push(line);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const tui = { requestRender: vi.fn() };
|
|
||||||
const openOverlay = vi.fn();
|
|
||||||
const closeOverlay = vi.fn();
|
|
||||||
let lastSelector: ReturnType<typeof createSelector> | null = null;
|
|
||||||
const createSelectorSpy = vi.fn(() => {
|
|
||||||
lastSelector = createSelector();
|
|
||||||
return lastSelector;
|
|
||||||
});
|
|
||||||
const spawnCommand = vi.fn();
|
|
||||||
|
|
||||||
const { runLocalShellLine } = createLocalShellRunner({
|
const firstRun = harness.runLocalShellLine("!ls");
|
||||||
chatLog,
|
expect(harness.openOverlay).toHaveBeenCalledTimes(1);
|
||||||
tui,
|
const selector = harness.getLastSelector();
|
||||||
openOverlay,
|
|
||||||
closeOverlay,
|
|
||||||
createSelector: createSelectorSpy,
|
|
||||||
spawnCommand,
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstRun = runLocalShellLine("!ls");
|
|
||||||
expect(openOverlay).toHaveBeenCalledTimes(1);
|
|
||||||
const selector = lastSelector as ReturnType<typeof createSelector> | null;
|
|
||||||
selector?.onSelect?.({ value: "no", label: "No" });
|
selector?.onSelect?.({ value: "no", label: "No" });
|
||||||
await firstRun;
|
await firstRun;
|
||||||
|
|
||||||
await runLocalShellLine("!pwd");
|
await harness.runLocalShellLine("!pwd");
|
||||||
|
|
||||||
expect(messages).toContain("local shell: not enabled");
|
expect(harness.messages).toContain("local shell: not enabled");
|
||||||
expect(messages).toContain("local shell: not enabled for this session");
|
expect(harness.messages).toContain("local shell: not enabled for this session");
|
||||||
expect(createSelectorSpy).toHaveBeenCalledTimes(1);
|
expect(harness.createSelectorSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(spawnCommand).not.toHaveBeenCalled();
|
expect(harness.spawnCommand).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets OPENCLAW_SHELL when running local shell commands", async () => {
|
it("sets OPENCLAW_SHELL when running local shell commands", async () => {
|
||||||
const messages: string[] = [];
|
|
||||||
const chatLog = {
|
|
||||||
addSystem: (line: string) => {
|
|
||||||
messages.push(line);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const tui = { requestRender: vi.fn() };
|
|
||||||
const openOverlay = vi.fn();
|
|
||||||
const closeOverlay = vi.fn();
|
|
||||||
let lastSelector: ReturnType<typeof createSelector> | null = null;
|
|
||||||
const createSelectorSpy = vi.fn(() => {
|
|
||||||
lastSelector = createSelector();
|
|
||||||
return lastSelector;
|
|
||||||
});
|
|
||||||
const spawnCommand = vi.fn((_command: string, _options: unknown) => {
|
const spawnCommand = vi.fn((_command: string, _options: unknown) => {
|
||||||
const stdout = new EventEmitter();
|
const stdout = new EventEmitter();
|
||||||
const stderr = new EventEmitter();
|
const stderr = new EventEmitter();
|
||||||
@@ -82,27 +83,22 @@ describe("createLocalShellRunner", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const { runLocalShellLine } = createLocalShellRunner({
|
const harness = createShellHarness({
|
||||||
chatLog,
|
|
||||||
tui,
|
|
||||||
openOverlay,
|
|
||||||
closeOverlay,
|
|
||||||
createSelector: createSelectorSpy,
|
|
||||||
spawnCommand: spawnCommand as unknown as typeof import("node:child_process").spawn,
|
spawnCommand: spawnCommand as unknown as typeof import("node:child_process").spawn,
|
||||||
env: { PATH: "/tmp/bin", USER: "dev" },
|
env: { PATH: "/tmp/bin", USER: "dev" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstRun = runLocalShellLine("!echo hi");
|
const firstRun = harness.runLocalShellLine("!echo hi");
|
||||||
expect(openOverlay).toHaveBeenCalledTimes(1);
|
expect(harness.openOverlay).toHaveBeenCalledTimes(1);
|
||||||
const selector = lastSelector as ReturnType<typeof createSelector> | null;
|
const selector = harness.getLastSelector();
|
||||||
selector?.onSelect?.({ value: "yes", label: "Yes" });
|
selector?.onSelect?.({ value: "yes", label: "Yes" });
|
||||||
await firstRun;
|
await firstRun;
|
||||||
|
|
||||||
expect(createSelectorSpy).toHaveBeenCalledTimes(1);
|
expect(harness.createSelectorSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(spawnCommand).toHaveBeenCalledTimes(1);
|
expect(spawnCommand).toHaveBeenCalledTimes(1);
|
||||||
const spawnOptions = spawnCommand.mock.calls[0]?.[1] as { env?: Record<string, string> };
|
const spawnOptions = spawnCommand.mock.calls[0]?.[1] as { env?: Record<string, string> };
|
||||||
expect(spawnOptions.env?.OPENCLAW_SHELL).toBe("tui-local");
|
expect(spawnOptions.env?.OPENCLAW_SHELL).toBe("tui-local");
|
||||||
expect(spawnOptions.env?.PATH).toBe("/tmp/bin");
|
expect(spawnOptions.env?.PATH).toBe("/tmp/bin");
|
||||||
expect(messages).toContain("local shell: enabled for this session");
|
expect(harness.messages).toContain("local shell: enabled for this session");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user