Skip to content
Open
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
8 changes: 8 additions & 0 deletions hub-config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ sources:
# clientId: "your-client-id.apps.googleusercontent.com"
# clientSecret: "your-client-secret"

google_drive:
enabled: true
owner_auth:
type: oauth2
# Uncomment to use your own OAuth app:
# clientId: "your-client-id.apps.googleusercontent.com"
# clientSecret: "your-client-secret"

github:
enabled: true
owner_auth:
Expand Down
87 changes: 87 additions & 0 deletions skills/drive-assistant/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
name: drive-assistant
description: Manage your Google Drive files through PersonalDataHub
user_invocable: true
---

# Drive Assistant

Help users manage their Google Drive by searching, creating, deleting, and uploading files through PersonalDataHub.

## Instructions

### 1. Read the PersonalDataHub config

Read `~/.pdh/config.json` to get the `hubUrl`.

### 2. Verify the hub is running

Run `curl -s <hubUrl>/health` via Bash.

### 3. Manage Files

Based on the user's request, you can perform the following actions:

#### Search Files
```bash
curl -s -X POST <hubUrl>/app/v1/propose \
-H "Content-Type: application/json" \
-d '{
"source": "google_drive",
"action_type": "search_files",
"action_data": {
"query": "name contains '\''test'\''"
},
"purpose": "Searching for files related to '\''test'\''"
}'
```

#### Create File (Metadata only/Folder)
```bash
curl -s -X POST <hubUrl>/app/v1/propose \
-H "Content-Type: application/json" \
-d '{
"source": "google_drive",
"action_type": "create_file",
"action_data": {
"name": "New Folder",
"mimeType": "application/vnd.google-apps.folder"
},
"purpose": "Creating a new folder in Google Drive"
}'
```

#### Upload File (Create with content)
```bash
curl -s -X POST <hubUrl>/app/v1/propose \
-H "Content-Type: application/json" \
-d '{
"source": "google_drive",
"action_type": "upload_file",
"action_data": {
"name": "notes.txt",
"content": "This is a note.",
"mimeType": "text/plain"
},
"purpose": "Uploading a text file to Google Drive"
}'
```

#### Delete File
```bash
curl -s -X POST <hubUrl>/app/v1/propose \
-H "Content-Type: application/json" \
-d '{
"source": "google_drive",
"action_type": "delete_file",
"action_data": {
"fileId": "<file_id>"
},
"purpose": "Deleting a file from Google Drive"
}'
```

**Guidelines:**
- Always provide a clear `purpose`.
- When searching, use valid Google Drive API query syntax.
- Inform the user that actions must be approved in the PDH GUI.
24 changes: 6 additions & 18 deletions src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,12 @@ port: 4000

expect(config.sources.gmail).toBeDefined();
expect(config.sources.gmail.enabled).toBe(true);
expect(config.sources.gmail.owner_auth.type).toBe('oauth2');
expect(config.sources.gmail.owner_auth.clientId).toBe('test-client-id');
expect(config.sources.gmail.owner_auth!.type).toBe('oauth2');
expect(config.sources.gmail.owner_auth!.clientId).toBe('test-client-id');
expect(config.sources.gmail.boundary.after).toBe('2026-01-01');
expect(config.port).toBe(4000);
});

it('rejects config with missing required fields', () => {
const yaml = `
sources:
gmail:
enabled: true
`;
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, yaml);

expect(() => loadConfig(configPath)).toThrow();
});

