Compare commits

..
Author SHA1 Message Date
Peter Steinberger 6607f865cf fix: harden gemini cli oauth discovery (#1773) (thanks @benostein) 2026-01-25 13:07:03 +00:00
Ben Stein f0f5bc6cf1 fix(gemini-cli-auth): auto-extract OAuth credentials from installed Gemini CLI
Fixes #1765

- Extract client ID and secret from Gemini CLI's bundled oauth2.js
- Cross-platform binary lookup (no shell commands)
- Fallback to env vars for user override
- Add tests for credential extraction
2026-01-25 12:58:13 +00:00
Peter Steinbergerandymat19 7a5e103a6a fix: treat Windows platform labels as Windows for node shell (#1760)
Thanks @ymat19.

Co-authored-by: ymat19 <45934497+ymat19@users.noreply.github.com>
2026-01-25 12:57:06 +00:00
ymat19 4e23b7f654 fix: use exact match for win32 platform detection
The previous check used includes("win") which incorrectly matched
"darwin" (macOS) because it contains "win". This caused cmd.exe to be
used on macOS instead of /bin/sh.
2026-01-25 12:57:06 +00:00
Senol Dogan 7253bf398d feat: audit fixes and documentation improvements (#1762)
* feat: audit fixes and documentation improvements

- Refactored model selection to drop legacy fallback and add warning
- Improved heartbeat content validation
- Added Skill Creation guide
- Updated CONTRIBUTING.md with roadmap

* style: fix formatting in model-selection.ts

* style: fix formatting and improve model selection logic with tests
2026-01-25 12:54:48 +00:00
Peter SteinbergerandAkshay 026def686e fix(matrix): decrypt E2EE media + size guard (#1744)
Thanks @araa47.

Co-authored-by: Akshay <araa47@users.noreply.github.com>
2026-01-25 12:53:57 +00:00
14 changed files with 526 additions and 45 deletions

No files matched your search

+3 -1
View File
@@ -28,8 +28,11 @@ Docs: https://docs.clawd.bot
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
### Fixes
- Gemini CLI OAuth: auto-detect client id from installed CLI and harden discovery paths. (#1773) Thanks @benostein.
- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.
- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete.
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19.
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Web UI: hide internal `message_id` hints in chat bubbles.
@@ -46,7 +49,6 @@ Docs: https://docs.clawd.bot
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery.
- Models: default missing custom provider fields so minimal configs are accepted.
- Models/Heartbeat: normalize provider-less default model fallback and skip empty checklist items. (#1762) Thanks @senoldogann.
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
+1 -1
View File
@@ -46,7 +46,7 @@ AI PRs are first-class citizens here. We just want transparency so reviewers kno
We are currently prioritizing:
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
- **UX**: Improving the onboarding wizard and error messages.
- **Skills**: ClawdHub-first (no new core/bundled skills).
- **Skills**: Expanding the library of bundled skills and improving the Skill Creation developer experience.
- **Performance**: Optimizing token usage and compaction logic.
Check the [GitHub Issues](https://github.com/clawdbot/clawdbot/issues) for "good first issue" labels!
+5 -5
View File
@@ -8,7 +8,7 @@ A skill is a directory containing a `SKILL.md` file (which provides instructions
## Step-by-Step: Your First Skill
### 1. Create the Directory
Skills live in your workspace at `<workspace>/skills` (default: `~/clawd/skills`). Create a new folder for your skill:
Skills live in your workspace, usually `~/clawd/skills/`. Create a new folder for your skill:
```bash
mkdir -p ~/clawd/skills/hello-world
```
@@ -23,18 +23,18 @@ description: A simple skill that says hello.
---
# Hello World Skill
When the user asks for a greeting, use the `exec` tool to run `echo "Hello from your custom skill!"`.
When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!".
```
### 3. Add Tools (Optional)
You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `exec` or `web`).
You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`).
### 4. Refresh Clawdbot
If the skills watcher is enabled (default), changes are picked up automatically. Otherwise restart the gateway or start a new session to refresh the skills snapshot.
Ask your agent to "refresh skills" or restart the gateway. Clawdbot will discover the new directory and index the `SKILL.md`.
## Best Practices
- **Be Concise**: Instruct the model on *what* to do, not how to be an AI.
- **Safety First**: If your skill uses `exec`, ensure the prompts don't allow arbitrary command injection from untrusted user input.
- **Safety First**: If your skill uses `bash`, ensure the prompts don't allow arbitrary command injection from untrusted user input.
- **Test Locally**: Use `clawdbot agent --message "use my new skill"` to test.
## Shared Skills
+12 -1
View File
@@ -18,7 +18,18 @@ Restart the Gateway after enabling.
clawdbot models auth login --provider google-gemini-cli --set-default
```
## Env vars
## Requirements
If the Gemini CLI is installed, Clawdbot extracts the OAuth client ID automatically.
```bash
brew install gemini-cli
# or: npm install -g @google/gemini-cli
```
## Env vars (optional)
Override auto-detected credentials with:
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { join } from "node:path";
// Mock fs module before importing the module under test
const mockExistsSync = vi.fn();
const mockReadFileSync = vi.fn();
const mockRealpathSync = vi.fn();
const mockReaddirSync = vi.fn();
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: (...args: Parameters<typeof actual.existsSync>) => mockExistsSync(...args),
readFileSync: (...args: Parameters<typeof actual.readFileSync>) => mockReadFileSync(...args),
realpathSync: (...args: Parameters<typeof actual.realpathSync>) => mockRealpathSync(...args),
readdirSync: (...args: Parameters<typeof actual.readdirSync>) => mockReaddirSync(...args),
};
});
describe("extractGeminiCliCredentials", () => {
const FAKE_CLIENT_ID = "123456789-abcdef.apps.googleusercontent.com";
const FAKE_CLIENT_SECRET = "GOCSPX-FakeSecretValue123";
const FAKE_OAUTH2_CONTENT = `
const clientId = "${FAKE_CLIENT_ID}";
const clientSecret = "${FAKE_CLIENT_SECRET}";
`;
const FAKE_OAUTH2_ID_ONLY = `
const clientId = "${FAKE_CLIENT_ID}";
`;
let originalPath: string | undefined;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalPath = process.env.PATH;
});
afterEach(() => {
process.env.PATH = originalPath;
});
it("returns null when gemini binary is not in PATH", async () => {
process.env.PATH = "/nonexistent";
mockExistsSync.mockReturnValue(false);
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
clearCredentialsCache();
expect(extractGeminiCliCredentials()).toBeNull();
});
it("extracts credentials from oauth2.js in known path", async () => {
const fakeBinDir = "/fake/bin";
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`;
const fakeShimContent = `node "${fakeResolvedPath}"`;
process.env.PATH = fakeBinDir;
mockExistsSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath) return true;
if (p === fakeCliRoot) return true;
if (p === fakeOauth2Path) return true;
return false;
});
mockRealpathSync.mockReturnValue(fakeResolvedPath);
mockReadFileSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
if (p === fakeOauth2Path) return FAKE_OAUTH2_CONTENT;
return "";
});
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
clearCredentialsCache();
const result = extractGeminiCliCredentials();
expect(result).toEqual({
clientId: FAKE_CLIENT_ID,
clientSecret: FAKE_CLIENT_SECRET,
});
});
it("returns null when oauth2.js cannot be found", async () => {
const fakeBinDir = "/fake/bin";
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
const fakeShimContent = `node "${fakeResolvedPath}"`;
process.env.PATH = fakeBinDir;
mockExistsSync.mockImplementation((p: string) => p === fakeGeminiPath || p === fakeCliRoot);
mockRealpathSync.mockReturnValue(fakeResolvedPath);
mockReaddirSync.mockReturnValue([]); // Empty directory for recursive search
mockReadFileSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
return "";
});
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
clearCredentialsCache();
expect(extractGeminiCliCredentials()).toBeNull();
});
it("returns null when oauth2.js lacks credentials", async () => {
const fakeBinDir = "/fake/bin";
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`;
const fakeShimContent = `node "${fakeResolvedPath}"`;
process.env.PATH = fakeBinDir;
mockExistsSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath) return true;
if (p === fakeCliRoot) return true;
if (p === fakeOauth2Path) return true;
return false;
});
mockRealpathSync.mockReturnValue(fakeResolvedPath);
mockReadFileSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
if (p === fakeOauth2Path) return "// no credentials here";
return "";
});
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
clearCredentialsCache();
expect(extractGeminiCliCredentials()).toBeNull();
});
it("caches credentials after first extraction", async () => {
const fakeBinDir = "/fake/bin";
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`;
const fakeShimContent = `node "${fakeResolvedPath}"`;
process.env.PATH = fakeBinDir;
mockExistsSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath) return true;
if (p === fakeCliRoot) return true;
if (p === fakeOauth2Path) return true;
return false;
});
mockRealpathSync.mockReturnValue(fakeResolvedPath);
mockReadFileSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
if (p === fakeOauth2Path) return FAKE_OAUTH2_CONTENT;
return "";
});
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
clearCredentialsCache();
// First call
const result1 = extractGeminiCliCredentials();
expect(result1).not.toBeNull();
// Second call should use cache (readFileSync not called again)
const readCount = mockReadFileSync.mock.calls.length;
const result2 = extractGeminiCliCredentials();
expect(result2).toEqual(result1);
expect(mockReadFileSync.mock.calls.length).toBe(readCount);
});
it("returns client id when secret is missing", async () => {
const fakeBinDir = "/fake/bin";
const fakeGeminiPath = join(fakeBinDir, "gemini");
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`;
const fakeShimContent = `node "${fakeResolvedPath}"`;
process.env.PATH = fakeBinDir;
mockExistsSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath) return true;
if (p === fakeCliRoot) return true;
if (p === fakeOauth2Path) return true;
return false;
});
mockRealpathSync.mockReturnValue(fakeResolvedPath);
mockReadFileSync.mockImplementation((p: string) => {
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
if (p === fakeOauth2Path) return FAKE_OAUTH2_ID_ONLY;
return "";
});
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
clearCredentialsCache();
const result = extractGeminiCliCredentials();
expect(result).toEqual({
clientId: FAKE_CLIENT_ID,
clientSecret: undefined,
});
});
});
+198 -9
View File
@@ -1,6 +1,7 @@
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
import { createServer } from "node:http";
import { delimiter, dirname, isAbsolute, join, normalize } from "node:path";
const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
const CLIENT_SECRET_KEYS = [
@@ -47,15 +48,203 @@ function resolveEnv(keys: string[]): string | undefined {
return undefined;
}
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
const clientId = resolveEnv(CLIENT_ID_KEYS);
if (!clientId) {
throw new Error(
"Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).",
);
type GeminiCliClientConfig = { clientId: string; clientSecret?: string };
let cachedGeminiCliCredentials: GeminiCliClientConfig | null = null;
/** @internal */
export function clearCredentialsCache(): void {
cachedGeminiCliCredentials = null;
}
/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */
export function extractGeminiCliCredentials(): GeminiCliClientConfig | null {
if (cachedGeminiCliCredentials) return cachedGeminiCliCredentials;
try {
const oauthPath = findGeminiCliOAuthPath();
if (!oauthPath) return null;
const content = readFileSync(oauthPath, "utf8");
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/i);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (idMatch) {
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch?.[1] };
return cachedGeminiCliCredentials;
}
} catch {
// Gemini CLI not installed or extraction failed
}
const clientSecret = resolveEnv(CLIENT_SECRET_KEYS);
return { clientId, clientSecret };
return null;
}
function findGeminiCliOAuthPath(): string | null {
const geminiPath = findInPath("gemini");
if (!geminiPath) return null;
for (const root of getGeminiCliRoots(geminiPath)) {
const searchPaths = [
join(root, "node_modules", "@google", "gemini-cli-core", "dist", "src", "code_assist", "oauth2.js"),
join(root, "node_modules", "@google", "gemini-cli-core", "dist", "code_assist", "oauth2.js"),
];
for (const p of searchPaths) {
if (existsSync(p)) return p;
}
if (shouldSearchRoot(root)) {
const found = findFile(root, "oauth2.js", 8);
if (found) return found;
}
}
return null;
}
function getGeminiCliRoots(geminiPath: string): string[] {
const roots = new Set<string>();
const resolved = safeRealpath(geminiPath);
for (const p of [geminiPath, resolved].filter(Boolean) as string[]) {
for (const root of extractGeminiCliRootsFromFile(p)) {
roots.add(root);
}
const fromPath = extractGeminiCliRootFromPath(p);
if (fromPath) roots.add(fromPath);
}
for (const globalRoot of getGlobalNodeModulesRoots()) {
const candidate = join(globalRoot, "@google", "gemini-cli");
if (existsSync(candidate)) roots.add(candidate);
}
return [...roots].filter((root) => existsSync(root));
}
function extractGeminiCliRootsFromFile(filePath: string): string[] {
const roots: string[] = [];
try {
const content = readFileSync(filePath, "utf8");
if (!content.includes("gemini-cli")) return roots;
const dir = dirname(filePath);
const regex = /["']([^"'\\r\\n]*@google[\\/]+gemini-cli[\\/]+dist[\\/]+index\\.js)["']/gi;
for (const match of content.matchAll(regex)) {
const resolved = resolveShimPath(dir, match[1]);
const root = extractGeminiCliRootFromPath(resolved);
if (root) roots.push(root);
}
} catch {
// ignore non-text files or missing paths
}
return roots;
}
function resolveShimPath(baseDir: string, rawPath: string): string {
let candidate = rawPath
.replace(/%~?dp0%/gi, baseDir)
.replace(/\$PSScriptRoot/gi, baseDir)
.replace(/__dirname/g, baseDir);
if (!isAbsolute(candidate)) {
candidate = join(baseDir, candidate);
}
return normalize(candidate);
}
function extractGeminiCliRootFromPath(pathValue: string): string | null {
const normalized = pathValue.replace(/\\/g, "/");
const marker = "/@google/gemini-cli/";
const idx = normalized.indexOf(marker);
if (idx === -1) return null;
return normalize(`${normalized.slice(0, idx)}${marker.slice(0, -1)}`);
}
function safeRealpath(pathValue: string): string | null {
try {
return realpathSync(pathValue);
} catch {
return null;
}
}
function getGlobalNodeModulesRoots(): string[] {
const roots = new Set<string>();
const prefix = process.env.npm_config_prefix ?? process.env.NPM_CONFIG_PREFIX ?? process.env.PREFIX;
if (prefix) {
if (process.platform === "win32") {
roots.add(join(prefix, "node_modules"));
} else {
roots.add(join(prefix, "lib", "node_modules"));
}
}
if (process.platform === "win32") {
const appData = process.env.APPDATA;
if (appData) roots.add(join(appData, "npm", "node_modules"));
} else {
roots.add("/usr/local/lib/node_modules");
roots.add("/opt/homebrew/lib/node_modules");
}
const pnpmHome = process.env.PNPM_HOME;
if (pnpmHome) {
roots.add(join(pnpmHome, "global", "5", "node_modules"));
roots.add(join(pnpmHome, "global", "4", "node_modules"));
}
return [...roots].filter((root) => existsSync(root));
}
function shouldSearchRoot(root: string): boolean {
const normalized = root.replace(/\\/g, "/");
if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) return false;
const segments = normalized.split("/").filter(Boolean);
return segments.length >= 3;
}
function findInPath(name: string): string | null {
const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""];
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
for (const ext of exts) {
const p = join(dir, name + ext);
if (existsSync(p)) return p;
}
}
return null;
}
function findFile(dir: string, name: string, depth: number): string | null {
if (depth <= 0) return null;
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isFile() && entry.name === name) return p;
if (entry.isDirectory() && !entry.name.startsWith(".")) {
const found = findFile(p, name, depth - 1);
if (found) return found;
}
}
} catch {}
return null;
}
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
// 1. Check env vars first (user override)
const envClientId = resolveEnv(CLIENT_ID_KEYS);
const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS);
if (envClientId) {
return { clientId: envClientId, clientSecret: envClientSecret };
}
// 2. Try to extract from installed Gemini CLI
const extracted = extractGeminiCliCredentials();
if (extracted?.clientId) {
return extracted;
}
// 3. No credentials available
throw new Error(
"Gemini CLI credentials not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.",
);
}
function isWSL(): boolean {
@@ -329,16 +329,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
return;
}
const contentType =
"info" in content && content.info && "mimetype" in content.info
? (content.info as { mimetype?: string }).mimetype
const contentInfo =
"info" in content && content.info && typeof content.info === "object"
? (content.info as { mimetype?: string; size?: number })
: undefined;
const contentType = contentInfo?.mimetype;
const contentSize =
typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
if (mediaUrl?.startsWith("mxc://")) {
try {
media = await downloadMatrixMedia({
client,
mxcUrl: mediaUrl,
contentType,
sizeBytes: contentSize,
maxBytes: mediaMaxBytes,
file: contentFile,
});
@@ -25,10 +25,8 @@ describe("downloadMatrixMedia", () => {
it("decrypts encrypted media when file payloads are present", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted"));
const client = {
downloadContent,
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("matrix-bot-sdk").MatrixClient;
@@ -55,7 +53,8 @@ describe("downloadMatrixMedia", () => {
file,
});
expect(decryptMedia).toHaveBeenCalled();
// decryptMedia should be called with just the file object (it handles download internally)
expect(decryptMedia).toHaveBeenCalledWith(file);
expect(saveMediaBuffer).toHaveBeenCalledWith(
Buffer.from("decrypted"),
"image/png",
@@ -64,4 +63,41 @@ describe("downloadMatrixMedia", () => {
);
expect(result?.path).toBe("/tmp/media");
});
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
};
await expect(
downloadMatrixMedia({
client,
mxcUrl: "mxc://example/file",
contentType: "image/png",
sizeBytes: 2048,
maxBytes: 1024,
file,
}),
).rejects.toThrow("Matrix media exceeds configured size limit");
expect(decryptMedia).not.toHaveBeenCalled();
expect(saveMediaBuffer).not.toHaveBeenCalled();
});
});
+12 -9
View File
@@ -40,6 +40,7 @@ async function fetchMatrixMediaBuffer(params: {
/**
* Download and decrypt encrypted media from a Matrix room.
* Uses matrix-bot-sdk's decryptMedia which handles both download and decryption.
*/
async function fetchEncryptedMediaBuffer(params: {
client: MatrixClient;
@@ -50,18 +51,13 @@ async function fetchEncryptedMediaBuffer(params: {
throw new Error("Cannot decrypt media: crypto not enabled");
}
// Download the encrypted content
const encryptedBuffer = await params.client.downloadContent(params.file.url);
if (encryptedBuffer.byteLength > params.maxBytes) {
// decryptMedia handles downloading and decrypting the encrypted content internally
const decrypted = await params.client.crypto.decryptMedia(params.file);
if (decrypted.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
// Decrypt using matrix-bot-sdk crypto
const decrypted = await params.client.crypto.decryptMedia(
Buffer.from(encryptedBuffer),
params.file,
);
return { buffer: decrypted };
}
@@ -69,6 +65,7 @@ export async function downloadMatrixMedia(params: {
client: MatrixClient;
mxcUrl: string;
contentType?: string;
sizeBytes?: number;
maxBytes: number;
file?: EncryptedFile;
}): Promise<{
@@ -77,6 +74,12 @@ export async function downloadMatrixMedia(params: {
placeholder: string;
} | null> {
let fetched: { buffer: Buffer; headerType?: string } | null;
if (
typeof params.sizeBytes === "number" &&
params.sizeBytes > params.maxBytes
) {
throw new Error("Matrix media exceeds configured size limit");
}
if (params.file) {
// Encrypted media
@@ -29,6 +29,7 @@ export type RoomMessageEventContent = MessageEventContent & {
file?: EncryptedFile;
info?: {
mimetype?: string;
size?: number;
};
"m.relates_to"?: {
rel_type?: string;
+2 -3
View File
@@ -137,11 +137,10 @@ export function resolveConfiguredModelRef(params: {
if (aliasMatch) return aliasMatch.ref;
// Default to anthropic if no provider is specified, but warn as this is deprecated.
const normalized = normalizeProviderModelId("anthropic", trimmed);
console.warn(
`[clawdbot] Model "${trimmed}" specified without provider. Falling back to "anthropic/${normalized}". Please use "anthropic/${normalized}" in your config.`,
`[clawdbot] Model "${trimmed}" specified without provider. Falling back to "anthropic/${trimmed}". Please use "anthropic/${trimmed}" in your config.`,
);
return { provider: "anthropic", model: normalized };
return { provider: "anthropic", model: trimmed };
}
const resolved = resolveModelRefFromString({
-9
View File
@@ -133,15 +133,6 @@ describe("isHeartbeatContentEffectivelyEmpty", () => {
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n")).toBe(true);
});
it("returns true for empty list items", () => {
expect(isHeartbeatContentEffectivelyEmpty("- ")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("- [ ]")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("- [x]")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("* [ ]")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("+ [ ]")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n- [ ]")).toBe(true);
});
it("returns true for comments only", () => {
expect(isHeartbeatContentEffectivelyEmpty("# Header\n# Another comment")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("## Subheader\n### Another")).toBe(true);
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { buildNodeShellCommand } from "./node-shell.js";
describe("buildNodeShellCommand", () => {
it("uses cmd.exe for win32", () => {
expect(buildNodeShellCommand("echo hi", "win32")).toEqual([
"cmd.exe",
"/d",
"/s",
"/c",
"echo hi",
]);
});
it("uses cmd.exe for windows labels", () => {
expect(buildNodeShellCommand("echo hi", "windows")).toEqual([
"cmd.exe",
"/d",
"/s",
"/c",
"echo hi",
]);
expect(buildNodeShellCommand("echo hi", "Windows 11")).toEqual([
"cmd.exe",
"/d",
"/s",
"/c",
"echo hi",
]);
});
it("uses /bin/sh for darwin", () => {
expect(buildNodeShellCommand("echo hi", "darwin")).toEqual(["/bin/sh", "-lc", "echo hi"]);
});
it("uses /bin/sh when platform missing", () => {
expect(buildNodeShellCommand("echo hi")).toEqual(["/bin/sh", "-lc", "echo hi"]);
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ export function buildNodeShellCommand(command: string, platform?: string | null)
const normalized = String(platform ?? "")
.trim()
.toLowerCase();
if (normalized.includes("win")) {
if (normalized.startsWith("win")) {
return ["cmd.exe", "/d", "/s", "/c", command];
}
return ["/bin/sh", "-lc", command];