Promote AgentCompany-New to the repository root and retire the old app
The legacy application - its src, tests, landing site, promo assets, docs, release tooling, CI workflows, and root configs - leaves the tree; it stays reachable in history. Everything under AgentCompany-New moves up unchanged: same package name, so the production userData/database path is untouched, and e2e/build scripts already resolved paths relative to the project. The developer cred.txt stays gitignored at its new location. Full verify:ci passes from the new root.
This commit is contained in:
1 parent
2350f744e3
commit
07d0b4587d
1904 files changed
+1133
-282075
No files matched your search
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Anthropic Claude Pro/Max — authorization-code + PKCE flow.
|
||||
*
|
||||
* Ported from the reference implementation in @mariozechner/pi-ai. Uses a
|
||||
* loopback callback server on the registered redirect URI and a JSON token
|
||||
* exchange.
|
||||
*/
|
||||
import { startOAuthCallbackServer } from "./oauth-callback-server";
|
||||
import { generatePkce } from "./pkce";
|
||||
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
|
||||
|
||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
||||
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
||||
const CALLBACK_PORT = 53692;
|
||||
const CALLBACK_PATH = "/callback";
|
||||
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
const SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
||||
|
||||
function buildAuthorizeUrl(challenge: string, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
});
|
||||
return `${AUTHORIZE_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function exchangeCode(code: string, state: string, verifier: string): Promise<OAuthCredentials> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
state,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Token exchange failed: ${response.status} ${text}`);
|
||||
const json = JSON.parse(text) 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 };
|
||||
}
|
||||
|
||||
/** Refresh an expired Claude Pro/Max access token. */
|
||||
export async function refreshClaudePro(refreshToken: string): Promise<OAuthCredentials> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refreshToken }),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Claude token refresh failed: ${response.status} ${text}`);
|
||||
const json = JSON.parse(text) 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("Claude token refresh response was missing required fields.");
|
||||
}
|
||||
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000 };
|
||||
}
|
||||
|
||||
export const anthropicClaudeFlow: OAuthFlow = {
|
||||
providerKey: "claude-pro",
|
||||
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 Claude sign-in in your browser." });
|
||||
const result = await server.waitForCode();
|
||||
if (result.state !== verifier) throw new Error("OAuth state mismatch.");
|
||||
return await exchangeCode(result.code, result.state, verifier);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
|
||||
export 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,146 @@
|
||||
/**
|
||||
* 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_ENV = "AGENTCOMPANY_GOOGLE_ANTIGRAVITY_CLIENT_SECRET";
|
||||
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 CODE_ASSIST_ENDPOINTS = ["https://cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com"];
|
||||
|
||||
export function resolveAntigravityClientSecret(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const secret = env[CLIENT_SECRET_ENV]?.trim();
|
||||
if (!secret) throw new Error(`${CLIENT_SECRET_ENV} is required for Google Antigravity OAuth.`);
|
||||
return secret;
|
||||
}
|
||||
|
||||
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: resolveAntigravityClientSecret(),
|
||||
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.
|
||||
}
|
||||
}
|
||||
throw new Error("Antigravity project discovery failed.");
|
||||
}
|
||||
|
||||
/** Refresh an expired Antigravity access token, retaining the project id. */
|
||||
export async function refreshAntigravity(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
|
||||
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: resolveAntigravityClientSecret(),
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Antigravity token refresh failed: ${response.status} ${text}`);
|
||||
const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Antigravity token refresh response was missing required fields.");
|
||||
}
|
||||
const refresh = typeof json.refresh_token === "string" && json.refresh_token ? json.refresh_token : refreshToken;
|
||||
return { access: json.access_token, refresh, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000, projectId };
|
||||
}
|
||||
|
||||
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,23 @@
|
||||
import { githubCopilotFlow } from "./github-copilot-flow";
|
||||
import { openaiCodexFlow } from "./openai-codex-flow";
|
||||
import { antigravityFlow } from "./google-antigravity-flow";
|
||||
import { anthropicClaudeFlow } from "./anthropic-claude-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,
|
||||
"claude-pro": anthropicClaudeFlow,
|
||||
};
|
||||
|
||||
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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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,115 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
/** Refresh an expired Codex access token using the long-lived refresh token. */
|
||||
export async function refreshCodex(refreshToken: string): Promise<OAuthCredentials> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID }),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Codex token refresh failed: ${response.status} ${text}`);
|
||||
const json = JSON.parse(text) 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("Codex token refresh response was missing required fields.");
|
||||
}
|
||||
const accountId = extractAccountId(json.access_token);
|
||||
if (!accountId) throw new Error("Could not read the ChatGPT account id from the refreshed token.");
|
||||
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000, accountId };
|
||||
}
|
||||
|
||||
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 !== 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 };
|
||||
}
|
||||
Reference in new issue
Block a user