it('rejects config with bad types', () => {
const yaml = `
sources:
Expand Down Expand Up @@ -87,8 +75,8 @@ sources:
writeFileSync(configPath, yaml);
const config = loadConfig(configPath);

expect(config.sources.gmail.owner_auth.clientId).toBe('env-client-id');
expect(config.sources.gmail.owner_auth.clientSecret).toBe('env-secret');
expect(config.sources.gmail.owner_auth!.clientId).toBe('env-client-id');
expect(config.sources.gmail.owner_auth!.clientSecret).toBe('env-secret');

delete process.env.TEST_CLIENT_ID;
delete process.env.TEST_SECRET;
Expand Down Expand Up @@ -216,8 +204,8 @@ port: 4000

const config = loadConfigFiles([gmailPath, githubPath]);
expect(Object.keys(config.sources).sort()).toEqual(['github', 'gmail']);
expect(config.sources.gmail.owner_auth.clientId).toBe('gmail-id');
expect(config.sources.github.owner_auth.clientId).toBe('github-id');
expect(config.sources.gmail.owner_auth!.clientId).toBe('gmail-id');
expect(config.sources.github.owner_auth!.clientId).toBe('github-id');
expect(config.port).toBe(4000);
});

Expand Down
2 changes: 1 addition & 1 deletion src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const sourceBoundarySchema = z.object({

const sourceConfigSchema = z.object({
enabled: z.boolean().default(true),
owner_auth: ownerAuthSchema,
owner_auth: ownerAuthSchema.optional(),
agent_identity: agentIdentitySchema.optional(),
boundary: sourceBoundarySchema.default({}),

Expand Down
137 changes: 136 additions & 1 deletion src/gateway/auth/oauth-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import type { TokenManager } from './token-manager.js';
import { AuditLog } from '../audit/log.js';
import { GmailConnector } from '../connectors/gmail/connector.js';
import { GoogleCalendarConnector } from '../connectors/calendar/connector.js';
import { GoogleDriveConnector } from '../connectors/google_drive/connector.js';
import { GitHubConnector } from '../connectors/github/connector.js';
import { generateCodeVerifier, computeCodeChallenge, getGmailCredentials, getGitHubCredentials, getCalendarCredentials } from './pkce.js';
import { generateCodeVerifier, computeCodeChallenge, getGmailCredentials, getGitHubCredentials, getCalendarCredentials, getDriveCredentials } from './pkce.js';

interface OAuthDeps {
store: DataStore;
Expand All @@ -28,6 +29,12 @@ const CALENDAR_SCOPES = [
'https://www.googleapis.com/auth/calendar.events',
];

const DRIVE_SCOPES = [
'https://www.googleapis.com/auth/drive.readonly',
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive.metadata.readonly',
];

export function getBaseUrl(config: HubConfigParsed): string {
if (config.deployment?.base_url) return config.deployment.base_url;
const port = config.port ?? 3000;
Expand Down Expand Up @@ -438,5 +445,133 @@ export function createOAuthRoutes(deps: OAuthDeps): Hono {
return c.json({ ok: true });
});

// --- Google Drive OAuth ---

app.get('/google_drive/start', async (c) => {
const driveConfig = deps.config.sources.google_drive;
if (!driveConfig) {
return c.redirect('/?oauth_error=drive_not_configured');
}

const { clientId, clientSecret } = getDriveCredentials(deps.config);
if (!clientId || !clientSecret) {
return c.redirect('/?oauth_error=drive_missing_credentials');
}

const codeVerifier = generateCodeVerifier();
const codeChallenge = computeCodeChallenge(codeVerifier);

const state = randomBytes(32).toString('hex');
await deps.store.setOAuthState(state, { source: 'google_drive', createdAt: Date.now(), codeVerifier });

const oauth2Client = new google.auth.OAuth2(
clientId,
clientSecret,
`${getBaseUrl(deps.config)}/oauth/google_drive/callback`,
);

const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
prompt: 'consent',
scope: DRIVE_SCOPES,
state,
code_challenge: codeChallenge,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
code_challenge_method: 'S256' as any,
});

return c.redirect(authUrl);
});

app.get('/google_drive/callback', async (c) => {
const code = c.req.query('code');
const state = c.req.query('state');
const error = c.req.query('error');

if (error) {
return c.redirect(`/?oauth_error=${encodeURIComponent(error)}`);
}

if (!code || !state) {
return c.redirect('/?oauth_error=missing_code_or_state');
}

const pending = await deps.store.getAndDeleteOAuthState(state);
if (!pending || pending.source !== 'google_drive') {
return c.redirect('/?oauth_error=invalid_state');
}
const { codeVerifier } = pending;

const { clientId, clientSecret } = getDriveCredentials(deps.config);

const oauth2Client = new google.auth.OAuth2(
clientId,
clientSecret,
`${getBaseUrl(deps.config)}/oauth/google_drive/callback`,
);

try {
const { tokens } = await oauth2Client.getToken({ code, codeVerifier });
oauth2Client.setCredentials(tokens);

let userInfo: { data: { emailAddress?: string | null } } = { data: {} };
try {
const driveApi = google.drive({ version: 'v3', auth: oauth2Client });
const about = await driveApi.about.get({ fields: 'user(emailAddress)' });
userInfo = { data: { emailAddress: about.data.user?.emailAddress } };
} catch (infoErr) {
console.warn('Failed to fetch userinfo (non-fatal):', (infoErr as Error).message);
}

const expiresAt = tokens.expiry_date
? new Date(tokens.expiry_date).toISOString()
: undefined;

await deps.tokenManager.storeToken('google_drive', {
access_token: tokens.access_token!,
refresh_token: tokens.refresh_token ?? undefined,
token_type: tokens.token_type ?? 'Bearer',
expires_at: expiresAt,
scopes: DRIVE_SCOPES.join(' '),
account_info: {
email: userInfo.data.emailAddress,
},
});

const connector = new GoogleDriveConnector({
clientId,
clientSecret,
accessToken: tokens.access_token!,
refreshToken: tokens.refresh_token ?? undefined,
});
deps.connectorRegistry.set('google_drive', connector);

connector.getAuth().on('tokens', async (newTokens) => {
if (newTokens.access_token) {
const newExpiry = newTokens.expiry_date
? new Date(newTokens.expiry_date).toISOString()
: undefined;
await deps.tokenManager.updateAccessToken('google_drive', newTokens.access_token, newExpiry);
}
});

await auditLog.insert('oauth_connected', 'google_drive', {
email: userInfo.data.emailAddress,
});

return c.redirect('/?oauth_success=google_drive');
} catch (err) {
const message = err instanceof Error ? err.message : 'unknown_error';
return c.redirect(`/?oauth_error=${encodeURIComponent(message)}`);
}
});

app.post('/google_drive/disconnect', async (c) => {
await deps.tokenManager.deleteToken('google_drive');
deps.connectorRegistry.delete('google_drive');
await auditLog.insert('oauth_disconnected', 'google_drive', {});
return c.json({ ok: true });
});

return app;
}
23 changes: 17 additions & 6 deletions src/gateway/auth/pkce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ export interface ResolvedCredentials {
export function getGmailCredentials(config: HubConfigParsed): ResolvedCredentials {
const gmailConfig = config.sources.gmail;
return {
clientId: gmailConfig?.owner_auth.clientId ?? '',
clientSecret: gmailConfig?.owner_auth.clientSecret ?? '',
clientId: gmailConfig?.owner_auth?.clientId ?? '',
clientSecret: gmailConfig?.owner_auth?.clientSecret ?? '',
};
}

Expand All @@ -45,8 +45,8 @@ export function getGmailCredentials(config: HubConfigParsed): ResolvedCredential
export function getGitHubCredentials(config: HubConfigParsed): ResolvedCredentials {
const githubConfig = config.sources.github;
return {
clientId: githubConfig?.owner_auth.clientId ?? '',
clientSecret: githubConfig?.owner_auth.clientSecret ?? '',
clientId: githubConfig?.owner_auth?.clientId ?? '',
clientSecret: githubConfig?.owner_auth?.clientSecret ?? '',
};
}

Expand All @@ -56,7 +56,18 @@ export function getGitHubCredentials(config: HubConfigParsed): ResolvedCredentia
export function getCalendarCredentials(config: HubConfigParsed): ResolvedCredentials {
const calConfig = config.sources.google_calendar;
return {
clientId: calConfig?.owner_auth.clientId ?? '',
clientSecret: calConfig?.owner_auth.clientSecret ?? '',
clientId: calConfig?.owner_auth?.clientId ?? '',
clientSecret: calConfig?.owner_auth?.clientSecret ?? '',
};
}

/**
* Returns Google Drive OAuth credentials from config.
*/
export function getDriveCredentials(config: HubConfigParsed): ResolvedCredentials {
const driveConfig = config.sources.google_drive;
return {
clientId: driveConfig?.owner_auth?.clientId ?? '',
clientSecret: driveConfig?.owner_auth?.clientSecret ?? '',
};
}
Loading