mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
Reuse the cloud Claude and Codex login drivers behind isolated local attempts, and present the shared browser-code UI in connection setup. Co-Authored-By: Paperclip <noreply@paperclip.ing>
63 lines
3.6 KiB
TypeScript
63 lines
3.6 KiB
TypeScript
import { readLocalAiCredentialFile } from "./local-ai-credential-file.js";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { readIsolatedClaudeKeychainToken } from "@paperclipai/adapter-claude-local/server";
|
|
import { readCodexAuthInfo, fetchCodexQuota } from "@paperclipai/adapter-codex-local/server";
|
|
import { parseGrokAuthPayload, hasUsableGrokAuthValue } from "@paperclipai/adapter-grok-local/server";
|
|
import type { AiProvider } from "@paperclipai/shared";
|
|
import { unprocessable } from "../errors.js";
|
|
|
|
/** Read and verify a connection-specific login home. */
|
|
export async function readVerifiedLocalAiCredential(provider: AiProvider, loginHome?: string): Promise<string> {
|
|
if (provider === "openrouter") throw unprocessable("OpenRouter requires an API key.");
|
|
if (!loginHome) throw unprocessable(provider === "xai"
|
|
? "Start local sign-in for this connection before connecting."
|
|
: "Start browser sign-in for this connection before connecting.");
|
|
try {
|
|
if (provider === "anthropic") {
|
|
// Never change process.env or fall back to the server account when an
|
|
// authenticated user's isolated login is missing or invalid.
|
|
let token: string | null = null;
|
|
for (const name of [".credentials.json", "credentials.json"]) {
|
|
const raw = await readLocalAiCredentialFile(path.join(loginHome, name)).catch(() => null);
|
|
if (!raw) continue;
|
|
let parsed;
|
|
try { parsed = JSON.parse(raw); } catch { continue; }
|
|
const value = parsed?.claudeAiOauth?.accessToken;
|
|
if (typeof value === "string" && value.length) { token = value; break; }
|
|
}
|
|
// On macOS the CLI stores the isolated login in the auth home's own
|
|
// suffixed Keychain item rather than a credentials file. The helper
|
|
// never consults the unsuffixed operator item.
|
|
if (!token) token = await readIsolatedClaudeKeychainToken(loginHome);
|
|
if (!token) throw new Error("Missing login");
|
|
// Claude's setup-token OAuth flow requests user:inference. The usage API
|
|
// requires user:profile and returns 403 for that valid token, so it cannot
|
|
// serve as a sign-in check. This isolated credential is written only after
|
|
// the provider CLI completes the code exchange successfully.
|
|
return token;
|
|
}
|
|
if (provider === "openai") {
|
|
const auth = await readCodexAuthInfo(loginHome);
|
|
if (!auth?.accessToken || !auth.refreshToken || !auth.idToken) throw new Error("Missing login");
|
|
await fetchCodexQuota(auth.accessToken, auth.accountId);
|
|
return JSON.stringify({ tokens: { access_token: auth.accessToken, refresh_token: auth.refreshToken, id_token: auth.idToken, account_id: auth.accountId }, last_refresh: auth.lastRefresh });
|
|
}
|
|
const raw = await fs.readFile(path.join(loginHome!, "auth.json"), "utf8");
|
|
const payload = parseGrokAuthPayload(JSON.parse(raw));
|
|
if (!payload || !hasUsableGrokAuthValue(payload.value)) throw new Error("Missing login");
|
|
const response = await fetch("https://api.x.ai/v1/models", {
|
|
headers: { Authorization: `Bearer ${payload.value.key}` },
|
|
redirect: "error", signal: AbortSignal.timeout(15000),
|
|
});
|
|
await response.body?.cancel();
|
|
if (!response.ok) throw new Error("Invalid login");
|
|
return raw;
|
|
} catch {
|
|
// Provider/CLI errors may contain credential material; never return them.
|
|
throw unprocessable(provider === "xai"
|
|
? "Could not verify the local subscription. Run the sign-in command shown for this connection, finish signing in, then try Connect again."
|
|
: "Could not verify the local subscription. Finish browser sign-in for this connection, then try Connect again.");
|
|
}
|
|
}
|