Skip to content
This repository was archived by the owner on May 9, 2026. It is now read-only.
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: 1 addition & 1 deletion dev/test-harness/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// RPC Client
// ---------------------------------------------------------------------------

const RUNTIME_URL = process.env.SKILLS_RUNTIME_URL || 'http://127.0.0.1:7799';
const RUNTIME_URL = process.env.SKILLS_RUNTIME_URL || 'http://127.0.0.1:7788';

let rpcId = 0;

Expand Down
1 change: 0 additions & 1 deletion openhuman
Submodule openhuman deleted from 3034ec
1 change: 1 addition & 0 deletions openhuman
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"prepare": "husky",
"core:build": "cargo build --manifest-path openhuman/Cargo.toml --bin openhuman-core",
"dev:runtime": "(cd openhuman && cargo build) && node scripts/dev-runtime.mjs",
"serve:core": "node scripts/serve-core.mjs",
"core:run": "cargo run --manifest-path openhuman/Cargo.toml --bin openhuman-core -- skills run --skills-dir ./skills",
"core:list": "cargo run --manifest-path openhuman/Cargo.toml --bin openhuman-core -- skills list --skills-dir ./skills",
"core:test": "cargo run --manifest-path openhuman/Cargo.toml --bin openhuman-core -- skills test --skills-dir ./skills",
Expand Down
108 changes: 108 additions & 0 deletions scripts/serve-core.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* Start openhuman `serve` with openhuman-skills/.env applied (BACKEND_URL, JWT_TOKEN, …).
*
* Usage:
* npm run serve:core
* npm run serve:core -- --port 7788
*
* Finds the openhuman repo via: ./openhuman (submodule), ../openhuman (sibling), or OPENHUMAN_ROOT.
*/

import { spawn } from 'child_process';
import { existsSync, readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = resolve(__dirname, '..');

/**
* Parse `openhuman-skills/.env` into a copy of `process.env`, filling keys
* not already set in the environment (does not override existing vars).
* @returns {NodeJS.ProcessEnv}
*/
function loadEnvFromSkillsDotenv() {
const envPath = resolve(rootDir, '.env');
const envVars = { ...process.env };
if (!existsSync(envPath)) {
console.warn(`\x1b[33m No .env at ${envPath} — BACKEND_URL may default in skills\x1b[0m`);
return envVars;
}
for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim();
if (!process.env[key] && value) {
envVars[key] = value;
}
}
console.log(`\x1b[2m Loaded .env from ${envPath}\x1b[0m`);
return envVars;
}

/**
* Locate the openhuman repo: `./openhuman` submodule, sibling `../openhuman`, or `OPENHUMAN_ROOT`.
* Exits the process if no checkout with `Cargo.toml` is found.
* @returns {string} Absolute path to the openhuman repository root
*/
function findOpenhumanRoot() {
const fromSubmodule = resolve(rootDir, 'openhuman', 'Cargo.toml');
if (existsSync(fromSubmodule)) {
return resolve(rootDir, 'openhuman');
}
const sibling = resolve(rootDir, '..', 'openhuman', 'Cargo.toml');
if (existsSync(sibling)) {
return resolve(rootDir, '..', 'openhuman');
}
if (process.env.OPENHUMAN_ROOT) {
const o = resolve(process.env.OPENHUMAN_ROOT);
if (existsSync(resolve(o, 'Cargo.toml'))) {
return o;
}
}
console.error(
'\x1b[31m Could not find openhuman (expected ./openhuman or ../openhuman). Set OPENHUMAN_ROOT.\x1b[0m'
);
process.exit(1);
}

const argv = process.argv.slice(2);
let port = 7788;
const portIdx = argv.indexOf('--port');
if (portIdx !== -1 && argv[portIdx + 1]) {
port = parseInt(argv[portIdx + 1], 10);
}

const env = loadEnvFromSkillsDotenv();
const openhumanRoot = findOpenhumanRoot();
const backend = env.BACKEND_URL || '(unset — skills default)';
const jwt = env.JWT_TOKEN ? `<${String(env.JWT_TOKEN).length} chars>` : '(unset)';

console.log(`\n\x1b[36m openhuman serve (with skills .env)\x1b[0m`);
console.log(`\x1b[2m Port: ${port}\x1b[0m`);
console.log(`\x1b[2m Backend: ${backend}\x1b[0m`);
console.log(`\x1b[2m JWT: ${jwt}\x1b[0m`);
console.log(`\x1b[2m Core: ${openhumanRoot}\x1b[0m\n`);

