Merge server-owned web subscription connections
This commit is contained in:
@@ -13,6 +13,9 @@ dist/
|
||||
|
||||
# dev auth/keys store (auth-keys wave): repo-root auth.json — secrets never committed
|
||||
auth.json
|
||||
chatgpt-credentials.json*
|
||||
github-copilot-credentials.json*
|
||||
xai-credentials.json*
|
||||
|
||||
# dev-fresh launcher scratch (fresh-user sim): disposable per launch
|
||||
.fresh-data/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { subscriptionBrowserResponse } from "@/src/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const GET = subscriptionBrowserResponse;
|
||||
export const POST = subscriptionBrowserResponse;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { subscriptionControlsResponse } from "@/src/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const GET = subscriptionControlsResponse;
|
||||
export const POST = subscriptionControlsResponse;
|
||||
@@ -116,6 +116,9 @@ export function wireSource(source: NotifySource, hub: ReturnType<typeof createNo
|
||||
|
||||
const EventsContext = createContext<ReturnType<typeof createNotifyHub> | null>(null);
|
||||
|
||||
/** Transport adapters may also be mounted in isolation with an injected bridge. */
|
||||
export function useNotifyHub() { return useContext(EventsContext); }
|
||||
|
||||
export function ServerEventsProvider({ children, connect }: { children: ReactNode; connect?: () => NotifySource }) {
|
||||
const hub = useMemo(() => createNotifyHub(), []);
|
||||
useEffect(() => wireSource((connect ?? openEventsChannel)(), hub), [hub, connect]);
|
||||
|
||||
@@ -5,8 +5,10 @@ import type { ChatGPTStatus } from "@/src/core";
|
||||
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Toggle } from "./controls";
|
||||
import { useChatGPTBridge } from "./subscription-client";
|
||||
|
||||
export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.";
|
||||
export function chatGPTNotice(web = false): string { return web ? `${CHATGPT_NOTICE.replace("on this device", "on the machine running LearnHarness")} Complete ChatGPT sign-in in a browser on that same machine.` : CHATGPT_NOTICE; }
|
||||
export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage.";
|
||||
const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts";
|
||||
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
|
||||
@@ -41,6 +43,8 @@ const Context = createContext({
|
||||
/** UI lifetime spans setup's keys → models transition. Only existing safe controls
|
||||
* are used; account IDs remain control values, never displayed account labels. */
|
||||
export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
const bridge = useChatGPTBridge();
|
||||
const web = typeof window !== "undefined" && bridge !== window.lh?.chatgpt;
|
||||
const [status, setStatus] = useState<ChatGPTStatus>(empty);
|
||||
const [dialog, setDialog] = useState<Dialog | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -79,14 +83,14 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) {
|
||||
operation.current = "change";
|
||||
const request = ++statusSequence.current;
|
||||
const result: unknown = await window.lh!.chatgpt!.consent(attempt.background);
|
||||
const result: unknown = await bridge!.consent(attempt.background);
|
||||
if (!owns(attempt)) return;
|
||||
if (!isStatus(result)) { setError(replyError(result)); finish(attempt); return; }
|
||||
replyFailure = result.error;
|
||||
let saved: ChatGPTStatus | null;
|
||||
if (request !== statusSequence.current) {
|
||||
const refresh = ++statusSequence.current;
|
||||
const value: unknown = await window.lh!.chatgpt!.status();
|
||||
const value: unknown = await bridge!.status();
|
||||
if (!owns(attempt)) return;
|
||||
saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current;
|
||||
} else saved = publishStatus(result);
|
||||
@@ -120,7 +124,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
const bridge = window.lh?.chatgpt;
|
||||
const refresh = async () => {
|
||||
const request = ++statusSequence.current;
|
||||
try {
|
||||
@@ -131,7 +134,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
void refresh();
|
||||
const off = bridge?.onChange(() => void refresh());
|
||||
return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); };
|
||||
}, []);
|
||||
}, [bridge]);
|
||||
|
||||
const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId) ?? dialog.account : undefined;
|
||||
const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id);
|
||||
@@ -161,13 +164,13 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
// Keep cancellation serialized even after login's pending IPC reply.
|
||||
operation.current = "cancel"; setBusy(true);
|
||||
const request = ++statusSequence.current;
|
||||
void window.lh?.chatgpt?.cancel().then(async (value: unknown) => {
|
||||
void bridge?.cancel().then(async (value: unknown) => {
|
||||
if (!mounted.current || generation.current !== intent) return;
|
||||
if (!isStatus(value)) { setError(replyError(value)); return; }
|
||||
let next: ChatGPTStatus | null;
|
||||
if (request !== statusSequence.current) {
|
||||
const refresh = ++statusSequence.current;
|
||||
const current: unknown = await window.lh!.chatgpt!.status();
|
||||
const current: unknown = await bridge!.status();
|
||||
if (!mounted.current || generation.current !== intent) return;
|
||||
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
|
||||
} else next = publishStatus(value);
|
||||
@@ -195,7 +198,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
let next: ChatGPTStatus | null;
|
||||
if (request !== statusSequence.current) {
|
||||
const refresh = ++statusSequence.current;
|
||||
const current: unknown = await window.lh!.chatgpt!.status();
|
||||
const current: unknown = await bridge!.status();
|
||||
if (!mounted.current || intent !== generation.current) return;
|
||||
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
|
||||
} else next = publishStatus(value);
|
||||
@@ -214,7 +217,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } }
|
||||
}
|
||||
async function login() {
|
||||
const bridge = window.lh?.chatgpt;
|
||||
const owner = dialogRef.current;
|
||||
if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return;
|
||||
const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, backgroundDirty, pending: false, phase: "starting" };
|
||||
@@ -241,7 +243,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
// No finally: pending OAuth outlives this IPC promise. Only its status
|
||||
// completion, failure, or explicit cancellation releases the attempt.
|
||||
}
|
||||
const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined;
|
||||
const context = {
|
||||
status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning,
|
||||
connect: openConnect,
|
||||
@@ -262,7 +263,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
</> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
|
||||
<p className="mt-3 text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</p>
|
||||
<p className="mt-3 text-xs text-foreground">{managed?.email || "ChatGPT account"}</p>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{CHATGPT_DISCONNECT_COPY.body}</p>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{web ? CHATGPT_DISCONNECT_COPY.body.replace("from this device", "from the machine running LearnHarness") : CHATGPT_DISCONNECT_COPY.body}</p>
|
||||
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>Cancel</Button>
|
||||
@@ -277,7 +278,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
</select>
|
||||
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>}
|
||||
</div>}
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{chatGPTNotice(web)}</p>
|
||||
</> : null}
|
||||
<div className={`mt-4 flex items-center gap-3 text-xs text-foreground ${busy && operation.current === "change" && status.available && !status.pending ? "[&>button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background">
|
||||
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { ChatGPTControlBridge, ChatGPTStatus, NativeSubscriptionControlBridge, NativeSubscriptionProviderId, NativeSubscriptionStatus } from "@/src/core";
|
||||
import { useNotifyHub, type createNotifyHub } from "@/components/server-events";
|
||||
|
||||
type Hub = ReturnType<typeof createNotifyHub> | null;
|
||||
type Reply<T> = { value: T; browserAction?: string; error?: string };
|
||||
const PATH = "/api/subscriptions";
|
||||
const headers = { "Content-Type": "application/json", "x-learnharness-subscriptions": "1" };
|
||||
|
||||
async function status() {
|
||||
const response = await fetch(PATH, { headers, cache: "no-store" });
|
||||
if (!response.ok) throw new Error("Subscription status is unavailable.");
|
||||
return response.json() as Promise<{ chatgpt: ChatGPTStatus; native: NativeSubscriptionStatus[] }>;
|
||||
}
|
||||
async function control<T>(providerId: string, op: string, fields: Record<string, unknown> = {}): Promise<Reply<T>> {
|
||||
const response = await fetch(PATH, { method: "POST", headers, body: JSON.stringify({ providerId, op, ...fields }) });
|
||||
if (!response.ok) throw new Error("The subscription connection could not be changed.");
|
||||
return response.json() as Promise<Reply<T>>;
|
||||
}
|
||||
function onChange(hub: Hub, listener: () => void): () => void {
|
||||
const offEvent = hub?.subscribe("subscriptions-changed", listener);
|
||||
const offResync = hub?.onResync(listener);
|
||||
return () => { offEvent?.(); offResync?.(); };
|
||||
}
|
||||
/** Reserve the new tab synchronously in the click gesture. Only a one-use
|
||||
* same-origin action URL enters app JS; provider URLs remain in the owner.
|
||||
* Electron dev has no tabs: its narrow main-process opener performs this step. */
|
||||
async function withBrowser<T>(action: () => Promise<Reply<T>>): Promise<T> {
|
||||
const external = window.lh?.subscriptionTransport === "web" ? window.lh.openSubscriptionBrowser : undefined;
|
||||
const tab = external ? null : window.open("about:blank", "_blank");
|
||||
if (!external && !tab) throw new Error("Allow a sign-in tab to open, then try again.");
|
||||
if (tab) tab.opener = null;
|
||||
try {
|
||||
const reply = await action();
|
||||
if (reply.browserAction) {
|
||||
if (!/^\/api\/subscriptions\/browser\?id=[0-9a-f-]{36}$/.test(reply.browserAction)) throw new Error("Invalid sign-in action.");
|
||||
if (external) await external(reply.browserAction);
|
||||
else if (tab && !tab.closed) tab.location.href = reply.browserAction;
|
||||
else throw new Error("The sign-in tab was closed. Cancel and try again.");
|
||||
} else tab?.close();
|
||||
return reply.value;
|
||||
} catch (error) { tab?.close(); throw error; }
|
||||
}
|
||||
export function createWebChatGPTBridge(hub: Hub): ChatGPTControlBridge {
|
||||
const call = async (op: string, fields?: Record<string, unknown>) => (await control<ChatGPTStatus>("chatgpt", op, fields)).value;
|
||||
return {
|
||||
status: async () => (await status()).chatgpt,
|
||||
login: (id) => withBrowser(() => control<ChatGPTStatus>("chatgpt", "login", { id })),
|
||||
cancel: () => call("cancel"), select: (id) => call("select", { id }),
|
||||
disconnect: (id) => call("disconnect", { id }), logout: (id) => call("logout", { id }),
|
||||
consent: (enabled) => call("consent", { enabled }),
|
||||
onChange: (listener) => onChange(hub, listener),
|
||||
};
|
||||
}
|
||||
export function createWebNativeBridge(hub: Hub): NativeSubscriptionControlBridge {
|
||||
const call = async (providerId: NativeSubscriptionProviderId, op: string, fields?: Record<string, unknown>) => { await control(providerId, op, fields); };
|
||||
return {
|
||||
status: async () => (await status()).native,
|
||||
login: (providerId, enabled) => call(providerId, "login", { enabled }),
|
||||
cancel: (providerId) => call(providerId, "cancel"),
|
||||
respond: (providerId, attemptId, promptId, value) => call(providerId, "respond", { attemptId, promptId, value }),
|
||||
open: async (providerId, attemptId, actionId) => { await withBrowser(() => control(providerId, "open", { attemptId, actionId })); },
|
||||
retry: (providerId) => call(providerId, "retry"), disconnect: (providerId) => call(providerId, "disconnect"),
|
||||
consent: (providerId, enabled) => call(providerId, "consent", { enabled }),
|
||||
onChange: (listener) => onChange(hub, listener),
|
||||
};
|
||||
}
|
||||
export function useChatGPTBridge(): ChatGPTControlBridge | undefined {
|
||||
const hub = useNotifyHub();
|
||||
return useMemo(() => typeof window === "undefined" ? undefined : window.lh?.subscriptionTransport !== "web" && window.lh?.chatgpt ? window.lh.chatgpt : createWebChatGPTBridge(hub), [hub]);
|
||||
}
|
||||
export function useNativeSubscriptionBridge(): NativeSubscriptionControlBridge | undefined {
|
||||
const hub = useNotifyHub();
|
||||
return useMemo(() => typeof window === "undefined" ? undefined : window.lh?.subscriptionTransport !== "web" && window.lh?.subscriptions ? window.lh.subscriptions : createWebNativeBridge(hub), [hub]);
|
||||
}
|
||||
@@ -6,16 +6,19 @@ import { notifySettingsSaved } from "@/components/settings-signal";
|
||||
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
|
||||
import { ChatGPTConnection } from "./chatgpt-connection";
|
||||
import { Toggle } from "./controls";
|
||||
import { useNativeSubscriptionBridge } from "./subscription-client";
|
||||
|
||||
export const COPILOT_POLICY_NOTICE = "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply.";
|
||||
export const SUBSCRIPTION_BACKGROUND_HINT = "Allow LearnHarness helper agents and automatic follow-ups to use this connection.";
|
||||
export function subscriptionNotice(label: string): string { return `Your prompts, tool results, and relevant session context are sent to ${label} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.`; }
|
||||
export function subscriptionDisconnectCopy(label: string): string { return `Disconnect ${label} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.`; }
|
||||
export function subscriptionNotice(label: string, web = false): string { return `Your prompts, tool results, and relevant session context are sent to ${label} to run the work you select. Credentials are stored ${web ? "on the machine running LearnHarness" : "locally"} in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.`; }
|
||||
export function subscriptionDisconnectCopy(label: string, web = false): string { return `Disconnect ${label} from LearnHarness? This stops work using this connection and removes its saved credentials from ${web ? "the machine running LearnHarness" : "this device"}. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.`; }
|
||||
export function emptySubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus { return { providerId, available: false, connected: false, pending: false, allowBackgroundUse: true, connectionId: null, epoch: 0, authorityRevision: 0, revision: 0 }; }
|
||||
const Context = createContext({ statuses: [] as NativeSubscriptionStatus[], busy: false, open: (_providerId: NativeSubscriptionProviderId) => {} });
|
||||
const CONTROL_ERROR = "The connection could not be changed. Try again.";
|
||||
|
||||
export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) {
|
||||
const bridge = useNativeSubscriptionBridge();
|
||||
const web = typeof window !== "undefined" && bridge !== window.lh?.subscriptions;
|
||||
const [statuses, setStatuses] = useState<NativeSubscriptionStatus[]>([]);
|
||||
const [dialog, setDialog] = useState<NativeSubscriptionProviderId | null>(null);
|
||||
const [view, setView] = useState<"notice" | "connection" | "manage">("notice");
|
||||
@@ -30,7 +33,7 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
|
||||
async function refresh() {
|
||||
const request = ++sequence.current;
|
||||
try {
|
||||
const values = await window.lh?.subscriptions?.status();
|
||||
const values = await bridge?.status();
|
||||
if (!mounted.current || request !== sequence.current || !Array.isArray(values)) return;
|
||||
const accepted = NATIVE_SUBSCRIPTIONS.map(({ providerId }) => {
|
||||
const next = values.find((s) => s.providerId === providerId), old = statusesRef.current.find((s) => s.providerId === providerId);
|
||||
@@ -41,10 +44,10 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
|
||||
}
|
||||
useEffect(() => {
|
||||
mounted.current = true; void refresh();
|
||||
const off = window.lh?.subscriptions?.onChange(() => { void refresh(); notifySettingsSaved("keys"); });
|
||||
const off = bridge?.onChange(() => { void refresh(); notifySettingsSaved("keys"); });
|
||||
return () => { mounted.current = false; generation.current++; off?.(); };
|
||||
// The root-lifetime subscriber reads current state through refs.
|
||||
}, []);
|
||||
}, [bridge]);
|
||||
useEffect(() => { setPromptValue(""); }, [prompt?.id]);
|
||||
function open(providerId: NativeSubscriptionProviderId) {
|
||||
if (operation || statusesRef.current.some((s) => s.pending)) return;
|
||||
@@ -62,7 +65,6 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
|
||||
function close() {
|
||||
if (!dialog || busy) return;
|
||||
if (confirm) { setConfirm(false); setError(null); return; }
|
||||
const bridge = window.lh?.subscriptions;
|
||||
if ((status?.pending || operation === "login") && bridge) { void control(() => bridge.cancel(dialog), true); return; }
|
||||
generation.current++; setDialog(null); setError(null);
|
||||
}
|
||||
@@ -71,23 +73,21 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
|
||||
generation.current++; setError(null); setConfirm(false); setBackground(status.allowBackgroundUse); setBackgroundDirty(false); setView("notice");
|
||||
}
|
||||
async function login() {
|
||||
const bridge = window.lh?.subscriptions;
|
||||
if (!dialog || !bridge || operation || !status?.available) return;
|
||||
const owner = ++generation.current; setOperation("login"); setError(null);
|
||||
try { await bridge.login(dialog, backgroundDirty ? background : undefined); await refresh(); if (mounted.current && generation.current === owner) setDialog(null); }
|
||||
catch { if (mounted.current && generation.current === owner) setError("Sign-in did not complete. Try connecting again."); }
|
||||
finally { if (mounted.current && generation.current === owner) setOperation(null); }
|
||||
}
|
||||
const bridge = typeof window === "undefined" ? undefined : window.lh?.subscriptions;
|
||||
return <Context.Provider value={{ statuses, busy: !!operation, open }}>
|
||||
{children}
|
||||
{dialog && descriptor && status && <ConnectionDialog key={`${dialog}:${view}:${confirm}`} label={`${descriptor.label} connection`} testid="subscription-connection-dialog" backdropTestid="subscription-dialog-backdrop" busy={busy} onClose={close}>
|
||||
<h2 className="text-sm font-semibold text-foreground">{descriptor.label}</h2>
|
||||
{confirm ? <>
|
||||
<p className="mt-3 text-sm font-semibold text-red-400">Disconnect {descriptor.label}?</p>
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label)}</p>
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label, web)}</p>
|
||||
</> : view === "notice" ? <>
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label)}</p>
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label, web)}</p>
|
||||
{dialog === "github-copilot" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="copilot-policy-notice">{COPILOT_POLICY_NOTICE}</p>}
|
||||
</> : <>
|
||||
<div className="mt-4 flex items-center gap-3 text-xs text-foreground">
|
||||
|
||||
@@ -61,6 +61,9 @@ declare global {
|
||||
lh?: {
|
||||
/** Read-only host OS; determines the native-control gutter. */
|
||||
platform?: string;
|
||||
/** External Next desktop-dev uses the server's web connections. */
|
||||
subscriptionTransport?: "desktop" | "web";
|
||||
openSubscriptionBrowser?: (action: string) => Promise<void>;
|
||||
chatgpt?: ChatGPTControlBridge;
|
||||
subscriptions?: NativeSubscriptionControlBridge;
|
||||
expandWindow: () => void;
|
||||
|
||||
@@ -431,9 +431,11 @@ The Electron main is a launcher + credential/security guard, never a host for te
|
||||
|
||||
### 8.1 Subscription connection boundary
|
||||
|
||||
`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved.
|
||||
`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. In packaged desktop mode Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved.
|
||||
|
||||
The renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them.
|
||||
The packaged desktop renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them.
|
||||
|
||||
For ordinary Next web/dev, `src/server/web-subscriptions.ts` composes these same plain-Node controllers into one lazy process-global credential owner beside configured `AUTH_FILE`. The server injects the private transport through `src/agent/subscription-runtime.ts`; the agent layer never imports the server layer. Existing scoped leases, authority revisions and foreground/background abort listeners work through that transport, without exposing an HTTP token/lease operation. `src/server/web-subscription-http.ts` exposes only same-origin status/control operations. Generic `subscriptions-changed` notifications use the existing SSE notify→fetch channel; opaque one-use browser actions redirect to provider pages without putting authorization URLs in app JSON. Browser adapters preserve the UI bridge contracts; Electron dev uses the web owner with a narrow system-browser opener. Desktop markers fail closed rather than falling back to web credentials. This is the existing shared developer-server trust model, not user authentication or DNS-rebinding protection; LAN behavior is unchanged. Only one server process may own an auth directory. See [development](development.md#subscription-connections-in-webdev) for storage, browser locality and access limitations.
|
||||
|
||||
`subscription-request-cycle.ts` is the shared admission/automatic-input recovery state machine; the old ChatGPT module is its compatibility export. Background consent is independent per provider, default ON, with foreground-preserving cancellation and queue ownership retained on no-dispatch. Native usage rejected before dispatch is transiently excluded from helper usage collectors, without adding persisted provenance fields. Catalogs and read-time pricing preserve the selected connection: subscription use displays compact provider labels, API dollars remain dollars, and mixed usage joins both without estimating subscription dollars or claiming detected tiers/allowances.
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ npm start # serve the production build
|
||||
npm test # the full suite (node:test via tsx; deterministic, no LLM, no network)
|
||||
npm run lint # eslint — includes the import law (architecture §2)
|
||||
npm run typecheck # tsc --noEmit
|
||||
node scripts/smoke-web-subscriptions.mjs # after build: offline Next start/dev + reload gate
|
||||
|
||||
npm run desktop:dev # next dev + Electron shell pointed at it
|
||||
npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY keychain)
|
||||
@@ -47,6 +48,16 @@ Secrets and machine-specific paths only. Model choice and thinking budgets are t
|
||||
|
||||
The packaged desktop app takes keys from `userData/auth.json` (BYOK, mode 0600, read/written directly by the server via `LH_AUTH_FILE`). PDFs render natively with pdfjs/Skia; SVGs use resvg.
|
||||
|
||||
### Subscription connections in web/dev
|
||||
|
||||
Ordinary `npm run dev` and `npm start` support the existing **ChatGPT, GitHub Copilot and Grok** connections in Settings → Providers. No separate launcher or change to Next's listening/LAN behavior is required. `npm run desktop:dev` uses these same dev-server connections; the packaged desktop app keeps its separate main-process credentials and private IPC transport.
|
||||
|
||||
Web/dev saves `chatgpt-credentials.json`, `github-copilot-credentials.json` and `xai-credentials.json` **beside the configured API-key `auth.json`**: the project directory by default, or the directory containing `LH_AUTH_FILE`. These are owner-only plaintext files, not files under `data/`, desktop Application Support, or a global Pi profile. Git and standalone-build exclusions cover both the files and atomic-write temporary files. Run only one server process per credential directory: refresh serialization is process-local, not a cross-process lock.
|
||||
|
||||
Sign-in opens a new tab in the browser that initiated it; permit that tab if your browser blocks popups. **ChatGPT sign-in must be completed in a browser on the server's machine**, because its callback listener remains `127.0.0.1` on that machine. Copilot/Grok use device codes. In the Electron dev shell, provider pages open in the system browser instead. Once connected, credentials and refresh remain server-owned; other browsers using that server share its connections and background-use consent, just as they share its API keys. Disconnect, storage-failure handling, and provider-specific background consent retain the existing behavior; subscription routes never fall back to API-key billing.
|
||||
|
||||
This remains a **trusted developer/shared-server mode**, not multi-user hosting. Subscription controls require same-origin JSON/custom-header requests; browser-opening actions are short-lived, origin-bound and one-use. These checks prevent ordinary cross-origin browser controls, **not user authentication or DNS-rebinding protection**. Do not expose an unprotected dev/server instance to untrusted users or the public Internet; a public deployment needs an independent access/host gate. Remote ChatGPT callback routing, per-user accounts and credential import are not implemented.
|
||||
|
||||
## Architecture
|
||||
|
||||
Five layers, dependency arrows pointing inward only, machine-enforced as four ESLint zones (shell → barrels only; core and agent never import below themselves). Two composition roots, one writer per store. The full spec — layers, systems, runtime flows, invariants, seams: [`docs/architecture.md`](docs/architecture.md).
|
||||
|
||||
@@ -30,6 +30,7 @@ const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt
|
||||
const { createSubscriptions, loadSubscriptionSDK } = require("./subscriptions.cjs");
|
||||
const { attachSubscriptionChild, subscriptionControl } = require("./subscription-ipc.cjs");
|
||||
const { desktopAPIHeaders } = require("./desktop-api.cjs");
|
||||
const { openWebSubscriptionBrowser } = require("./web-subscription-browser.cjs");
|
||||
const { randomBytes } = require("node:crypto");
|
||||
const { spawn } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
@@ -315,6 +316,7 @@ function createWindow(url) {
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
...(process.env.LH_DEV_URL ? { additionalArguments: ["--lh-web-subscriptions"] } : {}),
|
||||
preload: path.join(__dirname, "preload.cjs"),
|
||||
},
|
||||
// Live-resize + first-paint flash (owner, 2026-09-17): during a resize
|
||||
@@ -700,6 +702,13 @@ async function main() {
|
||||
return subscriptionControl(command, subscriptions);
|
||||
});
|
||||
|
||||
// The dev shell has no browser tabs. Only a one-use action on its own
|
||||
// Next server may be resolved here; never accept an arbitrary external URL.
|
||||
ipcMain.handle("lh:subscriptions:web-browser", async (event, action) => {
|
||||
if (!devUrl || !trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted development request.");
|
||||
await openWebSubscriptionBrowser(action, new URL(url).origin, { openExternal: (target) => shell.openExternal(target) });
|
||||
});
|
||||
|
||||
installMenu();
|
||||
|
||||
// Auto-update (Wave C): wire the updater before the window so the
|
||||
|
||||
@@ -40,6 +40,10 @@ ipcRenderer.on("lh:open-settings", (_event, section) => {
|
||||
|
||||
contextBridge.exposeInMainWorld("lh", {
|
||||
platform: process.platform,
|
||||
...(process.argv?.includes("--lh-web-subscriptions") ? {
|
||||
subscriptionTransport: "web",
|
||||
openSubscriptionBrowser: (action) => ipcRenderer.invoke("lh:subscriptions:web-browser", action),
|
||||
} : {}),
|
||||
chatgpt: {
|
||||
status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }),
|
||||
login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
/** Desktop-dev has no tabs. Resolve only an opaque action on its own Next
|
||||
* server, then open the owner-validated provider page outside the renderer.
|
||||
* The caller must authenticate its trusted app main frame first. */
|
||||
async function openWebSubscriptionBrowser(action, origin, { fetch: request = globalThis.fetch, openExternal }) {
|
||||
if (typeof action !== "string" || !/^\/api\/subscriptions\/browser\?id=[0-9a-f-]{36}$/.test(action)) throw new Error("Invalid sign-in action.");
|
||||
const response = await request(new URL(action, origin), {
|
||||
method: "POST", redirect: "manual", credentials: "omit",
|
||||
headers: { origin, "x-learnharness-subscriptions": "1" },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const target = response.headers.get("location");
|
||||
if (response.status !== 303 || !target) throw new Error("Sign-in action expired.");
|
||||
const parsed = new URL(target);
|
||||
if (parsed.protocol !== "https:" || parsed.username || parsed.password) throw new Error("Invalid provider page.");
|
||||
await openExternal(target);
|
||||
}
|
||||
module.exports = { openWebSubscriptionBrowser };
|
||||
@@ -48,6 +48,9 @@ const nextConfig: NextConfig = {
|
||||
"*": [
|
||||
"**/data/**/*",
|
||||
"auth.json",
|
||||
"chatgpt-credentials.json*",
|
||||
"github-copilot-credentials.json*",
|
||||
"xai-credentials.json*",
|
||||
".env*",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -52,7 +52,8 @@ if (exists(leakedData)) {
|
||||
// (the DEV BYOK key store, 0600) byte-identical into the DMG (caught in
|
||||
// the 2026-10-08 payload audit; excluded in next.config.ts — this guard
|
||||
// makes a regression loud instead of silent).
|
||||
for (const secret of ["auth.json", ".env", ".env.local"]) {
|
||||
const subscriptionSecrets = fs.readdirSync(STANDALONE).filter((name) => /^(chatgpt|github-copilot|xai)-credentials\.json/.test(name));
|
||||
for (const secret of ["auth.json", ...subscriptionSecrets, ".env", ".env.local"]) {
|
||||
if (exists(path.join(STANDALONE, secret))) {
|
||||
console.error(`[build-server] REFUSING: standalone contains ${secret} — dev secrets would ship in the app.`);
|
||||
console.error(" Check outputFileTracingExcludes in next.config.ts.");
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/** Offline Next runtime gate (run after next build, with no dev server running).
|
||||
* Proves cold-start registration, cross-route owner/events and dev reloads using
|
||||
* synthetic credentials in a fresh profile. No OAuth, provider traffic or real
|
||||
* auth/data. The dev reload changes only a source mtime, restoring it afterward.
|
||||
* This is a test, not an alternative app launcher. */
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, "..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const { SCOPES } = require("../electron/chatgpt.cjs");
|
||||
const sdk = (file) => import(pathToFileURL(path.join(ROOT, "node_modules/@earendil-works/pi-ai/dist", file)));
|
||||
const { createModels } = await sdk("index.js");
|
||||
const { builtinProviders } = await sdk("providers/all.js");
|
||||
const { githubCopilotProvider } = await sdk("providers/github-copilot.js");
|
||||
const models = createModels();
|
||||
for (const provider of builtinProviders()) models.setProvider(provider);
|
||||
const model = models.getModels("openai").find((entry) => entry.api === "openai-responses");
|
||||
const copilot = githubCopilotProvider().getModels()[0];
|
||||
assert(model && copilot, "Installed SDK catalogs required.");
|
||||
|
||||
const profile = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-runtime-"));
|
||||
await fs.chmod(profile, 0o700);
|
||||
for (const name of ["home", "config", "cache", "xdg-data", "data", "pi", "claude", "codex", "tmp"]) await fs.mkdir(path.join(profile, name), { mode: 0o700 });
|
||||
await fs.writeFile(path.join(profile, "auth.json"), "{}\n", { mode: 0o600 });
|
||||
const preload = path.join(profile, "offline-fetch.mjs");
|
||||
await fs.writeFile(preload, `const original = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
|
||||
if (url.href === 'https://api.openai.com/v1/models') return Response.json({ models: [{ slug: ${JSON.stringify(model.id)}, display_name: 'Offline fixture ChatGPT', visibility: 'list' }] });
|
||||
if (['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) return original(input, init);
|
||||
throw new Error('Offline subscription gate forbids external network: ' + url.origin + url.pathname);
|
||||
};\n`, { mode: 0o600 });
|
||||
// Intentionally an allowlist, not a copy of the calling shell's environment.
|
||||
const env = {
|
||||
PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: path.join(profile, "home"),
|
||||
XDG_CONFIG_HOME: path.join(profile, "config"), XDG_CACHE_HOME: path.join(profile, "cache"), XDG_DATA_HOME: path.join(profile, "xdg-data"),
|
||||
PI_CODING_AGENT_DIR: path.join(profile, "pi"), PI_AGENT_DIR: path.join(profile, "pi"), CLAUDE_CONFIG_DIR: path.join(profile, "claude"), CODEX_HOME: path.join(profile, "codex"),
|
||||
TMPDIR: path.join(profile, "tmp"), LH_AUTH_FILE: path.join(profile, "auth.json"), LH_DATA_DIR: path.join(profile, "data"),
|
||||
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`, NEXT_TELEMETRY_DISABLED: "1",
|
||||
};
|
||||
const saved = {
|
||||
version: 1, hostId: "offline-host", allowBackgroundUse: true, activeId: "offline-account",
|
||||
registrations: [{ id: "offline-account", clientId: "oaiapp_fixture", issuer: "https://auth.openai.com", subject: "offline-subject", email: "fixture@example.invalid", tokens: { accessToken: "fixture-chatgpt-access", refreshToken: "fixture-chatgpt-refresh", idToken: "fixture-id-token", scopes: SCOPES, expiresAt: Date.now() + 3600000 } }],
|
||||
};
|
||||
function timeout(promise, milliseconds, message) {
|
||||
let timer;
|
||||
return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), milliseconds); })]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
async function freePort() {
|
||||
const socket = createServer();
|
||||
await new Promise((resolve, reject) => { socket.once("error", reject); socket.listen(0, "127.0.0.1", resolve); });
|
||||
const { port } = socket.address(); await new Promise((resolve) => socket.close(resolve)); return port;
|
||||
}
|
||||
async function run(mode) {
|
||||
await fs.writeFile(path.join(profile, "chatgpt-credentials.json"), JSON.stringify(saved), { mode: 0o600 });
|
||||
for (const id of ["github-copilot", "xai"]) await fs.writeFile(path.join(profile, `${id}-credentials.json`), JSON.stringify({
|
||||
version: 1, allowBackgroundUse: true, connectionId: "00000000-0000-4000-8000-000000000001",
|
||||
credential: { type: "oauth", access: id === "xai" ? "fixture-xai-access" : "tid=fixture;proxy-ep=proxy.individual.githubcopilot.com;", refresh: "fixture-refresh", expires: Date.now() + 3600000, ...(id === "github-copilot" ? { availableModelIds: [copilot.id] } : {}) },
|
||||
}), { mode: 0o600 });
|
||||
const port = await freePort(), origin = `http://localhost:${port}`;
|
||||
let logs = "", exited = false;
|
||||
const child = spawn(process.execPath, [path.join(ROOT, "node_modules/next/dist/bin/next"), mode, "-p", String(port)], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
const exit = new Promise((resolve) => child.once("exit", () => { exited = true; resolve(); }));
|
||||
try {
|
||||
await timeout(new Promise((resolve, reject) => {
|
||||
const receive = (data) => { logs = (logs + data).slice(-16000); if (logs.includes("Ready in")) resolve(); };
|
||||
child.stdout.on("data", receive); child.stderr.on("data", receive);
|
||||
child.once("error", reject); child.once("exit", (code) => reject(new Error(`Next ${mode} exited ${code}.`)));
|
||||
}), 60000, `Next ${mode} readiness timeout.`);
|
||||
const request = (url, command) => fetch(origin + url, {
|
||||
...(command ? { method: "POST", body: JSON.stringify(command) } : {}),
|
||||
headers: { origin, "sec-fetch-site": "same-origin", "x-learnharness-subscriptions": "1", "content-type": "application/json" },
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
// Controls have not been imported through an HTTP request yet.
|
||||
const catalog = (await (await request("/api/models")).json()).providers;
|
||||
for (const id of ["chatgpt", "github-copilot-subscription", "xai-subscription"]) assert(catalog.find((provider) => provider.id === id)?.keyPresent, `${mode}: cold ${id} unavailable.`);
|
||||
let state = await (await request("/api/subscriptions")).json();
|
||||
assert.equal(state.chatgpt.accounts[0].email, "fixture@example.invalid"); assert.equal(state.native.length, 2);
|
||||
for (const secret of ["fixture-chatgpt-access", "fixture-chatgpt-refresh", "fixture-id-token", "fixture-xai-access", "fixture-refresh"]) assert(!JSON.stringify(state).includes(secret));
|
||||
const stream = await fetch(origin + "/api/events", { signal: AbortSignal.timeout(60000) });
|
||||
const reader = stream.body.getReader();
|
||||
try {
|
||||
const first = await reader.read(); assert(first.value);
|
||||
assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "consent", enabled: false })).status, 200);
|
||||
let events = new TextDecoder().decode(first.value);
|
||||
while (!events.includes("subscriptions-changed")) {
|
||||
const frame = await timeout(reader.read(), 5000, "Cross-route subscription SSE timeout.");
|
||||
assert(!frame.done, "Subscription event stream ended early."); events += new TextDecoder().decode(frame.value);
|
||||
}
|
||||
} finally { await reader.cancel(); }
|
||||
state = await (await request("/api/subscriptions")).json(); assert.equal(state.chatgpt.allowBackgroundUse, false);
|
||||
const login = await (await request("/api/subscriptions", { providerId: "chatgpt", op: "login" })).json();
|
||||
assert(login.browserAction); assert(!JSON.stringify(login).includes("auth.openai.com"));
|
||||
if (mode === "dev") {
|
||||
const file = path.join(ROOT, "src/server/web-subscriptions.ts"), times = await fs.stat(file);
|
||||
await fs.utimes(file, new Date(), new Date());
|
||||
try { assert.equal((await request("/api/models")).status, 200); assert.equal((await request("/api/subscriptions")).status, 200); }
|
||||
finally { await fs.utimes(file, times.atime, times.mtime); }
|
||||
}
|
||||
const navigate = () => fetch(origin + login.browserAction, { redirect: "manual", headers: { "sec-fetch-site": "same-origin" }, signal: AbortSignal.timeout(30000) });
|
||||
const browser = await navigate(); assert.equal(browser.status, 303, `${mode}: owner/action lost across routes or reload.`);
|
||||
const location = new URL(browser.headers.get("location")); assert.equal(location.origin, "https://auth.openai.com"); assert.equal(new URL(location.searchParams.get("redirect_uri")).hostname, "127.0.0.1");
|
||||
assert.equal((await navigate()).status, 410);
|
||||
assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "cancel" })).status, 200);
|
||||
assert.equal((await fetch(origin + "/api/subscriptions", { method: "POST", headers: { origin: "https://unrelated.invalid", "content-type": "application/json", "x-learnharness-subscriptions": "1" }, body: JSON.stringify({ providerId: "chatgpt", op: "disconnect", id: "offline-account" }) })).status, 403);
|
||||
assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "disconnect", id: "offline-account" })).status, 200);
|
||||
assert.equal((await (await request("/api/models")).json()).providers.find((provider) => provider.id === "chatgpt").keyPresent, false);
|
||||
console.log(`[smoke-web-subscriptions] ${mode}: cold catalogs, safe status, cross-route SSE/consent/disconnect, opaque redirect/replay, CSRF${mode === "dev" ? ", owner retained across source reload" : ""} passed`);
|
||||
} catch (error) { console.error(logs); throw error; }
|
||||
finally {
|
||||
if (!exited) child.kill("SIGTERM");
|
||||
try { await timeout(exit, 5000, "Next shutdown timeout."); }
|
||||
catch { child.kill("SIGKILL"); await exit; }
|
||||
}
|
||||
}
|
||||
try { await run("start"); await run("dev"); }
|
||||
finally { await fs.rm(profile, { recursive: true, force: true }); }
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Model } from "@earendil-works/pi-ai";
|
||||
import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
|
||||
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
|
||||
export type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||
@@ -20,14 +21,15 @@ const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision]
|
||||
function desktopIPCConnected(): boolean {
|
||||
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
|
||||
}
|
||||
function runtimeConnected(): boolean { return desktopIPCConnected() || webSubscriptionsEnabled(); }
|
||||
function bridge(): BridgeState {
|
||||
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
|
||||
if (!global[key]) {
|
||||
const state: BridgeState = { status: unavailable(), stamp: { epoch: 0, authorityRevision: 0, revision: 0 }, models: [], catalogAccount: null, pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0, catalogRequest: 0 };
|
||||
global[key] = state;
|
||||
process.on("message", (raw: unknown) => {
|
||||
const receive = (raw: unknown) => {
|
||||
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
|
||||
if (!desktopIPCConnected() || m?.channel !== "lh:chatgpt") return;
|
||||
if (m?.channel !== "lh:chatgpt") return;
|
||||
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
|
||||
acceptStatus(state, { ...m.stamp, status: m.status });
|
||||
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
|
||||
@@ -43,8 +45,11 @@ function bridge(): BridgeState {
|
||||
if (m.ok === true) call.resolve(m.value);
|
||||
else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
|
||||
}
|
||||
});
|
||||
};
|
||||
process.on("message", (raw: unknown) => { if (desktopIPCConnected()) receive(raw); });
|
||||
onWebSubscriptionPacket("lh:chatgpt", (packet) => { if (webSubscriptionsEnabled()) receive(packet); });
|
||||
process.on("disconnect", () => {
|
||||
if (webSubscriptionsEnabled()) return;
|
||||
state.status = unavailable(); clearCatalog(state); state.catalogRequest++;
|
||||
for (const controller of state.controllers.keys()) controller.abort("all");
|
||||
for (const call of state.pending.values()) { clearTimeout(call.timer); call.reject(new Error("The desktop connection closed.")); }
|
||||
@@ -60,9 +65,10 @@ function acceptStatus(state: BridgeState, value: StatusValue) {
|
||||
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.status.activeId !== state.status.activeId) clearCatalog(state);
|
||||
state.status = value.status; state.stamp = { epoch: value.epoch, authorityRevision: value.authorityRevision, revision: value.revision };
|
||||
}
|
||||
/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
|
||||
/** Runtime-only: inherited desktop IPC or direct web owner. Never HTTP leases. */
|
||||
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
||||
const state = bridge();
|
||||
if (webSubscriptionsEnabled()) return webSubscriptionRequest<T>("lh:chatgpt", op, fields);
|
||||
if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const id = `${process.pid}:${++state.counter}`;
|
||||
@@ -74,7 +80,7 @@ export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Rec
|
||||
});
|
||||
}
|
||||
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
|
||||
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return desktopIPCConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
|
||||
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return runtimeConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
|
||||
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; }
|
||||
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
|
||||
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
|
||||
@@ -86,7 +92,7 @@ export function chatGPTScope(selectedModelId: string): AdmissionScope {
|
||||
}
|
||||
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
|
||||
const s = bridge();
|
||||
return desktopIPCConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
|
||||
return runtimeConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
|
||||
}
|
||||
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
|
||||
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
|
||||
@@ -108,7 +114,7 @@ export async function chatGPTDenial(scope: AdmissionScope | undefined, controlle
|
||||
/** Preserve account order; unknown capabilities/windows are never fabricated. */
|
||||
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
|
||||
const state = bridge();
|
||||
if (!desktopIPCConnected()) { clearCatalog(state); return; }
|
||||
if (!runtimeConnected()) { clearCatalog(state); return; }
|
||||
const sequence = ++state.catalogRequest;
|
||||
try {
|
||||
const value = await chatGPTRequest<ModelsValue>("models");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Api, Model, ModelAuth } from "@earendil-works/pi-ai";
|
||||
import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
|
||||
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionScope, type NativeSubscriptionSnapshot, type NativeSubscriptionStatus } from "../core/contracts/subscriptions.ts";
|
||||
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
|
||||
|
||||
@@ -18,6 +19,7 @@ export function nativeSubscriptionId(logicalId: string): NativeSubscriptionProvi
|
||||
function desktopConnected(): boolean {
|
||||
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
|
||||
}
|
||||
function runtimeConnected(): boolean { return desktopConnected() || webSubscriptionsEnabled(); }
|
||||
function notify(state: State) { for (const listener of state.listeners) { try { listener(); } catch { /* observer isolation */ } } }
|
||||
function invalidate(state: State, providerId?: NativeSubscriptionProviderId) {
|
||||
for (const [controller, owner] of state.controllers) if (!providerId || owner.providerId === providerId) controller.abort("all");
|
||||
@@ -60,7 +62,11 @@ function bridge(): State {
|
||||
if (message.ok) pending.resolve(message.value);
|
||||
else pending.reject(new Error(message.error ?? "Subscription connection failed."));
|
||||
});
|
||||
onWebSubscriptionPacket("lh:subscriptions", (packet) => {
|
||||
if (webSubscriptionsEnabled() && packet.snapshot) accept(state, packet.snapshot as NativeSubscriptionSnapshot);
|
||||
});
|
||||
process.on("disconnect", () => {
|
||||
if (webSubscriptionsEnabled()) return;
|
||||
invalidate(state);
|
||||
for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("The desktop connection closed.")); }
|
||||
state.pending.clear(); notify(state);
|
||||
@@ -70,6 +76,7 @@ function bridge(): State {
|
||||
}
|
||||
export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
||||
const state = bridge();
|
||||
if (webSubscriptionsEnabled()) return webSubscriptionRequest<T>("lh:subscriptions", op, fields);
|
||||
if (!desktopConnected()) return Promise.reject(new Error("Subscription sign-in is available only in the desktop app with a private server connection."));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const id = `${process.pid}:${++state.counter}`;
|
||||
@@ -82,17 +89,17 @@ export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<
|
||||
}
|
||||
export async function refreshSubscriptionModels(): Promise<void> {
|
||||
const state = bridge();
|
||||
if (!desktopConnected()) { invalidate(state); return; }
|
||||
if (!runtimeConnected()) { invalidate(state); return; }
|
||||
try { accept(state, await subscriptionRequest<NativeSubscriptionSnapshot>("snapshot")); }
|
||||
catch { invalidate(state); notify(state); }
|
||||
}
|
||||
export function nativeSubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus | undefined { return bridge().statuses.get(providerId); }
|
||||
export function nativeSubscriptionConnected(providerId: NativeSubscriptionProviderId): boolean {
|
||||
const status = nativeSubscriptionStatus(providerId);
|
||||
return desktopConnected() && status?.available === true && status.connected && !!status.connectionId;
|
||||
return runtimeConnected() && status?.available === true && status.connected && !!status.connectionId;
|
||||
}
|
||||
export function nativeSubscriptionBackgroundAllowed(providerId: NativeSubscriptionProviderId): boolean { return nativeSubscriptionConnected(providerId) && nativeSubscriptionStatus(providerId)?.allowBackgroundUse === true; }
|
||||
export function nativeSubscriptionModels(): Model<Api>[] { return desktopConnected() ? [...bridge().models.values()].flat() : []; }
|
||||
export function nativeSubscriptionModels(): Model<Api>[] { return runtimeConnected() ? [...bridge().models.values()].flat() : []; }
|
||||
export function nativeSubscriptionModel(logicalId: string, modelId: string): Model<Api> | undefined { return nativeSubscriptionModels().find((m) => m.provider === logicalId && m.id === modelId); }
|
||||
export function onNativeSubscriptionChange(listener: () => void): () => void { const state = bridge(); state.listeners.add(listener); return () => { state.listeners.delete(listener); }; }
|
||||
export function subscriptionScope(providerId: NativeSubscriptionProviderId, selectedModelId: string): NativeSubscriptionScope {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Server-injected private web transport. The agent layer never imports a
|
||||
* filesystem owner or server composition root. Desktop IPC remains separate. */
|
||||
export type SubscriptionRuntimeChannel = "lh:chatgpt" | "lh:subscriptions";
|
||||
export type SubscriptionRuntimePacket = Record<string, unknown>;
|
||||
interface Runtime {
|
||||
request?: <T>(channel: SubscriptionRuntimeChannel, op: string, fields: Record<string, unknown>) => Promise<T>;
|
||||
listeners: Map<SubscriptionRuntimeChannel, Set<(packet: SubscriptionRuntimePacket) => void>>;
|
||||
}
|
||||
const KEY = Symbol.for("lh.webSubscriptionRuntime");
|
||||
function runtime(): Runtime {
|
||||
const global = globalThis as typeof globalThis & { [KEY]?: Runtime };
|
||||
return global[KEY] ??= { listeners: new Map() };
|
||||
}
|
||||
export function configureWebSubscriptionRuntime(request: NonNullable<Runtime["request"]>): void { runtime().request = request; }
|
||||
export function webSubscriptionsEnabled(): boolean {
|
||||
return process.env.LH_DESKTOP_SERVER !== "1" && !process.env.LH_DESKTOP_API_SECRET && !!runtime().request;
|
||||
}
|
||||
export function onWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, listener: (packet: SubscriptionRuntimePacket) => void): void {
|
||||
const state = runtime(); const listeners = state.listeners.get(channel) ?? new Set();
|
||||
listeners.add(listener); state.listeners.set(channel, listeners);
|
||||
}
|
||||
export function publishWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, packet: SubscriptionRuntimePacket): void {
|
||||
for (const listener of runtime().listeners.get(channel) ?? []) {
|
||||
try { listener(packet); } catch { /* observer isolation */ }
|
||||
}
|
||||
}
|
||||
export function webSubscriptionRequest<T>(channel: SubscriptionRuntimeChannel, op: string, fields: Record<string, unknown>): Promise<T> {
|
||||
if (!webSubscriptionsEnabled()) return Promise.reject(new Error("The web subscription owner is unavailable."));
|
||||
return runtime().request!<T>(channel, op, fields);
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export interface ServerEvents {
|
||||
"activity-changed": Record<string, never>;
|
||||
/** The session list changed (create, delete, title). */
|
||||
"chats-changed": Record<string, never>;
|
||||
/** Web subscription state changed. No account or credential data on the bus. */
|
||||
"subscriptions-changed": Record<string, never>;
|
||||
/** A chat's record changed WITHOUT a turn (the silent dismissal write).
|
||||
* Same notify-then-fetch contract as the turn events: open views of
|
||||
* the chat refetch and adopt the durable record. */
|
||||
@@ -61,6 +63,7 @@ export const SERVER_EVENT_TYPES = [
|
||||
"jobs-changed",
|
||||
"activity-changed",
|
||||
"chats-changed",
|
||||
"subscriptions-changed",
|
||||
"record-changed",
|
||||
"compaction-changed",
|
||||
] as const satisfies readonly ServerEventType[];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -12,7 +12,7 @@ const { GET } = await import("../app/api/models/route.ts");
|
||||
const { chatGPTRequest, chatGPTConnected, chatGPTModels } = await import("../src/agent/chatgpt-bridge.ts");
|
||||
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
|
||||
|
||||
test("Next-style connected IPC is not desktop transport and never delays keyed API catalogs", async () => {
|
||||
test("Next-style IPC is not desktop transport; web ownership works without delaying keyed catalogs", async () => {
|
||||
const send = Object.getOwnPropertyDescriptor(process, "send");
|
||||
const connected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
|
||||
@@ -35,7 +35,11 @@ test("Next-style connected IPC is not desktop transport and never delays keyed A
|
||||
assert.equal(catalog.providers.find((p: { id: string }) => p.id === "chatgpt").keyPresent, false);
|
||||
assert.equal(chatGPTConnected(), false);
|
||||
assert.deepEqual(chatGPTModels(), []);
|
||||
await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
|
||||
if (!mode && !capability) {
|
||||
const value = await chatGPTRequest<{ status: { available: boolean; accounts: unknown[] } }>("status");
|
||||
assert.equal(value.status.available, true);
|
||||
assert.deepEqual(value.status.accounts, []);
|
||||
} else await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
|
||||
assert.equal(sends, 0, "no desktop request/timer may be started over framework IPC");
|
||||
}
|
||||
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
|
||||
|
||||
@@ -15,7 +15,7 @@ const mainRequire = createRequire(mainFile);
|
||||
/** Decline the single-instance lock so normal boot never spawns a server.
|
||||
* Then call the REAL createWindow with a stub Electron API and inspect its
|
||||
* constructor options. No rewritten copy of the production logic. */
|
||||
function windowOptions(platform: string) {
|
||||
function windowOptions(platform: string, env: Record<string, string> = {}) {
|
||||
class FakeWindow {
|
||||
constructor(readonly options: unknown) {}
|
||||
webContents = { on() {}, setWindowOpenHandler() {} };
|
||||
@@ -23,13 +23,13 @@ function windowOptions(platform: string) {
|
||||
once() {}
|
||||
}
|
||||
const electron = {
|
||||
app: { setName() {}, requestSingleInstanceLock: () => false, quit() {} },
|
||||
app: { setName() {}, getPath: () => "/fixture/LearnHarness", setPath() {}, requestSingleInstanceLock: () => false, quit() {} },
|
||||
BrowserWindow: FakeWindow,
|
||||
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
|
||||
};
|
||||
const context = {
|
||||
require: (id: string) => id === "electron" ? electron : mainRequire(id),
|
||||
process: { platform, argv: [], env: {}, on() {} },
|
||||
process: { platform, argv: [], env, on() {} },
|
||||
__dirname: dirname(mainFile),
|
||||
};
|
||||
runInNewContext(mainSource, context, { filename: mainFile });
|
||||
@@ -61,6 +61,24 @@ describe("desktop window chrome", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("only the external dev shell receives the web subscription transport marker", () => {
|
||||
assert.equal(windowOptions("darwin").webPreferences.additionalArguments, undefined);
|
||||
assert.deepEqual(windowOptions("darwin", { LH_DEV_URL: "http://localhost:3000" }).webPreferences.additionalArguments, ["--lh-web-subscriptions"]);
|
||||
});
|
||||
|
||||
it("dev preload routes opaque sign-in actions through its narrow external-browser channel", async () => {
|
||||
let bridge!: { subscriptionTransport: string; openSubscriptionBrowser(action: string): Promise<void> };
|
||||
const invoked: unknown[][] = [];
|
||||
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), {
|
||||
process: { platform: "darwin", argv: ["--lh-web-subscriptions"] },
|
||||
require: () => ({ contextBridge: { exposeInMainWorld: (_: string, value: typeof bridge) => { bridge = value; } }, ipcRenderer: { on() {}, invoke: async (...args: unknown[]) => { invoked.push(args); } } }),
|
||||
});
|
||||
assert.equal(bridge.subscriptionTransport, "web");
|
||||
const action = "/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001";
|
||||
await bridge.openSubscriptionBrowser(action);
|
||||
assert.deepEqual(invoked, [["lh:subscriptions:web-browser", action]]);
|
||||
});
|
||||
|
||||
it("preload exposes OS identity without exposing Node/Electron to the renderer", () => {
|
||||
let bridge: Record<string, unknown> = {};
|
||||
const context = {
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
|
||||
const file = (relative: string) => root + (windows ? "\\" : "/") + (windows ? relative.replaceAll("/", "\\") : relative);
|
||||
const ignored = picomatch(patterns, { contains: true, dot: true, windows });
|
||||
it(`excludes genuine data directories and secrets (${windows ? "Windows" : "POSIX"})`, () => {
|
||||
for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local"]) {
|
||||
for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local", "chatgpt-credentials.json", "github-copilot-credentials.json", "xai-credentials.json", "chatgpt-credentials.json.fixture.tmp", "github-copilot-credentials.json.fixture.tmp", "xai-credentials.json.fixture.tmp"]) {
|
||||
assert.equal(ignored(file(relative)), true, relative);
|
||||
}
|
||||
// The explicit includes remain necessary: this raw ignore still matches SDK data.
|
||||
@@ -37,7 +37,7 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
|
||||
assert.equal(ignore(`${root}/node_modules/gcp-metadata/build/src/index.js`), false);
|
||||
});
|
||||
it("preserves all secret exclusions exactly", () => {
|
||||
assert.deepEqual(patterns, ["**/data/**/*", "auth.json", ".env*"]);
|
||||
assert.deepEqual(patterns, ["**/data/**/*", "auth.json", "chatgpt-credentials.json*", "github-copilot-credentials.json*", "xai-credentials.json*", ".env*"]);
|
||||
});
|
||||
it("retains exactly the narrow native-Turbopack closure workaround and existing runtime includes", () => {
|
||||
assert.deepEqual(nextConfig.outputFileTracingIncludes!["*"], [
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { afterEach } from "node:test";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { createNotifyHub } from "../components/server-events.tsx";
|
||||
import { createWebChatGPTBridge, createWebNativeBridge } from "../components/settings/subscription-client.ts";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.document });
|
||||
const originalFetch = globalThis.fetch;
|
||||
const action = "/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001";
|
||||
afterEach(() => { globalThis.fetch = originalFetch; delete window.lh; });
|
||||
|
||||
test("web ChatGPT reserves a new tab before async login, navigates only to an opaque action and receives push/resync", async () => {
|
||||
const calls: string[] = [];
|
||||
const tab = { opener: {}, closed: false, location: { href: "about:blank" }, close() { this.closed = true; } };
|
||||
window.open = (() => { calls.push("tab"); return tab; }) as unknown as typeof window.open;
|
||||
const safe = { available: true, pending: true, allowBackgroundUse: true, activeId: null, accounts: [], error: null };
|
||||
globalThis.fetch = async (url, options) => {
|
||||
assert.equal(url, "/api/subscriptions"); assert.equal(new Headers(options?.headers).get("x-learnharness-subscriptions"), "1");
|
||||
if (!options?.method) return Response.json({ chatgpt: safe, native: [] });
|
||||
calls.push("login"); assert.equal(tab.opener, null);
|
||||
assert.deepEqual(JSON.parse(String(options.body)), { providerId: "chatgpt", op: "login" });
|
||||
return Response.json({ value: safe, browserAction: action });
|
||||
};
|
||||
const hub = createNotifyHub(), bridge = createWebChatGPTBridge(hub);
|
||||
assert.deepEqual(await bridge.login(), safe); assert.deepEqual(calls, ["tab", "login"]);
|
||||
assert.equal(tab.location.href, action); assert(!tab.closed);
|
||||
let updates = 0; const off = bridge.onChange(() => updates++);
|
||||
hub.dispatch("subscriptions-changed", "{}"); hub.fireOpen(); assert.equal(updates, 2);
|
||||
off(); hub.fireOpen(); assert.equal(updates, 2);
|
||||
assert.deepEqual(await bridge.status(), safe);
|
||||
});
|
||||
|
||||
test("blocked popups do not start OAuth; failed login closes the reserved tab", async () => {
|
||||
const bridge = createWebChatGPTBridge(null);
|
||||
window.open = () => null;
|
||||
globalThis.fetch = async () => assert.fail("blocked popup must not start sign-in");
|
||||
await assert.rejects(bridge.login(), /Allow a sign-in tab/);
|
||||
const tab = { opener: {}, closed: false, close() { this.closed = true; } };
|
||||
window.open = (() => tab) as unknown as typeof window.open;
|
||||
globalThis.fetch = async () => new Response(null, { status: 400 });
|
||||
await assert.rejects(bridge.login()); assert(tab.closed);
|
||||
});
|
||||
|
||||
test("native web login retains device prompts; only the explicit Open browser action opens a tab", async () => {
|
||||
const requests: Record<string, unknown>[] = []; let tabs = 0;
|
||||
const tab = { opener: {}, closed: false, location: { href: "" }, close() {} };
|
||||
window.open = (() => { tabs++; return tab; }) as unknown as typeof window.open;
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
const command = JSON.parse(String(options?.body)); requests.push(command);
|
||||
return Response.json(command.op === "open" ? { browserAction: action } : {});
|
||||
};
|
||||
const bridge = createWebNativeBridge(null);
|
||||
await bridge.login("github-copilot", false); assert.equal(tabs, 0);
|
||||
await bridge.respond("github-copilot", "attempt", "prompt", "");
|
||||
await bridge.open("github-copilot", "attempt", "action"); assert.equal(tabs, 1); assert.equal(tab.location.href, action);
|
||||
assert.deepEqual(requests, [{ providerId: "github-copilot", op: "login", enabled: false }, { providerId: "github-copilot", op: "respond", attemptId: "attempt", promptId: "prompt", value: "" }, { providerId: "github-copilot", op: "open", attemptId: "attempt", actionId: "action" }]);
|
||||
});
|
||||
|
||||
test("Electron dev uses its narrow external-browser bridge, without attempting a renderer popup", async () => {
|
||||
const opened: string[] = [];
|
||||
window.lh = { expandWindow() {}, subscriptionTransport: "web", openSubscriptionBrowser: async (target) => { opened.push(target); } };
|
||||
window.open = () => assert.fail("Electron denies renderer popups");
|
||||
globalThis.fetch = async () => Response.json({ value: { pending: true }, browserAction: action });
|
||||
await createWebChatGPTBridge(null).login(); assert.deepEqual(opened, [action]);
|
||||
});
|
||||
|
||||
test("web notices identify the server machine and same-machine ChatGPT sign-in; desktop copy stays unchanged", async () => {
|
||||
const { CHATGPT_NOTICE, chatGPTNotice } = await import("../components/settings/chatgpt-connection.tsx");
|
||||
const { subscriptionNotice, subscriptionDisconnectCopy } = await import("../components/settings/subscription-connections.tsx");
|
||||
assert.equal(chatGPTNotice(), CHATGPT_NOTICE); assert(CHATGPT_NOTICE.includes("on this device"));
|
||||
assert(chatGPTNotice(true).includes("on the machine running LearnHarness"));
|
||||
assert(chatGPTNotice(true).endsWith("Complete ChatGPT sign-in in a browser on that same machine."));
|
||||
for (const label of ["GitHub Copilot", "Grok"]) {
|
||||
assert(subscriptionNotice(label).includes("stored locally")); assert(subscriptionDisconnectCopy(label).includes("from this device"));
|
||||
assert(subscriptionNotice(label, true).includes("on the machine running LearnHarness"));
|
||||
assert(subscriptionDisconnectCopy(label, true).includes("from the machine running LearnHarness"));
|
||||
}
|
||||
});
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { openWebSubscriptionBrowser } = require("../electron/web-subscription-browser.cjs");
|
||||
test("desktop-dev opener accepts only local action IDs, does not follow redirects or use browser cookies", async () => {
|
||||
const opened: string[] = [];
|
||||
const options = {
|
||||
openExternal: async (url: string) => { opened.push(url); },
|
||||
fetch: async (url: URL, init: RequestInit) => {
|
||||
assert.equal(url.origin, "http://localhost:3000"); assert.equal(url.pathname, "/api/subscriptions/browser");
|
||||
assert.equal(init.method, "POST"); assert.equal(init.redirect, "manual"); assert.equal(init.credentials, "omit");
|
||||
assert.equal(new Headers(init.headers).get("origin"), "http://localhost:3000");
|
||||
return new Response(null, { status: 303, headers: { Location: "https://auth.openai.com/authorize?state=fixture" } });
|
||||
},
|
||||
};
|
||||
await openWebSubscriptionBrowser(action, "http://localhost:3000", options); assert.equal(opened.length, 1);
|
||||
for (const target of ["https://unrelated.invalid", "//unrelated.invalid", "/api/keys", `${action}&target=evil`]) await assert.rejects(openWebSubscriptionBrowser(target, "http://localhost:3000", options), /Invalid sign-in action/);
|
||||
for (const target of ["file:///private/fixture", "http://unrelated.invalid", "https://user:pass@unrelated.invalid"]) await assert.rejects(openWebSubscriptionBrowser(action, "http://localhost:3000", { ...options, fetch: async () => new Response(null, { status: 303, headers: { Location: target } }) }));
|
||||
assert.equal(opened.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Exercise the real curator against synthetic payloads, never the repo payload. */
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, copyFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
for (const name of ["chatgpt-credentials.json", "github-copilot-credentials.json", "xai-credentials.json"]) {
|
||||
for (const secret of [name, `${name}.fixture.tmp`]) test(`payload curator refuses web credentials: ${secret}`, () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "lh-sub-payload-"));
|
||||
try {
|
||||
mkdirSync(path.join(root, "scripts")); mkdirSync(path.join(root, ".next", "standalone"), { recursive: true }); mkdirSync(path.join(root, ".next", "static"));
|
||||
copyFileSync(new URL("../scripts/build-server.mjs", import.meta.url), path.join(root, "scripts", "build-server.mjs"));
|
||||
writeFileSync(path.join(root, ".next", "standalone", "server.js"), "// fixture");
|
||||
writeFileSync(path.join(root, ".next", "standalone", secret), "fixture-not-real-credentials", { mode: 0o600 });
|
||||
const result = spawnSync(process.execPath, [path.join(root, "scripts", "build-server.mjs")], { encoding: "utf8", env: process.env });
|
||||
assert.equal(result.status, 1); assert(result.stderr.includes(`REFUSING: standalone contains ${secret}`));
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after } from "node:test";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createRequire } from "node:module";
|
||||
import { createModels, type OAuthCredential, type ProviderAuthInteraction } from "@earendil-works/pi-ai";
|
||||
import { githubCopilotProvider } from "@earendil-works/pi-ai/providers/github-copilot";
|
||||
import { xaiProvider } from "@earendil-works/pi-ai/providers/xai";
|
||||
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-sub-"));
|
||||
process.env.LH_AUTH_FILE = path.join(directory, "auth.json");
|
||||
process.env.LH_DATA_DIR = path.join(directory, "data");
|
||||
delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET;
|
||||
await fs.writeFile(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 });
|
||||
const require = createRequire(import.meta.url);
|
||||
const { SCOPES } = require("../electron/chatgpt.cjs");
|
||||
const { createWebSubscriptionOwner, webSubscriptionOwner, webSubscriptionsEnabled } = await import("../src/server/web-subscriptions.ts");
|
||||
const { subscriptionControlsResponse, subscriptionBrowserResponse } = await import("../src/server/web-subscription-http.ts");
|
||||
const chat = await import("../src/agent/chatgpt-bridge.ts");
|
||||
const native = await import("../src/agent/subscription-bridge.ts");
|
||||
const { piModels } = await import("../src/agent/models.ts");
|
||||
const { refreshConnectionCatalog } = await import("../src/server/model-catalog.ts");
|
||||
const model = piModels().getModels("openai").find((entry) => entry.api === "openai-responses")!;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input) => {
|
||||
assert.equal(String(input), "https://api.openai.com/v1/models", "unexpected network request forbidden");
|
||||
return Response.json({ models: [{ slug: model.id, display_name: "Fixture ChatGPT", visibility: "list" }] });
|
||||
};
|
||||
const copilot = githubCopilotProvider().getModels()[0]!;
|
||||
const credentials = (id: string): OAuthCredential => ({ type: "oauth", access: id === "xai" ? "fixture-xai-access" : "tid=fixture;proxy-ep=proxy.individual.githubcopilot.com;", refresh: "fixture-refresh", expires: Date.now() + 3600000, ...(id === "github-copilot" ? { availableModelIds: [copilot.id] } : {}) });
|
||||
await fs.writeFile(path.join(directory, "chatgpt-credentials.json"), JSON.stringify({ version: 1, hostId: "fixture-host", allowBackgroundUse: true, activeId: "fixture-account", registrations: [{ id: "fixture-account", clientId: "oaiapp_fixture", issuer: "https://auth.openai.com", subject: "fixture-subject", email: "fixture@example.invalid", tokens: { accessToken: "fixture-chatgpt-access", refreshToken: "fixture-chatgpt-refresh", idToken: "fixture-id-token", scopes: SCOPES, expiresAt: Date.now() + 3600000 } }] }), { mode: 0o600 });
|
||||
for (const id of ["github-copilot", "xai"]) await fs.writeFile(path.join(directory, `${id}-credentials.json`), JSON.stringify({ version: 1, allowBackgroundUse: true, connectionId: "00000000-0000-4000-8000-000000000001", credential: credentials(id) }), { mode: 0o600 });
|
||||
function request(command?: Record<string, unknown>, origin = "http://192.168.1.10:3000") {
|
||||
return new Request(`${origin}/api/subscriptions`, { method: command ? "POST" : "GET", headers: { host: new URL(origin).host, origin, "sec-fetch-site": "same-origin", "x-learnharness-subscriptions": "1", "content-type": "application/json" }, ...(command ? { body: JSON.stringify(command) } : {}) });
|
||||
}
|
||||
after(async () => { webSubscriptionOwner().close(); globalThis.fetch = originalFetch; await fs.rm(directory, { recursive: true, force: true }); });
|
||||
|
||||
test("ordinary web restores configured sibling credentials, private runtime leases work for LAN-owned requests", async () => {
|
||||
await refreshConnectionCatalog();
|
||||
assert(chat.chatGPTConnected()); assert(native.nativeSubscriptionConnected("github-copilot")); assert(native.nativeSubscriptionConnected("xai"));
|
||||
assert.equal((await chat.chatGPTLease(chat.chatGPTScope(model.id), false)).accessToken, "fixture-chatgpt-access");
|
||||
const xaiModel = native.nativeSubscriptionModels().find((entry) => entry.provider === "xai-subscription")!;
|
||||
assert.equal((await native.subscriptionLease(native.subscriptionScope("xai", xaiModel.id), false)).apiKey, "fixture-xai-access");
|
||||
const response = await subscriptionControlsResponse(request());
|
||||
assert.equal(response.status, 200); assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
const publicState = await response.text();
|
||||
for (const secret of ["fixture-chatgpt-access", "fixture-chatgpt-refresh", "fixture-id-token", "fixture-xai-access", "fixture-refresh"]) assert(!publicState.includes(secret));
|
||||
assert(publicState.includes("fixture@example.invalid"));
|
||||
});
|
||||
|
||||
test("web background OFF immediately aborts background streams, retains foreground and rejects private background leases", async () => {
|
||||
const foreground = chat.chatGPTAbortController(false), background = chat.chatGPTAbortController(true);
|
||||
const nativeForeground = native.subscriptionAbortController("xai", false), nativeBackground = native.subscriptionAbortController("xai", true);
|
||||
try {
|
||||
assert.equal((await subscriptionControlsResponse(request({ providerId: "chatgpt", op: "consent", enabled: false }))).status, 200);
|
||||
assert(background.controller.signal.aborted); assert(!foreground.controller.signal.aborted);
|
||||
const scope = chat.chatGPTScope(model.id); await chat.chatGPTLease(scope, false); await assert.rejects(chat.chatGPTLease(scope, true));
|
||||
assert.equal((await subscriptionControlsResponse(request({ providerId: "xai", op: "consent", enabled: false }))).status, 200);
|
||||
assert(nativeBackground.controller.signal.aborted); assert(!nativeForeground.controller.signal.aborted);
|
||||
const xaiModel = native.nativeSubscriptionModels().find((entry) => entry.provider === "xai-subscription")!;
|
||||
const nativeScope = native.subscriptionScope("xai", xaiModel.id); await native.subscriptionLease(nativeScope, false); await assert.rejects(native.subscriptionLease(nativeScope, true));
|
||||
await subscriptionControlsResponse(request({ providerId: "xai", op: "disconnect" }));
|
||||
assert(nativeForeground.controller.signal.aborted); assert(!native.nativeSubscriptionConnected("xai"));
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(path.join(directory, "xai-credentials.json"), "utf8")), { version: 1, allowBackgroundUse: false });
|
||||
} finally { foreground.release(); background.release(); nativeForeground.release(); nativeBackground.release(); }
|
||||
});
|
||||
|
||||
test("HTTP controls reject cross-origin, simple-form and private credential operations; desktop never falls back", async () => {
|
||||
const forged = request({ providerId: "chatgpt", op: "consent", enabled: true }); forged.headers.set("origin", "https://unrelated.invalid");
|
||||
assert.equal((await subscriptionControlsResponse(forged)).status, 403);
|
||||
const noHeader = request(); noHeader.headers.delete("x-learnharness-subscriptions");
|
||||
assert.equal((await subscriptionControlsResponse(noHeader)).status, 403);
|
||||
const noOrigin = request({ providerId: "chatgpt", op: "cancel" }); noOrigin.headers.delete("origin");
|
||||
assert.equal((await subscriptionControlsResponse(noOrigin)).status, 403);
|
||||
for (const op of ["lease", "models", "snapshot", "token", "openExternal"]) assert.equal((await subscriptionControlsResponse(request({ providerId: "chatgpt", op }))).status, 400);
|
||||
const huge = request({ providerId: "chatgpt", op: "cancel", padding: "x".repeat(16384) });
|
||||
assert.equal((await subscriptionControlsResponse(huge)).status, 413);
|
||||
const browser = new Request("http://localhost:3000/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001");
|
||||
assert.equal((await subscriptionBrowserResponse(browser)).status, 403);
|
||||
for (const [marker, secret] of [["1", undefined], [undefined, "fixture-capability"]]) {
|
||||
if (marker) process.env.LH_DESKTOP_SERVER = marker; else delete process.env.LH_DESKTOP_SERVER;
|
||||
if (secret) process.env.LH_DESKTOP_API_SECRET = secret; else delete process.env.LH_DESKTOP_API_SECRET;
|
||||
assert(!webSubscriptionsEnabled());
|
||||
assert.equal((await subscriptionControlsResponse(request())).status, 404);
|
||||
await assert.rejects(chat.chatGPTRequest("status"), /desktop app/);
|
||||
}
|
||||
delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET;
|
||||
});
|
||||
|
||||
function fakeSDK(login: (id: string, interaction: ProviderAuthInteraction) => Promise<OAuthCredential>) {
|
||||
const factory = (id: string) => {
|
||||
const provider = id === "xai" ? xaiProvider() : githubCopilotProvider();
|
||||
return { ...provider, auth: { oauth: { ...provider.auth.oauth!, login: (interaction: ProviderAuthInteraction) => login(id, interaction), refresh: async () => credentials(id) } } };
|
||||
};
|
||||
return { createModels, githubCopilotProvider: () => factory("github-copilot"), xaiProvider: () => factory("xai") };
|
||||
}
|
||||
test("device browser actions preserve complete URLs privately, are origin-bound, one-use and cancellation-bound", async () => {
|
||||
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-action-"));
|
||||
let complete!: (credential: OAuthCredential) => void;
|
||||
const pending = new Promise<OAuthCredential>((resolve) => { complete = resolve; });
|
||||
let notified!: () => void;
|
||||
const notification = new Promise<void>((resolve) => { notified = resolve; });
|
||||
const owner = createWebSubscriptionOwner({ directory: scratch, sdk: fakeSDK(async (_id, interaction) => { interaction.notify({ type: "device_code", userCode: "PUBLIC", verificationUri: "https://auth.x.ai/device?user_code=PUBLIC" }); notified(); return pending; }) });
|
||||
try {
|
||||
const login = owner.control("xai", { op: "login" }, "http://localhost:3000"); await notification;
|
||||
const interaction = (await owner.native.status()).find((entry: { providerId: string }) => entry.providerId === "xai")!.interaction;
|
||||
const command = { op: "open", attemptId: interaction.attemptId, actionId: interaction.browser.id };
|
||||
const opened = await owner.control("xai", command, "http://localhost:3000");
|
||||
assert(opened.browserAction); assert(!JSON.stringify(opened).includes("auth.x.ai"));
|
||||
const id = new URL(opened.browserAction, "http://localhost:3000").searchParams.get("id")!;
|
||||
assert.equal(await owner.takeBrowserAction(id, "http://other.invalid"), undefined);
|
||||
assert.equal(await owner.takeBrowserAction(id, "http://localhost:3000"), "https://auth.x.ai/device?user_code=PUBLIC");
|
||||
assert.equal(await owner.takeBrowserAction(id, "http://localhost:3000"), undefined);
|
||||
const stale = await owner.control("xai", command, "http://localhost:3000");
|
||||
await owner.control("xai", { op: "cancel" }, "http://localhost:3000");
|
||||
complete(credentials("xai")); await login;
|
||||
assert.equal(await owner.takeBrowserAction(new URL(stale.browserAction!, "http://localhost:3000").searchParams.get("id")!, "http://localhost:3000"), undefined);
|
||||
} finally { owner.close(); await fs.rm(scratch, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("web ChatGPT login supplies only an opaque browser action; cancel/replacement invalidates earlier launch", async () => {
|
||||
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-chat-action-"));
|
||||
const owner = createWebSubscriptionOwner({ directory: scratch, sdk: fakeSDK(async (id) => credentials(id)) });
|
||||
try {
|
||||
const first = await owner.control("chatgpt", { op: "login" }, "http://localhost:3000");
|
||||
assert(first.browserAction); assert(!JSON.stringify(first).includes("auth.openai.com"));
|
||||
await owner.control("chatgpt", { op: "cancel" }, "http://localhost:3000");
|
||||
const second = await owner.control("chatgpt", { op: "login" }, "http://localhost:3000");
|
||||
assert(second.browserAction);
|
||||
const firstId = new URL(first.browserAction, "http://localhost:3000").searchParams.get("id")!;
|
||||
assert.equal(await owner.takeBrowserAction(firstId, "http://localhost:3000"), undefined);
|
||||
const secondId = new URL(second.browserAction, "http://localhost:3000").searchParams.get("id")!;
|
||||
const target = new URL((await owner.takeBrowserAction(secondId, "http://localhost:3000"))!);
|
||||
assert.equal(target.origin, "https://auth.openai.com");
|
||||
const callback = new URL(target.searchParams.get("redirect_uri")!);
|
||||
assert.equal(callback.hostname, "127.0.0.1"); assert.equal(callback.pathname, "/auth/callback");
|
||||
assert(target.searchParams.has("state")); assert(target.searchParams.has("code_challenge"));
|
||||
} finally { owner.close(); await fs.rm(scratch, { recursive: true, force: true }); }
|
||||
});
|
||||
Reference in New Issue
Block a user