Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6607f865cf | ||
|
|
f0f5bc6cf1 | ||
|
|
7a5e103a6a | ||
|
|
4e23b7f654 | ||
|
|
7253bf398d | ||
|
|
026def686e |
No files matched your search
+3
-1
@@ -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
|
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
|
||||||
|
|
||||||
### Fixes
|
### 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: 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.
|
- 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: 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.
|
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
- 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: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||||
- Agents: use the active auth profile for auto-compaction recovery.
|
- Agents: use the active auth profile for auto-compaction recovery.
|
||||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
- 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.
|
- 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: 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.
|
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ AI PRs are first-class citizens here. We just want transparency so reviewers kno
|
|||||||
We are currently prioritizing:
|
We are currently prioritizing:
|
||||||
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
|
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
|
||||||
- **UX**: Improving the onboarding wizard and error messages.
|
- **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.
|
- **Performance**: Optimizing token usage and compaction logic.
|
||||||
|
|
||||||
Check the [GitHub Issues](https://github.com/clawdbot/clawdbot/issues) for "good first issue" labels!
|
Check the [GitHub Issues](https://github.com/clawdbot/clawdbot/issues) for "good first issue" labels!
|
||||||
@@ -8,7 +8,7 @@ A skill is a directory containing a `SKILL.md` file (which provides instructions
|
|||||||
## Step-by-Step: Your First Skill
|
## Step-by-Step: Your First Skill
|
||||||
|
|
||||||
### 1. Create the Directory
|
### 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
|
```bash
|
||||||
mkdir -p ~/clawd/skills/hello-world
|
mkdir -p ~/clawd/skills/hello-world
|
||||||
```
|
```
|
||||||
@@ -23,18 +23,18 @@ description: A simple skill that says hello.
|
|||||||
---
|
---
|
||||||
|
|
||||||
# Hello World Skill
|
# 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)
|
### 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
|
### 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
|
## Best Practices
|
||||||
- **Be Concise**: Instruct the model on *what* to do, not how to be an AI.
|
- **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.
|
- **Test Locally**: Use `clawdbot agent --message "use my new skill"` to test.
|
||||||
|
|
||||||
## Shared Skills
|
## Shared Skills
|
||||||
|
|||||||
@@ -18,7 +18,18 @@ Restart the Gateway after enabling.
|
|||||||
clawdbot models auth login --provider google-gemini-cli --set-default
|
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_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
|
||||||
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
|
- `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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
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 { 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_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
|
||||||
const CLIENT_SECRET_KEYS = [
|
const CLIENT_SECRET_KEYS = [
|
||||||
@@ -47,15 +48,203 @@ function resolveEnv(keys: string[]): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
|
type GeminiCliClientConfig = { clientId: string; clientSecret?: string };
|
||||||
const clientId = resolveEnv(CLIENT_ID_KEYS);
|
|
||||||
if (!clientId) {
|
let cachedGeminiCliCredentials: GeminiCliClientConfig | null = null;
|
||||||
throw new Error(
|
|
||||||
"Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).",
|
/** @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 null;
|
||||||
return { clientId, clientSecret };
|
}
|
||||||
|
|
||||||
|
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 {
|
function isWSL(): boolean {
|
||||||
|
|||||||
@@ -329,16 +329,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType =
|
const contentInfo =
|
||||||
"info" in content && content.info && "mimetype" in content.info
|
"info" in content && content.info && typeof content.info === "object"
|
||||||
? (content.info as { mimetype?: string }).mimetype
|
? (content.info as { mimetype?: string; size?: number })
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const contentType = contentInfo?.mimetype;
|
||||||
|
const contentSize =
|
||||||
|
typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
|
||||||
if (mediaUrl?.startsWith("mxc://")) {
|
if (mediaUrl?.startsWith("mxc://")) {
|
||||||
try {
|
try {
|
||||||
media = await downloadMatrixMedia({
|
media = await downloadMatrixMedia({
|
||||||
client,
|
client,
|
||||||
mxcUrl: mediaUrl,
|
mxcUrl: mediaUrl,
|
||||||
contentType,
|
contentType,
|
||||||
|
sizeBytes: contentSize,
|
||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
file: contentFile,
|
file: contentFile,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,10 +25,8 @@ describe("downloadMatrixMedia", () => {
|
|||||||
|
|
||||||
it("decrypts encrypted media when file payloads are present", async () => {
|
it("decrypts encrypted media when file payloads are present", async () => {
|
||||||
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
||||||
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted"));
|
|
||||||
|
|
||||||
const client = {
|
const client = {
|
||||||
downloadContent,
|
|
||||||
crypto: { decryptMedia },
|
crypto: { decryptMedia },
|
||||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||||
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
||||||
@@ -55,7 +53,8 @@ describe("downloadMatrixMedia", () => {
|
|||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(decryptMedia).toHaveBeenCalled();
|
// decryptMedia should be called with just the file object (it handles download internally)
|
||||||
|
expect(decryptMedia).toHaveBeenCalledWith(file);
|
||||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||||
Buffer.from("decrypted"),
|
Buffer.from("decrypted"),
|
||||||
"image/png",
|
"image/png",
|
||||||
@@ -64,4 +63,41 @@ describe("downloadMatrixMedia", () => {
|
|||||||
);
|
);
|
||||||
expect(result?.path).toBe("/tmp/media");
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -40,6 +40,7 @@ async function fetchMatrixMediaBuffer(params: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Download and decrypt encrypted media from a Matrix room.
|
* 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: {
|
async function fetchEncryptedMediaBuffer(params: {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -50,18 +51,13 @@ async function fetchEncryptedMediaBuffer(params: {
|
|||||||
throw new Error("Cannot decrypt media: crypto not enabled");
|
throw new Error("Cannot decrypt media: crypto not enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download the encrypted content
|
// decryptMedia handles downloading and decrypting the encrypted content internally
|
||||||
const encryptedBuffer = await params.client.downloadContent(params.file.url);
|
const decrypted = await params.client.crypto.decryptMedia(params.file);
|
||||||
if (encryptedBuffer.byteLength > params.maxBytes) {
|
|
||||||
|
if (decrypted.byteLength > params.maxBytes) {
|
||||||
throw new Error("Matrix media exceeds configured size limit");
|
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 };
|
return { buffer: decrypted };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +65,7 @@ export async function downloadMatrixMedia(params: {
|
|||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
mxcUrl: string;
|
mxcUrl: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
file?: EncryptedFile;
|
file?: EncryptedFile;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@@ -77,6 +74,12 @@ export async function downloadMatrixMedia(params: {
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
let fetched: { buffer: Buffer; headerType?: 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) {
|
if (params.file) {
|
||||||
// Encrypted media
|
// Encrypted media
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type RoomMessageEventContent = MessageEventContent & {
|
|||||||
file?: EncryptedFile;
|
file?: EncryptedFile;
|
||||||
info?: {
|
info?: {
|
||||||
mimetype?: string;
|
mimetype?: string;
|
||||||
|
size?: number;
|
||||||
};
|
};
|
||||||
"m.relates_to"?: {
|
"m.relates_to"?: {
|
||||||
rel_type?: string;
|
rel_type?: string;
|
||||||
|
|||||||
@@ -137,11 +137,10 @@ export function resolveConfiguredModelRef(params: {
|
|||||||
if (aliasMatch) return aliasMatch.ref;
|
if (aliasMatch) return aliasMatch.ref;
|
||||||
|
|
||||||
// Default to anthropic if no provider is specified, but warn as this is deprecated.
|
// Default to anthropic if no provider is specified, but warn as this is deprecated.
|
||||||
const normalized = normalizeProviderModelId("anthropic", trimmed);
|
|
||||||
console.warn(
|
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({
|
const resolved = resolveModelRefFromString({
|
||||||
|
|||||||
@@ -133,15 +133,6 @@ describe("isHeartbeatContentEffectivelyEmpty", () => {
|
|||||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n")).toBe(true);
|
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", () => {
|
it("returns true for comments only", () => {
|
||||||
expect(isHeartbeatContentEffectivelyEmpty("# Header\n# Another comment")).toBe(true);
|
expect(isHeartbeatContentEffectivelyEmpty("# Header\n# Another comment")).toBe(true);
|
||||||
expect(isHeartbeatContentEffectivelyEmpty("## Subheader\n### Another")).toBe(true);
|
expect(isHeartbeatContentEffectivelyEmpty("## Subheader\n### Another")).toBe(true);
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ export function buildNodeShellCommand(command: string, platform?: string | null)
|
|||||||
const normalized = String(platform ?? "")
|
const normalized = String(platform ?? "")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (normalized.includes("win")) {
|
if (normalized.startsWith("win")) {
|
||||||
return ["cmd.exe", "/d", "/s", "/c", command];
|
return ["cmd.exe", "/d", "/s", "/c", command];
|
||||||
}
|
}
|
||||||
return ["/bin/sh", "-lc", command];
|
return ["/bin/sh", "-lc", command];
|
||||||
|
|||||||
Reference in new issue
Block a user