const child = spawn(
'cargo',
['run', '--bin', 'openhuman-core', '--', 'serve', '--port', String(port)],
{
cwd: openhumanRoot,
stdio: 'inherit',
env: {
...env,
RUST_LOG: env.RUST_LOG || 'info',
},
}
);

child.on('exit', (code) => {
process.exit(code ?? 1);
});
process.on('SIGINT', () => child.kill('SIGINT'));
process.on('SIGTERM', () => child.kill('SIGTERM'));
172 changes: 21 additions & 151 deletions src/core/gmail/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// ---------------------------------------------------------------------------
// Gmail API helper (uses net.fetch directly with Bearer token)
// Gmail API helper (all requests via OAuth proxy — `oauth.fetch`)
import { getGmailSkillState } from '../state';
import type { ApiError } from '../types';

Expand All @@ -17,102 +17,6 @@ function sleep(ms: number): void {
}
}

const GMAIL_BASE_URL = 'https://gmail.googleapis.com/gmail/v1';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';

/** Cached access token for self_hosted mode (refresh_token → access_token exchange). */
let cachedSelfHostedToken: { token: string; expiresAt: number } | null = null;

/**
* Resolve a Gmail access token from the available credential sources.
*
* Priority:
* 1. Advanced auth (self_hosted): exchange refresh_token for access_token
* 2. Advanced auth (text): use service account key (access_token field if pre-exchanged)
* 3. OAuth credential with accessToken
* 4. null — not connected
*/
function resolveAccessToken(): string | null {
// Check advanced auth bridge first
const authCred = auth.getCredential();
if (authCred && authCred.mode === 'self_hosted') {
const creds = authCred.credentials;
const clientId = creds.client_id as string | undefined;
const clientSecret = creds.client_secret as string | undefined;
const refreshToken = creds.refresh_token as string | undefined;

if (clientId && clientSecret && refreshToken) {
// Use cached token if still valid (with 60s buffer)
if (cachedSelfHostedToken && cachedSelfHostedToken.expiresAt > Date.now() + 60_000) {
return cachedSelfHostedToken.token;
}

// Exchange refresh_token for access_token
try {
const body = `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&refresh_token=${encodeURIComponent(refreshToken)}&grant_type=refresh_token`;
const response = net.fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
timeout: 15,
});

if (response.status === 200) {
const data = JSON.parse(response.body) as { access_token: string; expires_in: number };
cachedSelfHostedToken = {
token: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
console.log('[gmail] Self-hosted token refreshed, expires in', data.expires_in, 's');
return data.access_token;
} else {
console.error('[gmail] Token refresh failed:', response.status, response.body);
return null;
}
} catch (err) {
console.error('[gmail] Token refresh error:', err);
return null;
}
}
}

if (authCred && authCred.mode === 'text') {
// Text mode: the user pasted a service account JSON or an access token.
// If the content looks like a raw token (no JSON structure), use it directly.
const content = (authCred.credentials.content || '') as string;
try {
const parsed = JSON.parse(content) as Record<string, unknown>;
// Service account JSON — would need JWT exchange (complex).
// For now, check if there's an access_token or private_key field.
if (parsed.access_token) {
return parsed.access_token as string;
}
// Service account with private_key: not supported yet in QuickJS runtime
// (would need JWT signing). Log a helpful message.
if (parsed.private_key) {
console.warn(
'[gmail] Service account JSON detected but JWT signing is not yet supported. Use a refresh token flow instead.'
);
return null;
}
} catch {
// Not JSON — treat as a raw access/bearer token
if (content.trim()) {
return content.trim();
}
}
return null;
}

// Fall back to OAuth credential
const oauthCred = oauth.getCredential();
if (oauthCred && oauthCred.accessToken) {
return oauthCred.accessToken as string;
}

return null;
}

