feat: add app-owned Copilot and Grok subscription connections
This commit is contained in:
+30
-1
@@ -27,6 +27,8 @@
|
||||
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
|
||||
const { createChatGPT, fileStore } = require("./chatgpt.cjs");
|
||||
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
|
||||
const { createSubscriptions, loadSubscriptionSDK } = require("./subscriptions.cjs");
|
||||
const { attachSubscriptionChild, subscriptionControl } = require("./subscription-ipc.cjs");
|
||||
const { desktopAPIHeaders } = require("./desktop-api.cjs");
|
||||
const { randomBytes } = require("node:crypto");
|
||||
const { spawn } = require("node:child_process");
|
||||
@@ -134,6 +136,8 @@ if (process.argv.includes("--browser-service")) {
|
||||
let serverProcess = null;
|
||||
let chatgpt = null;
|
||||
let chatgptChild = null;
|
||||
let subscriptions = null;
|
||||
let subscriptionChild = null;
|
||||
// Per-launch request capability, never OAuth tokens and never renderer state.
|
||||
let desktopAPISecret = null;
|
||||
const trustedAppWindows = new Set();
|
||||
@@ -222,6 +226,7 @@ function startServer(port) {
|
||||
);
|
||||
serverProcess = child;
|
||||
chatgptChild = attachChatGPTChild(child, chatgpt);
|
||||
subscriptionChild = attachSubscriptionChild(child, subscriptions);
|
||||
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
|
||||
child.stdout.on("data", fwd);
|
||||
child.stderr.on("data", fwd);
|
||||
@@ -230,7 +235,10 @@ function startServer(port) {
|
||||
serverProcess = null;
|
||||
chatgptChild?.dispose();
|
||||
chatgptChild = null;
|
||||
subscriptionChild?.dispose();
|
||||
subscriptionChild = null;
|
||||
chatgpt?.close();
|
||||
subscriptions?.close();
|
||||
});
|
||||
resolve(child);
|
||||
});
|
||||
@@ -255,6 +263,7 @@ function waitUntilUp(url, tries = 150) {
|
||||
|
||||
function killServer() {
|
||||
chatgpt?.close();
|
||||
subscriptions?.close();
|
||||
if (!serverProcess) return;
|
||||
const child = serverProcess;
|
||||
serverProcess = null;
|
||||
@@ -569,6 +578,7 @@ function installMenu() {
|
||||
async function main() {
|
||||
app.on("window-all-closed", () => {
|
||||
chatgpt?.pause();
|
||||
subscriptions?.pause();
|
||||
// macOS convention: the app stays resident, reactivated from the Dock.
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
@@ -600,6 +610,16 @@ async function main() {
|
||||
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("lh:chatgpt:changed");
|
||||
},
|
||||
});
|
||||
subscriptions = createSubscriptions({
|
||||
directory: app.getPath("userData"),
|
||||
loadSDK: () => loadSubscriptionSDK(path.dirname(serverEntry())),
|
||||
openExternal: (target) => shell.openExternal(target),
|
||||
onChange: (snapshot) => {
|
||||
subscriptionChild?.send(snapshot);
|
||||
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("lh:subscriptions:changed");
|
||||
},
|
||||
});
|
||||
await subscriptions.ready;
|
||||
log(`starting server: ${serverEntry()}`);
|
||||
log(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
|
||||
await startServer(new URL(url).port);
|
||||
@@ -617,6 +637,15 @@ async function main() {
|
||||
return chatGPTControl(command, chatgpt);
|
||||
});
|
||||
|
||||
ipcMain.handle("lh:subscriptions:control", async (event, command) => {
|
||||
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
|
||||
if (!subscriptions || !serverProcess?.connected) {
|
||||
if (command?.op === "status") return [];
|
||||
throw new Error("Subscription sign-in requires the desktop app's private server connection.");
|
||||
}
|
||||
return subscriptionControl(command, subscriptions);
|
||||
});
|
||||
|
||||
installMenu();
|
||||
|
||||
// Auto-update (Wave C): wire the updater before the window so the
|
||||
@@ -642,7 +671,7 @@ async function main() {
|
||||
if (width < target) win.setBounds({ x, y, width: target, height });
|
||||
});
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); createWindow(url); }
|
||||
if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); subscriptions?.resume(); createWindow(url); }
|
||||
});
|
||||
createWindow(url);
|
||||
log(`window open: ${url}`);
|
||||
|
||||
@@ -43,6 +43,21 @@ contextBridge.exposeInMainWorld("lh", {
|
||||
return () => ipcRenderer.removeListener("lh:chatgpt:changed", listener);
|
||||
},
|
||||
},
|
||||
subscriptions: {
|
||||
status: () => ipcRenderer.invoke("lh:subscriptions:control", { op: "status" }),
|
||||
login: (providerId, enabled) => ipcRenderer.invoke("lh:subscriptions:control", { op: "login", providerId, enabled }),
|
||||
cancel: (providerId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "cancel", providerId }),
|
||||
respond: (providerId, attemptId, promptId, value) => ipcRenderer.invoke("lh:subscriptions:control", { op: "respond", providerId, attemptId, promptId, value }),
|
||||
open: (providerId, attemptId, actionId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "open", providerId, attemptId, actionId }),
|
||||
retry: (providerId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "retry", providerId }),
|
||||
disconnect: (providerId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "disconnect", providerId }),
|
||||
consent: (providerId, enabled) => ipcRenderer.invoke("lh:subscriptions:control", { op: "consent", providerId, enabled }),
|
||||
onChange: (cb) => {
|
||||
const listener = () => cb();
|
||||
ipcRenderer.on("lh:subscriptions:changed", listener);
|
||||
return () => ipcRenderer.removeListener("lh:subscriptions:changed", listener);
|
||||
},
|
||||
},
|
||||
expandWindow: () => ipcRenderer.send("lh:expand-window"),
|
||||
onZoom: (cb) => {
|
||||
const listener = (_event, factor) => cb(factor);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use strict";
|
||||
const { PROVIDERS } = require("./subscription-store.cjs");
|
||||
const { publicError } = require("./subscriptions.cjs");
|
||||
function attachSubscriptionChild(child, controller) {
|
||||
let disposed = false;
|
||||
const send = (packet) => { if (!disposed && child.connected) { try { child.send(packet, () => {}); } catch { /* child exited */ } } };
|
||||
const listener = async (message) => {
|
||||
if (disposed || !child.connected || message?.channel !== "lh:subscriptions" || typeof message.id !== "string") return;
|
||||
const { id, op } = message;
|
||||
try {
|
||||
if (!controller || !["snapshot", "lease"].includes(op)) throw new Error();
|
||||
if (op === "lease" && (typeof message.background !== "boolean" || !validScope(message.scope))) throw new Error();
|
||||
const value = op === "snapshot" ? await controller.snapshot() : await controller.lease(message.scope, message.background);
|
||||
send({ channel: "lh:subscriptions", id, op, ok: true, value });
|
||||
} catch (error) { send({ channel: "lh:subscriptions", id, op, ok: false, error: publicError(error) }); }
|
||||
};
|
||||
child.on("message", listener);
|
||||
return {
|
||||
send(snapshot) { send({ channel: "lh:subscriptions", op: "changed", snapshot }); },
|
||||
dispose() { disposed = true; child.removeListener("message", listener); },
|
||||
};
|
||||
}
|
||||
function validScope(scope) {
|
||||
return scope && PROVIDERS.includes(scope.providerId) && typeof scope.connectionId === "string" && !!scope.connectionId && typeof scope.selectedModelId === "string" && !!scope.selectedModelId && [scope.epoch, scope.authorityRevision].every((n) => Number.isSafeInteger(n) && n >= 0);
|
||||
}
|
||||
/** Caller authenticates the exact trusted main-frame origin before dispatch. */
|
||||
async function subscriptionControl(command, controller) {
|
||||
try {
|
||||
if (!controller || !command || typeof command !== "object") throw new Error();
|
||||
if (command.op === "status") return await controller.status();
|
||||
if (!PROVIDERS.includes(command.providerId)) throw new Error();
|
||||
const id = command.providerId;
|
||||
switch (command.op) {
|
||||
case "login": return await controller.login(id, command.enabled);
|
||||
case "cancel": return controller.cancel(id);
|
||||
case "respond": return await controller.respond(id, command.attemptId, command.promptId, command.value);
|
||||
case "open": return await controller.open(id, command.attemptId, command.actionId);
|
||||
case "retry": return await controller.retry(id);
|
||||
case "disconnect": return await controller.disconnect(id);
|
||||
case "consent": return await controller.consent(id, command.enabled);
|
||||
default: throw new Error();
|
||||
}
|
||||
} catch (error) { throw new Error(publicError(error)); }
|
||||
}
|
||||
module.exports = { attachSubscriptionChild, subscriptionControl, validScope };
|
||||
@@ -0,0 +1,64 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const PROVIDERS = ["github-copilot", "xai"];
|
||||
const failure = (code) => Object.assign(new Error(code), { code });
|
||||
const nonempty = (value) => typeof value === "string" && value.length > 0 && !/[\x00-\x20\x7f]/.test(value);
|
||||
function validateCredential(providerId, credential) {
|
||||
if (!credential || credential.type !== "oauth" || !nonempty(credential.access) || !nonempty(credential.refresh) || !Number.isFinite(credential.expires) || credential.expires < 0) throw failure("storage");
|
||||
if (providerId === "github-copilot") {
|
||||
if (credential.enterpriseUrl !== undefined && !safeDomain(credential.enterpriseUrl)) throw failure("storage");
|
||||
if (credential.availableModelIds !== undefined && (!Array.isArray(credential.availableModelIds) || !credential.availableModelIds.every(nonempty))) throw failure("storage");
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
function safeDomain(value) {
|
||||
if (typeof value !== "string" || !value || /[\s/:@?#\\]/.test(value)) return false;
|
||||
try { return new URL(`https://${value}`).hostname === value; } catch { return false; }
|
||||
}
|
||||
function safeHttps(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" && !url.username && !url.password;
|
||||
} catch { return false; }
|
||||
}
|
||||
function validateState(providerId, state) {
|
||||
if (!PROVIDERS.includes(providerId) || !state || state.version !== 1 || typeof state.allowBackgroundUse !== "boolean") throw failure("storage");
|
||||
const hasId = Object.hasOwn(state, "connectionId");
|
||||
if (hasId !== Object.hasOwn(state, "credential") || (hasId && !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(state.connectionId))) throw failure("storage");
|
||||
if (hasId) validateCredential(providerId, state.credential);
|
||||
return state;
|
||||
}
|
||||
/** Main-process-only, one independent owner-only file per provider. No recovery writes on corruption. */
|
||||
function fileStore(file, providerId, io = fs) {
|
||||
return {
|
||||
async read() {
|
||||
try {
|
||||
const stat = await io.lstat(file);
|
||||
if (!stat.isFile() || (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || (typeof process.getuid === "function" && stat.uid !== process.getuid())))) throw failure("storage");
|
||||
return validateState(providerId, JSON.parse(await io.readFile(file, "utf8")));
|
||||
}
|
||||
catch (error) { if (error.code === "ENOENT") return { version: 1, allowBackgroundUse: true }; throw failure("storage"); }
|
||||
},
|
||||
async write(state) {
|
||||
validateState(providerId, state);
|
||||
const temporary = `${file}.${randomUUID()}.tmp`;
|
||||
let handle;
|
||||
try {
|
||||
await io.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
handle = await io.open(temporary, "wx", 0o600);
|
||||
await handle.writeFile(`${JSON.stringify(state)}\n`);
|
||||
await handle.sync(); await handle.close(); handle = null;
|
||||
await io.rename(temporary, file);
|
||||
if (process.platform !== "win32") {
|
||||
const directory = await io.open(path.dirname(file), "r");
|
||||
try { await directory.sync(); } finally { await directory.close(); }
|
||||
}
|
||||
} catch { throw failure("storage"); }
|
||||
finally { await handle?.close().catch(() => {}); await io.unlink(temporary).catch(() => {}); }
|
||||
},
|
||||
};
|
||||
}
|
||||
module.exports = { PROVIDERS, failure, validateCredential, validateState, safeDomain, safeHttps, fileStore };
|
||||
@@ -0,0 +1,251 @@
|
||||
"use strict";
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const { createRequire, registerHooks } = require("node:module");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const { PROVIDERS, fileStore, validateCredential, safeHttps, failure } = require("./subscription-store.cjs");
|
||||
|
||||
const LABELS = { "github-copilot": "GitHub Copilot", xai: "Grok" };
|
||||
const APIS = new Set(["openai-responses", "openai-completions", "anthropic-messages"]);
|
||||
const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
||||
const MESSAGES = {
|
||||
storage: "The saved connection could not be read or written safely. Reopen the app or reconnect after resolving the credential-file problem.",
|
||||
login: "Sign-in did not complete. Try connecting again.",
|
||||
refresh: "The connection could not be refreshed. Retry or reconnect in Providers.",
|
||||
cancel: "Sign-in cancelled.",
|
||||
unavailable: "This subscription connection is unavailable. Reconnect or explicitly choose another connection.",
|
||||
background: "Background use is off for this connection. Automatic follow-up remains deferred in its queue.",
|
||||
stale: "The connection or model catalog changed. Select an available model and retry.",
|
||||
interaction: "This sign-in action is no longer available.",
|
||||
};
|
||||
function publicError(error) { return MESSAGES[error?.code] ?? MESSAGES.unavailable; }
|
||||
/** Resolve the SAME SDK installation as the standalone server, through public exports. */
|
||||
async function loadSubscriptionSDK(baseDirectory) {
|
||||
const resolve = createRequire(path.join(baseDirectory, "package.json"));
|
||||
const load = (id) => {
|
||||
// Pi exports are import-only. Resolve the public export with ESM
|
||||
// conditions, then remove this synchronous, exact-specifier hook
|
||||
// BEFORE evaluation; no private package path or global hook remains.
|
||||
const hook = registerHooks({ resolve(specifier, context, next) {
|
||||
return next(specifier, specifier === id ? { ...context, conditions: [...context.conditions, "import"] } : context);
|
||||
} });
|
||||
let file;
|
||||
try { file = resolve.resolve(id); } finally { hook.deregister(); }
|
||||
return import(pathToFileURL(file).href);
|
||||
};
|
||||
const [sdk, copilot, xai] = await Promise.all([load("@earendil-works/pi-ai"), load("@earendil-works/pi-ai/providers/github-copilot"), load("@earendil-works/pi-ai/providers/xai")]);
|
||||
return { ...sdk, ...copilot, ...xai };
|
||||
}
|
||||
function validEndpoint(providerId, auth, credential) {
|
||||
if (!auth || (!(typeof auth.apiKey === "string" && auth.apiKey.length > 0) && !Object.entries(auth.headers ?? {}).some(([key, value]) => ["authorization", "x-api-key", "api-key"].includes(key.toLowerCase()) && typeof value === "string" && value.trim().length > 0))) return false;
|
||||
const value = auth.baseUrl ?? (providerId === "xai" ? "https://api.x.ai/v1" : "https://api.individual.githubcopilot.com");
|
||||
if (!safeHttps(value)) return false;
|
||||
const url = new URL(value);
|
||||
if (providerId === "xai") return url.origin === "https://api.x.ai" && url.pathname.replace(/\/$/, "") === "/v1" && !url.search && !url.hash;
|
||||
const domain = credential?.enterpriseUrl;
|
||||
return !url.search && !url.hash && url.pathname === "/" && (!url.port || url.port === "443") && (url.hostname.endsWith(".githubcopilot.com") || (domain && (url.hostname === domain || url.hostname.endsWith(`.${domain}`))));
|
||||
}
|
||||
/** Main owns durable credentials and refresh. Everything returned by status/snapshot is explicitly projected. */
|
||||
function createSubscriptions(options) {
|
||||
let sdk;
|
||||
let closed = false, paused = false;
|
||||
const entries = new Map(PROVIDERS.map((providerId) => [providerId, {
|
||||
providerId, state: { version: 1, allowBackgroundUse: true }, disk: options.stores?.[providerId] ?? fileStore(path.join(options.directory, `${providerId}-credentials.json`), providerId),
|
||||
epoch: 0, authorityRevision: 0, revision: 0, tail: Promise.resolve(), fatal: false, blocked: false, ready: false, error: undefined, authFailed: false, attempt: null, catalog: [], endpoint: null,
|
||||
}]));
|
||||
const entryFor = (id) => { const entry = entries.get(id); if (!entry) throw failure("unavailable"); return entry; };
|
||||
function serialized(entry, fn) { const result = entry.tail.then(fn); entry.tail = result.catch(() => {}); return result; }
|
||||
function statusOf(entry) {
|
||||
const available = entry.ready && !closed && !paused && !entry.fatal;
|
||||
const attempt = entry.attempt;
|
||||
return {
|
||||
providerId: entry.providerId, available, connected: !!(available && entry.state.credential && !entry.blocked && !entry.authFailed), pending: !!attempt, committing: attempt?.committing === true,
|
||||
allowBackgroundUse: entry.state.allowBackgroundUse, connectionId: entry.state.connectionId ?? null,
|
||||
epoch: entry.epoch, authorityRevision: entry.authorityRevision, revision: entry.revision,
|
||||
...(entry.error ? { error: entry.error } : {}),
|
||||
...(attempt ? { interaction: clone(attempt.public) } : {}),
|
||||
};
|
||||
}
|
||||
function snapshotNow() {
|
||||
return { statuses: [...entries.values()].map(statusOf), models: Object.fromEntries([...entries.values()].map((entry) => [entry.providerId, statusOf(entry).connected ? clone(entry.catalog) : []])) };
|
||||
}
|
||||
function changed(entry) { entry.revision++; try { options.onChange?.(snapshotNow()); } catch { /* observer cannot break credential writes */ } }
|
||||
function catalog(entry) {
|
||||
let models = entry.provider?.getModels().filter((model) => APIS.has(model.api)) ?? [];
|
||||
if (!entry.state.credential) models = [];
|
||||
else if (entry.providerId === "github-copilot" && !Array.isArray(entry.state.credential.availableModelIds)) models = [];
|
||||
else if (entry.provider?.filterModels) models = [...entry.provider.filterModels(models, entry.state.credential)];
|
||||
if (JSON.stringify(models) !== JSON.stringify(entry.catalog)) { entry.catalog = models; entry.authorityRevision++; }
|
||||
}
|
||||
async function updateEndpoint(entry) {
|
||||
let endpoint = null;
|
||||
if (entry.state.credential) {
|
||||
const auth = await entry.provider.auth.oauth.toAuth(entry.state.credential);
|
||||
if (!validEndpoint(entry.providerId, auth, entry.state.credential)) throw failure("storage");
|
||||
endpoint = auth.baseUrl ?? (entry.providerId === "xai" ? "https://api.x.ai/v1" : "https://api.individual.githubcopilot.com");
|
||||
}
|
||||
if (entry.endpoint !== endpoint) { entry.endpoint = endpoint; entry.authorityRevision++; }
|
||||
}
|
||||
function storageFailed(entry) { entry.fatal = true; entry.blocked = true; entry.epoch++; entry.error = MESSAGES.storage; changed(entry); }
|
||||
async function save(entry, state) {
|
||||
try { await entry.disk.write(state); } catch { storageFailed(entry); throw failure("storage"); }
|
||||
entry.state = clone(state);
|
||||
try { await updateEndpoint(entry); catalog(entry); } catch { storageFailed(entry); throw failure("storage"); }
|
||||
changed(entry);
|
||||
}
|
||||
const ready = (async () => {
|
||||
try { sdk = options.sdk ?? await options.loadSDK(); }
|
||||
catch { for (const entry of entries.values()) { entry.error = MESSAGES.unavailable; entry.fatal = true; changed(entry); } return; }
|
||||
await Promise.all([...entries.values()].map(async (entry) => {
|
||||
try {
|
||||
entry.state = clone(await entry.disk.read());
|
||||
const native = entry.providerId === "github-copilot" ? sdk.githubCopilotProvider() : sdk.xaiProvider();
|
||||
// No API-key auth method exists in this collection: ambient keys cannot become a fallback.
|
||||
entry.provider = { ...native, auth: { oauth: native.auth.oauth } };
|
||||
const store = {
|
||||
read: async (id) => id === entry.providerId ? clone(entry.state.credential) : undefined,
|
||||
list: async () => entry.state.credential ? [{ providerId: entry.providerId, type: "oauth" }] : [],
|
||||
modify: (id, fn, operation) => serialized(entry, async () => {
|
||||
if (id !== entry.providerId || entry.fatal || closed || paused || operation?.signal?.aborted) throw failure("unavailable");
|
||||
const next = await fn(clone(entry.state.credential));
|
||||
// A returned rotation must be saved even if the app paused while
|
||||
// refresh was in flight. Admission is rechecked after resolution;
|
||||
// queued logout deletes only after this serialized durable write.
|
||||
if (next !== undefined && JSON.stringify(next) !== JSON.stringify(entry.state.credential)) {
|
||||
try { validateCredential(id, next); } catch { storageFailed(entry); throw failure("storage"); }
|
||||
await save(entry, { ...entry.state, credential: next });
|
||||
}
|
||||
return clone(entry.state.credential);
|
||||
}),
|
||||
delete: (id) => serialized(entry, async () => {
|
||||
if (id !== entry.providerId || entry.fatal) throw failure("storage");
|
||||
await save(entry, { version: 1, allowBackgroundUse: entry.state.allowBackgroundUse });
|
||||
}),
|
||||
};
|
||||
entry.models = sdk.createModels({ credentials: store }); entry.models.setProvider(entry.provider);
|
||||
await updateEndpoint(entry); entry.ready = true; catalog(entry); changed(entry);
|
||||
} catch { entry.fatal = true; entry.error = MESSAGES.storage; changed(entry); }
|
||||
}));
|
||||
})();
|
||||
function current(entry, scope) {
|
||||
return statusOf(entry).connected && scope?.providerId === entry.providerId && scope.connectionId === entry.state.connectionId && scope.epoch === entry.epoch && scope.authorityRevision === entry.authorityRevision && entry.catalog.some((m) => m.id === scope.selectedModelId);
|
||||
}
|
||||
async function consent(providerId, enabled) {
|
||||
await ready; const entry = entryFor(providerId);
|
||||
if (typeof enabled !== "boolean" || entry.fatal || closed || paused) throw failure("unavailable");
|
||||
await serialized(entry, async () => { if (entry.state.allowBackgroundUse !== enabled) await save(entry, { ...entry.state, allowBackgroundUse: enabled }); });
|
||||
}
|
||||
async function login(providerId, enabled) {
|
||||
await ready; const entry = entryFor(providerId);
|
||||
if ((enabled !== undefined && typeof enabled !== "boolean") || entry.fatal || closed || paused || !entry.ready) throw failure("unavailable");
|
||||
if ([...entries.values()].some((e) => e.attempt)) throw failure("interaction");
|
||||
const abort = new AbortController();
|
||||
const attempt = { id: randomUUID(), abort, committing: false, actions: new Map(), prompt: null, public: {} };
|
||||
attempt.public = { attemptId: attempt.id }; entry.attempt = attempt; entry.error = undefined; changed(entry);
|
||||
const active = () => entry.attempt === attempt && !abort.signal.aborted && !closed && !paused;
|
||||
const addBrowser = (value) => {
|
||||
if (!safeHttps(value)) throw failure("interaction");
|
||||
const id = randomUUID(); attempt.actions.set(id, value);
|
||||
const url = new URL(value); attempt.public.browser = { id, url: `${url.origin}${url.pathname}` };
|
||||
};
|
||||
try {
|
||||
const staging = sdk.createModels(); staging.setProvider(entry.provider);
|
||||
const credential = await staging.login(providerId, "oauth", {
|
||||
signal: abort.signal,
|
||||
notify: (event) => {
|
||||
if (!active()) return;
|
||||
if (event.type === "auth_url") addBrowser(event.url);
|
||||
if (event.type === "device_code") { addBrowser(event.verificationUri); attempt.public.userCode = event.userCode; }
|
||||
// Native provider diagnostics are never forwarded. Public prompt wording comes from SDK-owned prompts.
|
||||
attempt.public.progress = event.type === "device_code" || event.type === "auth_url" ? "Complete sign-in in your browser." : "Waiting for the provider…";
|
||||
changed(entry);
|
||||
},
|
||||
prompt: (prompt) => new Promise((resolve, reject) => {
|
||||
if (!active() || prompt.signal?.aborted) { reject(failure("cancel")); return; }
|
||||
const id = randomUUID();
|
||||
const stop = () => { cleanup(); reject(failure("cancel")); };
|
||||
const cleanup = () => { abort.signal.removeEventListener("abort", stop); prompt.signal?.removeEventListener("abort", stop); if (attempt.prompt?.id === id) { attempt.prompt = null; delete attempt.public.prompt; } };
|
||||
attempt.prompt = { id, resolve: (value) => { cleanup(); resolve(value); }, reject: stop };
|
||||
attempt.public.prompt = { id, type: prompt.type, message: prompt.message, ...(prompt.placeholder ? { placeholder: prompt.placeholder } : {}), ...(prompt.type === "select" ? { options: clone(prompt.options) } : {}) };
|
||||
abort.signal.addEventListener("abort", stop, { once: true }); prompt.signal?.addEventListener("abort", stop, { once: true }); changed(entry);
|
||||
}),
|
||||
}, { agentName: "LearnHarness" });
|
||||
if (!active()) throw failure("cancel"); validateCredential(providerId, credential);
|
||||
const auth = await entry.provider.auth.oauth.toAuth(credential);
|
||||
if (!validEndpoint(providerId, auth, credential) || !active()) throw failure("login");
|
||||
await serialized(entry, async () => {
|
||||
if (!active() || entry.fatal) throw failure("cancel");
|
||||
attempt.committing = true; entry.blocked = true; entry.epoch++; changed(entry);
|
||||
await save(entry, { version: 1, allowBackgroundUse: enabled ?? entry.state.allowBackgroundUse, connectionId: randomUUID(), credential });
|
||||
entry.blocked = false; entry.authFailed = false; entry.error = undefined;
|
||||
});
|
||||
} catch (error) {
|
||||
if (active() && !entry.fatal) entry.error = error?.code === "cancel" ? undefined : MESSAGES.login;
|
||||
if (!abort.signal.aborted) throw failure(entry.fatal ? "storage" : "login");
|
||||
} finally { attempt.prompt?.reject(); if (entry.attempt === attempt) { entry.attempt = null; changed(entry); } }
|
||||
}
|
||||
function cancel(providerId) { const entry = entryFor(providerId); if (entry.attempt && !entry.attempt.committing) { entry.attempt.abort.abort(); entry.attempt = null; if (entry.authFailed && !entry.error) entry.error = MESSAGES.refresh; changed(entry); } }
|
||||
async function resolveAuth(entry, modelId) {
|
||||
if (entry.fatal || closed || paused || !entry.state.credential) throw failure("unavailable");
|
||||
const connectionId = entry.state.connectionId, epoch = entry.epoch;
|
||||
const unchanged = () => connectionId === entry.state.connectionId && epoch === entry.epoch && !entry.blocked && !closed && !paused;
|
||||
try {
|
||||
const model = entry.provider.getModels().find((m) => m.id === modelId);
|
||||
if (!model) throw failure("stale");
|
||||
const resolved = await entry.models.getAuth(model);
|
||||
if (!unchanged()) throw failure("stale");
|
||||
if (!validEndpoint(entry.providerId, resolved?.auth, entry.state.credential)) throw failure("unavailable");
|
||||
return {
|
||||
...(typeof resolved.auth.apiKey === "string" ? { apiKey: resolved.auth.apiKey } : {}),
|
||||
...(resolved.auth.headers ? { headers: Object.fromEntries(Object.entries(resolved.auth.headers).filter(([, value]) => value === null || typeof value === "string")) } : {}),
|
||||
...(typeof resolved.auth.baseUrl === "string" ? { baseUrl: resolved.auth.baseUrl } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (!entry.fatal && unchanged()) { entry.authFailed = true; entry.error = MESSAGES.refresh; entry.epoch++; changed(entry); }
|
||||
throw failure(entry.fatal ? "storage" : error?.code === "stale" ? "stale" : "refresh");
|
||||
}
|
||||
}
|
||||
return {
|
||||
ready,
|
||||
async snapshot() { await ready; return snapshotNow(); },
|
||||
async status() { await ready; return snapshotNow().statuses; },
|
||||
login, consent, cancel,
|
||||
async respond(providerId, attemptId, promptId, value) {
|
||||
const entry = entryFor(providerId), attempt = entry.attempt;
|
||||
if (!attempt || attempt.id !== attemptId || attempt.prompt?.id !== promptId || typeof value !== "string" || value.length > 4096) throw failure("interaction");
|
||||
const prompt = attempt.public.prompt;
|
||||
if (prompt.type === "select" && !prompt.options.some((option) => option.id === value)) throw failure("interaction");
|
||||
attempt.prompt.resolve(value); changed(entry);
|
||||
},
|
||||
async open(providerId, attemptId, actionId) {
|
||||
const attempt = entryFor(providerId).attempt;
|
||||
const target = attempt?.id === attemptId ? attempt.actions.get(actionId) : undefined;
|
||||
if (!target || !safeHttps(target)) throw failure("interaction");
|
||||
try { await options.openExternal(target); } catch { throw failure("interaction"); }
|
||||
},
|
||||
async lease(scope, background) {
|
||||
await ready; const entry = entryFor(scope?.providerId);
|
||||
if (!current(entry, scope)) throw failure("stale");
|
||||
if (background && !entry.state.allowBackgroundUse) throw failure("background");
|
||||
const auth = await resolveAuth(entry, scope.selectedModelId);
|
||||
if (!current(entry, scope)) throw failure("stale");
|
||||
if (background && !entry.state.allowBackgroundUse) throw failure("background");
|
||||
return { scope: clone(scope), auth: clone(auth) };
|
||||
},
|
||||
async retry(providerId) {
|
||||
await ready; const entry = entryFor(providerId);
|
||||
const model = entry.catalog[0]; if (!model || entry.fatal || entry.blocked) throw failure("unavailable");
|
||||
await resolveAuth(entry, model.id); entry.authFailed = false; entry.error = undefined; changed(entry);
|
||||
},
|
||||
async disconnect(providerId) {
|
||||
await ready; const entry = entryFor(providerId); cancel(providerId);
|
||||
if (entry.fatal) throw failure("storage");
|
||||
entry.blocked = true; entry.epoch++; changed(entry);
|
||||
await entry.models.logout(providerId); entry.blocked = false; entry.authFailed = false; entry.error = undefined; changed(entry);
|
||||
},
|
||||
pause() { paused = true; for (const entry of entries.values()) { cancel(entry.providerId); entry.epoch++; changed(entry); } },
|
||||
resume() { if (closed) return; paused = false; for (const entry of entries.values()) changed(entry); },
|
||||
close() { closed = true; for (const entry of entries.values()) { cancel(entry.providerId); entry.epoch++; changed(entry); } },
|
||||
};
|
||||
}
|
||||
module.exports = { createSubscriptions, loadSubscriptionSDK, publicError, validEndpoint, LABELS };
|
||||
Reference in New Issue
Block a user