Unify ChatGPT background use and account management

This commit is contained in:
amosblomqvist
2026-10-24 15:28:26 +03:00
parent 8691470d03
commit 42abeb65ae
19 changed files with 687 additions and 416 deletions
+105 -49
View File
@@ -8,19 +8,21 @@ import { Button } from "@/components/ui/button";
import { Toggle } from "./controls"; import { Toggle } from "./controls";
export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and encrypted credentials locally 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 const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and encrypted credentials locally 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 const CHATGPT_BACKGROUND_HINT = "Background work uses your ChatGPT allowance."; export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage.";
const REVOCATION_WARNING = "Signed out locally. Remote revocation was not confirmed; disconnect LearnHarness in ChatGPT Settings."; const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts";
const empty: ChatGPTStatus = { available: false, pending: false, activeId: null, accounts: [], error: null }; const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
export const CHATGPT_DISCONNECT_COPY = { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." };
const empty: ChatGPTStatus = { available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null };
type Account = ChatGPTStatus["accounts"][number]; type Account = ChatGPTStatus["accounts"][number];
type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string } | { kind: "warning" }; type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string; account: Account; confirm?: boolean } | { kind: "warning" };
type LoginAttempt = { generation: number; dialog: Dialog; registration: string; background: boolean; pending: boolean; accountId?: string; phase: "starting" | "pending" | "consent" }; type LoginAttempt = { generation: number; dialog: Dialog; registration: string; background: boolean; backgroundDirty: boolean; pending: boolean; accountId?: string; phase: "starting" | "pending" | "consent" };
const CONTROL_ERROR = "The ChatGPT connection could not be changed. Try again."; const CONTROL_ERROR = "The ChatGPT connection could not be changed. Try again.";
function isStatus(value: unknown): value is ChatGPTStatus { function isStatus(value: unknown): value is ChatGPTStatus {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const s = value as ChatGPTStatus; const s = value as ChatGPTStatus;
return typeof s.available === "boolean" && typeof s.pending === "boolean" && (s.activeId === null || typeof s.activeId === "string") && return typeof s.available === "boolean" && typeof s.pending === "boolean" && typeof s.allowBackgroundUse === "boolean" && (s.activeId === null || typeof s.activeId === "string") &&
(s.error === null || typeof s.error === "string") && (s.catalogError == null || typeof s.catalogError === "string") && Array.isArray(s.accounts) && (s.error === null || typeof s.error === "string") && (s.catalogError == null || typeof s.catalogError === "string") && Array.isArray(s.accounts) &&
s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && typeof a.background === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean")); s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean"));
} }
function replyError(value: unknown): string { function replyError(value: unknown): string {
return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : CONTROL_ERROR; return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : CONTROL_ERROR;
@@ -45,9 +47,10 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [dismissedError, setDismissedError] = useState<string | null>(null); const [dismissedError, setDismissedError] = useState<string | null>(null);
const [background, setBackground] = useState(false); const [background, setBackground] = useState(true);
const [backgroundDirty, setBackgroundDirty] = useState(false);
const [registration, setRegistration] = useState(""); const [registration, setRegistration] = useState("");
const operation = useRef<"login" | "cancel" | "change" | "logout" | null>(null); const operation = useRef<"login" | "cancel" | "change" | "disconnect" | null>(null);
const generation = useRef(0); const generation = useRef(0);
const statusSequence = useRef(0); const statusSequence = useRef(0);
const mounted = useRef(false); const mounted = useRef(false);
@@ -72,18 +75,28 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
finish(attempt); setError(next.error || CONTROL_ERROR); return; finish(attempt); setError(next.error || CONTROL_ERROR); return;
} }
attempt.phase = "consent"; attempt.accountId = account.id; attempt.phase = "consent"; attempt.accountId = account.id;
let replyFailure: string | null = null;
try { try {
if (account.background !== attempt.background) { if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) {
operation.current = "change"; operation.current = "change";
const result: unknown = await window.lh!.chatgpt!.consent(account.id, attempt.background); const request = ++statusSequence.current;
const result: unknown = await window.lh!.chatgpt!.consent(attempt.background);
if (!owns(attempt)) return; if (!owns(attempt)) return;
const saved = publishStatus(result); 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();
if (!owns(attempt)) return;
saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current;
} else saved = publishStatus(result);
if (!saved) { finish(attempt); return; } if (!saved) { finish(attempt); return; }
next = saved; next = saved;
} }
if (!owns(attempt)) return; if (!owns(attempt)) return;
const current = next.accounts.find((a) => a.id === account.id && a.id === next.activeId && a.connected); const current = next.accounts.find((a) => a.id === account.id && a.id === next.activeId && a.connected);
if (next.error || next.pending || !next.available || !current || current.background !== attempt.background) setError(next.error || CONTROL_ERROR); if (replyFailure || next.error || next.pending || !next.available || !current || (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background)) setError(replyFailure || next.error || CONTROL_ERROR);
else if (dialogRef.current === attempt.dialog) showDialog(null); else if (dialogRef.current === attempt.dialog) showDialog(null);
} catch { if (owns(attempt)) setError(CONTROL_ERROR); } } catch { if (owns(attempt)) setError(CONTROL_ERROR); }
finally { finish(attempt); } finally { finish(attempt); }
@@ -121,19 +134,23 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); }; return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); };
}, []); }, []);
const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId && a.connected) : undefined; 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);
const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null; const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null;
const warning = visibleError === REVOCATION_WARNING ? visibleError : null; const warning = visibleError === REVOCATION_WARNING ? visibleError : null;
function openConnect() { function openConnect() {
if (operation.current) return; if (operation.current) return;
setBackground(false); setBackground(statusRef.current.allowBackgroundUse);
setBackgroundDirty(false);
setRegistration(""); setRegistration("");
setError(null); setError(null);
setDismissedError(status.error); setDismissedError(status.error);
showDialog({ kind: "connect" }); showDialog({ kind: "connect" });
} }
function close() { function close() {
if (operation.current === "logout" || operation.current === "change" || operation.current === "cancel") return; if (operation.current === "disconnect" || operation.current === "change" || operation.current === "cancel") return;
const current = dialogRef.current;
if (current?.kind === "manage" && current.confirm) { showDialog({ ...current, confirm: false }); setError(null); return; }
const cancel = loginAttempt.current !== null || statusRef.current.pending; const cancel = loginAttempt.current !== null || statusRef.current.pending;
const intent = ++generation.current; const intent = ++generation.current;
const owner = dialogRef.current; const owner = dialogRef.current;
@@ -156,19 +173,36 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
}); });
} }
} }
async function control(kind: "change" | "logout", action: () => Promise<ChatGPTStatus>) { async function control(kind: "change" | "disconnect", action: () => Promise<ChatGPTStatus>, target?: string) {
if (operation.current) return; if (operation.current) return;
const intent = ++generation.current; const intent = ++generation.current;
const request = ++statusSequence.current;
const owner = dialogRef.current;
operation.current = kind; operation.current = kind;
setBusy(true); setError(null); setDismissedError(null); setBusy(true); setError(null);
if (kind === "disconnect") setDismissedError(null);
try { try {
const value: unknown = await action(); const value: unknown = await action();
if (!mounted.current || intent !== generation.current) return; if (!mounted.current || intent !== generation.current) return;
const next = publishStatus(value); if (!isStatus(value)) { setError(replyError(value)); return; }
if (!next) return; // Error-only IPC replies never replace safe status. let next: ChatGPTStatus | null;
if (kind === "logout" && !next.error && !next.accounts.find((a) => a.id === status.activeId && a.connected)) showDialog(null); if (request !== statusSequence.current) {
else if (kind === "logout" && next.error === REVOCATION_WARNING) showDialog({ kind: "warning" }); const refresh = ++statusSequence.current;
else setError(next.error); const current: unknown = await window.lh!.chatgpt!.status();
if (!mounted.current || intent !== generation.current) return;
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
} else next = publishStatus(value);
if (!next) return;
// Dismissed status warnings belong to an earlier action. Only a new
// disconnect outcome (or an explicit IPC error envelope above) reopens one.
const failure = [value.error, next.error].find((message) => message && (kind === "disconnect" || message !== dismissedError)) ?? null;
if (kind === "disconnect" && !next.accounts.some((a) => a.id === target)) {
if (dialogRef.current === owner) {
if (failure === REVOCATION_WARNING) showDialog({ kind: "warning" });
else if (!failure) showDialog(null);
}
setError(failure);
} else setError(failure || (kind === "disconnect" ? CONTROL_ERROR : null));
} catch { if (mounted.current && intent === generation.current) setError(CONTROL_ERROR); } } catch { if (mounted.current && intent === generation.current) setError(CONTROL_ERROR); }
finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } } finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } }
} }
@@ -176,7 +210,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
const bridge = window.lh?.chatgpt; const bridge = window.lh?.chatgpt;
const owner = dialogRef.current; const owner = dialogRef.current;
if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return; if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return;
const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, pending: false, phase: "starting" }; const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, backgroundDirty, pending: false, phase: "starting" };
loginAttempt.current = attempt; operation.current = "login"; loginAttempt.current = attempt; operation.current = "login";
const request = ++statusSequence.current; const request = ++statusSequence.current;
setBusy(true); setError(null); setDismissedError(null); setBusy(true); setError(null); setDismissedError(null);
@@ -204,45 +238,67 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
const context = { const context = {
status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning, status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning,
connect: openConnect, connect: openConnect,
manage: (id: string) => { if (!operation.current) { setError(null); showDialog({ kind: "manage", accountId: id }); } }, manage: (id: string) => {
const account = statusRef.current.accounts.find((a) => a.id === id);
if (!operation.current && account) { setError(null); showDialog({ kind: "manage", accountId: id, account }); }
},
select: (id: string) => { if (bridge) void control("change", () => bridge.select(id)); }, select: (id: string) => { if (bridge) void control("change", () => bridge.select(id)); },
dismissWarning: () => { setDismissedError(REVOCATION_WARNING); setError(null); }, dismissWarning: () => { setDismissedError(REVOCATION_WARNING); setError(null); },
}; };
return <Context.Provider value={context}> return <Context.Provider value={context}>
{children} {children}
{dialog && <AccountDialog onClose={close} busy={busy && operation.current !== "login"}> {dialog && <AccountDialog onClose={close} busy={busy && operation.current !== "login"}>
<h2 className="text-sm font-semibold text-foreground">{dialog.kind === "connect" ? "Continue with ChatGPT" : "ChatGPT account"}</h2>
{dialog.kind === "warning" ? <> {dialog.kind === "warning" ? <>
<p role="alert" className="mt-3 rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{REVOCATION_WARNING}</p> <p role="alert" className="rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{REVOCATION_WARNING}</p>
<div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); showDialog(null); }}>Dismiss</Button></div> <div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); showDialog(null); }}>Dismiss</Button></div>
</> : <> </> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
{dialog.kind === "connect" && status.accounts.length > 0 && <label className="mt-3 flex flex-col gap-1 text-xs text-muted-foreground"> <h2 className="text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</h2>
Saved ChatGPT account <p className="mt-3 text-xs text-foreground">{managed?.email || "ChatGPT account"}</p>
<select aria-label="Saved ChatGPT account" value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="rounded-sm border border-border bg-background px-2 py-1.5 text-foreground"> <p className="mt-3 text-xs text-muted-foreground">{CHATGPT_DISCONNECT_COPY.body}</p>
<option value="">Add another account or workspace</option> {context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)} <div className="mt-4 flex items-center justify-end gap-2">
</select> <Button variant="ghost" size="sm" disabled={busy} onClick={close}>Cancel</Button>
</label>} <Button variant="destructive" size="sm" data-testid="chatgpt-confirm-disconnect" disabled={busy || !managedPresent} onClick={() => bridge && managed && void control("disconnect", () => bridge.disconnect(managed.id), managed.id)}>Disconnect account</Button>
{managed && <p className="mt-3 text-xs text-foreground">{managed.email || "ChatGPT account"}</p>} </div>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p> </div> : <>
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground"> {dialog.kind === "connect" ? <>
<h2 className="text-sm font-semibold text-foreground">Continue with ChatGPT</h2>
{status.accounts.length > 0 && <div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<select aria-label="Saved ChatGPT account" value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5 text-foreground">
<option value="">Add another account or workspace</option>
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
</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>
</> : managed && <>
<div data-testid="chatgpt-managed-identity" className="flex items-center gap-2 text-xs text-foreground">
{status.accounts.length > 1 && managedPresent && !busy ? <select aria-label="Manage ChatGPT account" value={managed.id} onChange={(event) => context.manage(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5">
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
</select> : <span>{managed.email || "ChatGPT account"}</span>}
</div>
<div className="mt-2 flex items-center" data-testid="chatgpt-account-actions">
<Button variant="ghost" size="xs" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => dialog.kind === "manage" && showDialog({ ...dialog, confirm: true })}>Disconnect account</Button>
</div>
</>}
<div className="mt-4 flex items-center gap-3 text-xs text-foreground" data-testid="chatgpt-global-background">
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); }
else if (bridge) void control("change", () => bridge.consent(enabled));
}} />
<span>{BACKGROUND_LABEL}<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span>
</div>
{dialog.kind === "manage" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>}
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a> <a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a>
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a> <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
</div> </div>
{(dialog.kind === "connect" || managed) && <div className="mt-4 flex items-start gap-3 text-xs text-foreground">
<Toggle label="Allow background use" checked={dialog.kind === "connect" ? background : managed!.background} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
if (dialog.kind === "connect") setBackground(enabled);
else if (managed && bridge) void control("change", () => bridge.consent(managed.id, enabled));
}} />
<span>Allow background use<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span>
</div>}
{!status.available && <p className="mt-3 text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.</p>} {!status.available && <p className="mt-3 text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.</p>}
{status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">Waiting for browser sign-in…</p>} {status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">Waiting for browser sign-in…</p>}
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</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"> <div className="mt-4 flex items-center justify-end gap-2" data-testid="chatgpt-account-footer">
<Button variant="ghost" size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? "Cancel" : "Close"}</Button> <Button variant="ghost" size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? "Cancel" : "Close"}</Button>
{dialog.kind === "connect" && <ContinueButton onClick={() => void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />} {dialog.kind === "connect" && <ContinueButton onClick={() => void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />}
{managed && <Button variant="outline" size="sm" data-testid="chatgpt-disconnect" disabled={busy || status.pending} onClick={() => bridge && void control("logout", () => bridge.logout(managed.id))}>Disconnect</Button>}
</div> </div>
</>} </>}
</AccountDialog>} </AccountDialog>}
@@ -293,7 +349,7 @@ export function ChatGPTConnection({ setup = false }: { setup?: boolean }) {
const accounts = ui.status.accounts.filter((a) => a.connected); const accounts = ui.status.accounts.filter((a) => a.connected);
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection"> return <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{!setup && <span className="font-ui text-xs text-foreground">ChatGPT</span>} {!setup && active && <span className="font-ui text-xs text-foreground">ChatGPT</span>}
{active && !setup ? <> {active && !setup ? <>
<select aria-label="ChatGPT account" value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground"> <select aria-label="ChatGPT account" value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground">
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)} {accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
@@ -310,6 +366,6 @@ export function ChatGPTConnection({ setup = false }: { setup?: boolean }) {
export function ChatGPTBackgroundAccess({ provider }: { provider: string | undefined }) { export function ChatGPTBackgroundAccess({ provider }: { provider: string | undefined }) {
const ui = useContext(Context); const ui = useContext(Context);
const account: Account | undefined = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected); const account: Account | undefined = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
if (provider !== "chatgpt" || !account || account.background) return null; if (provider !== "chatgpt" || !account || ui.status.allowBackgroundUse) return null;
return <Button type="button" size="xs" variant="ghost" className="cursor-pointer" disabled={ui.busy} onClick={() => ui.manage(account.id)}>Enable background use</Button>; return <Button type="button" size="xs" variant="ghost" className="cursor-pointer" disabled={ui.busy} onClick={() => ui.manage(account.id)}>Enable background use</Button>;
} }
+16 -21
View File
@@ -15,8 +15,8 @@
* a paste box on a key the UI cannot make live would mislead. * a paste box on a key the UI cannot make live would mislead.
* - OpenRouter is NOT special: one provider among 33, a row only when * - OpenRouter is NOT special: one provider among 33, a row only when
* stored, otherwise it lives in the Add dropdown like everyone else. * stored, otherwise it lives in the Add dropdown like everyone else.
* - Tools & services (Deepgram, the Google pair) keep their own group, * - Providers groups API keys, subscriptions, dictation and search.
* same look, same interaction. * Unknown variables stay masked/read-only at the end of Model API keys.
* *
* API: GET /api/keys (masked statuses + the providers array), PUT * API: GET /api/keys (masked statuses + the providers array), PUT
* /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take * /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take
@@ -413,7 +413,7 @@ export function KeysSection({
return ( return (
<> <>
<SettingsGroup label="Model providers" testid="group-provider-keys"> <SettingsGroup label="Model API keys" testid="group-provider-keys">
{providerRows.map((status) => ( {providerRows.map((status) => (
<KeyTableRow <KeyTableRow
key={status.id} key={status.id}
@@ -447,11 +447,20 @@ export function KeysSection({
<AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} /> <AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
</div> </div>
)} )}
<div className="mt-3 border-t border-border/60 pt-3" data-testid="group-chatgpt"><ChatGPTConnection /></div> {unknownRows.length > 0 && <div data-testid="group-unknown-keys">
{unknownRows.map((status) => <div key={status.id} data-testid={`unknown-${status.id}`} className="flex items-center justify-between gap-3 px-1 py-1.5 opacity-40">
<span className="font-mono text-xs text-foreground">{status.id}</span>
<span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span>
</div>)}
</div>}
</SettingsGroup> </SettingsGroup>
<SettingsGroup label="Tools & services" testid="group-tool-keys"> <SettingsGroup label="Subscriptions" testid="group-subscriptions">
{toolRows.map((status) => ( <div data-testid="group-chatgpt"><ChatGPTConnection /></div>
</SettingsGroup>
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
{toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => (
<KeyTableRow <KeyTableRow
key={status.id} key={status.id}
label={status.info?.label ?? status.id} label={status.info?.label ?? status.id}
@@ -469,22 +478,8 @@ export function KeysSection({
testid={`key-${status.id.toLowerCase()}`} testid={`key-${status.id.toLowerCase()}`}
/> />
))} ))}
</SettingsGroup> </SettingsGroup>)}
{unknownRows.length > 0 && (
<SettingsGroup label="Other stored variables" testid="group-unknown-keys">
{unknownRows.map((status) => (
<div
key={status.id}
data-testid={`unknown-${status.id}`}
className="flex items-center justify-between gap-3 px-1 py-1.5 opacity-40"
>
<span className="font-mono text-xs text-foreground">{status.id}</span>
<span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span>
</div>
))}
</SettingsGroup>
)}
</> </>
); );
} }
+4 -4
View File
@@ -55,12 +55,12 @@ import { ChatGPTUIProvider } from "./chatgpt-connection.tsx";
* prose is out; only the keyboard note stays (storage location is a * prose is out; only the keyboard note stays (storage location is a
* real question users ask). */ * real question users ask). */
const SECTIONS = [ const SECTIONS = [
{ id: "keys", label: "Model providers" }, { id: "keys", label: "Providers" },
{ id: "general", label: "General" },
{ id: "agent", label: "Agent" }, { id: "agent", label: "Agent" },
{ id: "appearance", label: "Appearance" }, { id: "appearance", label: "Appearance" },
{ id: "graph", label: "Graph" }, { id: "graph", label: "Graph" },
{ id: "keyboard", label: "Keyboard", hint: "Stored on this device" }, { id: "keyboard", label: "Keyboard", hint: "Stored on this device" },
{ id: "general", label: "General" },
] as const; ] as const;
type SectionId = (typeof SECTIONS)[number]["id"]; type SectionId = (typeof SECTIONS)[number]["id"];
@@ -87,7 +87,7 @@ export function SettingsDialogProvider({
const [landOn, setLandOn] = useState<SectionId>("keys"); const [landOn, setLandOn] = useState<SectionId>("keys");
const [landOnRow, setLandOnRow] = useState<string | null>(null); const [landOnRow, setLandOnRow] = useState<string | null>(null);
const open = useCallback((section?: string, row?: string) => { const open = useCallback((section?: string, row?: string) => {
if (section) setLandOn(section as SectionId); setLandOn(section ? section as SectionId : "keys");
setLandOnRow(row ?? null); setLandOnRow(row ?? null);
setShow(true); setShow(true);
}, []); }, []);
@@ -377,7 +377,7 @@ export function SettingsDialogBody({
</SettingsSection> </SettingsSection>
)} )}
{active === "keys" && ( {active === "keys" && (
<SettingsSection title="Model providers" testid="section-keys"> <SettingsSection title="Providers" testid="section-keys">
<KeysSection initialRow={initialRow ?? undefined} /> <KeysSection initialRow={initialRow ?? undefined} />
</SettingsSection> </SettingsSection>
)} )}
+2 -1
View File
@@ -34,8 +34,9 @@ async function chatGPTControl(command, connection) {
if (command?.op === "login" && (command.id === undefined || typeof command.id === "string")) return await connection.login(command.id); if (command?.op === "login" && (command.id === undefined || typeof command.id === "string")) return await connection.login(command.id);
if (command?.op === "cancel") return connection.cancelLogin(); if (command?.op === "cancel") return connection.cancelLogin();
if (command?.op === "select" && typeof command.id === "string") return await connection.select(command.id); if (command?.op === "select" && typeof command.id === "string") return await connection.select(command.id);
if (command?.op === "disconnect" && typeof command.id === "string") return await connection.disconnect(command.id);
if (command?.op === "logout" && typeof command.id === "string") return await connection.logout(command.id); if (command?.op === "logout" && typeof command.id === "string") return await connection.logout(command.id);
if (command?.op === "consent" && typeof command.id === "string" && typeof command.enabled === "boolean") return await connection.consent(command.id, command.enabled); if (command?.op === "consent" && typeof command.enabled === "boolean") return await connection.consent(command.enabled);
return { error: "Unsupported ChatGPT control." }; return { error: "Unsupported ChatGPT control." };
} catch (err) { return { error: publicError(err) }; } } catch (err) { return { error: publicError(err) }; }
} }
+88 -45
View File
@@ -13,6 +13,7 @@ const AUTHORIZE = `${ISSUER}/api/accounts/authorize`;
const TOKEN = `${ISSUER}/api/accounts/oauth/token`; const TOKEN = `${ISSUER}/api/accounts/oauth/token`;
const JWKS = `${ISSUER}/.well-known/jwks.json`; const JWKS = `${ISSUER}/.well-known/jwks.json`;
const SCOPES = ["openid", "profile", "email", "offline_access", "resource.invoke", "chatgpt.tokens.use.direct"]; const SCOPES = ["openid", "profile", "email", "offline_access", "resource.invoke", "chatgpt.tokens.use.direct"];
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
const random = () => crypto.randomBytes(32).toString("base64url"); const random = () => crypto.randomBytes(32).toString("base64url");
const fail = (code) => Object.assign(new Error(code), { code }); const fail = (code) => Object.assign(new Error(code), { code });
const publicError = (err) => ({ const publicError = (err) => ({
@@ -22,10 +23,10 @@ const publicError = (err) => ({
storage: "ChatGPT credentials could not be saved securely. Reconnect before making requests.", storage: "ChatGPT credentials could not be saved securely. Reconnect before making requests.",
identity: "ChatGPT sign-in could not be verified. Select the intended saved account and try again.", identity: "ChatGPT sign-in could not be verified. Select the intended saved account and try again.",
scope: "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.", scope: "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.",
disconnected: "This ChatGPT connection is disconnected. Reconnect in Model providers.", disconnected: "This ChatGPT connection is disconnected. Reconnect in Providers.",
background: "Background use is off for this ChatGPT account. Enable Allow background use in Model providers, or explicitly select an API-key model for that workload.", background: "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers, or explicitly select an API-key model for that workload.",
catalog: "The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.", catalog: "The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.",
expired: "The ChatGPT session expired. Reconnect in Model providers.", expired: "The ChatGPT session expired. Reconnect in Providers.",
network: "ChatGPT could not be reached. Check your connection and try again.", network: "ChatGPT could not be reached. Check your connection and try again.",
pending: "A ChatGPT sign-in is already pending.", pending: "A ChatGPT sign-in is already pending.",
unavailable: "ChatGPT sign-in is unavailable in browser-only mode.", unavailable: "ChatGPT sign-in is unavailable in browser-only mode.",
@@ -36,9 +37,10 @@ const planScopes = (tokens) => !!tokens?.accessToken && SCOPES.every((scope) =>
/** Reject the entire encrypted record. Encryption is not schema validation. */ /** Reject the entire encrypted record. Encryption is not schema validation. */
function validateState(state) { function validateState(state) {
if (!state || state.version !== 1 || !nonempty(state.hostId) || !Array.isArray(state.registrations) || !(state.activeId === null || nonempty(state.activeId))) throw fail("storage"); if (!state || state.version !== 1 || !nonempty(state.hostId) || !Array.isArray(state.registrations) || !(state.activeId === null || nonempty(state.activeId))) throw fail("storage");
if (Object.hasOwn(state, "allowBackgroundUse") && typeof state.allowBackgroundUse !== "boolean") throw fail("storage");
const ids = new Set(); const clients = new Set(); const ids = new Set(); const clients = new Set();
for (const r of state.registrations) { for (const r of state.registrations) {
if (!r || !nonempty(r.id) || ids.has(r.id) || !nonempty(r.clientId) || r.clientId === "dynamic_agent_client" || clients.has(r.clientId) || r.issuer !== ISSUER || !nonempty(r.subject) || typeof r.background !== "boolean" || (r.email !== undefined && typeof r.email !== "string") || (Object.hasOwn(r, "reconnectRequired") && r.reconnectRequired !== true)) throw fail("storage"); if (!r || !nonempty(r.id) || ids.has(r.id) || !nonempty(r.clientId) || r.clientId === "dynamic_agent_client" || clients.has(r.clientId) || r.issuer !== ISSUER || !nonempty(r.subject) || (Object.hasOwn(r, "background") && typeof r.background !== "boolean") || (r.email !== undefined && typeof r.email !== "string") || (Object.hasOwn(r, "reconnectRequired") && r.reconnectRequired !== true)) throw fail("storage");
ids.add(r.id); clients.add(r.clientId); ids.add(r.id); clients.add(r.clientId);
if (r.tokens !== undefined) { if (r.tokens !== undefined) {
const t = r.tokens; const t = r.tokens;
@@ -115,7 +117,7 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
let storageDenied = false; let storageDenied = false;
let secureAvailable = true; let secureAvailable = true;
const stamp = () => ({ epoch, authorityRevision, revision }); const stamp = () => ({ epoch, authorityRevision, revision });
const backgroundIntent = new Map(); let backgroundIntent;
let pending; let pending;
let catalog = []; let catalog = [];
let catalogError = null; let catalogError = null;
@@ -132,7 +134,17 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
return next; return next;
}; };
const active = () => !storageDenied && !suspended && !closed && !paused ? state?.registrations.find((r) => r.id === state.activeId && r.tokens && !r.reconnectRequired) : undefined; const active = () => !storageDenied && !suspended && !closed && !paused ? state?.registrations.find((r) => r.id === state.activeId && r.tokens && !r.reconnectRequired) : undefined;
const backgroundAllowed = (account) => account.background === true && backgroundIntent.get(account.id)?.enabled !== false; // These fields are validated/verified before storage. Email is never identity.
const sameAccount = (a, b) => a.issuer === b.issuer && a.subject === b.subject;
function accountGroups() {
const groups = [];
for (const record of state?.registrations || []) {
const group = groups.find((members) => sameAccount(members[0], record));
if (group) group.push(record); else groups.push([record]);
}
return groups;
}
const backgroundAllowed = () => state?.allowBackgroundUse !== false && backgroundIntent?.enabled !== false;
const notify = (invalidate = "none") => { revision++; onChange({ channel: "lh:chatgpt", kind: "state", stamp: stamp(), status: snapshot(), invalidate }); }; const notify = (invalidate = "none") => { revision++; onChange({ channel: "lh:chatgpt", kind: "state", stamp: stamp(), status: snapshot(), invalidate }); };
function stop(backgroundOnly = false) { function stop(backgroundOnly = false) {
if (backgroundOnly) { if (backgroundOnly) {
@@ -185,15 +197,21 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
return { return {
available: !!state && secureAvailable && !closed && !paused, available: !!state && secureAvailable && !closed && !paused,
pending: !!pending, pending: !!pending,
allowBackgroundUse: backgroundAllowed(),
activeId: state?.activeId ?? null, activeId: state?.activeId ?? null,
accounts: (state?.registrations || []).map((r) => ({ accounts: accountGroups().map((members) => {
id: r.id, // Preserve the actual active registration ID at the private transport
email: r.email || "ChatGPT account", // boundary; inactive groups prefer a usable registration, then a
registration: r.clientId, // remembered one. No credential/client records are merged or deleted.
connected: !storageDenied && !closed && !paused && !suspended && !r.reconnectRequired && planScopes(r.tokens), const r = members.find((r) => r.id === state.activeId) ?? members.find((r) => !r.reconnectRequired && planScopes(r.tokens)) ?? members[0];
planEnabled: !storageDenied && !r.reconnectRequired && planScopes(r.tokens), return {
background: backgroundAllowed(r), id: r.id,
})), email: r.email || "ChatGPT account",
registration: r.clientId,
connected: !storageDenied && !closed && !paused && !suspended && !r.reconnectRequired && planScopes(r.tokens),
planEnabled: !storageDenied && !r.reconnectRequired && planScopes(r.tokens),
};
}),
error: lastError, error: lastError,
catalogError, catalogError,
}; };
@@ -308,7 +326,7 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
if (closed || abort.signal.aborted || epoch !== attemptEpoch) throw fail("cancelled"); if (closed || abort.signal.aborted || epoch !== attemptEpoch) throw fail("cancelled");
const existing = state.registrations.find((r) => r.clientId === clientId); const existing = state.registrations.find((r) => r.clientId === clientId);
if (existing && existing.subject !== verified.subject) throw fail("identity"); if (existing && existing.subject !== verified.subject) throw fail("identity");
const record = { id: existing?.id ?? crypto.randomUUID(), clientId, issuer: ISSUER, ...verified, background: existing?.background === true, tokens }; const record = { id: existing?.id ?? crypto.randomUUID(), clientId, issuer: ISSUER, ...verified, tokens };
const next = structuredClone(state); const next = structuredClone(state);
next.registrations = next.registrations.filter((r) => r.id !== record.id).concat(record); next.registrations = next.registrations.filter((r) => r.id !== record.id).concat(record);
next.activeId = record.id; next.activeId = record.id;
@@ -352,7 +370,7 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
if (!account || (capturedEpoch !== undefined && capturedEpoch !== epoch)) throw fail("disconnected"); if (!account || (capturedEpoch !== undefined && capturedEpoch !== epoch)) throw fail("disconnected");
if (!planScopes(account.tokens)) throw fail("scope"); if (!planScopes(account.tokens)) throw fail("scope");
if (expected && (expected.expectedAccountId !== account.id || expected.expectedAuthorityRevision !== authorityRevision || !catalog.some((m) => m.id === expected.selectedModelId))) throw fail("catalog"); if (expected && (expected.expectedAccountId !== account.id || expected.expectedAuthorityRevision !== authorityRevision || !catalog.some((m) => m.id === expected.selectedModelId))) throw fail("catalog");
if (background && !backgroundAllowed(account)) throw fail("background"); if (background && !backgroundAllowed()) throw fail("background");
return account; return account;
} }
async function credential(background, expected) { async function credential(background, expected) {
@@ -450,23 +468,62 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
return snapshot(); return snapshot();
}); });
} }
async function consent(id, enabled) { async function consent(enabled) {
if (typeof enabled !== "boolean") throw fail("identity"); if (typeof enabled !== "boolean") throw fail("identity");
const intent = { enabled: false, version: (backgroundIntent.get(id)?.version ?? 0) + 1 }; // One app-owned intent, independent of account/registration lifetime.
backgroundIntent.set(id, intent); const intent = { enabled: false };
if (!enabled) { backgroundIntent = intent; // ON cannot grant until its durable save.
const account = state?.registrations.find((r) => r.id === id); if (!enabled) stop(true); // Revoke all ChatGPT background streams only.
if (account) account.background = false;
stop(true); // Revoke background calls immediately; foreground stays usable.
}
await ready(); await ready();
return serial(async () => { return serial(async () => {
await save({ ...structuredClone(state), allowBackgroundUse: enabled });
if (backgroundIntent === intent && enabled) backgroundIntent = undefined;
notify();
return snapshot();
});
}
async function revoke(account) {
let revoked = false;
try {
if (account.tokens?.refreshToken) {
const config = await json(`${ISSUER}/.well-known/openid-configuration`);
if (config.issuer !== ISSUER || !allowedAuthEndpoint(config.revocation_endpoint)) throw fail("identity");
for (let attempt = 0; attempt < 2; attempt++) {
if (attempt) await new Promise((resolve) => setTimeout(resolve, 200));
try {
const response = await request(config.revocation_endpoint, { method: "POST", redirect: "error", signal: AbortSignal.timeout(5000), headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token: account.tokens.refreshToken, token_type_hint: "refresh_token", client_id: account.clientId }) });
revoked = response.status === 200;
if (revoked || response.status < 500) break;
} catch { /* retry only while the refresh token is still in memory */ }
}
}
} catch { /* local action is mandatory even when revocation is unavailable */ }
return revoked;
}
async function disconnect(id) {
// Explicit local forgetting: a visible account represents every retained
// registration with this exact verified issuer/subject, never its email.
pending?.cancel();
const initial = state?.registrations.find((r) => r.id === id);
let stopped = !!initial && state.registrations.some((r) => r.id === state.activeId && sameAccount(r, initial));
if (stopped) stop();
await ready();
return serial(async () => {
const target = state.registrations.find((r) => r.id === id);
if (!target) throw fail("identity");
const members = state.registrations.filter((r) => sameAccount(r, target));
const activeGroup = members.some((r) => r.id === state.activeId);
if (activeGroup && !stopped) { stop(); stopped = true; }
const disconnectEpoch = epoch;
let revoked = true;
for (const member of members) { const confirmed = await revoke(member); revoked = confirmed && revoked; }
const next = structuredClone(state); const next = structuredClone(state);
const account = next.registrations.find((r) => r.id === id); next.registrations = next.registrations.filter((r) => !sameAccount(r, target));
if (!account) throw fail("identity"); if (activeGroup) next.activeId = null; // Never silently select another account.
account.background = enabled;
await save(next); await save(next);
if (backgroundIntent.get(id) === intent && enabled) backgroundIntent.delete(id); if (members.some((r) => r.clientId === retryRegistration?.clientId)) retryRegistration = undefined;
if (stopped && !closed && epoch === disconnectEpoch) suspended = false;
lastError = revoked ? null : REVOCATION_WARNING;
notify(); notify();
return snapshot(); return snapshot();
}); });
@@ -478,28 +535,14 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
return serial(async () => { return serial(async () => {
const account = state.registrations.find((r) => r.id === id); const account = state.registrations.find((r) => r.id === id);
if (!account) throw fail("identity"); if (!account) throw fail("identity");
let revoked = false; const revoked = await revoke(account);
try {
if (account.tokens?.refreshToken) {
const config = await json(`${ISSUER}/.well-known/openid-configuration`);
if (config.issuer !== ISSUER || !allowedAuthEndpoint(config.revocation_endpoint)) throw fail("identity");
for (let attempt = 0; attempt < 2; attempt++) {
if (attempt) await new Promise((resolve) => setTimeout(resolve, 200));
try {
const response = await request(config.revocation_endpoint, { method: "POST", redirect: "error", signal: AbortSignal.timeout(5000), headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token: account.tokens.refreshToken, token_type_hint: "refresh_token", client_id: account.clientId }) });
revoked = response.status === 200;
if (revoked || response.status < 500) break;
} catch { /* retry only while the refresh token is still in memory */ }
}
}
} catch { /* local sign-out is mandatory even when revocation is unavailable */ }
const next = structuredClone(state); const next = structuredClone(state);
delete next.registrations.find((r) => r.id === id).tokens; delete next.registrations.find((r) => r.id === id).tokens;
if (next.activeId === id) next.activeId = null; if (next.activeId === id) next.activeId = null;
const logoutEpoch = epoch; const logoutEpoch = epoch;
await save(next); await save(next);
if (!closed && epoch === logoutEpoch) suspended = false; if (!closed && epoch === logoutEpoch) suspended = false;
lastError = revoked ? null : "Signed out locally. Remote revocation was not confirmed; disconnect LearnHarness in ChatGPT Settings."; lastError = revoked ? null : REVOCATION_WARNING;
notify(); notify();
return snapshot(); return snapshot();
}); });
@@ -507,7 +550,7 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
function close() { closed = true; pending?.cancel(); stop(); } function close() { closed = true; pending?.cancel(); stop(); }
function pause() { paused = true; pending?.cancel(); stop(); notify(); } function pause() { paused = true; pending?.cancel(); stop(); notify(); }
function resume() { paused = false; if (!storageDenied) suspended = false; notify(); } function resume() { paused = false; if (!storageDenied) suspended = false; notify(); }
return { status, login, cancelLogin, lease, models, select, consent, logout, close, pause, resume, stamp }; return { status, login, cancelLogin, lease, models, select, consent, disconnect, logout, close, pause, resume, stamp };
} }
module.exports = { createChatGPT, encryptedStore, publicError, allowedAuthEndpoint, SCOPES, validateState }; module.exports = { createChatGPT, encryptedStore, publicError, allowedAuthEndpoint, SCOPES, validateState };
+2 -1
View File
@@ -34,8 +34,9 @@ contextBridge.exposeInMainWorld("lh", {
login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }), login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }),
cancel: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "cancel" }), cancel: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "cancel" }),
select: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "select", id }), select: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "select", id }),
disconnect: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "disconnect", id }),
logout: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "logout", id }), logout: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "logout", id }),
consent: (id, enabled) => ipcRenderer.invoke("lh:chatgpt:control", { op: "consent", id, enabled }), consent: (enabled) => ipcRenderer.invoke("lh:chatgpt:control", { op: "consent", enabled }),
onChange: (cb) => { onChange: (cb) => {
const listener = () => cb(); const listener = () => cb();
ipcRenderer.on("lh:chatgpt:changed", listener); ipcRenderer.on("lh:chatgpt:changed", listener);
+2 -2
View File
@@ -12,7 +12,7 @@ interface BridgeState {
pending: Map<string, Pending>; controllers: Map<AbortController, boolean>; listeners: Set<() => void>; counter: number; catalogRequest: number; pending: Map<string, Pending>; controllers: Map<AbortController, boolean>; listeners: Set<() => void>; counter: number; catalogRequest: number;
} }
const key = Symbol.for("lh.chatgpt.bridge"); const key = Symbol.for("lh.chatgpt.bridge");
const unavailable = (): ChatGPTStatus => ({ available: false, pending: false, activeId: null, accounts: [], error: null }); const unavailable = (): ChatGPTStatus => ({ available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null });
const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision].every((v) => Number.isSafeInteger(v) && v >= 0); const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision].every((v) => Number.isSafeInteger(v) && v >= 0);
function bridge(): BridgeState { function bridge(): BridgeState {
const global = globalThis as typeof globalThis & { [key]?: BridgeState }; const global = globalThis as typeof globalThis & { [key]?: BridgeState };
@@ -69,7 +69,7 @@ export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Rec
} }
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; } export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); } export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return 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.accounts.some((a) => a.id === s.activeId && a.background); } 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 onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; } export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
export function chatGPTModel(id: string): Model<"openai-responses"> | undefined { return chatGPTModels().find((m) => m.id === id); } export function chatGPTModel(id: string): Model<"openai-responses"> | undefined { return chatGPTModels().find((m) => m.id === id); }
+4 -4
View File
@@ -8,12 +8,12 @@ import type { AdmissionScope, ChatGPTDispatchLifecycle } from "./chatgpt-request
const USAGE = "https://chatgpt.com/settings/usage"; const USAGE = "https://chatgpt.com/settings/usage";
export function chatGPTStreamError(message: string): string { export function chatGPTStreamError(message: string): string {
if (/subscription_sharing_usage_limit_exceeded|quota|429/.test(message)) return `ChatGPT plan usage limit reached. Manage usage at ${USAGE}. No API-key fallback was used.`; if (/subscription_sharing_usage_limit_exceeded|quota|429/.test(message)) return `ChatGPT plan usage limit reached. Manage usage at ${USAGE}. No API-key fallback was used.`;
if (/scope|authorization_context|permission/.test(message)) return "ChatGPT plan permission is missing. Reconnect in Connections and authorize plan use. No API-key fallback was used."; if (/scope|authorization_context|permission/.test(message)) return "ChatGPT plan permission is missing. Reconnect in Providers and authorize plan use. No API-key fallback was used.";
if (/expired|invalid_grant|401|invalid_user/.test(message)) return "Your ChatGPT session expired. Reconnect in Connections. No API-key fallback was used."; if (/expired|invalid_grant|401|invalid_user/.test(message)) return "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.";
if (/background|Background/.test(message)) return "Background use is off for this ChatGPT account. Enable Allow background use in Connections to run this work. No API-key fallback was used."; if (/background|Background/.test(message)) return "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.";
if (/eligible|403/.test(message)) return "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility."; if (/eligible|403/.test(message)) return "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.";
if (/cancel|abort|closed|disconnect/i.test(message)) return "ChatGPT request cancelled or connection closed. No API-key fallback was used."; if (/cancel|abort|closed|disconnect/i.test(message)) return "ChatGPT request cancelled or connection closed. No API-key fallback was used.";
if (/catalog|model/.test(message)) return "The selected model is unavailable for this ChatGPT account. Refresh Connections and explicitly choose an available model. No API-key fallback was used."; if (/catalog|model/.test(message)) return "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.";
if (/incomplete|terminal|interrupted|stop reason/.test(message)) return "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used."; if (/incomplete|terminal|interrupted|stop reason/.test(message)) return "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.";
return "ChatGPT request failed. Check your connection and ChatGPT usage, then retry. No API-key fallback was used."; return "ChatGPT request failed. Check your connection and ChatGPT usage, then retry. No API-key fallback was used.";
} }
+6 -2
View File
@@ -2,8 +2,10 @@
export interface ChatGPTStatus { export interface ChatGPTStatus {
available: boolean; available: boolean;
pending: boolean; pending: boolean;
/** App-wide ChatGPT background permission, independent of account selection. */
allowBackgroundUse: boolean;
activeId: string | null; activeId: string | null;
accounts: { id: string; email: string; registration: string; connected: boolean; background: boolean; planEnabled?: boolean }[]; accounts: { id: string; email: string; registration: string; connected: boolean; planEnabled?: boolean }[];
error: string | null; error: string | null;
catalogError?: string | null; catalogError?: string | null;
} }
@@ -12,7 +14,9 @@ export interface ChatGPTControlBridge {
login(id?: string): Promise<ChatGPTStatus>; login(id?: string): Promise<ChatGPTStatus>;
cancel(): Promise<ChatGPTStatus>; cancel(): Promise<ChatGPTStatus>;
select(id: string): Promise<ChatGPTStatus>; select(id: string): Promise<ChatGPTStatus>;
/** Explicitly forget every local registration/credential for this verified identity. */
disconnect(id: string): Promise<ChatGPTStatus>;
logout(id: string): Promise<ChatGPTStatus>; logout(id: string): Promise<ChatGPTStatus>;
consent(id: string, enabled: boolean): Promise<ChatGPTStatus>; consent(enabled: boolean): Promise<ChatGPTStatus>;
onChange(callback: () => void): () => void; onChange(callback: () => void): () => void;
} }
+1 -1
View File
@@ -209,7 +209,7 @@ export async function handleChatInput(
if (conversation.provider === "chatgpt") { if (conversation.provider === "chatgpt") {
await refreshConnectionCatalog(); await refreshConnectionCatalog();
if (input.type === "steers" && !chatGPTBackgroundAllowed()) { if (input.type === "steers" && !chatGPTBackgroundAllowed()) {
return Response.json({ error: "Background use is off for this ChatGPT account. Automatic follow-up remains deferred in its queue." }, { status: 403 }); return Response.json({ error: "ChatGPT background use is off in this app. Automatic follow-up remains deferred in its queue." }, { status: 403 });
} }
} }
if (!providerKeyPresent(conversation.provider)) { if (!providerKeyPresent(conversation.provider)) {
+179 -10
View File
@@ -82,12 +82,15 @@ test("PKCE/state/nonce/host identity and exact ephemeral 127.0.0.1 callback; no
assert.equal(createHash("sha256").update(form.get("code_verifier")!).digest("base64url"), params.get("code_challenge")); assert.equal(createHash("sha256").update(form.get("code_verifier")!).digest("base64url"), params.get("code_challenge"));
assert.equal(form.get("resource"), "https://api.openai.com/v1"); assert.equal(form.get("resource"), "https://api.openai.com/v1");
const status = await f.service.status(); const status = await f.service.status();
assert.equal(status.accounts[0].background, false); assert.equal(status.allowBackgroundUse, true);
assert.equal(f.saved.registrations[0].background, undefined, "new registrations do not store obsolete per-account preferences");
assert.equal(status.activeId, f.saved.activeId); assert.equal(status.activeId, f.saved.activeId);
assert(!JSON.stringify(status).includes("scratch-oauth-token")); assert(!JSON.stringify(status).includes("scratch-oauth-token"));
assert(!JSON.stringify(status).includes("scratch-refresh")); assert(!JSON.stringify(status).includes("scratch-refresh"));
assert(!JSON.stringify(status).includes("idToken")); assert(!JSON.stringify(status).includes("idToken"));
assert.equal((await f.service.lease(false)).accessToken, "scratch-oauth-token"); assert.equal((await f.service.lease(false)).accessToken, "scratch-oauth-token");
assert.equal((await f.service.lease(true)).accessToken, "scratch-oauth-token");
await f.service.consent(false);
await assert.rejects(f.service.lease(true), /background/); await assert.rejects(f.service.lease(true), /background/);
} finally { f.service.close(); } } finally { f.service.close(); }
}); });
@@ -243,7 +246,7 @@ test("invalid rotating replacement is durably revocation-only; verified mapping
try { try {
await restarted.service.status(); await restarted.service.status();
await restarted.service.select(restricted.id); await restarted.service.select(restricted.id);
restarted.service.resume(); await restarted.service.consent(restricted.id, true); restarted.service.resume(); await restarted.service.consent(true);
await assert.rejects(restarted.service.lease(false)); await assert.rejects(restarted.service.lease(false));
assert.equal((await restarted.service.status()).accounts[0].connected, false); assert.equal((await restarted.service.status()).accounts[0].connected, false);
assert.deepEqual((await restarted.service.models()).models, []); assert.deepEqual((await restarted.service.models()).models, []);
@@ -268,7 +271,7 @@ test("detected secure backend loss emits full invalidation, denies all leases an
assert.equal((await f.service.status()).available, false); assert.equal((await f.service.status()).available, false);
assert(pushes.some((p) => p.invalidate === "all" && p.stamp.epoch > epoch)); assert(pushes.some((p) => p.invalidate === "all" && p.stamp.epoch > epoch));
await assert.rejects(f.service.lease(false)); await assert.rejects(f.service.lease(true)); await assert.rejects(f.service.lease(false)); await assert.rejects(f.service.lease(true));
lost = false; f.service.resume(); await f.service.consent(f.saved.activeId, true); lost = false; f.service.resume(); await f.service.consent(true);
await assert.rejects(f.service.lease(false)); await assert.rejects(f.service.lease(false));
assert.equal((await f.service.status()).accounts[0].connected, false); assert.equal((await f.service.status()).accounts[0].connected, false);
} finally { f.service.close(); } } finally { f.service.close(); }
@@ -280,7 +283,7 @@ test("strict encrypted schema rejects malformed marker/duplicates/token/active i
const file = path.join(dir, "credentials.enc.json"); const file = path.join(dir, "credentials.enc.json");
const secure = { isEncryptionAvailable: () => true, encryptString: (s: string) => Buffer.from(s), decryptString: (b: Buffer) => b.toString() }; const secure = { isEncryptionAvailable: () => true, encryptString: (s: string) => Buffer.from(s), decryptString: (b: Buffer) => b.toString() };
try { try {
for (const mutate of [(s: any) => s.registrations[0].reconnectRequired = false, (s: any) => s.registrations[0].reconnectRequired = "true", (s: any) => s.registrations.push(s.registrations[0]), (s: any) => s.activeId = "missing", (s: any) => s.registrations[0].tokens.scopes = "not-array", (s: any) => s.registrations[0].tokens.expiresAt = -1]) { for (const mutate of [(s: any) => s.registrations[0].reconnectRequired = false, (s: any) => s.registrations[0].reconnectRequired = "true", (s: any) => s.registrations.push(s.registrations[0]), (s: any) => s.activeId = "missing", (s: any) => s.registrations[0].tokens.scopes = "not-array", (s: any) => s.registrations[0].tokens.expiresAt = -1, (s: any) => s.allowBackgroundUse = "true", (s: any) => s.allowBackgroundUse = null, (s: any) => s.registrations[0].background = "false"]) {
const state = structuredClone(f.saved); mutate(state); const state = structuredClone(f.saved); mutate(state);
const bytes = JSON.stringify({ version: 1, encrypted: Buffer.from(JSON.stringify(state)).toString("base64") }); const bytes = JSON.stringify({ version: 1, encrypted: Buffer.from(JSON.stringify(state)).toString("base64") });
await fs.writeFile(file, bytes); await fs.writeFile(file, bytes);
@@ -293,15 +296,15 @@ test("strict encrypted schema rejects malformed marker/duplicates/token/active i
test("consent OFF wins an older ON save; foreground catalog authority remains unchanged", async () => { test("consent OFF wins an older ON save; foreground catalog authority remains unchanged", async () => {
let hold = false; const entered = deferred(); const release = deferred(); let hold = false; const entered = deferred(); const release = deferred();
const f = await loggedIn({ write: async (next) => { if (hold && next.registrations[0]?.background) { entered.resolve(); await release.promise; } } }); const f = await loggedIn({ write: async (next) => { if (hold && next.allowBackgroundUse === true) { entered.resolve(); await release.promise; } } });
try { try {
const catalog = await f.service.models(); const id = f.saved.activeId; const catalog = await f.service.models(); const id = f.saved.activeId;
hold = true; hold = true;
const on = f.service.consent(id, true); await entered.promise; const on = f.service.consent(true); await entered.promise;
const off = f.service.consent(id, false); const off = f.service.consent(false);
assert.equal((await f.service.status()).accounts[0].background, false); assert.equal((await f.service.status()).allowBackgroundUse, false);
release.resolve(); await on; await off; release.resolve(); await on; await off;
assert.equal(f.saved.registrations[0].background, false); assert.equal(f.saved.allowBackgroundUse, false);
assert.equal(f.service.stamp().authorityRevision, catalog.authorityRevision); assert.equal(f.service.stamp().authorityRevision, catalog.authorityRevision);
assert.equal((await f.rawLease(false, { expectedAccountId: id, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" })).accountId, id); assert.equal((await f.rawLease(false, { expectedAccountId: id, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" })).accountId, id);
await assert.rejects(f.rawLease(true, { expectedAccountId: id, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" }), /background/); await assert.rejects(f.rawLease(true, { expectedAccountId: id, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" }), /background/);
@@ -325,12 +328,178 @@ test("scope-disabled credentials remain revocable and unconfirmed revoke never c
try { try {
await assert.rejects(f.service.lease(false)); await assert.rejects(f.service.lease(false));
const status = await f.service.logout(f.saved.activeId); const status = await f.service.logout(f.saved.activeId);
assert.match(status.error, /Remote revocation was not confirmed/); assert.equal(status.error, "Account disconnected locally. Remote revocation could not be confirmed.");
assert.equal(f.requests.filter((r) => r.form?.get("token") === "scope-disabled-refresh").length, 2); assert.equal(f.requests.filter((r) => r.form?.get("token") === "scope-disabled-refresh").length, 2);
assert.equal(f.saved.registrations[0].tokens, undefined); assert.equal(f.saved.registrations[0].tokens, undefined);
} finally { f.service.close(); } } finally { f.service.close(); }
}); });
test("repeated real verified sign-in creates one visible account while preserving separately bound registrations until explicit forgetting", async () => {
const f = await loggedIn();
try {
const original = f.saved.registrations[0];
await f.service.login(); await f.callback({ client_id: "oaiapp_repeat" });
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
const status = await f.service.status();
assert.equal(f.saved.registrations.length, 2); assert.equal(status.accounts.length, 1);
assert.equal(status.activeId, status.accounts[0].id); assert.notEqual(status.activeId, original.id);
assert.equal(f.saved.registrations[0].clientId, original.clientId); assert.equal(f.saved.registrations[1].clientId, "oaiapp_repeat");
await f.service.disconnect(status.activeId);
assert.deepEqual(f.saved.registrations, []); assert.equal((await f.service.status()).accounts.length, 0);
assert.equal(f.requests.filter((r) => r.form?.has("token_type_hint")).length, 2);
} finally { f.service.close(); }
});
test("verified identity grouping hides legacy duplicate registrations without merging client credentials or matching emails", async () => {
const first = await loggedIn(); first.service.close();
const state = structuredClone(first.saved);
const original = state.registrations[0];
const duplicate = { ...structuredClone(original), id: "legacy-duplicate", clientId: "oaiapp_legacy" };
duplicate.tokens.idToken = await signed(duplicate.clientId, "legacy-nonce", original.subject);
const distinct = { ...structuredClone(original), id: "different-subject", clientId: "oaiapp_distinct", subject: "different-verified-subject" };
distinct.tokens.idToken = await signed(distinct.clientId, "legacy-nonce", distinct.subject);
state.registrations.push(duplicate, distinct);
state.activeId = duplicate.id;
const f = fixture({ state });
try {
const status = await f.service.status();
assert.equal(status.accounts.length, 2, "same issuer+subject groups; equal emails alone do not");
assert.equal(status.activeId, duplicate.id, "private transport keeps the actual active registration");
assert(status.accounts.some((a: any) => a.id === duplicate.id));
assert(status.accounts.some((a: any) => a.id === distinct.id));
assert.equal(f.saved.registrations.length, 3, "a status read never purges or merges stored credential handles");
assert.deepEqual(f.saved, state);
await f.service.select(distinct.id);
assert.equal((await f.service.status()).activeId, distinct.id);
assert.equal((await f.service.status()).accounts.length, 2);
} finally { f.service.close(); }
});
test("explicit disconnect forgets all exact-identity registrations/handles, aborts active leases, and never auto-selects a same-email distinct account", async () => {
const first = await loggedIn(); first.service.close();
const state = structuredClone(first.saved), original = state.registrations[0];
const duplicate = { ...structuredClone(original), id: "duplicate-client", clientId: "oaiapp_duplicate", tokens: { ...original.tokens, refreshToken: "duplicate-refresh" } };
duplicate.tokens.idToken = await signed(duplicate.clientId, "historical", original.subject);
const other = { ...structuredClone(original), id: "distinct-account", clientId: "oaiapp_other", subject: "other-subject" };
other.tokens.idToken = await signed(other.clientId, "historical", other.subject);
state.registrations.push(duplicate, other);
const pushes: any[] = [], entered = deferred(), release = deferred();
let held = false;
const f = fixture({ state, change: (push) => pushes.push(push), provider: async (url) => {
if (url.endsWith("/revoke") && !held) { held = true; entered.resolve(); await release.promise; }
return undefined;
} });
try {
await f.service.status(); const catalog = await f.service.models();
const expected = { expectedAccountId: state.activeId, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" };
await f.rawLease(false, expected);
const removing = f.service.disconnect(original.id); await entered.promise;
assert(pushes.some((push) => push.invalidate === "all"));
assert.equal((await f.service.status()).accounts.find((a: any) => a.id === original.id).connected, false);
release.resolve(); const result = await removing;
assert.equal(result.activeId, null); assert.equal(result.accounts.length, 1); assert.equal(result.accounts[0].id, other.id);
assert.deepEqual(f.saved.registrations, [other], "all duplicate handles gone; other verified subject untouched");
const revocations = f.requests.filter((r) => r.form?.has("token_type_hint"));
assert.equal(revocations.length, 2); assert(revocations.some((r) => r.form!.get("client_id") === duplicate.clientId && r.form!.get("token") === "duplicate-refresh"));
await assert.rejects(f.rawLease(false, expected));
await assert.rejects(f.service.select(original.id));
assert.equal(f.saved.activeId, null, "no automatic account/provider fallback");
} finally { release.resolve(); f.service.close(); }
});
test("disconnecting an inactive identity preserves the active identity/credentials/catalog authority", async () => {
const first = await loggedIn(); first.service.close();
const state = structuredClone(first.saved), original = state.registrations[0];
const other = { ...structuredClone(original), id: "inactive-account", clientId: "oaiapp_inactive", subject: "inactive-subject" };
other.tokens.idToken = await signed(other.clientId, "historical", other.subject);
state.registrations.push(other); const f = fixture({ state });
try {
await f.service.status(); const catalog = await f.service.models(); const before = f.service.stamp();
await f.service.disconnect(other.id);
assert.equal(f.saved.activeId, original.id); assert.deepEqual(f.saved.registrations, [original]);
assert.equal(f.service.stamp().epoch, before.epoch); assert.equal(f.service.stamp().authorityRevision, before.authorityRevision);
assert.equal((await f.rawLease(false, { expectedAccountId: original.id, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" })).accountId, original.id);
} finally { f.service.close(); }
});
test("signed-out legacy registrations can be explicitly forgotten; failed revocation never retains forgotten handles", async () => {
const first = await loggedIn(); await first.service.logout(first.saved.activeId); first.service.close();
const state = structuredClone(first.saved), duplicate = { ...structuredClone(state.registrations[0]), id: "signed-out-duplicate", clientId: "oaiapp_old" };
state.registrations.push(duplicate);
const f = fixture({ state });
try {
const status = await f.service.status(); assert.equal(status.accounts.length, 1); assert.equal(status.accounts[0].connected, false);
const result = await f.service.disconnect(status.accounts[0].id);
assert.equal(result.accounts.length, 0); assert.equal(result.activeId, null); assert.equal(f.saved.registrations.length, 0);
assert.equal(result.error, "Account disconnected locally. Remote revocation could not be confirmed.");
} finally { f.service.close(); }
const unconfirmed = await loggedIn({ provider: async (url) => url.endsWith("/revoke") ? new Response(null, { status: 400 }) : undefined });
try {
const result = await unconfirmed.service.disconnect(unconfirmed.saved.activeId);
assert.equal(result.error, "Account disconnected locally. Remote revocation could not be confirmed."); assert.deepEqual(unconfirmed.saved.registrations, []);
assert.equal(unconfirmed.saved.activeId, null); assert.equal(JSON.stringify(unconfirmed.saved).includes("scratch-refresh"), false);
} finally { unconfirmed.service.close(); }
});
test("disconnect racing refresh revokes the latest rotated handle before forgetting, never its stale predecessor", async () => {
const entered = deferred(), release = deferred();
const f = await loggedIn({ token: async (form, url) => {
if (form.get("grant_type") === "refresh_token") {
entered.resolve(); await release.promise;
return Response.json({ access_token: "latest-access", refresh_token: "latest-refresh", token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
}
return Response.json({ access_token: "scratch-access", refresh_token: "scratch-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") });
} });
try {
const lease = f.service.lease(false); const rejected = assert.rejects(lease); await entered.promise;
const forgetting = f.service.disconnect(f.saved.activeId); release.resolve(); await rejected; await forgetting;
const revoked = f.requests.filter((r) => r.form?.has("token_type_hint"));
assert.equal(revoked.length, 1); assert.equal(revoked[0]!.form!.get("token"), "latest-refresh");
assert.deepEqual(f.saved.registrations, []); assert.equal(f.saved.activeId, null);
} finally { release.resolve(); f.service.close(); }
});
test("disconnect persistence failure is not reported as local forgetting and leaves encrypted state fail-closed", async () => {
let failWrite = false;
const f = await loggedIn({ write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); } });
try {
const before = structuredClone(f.saved); failWrite = true;
await assert.rejects(f.service.disconnect(f.saved.activeId));
assert.deepEqual(f.saved, before); assert.equal((await f.service.status()).accounts[0].connected, false);
await assert.rejects(f.service.lease(false));
} finally { f.service.close(); }
});
test("one app-wide background preference defaults ON over legacy OFF, persists with zero accounts, and never changes on auth/account operations", async () => {
const first = await loggedIn(); first.service.close();
const legacy = structuredClone(first.saved); legacy.registrations[0].background = false;
const f = fixture({ state: legacy });
try {
assert.equal((await f.service.status()).allowBackgroundUse, true, "absent new setting ignores legacy per-account OFF");
await f.service.lease(true);
await f.service.consent(false);
assert.equal(f.saved.allowBackgroundUse, false); assert.equal(f.saved.registrations[0].background, false);
await assert.rejects(f.service.lease(true), /background/);
await f.service.lease(false);
const id = f.saved.activeId;
await f.service.select(id); assert.equal((await f.service.status()).allowBackgroundUse, false);
await f.service.login(id); await f.callback();
for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
assert.equal(f.saved.allowBackgroundUse, false, "reconnecting cannot overwrite app preference");
assert.equal(f.saved.registrations[0].background, undefined, "new verified registrations omit obsolete per-account preference");
await f.service.disconnect(f.saved.activeId);
assert.deepEqual(f.saved.registrations, []); assert.equal(f.saved.allowBackgroundUse, false);
const restarted = fixture({ state: f.saved });
try {
assert.equal((await restarted.service.status()).allowBackgroundUse, false, "zero-account restart preserves global OFF");
await restarted.service.login(); await restarted.callback();
for (let i = 0; i < 100 && (await restarted.service.status()).pending; i++) await new Promise((r) => setImmediate(r));
assert.equal(restarted.saved.allowBackgroundUse, false);
await restarted.service.consent(true); assert.equal(restarted.saved.allowBackgroundUse, true);
} finally { restarted.service.close(); }
} finally { f.service.close(); }
});
test("private leases bind active account, authority revision and raw visible model membership", async () => { test("private leases bind active account, authority revision and raw visible model membership", async () => {
const f = await loggedIn(); const f = await loggedIn();
try { try {
+1 -1
View File
@@ -19,7 +19,7 @@ const { persistTurn } = await import("../src/server/turn-service.ts");
const ipc = (value: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", value); const ipc = (value: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", value);
let revision = 1; let revision = 1;
function connection(background: boolean) { function connection(background: boolean) {
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: revision++ }, invalidate: background ? "none" : "background", status: { available: true, pending: false, activeId: "scratch-account", accounts: [{ id: "scratch-account", connected: true, planEnabled: true, background }], error: null } }); ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: revision++ }, invalidate: background ? "none" : "background", status: { available: true, pending: false, allowBackgroundUse: background, activeId: "scratch-account", accounts: [{ id: "scratch-account", connected: true, planEnabled: true, background: !background }], error: null } });
} }
after(() => { fs.rmSync(dir, { recursive: true, force: true }); }); after(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+2 -2
View File
@@ -6,7 +6,7 @@ const send = Object.getOwnPropertyDescriptor(process, "send"), connected = Objec
const pending: any[] = []; const pending: any[] = [];
const model = piModels().getModels("openai").find((m) => m.api === "openai-responses")!; const model = piModels().getModels("openai").find((m) => m.api === "openai-responses")!;
const ipc = (m: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", m); const ipc = (m: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", m);
const status = (account: string, background = true) => ({ available: true, pending: false, activeId: account, accounts: [{ id: account, connected: true, background, planEnabled: true }], error: null }); const status = (account: string, background = true) => ({ available: true, pending: false, allowBackgroundUse: background, activeId: account, accounts: [{ id: account, connected: true, background: !background, planEnabled: true }], error: null });
const value = (name: string, revision: number, account = "A", authorityRevision = 1) => ({ epoch: account === "A" ? 1 : 2, authorityRevision, revision, status: status(account), accountId: account, models: [{ id: model.id, name }] }); const value = (name: string, revision: number, account = "A", authorityRevision = 1) => ({ epoch: account === "A" ? 1 : 2, authorityRevision, revision, status: status(account), accountId: account, models: [{ id: model.id, name }] });
function reply(request: any, v: unknown) { ipc({ channel: "lh:chatgpt", id: request.id, op: request.op, ok: true, value: v }); } function reply(request: any, v: unknown) { ipc({ channel: "lh:chatgpt", id: request.id, op: request.op, ok: true, value: v }); }
before(() => { before(() => {
@@ -27,7 +27,7 @@ test("consent-only status does not discard current-authority catalog or overwrit
const refresh = refreshChatGPTModels([model as never]); const request = pending.at(-1); const refresh = refreshChatGPTModels([model as never]); const request = pending.at(-1);
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 3 }, status: status("A", false), invalidate: "background" }); ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 3 }, status: status("A", false), invalidate: "background" });
reply(request, value("same authority", 2)); await refresh; reply(request, value("same authority", 2)); await refresh;
assert.equal(chatGPTModels()[0]?.name, "same authority"); assert.equal(chatGPTStatus().accounts[0]?.background, false); assert.equal(chatGPTModels()[0]?.name, "same authority"); assert.equal(chatGPTStatus().allowBackgroundUse, false);
const scope = chatGPTScope(model.id); assert(chatGPTScopeCurrent(scope)); const scope = chatGPTScope(model.id); assert(chatGPTScopeCurrent(scope));
const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true); const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true);
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 4 }, status: status("A", false), invalidate: "background" }); ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 4 }, status: status("A", false), invalidate: "background" });
+259 -267
View File
@@ -5,8 +5,11 @@ import { afterEach, test } from "node:test";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom"; import { parseHTML } from "linkedom";
import { ChatGPTBackgroundAccess, ChatGPTConnection, ChatGPTUIProvider, CHATGPT_NOTICE } from "../components/settings/chatgpt-connection.tsx"; import { createRequire } from "node:module";
import { generateKeyPair, exportJWK, SignJWT } from "jose";
import { ChatGPTBackgroundAccess, ChatGPTConnection, ChatGPTUIProvider, CHATGPT_NOTICE, CHATGPT_DISCONNECT_COPY } from "../components/settings/chatgpt-connection.tsx";
import { SettingsDialogBody } from "../components/settings/settings-dialog.tsx"; import { SettingsDialogBody } from "../components/settings/settings-dialog.tsx";
import { KeysSection } from "../components/settings/keys-section.tsx";
import { ModelControl } from "../components/model-picker.tsx"; import { ModelControl } from "../components/model-picker.tsx";
import type { ChatGPTStatus, ChatGPTControlBridge } from "../src/core/index.ts"; import type { ChatGPTStatus, ChatGPTControlBridge } from "../src/core/index.ts";
@@ -14,26 +17,30 @@ const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } }); Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } });
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
let root: Root | undefined, host: HTMLDivElement | undefined; let root: Root | undefined, host: HTMLDivElement | undefined;
const account = (id = "private-registration-id", email = "learner@example.invalid", connected = true, background = false) => ({ id, email, registration: "private-issued-client", connected, background, planEnabled: true }); const account = (id = "private-registration-id", email = "learner@example.invalid", connected = true) => ({ id, email, registration: "private-issued-client", connected, planEnabled: connected });
const disconnected = (): ChatGPTStatus => ({ available: true, pending: false, activeId: null, accounts: [account("signed-out-record", "signed-out@example.invalid", false)], error: null }); const disconnected = (allowBackgroundUse = true): ChatGPTStatus => ({ available: true, pending: false, allowBackgroundUse, activeId: null, accounts: [account("signed-out-record", "signed-out@example.invalid", false)], error: null });
const connected = (background = false): ChatGPTStatus => ({ ...disconnected(), activeId: "private-registration-id", accounts: [account(undefined, undefined, true, background), ...disconnected().accounts] }); const connected = (allowBackgroundUse = true): ChatGPTStatus => ({ ...disconnected(allowBackgroundUse), activeId: "private-registration-id", accounts: [account(), ...disconnected().accounts] });
const storageError = "ChatGPT credentials could not be saved securely. Reconnect before making requests.";
const warning = "Account disconnected locally. Remote revocation could not be confirmed.";
const errorReply = () => ({ error: storageError } as ChatGPTStatus);
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; } function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
function fixture(initial = disconnected(), overrides: Partial<ChatGPTControlBridge> = {}) { function fixture(initial = disconnected(), overrides: Partial<ChatGPTControlBridge> = {}) {
let status = initial; let status = initial;
const calls = { login: 0, cancel: 0, consent: [] as boolean[], consentIds: [] as string[], logout: 0 }; const calls = { login: [] as (string | undefined)[], cancel: 0, consent: [] as boolean[], disconnect: [] as string[], select: [] as string[], logout: 0 };
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
function push(next: ChatGPTStatus) { status = next; for (const cb of listeners) cb(); }
const bridge: ChatGPTControlBridge = { const bridge: ChatGPTControlBridge = {
status: async () => status, status: async () => status,
login: async () => { calls.login++; return status = connected(); }, login: async (id) => { calls.login.push(id); push({ ...status, pending: true, error: null }); return status; },
cancel: async () => { calls.cancel++; return status = disconnected(); }, cancel: async () => { calls.cancel++; push({ ...status, pending: false, error: null }); return status; },
select: async (id) => { status = { ...status, activeId: id }; return status; }, select: async (id) => { calls.select.push(id); push({ ...status, activeId: id }); return status; },
logout: async () => { calls.logout++; return status = disconnected(); }, disconnect: async (id) => { calls.disconnect.push(id); push({ ...status, activeId: status.activeId === id ? null : status.activeId, accounts: status.accounts.filter((a) => a.id !== id) }); return status; },
consent: async (id, enabled) => { calls.consentIds.push(id); calls.consent.push(enabled); status = { ...status, accounts: status.accounts.map((a) => a.id === id ? { ...a, background: enabled } : a) }; return status; }, logout: async () => { calls.logout++; return status; },
onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); }, consent: async (enabled) => { calls.consent.push(enabled); push({ ...status, allowBackgroundUse: enabled }); return status; },
...overrides, onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); }, ...overrides,
}; };
window.lh = { expandWindow() {}, chatgpt: bridge }; window.lh = { expandWindow() {}, chatgpt: bridge };
return { calls, bridge, push(next: ChatGPTStatus) { status = next; for (const cb of listeners) cb(); } }; return { calls, bridge, push, get status() { return status; }, complete() { push({ ...connected(status.allowBackgroundUse), pending: false }); } };
} }
async function mount(children: React.ReactNode = <ChatGPTConnection />) { async function mount(children: React.ReactNode = <ChatGPTConnection />) {
host = document.createElement("div"); document.body.append(host); root = createRoot(host); host = document.createElement("div"); document.body.append(host); root = createRoot(host);
@@ -42,288 +49,273 @@ async function mount(children: React.ReactNode = <ChatGPTConnection />) {
const byTest = (id: string) => document.querySelector<HTMLElement>(`[data-testid="${id}"]`)!; const byTest = (id: string) => document.querySelector<HTMLElement>(`[data-testid="${id}"]`)!;
async function click(element: HTMLElement) { assert(element); await act(async () => element.click()); } async function click(element: HTMLElement) { assert(element); await act(async () => element.click()); }
function button(text: string, scope: ParentNode = document) { return [...scope.querySelectorAll<HTMLButtonElement>("button")].find((el) => el.textContent === text)!; } function button(text: string, scope: ParentNode = document) { return [...scope.querySelectorAll<HTMLButtonElement>("button")].find((el) => el.textContent === text)!; }
const toggle = () => document.querySelector<HTMLElement>('[data-testid="chatgpt-account-dialog"] [role="switch"]')!;
async function selectAccount(label: string, id: string) {
const select = document.querySelector<HTMLSelectElement>(`select[aria-label="${label}"]`)!; assert(select);
await act(async () => { Object.defineProperty(select, "value", { configurable: true, writable: true, value: id }); select.dispatchEvent(new window.Event("change", { bubbles: true })); });
}
async function begin() { await click(byTest("chatgpt-login")); await click(byTest("chatgpt-proceed")); }
async function confirmDisconnect() { await click(byTest("chatgpt-disconnect")); await click(byTest("chatgpt-confirm-disconnect")); }
afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; delete window.lh; globalThis.fetch = originalFetch; }); afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; delete window.lh; globalThis.fetch = originalFetch; });
test("settings put Model providers first and select it by default", () => { test("Providers is the first/default page; General is last and other pages keep relative order", () => {
const html = renderToStaticMarkup(<SettingsDialogBody onClose={() => {}} />); const html = renderToStaticMarkup(<SettingsDialogBody onClose={() => {}} />);
assert.match(html, /settings-nav-keys[^>]*aria-current="true"/); const ids = [...html.matchAll(/data-testid="settings-nav-([^"]+)"/g)].map((m) => m[1]);
assert(html.indexOf("settings-nav-keys") < html.indexOf("settings-nav-general")); assert.deepEqual(ids, ["keys", "agent", "appearance", "graph", "keyboard", "general"]);
assert.match(html, />Model providers<\/button>/); assert.match(html, /settings-nav-keys[^>]*aria-current="true"/); assert.match(html, />Providers<\/button>/);
assert.doesNotMatch(html, />Connections<\/button>/); assert.doesNotMatch(html, />Model providers<\/button>/);
}); });
test("setup and disconnected rows are secondary buttons, not disclosure walls; cancel never logs in or changes consent", async () => { test("Providers has exactly four ordered sections; unknown variables stay masked/read-only at the end of Model API keys", async () => {
globalThis.fetch = async (url) => {
assert.equal(url, "/api/keys");
return Response.json({ providers: [{ id: "openai", envVar: "OPENAI_API_KEY", label: "OpenAI" }], keys: [{ id: "FUTURE_VAR", info: null, present: true, masked: "…abcd", source: "file", shadowedByEnv: false }] });
};
fixture(); await mount(<KeysSection />);
const headings = [...document.querySelectorAll('[data-testid^="group-"] > p')].map((el) => el.textContent);
assert.deepEqual(headings, ["Model API keys", "Subscriptions", "Dictation", "Search"]);
assert(byTest("key-deepgram_api_key").closest('[data-testid="group-dictation"]'));
assert(byTest("key-tavily_api_key").closest('[data-testid="group-search"]'));
assert(byTest("key-brave_api_key").closest('[data-testid="group-search"]'));
assert(byTest("unknown-FUTURE_VAR").closest('[data-testid="group-provider-keys"]'));
assert(byTest("group-unknown-keys").parentElement?.lastElementChild === byTest("group-unknown-keys"));
assert.equal(byTest("unknown-FUTURE_VAR").textContent, "FUTURE_VAR…abcd"); assert(!byTest("unknown-FUTURE_VAR").querySelector("input, button"));
});
test("disconnected row is only secondary Continue; initial default is ON; cancel discards draft without OAuth/global mutation", async () => {
const f = fixture(); await mount(<ChatGPTConnection setup />); const f = fixture(); await mount(<ChatGPTConnection setup />);
assert(!document.body.textContent?.includes(CHATGPT_NOTICE)); assert.equal(byTest("chatgpt-connection").textContent, "Continue with ChatGPT");
assert(!document.body.textContent?.includes("Manage usage")); assert(!document.body.textContent?.includes(CHATGPT_NOTICE)); const trigger = byTest("chatgpt-login");
assert(!document.body.textContent?.includes("signed-out@example.invalid")); assert.match(trigger.className, /cursor-pointer/); assert.match(trigger.className, /hover:bg-accent/); assert.match(trigger.className, /focus-visible:ring/);
assert.equal(f.calls.login, 0); await click(trigger); assert.equal(f.calls.login.length, 0); assert(document.body.textContent?.includes(CHATGPT_NOTICE));
const trigger = byTest("chatgpt-login"); assert.equal(toggle().getAttribute("aria-checked"), "true");
assert.match(trigger.className, /cursor-pointer/); assert.equal(toggle().getAttribute("aria-label"), "Allow background use for all ChatGPT accounts");
assert.match(trigger.className, /hover:bg-accent/); assert(byTest("chatgpt-global-background").textContent?.includes("Background work counts toward your ChatGPT plan usage."));
assert.match(trigger.className, /focus-visible:ring/); await click(toggle());
await click(trigger);
assert.equal(f.calls.login, 0, "opening disclosure is not OAuth");
assert(document.body.textContent?.includes(CHATGPT_NOTICE));
const toggle = document.querySelector<HTMLElement>('[role="switch"][aria-label="Allow background use"]')!;
assert.equal(toggle.getAttribute("aria-checked"), "false");
await click(toggle); assert.equal(toggle.getAttribute("aria-checked"), "true");
await click(button("Cancel", byTest("chatgpt-account-dialog"))); await click(button("Cancel", byTest("chatgpt-account-dialog")));
assert.equal(f.calls.login.length, 0); assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.cancel, 0);
await click(byTest("chatgpt-login")); assert.equal(toggle().getAttribute("aria-checked"), "true");
});
for (const value of [true, false]) test(`untouched initial sign-in preserves existing global ${value} through verified completion`, async () => {
const f = fixture(disconnected(value)); await mount(); await click(byTest("chatgpt-login"));
assert.equal(toggle().getAttribute("aria-checked"), String(value)); await click(byTest("chatgpt-proceed"));
assert(document.body.textContent?.includes("Waiting for browser sign-in")); assert.deepEqual(f.calls.consent, []);
await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []); assert.equal(f.status.allowBackgroundUse, value);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(button("Manage…"));
});
for (const value of [true, false]) test(`explicit global draft ${!value} is applied only after actual pending/onChange verified completion`, async () => {
const f = fixture(disconnected(value)); await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
assert.deepEqual(f.calls.consent, []); assert((byTest("chatgpt-proceed") as HTMLButtonElement).disabled);
await act(async () => f.complete()); assert.deepEqual(f.calls.consent, [!value]); assert.equal(f.status.allowBackgroundUse, !value);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
assert.equal(f.calls.login, 0); assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.cancel, 0); await act(async () => f.push(f.status)); assert.deepEqual(f.calls.consent, [!value]);
await click(byTest("chatgpt-login"));
assert.equal(document.querySelector('[role="switch"]')?.getAttribute("aria-checked"), "false", "each initial connection choice starts OFF");
}); });
test("only explicit proceed invokes OAuth; initial background opt-in waits for verified connection", async () => { test("Cancel after initial IPC promise ends cancels actual OAuth; late completion cannot apply draft or close another dialog", async () => {
const f = fixture(); await mount(); await click(byTest("chatgpt-login")); const f = fixture(); await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal(f.calls.cancel, 1);
assert.deepEqual(f.calls.consent, []); await click(byTest("chatgpt-login")); const dialog = byTest("chatgpt-account-dialog");
await click(byTest("chatgpt-proceed")); await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []); assert(byTest("chatgpt-account-dialog") === dialog); assert.equal(f.calls.login.length, 1);
assert.equal(f.calls.login, 1); assert.deepEqual(f.calls.consent, [true]);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
assert(button("Manage…"));
}); });
test("closing pending OAuth cancels and a late login result cannot apply draft consent", async () => { test("email-only connected selector and Manage remain responsive; account selection never changes global OFF", async () => {
const login = deferred<ChatGPTStatus>(); const initial = connected(false); initial.accounts.push(account("second", "second@example.invalid"), account("missing", ""));
const f = fixture(disconnected(), { login: () => { f.calls.login++; return login.promise; } }); const f = fixture(initial); await mount();
await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector<HTMLElement>('[role="switch"]')!); assert.deepEqual([...document.querySelectorAll('select[aria-label="ChatGPT account"] option')].map((o) => o.textContent), ["learner@example.invalid", "second@example.invalid", "ChatGPT account"]);
await click(byTest("chatgpt-proceed")); assert.doesNotMatch(byTest("chatgpt-connection").textContent!, /private-registration|private-issued| · active|signed-out/);
await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.match(byTest("chatgpt-connection").firstElementChild!.className, /items-center/);
assert.equal(f.calls.cancel, 1); await selectAccount("ChatGPT account", "second"); assert.deepEqual(f.calls.select, ["second"]); assert.equal(f.status.allowBackgroundUse, false);
await act(async () => login.resolve(connected())); await click(button("Manage…")); assert(byTest("chatgpt-managed-identity").textContent?.includes("second@example.invalid"));
assert.deepEqual(f.calls.consent, []); assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Close"]);
assert(!button("Manage…")); const dialog = byTest("chatgpt-account-dialog").innerHTML;
assert(dialog.indexOf('data-testid="chatgpt-managed-identity"') < dialog.indexOf('data-testid="chatgpt-account-actions"'));
assert(dialog.indexOf('data-testid="chatgpt-account-actions"') < dialog.indexOf('data-testid="chatgpt-global-background"'));
await click(toggle()); assert.deepEqual(f.calls.consent, [true]); assert.equal(toggle().getAttribute("aria-checked"), "true");
await click(button("Close")); await selectAccount("ChatGPT account", "private-registration-id"); assert.equal(f.status.allowBackgroundUse, true);
assert.equal(f.calls.logout, 0);
}); });
test("connected account selector displays email only, excludes signed-out registrations, and retains safe fallback", async () => { test("Disconnect account is the sole secondary action under identity; confirmation Cancel never forgets; Close is only Manage footer action", async () => {
const status = connected(); status.accounts.push(account("second-id", "second@example.invalid"), account("missing-email", "")); assert.deepEqual(CHATGPT_DISCONNECT_COPY, { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." });
fixture(status); await mount(); const f = fixture(connected()); await mount(); await click(button("Manage…"));
const select = document.querySelector<HTMLSelectElement>('select[aria-label="ChatGPT account"]')!; assert.equal(byTest("chatgpt-account-actions").textContent, "Disconnect account"); assert.match(byTest("chatgpt-disconnect").className, /text-red/);
assert.deepEqual([...select.querySelectorAll("option")].map((o) => o.textContent), ["learner@example.invalid", "second@example.invalid", "ChatGPT account"]); assert(!byTest("chatgpt-account-footer").contains(byTest("chatgpt-disconnect")));
assert.doesNotMatch(document.body.textContent!, /private-registration-id|private-issued-client|second-id|signed-out@example| · active|registration /); await click(byTest("chatgpt-disconnect")); assert.equal(f.calls.disconnect.length, 0);
assert(!document.body.textContent?.includes(CHATGPT_NOTICE)); assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.title));
await click(button("Manage…")); assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.body));
assert(document.body.textContent?.includes(CHATGPT_NOTICE)); assert(!document.body.textContent?.includes("delete your OpenAI"));
assert(byTest("chatgpt-disconnect")); await click(button("Cancel", byTest("chatgpt-disconnect-confirmation"))); assert(byTest("chatgpt-disconnect")); assert.deepEqual(f.calls.disconnect, []);
await click(button("Close")); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
}); });
test("successful local disconnect removes action, resets dialog, and prevents repeated pending clicks without deleting registration UI state", async () => { test("confirmed disconnect preserves identity/pending content until outcome, prevents duplicate clicks, then closes without selecting another account", async () => {
const logout = deferred<ChatGPTStatus>(); const done = deferred<ChatGPTStatus>();
const f = fixture(connected(), { logout: () => { f.calls.logout++; return logout.promise; } }); const f = fixture(connected(false), { disconnect: (id) => { f.calls.disconnect.push(id); return done.promise; } });
await mount(); await click(button("Manage…")); await mount(); await click(button("Manage…")); await confirmDisconnect();
const disconnect = byTest("chatgpt-disconnect") as HTMLButtonElement; await click(byTest("chatgpt-confirm-disconnect")); assert.deepEqual(f.calls.disconnect, ["private-registration-id"]);
await act(async () => { disconnect.click(); disconnect.click(); }); const final = { ...disconnected(false), accounts: [account("other", "other@example.invalid")] };
assert.equal(f.calls.logout, 1); assert.equal(disconnect.disabled, true); await act(async () => f.push(final));
await act(async () => logout.resolve(disconnected())); assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes("learner@example.invalid"));
assert(!document.querySelector('[data-testid="chatgpt-disconnect"]')); assert((byTest("chatgpt-confirm-disconnect") as HTMLButtonElement).disabled); assert.equal(byTest("chatgpt-account-dialog").getAttribute("aria-busy"), "true");
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); await act(async () => done.resolve(final));
assert(byTest("chatgpt-login")); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(byTest("chatgpt-login")); assert.equal(f.status.activeId, null);
assert(!document.body.textContent?.includes("Signed out locally")); assert.equal(f.status.allowBackgroundUse, false); assert.equal(f.calls.logout, 0);
assert(!document.querySelector('[role="alert"]'));
await click(byTest("chatgpt-login"));
assert(document.body.textContent?.includes("signed-out@example.invalid"), "retained registration can still be explicitly reauthorized inside sign-in dialog");
}); });
test("real unconfirmed revoke is a distinct contextual dismissible warning, not permanent global red error", async () => { test("signed-out legacy accounts remain manageable/forgettable without pretending selection is a usable connection", async () => {
const warning = "Signed out locally. Remote revocation was not confirmed; disconnect LearnHarness in ChatGPT Settings."; const f = fixture(); await mount(); assert.equal(byTest("chatgpt-connection").textContent, "Continue with ChatGPT");
fixture(connected(), { logout: async () => ({ ...disconnected(), error: warning }) }); await click(byTest("chatgpt-login")); await selectAccount("Saved ChatGPT account", "signed-out-record"); await click(button("Manage…"));
await mount(); await click(button("Manage…")); await click(byTest("chatgpt-disconnect")); assert(byTest("chatgpt-managed-identity").textContent?.includes("signed-out@example.invalid"));
assert(byTest("chatgpt-login")); assert(!document.querySelector('[data-testid="chatgpt-disconnect"]')); assert.deepEqual(f.calls.select, []); assert.equal(f.calls.login.length, 0); await confirmDisconnect();
const alert = byTest("chatgpt-revocation-warning"); assert.deepEqual(f.calls.disconnect, ["signed-out-record"]); assert.equal(f.status.accounts.length, 0); assert.equal(f.status.allowBackgroundUse, true);
assert.equal(alert.textContent, warning); assert.doesNotMatch(alert.className, /text-red/);
await click(button("Dismiss", byTest("chatgpt-account-dialog")));
assert(!document.querySelector('[data-testid="chatgpt-revocation-warning"]'));
assert(!document.querySelector('[role="alert"]'));
}); });
test("background model choice has contextual Enable action only while connected, selected, and consent OFF", async () => { test("unconfirmed provider outcome is exactly the approved concise dismissible warning, never permanent red success/navigation advice", async () => {
const f = fixture(connected()); const f = fixture(connected(), { disconnect: async () => { const result = { ...disconnected(), accounts: [], error: warning }; f.push(result); return result; } });
await mount(<><ChatGPTBackgroundAccess provider="chatgpt" /><ChatGPTBackgroundAccess provider="openai" /><ChatGPTConnection /></>); await mount(); await click(button("Manage…")); await confirmDisconnect();
const alert = byTest("chatgpt-revocation-warning"); assert.equal(alert.textContent, warning); assert.doesNotMatch(alert.className, /text-red/);
assert(!document.querySelector('[data-testid="chatgpt-disconnect"]')); assert(byTest("chatgpt-login"));
await click(button("Dismiss", byTest("chatgpt-account-dialog"))); assert(!document.querySelector('[role="alert"]'));
assert(!document.body.textContent?.includes("ChatGPT Settings"));
});
test("dismissed revocation warning stays dismissed across global setting changes but a new unconfirmed disconnect is shown", async () => {
const initial = connected(); initial.error = warning;
const f = fixture(initial, { disconnect: async () => { const next = { ...disconnected(false), accounts: [], error: warning }; f.push(next); return next; } });
await mount(); await click(button("Dismiss")); await click(button("Manage…")); await click(toggle());
assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!document.querySelector('[role="alert"]'));
await confirmDisconnect(); assert.equal(byTest("chatgpt-revocation-warning").textContent, warning);
});
test("contextual Enable action opens the same global control without silently enabling; no foreground/provider clutter", async () => {
const f = fixture(connected(false)); await mount(<><ChatGPTBackgroundAccess provider="chatgpt" /><ChatGPTBackgroundAccess provider="openai" /><ChatGPTConnection /></>);
assert.equal([...document.querySelectorAll("button")].filter((b) => b.textContent === "Enable background use").length, 1); assert.equal([...document.querySelectorAll("button")].filter((b) => b.textContent === "Enable background use").length, 1);
await click(button("Enable background use")); await click(button("Enable background use")); assert.deepEqual(f.calls.consent, []);
assert.deepEqual(f.calls.consent, []); assert.equal(toggle().getAttribute("aria-checked"), "false"); await click(toggle()); assert.deepEqual(f.calls.consent, [true]); assert(!button("Enable background use"));
await click(document.querySelector<HTMLElement>('[role="switch"]')!); await act(async () => f.push(disconnected(false))); assert(!button("Enable background use"));
assert.deepEqual(f.calls.consent, [true]);
assert(!button("Enable background use"));
await act(async () => f.push(disconnected()));
assert(!button("Enable background use"));
}); });
test("browser-only sign-in is disabled without invoking OAuth", async () => { test("error-only disconnect reply preserves coherent status/confirmation and permits explicit retry, not false forgotten/connected success", async () => {
await mount(); const f = fixture(connected(), { disconnect: async () => errorReply() }); await mount(); await click(button("Manage…"));
const trigger = byTest("chatgpt-login") as HTMLButtonElement; await assert.doesNotReject(confirmDisconnect); assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
assert(trigger.disabled); assert.match(trigger.className, /disabled:opacity/); assert.equal((byTest("chatgpt-confirm-disconnect") as HTMLButtonElement).disabled, false); assert(byTest("chatgpt-disconnect-confirmation"));
await click(trigger); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); f.bridge.disconnect = async () => { const next = { ...disconnected(), accounts: [] }; f.push(next); return next; };
await click(byTest("chatgpt-confirm-disconnect")); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(byTest("chatgpt-login"));
}); });
test("foreground model/dictation toolbar has no plan badge or background-consent action", async () => { test("error-only global consent reply keeps saved state visible and a recoverable error", async () => {
fixture(connected(), { consent: async () => errorReply() }); await mount(); await click(button("Manage…"));
await assert.doesNotReject(() => click(toggle())); assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
assert.equal((toggle() as HTMLButtonElement).disabled, false);
});
test("error-only login reply releases its operation without corrupting status or auto-retrying", async () => {
let calls = 0; fixture(disconnected(), { login: async () => { calls++; return errorReply(); } }); await mount(); await assert.doesNotReject(begin);
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert.equal((byTest("chatgpt-proceed") as HTMLButtonElement).disabled, false); assert.equal(calls, 1);
});
test("error-only initial global draft save leaves disclosure/error and duplicate completion cannot retry the setting", async () => {
let calls = 0; const f = fixture(disconnected(), { consent: async () => { calls++; return errorReply(); } });
await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed")); await act(async () => f.complete());
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert(byTest("chatgpt-account-dialog"));
await act(async () => f.push(f.status)); assert.equal(calls, 1); assert.equal(f.calls.login.length, 1);
});
test("error-only pending cancellation remains retryable and cannot apply a global draft", async () => {
const f = fixture(disconnected(), { cancel: async () => { f.calls.cancel++; return errorReply(); } });
await mount(); await begin(); await click(button("Cancel", byTest("chatgpt-account-dialog")));
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert(byTest("chatgpt-account-dialog"));
f.bridge.cancel = async () => { f.calls.cancel++; const next = { ...f.status, pending: false }; f.push(next); return next; };
await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal(f.calls.cancel, 2); assert.deepEqual(f.calls.consent, []);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
});
test("failed pending verification never applies draft/starts login/closes disclosure on a later clean status", async () => {
const f = fixture(); await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
await act(async () => f.push({ ...connected(), error: "ChatGPT sign-in could not be verified." }));
await act(async () => f.push(connected())); assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.login.length, 1); assert(byTest("chatgpt-account-dialog"));
});
test("saved-registration mismatch cannot complete the attempt or apply its global draft", async () => {
const f = fixture(); await mount(); await click(byTest("chatgpt-login")); await selectAccount("Saved ChatGPT account", "signed-out-record");
await click(toggle()); await click(byTest("chatgpt-proceed")); await act(async () => f.complete());
assert.deepEqual(f.calls.consent, []); assert.deepEqual(f.calls.login, ["signed-out-record"]); assert(document.querySelector('[role="alert"]')); assert(byTest("chatgpt-account-dialog"));
});
test("account invalidation while initial global consent awaits IPC fences stale reply and preserves an unrelated later dialog", async () => {
const held = deferred<ChatGPTStatus>(); const f = fixture(disconnected(), { consent: () => held.promise });
await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed")); await act(async () => f.complete());
const other = { ...connected(), activeId: "other", accounts: [account("other", "other@example.invalid")] }; await act(async () => f.push(other));
await click(button("Cancel", byTest("chatgpt-account-dialog"))); await click(button("Manage…")); const dialog = byTest("chatgpt-account-dialog");
await act(async () => held.resolve(connected(false))); assert(byTest("chatgpt-account-dialog") === dialog);
assert(dialog.textContent?.includes("other@example.invalid")); assert.equal(toggle().getAttribute("aria-checked"), "true");
});
test("late global-control reply cannot overwrite a newer authoritative global value or leave control stuck", async () => {
const held = deferred<ChatGPTStatus>(); const f = fixture(connected(), { consent: () => held.promise });
await mount(); await click(button("Manage…")); await click(toggle());
await act(async () => { f.push(connected(false)); f.push(connected(true)); });
await act(async () => held.resolve(connected(false)));
assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal((toggle() as HTMLButtonElement).disabled, false);
await click(button("Close")); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
});
test("late initial global draft reply cannot resurrect an older global value after a newer authoritative setting", async () => {
const held = deferred<ChatGPTStatus>(); const f = fixture(disconnected(), { consent: () => held.promise });
await mount(<><ChatGPTConnection /><ChatGPTBackgroundAccess provider="chatgpt" /></>);
await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed")); await act(async () => f.complete());
await act(async () => { f.push(connected(false)); f.push(connected(true)); });
await act(async () => held.resolve(connected(false)));
assert(!button("Enable background use")); assert(document.querySelector('[role="alert"]')); assert.equal((byTest("chatgpt-proceed") as HTMLButtonElement).disabled, false);
});
test("coalesced verified completion before late pending acknowledgement is reconciled once from fresh status", async () => {
const ack = deferred<ChatGPTStatus>(); const f = fixture(disconnected(), { login: () => { f.calls.login.push(undefined); return ack.promise; } });
await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []);
await act(async () => ack.resolve({ ...disconnected(), pending: true })); assert.deepEqual(f.calls.consent, [false]); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
});
test("canceled attempt's late reply cannot release a newer pending attempt or apply its old draft", async () => {
const old = deferred<ChatGPTStatus>(); const f = fixture(disconnected(), { login: async () => { f.calls.login.push(undefined); if (f.calls.login.length === 1) return old.promise; f.push({ ...f.status, pending: true }); return f.status; } });
await mount(); await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
await click(button("Cancel", byTest("chatgpt-account-dialog"))); await begin(); await act(async () => old.resolve(connected(false)));
assert((byTest("chatgpt-proceed") as HTMLButtonElement).disabled); assert.deepEqual(f.calls.consent, []);
await act(async () => f.complete()); assert.deepEqual(f.calls.consent, []); assert.equal(f.status.allowBackgroundUse, true); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
});
test("real main/control bridge: immediate pending, actual cancellation, verified callback, then explicit global draft persistence", async () => {
const require = createRequire(import.meta.url); const { createChatGPT, SCOPES } = require("../electron/chatgpt.cjs"); const { chatGPTControl } = require("../electron/chatgpt-ipc.cjs");
const { privateKey, publicKey } = await generateKeyPair("RS256"); const jwk = { ...await exportJWK(publicKey), kid: "ui-fixture", alg: "RS256", use: "sig" };
let saved: any = null, authorize!: URL, tokenCalls = 0; const listeners = new Set<() => void>();
const service = createChatGPT({ store: { secure() {}, async read() { return saved; }, async write(s: any) { saved = structuredClone(s); } }, openExternal: async (url: string) => { authorize = new URL(url); }, onChange: () => { for (const cb of listeners) cb(); }, fetch: async (url: string, init: RequestInit) => {
if (url.endsWith("jwks.json")) return Response.json({ keys: [jwk] });
assert.equal(url, "https://auth.openai.com/api/accounts/oauth/token"); tokenCalls++;
const form = init.body as URLSearchParams;
const jwt = await new SignJWT({ nonce: authorize.searchParams.get("nonce"), email: "fixture@example.invalid" }).setProtectedHeader({ alg: "RS256", kid: "ui-fixture" }).setIssuer("https://auth.openai.com").setSubject("fixture-subject").setAudience(form.get("client_id")!).setIssuedAt().setExpirationTime("1h").sign(privateKey);
return Response.json({ access_token: "fixture-access", refresh_token: "fixture-refresh", id_token: jwt, token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
} });
window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), consent: (enabled) => chatGPTControl({ op: "consent", enabled }, service), onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } };
try {
await mount(); await begin(); assert.equal((await service.status()).pending, true); await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal((await service.status()).pending, false); assert.equal(tokenCalls, 0);
await click(byTest("chatgpt-login")); await click(toggle()); await click(byTest("chatgpt-proceed"));
const callback = new URL(authorize.searchParams.get("redirect_uri")!); callback.search = new URLSearchParams({ state: authorize.searchParams.get("state")!, code: "fixture-code", client_id: "oaiapp_ui_fixture" }).toString();
await act(async () => { const response = await originalFetch(callback); await response.text(); for (let i = 0; i < 100 && (saved.allowBackgroundUse !== false || (await service.status()).pending); i++) await new Promise((r) => setImmediate(r)); });
assert.equal(tokenCalls, 1); assert.equal(saved.allowBackgroundUse, false); assert.equal(saved.registrations[0].background, undefined);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(button("Manage…"));
} finally { await act(async () => service.close()); }
});
test("browser-only sign-in is disabled; foreground toolbar has no plan badge or background action", async () => {
await mount(); assert((byTest("chatgpt-login") as HTMLButtonElement).disabled); await click(byTest("chatgpt-login")); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
await act(async () => root!.unmount()); host?.remove(); root = undefined;
globalThis.fetch = async (input) => { globalThis.fetch = async (input) => {
if (input === "/api/models") return Response.json({ models: [], providers: [], slots: {}, defaults: {} }); if (input === "/api/models") return Response.json({ models: [], providers: [], slots: {}, defaults: {} });
if (input === "/api/settings") return Response.json({ settings: { models: { strong: { model: { provider: "chatgpt", id: "gpt-5" } } } } }); if (input === "/api/settings") return Response.json({ settings: { models: { strong: { model: { provider: "chatgpt", id: "gpt-5" } } } } });
throw new Error(`unexpected fixture request ${input}`); throw new Error(`unexpected fixture request ${input}`);
}; };
fixture(connected()); await mount(<ModelControl />); fixture(connected(false)); await mount(<ModelControl />); assert.match(byTest("stats-model").textContent!, /gpt/i);
assert.match(byTest("stats-model").textContent!, /gpt/i); assert(!document.body.textContent?.includes("Using ChatGPT plan")); assert(!document.body.textContent?.includes("Enable background use"));
assert(!document.body.textContent?.includes("Using ChatGPT plan"));
assert(!document.body.textContent?.includes("Enable background use"));
assert(!document.querySelector('a[href="https://chatgpt.com/settings/usage"]'));
});
const storageError = "ChatGPT credentials could not be saved securely. Reconnect before making requests.";
const errorReply = () => ({ error: storageError } as ChatGPTStatus); // Actual IPC catch envelope, deliberately not a snapshot.
function pendingFixture(initial = disconnected(), overrides: Partial<ChatGPTControlBridge> = {}) {
const f = fixture(initial, { login: async () => { f.calls.login++; const pending = { ...initial, pending: true }; f.push(pending); return pending; }, ...overrides });
return f;
}
test("production lifecycle: Cancel after the immediate pending reply cancels OAuth; later events cannot apply the old choice or close a new dialog", async () => {
const f = pendingFixture(); await mount(); await click(byTest("chatgpt-login"));
await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
assert(document.body.textContent?.includes("Waiting for browser sign-in"));
await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal(f.calls.cancel, 1);
await click(byTest("chatgpt-login")); const nextDialog = byTest("chatgpt-account-dialog");
await act(async () => f.push(connected(false)));
assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.login, 1); assert.equal(byTest("chatgpt-account-dialog"), nextDialog);
});
test("production lifecycle: verified onChange applies explicit ON once to the completed account and closes only its disclosure", async () => {
const f = pendingFixture(); await mount(); await click(byTest("chatgpt-login"));
await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
assert.equal(f.calls.login, 1); assert.deepEqual(f.calls.consent, []);
assert((byTest("chatgpt-proceed") as HTMLButtonElement).disabled);
await act(async () => f.push(connected(false)));
assert.deepEqual(f.calls.consent, [true]); assert.deepEqual(f.calls.consentIds, ["private-registration-id"]);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
await act(async () => f.push(connected(true))); assert.deepEqual(f.calls.consent, [true]);
});
test("production lifecycle: initial OFF revokes retained ON only after verified returning-account completion", async () => {
const initial = connected(true); initial.activeId = null; initial.accounts[0]!.connected = false;
const f = pendingFixture(initial); await mount(); await click(byTest("chatgpt-login"));
assert.equal(document.querySelector('[role="switch"]')?.getAttribute("aria-checked"), "false");
await click(byTest("chatgpt-proceed")); assert.deepEqual(f.calls.consent, []);
await act(async () => f.push(connected(true)));
assert.deepEqual(f.calls.consent, [false]); assert.deepEqual(f.calls.consentIds, ["private-registration-id"]);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
});
test("error-only logout reply preserves valid status, shows recoverable error, never claims disconnect, and permits explicit retry", async () => {
const f = fixture(connected(), { logout: async () => { f.calls.logout++; return errorReply(); } });
await mount(); await click(button("Manage…"));
await assert.doesNotReject(() => click(byTest("chatgpt-disconnect")));
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
assert.equal((byTest("chatgpt-disconnect") as HTMLButtonElement).disabled, false);
assert.equal(document.querySelector('select[aria-label="ChatGPT account"] option')?.textContent, "learner@example.invalid");
assert(!document.querySelector('[data-testid="chatgpt-login"]')); assert(!document.body.textContent?.includes("Signed out locally"));
f.bridge.logout = async () => { f.calls.logout++; return disconnected(); };
await click(byTest("chatgpt-disconnect")); assert.equal(f.calls.logout, 2);
assert(byTest("chatgpt-login")); assert(!document.querySelector('[data-testid="chatgpt-disconnect"]'));
});
test("error-only consent reply leaves the saved toggle unchanged with a contextual error", async () => {
fixture(connected(), { consent: async () => errorReply() }); await mount(); await click(button("Manage…"));
await assert.doesNotReject(() => click(document.querySelector<HTMLElement>('[role="switch"]')!));
assert.equal(document.querySelector('[role="switch"]')?.getAttribute("aria-checked"), "false");
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert(byTest("chatgpt-disconnect"));
});
test("error-only initial login reply is recoverable without corrupting status or automatically retrying", async () => {
const f = fixture(disconnected(), { login: async () => { f.calls.login++; return errorReply(); } });
await mount(); await click(byTest("chatgpt-login")); await assert.doesNotReject(() => click(byTest("chatgpt-proceed")));
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
assert.equal((byTest("chatgpt-proceed") as HTMLButtonElement).disabled, false); assert.equal(f.calls.login, 1);
assert.deepEqual(f.calls.consent, []);
});
test("error-only consent after verified login keeps disclosure/error open and cannot be retried by a duplicate completion push", async () => {
let consentCalls = 0;
const f = pendingFixture(disconnected(), { consent: async () => { consentCalls++; return errorReply(); } });
await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
await act(async () => f.push(connected(false)));
assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert(byTest("chatgpt-account-dialog"));
await act(async () => f.push(connected(false))); assert.equal(consentCalls, 1); assert.equal(f.calls.login, 1);
});
test("failed pending attempt never applies draft consent, starts another login, or closes disclosure on a later clean status", async () => {
const f = pendingFixture(); await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
await act(async () => f.push({ ...connected(false), error: "ChatGPT sign-in could not be verified." }));
assert.equal(document.querySelector('[role="alert"]')?.textContent, "ChatGPT sign-in could not be verified.");
await act(async () => f.push(connected(false)));
assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.login, 1); assert(byTest("chatgpt-account-dialog"));
});
test("saved-registration attempt cannot apply its choice to a different active account", async () => {
const f = pendingFixture(); await mount(); await click(byTest("chatgpt-login"));
const select = document.querySelector<HTMLSelectElement>('select[aria-label="Saved ChatGPT account"]')!;
await act(async () => { select.querySelector<HTMLOptionElement>('option[value="signed-out-record"]')!.selected = true; select.dispatchEvent(new window.Event("change", { bubbles: true })); });
await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
await act(async () => f.push(connected(false)));
assert.deepEqual(f.calls.consent, []); assert(byTest("chatgpt-account-dialog")); assert(document.querySelector('[role="alert"]'));
});
test("account invalidation while initial consent is awaiting IPC fences its stale reply and preserves an unrelated later Manage dialog", async () => {
const consent = deferred<ChatGPTStatus>();
const f = pendingFixture(disconnected(), { consent: () => consent.promise });
await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
await act(async () => f.push(connected(false)));
const other = { ...connected(), activeId: "other", accounts: [account("other", "other@example.invalid")] };
await act(async () => f.push(other));
await click(button("Cancel", byTest("chatgpt-account-dialog"))); await click(button("Manage…")); const manage = byTest("chatgpt-account-dialog");
await act(async () => consent.resolve(connected(true)));
assert.equal(byTest("chatgpt-account-dialog"), manage); assert(manage.textContent?.includes("other@example.invalid"));
assert.equal(document.querySelector('select[aria-label="ChatGPT account"] option')?.textContent, "other@example.invalid");
});
test("verified event before the initial IPC acknowledgement is handled once; the late pending reply cannot reopen or overwrite it", async () => {
const ack = deferred<ChatGPTStatus>();
const f = fixture(disconnected(), { login: () => { f.calls.login++; return ack.promise; } });
await mount(); await click(byTest("chatgpt-login")); await click(byTest("chatgpt-proceed"));
await act(async () => f.push({ ...disconnected(), pending: true }));
await act(async () => f.push(connected(false)));
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
await act(async () => ack.resolve({ ...disconnected(), pending: true }));
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(button("Manage…")); assert.equal(f.calls.login, 1);
});
test("error-only pending cancellation reply remains visible and recoverable without replacing status", async () => {
const f = pendingFixture(disconnected(), { cancel: async () => { f.calls.cancel++; return errorReply(); } });
await mount(); await click(byTest("chatgpt-login")); await click(byTest("chatgpt-proceed"));
await assert.doesNotReject(() => click(button("Cancel", byTest("chatgpt-account-dialog"))));
assert.equal(f.calls.cancel, 1); assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError);
assert(document.querySelector('[data-testid="chatgpt-connection"]')); assert.deepEqual(f.calls.consent, []);
assert(byTest("chatgpt-account-dialog"));
f.bridge.cancel = async () => { f.calls.cancel++; return disconnected(); };
await click(button("Cancel", byTest("chatgpt-account-dialog")));
assert.equal(f.calls.cancel, 2); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
});
test("coalesced completion before the pending acknowledgement is reconciled from fresh post-ack status", async () => {
const ack = deferred<ChatGPTStatus>();
const f = fixture(disconnected(), { login: () => { f.calls.login++; return ack.promise; } });
await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
// A status reader can miss pending entirely when two notifications coalesce.
await act(async () => f.push(connected(false))); assert.deepEqual(f.calls.consent, []);
await act(async () => ack.resolve({ ...disconnected(), pending: true }));
assert.deepEqual(f.calls.consent, [true]); assert.deepEqual(f.calls.consentIds, ["private-registration-id"]);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
});
test("a canceled attempt's late IPC reply cannot release a newer pending attempt or apply its old ON choice", async () => {
const oldAck = deferred<ChatGPTStatus>();
const f = fixture(disconnected(), { login: async () => {
f.calls.login++;
if (f.calls.login === 1) return oldAck.promise;
const pending = { ...disconnected(), pending: true }; f.push(pending); return pending;
} });
await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector<HTMLElement>('[role="switch"]')!); await click(byTest("chatgpt-proceed"));
await act(async () => f.push({ ...disconnected(), pending: true })); await click(button("Cancel", byTest("chatgpt-account-dialog")));
await click(byTest("chatgpt-login")); await click(byTest("chatgpt-proceed"));
await act(async () => oldAck.resolve(connected(false)));
assert((byTest("chatgpt-proceed") as HTMLButtonElement).disabled); assert.deepEqual(f.calls.consent, []);
const next = { ...connected(), activeId: "new-attempt-account", accounts: [account("new-attempt-account", "new@example.invalid", true, true)] };
await act(async () => f.push(next));
assert.deepEqual(f.calls.consent, [false]); assert.deepEqual(f.calls.consentIds, ["new-attempt-account"]);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert.equal(f.calls.login, 2);
}); });
+1 -1
View File
@@ -24,7 +24,7 @@ const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses"
let epoch = 1, revision = 1, background = true; let epoch = 1, revision = 1, background = true;
let cut: "lease" | "payload" | "full" | "afterFetch" | "none" = "none"; let cut: "lease" | "payload" | "full" | "afterFetch" | "none" = "none";
let leases: boolean[] = [], fetches = 0, toolFirst = false; let leases: boolean[] = [], fetches = 0, toolFirst = false;
const status = () => ({ available: true, pending: false, activeId: "scratch", accounts: [{ id: "scratch", registration: "oaiapp_scratch", email: "dummy@example.invalid", connected: true, planEnabled: true, background }], error: null }); const status = () => ({ available: true, pending: false, allowBackgroundUse: background, activeId: "scratch", accounts: [{ id: "scratch", registration: "oaiapp_scratch", email: "dummy@example.invalid", connected: true, planEnabled: true, background: !background }], error: null });
const ipc = (m: unknown) => (process.emit as (name: string, m: unknown) => boolean)("message", m); const ipc = (m: unknown) => (process.emit as (name: string, m: unknown) => boolean)("message", m);
function revoke(full = false) { function revoke(full = false) {
background = false; if (full) epoch++; background = false; if (full) epoch++;
+1 -1
View File
@@ -15,7 +15,7 @@ let requests: { authorization: string | null; body: Record<string, any>; url: st
let responseEvents: unknown[] = []; let responseEvents: unknown[] = [];
const ipc = (value: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", value); const ipc = (value: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", value);
const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">; const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">;
const status = { available: true, pending: false, activeId: "scratch-account", accounts: [{ id: "scratch-account", email: "dummy@example.invalid", registration: "oaiapp_scratch", connected: true, planEnabled: true, background: false }], error: null }; const status = { available: true, pending: false, allowBackgroundUse: false, activeId: "scratch-account", accounts: [{ id: "scratch-account", email: "dummy@example.invalid", registration: "oaiapp_scratch", connected: true, planEnabled: true, background: false }], error: null };
before(async () => { before(async () => {
assert(model); assert(model);
process.env.OPENAI_API_KEY = "sk-scratch-must-never-be-used"; process.env.OPENAI_API_KEY = "sk-scratch-must-never-be-used";
+2 -2
View File
@@ -63,8 +63,8 @@ test("plan plus unknown graph rows keep the existing unknown suffix without fabr
test("root-lifetime safe bridge publishes with Connections unmounted; commands/panel pushes do not duplicate global signals", async () => { test("root-lifetime safe bridge publishes with Connections unmounted; commands/panel pushes do not duplicate global signals", async () => {
let pushes: (() => void)[] = [], subscriptions = 0, unsubscriptions = 0, fetches = 0; let pushes: (() => void)[] = [], subscriptions = 0, unsubscriptions = 0, fetches = 0;
const safe: ChatGPTStatus = { available: true, pending: false, activeId: "a", accounts: [{ id: "a", email: "dummy@example.invalid", registration: "oaiapp_dummy", connected: true, background: false, planEnabled: true }], error: null }; const safe: ChatGPTStatus = { available: true, pending: false, allowBackgroundUse: false, activeId: "a", accounts: [{ id: "a", email: "dummy@example.invalid", registration: "oaiapp_dummy", connected: true, planEnabled: true }], error: null };
window.lh = { expandWindow() {}, chatgpt: { status: async () => safe, login: async () => safe, cancel: async () => safe, select: async () => safe, logout: async () => safe, consent: async () => safe, onChange: (cb) => { subscriptions++; pushes.push(cb); return () => { unsubscriptions++; pushes = pushes.filter((c) => c !== cb); }; } } }; window.lh = { expandWindow() {}, chatgpt: { status: async () => safe, login: async () => safe, cancel: async () => safe, select: async () => safe, disconnect: async () => safe, logout: async () => safe, consent: async () => safe, onChange: (cb) => { subscriptions++; pushes.push(cb); return () => { unsubscriptions++; pushes = pushes.filter((c) => c !== cb); }; } } };
globalThis.fetch = async (input) => { assert.equal(input, "/api/models"); fetches++; return Response.json(body(`model-${fetches}`)); }; globalThis.fetch = async (input) => { assert.equal(input, "/api/models"); fetches++; return Response.json(body(`model-${fetches}`)); };
await mount(<SidebarProvider><Reader id="root" /></SidebarProvider>); await mount(<SidebarProvider><Reader id="root" /></SidebarProvider>);
assert.equal(subscriptions, 1); assert.equal(subscriptions, 1);
+10
View File
@@ -69,6 +69,16 @@ test("all methods fail closed for desktop missing/wrong secret; API-only matcher
} }
}); });
test("renderer controls route explicit account forgetting and account-independent boolean consent only", async () => {
const forgotten: string[] = [], changes: boolean[] = [];
const connection = { disconnect: async (id: string) => { forgotten.push(id); return { ok: true }; }, consent: async (enabled: boolean) => { changes.push(enabled); return { ok: true }; } };
assert.deepEqual(await chatGPTControl({ op: "disconnect", id: "fixture-account" }, connection), { ok: true });
assert.deepEqual(await chatGPTControl({ op: "consent", enabled: false }, connection), { ok: true });
assert.deepEqual(forgotten, ["fixture-account"]); assert.deepEqual(changes, [false]);
for (const command of [{ op: "disconnect" }, { op: "disconnect", id: 7 }, { op: "consent", enabled: "false" }]) assert.deepEqual(await chatGPTControl(command, connection), { error: "Unsupported ChatGPT control." });
assert.deepEqual(changes, [false]);
});
test("renderer controls have no lease or general token bridge, and require the trusted main frame", async () => { test("renderer controls have no lease or general token bridge, and require the trusted main frame", async () => {
let leased = false; let leased = false;
const connection = { lease: async () => { leased = true; return { accessToken: "scratch-private-token" }; } }; const connection = { lease: async () => { leased = true; return { accessToken: "scratch-private-token" }; } };
+2 -2
View File
@@ -41,7 +41,7 @@ describe("SettingsDialog (the designed surface, SSR)", () => {
assert.ok(!html.includes("settings-dialog")); assert.ok(!html.includes("settings-dialog"));
}); });
it("the dialog frame: role=dialog + the ✕ + the six-section nav, Model providers first and active", () => { it("the dialog frame: role=dialog + the ✕ + the six-section nav, Providers first and General last", () => {
const html = renderToStaticMarkup(<SettingsDialogBody onClose={() => {}} />); const html = renderToStaticMarkup(<SettingsDialogBody onClose={() => {}} />);
assert.ok(html.includes('role="dialog"')); assert.ok(html.includes('role="dialog"'));
assert.ok(html.includes('aria-modal="true"')); assert.ok(html.includes('aria-modal="true"'));
@@ -49,7 +49,7 @@ describe("SettingsDialog (the designed surface, SSR)", () => {
for (const id of ["general", "keys", "agent", "appearance", "graph", "keyboard"]) { for (const id of ["general", "keys", "agent", "appearance", "graph", "keyboard"]) {
assert.ok(html.includes(`data-testid="settings-nav-${id}"`), id); assert.ok(html.includes(`data-testid="settings-nav-${id}"`), id);
} }
// Model providers is the first landing section; internal keys deep links survive. // Providers is the first landing section; internal keys deep links survive.
assert.ok(tagOf(html, "settings-nav-keys").includes('aria-current="true"')); assert.ok(tagOf(html, "settings-nav-keys").includes('aria-current="true"'));
assert.ok(html.indexOf('data-testid="settings-nav-keys"') < html.indexOf('data-testid="settings-nav-general"')); assert.ok(html.indexOf('data-testid="settings-nav-keys"') < html.indexOf('data-testid="settings-nav-general"'));
assert.ok(tagOf(html, "settings-nav-keyboard").includes('aria-current="false"')); assert.ok(tagOf(html, "settings-nav-keyboard").includes('aria-current="false"'));