import fs from "node:fs"; import http from "node:http"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import { spawn } from "node:child_process"; import { BorderedLoader, type ExtensionAPI, type ExtensionCommandContext, type ExtensionUIContext, } from "@earendil-works/pi-coding-agent"; const TOKENS_DIR = process.env.GOOGLE_WORKSPACE_TOKENS_DIR || path.join(os.homedir(), ".pi", "google-workspace", "tokens"); const GOOGLE_WORKSPACE_BROKER_CALLBACK_URL = "https://radius.pi.dev/v1/oauth/google-workspaces/callback"; type TokenPayload = { access_token: string; refresh_token?: string | null; expiry_date: number; scope?: string; token_type?: string; __authMode: "broker"; __email: string; }; type LoginResult = { email: string; tokenPath: string; }; export default function googleWorkspacesExtension(pi: ExtensionAPI) { let activeLoginController: AbortController | undefined; let sessionShuttingDown = false; pi.on("session_start", (_event, ctx) => { refreshGoogleWorkspaceStatus(ctx.ui); }); pi.on("session_shutdown", (_event, ctx) => { sessionShuttingDown = true; activeLoginController?.abort(); ctx.ui.setStatus("google-workspaces", undefined); }); pi.on("before_agent_start", (event) => { const connections = listGoogleWorkspaceConnections(); if (connections.length === 0) { return { systemPrompt: event.systemPrompt + "\n\nGoogle Workspace: no locally connected accounts were found. If the user asks for Gmail, Calendar, Drive, Docs, Sheets, Slides, Contacts, or Meet access, ask them to run /google-workspaces first.", }; } const lines = connections.map((connection) => `- ${connection.email}`); return { systemPrompt: event.systemPrompt + "\n\nGoogle Workspace local connections:\n" + `${lines.join("\n")}\n` + "When the user asks for Google Workspace data for a specific email account, use the matching connected account above. Resolve credentials privately in the runtime; do not expose token values or token file paths in responses. If they do not specify an account and exactly one account is connected, use it by default.", }; }); pi.registerCommand("google-workspaces", { description: "Connect a Google Workspace account via Radius, or run /google-workspaces status", handler: async (args: string, ctx: ExtensionCommandContext) => { const command = String(args || "").trim().toLowerCase(); if (command === "status" || command === "list") { const summary = formatGoogleWorkspaceStatusMessage(); ctx.ui.setEditorText(summary); ctx.ui.notify("Google Workspace status loaded", "info"); refreshGoogleWorkspaceStatus(ctx.ui); return; } if (activeLoginController) { ctx.ui.notify("Google Workspace sign-in is already in progress", "warning"); return; } const controller = new AbortController(); activeLoginController = controller; try { let result: LoginResult; if (ctx.mode === "tui") { const outcome = await loginGoogleWorkspaceAccountWithUi(ctx, controller); if (sessionShuttingDown) { return; } if (outcome.type === "cancelled") { ctx.ui.notify("Google Workspace sign-in cancelled", "info"); return; } if (outcome.type === "failed") { throw outcome.cause; } result = outcome.result; } else { ctx.ui.setStatus("google-workspaces", "Opening Google Workspace sign-in..."); result = await loginGoogleWorkspaceAccount(controller.signal); } if (sessionShuttingDown) { return; } ctx.ui.notify( `Connected Google Workspace account: ${result.email}\nToken: ${result.tokenPath}`, "info", ); } catch (error) { if (sessionShuttingDown) { return; } const message = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Google Workspace login failed: ${message}`, "error"); } finally { if (activeLoginController === controller) { activeLoginController = undefined; } if (!sessionShuttingDown) { refreshGoogleWorkspaceStatus(ctx.ui); } } }, }); } async function loginGoogleWorkspaceAccountWithUi( ctx: ExtensionCommandContext, controller: AbortController, ) { return ctx.ui.custom< | { type: "connected"; result: LoginResult } | { type: "cancelled" } | { type: "failed"; cause: unknown } >((tui, theme, _keybindings, done) => { const loader = new BorderedLoader( tui, theme, "Waiting for Google Workspace authentication...", ); loader.onAbort = () => { controller.abort(); done({ type: "cancelled" }); }; loginGoogleWorkspaceAccount(controller.signal) .then((result) => done({ type: "connected", result })) .catch((cause: unknown) => { done(controller.signal.aborted ? { type: "cancelled" } : { type: "failed", cause }); }); return loader; }); } async function loginGoogleWorkspaceAccount(signal: AbortSignal): Promise { ensureTokensDir(); const host = process.env.GOOGLE_WORKSPACE_CALLBACK_HOST || "localhost"; const port = await getAvailablePort(); const localCallbackUrl = `http://${host}:${port}/oauth2callback`; const startUrl = new URL(getBrokerStartUrl()); startUrl.searchParams.set("return_to", localCallbackUrl); const token = await waitForOAuthRedirect({ host, port, startUrl: startUrl.toString(), signal, }); const email = normalizeEmail(token.__email); const tokenPath = tokenPathForEmail(email); fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2)); try { fs.chmodSync(tokenPath, 0o600); } catch { // Ignore chmod failures on non-POSIX filesystems. } return { email, tokenPath }; } function getBrokerStartUrl(): string { return GOOGLE_WORKSPACE_BROKER_CALLBACK_URL.endsWith("/callback") ? `${GOOGLE_WORKSPACE_BROKER_CALLBACK_URL.slice(0, -"/callback".length)}/start` : `${GOOGLE_WORKSPACE_BROKER_CALLBACK_URL}/start`; } async function waitForOAuthRedirect(input: { host: string; port: number; startUrl: string; signal: AbortSignal; }): Promise { const { host, port, startUrl, signal } = input; if (signal.aborted) { throw new Error("Google Workspace authentication cancelled."); } const redirectPromise = new Promise((resolve, reject) => { const server = http.createServer((req, res) => { try { if (!req.url || !req.url.startsWith("/oauth2callback")) { res.statusCode = 404; res.end("Not found"); return; } const parsed = new URL(req.url, `http://${host}:${port}`); const errorCode = parsed.searchParams.get("error"); if (errorCode) { const description = parsed.searchParams.get("error_description") || "No additional details"; res.statusCode = 400; res.end("Authentication failed."); clearTimeout(timer); server.close(); reject(new Error(`Google OAuth error: ${errorCode}. ${description}`)); return; } const email = parsed.searchParams.get("email"); const accessToken = parsed.searchParams.get("access_token"); const refreshToken = parsed.searchParams.get("refresh_token"); const scope = parsed.searchParams.get("scope"); const tokenType = parsed.searchParams.get("token_type"); const expiryDateRaw = parsed.searchParams.get("expiry_date"); if (!email || !accessToken || !expiryDateRaw) { res.statusCode = 400; res.end("Authentication failed: missing callback fields."); clearTimeout(timer); server.close(); reject(new Error("Authentication failed: callback did not include email and tokens.")); return; } const expiryDate = Number.parseInt(expiryDateRaw, 10); if (Number.isNaN(expiryDate)) { res.statusCode = 400; res.end("Authentication failed: invalid expiry date."); clearTimeout(timer); server.close(); reject(new Error("Authentication failed: callback expiry_date is invalid.")); return; } const token: TokenPayload = { access_token: accessToken, refresh_token: refreshToken || null, scope: scope || undefined, token_type: tokenType || undefined, expiry_date: expiryDate, __authMode: "broker", __email: normalizeEmail(email), }; res.end("Authentication successful. You can close this tab."); clearTimeout(timer); server.close(); resolve(token); } catch (error) { clearTimeout(timer); server.close(); reject(error); } }); const timer = setTimeout( () => { server.close(); reject(new Error("Authentication timed out after 5 minutes. Please try again.")); }, 5 * 60 * 1000, ); signal.addEventListener( "abort", () => { clearTimeout(timer); if (server.listening) { server.close(); } reject(new Error("Google Workspace authentication cancelled.")); }, { once: true }, ); server.on("error", (error) => { clearTimeout(timer); reject(new Error(`OAuth callback server error: ${error.message}`)); }); server.listen(port, host, () => { if (signal.aborted) { server.close(); return; } try { openUrlInBrowser(startUrl); } catch { clearTimeout(timer); server.close(); reject( new Error(`Could not open browser automatically. Open this URL manually:\n${startUrl}`), ); } }); }); return redirectPromise; } function normalizeEmail(email: string): string { const normalized = String(email || "") .trim() .toLowerCase(); if (!normalized) { throw new Error("Google Workspace account email is missing."); } if (!/^[^@\s]+@[^@\s]+$/.test(normalized)) { throw new Error(`Invalid email address: ${email}`); } return normalized; } function tokenFilenameForEmail(email: string): string { return `${encodeURIComponent(normalizeEmail(email))}.json`; } function tokenPathForEmail(email: string): string { return path.join(TOKENS_DIR, tokenFilenameForEmail(email)); } function ensureTokensDir(): void { fs.mkdirSync(TOKENS_DIR, { recursive: true }); } function listGoogleWorkspaceConnections(): Array<{ email: string; tokenPath: string }> { if (!fs.existsSync(TOKENS_DIR)) { return []; } const entries = fs.readdirSync(TOKENS_DIR, { withFileTypes: true }); const connections: Array<{ email: string; tokenPath: string }> = []; for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith(".json")) { continue; } const tokenPath = path.join(TOKENS_DIR, entry.name); try { const payload = JSON.parse(fs.readFileSync(tokenPath, "utf8")) as Partial; const email = normalizeEmail(payload.__email || decodeTokenFilename(entry.name)); connections.push({ email, tokenPath }); } catch { // Ignore malformed token files. } } return connections.sort((a, b) => a.email.localeCompare(b.email)); } function decodeTokenFilename(filename: string): string { return decodeURIComponent(filename.replace(/\.json$/i, "")); } function formatGoogleWorkspaceStatusMessage(): string { const connections = listGoogleWorkspaceConnections(); if (connections.length === 0) { return [ "Google Workspace: not connected", `Tokens directory: ${TOKENS_DIR}`, "Run /google-workspaces to connect an account.", ].join("\n"); } return [ `Google Workspace: connected (${connections.length} account${connections.length === 1 ? "" : "s"})`, `Tokens directory: ${TOKENS_DIR}`, ...connections.map((connection) => `- ${connection.email}: ${connection.tokenPath}`), ].join("\n"); } function refreshGoogleWorkspaceStatus(ui: Pick): void { const connections = listGoogleWorkspaceConnections(); if (connections.length === 0) { ui.setStatus("google-workspaces", "Google Workspace: not connected"); return; } if (connections.length === 1) { ui.setStatus("google-workspaces", `Google Workspace: ${connections[0].email}`); return; } ui.setStatus("google-workspaces", `Google Workspace: ${connections.length} accounts connected`); } function getAvailablePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); server.listen(0, () => { const address = server.address(); const port = address && typeof address === "object" ? address.port : 0; server.close(() => resolve(port)); }); server.on("error", reject); }); } function openUrlInBrowser(targetUrl: string): void { let command: string; let args: string[]; if (process.platform === "darwin") { command = "open"; args = [targetUrl]; } else if (process.platform === "win32") { command = "cmd"; args = ["/c", "start", "", targetUrl]; } else { command = "xdg-open"; args = [targetUrl]; } const child = spawn(command, args, { detached: true, stdio: "ignore", }); child.unref(); }