Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ node_modules/
# npm prepare/pretest/prebuild:bin lifecycle hooks; manually via
# `npm run embed:web-sanitize`.
src/web-sanitize.embed.js

integrations/gmail/
11 changes: 11 additions & 0 deletions docs/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ solrac

You should see structured JSON log lines on stdout. DM your bot — the first message should produce a 🤔 / 🦙 / 🙂 thinking stub within a second.

## CLI subcommands

The binary supports a small set of subcommands beyond the default server boot:

| Command | Purpose |
|---------|---------|
| `solrac` | Boot the server (default). |
| `solrac gmail-auth <alias>` | One-time OAuth bootstrap for a Gmail account. Opens the browser, captures the redirect, and writes tokens to `$SOLRAC_HOME/integrations/gmail/<alias>.json`. See [`docs/USAGE.md`](./USAGE.md) → Gmail integration for the full setup. |

Subcommands do **not** require `ANTHROPIC_API_KEY`, `TELEGRAM_BOT_TOKEN`, or `ALLOWLIST_BOOTSTRAP` to be set — they run before solrac's full env validation, so a fresh install can authenticate Gmail accounts before configuring the bot.

## Upgrading

Just rerun the install command:
Expand Down
51 changes: 36 additions & 15 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,8 @@ The destructive ops (`delete`, `send`) carry **two** safeguards: solrac's Telegr

##### Setup (~5 min one-time + ~1 min per account)

All gmail on-disk state lives under `$SOLRAC_HOME/integrations/gmail/`. With the default `$SOLRAC_HOME=~/.solrac` that's `~/.solrac/integrations/gmail/`; with a custom value (e.g. `SOLRAC_HOME=/var/solrac`) the paths follow.

```bash
# 1. Install Gmail's optional runtime deps. Run this in the same directory
# as solrac's `package.json` (the cloned repo root, e.g. `~/code/solrac`).
Expand All @@ -699,17 +701,25 @@ The destructive ops (`delete`, `send`) carry **two** safeguards: solrac's Telegr
cd /path/to/solrac # the directory with package.json
npm install --save googleapis google-auth-library

# 2. Get an OAuth client credentials.json from Google Cloud Console:
# APIs & Services → Credentials → Create credentials → OAuth client ID.
# Application type: "Desktop app".
# Save the downloaded JSON to:
mkdir -p ~/.solrac/gmail
mv ~/Downloads/client_secret_*.json ~/.solrac/gmail/credentials.json
# (Skip step 1 entirely on a binary install — `solrac` ships googleapis +
# google-auth-library bundled.)

# 3. Authenticate one or more accounts (opens browser per call).
bun scripts/gmail-auth.ts personal
bun scripts/gmail-auth.ts work
# Each writes ~/.solrac/gmail/<alias>.json + appends to accounts.json.
# 2. Get an OAuth client credentials.json from Google Cloud Console:
# - Enable Gmail API: https://console.cloud.google.com/apis/library/gmail.googleapis.com
# - Create OAuth client: https://console.cloud.google.com/apis/credentials
# → Create Credentials → OAuth client ID → Desktop app
# Save the downloaded JSON to (substitute $SOLRAC_HOME as needed):
mkdir -p ~/.solrac/integrations/gmail
mv ~/Downloads/client_secret_*.json ~/.solrac/integrations/gmail/credentials.json

# 3. Authenticate one or more accounts (opens browser per call). Works
# identically from a source checkout (`bun src/main.ts gmail-auth …`) or
# a curl-pipe binary install (`solrac gmail-auth …`).
solrac gmail-auth personal
solrac gmail-auth work
# Each writes $SOLRAC_HOME/integrations/gmail/<alias>.json + appends to
# accounts.json. The command prints the resolved solracHome + gmailDir on
# its first two lines so the operator sees exactly where files land.

# 4. Restart solrac. Boot log should show:
# integrations.gmail.loaded accountCount:2 toolCount:11
Expand All @@ -719,11 +729,22 @@ If Gmail is unconfigured, the boot log distinguishes which precondition failed:

| Log event | Meaning |
|---|---|
| `integrations.gmail.deps_missing` | `googleapis` / `google-auth-library` not installed. Run the `npm install` above. |
| `integrations.gmail.disabled` | `~/.solrac/gmail/credentials.json` not found. Get it from Google Cloud Console. |
| `integrations.gmail.no_accounts` | Credentials present, but no accounts authed. Run `bun scripts/gmail-auth.ts <alias>`. |
| `integrations.gmail.deps_missing` | `googleapis` / `google-auth-library` not installed. Run the `npm install` above. (Source checkouts only — the binary bundles these.) |
| `integrations.gmail.disabled` | `credentials.json` not found at the path in `expectedAt`. Get it from Google Cloud Console. |
| `integrations.gmail.no_accounts` | Credentials present, but no accounts authed. Run `solrac gmail-auth <alias>`. |
| `integrations.gmail.loaded` | All set. Tool count + account count reported. |

##### Migrating from `~/.solrac/gmail/` (pre-PNX-171 layout)

Before PNX-171, gmail state lived in `~/.solrac/gmail/` regardless of `SOLRAC_HOME`. Move it once:

```bash
mkdir -p "$SOLRAC_HOME/integrations"
mv ~/.solrac/gmail "$SOLRAC_HOME/integrations/gmail"
```

If `SOLRAC_HOME` is unset and you're on the default `~/.solrac`, the move is `mv ~/.solrac/gmail ~/.solrac/integrations/gmail`. No re-auth needed — token files carry over verbatim.

##### Use cases

```
Expand All @@ -737,8 +758,8 @@ The third one will require approving the send via inline-keyboard. The agent mus
##### Limits to know

