test(integration): dedupe messaging, secrets, and plugin test suites

This commit is contained in:
Peter Steinberger
2026-03-02 06:41:31 +00:00
parent d3e0c0b29c
commit 45888276a3
21 changed files with 1840 additions and 2416 deletions

View File

@@ -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);
}); });
}); });

View File

@@ -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();

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -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);
}); });

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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({

View File

@@ -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",

View 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");

View File

@@ -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"');

View File

@@ -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", () => {

View File

@@ -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();
}); });
}); });

View File

@@ -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);
}); });

View File

@@ -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();
}); });
}); });

View File

@@ -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);
}); });

View File

@@ -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);
}); });

View File

@@ -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
); );

View File

@@ -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"]);
}); });
}); });

View File

@@ -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();

View File

@@ -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");
}); });
}); });