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.
This commit is contained in:
ZacharyZhang-NY committed 2026-05-29 20:08:55 -04:00
1 parent d5bf6c6b5f
commit 49a9f16221
24 files changed
+1057 -64

No files matched your search

@@ -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<string, unknown>) =>
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);
@@ -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<Record<string, unknown>> {
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<string, unknown>;
}
interface DeviceCode {
device_code: string;
user_code: string;
verification_uri: string;
interval: number;
expires_in: number;
}
async function startDeviceFlow(domain: string): Promise<DeviceCode> {
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<string, unknown>): 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<void> {
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<string> {
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<OAuthCredentials> {
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<void> {
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<OAuthCredentials> {
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;
},
};
@@ -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, unknown>): 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<string> {
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<string, unknown>);
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<OAuthCredentials> {
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();
}
},
};
@@ -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<OAuthCallbackResult>;
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<OAuthCallbackServer> {
const { port, path, signal } = options;
return new Promise<OAuthCallbackServer>((resolve, reject) => {
let settle: ((value: OAuthCallbackResult) => void) | null = null;
let failWait: ((error: Error) => void) | null = null;
const codePromise = new Promise<OAuthCallbackResult>((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();
},
});
});
});
}
@@ -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<string, OAuthFlow> = {
"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;
}
@@ -0,0 +1,49 @@
/** Minimal HTML shown in the user's browser after the OAuth redirect. */
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function renderPage(heading: string, message: string, details?: string): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(heading)}</title>
<style>
* { box-sizing: border-box; }
html { color-scheme: dark; }
body {
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
padding: 24px; background: #09090b; color: #fafafa; text-align: center;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
main { width: 100%; max-width: 520px; }
h1 { margin: 0 0 10px; font-size: 26px; font-weight: 650; }
p { margin: 0; line-height: 1.7; color: #a1a1aa; font-size: 15px; }
.details { margin-top: 14px; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; color: #a1a1aa; word-break: break-word; }
</style>
</head>
<body>
<main>
<h1>${escapeHtml(heading)}</h1>
<p>${escapeHtml(message)}</p>
${details ? `<div class="details">${escapeHtml(details)}</div>` : ""}
</main>
</body>
</html>`;
}
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);
}
@@ -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<OAuthCredentials>;
}
@@ -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<string, unknown> | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>;
} 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<OAuthCredentials> {
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();
}
},
};
@@ -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 };
}
@@ -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 {
@@ -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<string, AbortController>();
async run(input: { providerKey: string; enterpriseDomain?: string | null }, hooks: ProviderOAuthHooks): Promise<string> {
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();
}
}
@@ -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
+2
View File
@@ -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),
@@ -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 (
<div className="form-grid">
@@ -275,26 +275,25 @@ function ProviderStep({
{selected.credentialHelp && (
<div style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5 }}>{selected.credentialHelp}</div>
)}
{isOAuthLike && selected.signInUrl && (
<div className="row" style={{ gap: 8 }}>
<Button
tone="primary"
onClick={() => void window.agentCompany.openExternalUrl(selected.signInUrl ?? "")}
>
Open {selected.label} sign-in
</Button>
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{selected.signInUrl}</span>
</div>
)}
<Field label={secretLabel}>
<input
className="input"
type="password"
autoComplete="off"
value={draft.providerSecret}
onChange={(event) => onChange({ providerSecret: event.target.value })}
{isOAuthLike ? (
<ProviderOAuthConnect
key={selected.key}
providerKey={selected.key}
label={selected.label}
buildInput={() => ({ providerKey: selected.key })}
onComplete={(secret) => onChange({ providerSecret: secret ?? "" })}
/>
</Field>
) : (
<Field label="API key">
<input
className="input"
type="password"
autoComplete="off"
value={draft.providerSecret}
onChange={(event) => onChange({ providerSecret: event.target.value })}
/>
</Field>
)}
<Field label="Model">
{selected.models.length ? (
<select
@@ -1,6 +1,7 @@
import { useState, type FormEvent } from "react";
import { Button, Field, Pill } from "../../shared/ui";
import { Dialog } from "../../shared/Dialog";
import { ProviderOAuthConnect } from "../../shared/ProviderOAuthConnect";
import type {
AgentRecord,
ProviderCatalogEntry,
@@ -41,19 +42,19 @@ type WorkspaceScope = "organization" | "workspace";
export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, catalog, agents, workspaces, providerRateLimits, providerRequests, actions }: ProvidersPanelProps) {
const [active, setActive] = useState<ProviderRecord | null>(null);
const [signInLaunched, setSignInLaunched] = useState(false);
const connected = providers.filter((provider) => provider.enabled);
const available = providers.filter((provider) => !provider.enabled);
const activeCatalogEntry = active ? catalog.find((entry) => entry.key === active.providerKey) ?? null : null;
const activeIsOAuth =
activeCatalogEntry?.authType === "oauth_pkce" || activeCatalogEntry?.authType === "account_subscription";
const close = () => {
setActive(null);
setSignInLaunched(false);
};
const save = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!active || !activeCatalogEntry) return;
if (!active || !activeCatalogEntry || activeIsOAuth) return;
const form = new FormData(event.currentTarget);
const secret = composeSecret(activeCatalogEntry, form);
void actions.run(() => window.agentCompany.saveProviderCredential({
@@ -65,12 +66,6 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat
})).then((result) => { if (result) close(); });
};
const launchSignIn = () => {
if (!active) return;
setSignInLaunched(true);
void actions.run(() => window.agentCompany.launchProviderSignIn({ orgId, providerId: active.id, employeeId }));
};
return (
<div className="page">
<h1 className="page-h1">Providers</h1>
@@ -124,16 +119,22 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat
title={active ? `Connect ${active.label}` : "Connect provider"}
open={Boolean(active)}
submitLabel="Save"
hideSubmit={activeIsOAuth}
onClose={close}
onSubmit={save}
>
{active && activeCatalogEntry && (
<ProviderConnectFields
provider={active}
entry={activeCatalogEntry}
signInLaunched={signInLaunched}
onLaunchSignIn={launchSignIn}
/>
activeIsOAuth ? (
<ProviderOAuthFields
orgId={orgId}
employeeId={employeeId}
provider={active}
entry={activeCatalogEntry}
onConnected={() => { void actions.reload(); close(); }}
/>
) : (
<ProviderConnectFields provider={active} entry={activeCatalogEntry} />
)
)}
</Dialog>
</div>
@@ -274,13 +275,9 @@ function ProviderPolicyRow({
function ProviderConnectFields({
provider,
entry,
signInLaunched,
onLaunchSignIn,
}: {
provider: ProviderRecord;
entry: ProviderCatalogEntry;
signInLaunched: boolean;
onLaunchSignIn: () => void;
}) {
if (entry.authType === "api_key") {
return (
@@ -291,24 +288,48 @@ function ProviderConnectFields({
</>
);
}
if (entry.authType === "oauth_pkce" || entry.authType === "account_subscription") {
return (
<>
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
<div className="row" style={{ gap: 8 }}>
<Button tone={signInLaunched ? "ghost" : "primary"} onClick={onLaunchSignIn}>
{signInLaunched ? "Re-open sign-in window" : `Open ${entry.label} sign-in`}
</Button>
{entry.signInUrl && <span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{entry.signInUrl}</span>}
</div>
<Field label="Access token"><input className="input" name="secret" type="password" required /></Field>
<ModelPicker entry={entry} provider={provider} />
</>
);
}
return <CloudFieldsForm entry={entry} provider={provider} />;
}
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 (
<>
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
<Field label="Default model">
{entry.models.length ? (
<select className="input" value={model} onChange={(event) => setModel(event.target.value)}>
{entry.models.map((option) => (
<option key={option.id} value={option.id}>{option.name}</option>
))}
</select>
) : (
<input className="input" value={model} onChange={(event) => setModel(event.target.value)} />
)}
</Field>
<ProviderOAuthConnect
key={entry.key}
providerKey={entry.key}
label={entry.label}
buildInput={() => ({ 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) {
@@ -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<HTMLFormElement>) => void;
hideSubmit?: boolean;
}) {
if (!open) return null;
return (
@@ -23,8 +25,8 @@ export function Dialog({
<h2 className="modal-title">{title}</h2>
<div style={{ display: "grid", gap: 12 }}>{children}</div>
<div className="modal-actions">
<Button tone="ghost" onClick={onClose}>Cancel</Button>
<Button tone="primary" type="submit">{submitLabel}</Button>
<Button tone="ghost" onClick={onClose}>{hideSubmit ? "Close" : "Cancel"}</Button>
{!hideSubmit && <Button tone="primary" type="submit">{submitLabel}</Button>}
</div>
</form>
</div>
@@ -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<Phase>("idle");
const [instruction, setInstruction] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="oauth-connect">
<div className="oauth-connect-row">
<span className="spinner" aria-hidden />
<span>Waiting for {label} sign-in</span>
<button type="button" className="link-button" onClick={cancel}>Cancel</button>
</div>
{instruction && <div className="oauth-connect-code">{instruction}</div>}
</div>
);
}
if (phase === "done") {
return (
<div className="oauth-connect-row done">
<Icon name="check" size={14} color="var(--green)" />
<span>Signed in to {label}</span>
<button type="button" className="link-button" onClick={run}>Redo</button>
</div>
);
}
return (
<div className="oauth-connect">
<Button tone="primary" onClick={run}>Sign in with {label}</Button>
{error && <div className="oauth-connect-error">{error}</div>}
</div>
);
}
@@ -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); } }
+21
View File
@@ -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;
+18 -1
View File
@@ -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<DesktopResult<AppSnapshot>>;
@@ -85,6 +100,8 @@ export interface DesktopApi {
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
revokeProviderCredential(input: RevokeProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
launchProviderSignIn(input: LaunchProviderSignInInput): Promise<DesktopResult<null>>;
startProviderOAuth(input: StartProviderOAuthInput): Promise<DesktopResult<StartProviderOAuthResult>>;
cancelProviderOAuth(input: CancelProviderOAuthInput): Promise<DesktopResult<null>>;
checkProviderHealth(input: CheckProviderHealthInput): Promise<DesktopResult<AppSnapshot>>;
updateProviderPolicy(input: UpdateProviderPolicyInput): Promise<DesktopResult<AppSnapshot>>;
openExternalUrl(url: string): Promise<DesktopResult<null>>;
+2
View File
@@ -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",
@@ -44,8 +44,8 @@ const RAW_CATALOG: Omit<ProviderCatalogEntry, "models">[] = [
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<ProviderCatalogEntry, "models">[] = [
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<ProviderCatalogEntry, "models">[] = [
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",
@@ -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, unknown>): 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");
});
});
@@ -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({