feat: enable subscription connections in web and dev

This commit is contained in:
amosblomqvist
2026-10-26 03:00:40 +02:00
parent fc3673466d
commit c0c670b810
29 changed files with 838 additions and 41 deletions
+1
View File
@@ -43,4 +43,5 @@ export * from "./compaction.ts";
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
export * from "./observability.ts";
export * from "./model-catalog.ts";
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
export { sessionCost } from "./pricing.ts";
+68
View File
@@ -0,0 +1,68 @@
import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts";
import { webSubscriptionOwner, webSubscriptionsEnabled, webSubscriptionStatus } from "./web-subscriptions.ts";
const HEADERS = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff" };
/** Same-origin browser controls, not user authentication. Web/dev deliberately
* retains the existing shared-server/API-key trust model. Never trust forwarded
* origins or add CORS grants here. A public deployment needs its own access gate. */
export function subscriptionRequestOrigin(request: Request): string | undefined {
try {
const url = new URL(request.url);
const host = request.headers.get("host") ?? url.host;
const addressed = new URL(`${url.protocol}//${host}`);
if (!["http:", "https:"].includes(addressed.protocol) || addressed.username || addressed.password || addressed.pathname !== "/" || addressed.search || addressed.hash) return;
return addressed.origin;
} catch { return; }
}
function authorized(request: Request): boolean {
const origin = subscriptionRequestOrigin(request);
if (!origin || request.headers.get("x-learnharness-subscriptions") !== "1") return false;
if (request.headers.get("sec-fetch-site") === "cross-site") return false;
const supplied = request.headers.get("origin");
return request.method === "GET" ? !supplied || supplied === origin : supplied === origin;
}
function error(message: string, status: number) { return Response.json({ error: message }, { status, headers: HEADERS }); }
export async function subscriptionControlsResponse(request: Request): Promise<Response> {
if (!webSubscriptionsEnabled()) return error("Web subscription controls are unavailable in desktop-owned mode.", 404);
if (!authorized(request)) return error("Subscription controls require a same-origin LearnHarness request.", 403);
try {
if (request.method === "GET") return Response.json(await webSubscriptionStatus(), { headers: HEADERS });
if (request.method !== "POST") return error("Unsupported method.", 405);
if (request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") return error("Expected JSON.", 415);
// Bound both advertised and streamed input before parsing commands.
if (Number(request.headers.get("content-length")) > 16384) return error("Request too large.", 413);
const reader = request.body?.getReader();
if (!reader) return error("Expected a command.", 400);
const chunks: Uint8Array[] = []; let size = 0;
try {
for (;;) {
const next = await reader.read(); if (next.done) break;
size += next.value.byteLength;
if (size > 16384) { await reader.cancel(); return error("Request too large.", 413); }
chunks.push(next.value);
}
} finally { reader.releaseLock(); }
const command: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (!command || typeof command !== "object" || Array.isArray(command)) return error("Invalid command.", 400);
const fields = command as Record<string, unknown>;
const providerId = fields.providerId;
if (providerId !== "chatgpt" && !NATIVE_SUBSCRIPTIONS.some((provider) => provider.providerId === providerId)) return error("Unsupported connection.", 400);
const allowed = providerId === "chatgpt" ? ["login", "cancel", "select", "disconnect", "logout", "consent"] : ["login", "cancel", "respond", "open", "retry", "disconnect", "consent"];
if (typeof fields.op !== "string" || !allowed.includes(fields.op)) return error("Unsupported control.", 400);
return Response.json(await webSubscriptionOwner().control(providerId as string, fields, subscriptionRequestOrigin(request)!), { headers: HEADERS });
} catch {
return error("The subscription connection could not be changed. Retry or reconnect in Providers.", 400);
}
}
/** One-use same-origin navigation. Full provider authorization URLs go only to
* a top-level browser redirect, never status JSON, the SSE bus or application JS. */
export async function subscriptionBrowserResponse(request: Request): Promise<Response> {
if (!webSubscriptionsEnabled()) return error("Unavailable.", 404);
const origin = subscriptionRequestOrigin(request);
if (!origin || (request.method === "POST" ? !authorized(request) : request.headers.get("sec-fetch-site") !== "same-origin")) return error("Open sign-in from LearnHarness.", 403);
const id = new URL(request.url).searchParams.get("id");
if (!id || !/^[0-9a-f-]{36}$/.test(id)) return error("This sign-in action is no longer available.", 410);
const target = await webSubscriptionOwner().takeBrowserAction(id, origin);
if (!target) return error("This sign-in action is no longer available.", 410);
return new Response(null, { status: 303, headers: { ...HEADERS, Location: target } });
}
+127
View File
@@ -0,0 +1,127 @@
/** Persistent Node web/dev owner. Never imported by client code. Desktop keeps
* its existing main-process owner; a broken desktop IPC channel cannot fall
* back to these files. One server process must own each configured auth dir. */
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { AUTH_FILE } from "../../config.ts";
import { createChatGPT, fileStore } from "../../electron/chatgpt.cjs";
import { createSubscriptions } from "../../electron/subscriptions.cjs";
import { chatGPTControl } from "../../electron/chatgpt-ipc.cjs";
import { subscriptionControl } from "../../electron/subscription-ipc.cjs";
import { emit } from "../core/events.ts";
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
import type { NativeSubscriptionSnapshot } from "../core/contracts/subscriptions.ts";
import { configureWebSubscriptionRuntime, publishWebSubscriptionPacket, webSubscriptionsEnabled, type SubscriptionRuntimeChannel as Channel, type SubscriptionRuntimePacket as Packet } from "../agent/subscription-runtime.ts";
export { webSubscriptionsEnabled } from "../agent/subscription-runtime.ts";
interface BrowserAction { target: string; origin: string; scope: string; expires: number; current(): Promise<boolean> }
const KEY = Symbol.for("lh.webSubscriptions");
interface Registry {
owner?: ReturnType<typeof createWebSubscriptionOwner>;
}
function registry(): Registry {
const global = globalThis as typeof globalThis & { [KEY]?: Registry };
return global[KEY] ??= {};
}
function publish(channel: Channel, packet: Packet) {
publishWebSubscriptionPacket(channel, packet);
emit("subscriptions-changed", {});
}
async function loadSDK() {
const [sdk, copilot, xai] = await Promise.all([
import("@earendil-works/pi-ai"),
import("@earendil-works/pi-ai/providers/github-copilot"),
import("@earendil-works/pi-ai/providers/xai"),
]);
return { ...sdk, ...copilot, ...xai };
}
/** Injectable factories/stores keep all test authentication offline. */
export function createWebSubscriptionOwner(options: {
directory: string;
chatgptStore?: Parameters<typeof createChatGPT>[0]["store"];
sdk?: unknown;
onPacket?: (channel: Channel, packet: Packet) => void;
} ) {
const notify = options.onPacket ?? (() => {});
const launches = new AsyncLocalStorage<string[]>();
const actions = new Map<string, BrowserAction>();
let chatGPTLaunch = 0;
const openExternal = async (target: string) => {
const captured = launches.getStore();
if (!captured || new URL(target).protocol !== "https:") throw new Error("No browser action is pending.");
captured.push(target);
};
const chatgpt = createChatGPT({
store: options.chatgptStore ?? fileStore(path.join(options.directory, "chatgpt-credentials.json")),
openExternal,
onChange: (packet?: Packet) => { if (packet) notify("lh:chatgpt", packet); },
});
const native = createSubscriptions({
directory: options.directory, sdk: options.sdk, loadSDK, openExternal,
onChange: (snapshot: NativeSubscriptionSnapshot) => notify("lh:subscriptions", { channel: "lh:subscriptions", op: "changed", snapshot }),
});
return {
chatgpt, native,
async control(providerId: string, command: Record<string, unknown>, origin: string) {
const targets: string[] = [];
const value = await launches.run(targets, () => providerId === "chatgpt"
? chatGPTControl(command, chatgpt)
: subscriptionControl({ ...command, providerId }, native));
let browserAction: string | undefined;
const target = targets[0];
if (target) {
const scope = providerId === "chatgpt" ? "chatgpt" : `${providerId}:${command.attemptId}:${command.actionId}`;
for (const [id, action] of actions) if (action.expires <= Date.now() || action.scope === scope) actions.delete(id);
const id = randomUUID();
const generation = providerId === "chatgpt" ? ++chatGPTLaunch : chatGPTLaunch;
const current = providerId === "chatgpt"
? async () => generation === chatGPTLaunch && (await chatgpt.status()).pending === true
: async () => (await native.status()).some((status: { providerId: string; interaction?: { attemptId: string; browser?: { id: string } } }) => status.providerId === providerId && status.interaction?.attemptId === command.attemptId && status.interaction?.browser?.id === command.actionId);
actions.set(id, { target, origin, scope, expires: Date.now() + 180000, current });
browserAction = `/api/subscriptions/browser?id=${id}`;
}
return { value, ...(browserAction ? { browserAction } : {}) };
},
async takeBrowserAction(id: string, origin: string): Promise<string | undefined> {
const action = actions.get(id);
if (!action || action.origin !== origin) return;
actions.delete(id);
if (action.expires <= Date.now() || !await action.current()) return;
return action.target;
},
close() { actions.clear(); chatgpt.close(); native.close(); },
};
}
export function webSubscriptionOwner() {
if (!webSubscriptionsEnabled()) throw new Error("Web subscription controls are unavailable in desktop-owned mode.");
const state = registry();
if (!state.owner) {
state.owner = createWebSubscriptionOwner({ directory: path.dirname(AUTH_FILE), onPacket: publish });
process.once("exit", () => state.owner?.close());
}
return state.owner;
}
/** Private runtime calls only: no HTTP lease/token operation exists. */
export async function webSubscriptionRequest<T>(channel: Channel, op: string, fields: Record<string, unknown>): Promise<T> {
const owner = webSubscriptionOwner();
let value: unknown;
if (channel === "lh:chatgpt") {
if (op === "status") value = { status: await owner.chatgpt.status(), ...owner.chatgpt.stamp() };
else if (op === "models") value = await owner.chatgpt.models();
else if (op === "lease" && typeof fields.background === "boolean") value = await owner.chatgpt.lease(fields.background, fields);
else throw new Error("Invalid private operation.");
} else {
if (op === "snapshot") value = await owner.native.snapshot();
else if (op === "lease" && typeof fields.background === "boolean") value = await owner.native.lease(fields.scope, fields.background);
else throw new Error("Invalid private operation.");
}
return value as T;
}
configureWebSubscriptionRuntime(webSubscriptionRequest);
export async function webSubscriptionStatus(): Promise<{ chatgpt: ChatGPTStatus; native: NativeSubscriptionSnapshot["statuses"] }> {
const owner = webSubscriptionOwner();
const [chatgpt, native] = await Promise.all([owner.chatgpt.status(), owner.native.status()]);
return { chatgpt, native };
}