- **`gmail_delete_message` is permanent.** Use `gmail_trash_message` (Trash, recoverable 30 days) for normal deletes. The permanent-delete tool exists for cases where you really mean it.
- **OAuth refresh** is automatic. The integration writes refreshed tokens back to `~/.solrac/gmail/<alias>.json` whenever Google rotates them.
- **Scope is fixed** at read + modify + send + userinfo (for email-address discovery). To narrow scope, edit `scripts/gmail-auth.ts` SCOPES and re-auth per account.
- **OAuth refresh** is automatic. The integration writes refreshed tokens back to `$SOLRAC_HOME/integrations/gmail/<alias>.json` whenever Google rotates them.
- **Scope is fixed** at read + modify + send + userinfo (for email-address discovery). To narrow scope, edit `src/integrations-builtin/gmail/auth-cli.ts` SCOPES and re-auth per account.
- **The `googleapis` package is ~30MB.** That's why it's an optional dep, not a runtime requirement. If you don't want Gmail, skip step 1 and Gmail self-gates on `deps_missing`.

#### `notion` — single-token Notion workspace
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ function parseDefaultEngine(raw: string | undefined): DefaultEngine {
* flag) so the same binary behaves correctly whether it's `bun src/main.ts`
* in a checkout or `solrac` from `/usr/local/bin/`.
*/
function resolveSolracHome(raw: string | undefined): string {
export function resolveSolracHome(raw: string | undefined): string {
if (raw && raw.trim() !== "") return resolve(raw.trim());
if (existsSync(resolve(process.cwd(), "SOUL.md"))) return process.cwd();
return resolve(homedir(), ".solrac");
Expand Down
164 changes: 91 additions & 73 deletions scripts/gmail-auth.ts → src/integrations-builtin/gmail/auth-cli.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,30 @@
#!/usr/bin/env bun
/**
* @fileoverview Gmail OAuth bootstrap CLI for the blessed Gmail integration.
* @purpose One-time interactive auth: opens a browser for Google's OAuth
* consent screen, captures the redirect, exchanges the code for
* tokens, and writes them to `~/.solrac/gmail/<alias>.json` plus
* `~/.solrac/gmail/accounts.json`.
* @fileoverview Gmail OAuth bootstrap, surfaced as `solrac gmail-auth <alias>`.
* @purpose One-time interactive OAuth per Gmail account the agent can access.
* Opens a browser for Google consent, captures the redirect on a
* loopback server, exchanges the code for tokens, and writes them
* to `$SOLRAC_HOME/integrations/gmail/<alias>.json` plus an entry
* in `accounts.json`.
*
* Run once per Gmail account the operator wants the agent to access:
*
* bun scripts/gmail-auth.ts personal
* bun scripts/gmail-auth.ts work
*
* Prerequisite: download an OAuth client credentials.json from
* Google Cloud Console (APIs & Services → Credentials) and save it to
* `~/.solrac/gmail/credentials.json` first. The redirect URI in your
* OAuth client config should include `http://localhost` (the script
* binds a random ephemeral port at runtime; Google accepts loopback
* with any port).
*
* Adapted from `apps/utcp-tools/scripts/gmail-auth.ts` with two changes:
*
* 1. **Paths in `~/.solrac/gmail/`** instead of relative-to-script.
* Solrac is a self-contained deployment; OAuth state belongs in
* the operator's home dir, not next to the source.
*
* 2. **Bun shebang.** Solrac runs on Bun (no `tsx`); the operator
* invokes via `bun scripts/gmail-auth.ts <alias>` rather than
* `pnpm gmail:auth`.
* Why this lives in `src/` (not `scripts/`): the curl-pipe binary install
* ships `solrac` only — no `bun`, no source tree. Putting the bootstrap
* behind a subcommand lets it run from the same binary the operator
* already has.
*
* Cross-references:
* - src/integrations-builtin/gmail/client.ts — reads what this writes.
* - ./client.ts — reads what this writes.
* - src/main.ts — `argv[2] === "gmail-auth"` dispatch arm.
* - docs/USAGE.md#integrations — operator-facing setup walkthrough.
*/

import { OAuth2Client } from "google-auth-library";
import { google } from "googleapis";
import { exec } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { homedir } from "node:os";
import { join } from "node:path";
import { resolveSolracHome } from "../../config.ts";
import { resolveGmailPaths, type AccountsConfig } from "./client.ts";

const GMAIL_DIR = join(homedir(), ".solrac", "gmail");
const CREDENTIALS_PATH = join(GMAIL_DIR, "credentials.json");
const ACCOUNTS_PATH = join(GMAIL_DIR, "accounts.json");
type LooseAny = any; // eslint-disable-line @typescript-eslint/no-explicit-any

const SCOPES = [
"https://www.googleapis.com/auth/gmail.readonly",
Expand All @@ -57,53 +38,94 @@ interface CredentialsFile {
web?: { client_id: string; client_secret: string };
}

interface AccountInfo {
email: string;
tokenFile: string;
scopes: string[];
createdAt: string;
function printUsage(): void {
console.error("Usage: solrac gmail-auth <alias>");
console.error("Example: solrac gmail-auth personal");
}

type AccountsConfig = Record<string, AccountInfo>;
function printMissingCredentials(credentialsPath: string): void {
console.error(`Error: credentials.json not found at ${credentialsPath}\n`);
console.error("To create one:");
console.error(
" 1. Enable the Gmail API:",
"https://console.cloud.google.com/apis/library/gmail.googleapis.com",
);
console.error(
" 2. Create an OAuth client:",
"https://console.cloud.google.com/apis/credentials",
);
console.error(
" → Create Credentials → OAuth client ID → Desktop app",
);
console.error(` 3. Download the JSON and save it to the path above.`);
}

async function main(): Promise<void> {
const alias = process.argv[2];
/**
* Run the gmail-auth bootstrap. Returns the exit code; the dispatcher in
* `main.ts` calls `process.exit(code)` so this function stays testable.
*/
export async function runGmailAuth(argv: string[]): Promise<number> {
const alias = argv[0];
if (!alias) {
console.error("Usage: bun scripts/gmail-auth.ts <alias>");
console.error('Example: bun scripts/gmail-auth.ts personal');
process.exit(1);
printUsage();
return 1;
}
if (!/^[a-z0-9_-]+$/i.test(alias)) {
console.error(
"Error: alias must be alphanumeric with dashes/underscores only.",
);
process.exit(1);
return 1;
}

if (!existsSync(GMAIL_DIR)) {
mkdirSync(GMAIL_DIR, { recursive: true });
const solracHome = resolveSolracHome(process.env.SOLRAC_HOME);
const paths = resolveGmailPaths(solracHome);

console.log(`solrac home: ${solracHome}`);
console.log(`gmail dir: ${paths.gmailDir}\n`);

if (!existsSync(paths.gmailDir)) {
mkdirSync(paths.gmailDir, { recursive: true });
}
if (!existsSync(CREDENTIALS_PATH)) {
console.error(`Error: ${CREDENTIALS_PATH} not found.`);
console.error(
"Download from Google Cloud Console → APIs & Services → Credentials, " +
"and save the JSON file as ~/.solrac/gmail/credentials.json.",
);
process.exit(1);
if (!existsSync(paths.credentialsPath)) {
printMissingCredentials(paths.credentialsPath);
return 1;
}

const credentials: CredentialsFile = JSON.parse(
readFileSync(CREDENTIALS_PATH, "utf8"),
readFileSync(paths.credentialsPath, "utf8"),
);
const config = credentials.installed ?? credentials.web;
if (!config) {
console.error(
"Error: invalid credentials.json — missing 'installed' or 'web' key.",
);
process.exit(1);
return 1;
}
const { client_id, client_secret } = config;

// googleapis / google-auth-library are optional deps; the gmail integration
// self-gates on their presence at boot. Lazy-load here so the bootstrap
// fails loud with an actionable hint instead of an import error.
let OAuth2Client: LooseAny;
let google: LooseAny;
try {
const [authLib, googleApis] = await Promise.all([
import("google-auth-library"),
import("googleapis"),
]);
OAuth2Client = (authLib as LooseAny).OAuth2Client;
google = (googleApis as LooseAny).google;
} catch (err) {
console.error(
"Error: googleapis + google-auth-library are not installed.\n",
);
console.error(
"Run: npm install googleapis google-auth-library\n",
);
console.error(`(import error: ${(err as Error).message})`);
return 1;
}

// Bind a random port in 3457-3556. Google accepts any localhost port
// for OAuth callbacks even if not pre-registered.
const port = 3457 + Math.floor(Math.random() * 100);
Expand All @@ -116,7 +138,7 @@ async function main(): Promise<void> {
prompt: "consent", // force consent so we always get a refresh_token
});

console.log(`\nAuthenticating account: ${alias}`);
console.log(`Authenticating account: ${alias}`);
console.log("Opening browser for Google sign-in...");

const code = await new Promise<string>((resolve, reject) => {
Expand Down Expand Up @@ -149,7 +171,7 @@ async function main(): Promise<void> {
});

server.listen(port, () => {
// macOS `open` opens default browser. Linux operators may need to
// macOS `open` opens the default browser. Linux operators may need to
// copy-paste the URL — print it as a fallback.
exec(`open "${authUrl}"`, (err) => {
if (err) {
Expand All @@ -158,8 +180,6 @@ async function main(): Promise<void> {
});
});

// 5-minute timeout — give the operator time to consent without leaving
// the script hanging forever if they walk away.
setTimeout(
() => {
server.close();
Expand All @@ -173,40 +193,38 @@ async function main(): Promise<void> {
const { tokens } = await oauth2Client.getToken(code);
oauth2Client.setCredentials(tokens);

// Pull the email address so accounts.json carries human-readable info.
const oauth2 = google.oauth2({ version: "v2", auth: oauth2Client });
const userInfo = await oauth2.userinfo.get();
const email = userInfo.data.email;
if (!email) {
console.error("Error: could not retrieve email address from Google.");
process.exit(1);
return 1;
}

const tokenFile = `${alias}.json`;
writeFileSync(join(GMAIL_DIR, tokenFile), JSON.stringify(tokens, null, 2));
console.log(`Token saved to: ~/.solrac/gmail/${tokenFile}`);
const tokenPath = join(paths.gmailDir, tokenFile);
writeFileSync(tokenPath, JSON.stringify(tokens, null, 2));
console.log(`Token saved to: ${tokenPath}`);

let accounts: AccountsConfig = {};
if (existsSync(ACCOUNTS_PATH)) {
accounts = JSON.parse(readFileSync(ACCOUNTS_PATH, "utf8")) as AccountsConfig;
if (existsSync(paths.accountsPath)) {
accounts = JSON.parse(
readFileSync(paths.accountsPath, "utf8"),
) as AccountsConfig;
}
accounts[alias] = {
email,
tokenFile,
scopes: SCOPES.filter((s) => !s.includes("userinfo")),
createdAt: new Date().toISOString(),
};
writeFileSync(ACCOUNTS_PATH, JSON.stringify(accounts, null, 2));
writeFileSync(paths.accountsPath, JSON.stringify(accounts, null, 2));
console.log(`Account registered: ${alias} → ${email}`);

console.log(`\n✓ Authentication complete.`);
console.log(
`\nRestart solrac to load the new account. Then via the agent:\n` +
` "search my ${alias} Gmail for unread emails"\n`,
);
return 0;
}

main().catch((err: unknown) => {
console.error("Authentication failed:", (err as Error).message);
process.exit(1);
});
Loading
Loading