/** Returns true if any form of Gmail credential is available. */
export function isGmailConnected(): boolean {
const authCred = auth.getCredential();
Expand All @@ -121,17 +25,20 @@ export function isGmailConnected(): boolean {
return !!oauthCred;
}

/** Reset cached self-hosted token (e.g., on credential change). */
export function resetTokenCache(): void {
cachedSelfHostedToken = null;
}

/** Parsed Gmail API result: success payload or structured error for callers. */
export interface GmailApiResponse<T> {
success: boolean;
data?: T;
error?: ApiError;
}

/**
* Perform a Gmail REST request through the managed OAuth proxy (`oauth.fetch`).
* Handles 429 backoff, rate-limit header updates, and optional raw batch bodies.
*
* @param endpoint - Path under the Gmail API root (leading `/` optional)
* @param options - HTTP method, body, headers, timeout, or `rawBatch` for multipart batch responses
*/
export function gmailFetch<T = unknown>(
endpoint: string,
options: {
Expand All @@ -145,56 +52,27 @@ export function gmailFetch<T = unknown>(
const cleanPath = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const method = options.method || 'GET';
const timeout = options.timeout || 10;
const isBatch = cleanPath === '/batch';

// Determine whether to use direct API calls (with a resolved access token)
// or the OAuth proxy (for encrypted/managed credentials without a local token).
const oauthCred = oauth.getCredential();

for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
// Resolve token inside the loop so retries after 401 cache clear get a fresh token
const accessToken = resolveAccessToken();
const useProxy = !accessToken && !!oauthCred;

if (!accessToken && !useProxy) {
console.log('[gmail] gmailFetch: no access token and no OAuth credential');
if (!oauthCred) {
console.log('[gmail] gmailFetch: no OAuth credential');
return {
success: false,
error: { code: 401, message: 'Gmail not connected. Complete setup first.' },
};
}

try {
let response: { status: number; headers: Record<string, string>; body: string };

if (useProxy) {
// Managed/encrypted OAuth: use the proxy which decrypts tokens server-side.
// oauth.fetch path is relative to the manifest apiBaseUrl (gmail.googleapis.com/gmail/v1),
// so pass the endpoint path directly without the /gmail/v1 prefix.
console.log('[gmail] gmailFetch (proxy):', method, cleanPath);
response = oauth.fetch(cleanPath, {
method,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
body: options.body,
timeout,
});
} else {
// Direct API call with resolved access token
const url = isBatch
? 'https://www.googleapis.com/batch/gmail/v1'
: `${GMAIL_BASE_URL}${cleanPath}`;
console.log('[gmail] gmailFetch (direct):', method, url);
response = net.fetch(url, {
method,
headers: {
Authorization: `Bearer ${accessToken}`,
...(isBatch ? {} : { 'Content-Type': 'application/json' }),
...(options.headers || {}),
},
body: options.body,
timeout,
});
}
// All Gmail API requests go through OAuth proxy.
// oauth.fetch path is relative to the manifest apiBaseUrl.
console.log('[gmail] gmailFetch (oauth.fetch):', method, cleanPath);
const response = oauth.fetch(cleanPath, {
method,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
body: options.body,
timeout,
});
console.log('[gmail] gmailFetch response status:', response.status);

const s = getGmailSkillState();
Expand All @@ -212,15 +90,7 @@ export function gmailFetch<T = unknown>(
continue;
}

// -- 401 Unauthorized: invalidate cached token and retry with fresh one -
if (!useProxy && response.status === 401 && attempt < MAX_RETRIES) {
const bodyPreview = response.body ? response.body.slice(0, 200) : '(empty)';
console.log(`[gmail] gmailFetch: 401 Unauthorized body=${bodyPreview}`);
cachedSelfHostedToken = null;
// Token will be re-resolved at the top of the next iteration
console.log('[gmail] gmailFetch: cleared token cache, retrying');
continue;
} else if (response.status >= 400) {
if (response.status >= 400) {
const bodyPreview = response.body ? response.body.slice(0, 200) : '(empty)';
console.log(`[gmail] gmailFetch: error status=${response.status} body=${bodyPreview}`);
}
Expand Down
6 changes: 1 addition & 5 deletions src/core/gmail/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Gmail skill main entry point
// Gmail integration with OAuth bridge; sync sends list API response (id + threadId) to frontend.
import { loadGmailProfile } from './api/helpers';
import { isGmailConnected, resetTokenCache } from './api/index';
import { isGmailConnected } from './api/index';
import { getEmailCount } from './db/helpers';
import { initializeGmailSchema } from './db/schema';
import { getGmailSkillState } from './state';
Expand Down Expand Up @@ -161,9 +161,6 @@ function onAuthComplete(args: { mode: string; credentials: Record<string, unknow
return { status: 'complete' };
}

// Reset any cached tokens from a previous credential
resetTokenCache();

if (args.mode === 'self_hosted') {
const clientId = args.credentials.client_id as string | undefined;
const clientSecret = args.credentials.client_secret as string | undefined;
Expand Down Expand Up @@ -335,7 +332,6 @@ function onAuthRevoked(args: { mode?: string }): void {
s.profile = null;
state.delete('config');
cron.unregister('gmail-sync');
resetTokenCache();
publishSkillState();
}

Expand Down
Loading
Loading