From 49a9f16221fd45f2ee182d9ffc0f41b341629ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 29 May 2026 20:08:55 -0400 Subject: [PATCH] Replace token-paste with native device-code/PKCE provider sign-in Implement the official OAuth login flows natively (ported from pi-ai's reference): GitHub Copilot device-code, GPT account (ChatGPT Codex) and Google Antigravity authorization-code + PKCE via a loopback callback server. Obtained credentials are stored as JSON through the existing secret vault. Onboarding and Settings now run the real sign-in instead of pasting an access token. --- AgentCompany-New/src/main/app/ipc-handlers.ts | 40 +++ .../services/oauth/github-copilot-flow.ts | 227 ++++++++++++++++++ .../services/oauth/google-antigravity-flow.ts | 119 +++++++++ .../services/oauth/oauth-callback-server.ts | 76 ++++++ .../src/main/services/oauth/oauth-flows.ts | 21 ++ .../src/main/services/oauth/oauth-page.ts | 49 ++++ .../src/main/services/oauth/oauth-types.ts | 30 +++ .../main/services/oauth/openai-codex-flow.ts | 97 ++++++++ .../src/main/services/oauth/pkce.ts | 13 + .../main/services/provider-auth-profiles.ts | 23 +- .../main/services/provider-oauth-service.ts | 40 +++ .../src/main/services/provider-service.ts | 7 + AgentCompany-New/src/preload/index.ts | 2 + .../src/renderer/src/app/Onboarding.tsx | 39 ++- .../src/pages/settings/ProvidersPanel.tsx | 89 ++++--- .../src/renderer/src/shared/Dialog.tsx | 6 +- .../src/shared/ProviderOAuthConnect.tsx | 82 +++++++ .../src/renderer/src/styles/components.css | 25 ++ AgentCompany-New/src/shared/commands.ts | 21 ++ AgentCompany-New/src/shared/desktop-api.ts | 19 +- AgentCompany-New/src/shared/ipc.ts | 2 + .../src/shared/provider-catalog.ts | 8 +- .../tests/unit/provider-oauth.test.ts | 84 +++++++ .../tests/unit/provider-service.test.ts | 2 +- 24 files changed, 1057 insertions(+), 64 deletions(-) create mode 100644 AgentCompany-New/src/main/services/oauth/github-copilot-flow.ts create mode 100644 AgentCompany-New/src/main/services/oauth/google-antigravity-flow.ts create mode 100644 AgentCompany-New/src/main/services/oauth/oauth-callback-server.ts create mode 100644 AgentCompany-New/src/main/services/oauth/oauth-flows.ts create mode 100644 AgentCompany-New/src/main/services/oauth/oauth-page.ts create mode 100644 AgentCompany-New/src/main/services/oauth/oauth-types.ts create mode 100644 AgentCompany-New/src/main/services/oauth/openai-codex-flow.ts create mode 100644 AgentCompany-New/src/main/services/oauth/pkce.ts create mode 100644 AgentCompany-New/src/main/services/provider-oauth-service.ts create mode 100644 AgentCompany-New/src/renderer/src/shared/ProviderOAuthConnect.tsx create mode 100644 AgentCompany-New/tests/unit/provider-oauth.test.ts diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index ceaedd62..6b55bf6b 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -8,6 +8,7 @@ import { OrganizationService } from "../services/organization-service"; import { ApprovalService } from "../services/approval-service"; import { ChatService } from "../services/chat-service"; import { ProviderService } from "../services/provider-service"; +import { ProviderOAuthService } from "../services/provider-oauth-service"; import { SkillService } from "../services/skill-service"; import { SocialService } from "../services/social-service"; import { FileService } from "../services/file-service"; @@ -34,6 +35,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { const approvals = new ApprovalService(deps.connection.db); const chat = new ChatService(deps.connection.db); const providers = new ProviderService(deps.connection.db); + const providerOAuth = new ProviderOAuthService(); const skills = new SkillService(deps.connection.db, deps.artifactRoot); const taskExecution = new TaskExecutionService(deps.connection.db, providers, skills); const social = new SocialService(deps.connection.db); @@ -128,6 +130,44 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("provider_sign_in_failed", error instanceof Error ? error.message : String(error)); } }); + ipcMain.handle(IPC_CHANNELS.startProviderOAuth, async (_event, input) => { + const emit = (event: Record) => + deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "provider-oauth", providerKey: input.providerKey, ...event }); + try { + const secret = await providerOAuth.run( + { providerKey: input.providerKey, enterpriseDomain: input.enterpriseDomain ?? null }, + { + onAuth: ({ url, instructions }) => { + void shell.openExternal(url); + emit({ phase: "authorize", url, instructions }); + }, + onProgress: (message) => emit({ phase: "progress", message }), + }, + ); + if (input.orgId && input.providerId && input.employeeId) { + providers.saveCredential({ + orgId: input.orgId, + providerId: input.providerId, + secret, + model: input.model ?? null, + employeeId: input.employeeId, + }); + emit({ phase: "active" }); + deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" }); + return ok({ secret: null }); + } + emit({ phase: "active" }); + return ok({ secret }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + emit({ phase: message === "Login cancelled" ? "cancelled" : "error", message }); + return fail("provider_oauth_failed", message); + } + }); + ipcMain.handle(IPC_CHANNELS.cancelProviderOAuth, (_event, input) => { + providerOAuth.cancel(input.providerKey); + return ok(null); + }); ipcMain.handle(IPC_CHANNELS.openExternalUrl, async (_event, url: string) => { try { const parsed = new URL(url); diff --git a/AgentCompany-New/src/main/services/oauth/github-copilot-flow.ts b/AgentCompany-New/src/main/services/oauth/github-copilot-flow.ts new file mode 100644 index 00000000..ab7bbe81 --- /dev/null +++ b/AgentCompany-New/src/main/services/oauth/github-copilot-flow.ts @@ -0,0 +1,227 @@ +/** + * GitHub Copilot OAuth — device-code flow. + * + * Ported from the reference implementation in @mariozechner/pi-ai. The device + * flow obtains a GitHub access token, then exchanges it for a Copilot token. + */ +import { listProviderModels } from "../../../shared/provider-models"; +import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types"; + +// Decoded from the published GitHub Copilot client id used by the official CLI. +const CLIENT_ID = "Iv1.b507a08c87ecfe98"; +const COPILOT_HEADERS = { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.107.0", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "vscode-chat", +} as const; +const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2; +const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4; + +interface CopilotUrls { + deviceCodeUrl: string; + accessTokenUrl: string; + copilotTokenUrl: string; +} + +function normalizeDomain(input: string | null | undefined): string | null { + const trimmed = (input ?? "").trim(); + if (!trimmed) return null; + try { + const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`); + return url.hostname; + } catch { + return null; + } +} + +function getUrls(domain: string): CopilotUrls { + return { + deviceCodeUrl: `https://${domain}/login/device/code`, + accessTokenUrl: `https://${domain}/login/oauth/access_token`, + copilotTokenUrl: `https://api.${domain}/copilot_internal/v2/token`, + }; +} + +async function fetchJson(url: string, init: RequestInit): Promise> { + const response = await fetch(url, init); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`${response.status} ${response.statusText}: ${text}`); + } + return (await response.json()) as Record; +} + +interface DeviceCode { + device_code: string; + user_code: string; + verification_uri: string; + interval: number; + expires_in: number; +} + +async function startDeviceFlow(domain: string): Promise { + const data = await fetchJson(getUrls(domain).deviceCodeUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": COPILOT_HEADERS["User-Agent"], + }, + body: new URLSearchParams({ client_id: CLIENT_ID, scope: "read:user" }), + }); + const deviceCode = data.device_code; + const userCode = data.user_code; + const verificationUri = data.verification_uri; + const interval = data.interval; + const expiresIn = data.expires_in; + if ( + typeof deviceCode !== "string" || + typeof userCode !== "string" || + typeof verificationUri !== "string" || + typeof interval !== "number" || + typeof expiresIn !== "number" + ) { + throw new Error("Invalid device code response from GitHub."); + } + return { device_code: deviceCode, user_code: userCode, verification_uri: verificationUri, interval, expires_in: expiresIn }; +} + +export type PollDecision = + | { kind: "token"; accessToken: string } + | { kind: "pending" } + | { kind: "slow_down"; intervalSeconds: number | null } + | { kind: "error"; message: string }; + +/** Pure interpreter of a device-token poll response (unit-tested). */ +export function interpretPollResponse(raw: Record): PollDecision { + if (typeof raw.access_token === "string") return { kind: "token", accessToken: raw.access_token }; + if (typeof raw.error === "string") { + if (raw.error === "authorization_pending") return { kind: "pending" }; + if (raw.error === "slow_down") { + return { kind: "slow_down", intervalSeconds: typeof raw.interval === "number" ? raw.interval : null }; + } + const description = typeof raw.error_description === "string" ? `: ${raw.error_description}` : ""; + return { kind: "error", message: `Device flow failed: ${raw.error}${description}` }; + } + return { kind: "pending" }; +} + +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Login cancelled")); + return; + } + const timeout = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timeout); + reject(new Error("Login cancelled")); + }, + { once: true }, + ); + }); +} + +async function pollForGitHubAccessToken(domain: string, device: DeviceCode, signal?: AbortSignal): Promise { + const { accessTokenUrl } = getUrls(domain); + const deadline = Date.now() + device.expires_in * 1000; + let intervalMs = Math.max(1000, Math.floor(device.interval * 1000)); + let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER; + while (Date.now() < deadline) { + if (signal?.aborted) throw new Error("Login cancelled"); + const remainingMs = deadline - Date.now(); + const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs); + await abortableSleep(waitMs, signal); + const raw = await fetchJson(accessTokenUrl, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": COPILOT_HEADERS["User-Agent"], + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + device_code: device.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + const decision = interpretPollResponse(raw); + if (decision.kind === "token") return decision.accessToken; + if (decision.kind === "error") throw new Error(decision.message); + if (decision.kind === "slow_down") { + intervalMs = decision.intervalSeconds && decision.intervalSeconds > 0 ? decision.intervalSeconds * 1000 : Math.max(1000, intervalMs + 5000); + intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER; + } + } + throw new Error("Device flow timed out. Please try signing in again."); +} + +export async function refreshGitHubCopilotToken(githubToken: string, enterpriseDomain?: string | null): Promise { + const domain = enterpriseDomain || "github.com"; + const raw = await fetchJson(getUrls(domain).copilotTokenUrl, { + headers: { Accept: "application/json", Authorization: `Bearer ${githubToken}`, ...COPILOT_HEADERS }, + }); + const token = raw.token; + const expiresAt = raw.expires_at; + if (typeof token !== "string" || typeof expiresAt !== "number") { + throw new Error("Invalid Copilot token response."); + } + return { + access: token, + refresh: githubToken, + expires: expiresAt * 1000 - 5 * 60 * 1000, + enterpriseUrl: enterpriseDomain ?? null, + }; +} + +function copilotBaseUrl(token: string, enterpriseDomain?: string | null): string { + const match = token.match(/proxy-ep=([^;]+)/); + if (match) return `https://${match[1].replace(/^proxy\./, "api.")}`; + if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`; + return "https://api.individual.githubcopilot.com"; +} + +/** Best-effort model policy enablement, matching the official client behaviour. */ +async function enableCopilotModels(token: string, enterpriseDomain?: string | null): Promise { + const baseUrl = copilotBaseUrl(token, enterpriseDomain); + await Promise.all( + listProviderModels("github-copilot").map(async (model) => { + try { + await fetch(`${baseUrl}/models/${model.id}/policy`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...COPILOT_HEADERS, + "openai-intent": "chat-policy", + "x-interaction-type": "chat-policy", + }, + body: JSON.stringify({ state: "enabled" }), + }); + } catch { + // Per-model enablement is advisory; the model may already be enabled. + } + }), + ); +} + +export const githubCopilotFlow: OAuthFlow = { + providerKey: "github-copilot", + async login(callbacks: OAuthLoginCallbacks): Promise { + const enterpriseDomain = normalizeDomain(callbacks.enterpriseDomain); + const domain = enterpriseDomain ?? "github.com"; + const device = await startDeviceFlow(domain); + callbacks.onAuth({ + url: device.verification_uri, + instructions: `Enter code ${device.user_code} at ${device.verification_uri}`, + }); + const githubToken = await pollForGitHubAccessToken(domain, device, callbacks.signal); + const credentials = await refreshGitHubCopilotToken(githubToken, enterpriseDomain ?? undefined); + callbacks.onProgress?.("Enabling Copilot models"); + await enableCopilotModels(credentials.access, enterpriseDomain ?? undefined); + return credentials; + }, +}; diff --git a/AgentCompany-New/src/main/services/oauth/google-antigravity-flow.ts b/AgentCompany-New/src/main/services/oauth/google-antigravity-flow.ts new file mode 100644 index 00000000..49e86d2a --- /dev/null +++ b/AgentCompany-New/src/main/services/oauth/google-antigravity-flow.ts @@ -0,0 +1,119 @@ +/** + * Google Antigravity (Gemini 3 / Claude / GPT-OSS via Google Cloud) — + * authorization-code + PKCE flow with project discovery. + * + * Ported from the reference implementation in @mariozechner/pi-ai. + */ +import { startOAuthCallbackServer } from "./oauth-callback-server"; +import { generatePkce } from "./pkce"; +import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types"; + +const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; +const CLIENT_SECRET = "***REMOVED***"; +const CALLBACK_PORT = 51121; +const CALLBACK_PATH = "/oauth-callback"; +const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; +const SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +]; +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_URL = "https://oauth2.googleapis.com/token"; +const DEFAULT_PROJECT_ID = "rising-fact-p41fc"; +const CODE_ASSIST_ENDPOINTS = ["https://cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com"]; + +function buildAuthorizeUrl(challenge: string, state: string): string { + const params = new URLSearchParams({ + client_id: CLIENT_ID, + response_type: "code", + redirect_uri: REDIRECT_URI, + scope: SCOPES.join(" "), + code_challenge: challenge, + code_challenge_method: "S256", + state, + access_type: "offline", + prompt: "consent", + }); + return `${AUTH_URL}?${params.toString()}`; +} + +async function exchangeCode(code: string, verifier: string): Promise<{ access: string; refresh: string; expires: number }> { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }), + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Token exchange failed: ${response.status} ${text}`); + } + const json = (await response.json()) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; + if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") { + throw new Error("Token response was missing required fields."); + } + return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000 }; +} + +/** Extract a Code Assist project id from a loadCodeAssist response (unit-tested). */ +export function readProjectId(payload: Record): string | null { + const project = payload.cloudaicompanionProject; + if (typeof project === "string" && project) return project; + if (project && typeof project === "object" && "id" in project) { + const id = (project as { id?: unknown }).id; + if (typeof id === "string" && id) return id; + } + return null; +} + +async function discoverProject(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + }; + for (const endpoint of CODE_ASSIST_ENDPOINTS) { + try { + const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { + method: "POST", + headers, + body: JSON.stringify({ metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" } }), + }); + if (response.ok) { + const projectId = readProjectId((await response.json()) as Record); + if (projectId) return projectId; + } + } catch { + // Try the next endpoint. + } + } + return DEFAULT_PROJECT_ID; +} + +export const antigravityFlow: OAuthFlow = { + providerKey: "google-antigravity", + async login(callbacks: OAuthLoginCallbacks): Promise { + const { verifier, challenge } = generatePkce(); + const server = await startOAuthCallbackServer({ port: CALLBACK_PORT, path: CALLBACK_PATH, signal: callbacks.signal }); + try { + callbacks.onAuth({ url: buildAuthorizeUrl(challenge, verifier), instructions: "Complete the Google sign-in in your browser." }); + const result = await server.waitForCode(); + if (result.state !== verifier) throw new Error("OAuth state mismatch."); + const tokens = await exchangeCode(result.code, verifier); + callbacks.onProgress?.("Resolving project"); + const projectId = await discoverProject(tokens.access); + return { access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, projectId }; + } finally { + server.close(); + } + }, +}; diff --git a/AgentCompany-New/src/main/services/oauth/oauth-callback-server.ts b/AgentCompany-New/src/main/services/oauth/oauth-callback-server.ts new file mode 100644 index 00000000..3e1dbf7d --- /dev/null +++ b/AgentCompany-New/src/main/services/oauth/oauth-callback-server.ts @@ -0,0 +1,76 @@ +import { createServer } from "node:http"; +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page"; + +const HTML_HEADERS = { "Content-Type": "text/html; charset=utf-8" } as const; + +export interface OAuthCallbackResult { + code: string; + state: string | null; +} + +export interface OAuthCallbackServer { + /** Resolves when the provider redirects with a code; rejects on provider + * error or cancellation. */ + waitForCode(): Promise; + close(): void; +} + +/** + * Bind a loopback HTTP server that captures the OAuth authorization-code + * redirect. The provider's OAuth client is registered with the exact loopback + * redirect URI, so the port and path must match the flow's REDIRECT_URI. + */ +export function startOAuthCallbackServer(options: { + port: number; + path: string; + signal?: AbortSignal; +}): Promise { + const { port, path, signal } = options; + return new Promise((resolve, reject) => { + let settle: ((value: OAuthCallbackResult) => void) | null = null; + let failWait: ((error: Error) => void) | null = null; + const codePromise = new Promise((resolveCode, rejectCode) => { + settle = resolveCode; + failWait = rejectCode; + }); + + const server = createServer((req, res) => { + const url = new URL(req.url ?? "", `http://127.0.0.1:${port}`); + if (url.pathname !== path) { + res.writeHead(404, HTML_HEADERS); + res.end(oauthErrorHtml("Callback route not found.")); + return; + } + const error = url.searchParams.get("error"); + if (error) { + res.writeHead(400, HTML_HEADERS); + res.end(oauthErrorHtml("Sign-in did not complete.", error)); + failWait?.(new Error(`Sign-in failed: ${error}`)); + return; + } + const code = url.searchParams.get("code"); + if (!code) { + res.writeHead(400, HTML_HEADERS); + res.end(oauthErrorHtml("Missing authorization code.")); + return; + } + res.writeHead(200, HTML_HEADERS); + res.end(oauthSuccessHtml("Sign-in complete. You can close this window and return to AgentCompany.")); + settle?.({ code, state: url.searchParams.get("state") }); + }); + + const onAbort = () => failWait?.(new Error("Login cancelled")); + signal?.addEventListener("abort", onAbort, { once: true }); + + server.on("error", (err) => reject(err)); + server.listen(port, "127.0.0.1", () => { + resolve({ + waitForCode: () => codePromise, + close: () => { + signal?.removeEventListener("abort", onAbort); + server.close(); + }, + }); + }); + }); +} diff --git a/AgentCompany-New/src/main/services/oauth/oauth-flows.ts b/AgentCompany-New/src/main/services/oauth/oauth-flows.ts new file mode 100644 index 00000000..da4c7f5c --- /dev/null +++ b/AgentCompany-New/src/main/services/oauth/oauth-flows.ts @@ -0,0 +1,21 @@ +import { githubCopilotFlow } from "./github-copilot-flow"; +import { openaiCodexFlow } from "./openai-codex-flow"; +import { antigravityFlow } from "./google-antigravity-flow"; +import type { OAuthFlow } from "./oauth-types"; + +/** Provider keys (from the catalog) mapped to their native OAuth login flow. */ +const OAUTH_FLOWS: Record = { + "github-copilot": githubCopilotFlow, + "gpt-account": openaiCodexFlow, + "google-antigravity": antigravityFlow, +}; + +export function hasOAuthFlow(providerKey: string): boolean { + return providerKey in OAUTH_FLOWS; +} + +export function getOAuthFlow(providerKey: string): OAuthFlow { + const flow = OAUTH_FLOWS[providerKey]; + if (!flow) throw new Error(`${providerKey} does not support interactive sign-in.`); + return flow; +} diff --git a/AgentCompany-New/src/main/services/oauth/oauth-page.ts b/AgentCompany-New/src/main/services/oauth/oauth-page.ts new file mode 100644 index 00000000..60596510 --- /dev/null +++ b/AgentCompany-New/src/main/services/oauth/oauth-page.ts @@ -0,0 +1,49 @@ +/** Minimal HTML shown in the user's browser after the OAuth redirect. */ + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function renderPage(heading: string, message: string, details?: string): string { + return ` + + + + + ${escapeHtml(heading)} + + + +
+

${escapeHtml(heading)}

+

${escapeHtml(message)}

+ ${details ? `
${escapeHtml(details)}
` : ""} +
+ +`; +} + +export function oauthSuccessHtml(message: string): string { + return renderPage("Authentication successful", message); +} + +export function oauthErrorHtml(message: string, details?: string): string { + return renderPage("Authentication failed", message, details); +} diff --git a/AgentCompany-New/src/main/services/oauth/oauth-types.ts b/AgentCompany-New/src/main/services/oauth/oauth-types.ts new file mode 100644 index 00000000..011c585b --- /dev/null +++ b/AgentCompany-New/src/main/services/oauth/oauth-types.ts @@ -0,0 +1,30 @@ +/** + * Shared types for the native provider OAuth login flows. + * + * These flows obtain real provider credentials through the providers' official + * device-code / authorization-code (PKCE) processes. The resulting credentials + * are stored as JSON via the existing secret vault so a token can be extracted + * for requests and a refresh token retained for future renewal. + */ + +export interface OAuthCredentials { + access: string; + refresh: string; + expires: number; + [key: string]: unknown; +} + +export interface OAuthLoginCallbacks { + /** Called once the authorization URL is ready. `instructions` carries the + * device user-code (device flow) or a short note (authorization-code flow). */ + onAuth: (info: { url: string; instructions?: string }) => void; + onProgress?: (message: string) => void; + signal?: AbortSignal; + /** GitHub Enterprise domain for Copilot; null/empty means github.com. */ + enterpriseDomain?: string | null; +} + +export interface OAuthFlow { + providerKey: string; + login(callbacks: OAuthLoginCallbacks): Promise; +} diff --git a/AgentCompany-New/src/main/services/oauth/openai-codex-flow.ts b/AgentCompany-New/src/main/services/oauth/openai-codex-flow.ts new file mode 100644 index 00000000..99baafac --- /dev/null +++ b/AgentCompany-New/src/main/services/oauth/openai-codex-flow.ts @@ -0,0 +1,97 @@ +/** + * OpenAI Codex (ChatGPT Plus/Pro subscription) — authorization-code + PKCE flow. + * + * Ported from the reference implementation in @mariozechner/pi-ai. Uses a + * loopback callback server on the registered redirect URI. + */ +import { startOAuthCallbackServer } from "./oauth-callback-server"; +import { generatePkce } from "./pkce"; +import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types"; + +const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; +const TOKEN_URL = "https://auth.openai.com/oauth/token"; +const CALLBACK_PORT = 1455; +const CALLBACK_PATH = "/auth/callback"; +const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; +const SCOPE = "openid profile email offline_access"; +const JWT_CLAIM_PATH = "https://api.openai.com/auth"; + +function buildAuthorizeUrl(challenge: string, state: string): string { + const url = new URL(AUTHORIZE_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPE); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + url.searchParams.set("id_token_add_organizations", "true"); + url.searchParams.set("codex_cli_simplified_flow", "true"); + url.searchParams.set("originator", "agentcompany"); + return url.toString(); +} + +function decodeJwtClaims(token: string): Record | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record; + } catch { + return null; + } +} + +/** Extract the ChatGPT account id embedded in the access token (unit-tested). */ +export function extractAccountId(accessToken: string): string | null { + const claims = decodeJwtClaims(accessToken); + const auth = claims?.[JWT_CLAIM_PATH]; + if (auth && typeof auth === "object" && "chatgpt_account_id" in auth) { + const accountId = (auth as { chatgpt_account_id?: unknown }).chatgpt_account_id; + if (typeof accountId === "string" && accountId.length > 0) return accountId; + } + return null; +} + +async function exchangeCode(code: string, verifier: string): Promise<{ access: string; refresh: string; expires: number }> { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + code_verifier: verifier, + redirect_uri: REDIRECT_URI, + }), + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Token exchange failed: ${response.status} ${text}`); + } + const json = (await response.json()) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; + if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") { + throw new Error("Token response was missing required fields."); + } + return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 }; +} + +export const openaiCodexFlow: OAuthFlow = { + providerKey: "gpt-account", + async login(callbacks: OAuthLoginCallbacks): Promise { + const { verifier, challenge } = generatePkce(); + const state = generatePkce().verifier; + const server = await startOAuthCallbackServer({ port: CALLBACK_PORT, path: CALLBACK_PATH, signal: callbacks.signal }); + try { + callbacks.onAuth({ url: buildAuthorizeUrl(challenge, state), instructions: "Complete the ChatGPT sign-in in your browser." }); + const result = await server.waitForCode(); + if (result.state && result.state !== state) throw new Error("OAuth state mismatch."); + const tokens = await exchangeCode(result.code, verifier); + const accountId = extractAccountId(tokens.access); + if (!accountId) throw new Error("Could not read the ChatGPT account id from the token."); + return { access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, accountId }; + } finally { + server.close(); + } + }, +}; diff --git a/AgentCompany-New/src/main/services/oauth/pkce.ts b/AgentCompany-New/src/main/services/oauth/pkce.ts new file mode 100644 index 00000000..35f31f97 --- /dev/null +++ b/AgentCompany-New/src/main/services/oauth/pkce.ts @@ -0,0 +1,13 @@ +import { createHash, randomBytes } from "node:crypto"; + +export interface PkcePair { + verifier: string; + challenge: string; +} + +/** Generate a PKCE verifier and its S256 challenge (RFC 7636). */ +export function generatePkce(): PkcePair { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} diff --git a/AgentCompany-New/src/main/services/provider-auth-profiles.ts b/AgentCompany-New/src/main/services/provider-auth-profiles.ts index c663e385..88e10577 100644 --- a/AgentCompany-New/src/main/services/provider-auth-profiles.ts +++ b/AgentCompany-New/src/main/services/provider-auth-profiles.ts @@ -14,11 +14,30 @@ export function parseApiKey(secret: string): ProviderAuthProfile { } export function parseAccessToken(secret: string): ProviderAuthProfile { - return { type: "access_token", accessToken: nonEmptySecret(secret, "Access token") }; + return { type: "access_token", accessToken: oauthAccessToken(secret, "Access token") }; } export function parseAccountToken(secret: string): ProviderAuthProfile { - return { type: "account_token", accessToken: nonEmptySecret(secret, "Account token") }; + return { type: "account_token", accessToken: oauthAccessToken(secret, "Account token") }; +} + +/** + * Accepts either a raw token string or the JSON credential object produced by + * the native OAuth login flow, returning the usable access token. + */ +function oauthAccessToken(secret: string, label: string): string { + const value = nonEmptySecret(secret, label); + if (!value.startsWith("{")) return value; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error(`${label} credential must be a token or JSON.`); + } + if (isRecord(parsed) && typeof parsed.access === "string" && parsed.access.trim()) { + return parsed.access.trim(); + } + throw new Error(`${label} credential is missing an access token.`); } export function parseAzureProfile(secret: string): ProviderAuthProfile { diff --git a/AgentCompany-New/src/main/services/provider-oauth-service.ts b/AgentCompany-New/src/main/services/provider-oauth-service.ts new file mode 100644 index 00000000..911683f1 --- /dev/null +++ b/AgentCompany-New/src/main/services/provider-oauth-service.ts @@ -0,0 +1,40 @@ +import { getOAuthFlow } from "./oauth/oauth-flows"; + +export interface ProviderOAuthHooks { + onAuth: (info: { url: string; instructions?: string }) => void; + onProgress?: (message: string) => void; +} + +/** + * Drives a provider's native OAuth login and returns the obtained credentials + * as a JSON string, ready to be stored through the existing secret vault. The + * service is decoupled from Electron: opening the browser and emitting UI + * events are supplied by the caller via {@link ProviderOAuthHooks}. + */ +export class ProviderOAuthService { + private readonly controllers = new Map(); + + async run(input: { providerKey: string; enterpriseDomain?: string | null }, hooks: ProviderOAuthHooks): Promise { + const flow = getOAuthFlow(input.providerKey); + if (this.controllers.has(input.providerKey)) { + throw new Error("A sign-in for this provider is already in progress."); + } + const controller = new AbortController(); + this.controllers.set(input.providerKey, controller); + try { + const credentials = await flow.login({ + onAuth: hooks.onAuth, + onProgress: hooks.onProgress, + signal: controller.signal, + enterpriseDomain: input.enterpriseDomain ?? null, + }); + return JSON.stringify(credentials); + } finally { + this.controllers.delete(input.providerKey); + } + } + + cancel(providerKey: string): void { + this.controllers.get(providerKey)?.abort(); + } +} diff --git a/AgentCompany-New/src/main/services/provider-service.ts b/AgentCompany-New/src/main/services/provider-service.ts index f121ac1f..78a637fd 100644 --- a/AgentCompany-New/src/main/services/provider-service.ts +++ b/AgentCompany-New/src/main/services/provider-service.ts @@ -96,6 +96,7 @@ export class ProviderService { providerKey: provider.provider_key, authType: provider.auth_type, employeeId: input.employeeId ?? null, + secret, }); const authProfile = this.authBroker.createProviderProfile({ orgId: input.orgId, @@ -263,10 +264,16 @@ export class ProviderService { providerKey: string; authType: ProviderAuthType; employeeId: string | null; + secret: string; }): { sourceType: string; sourceRef: string | null } { if (input.authType !== PROVIDER_AUTH_TYPE.OAUTH_PKCE && input.authType !== PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION) { return { sourceType: "manual_secret", sourceRef: null }; } + // Credentials obtained through the native OAuth login arrive as a JSON + // object; they are authenticated by the flow itself and need no paste session. + if (input.secret.trim().startsWith("{")) { + return { sourceType: "provider_oauth_login", sourceRef: null }; + } if (!input.employeeId) throw new Error("Provider sign-in employee is required."); const session = this.db.prepare(` select id diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index adf66cf5..a0c034eb 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -29,6 +29,8 @@ const api: DesktopApi = { saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input), revokeProviderCredential: (input) => invoke(IPC_CHANNELS.revokeProviderCredential, input), launchProviderSignIn: (input) => invoke(IPC_CHANNELS.launchProviderSignIn, input), + startProviderOAuth: (input) => invoke(IPC_CHANNELS.startProviderOAuth, input), + cancelProviderOAuth: (input) => invoke(IPC_CHANNELS.cancelProviderOAuth, input), checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input), updateProviderPolicy: (input) => invoke(IPC_CHANNELS.updateProviderPolicy, input), openExternalUrl: (url) => invoke(IPC_CHANNELS.openExternalUrl, url), diff --git a/AgentCompany-New/src/renderer/src/app/Onboarding.tsx b/AgentCompany-New/src/renderer/src/app/Onboarding.tsx index 9a87c59f..facd9e52 100644 --- a/AgentCompany-New/src/renderer/src/app/Onboarding.tsx +++ b/AgentCompany-New/src/renderer/src/app/Onboarding.tsx @@ -5,6 +5,7 @@ import { type ProviderCatalogEntry, } from "../../../shared/domain"; import { Button, Field, Icon, IconButton, Pill } from "../shared/ui"; +import { ProviderOAuthConnect } from "../shared/ProviderOAuthConnect"; import type { SnapshotActions } from "./useSnapshot"; type Step = "welcome" | "provider" | "company" | "mode" | "mission"; @@ -244,7 +245,6 @@ function ProviderStep({ const selected = providers.find((entry) => entry.key === draft.providerKey) ?? providers[0]; if (!selected) throw new Error("No provider available for onboarding."); const isOAuthLike = selected.authType === "oauth_pkce" || selected.authType === "account_subscription"; - const secretLabel = isOAuthLike ? "Access token" : "API key"; return (
@@ -275,26 +275,25 @@ function ProviderStep({ {selected.credentialHelp && (
{selected.credentialHelp}
)} - {isOAuthLike && selected.signInUrl && ( -
- - {selected.signInUrl} -
- )} - - onChange({ providerSecret: event.target.value })} + {isOAuthLike ? ( + ({ providerKey: selected.key })} + onComplete={(secret) => onChange({ providerSecret: secret ?? "" })} /> - + ) : ( + + onChange({ providerSecret: event.target.value })} + /> + + )} {selected.models.length ? ( - - - ); - } return ; } +function ProviderOAuthFields({ + orgId, + employeeId, + provider, + entry, + onConnected, +}: { + orgId: string; + employeeId: string; + provider: ProviderRecord; + entry: ProviderCatalogEntry; + onConnected: () => void; +}) { + const [model, setModel] = useState(provider.model ?? entry.defaultModel ?? entry.models[0]?.id ?? ""); + return ( + <> +

{entry.credentialHelp}

+ + {entry.models.length ? ( + + ) : ( + setModel(event.target.value)} /> + )} + + ({ providerKey: entry.key, orgId, providerId: provider.id, employeeId, model })} + onComplete={() => onConnected()} + /> + + ); +} + function ModelPicker({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) { const defaultValue = provider.model ?? entry.defaultModel ?? entry.models[0]?.id ?? ""; if (entry.models.length === 0) { diff --git a/AgentCompany-New/src/renderer/src/shared/Dialog.tsx b/AgentCompany-New/src/renderer/src/shared/Dialog.tsx index f8bd9df0..d67b2e03 100644 --- a/AgentCompany-New/src/renderer/src/shared/Dialog.tsx +++ b/AgentCompany-New/src/renderer/src/shared/Dialog.tsx @@ -8,6 +8,7 @@ export function Dialog({ submitLabel, onClose, onSubmit, + hideSubmit = false, }: { title: string; open: boolean; @@ -15,6 +16,7 @@ export function Dialog({ submitLabel: string; onClose: () => void; onSubmit: (event: FormEvent) => void; + hideSubmit?: boolean; }) { if (!open) return null; return ( @@ -23,8 +25,8 @@ export function Dialog({

{title}

{children}
- - + + {!hideSubmit && }
diff --git a/AgentCompany-New/src/renderer/src/shared/ProviderOAuthConnect.tsx b/AgentCompany-New/src/renderer/src/shared/ProviderOAuthConnect.tsx new file mode 100644 index 00000000..f960ced5 --- /dev/null +++ b/AgentCompany-New/src/renderer/src/shared/ProviderOAuthConnect.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from "react"; +import type { StartProviderOAuthInput } from "../../../shared/commands"; +import { Button, Icon } from "./ui"; + +type Phase = "idle" | "running" | "done" | "error"; + +/** + * Runs a provider's native OAuth login (device-code / PKCE) and reports the + * outcome. The browser opens automatically; for the device flow the user code + * is shown here, which is the only interactive instruction required. + */ +export function ProviderOAuthConnect({ + providerKey, + label, + buildInput, + onComplete, +}: { + providerKey: string; + label: string; + buildInput: () => StartProviderOAuthInput; + onComplete: (secret: string | null) => void; +}) { + const [phase, setPhase] = useState("idle"); + const [instruction, setInstruction] = useState(null); + const [error, setError] = useState(null); + + useEffect( + () => + window.agentCompany.subscribe((event) => { + if (event.type !== "provider-oauth" || event.providerKey !== providerKey) return; + if (event.phase === "authorize" && event.instructions) setInstruction(event.instructions); + }), + [providerKey], + ); + + const run = () => { + setPhase("running"); + setInstruction(null); + setError(null); + void window.agentCompany.startProviderOAuth(buildInput()).then((result) => { + if (result.ok) { + setPhase("done"); + onComplete(result.data.secret); + } else { + setPhase("error"); + setError(result.error.message); + } + }); + }; + + const cancel = () => void window.agentCompany.cancelProviderOAuth({ providerKey }); + + if (phase === "running") { + return ( +
+
+ + Waiting for {label} sign-in + +
+ {instruction &&
{instruction}
} +
+ ); + } + + if (phase === "done") { + return ( +
+ + Signed in to {label} + +
+ ); + } + + return ( +
+ + {error &&
{error}
} +
+ ); +} diff --git a/AgentCompany-New/src/renderer/src/styles/components.css b/AgentCompany-New/src/renderer/src/styles/components.css index e636e7ef..2eede804 100644 --- a/AgentCompany-New/src/renderer/src/styles/components.css +++ b/AgentCompany-New/src/renderer/src/styles/components.css @@ -180,3 +180,28 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; } color: var(--text-3); } .link-button:hover { color: var(--text-1); } + +.oauth-connect { display: flex; flex-direction: column; gap: 8px; } +.oauth-connect-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-2); } +.oauth-connect-row.done { color: var(--text-1); } +.oauth-connect-row .link-button { margin-left: auto; } +.oauth-connect-code { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 12.5px; + color: var(--text-1); + background: var(--panel-3); + border-radius: var(--r-sm); + padding: 8px 10px; + word-break: break-word; +} +.oauth-connect-error { font-size: 12px; color: var(--red); } +.spinner { + width: 13px; + height: 13px; + border-radius: 50%; + border: 2px solid var(--border-strong); + border-top-color: var(--text-2); + animation: spinner-rotate 0.7s linear infinite; + flex-shrink: 0; +} +@keyframes spinner-rotate { to { transform: rotate(360deg); } } diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index 7a331f66..004d1391 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -184,6 +184,27 @@ export interface LaunchProviderSignInInput { employeeId: string; } +export interface StartProviderOAuthInput { + providerKey: string; + enterpriseDomain?: string | null; + /** Present when connecting an existing org's provider (Settings); absent + * during onboarding, where the obtained credential is returned to the caller. */ + orgId?: string; + providerId?: string; + employeeId?: string; + model?: string | null; +} + +export interface StartProviderOAuthResult { + /** The obtained credential JSON when no org context was supplied, else null + * (the credential was already stored). */ + secret: string | null; +} + +export interface CancelProviderOAuthInput { + providerKey: string; +} + export interface CheckProviderHealthInput { orgId: string; providerId: string; diff --git a/AgentCompany-New/src/shared/desktop-api.ts b/AgentCompany-New/src/shared/desktop-api.ts index e31f23f6..04cfd912 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -14,6 +14,9 @@ import type { CreateLegalHoldInput, CreateMigrationReportInput, CheckProviderHealthInput, + StartProviderOAuthInput, + StartProviderOAuthResult, + CancelProviderOAuthInput, MarkChatSpaceReadInput, CreateOrganizationInput, CreateProviderResponseInput, @@ -58,9 +61,21 @@ import type { } from "./commands"; import type { ProviderStreamRendererEvent } from "./provider-request-domain"; +export type ProviderOAuthPhase = "authorize" | "progress" | "active" | "error" | "cancelled"; + +export interface ProviderOAuthEvent { + type: "provider-oauth"; + providerKey: string; + phase: ProviderOAuthPhase; + url?: string; + instructions?: string; + message?: string; +} + export type RendererEvent = | { type: "snapshot-changed" } - | { type: "provider-response-stream"; event: ProviderStreamRendererEvent }; + | { type: "provider-response-stream"; event: ProviderStreamRendererEvent } + | ProviderOAuthEvent; export interface DesktopApi { loadSnapshot(): Promise>; @@ -85,6 +100,8 @@ export interface DesktopApi { saveProviderCredential(input: SaveProviderCredentialInput): Promise>; revokeProviderCredential(input: RevokeProviderCredentialInput): Promise>; launchProviderSignIn(input: LaunchProviderSignInInput): Promise>; + startProviderOAuth(input: StartProviderOAuthInput): Promise>; + cancelProviderOAuth(input: CancelProviderOAuthInput): Promise>; checkProviderHealth(input: CheckProviderHealthInput): Promise>; updateProviderPolicy(input: UpdateProviderPolicyInput): Promise>; openExternalUrl(url: string): Promise>; diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index cbd1a089..548b69fa 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -21,6 +21,8 @@ export const IPC_CHANNELS = { saveProviderCredential: "agentcompany:save-provider-credential", revokeProviderCredential: "agentcompany:revoke-provider-credential", launchProviderSignIn: "agentcompany:launch-provider-sign-in", + startProviderOAuth: "agentcompany:start-provider-oauth", + cancelProviderOAuth: "agentcompany:cancel-provider-oauth", checkProviderHealth: "agentcompany:check-provider-health", updateProviderPolicy: "agentcompany:update-provider-policy", openExternalUrl: "agentcompany:open-external-url", diff --git a/AgentCompany-New/src/shared/provider-catalog.ts b/AgentCompany-New/src/shared/provider-catalog.ts index 5f57a8fd..e6c0dcf8 100644 --- a/AgentCompany-New/src/shared/provider-catalog.ts +++ b/AgentCompany-New/src/shared/provider-catalog.ts @@ -44,8 +44,8 @@ const RAW_CATALOG: Omit[] = [ authType: PROVIDER_AUTH_TYPE.OAUTH_PKCE, defaultModel: "claude-opus-4-5", capabilityTags: ["account", "coding"], - signInUrl: "https://github.com/login", - credentialHelp: "Sign in, then copy the personal access token (scope copilot) from github.com → Settings → Developer settings.", + signInUrl: "https://github.com/login/device", + credentialHelp: "Sign in with the GitHub device code shown here — no token to copy.", }, { key: "google-antigravity", @@ -54,7 +54,7 @@ const RAW_CATALOG: Omit[] = [ defaultModel: "claude-sonnet-4-5", capabilityTags: ["account", "coding"], signInUrl: "https://antigravity.google/", - credentialHelp: "Sign in to Antigravity, then paste the session token shown in the dashboard.", + credentialHelp: "Sign in with your Google account in the browser; AgentCompany finishes the handshake.", }, { key: "gpt-account", @@ -63,7 +63,7 @@ const RAW_CATALOG: Omit[] = [ defaultModel: "gpt-5.3-codex", capabilityTags: ["codex", "subscription"], signInUrl: "https://chatgpt.com/codex", - credentialHelp: "Sign in to ChatGPT Plus/Pro, then paste the session access token from chatgpt.com/codex/settings.", + credentialHelp: "Sign in to ChatGPT Plus/Pro in the browser; AgentCompany finishes the handshake.", }, { key: "azure-openai", diff --git a/AgentCompany-New/tests/unit/provider-oauth.test.ts b/AgentCompany-New/tests/unit/provider-oauth.test.ts new file mode 100644 index 00000000..e84b4d30 --- /dev/null +++ b/AgentCompany-New/tests/unit/provider-oauth.test.ts @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; +import { describe, expect, test } from "vitest"; +import { interpretPollResponse } from "../../src/main/services/oauth/github-copilot-flow"; +import { extractAccountId } from "../../src/main/services/oauth/openai-codex-flow"; +import { readProjectId } from "../../src/main/services/oauth/google-antigravity-flow"; +import { generatePkce } from "../../src/main/services/oauth/pkce"; +import { parseAccessToken, parseAccountToken } from "../../src/main/services/provider-auth-profiles"; + +function makeJwt(claims: Record): string { + const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${part({ alg: "none" })}.${part(claims)}.signature`; +} + +describe("github copilot device-code poll interpreter", () => { + test("returns the token when present", () => { + expect(interpretPollResponse({ access_token: "ghu_token" })).toEqual({ kind: "token", accessToken: "ghu_token" }); + }); + + test("keeps polling while authorization is pending", () => { + expect(interpretPollResponse({ error: "authorization_pending" })).toEqual({ kind: "pending" }); + expect(interpretPollResponse({})).toEqual({ kind: "pending" }); + }); + + test("backs off on slow_down with the provided interval", () => { + expect(interpretPollResponse({ error: "slow_down", interval: 9 })).toEqual({ kind: "slow_down", intervalSeconds: 9 }); + expect(interpretPollResponse({ error: "slow_down" })).toEqual({ kind: "slow_down", intervalSeconds: null }); + }); + + test("fails on terminal errors", () => { + const decision = interpretPollResponse({ error: "access_denied", error_description: "user declined" }); + expect(decision.kind).toBe("error"); + if (decision.kind === "error") expect(decision.message).toContain("access_denied: user declined"); + }); +}); + +describe("openai codex account id extraction", () => { + test("reads the chatgpt account id from the token claims", () => { + const token = makeJwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct_42" } }); + expect(extractAccountId(token)).toBe("acct_42"); + }); + + test("returns null when the claim is absent or malformed", () => { + expect(extractAccountId(makeJwt({ sub: "user" }))).toBeNull(); + expect(extractAccountId("not-a-jwt")).toBeNull(); + }); +}); + +describe("antigravity project discovery", () => { + test("reads a string or object project id", () => { + expect(readProjectId({ cloudaicompanionProject: "proj-a" })).toBe("proj-a"); + expect(readProjectId({ cloudaicompanionProject: { id: "proj-b" } })).toBe("proj-b"); + }); + + test("returns null when no project is present", () => { + expect(readProjectId({})).toBeNull(); + expect(readProjectId({ cloudaicompanionProject: {} })).toBeNull(); + }); +}); + +describe("pkce generation", () => { + test("produces an S256 challenge for the verifier", () => { + const { verifier, challenge } = generatePkce(); + expect(verifier).not.toBe(""); + expect(verifier).not.toBe(challenge); + expect(challenge).toBe(createHash("sha256").update(verifier).digest("base64url")); + }); +}); + +describe("oauth credential parsing", () => { + test("accepts a raw token", () => { + expect(parseAccessToken("ghu_raw")).toEqual({ type: "access_token", accessToken: "ghu_raw" }); + expect(parseAccountToken("chatgpt_raw")).toEqual({ type: "account_token", accessToken: "chatgpt_raw" }); + }); + + test("extracts the access token from native OAuth credential JSON", () => { + const secret = JSON.stringify({ access: "live_token", refresh: "r", expires: 123 }); + expect(parseAccessToken(secret)).toEqual({ type: "access_token", accessToken: "live_token" }); + expect(parseAccountToken(secret)).toEqual({ type: "account_token", accessToken: "live_token" }); + }); + + test("rejects credential JSON without an access token", () => { + expect(() => parseAccessToken(JSON.stringify({ refresh: "r" }))).toThrow("missing an access token"); + }); +}); diff --git a/AgentCompany-New/tests/unit/provider-service.test.ts b/AgentCompany-New/tests/unit/provider-service.test.ts index 343fc2d3..3decdfad 100644 --- a/AgentCompany-New/tests/unit/provider-service.test.ts +++ b/AgentCompany-New/tests/unit/provider-service.test.ts @@ -197,7 +197,7 @@ describe("provider service", () => { expect(launched).toMatchObject({ providerId: copilotProviderId, label: "GitHub Copilot", - signInUrl: "https://github.com/login", + signInUrl: "https://github.com/login/device", partition: `persist:agentcompany-provider-${copilotProviderId}`, }); expect(state.providerAuthSessions[0]).toMatchObject({