Unpriced turns
@@ -168,7 +169,7 @@ export function StatsBar({
{turn.steps.length} steps
- {c === null ? "—" : formatUsd(c)}
+ {turn.model.startsWith("chatgpt:") ? "ChatGPT plan" : c === null ? "—" : formatUsd(c)}
);
diff --git a/components/usage-format.ts b/components/usage-format.ts
index 584b371..f4e066f 100644
--- a/components/usage-format.ts
+++ b/components/usage-format.ts
@@ -8,6 +8,11 @@ export function formatUsd(c: number): string {
return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
}
+export function planCostLabel(dollars: number | null, subscription: boolean, format = formatUsd): string {
+ if (subscription) return dollars === null ? "ChatGPT plan" : `${format(dollars)} API + ChatGPT plan`;
+ return dollars === null ? "—" : format(dollars);
+}
+
/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
export function formatTokens(n: number): string {
if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
diff --git a/electron-builder.yml b/electron-builder.yml
index ff2c659..8c27305 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -81,6 +81,8 @@ files:
- node_modules/universalify/**/*
- node_modules/ms/**/*
- node_modules/argparse/**/*
+ # Main-process ID-token signature verification (no bundled OAuth implementation).
+ - node_modules/jose/**/*
extraResources:
# Copy from the PARENT dir (.next), not .next/standalone itself:
# electron-builder hard-excludes a copy's root node_modules and the
diff --git a/electron/chatgpt-ipc.cjs b/electron/chatgpt-ipc.cjs
new file mode 100644
index 0000000..c5e97e0
--- /dev/null
+++ b/electron/chatgpt-ipc.cjs
@@ -0,0 +1,42 @@
+"use strict";
+const { publicError } = require("./chatgpt.cjs");
+
+/** Inherited Node child IPC, not a listening HTTP/socket token service. */
+function attachChatGPTChild(child, connection) {
+ const send = (value) => {
+ if (child.connected) child.send({ channel: "lh:chatgpt", ...value }, () => {});
+ };
+ const listener = async (message) => {
+ if (!message || message.channel !== "lh:chatgpt" || typeof message.id !== "string" || message.id.length > 100) return;
+ try {
+ let value;
+ if (message.op === "status") value = { status: await connection.status(), ...connection.stamp() };
+ else if (message.op === "models") value = await connection.models();
+ else if (message.op === "lease" && typeof message.background === "boolean") value = await connection.lease(message.background, message);
+ else throw new Error("Invalid private operation");
+ send({ id: message.id, op: message.op, ok: true, value });
+ } catch (err) { send({ id: message.id, op: message.op, ok: false, stamp: connection.stamp(), error: publicError(err) }); }
+ };
+ child.on("message", listener);
+ return { send, dispose: () => child.removeListener("message", listener) };
+}
+
+/** Sender must be the trusted window's main frame at its exact app origin. */
+function trustedSender(event, origin, BrowserWindow) {
+ const win = BrowserWindow.fromWebContents(event.sender);
+ try {
+ return !!win && event.senderFrame === event.sender.mainFrame && new URL(event.senderFrame.url).origin === origin;
+ } catch { return false; }
+}
+async function chatGPTControl(command, connection) {
+ try {
+ if (command?.op === "status") return await connection.status();
+ 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 === "select" && typeof command.id === "string") return await connection.select(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);
+ return { error: "Unsupported ChatGPT control." };
+ } catch (err) { return { error: publicError(err) }; }
+}
+module.exports = { attachChatGPTChild, trustedSender, chatGPTControl };
diff --git a/electron/chatgpt.cjs b/electron/chatgpt.cjs
new file mode 100644
index 0000000..888ad0b
--- /dev/null
+++ b/electron/chatgpt.cjs
@@ -0,0 +1,513 @@
+"use strict";
+
+// LearnHarness-owned public-client OAuth. Never import pi's bundled login.
+// This module is main-process-only; dependencies are injectable for scratch tests.
+const crypto = require("node:crypto");
+const http = require("node:http");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+
+const ISSUER = "https://auth.openai.com";
+const RESOURCE = "https://api.openai.com/v1";
+const AUTHORIZE = `${ISSUER}/api/accounts/authorize`;
+const TOKEN = `${ISSUER}/api/accounts/oauth/token`;
+const JWKS = `${ISSUER}/.well-known/jwks.json`;
+const SCOPES = ["openid", "profile", "email", "offline_access", "resource.invoke", "chatgpt.tokens.use.direct"];
+const random = () => crypto.randomBytes(32).toString("base64url");
+const fail = (code) => Object.assign(new Error(code), { code });
+const publicError = (err) => ({
+ cancelled: "ChatGPT sign-in cancelled.",
+ timeout: "ChatGPT sign-in timed out. Try again.",
+ secure_storage: "Secure credential storage is unavailable. ChatGPT sign-in requires the desktop app and an OS secure-storage backend.",
+ 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.",
+ scope: "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.",
+ disconnected: "This ChatGPT connection is disconnected. Reconnect in Connections.",
+ background: "Background use is off for this ChatGPT account. Enable Allow background use in Connections, 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.",
+ expired: "The ChatGPT session expired. Reconnect in Connections.",
+ network: "ChatGPT could not be reached. Check your connection and try again.",
+ pending: "A ChatGPT sign-in is already pending.",
+ unavailable: "ChatGPT sign-in is unavailable in browser-only mode.",
+}[err?.code] || "ChatGPT sign-in failed. Try again; no API-key fallback was used.");
+
+const nonempty = (v) => typeof v === "string" && v.length > 0 && !/[\x00-\x20\x7f]/.test(v);
+const planScopes = (tokens) => !!tokens?.accessToken && SCOPES.every((scope) => tokens.scopes.includes(scope));
+/** Reject the entire encrypted record. Encryption is not schema validation. */
+function validateState(state) {
+ if (!state || state.version !== 1 || !nonempty(state.hostId) || !Array.isArray(state.registrations) || !(state.activeId === null || nonempty(state.activeId))) throw fail("storage");
+ const ids = new Set(); const clients = new Set();
+ 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");
+ ids.add(r.id); clients.add(r.clientId);
+ if (r.tokens !== undefined) {
+ const t = r.tokens;
+ if (!t || !nonempty(t.idToken) || !Array.isArray(t.scopes) || t.scopes.some((s) => !nonempty(s)) || !Number.isFinite(t.expiresAt) || t.expiresAt < 0 || (t.accessToken !== undefined && (!nonempty(t.accessToken) || t.accessToken.startsWith("sk-"))) || (t.refreshToken !== undefined && !nonempty(t.refreshToken))) throw fail("storage");
+ }
+ }
+ if (state.activeId !== null && !ids.has(state.activeId)) throw fail("storage");
+ return state;
+}
+
+/** Encrypted envelope only, outside learning data. No plaintext fallback. */
+function encryptedStore(file, safeStorage) {
+ function secure() {
+ if (!safeStorage.isEncryptionAvailable() || safeStorage.getSelectedStorageBackend?.() === "basic_text") throw fail("secure_storage");
+ }
+ return {
+ secure,
+ async read() {
+ secure();
+ try {
+ const envelope = JSON.parse(await fs.readFile(file, "utf8"));
+ if (envelope.version !== 1 || typeof envelope.encrypted !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(envelope.encrypted) || !envelope.encrypted) throw fail("storage");
+ const state = JSON.parse(safeStorage.decryptString(Buffer.from(envelope.encrypted, "base64")));
+ return validateState(state);
+ } catch (err) {
+ if (err.code === "ENOENT") return null;
+ throw fail("storage");
+ }
+ },
+ async write(state) {
+ secure();
+ validateState(state);
+ const temp = `${file}.${crypto.randomUUID()}.tmp`;
+ let handle;
+ try {
+ const encrypted = safeStorage.encryptString(JSON.stringify(state)).toString("base64");
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
+ handle = await fs.open(temp, "wx", 0o600);
+ await handle.writeFile(JSON.stringify({ version: 1, encrypted }));
+ await handle.sync();
+ await handle.close();
+ handle = null;
+ await fs.rename(temp, file);
+ // Directory fsync is supported on Unix; the file fsync above is mandatory.
+ if (process.platform !== "win32") {
+ const dir = await fs.open(path.dirname(file), "r");
+ try { await dir.sync(); } finally { await dir.close(); }
+ }
+ } catch {
+ throw fail("storage");
+ } finally {
+ await handle?.close().catch(() => {});
+ await fs.unlink(temp).catch(() => {});
+ }
+ },
+ };
+}
+
+function allowedAuthEndpoint(value) {
+ try {
+ const url = new URL(value);
+ return url.origin === ISSUER && !url.username && !url.password && !url.search && !url.hash;
+ } catch { return false; }
+}
+
+function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch, onChange = () => {}, timeoutMs = 180000 }) {
+ let state;
+ let init;
+ let queue = Promise.resolve();
+ let epoch = 0;
+ let revision = 0;
+ let authorityRevision = 0;
+ let catalogSequence = 0;
+ let storageDenied = false;
+ let secureAvailable = true;
+ const stamp = () => ({ epoch, authorityRevision, revision });
+ const backgroundIntent = new Map();
+ let pending;
+ let catalog = [];
+ let catalogError = null;
+ let lastError = null;
+ let closed = false;
+ let paused = false;
+ let suspended = false;
+ let retryRegistration;
+ let transportAbort = new AbortController();
+ let backgroundAbort = new AbortController();
+ const serial = (fn) => {
+ const next = queue.then(fn, fn);
+ queue = next.catch(() => {});
+ return next;
+ };
+ 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;
+ const notify = (invalidate = "none") => { revision++; onChange({ channel: "lh:chatgpt", kind: "state", stamp: stamp(), status: snapshot(), invalidate }); };
+ function stop(backgroundOnly = false) {
+ if (backgroundOnly) {
+ backgroundAbort.abort();
+ backgroundAbort = new AbortController();
+ notify("background");
+ return;
+ }
+ suspended = true;
+ epoch++;
+ authorityRevision++;
+ catalogSequence++;
+ transportAbort.abort();
+ transportAbort = new AbortController();
+ catalog = [];
+ catalogError = null;
+ notify("all");
+ }
+ async function ready() {
+ if (closed) throw fail("disconnected");
+ if (!init) init = (async () => {
+ const loaded = await store.read();
+ state = loaded ? validateState(loaded) : null;
+ if (!state) {
+ const first = { version: 1, hostId: `urn:uuid:${crypto.randomUUID()}`, activeId: null, registrations: [] };
+ await store.write(first);
+ state = first;
+ }
+ })();
+ try { await init; checkSecure(); } catch (err) { denyStorage(err); throw err; }
+ }
+ function denyStorage(err) {
+ secureAvailable = false;
+ if (!storageDenied) { storageDenied = true; lastError = publicError(err); stop(); }
+ }
+ function checkSecure() {
+ try { store.secure(); secureAvailable = true; } catch (err) { denyStorage(err); throw err; }
+ }
+ async function save(next) {
+ try { checkSecure(); validateState(next); await store.write(next); checkSecure(); } catch (err) {
+ // A rotated token may already have invalidated its predecessor. Fail closed.
+ denyStorage(err);
+ lastError = publicError(err);
+ notify();
+ throw err;
+ }
+ state = next;
+ }
+ function snapshot() {
+ return {
+ available: !!state && secureAvailable && !closed && !paused,
+ pending: !!pending,
+ activeId: state?.activeId ?? null,
+ accounts: (state?.registrations || []).map((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),
+ background: backgroundAllowed(r),
+ })),
+ error: lastError,
+ catalogError,
+ };
+ }
+ async function status() {
+ try { await ready(); } catch (err) { return { ...snapshot(), available: false, error: publicError(err) }; }
+ return snapshot();
+ }
+ async function json(url, options = {}) {
+ try {
+ const response = await request(url, { ...options, redirect: "error", signal: options.signal ?? AbortSignal.timeout(15000) });
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({}));
+ const oauthCode = typeof body.error === "string" ? body.error : body.error?.code;
+ throw Object.assign(fail(oauthCode === "invalid_grant" || response.status === 401 || response.status === 403 ? "expired" : "network"), { oauthCode });
+ }
+ return await response.json();
+ } catch (err) { throw err.code ? err : fail("network"); }
+ }
+ async function identity(idToken, clientId, nonce, expectedSubject) {
+ try {
+ const { jwtVerify, createLocalJWKSet } = await import("jose");
+ // Fixed, documented JWKS origin; never follow a discovery-supplied URL.
+ const keys = await json(JWKS);
+ const { payload } = await jwtVerify(idToken, createLocalJWKSet(keys), {
+ issuer: ISSUER, audience: clientId, algorithms: ["RS256"],
+ requiredClaims: ["sub", "exp", "iat"], clockTolerance: 5,
+ });
+ if (typeof payload.sub !== "string" || !payload.sub || (nonce !== undefined && payload.nonce !== nonce) || (expectedSubject && payload.sub !== expectedSubject)) throw fail("identity");
+ if ((payload.azp !== undefined && payload.azp !== clientId) || (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== clientId)) throw fail("identity");
+ return { subject: payload.sub, email: typeof payload.email === "string" ? payload.email : undefined };
+ } catch { throw fail("identity"); }
+ }
+ function tokenSet(body, previous) {
+ const scopes = typeof body.scope === "string" ? body.scope.split(/\s+/) : previous?.scopes;
+ if (!scopes || SCOPES.some((scope) => !scopes.includes(scope))) {
+ // Lack of entitlement is not lack of revocable material.
+ return { idToken: body.id_token ?? previous?.idToken, scopes: scopes ?? [], expiresAt: 0,
+ ...(nonempty(body.refresh_token) ? { refreshToken: body.refresh_token } : previous?.refreshToken ? { refreshToken: previous.refreshToken } : {}) };
+ }
+ if (typeof body.access_token !== "string" || !body.access_token || body.access_token.startsWith("sk-") || body.token_type?.toLowerCase() !== "bearer" || !Number.isFinite(body.expires_in) || body.expires_in <= 0) throw fail("identity");
+ const refreshToken = body.refresh_token ?? previous?.refreshToken;
+ if (!nonempty(refreshToken)) throw fail("identity");
+ return { accessToken: body.access_token, refreshToken, idToken: body.id_token ?? previous?.idToken, scopes, expiresAt: Date.now() + body.expires_in * 1000 };
+ }
+ async function grant(fields, signal) {
+ return json(TOKEN, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body: new URLSearchParams({ ...fields, resource: RESOURCE }), signal });
+ }
+
+ async function login(accountId) {
+ await ready();
+ if (pending) throw fail("pending");
+ if (paused) throw fail("disconnected");
+ const selected = accountId ? state.registrations.find((r) => r.id === accountId) : retryRegistration;
+ if (accountId && !selected) throw fail("identity");
+ const attemptEpoch = epoch;
+ const verifier = random();
+ const nonce = random();
+ const expectedState = random();
+ const abort = new AbortController();
+ let finish;
+ const completion = new Promise((resolve) => { finish = resolve; });
+ const listener = http.createServer();
+ let timer;
+ let consumed = false;
+ const cleanup = () => { clearTimeout(timer); listener.close(); listener.closeAllConnections(); };
+ const cancel = (code = "cancelled") => {
+ abort.abort();
+ cleanup();
+ if (pending?.abort === abort) pending = null;
+ lastError = publicError(fail(code));
+ finish();
+ notify();
+ };
+ pending = { abort, cancel };
+ lastError = null;
+ notify();
+ try {
+ await new Promise((resolve, reject) => {
+ listener.once("error", reject);
+ listener.listen(0, "127.0.0.1", resolve);
+ });
+ const redirectUri = `http://127.0.0.1:${listener.address().port}/auth/callback`;
+ listener.on("request", async (req, res) => {
+ let callback;
+ try { callback = new URL(req.url || "/", redirectUri); }
+ catch { res.writeHead(400).end(); return; }
+ res.setHeader("Cache-Control", "no-store");
+ res.setHeader("Content-Security-Policy", "default-src 'none'");
+ res.setHeader("Referrer-Policy", "no-referrer");
+ if (req.method !== "GET" || callback.origin !== new URL(redirectUri).origin || callback.pathname !== "/auth/callback" || req.headers.host !== new URL(redirectUri).host || consumed) { res.writeHead(404).end(); return; }
+ const params = callback.searchParams;
+ // Reject duplicate parameters and unsolicited callbacks without consuming a valid pending attempt.
+ if (["state", "code", "client_id", "error"].some((key) => params.getAll(key).length > 1) || params.get("state") !== expectedState) { res.writeHead(400).end("Sign-in could not be verified."); return; }
+ consumed = true;
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("Return to LearnHarness to view the sign-in result. You may close this tab.");
+ cleanup();
+ try {
+ if (params.has("error")) throw fail(params.get("error") === "access_denied" ? "cancelled" : "identity");
+ const clientId = params.get("client_id") || selected?.clientId;
+ if (!clientId || clientId === "dynamic_agent_client" || clientId.length > 512 || /[\s\x00-\x1f]/.test(clientId) || (selected && clientId !== selected.clientId) || !params.get("code")) throw fail("identity");
+ let body;
+ try { body = await grant({ grant_type: "authorization_code", client_id: clientId, code: params.get("code"), code_verifier: verifier, redirect_uri: redirectUri }, AbortSignal.any([abort.signal, AbortSignal.timeout(15000)])); }
+ catch (err) {
+ if (err.oauthCode === "invalid_grant" && !selected?.subject) retryRegistration = { clientId };
+ throw err;
+ }
+ if (typeof body.id_token !== "string") throw fail("identity");
+ const verified = await identity(body.id_token, clientId, nonce, selected?.subject);
+ const tokens = tokenSet(body);
+ await serial(async () => {
+ if (closed || abort.signal.aborted || epoch !== attemptEpoch) throw fail("cancelled");
+ const existing = state.registrations.find((r) => r.clientId === clientId);
+ 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 next = structuredClone(state);
+ next.registrations = next.registrations.filter((r) => r.id !== record.id).concat(record);
+ next.activeId = record.id;
+ const previous = structuredClone(state);
+ await save(next); // Active identity changes only AFTER validation and durable encrypted persistence.
+ if (closed || abort.signal.aborted || epoch !== attemptEpoch) {
+ await save(previous);
+ throw fail("cancelled");
+ }
+ stop();
+ storageDenied = false;
+ suspended = false;
+ retryRegistration = undefined;
+ });
+ lastError = planScopes(tokens) ? null : publicError(fail("scope"));
+ } catch (err) { if (!abort.signal.aborted) lastError = publicError(err); }
+ finally { if (pending?.abort === abort) pending = null; finish(); notify(); }
+ });
+ timer = setTimeout(() => cancel("timeout"), timeoutMs);
+ const url = new URL(AUTHORIZE);
+ url.search = new URLSearchParams({
+ client_id: selected?.clientId ?? "dynamic_agent_client",
+ ...(selected ? {} : { agent_name_hint: "LearnHarness" }),
+ ext_agent_host_id: state.hostId,
+ ...(selected?.tokens?.idToken ? { id_token_hint: selected.tokens.idToken } : {}),
+ ...(selected?.email ? { login_hint: selected.email } : {}),
+ response_type: "code", redirect_uri: redirectUri, scope: SCOPES.join(" "), resource: RESOURCE,
+ state: expectedState, nonce, code_challenge_method: "S256",
+ code_challenge: crypto.createHash("sha256").update(verifier).digest("base64url"),
+ }).toString();
+ await openExternal(url.toString()); // Trusted main only. Never return this URL to the renderer.
+ } catch (err) { cancel(err.code || "network"); }
+ void completion;
+ return snapshot();
+ }
+ function cancelLogin() { pending?.cancel(); return snapshot(); }
+
+ function authorized(background, expected, capturedEpoch) {
+ checkSecure();
+ const account = active();
+ if (!account || (capturedEpoch !== undefined && capturedEpoch !== epoch)) throw fail("disconnected");
+ 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 (background && !backgroundAllowed(account)) throw fail("background");
+ return account;
+ }
+ async function credential(background, expected) {
+ await ready();
+ return serial(async () => {
+ const requestEpoch = epoch;
+ const account = authorized(background, expected);
+ if (account.tokens.expiresAt <= Date.now() + 60000) {
+ const refreshEpoch = epoch;
+ const signal = AbortSignal.any([transportAbort.signal, ...(background ? [backgroundAbort.signal] : []), AbortSignal.timeout(15000)]);
+ let body;
+ try { body = await grant({ grant_type: "refresh_token", client_id: account.clientId, refresh_token: account.tokens.refreshToken }, signal); }
+ catch (err) {
+ if (err.oauthCode === "invalid_grant") {
+ const next = structuredClone(state);
+ delete next.registrations.find((r) => r.id === account.id).tokens;
+ next.activeId = null;
+ await save(next);
+ stop();
+ notify();
+ }
+ throw err;
+ }
+ let tokens;
+ try {
+ if (body.id_token !== undefined) await identity(body.id_token, account.clientId, undefined, account.subject);
+ tokens = tokenSet(body, account.tokens);
+ } catch (err) {
+ // The fixed token endpoint may have rotated already. Preserve only
+ // verified identity and a single likely-latest revocation handle.
+ stop();
+ const restricted = structuredClone(state);
+ const record = restricted.registrations.find((r) => r.id === account.id);
+ record.reconnectRequired = true;
+ if (nonempty(body.refresh_token)) record.tokens.refreshToken = body.refresh_token;
+ await save(restricted);
+ lastError = publicError(fail("identity")); notify();
+ throw err;
+ }
+ // A successful remote rotation must be durably retained even when
+ // logout, account selection, consent revocation, or pause denies use.
+ // Those serialized operations will revoke/clear the LATEST token.
+ const next = structuredClone(state);
+ next.registrations.find((r) => r.id === account.id).tokens = tokens;
+ await save(next);
+ if (closed || refreshEpoch !== epoch || signal.aborted) throw fail("disconnected");
+ }
+ const current = authorized(background, expected, requestEpoch);
+ return { accessToken: current.tokens.accessToken, accountId: current.id, ...stamp() };
+ });
+ }
+ async function lease(background, expected) {
+ if (typeof background !== "boolean" || !expected || !nonempty(expected.expectedAccountId) || !Number.isSafeInteger(expected.expectedAuthorityRevision) || !nonempty(expected.selectedModelId)) throw fail("catalog");
+ return credential(background, expected);
+ }
+ const modelValue = () => ({ ...stamp(), status: snapshot(), accountId: active()?.id ?? null, models: catalog });
+ async function models() {
+ const sequence = ++catalogSequence;
+ try {
+ await ready();
+ if (!active()) return modelValue();
+ // Discovery is identity/scope authorization, not an invented model lease.
+ const token = await credential(false);
+ const body = await json(`${RESOURCE}/models`, { headers: { Authorization: `Bearer ${token.accessToken}` }, signal: AbortSignal.any([transportAbort.signal, AbortSignal.timeout(15000)]) });
+ authorized(false, undefined, token.epoch);
+ if (sequence !== catalogSequence || active()?.id !== token.accountId || !Array.isArray(body.models)) throw fail("catalog");
+ const visible = body.models.filter((m) => m && m.visibility === "list" && nonempty(m.slug) && typeof m.display_name === "string").map((m) => ({ id: m.slug, name: m.display_name }));
+ const changed = JSON.stringify(visible) !== JSON.stringify(catalog);
+ catalog = visible;
+ if (changed) authorityRevision++;
+ const hadError = catalogError !== null; catalogError = null;
+ if (changed || hadError) notify();
+ return modelValue();
+ } catch {
+ if (sequence === catalogSequence) {
+ if (catalog.length) { catalog = []; authorityRevision++; }
+ catalogError = publicError(fail("catalog")); notify();
+ }
+ return modelValue();
+ }
+ }
+ async function select(id) {
+ pending?.cancel();
+ stop();
+ const selectionIntent = epoch;
+ await ready();
+ return serial(async () => {
+ const account = state.registrations.find((r) => r.id === id);
+ if (!account?.tokens) throw fail("disconnected");
+ await save({ ...state, activeId: id });
+ if (epoch !== selectionIntent || closed || paused || storageDenied) throw fail("disconnected");
+ suspended = false;
+ lastError = null;
+ notify();
+ return snapshot();
+ });
+ }
+ async function consent(id, enabled) {
+ if (typeof enabled !== "boolean") throw fail("identity");
+ const intent = { enabled: false, version: (backgroundIntent.get(id)?.version ?? 0) + 1 };
+ backgroundIntent.set(id, intent);
+ if (!enabled) {
+ const account = state?.registrations.find((r) => r.id === id);
+ if (account) account.background = false;
+ stop(true); // Revoke background calls immediately; foreground stays usable.
+ }
+ await ready();
+ return serial(async () => {
+ const next = structuredClone(state);
+ const account = next.registrations.find((r) => r.id === id);
+ if (!account) throw fail("identity");
+ account.background = enabled;
+ await save(next);
+ if (backgroundIntent.get(id) === intent && enabled) backgroundIntent.delete(id);
+ notify();
+ return snapshot();
+ });
+ }
+ async function logout(id) {
+ pending?.cancel();
+ stop();
+ await ready();
+ return serial(async () => {
+ const account = state.registrations.find((r) => r.id === id);
+ if (!account) throw fail("identity");
+ 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 sign-out is mandatory even when revocation is unavailable */ }
+ const next = structuredClone(state);
+ delete next.registrations.find((r) => r.id === id).tokens;
+ if (next.activeId === id) next.activeId = null;
+ const logoutEpoch = epoch;
+ await save(next);
+ if (!closed && epoch === logoutEpoch) suspended = false;
+ lastError = revoked ? null : "Signed out locally. Remote revocation was not confirmed; disconnect LearnHarness in ChatGPT Settings.";
+ notify();
+ return snapshot();
+ });
+ }
+ function close() { closed = true; pending?.cancel(); stop(); }
+ function pause() { paused = true; pending?.cancel(); stop(); notify(); }
+ function resume() { paused = false; if (!storageDenied) suspended = false; notify(); }
+ return { status, login, cancelLogin, lease, models, select, consent, logout, close, pause, resume, stamp };
+}
+
+module.exports = { createChatGPT, encryptedStore, publicError, allowedAuthEndpoint, SCOPES, validateState };
diff --git a/electron/desktop-api.cjs b/electron/desktop-api.cjs
new file mode 100644
index 0000000..b6819fb
--- /dev/null
+++ b/electron/desktop-api.cjs
@@ -0,0 +1,25 @@
+"use strict";
+
+const HEADER = "x-learnharness-desktop";
+
+/** Called for EVERY outgoing request, so redirects cannot carry the secret
+ * to another origin. Never expose this function or its secret to preload. */
+function desktopAPIHeaders(details, origin, secret, windows) {
+ const headers = { ...details.requestHeaders };
+ for (const name of Object.keys(headers)) if (name.toLowerCase() === HEADER) delete headers[name];
+ if (!secret) return headers;
+ try {
+ const target = new URL(details.url);
+ if (target.origin !== origin || target.username || target.password || !(target.pathname === "/api" || target.pathname.startsWith("/api/"))) return headers;
+ const win = windows.find((candidate) => !candidate.isDestroyed() && candidate.webContents.id === details.webContentsId);
+ if (!win || win.webContents.isDestroyed()) return headers;
+ const contents = win.webContents;
+ if (details.webContents && details.webContents !== contents) return headers;
+ if (!details.frame || details.frame !== contents.mainFrame) return headers;
+ if (new URL(contents.mainFrame.url).origin !== origin) return headers;
+ if (details.initiatorOrigin !== undefined && details.initiatorOrigin !== origin) return headers;
+ headers[HEADER] = secret;
+ } catch { /* Missing/destroyed/navigated frames fail closed. */ }
+ return headers;
+}
+module.exports = { desktopAPIHeaders, HEADER };
diff --git a/electron/main.cjs b/electron/main.cjs
index 2b46ae0..7f9622b 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -24,7 +24,11 @@
* would shadow every settings-page key change until restart.
*/
-const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
+const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog, safeStorage } = require("electron");
+const { createChatGPT, encryptedStore } = require("./chatgpt.cjs");
+const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
+const { desktopAPIHeaders } = require("./desktop-api.cjs");
+const { randomBytes } = require("node:crypto");
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const http = require("node:http");
@@ -128,6 +132,11 @@ if (process.argv.includes("--browser-service")) {
}
let serverProcess = null;
+let chatgpt = null;
+let chatgptChild = null;
+// Per-launch request capability, never OAuth tokens and never renderer state.
+let desktopAPISecret = null;
+const trustedAppWindows = new Set();
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
// in an event handler): the window + child server are the product, and
@@ -201,17 +210,23 @@ function startServer(port) {
// (no env injection anymore: a spawn-time copy would shadow every
// settings-page key change until restart; env wins over file).
LH_AUTH_FILE: AUTH_FILE(),
+ LH_DESKTOP_SERVER: "1",
+ LH_DESKTOP_API_SECRET: desktopAPISecret,
},
- stdio: ["ignore", "pipe", "pipe"],
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
},
);
serverProcess = child;
+ chatgptChild = attachChatGPTChild(child, chatgpt);
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
child.stdout.on("data", fwd);
child.stderr.on("data", fwd);
child.on("exit", (code, signal) => {
log(`server exited (code=${code} signal=${signal})`);
serverProcess = null;
+ chatgptChild?.dispose();
+ chatgptChild = null;
+ chatgpt?.close();
});
resolve(child);
});
@@ -235,6 +250,7 @@ function waitUntilUp(url, tries = 150) {
}
function killServer() {
+ chatgpt?.close();
if (!serverProcess) return;
const child = serverProcess;
serverProcess = null;
@@ -306,6 +322,8 @@ function createWindow(url) {
minHeight: 600,
});
+ trustedAppWindows.add(win);
+ win.once("closed", () => trustedAppWindows.delete(win));
const allowedOrigin = () => new URL(url).origin;
// Deny-by-default; the ONLY grants are media (mic/STT groundwork) and
// clipboard-sanitized-write — the copy affordances in the transcript
@@ -546,6 +564,7 @@ function installMenu() {
async function main() {
app.on("window-all-closed", () => {
+ chatgpt?.pause();
// macOS convention: the app stays resident, reactivated from the Dock.
if (process.platform !== "darwin") app.quit();
});
@@ -563,6 +582,20 @@ async function main() {
const devUrl = process.env.LH_DEV_URL;
const url = devUrl ?? `http://127.0.0.1:${await freePort()}`;
if (!devUrl) {
+ desktopAPISecret = randomBytes(32).toString("hex");
+ const ownedOrigin = new URL(url).origin;
+ // All URLs, not only /api: remove the header again on external redirects.
+ session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ["
"] }, (details, callback) => {
+ callback({ requestHeaders: desktopAPIHeaders(details, ownedOrigin, desktopAPISecret, [...trustedAppWindows]) });
+ });
+ chatgpt = createChatGPT({
+ store: encryptedStore(path.join(app.getPath("userData"), "chatgpt-credentials.enc.json"), safeStorage),
+ openExternal: (target) => shell.openExternal(target),
+ onChange: (value) => {
+ chatgptChild?.send(value);
+ for (const win of BrowserWindow.getAllWindows()) win.webContents.send("lh:chatgpt:changed");
+ },
+ });
log(`starting server: ${serverEntry()}`);
log(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
await startServer(new URL(url).port);
@@ -572,6 +605,14 @@ async function main() {
log(`dev mode: ${devUrl}`);
}
+ // Renderer controls contain no token/URL/code operation. The separate server
+ // child is the only recipient of credential leases over inherited IPC.
+ ipcMain.handle("lh:chatgpt:control", async (event, command) => {
+ if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) return { error: "Untrusted desktop request." };
+ if (!chatgpt || !serverProcess?.connected) return { available: false, pending: false, activeId: null, accounts: [], error: "ChatGPT sign-in requires the desktop app's private server connection. Browser-only and externally launched development servers keep API-key connections." };
+ return chatGPTControl(command, chatgpt);
+ });
+
installMenu();
// Auto-update (Wave C): wire the updater before the window so the
@@ -597,7 +638,7 @@ async function main() {
if (width < target) win.setBounds({ x, y, width: target, height });
});
app.on("activate", () => {
- if (BrowserWindow.getAllWindows().length === 0) createWindow(url);
+ if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); createWindow(url); }
});
createWindow(url);
log(`window open: ${url}`);
diff --git a/electron/preload.cjs b/electron/preload.cjs
index 1751ae0..ece1a5d 100644
--- a/electron/preload.cjs
+++ b/electron/preload.cjs
@@ -29,6 +29,19 @@ const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("lh", {
platform: process.platform,
+ chatgpt: {
+ status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }),
+ login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }),
+ cancel: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "cancel" }),
+ select: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "select", id }),
+ logout: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "logout", id }),
+ consent: (id, enabled) => ipcRenderer.invoke("lh:chatgpt:control", { op: "consent", id, enabled }),
+ onChange: (cb) => {
+ const listener = () => cb();
+ ipcRenderer.on("lh:chatgpt:changed", listener);
+ return () => ipcRenderer.removeListener("lh:chatgpt:changed", listener);
+ },
+ },
expandWindow: () => ipcRenderer.send("lh:expand-window"),
onZoom: (cb) => {
const listener = (_event, factor) => cb(factor);
diff --git a/package-lock.json b/package-lock.json
index faf0a99..c184bcd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,6 +31,7 @@
"dagre-d3-es": "7.0.14",
"electron-updater": "^6.8.9",
"embla-carousel-react": "^8.6.0",
+ "jose": "^6.2.12",
"katex": "0.18.2",
"linkedom": "^0.18.13",
"lucide-react": "^1.33.0",
@@ -11655,6 +11656,13 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "6.2.12",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
+ "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
+ "license": "MIT",
+ "funding": { "url": "https://github.com/sponsors/panva" }
+ },
"node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
diff --git a/package.json b/package.json
index 2b453eb..89f8f1d 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
"dagre-d3-es": "7.0.14",
"electron-updater": "^6.8.9",
"embla-carousel-react": "^8.6.0",
+ "jose": "^6.2.12",
"katex": "0.18.2",
"linkedom": "^0.18.13",
"lucide-react": "^1.33.0",
diff --git a/proxy.ts b/proxy.ts
new file mode 100644
index 0000000..bb9897e
--- /dev/null
+++ b/proxy.ts
@@ -0,0 +1,17 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { desktopAPIAuthorized } from "./src/server/desktop-access.ts";
+
+/** Applies to every API method, including SSE and spending/settings routes.
+ * No public callback or worker bypass: internal jobs call server functions,
+ * and any legitimate self-HTTP request must carry the private capability. */
+export function proxy(request: NextRequest) {
+ if (!desktopAPIAuthorized(request.headers)) {
+ return NextResponse.json(
+ { error: "Desktop API access requires the trusted LearnHarness window." },
+ { status: 403, headers: { "Cache-Control": "no-store" } },
+ );
+ }
+ return NextResponse.next();
+}
+
+export const config = { matcher: "/api/:path*" };
diff --git a/public/siwc/chatgpt-logo-white.svg b/public/siwc/chatgpt-logo-white.svg
new file mode 100644
index 0000000..b40a361
--- /dev/null
+++ b/public/siwc/chatgpt-logo-white.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/agent/chatgpt-bridge.ts b/src/agent/chatgpt-bridge.ts
new file mode 100644
index 0000000..269e026
--- /dev/null
+++ b/src/agent/chatgpt-bridge.ts
@@ -0,0 +1,122 @@
+import type { Model } from "@earendil-works/pi-ai";
+import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
+import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
+export type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
+export interface Stamp { epoch: number; authorityRevision: number; revision: number }
+interface StatusValue extends Stamp { status: ChatGPTStatus }
+interface ModelsValue extends StatusValue { accountId: string | null; models: { id: string; name: string }[] }
+interface LeaseValue extends Stamp { accessToken: string; accountId: string }
+interface Pending { op: string; resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType }
+interface BridgeState {
+ status: ChatGPTStatus; stamp: Stamp; models: Model<"openai-responses">[]; catalogAccount: string | null;
+ pending: Map; controllers: Map; listeners: Set<() => void>; counter: number; catalogRequest: number;
+}
+const key = Symbol.for("lh.chatgpt.bridge");
+const unavailable = (): ChatGPTStatus => ({ available: false, pending: false, activeId: null, accounts: [], error: null });
+const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision].every((v) => Number.isSafeInteger(v) && v >= 0);
+function bridge(): BridgeState {
+ const global = globalThis as typeof globalThis & { [key]?: BridgeState };
+ if (!global[key]) {
+ const state: BridgeState = { status: unavailable(), stamp: { epoch: 0, authorityRevision: 0, revision: 0 }, models: [], catalogAccount: null, pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0, catalogRequest: 0 };
+ global[key] = state;
+ process.on("message", (raw: unknown) => {
+ const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
+ if (m?.channel !== "lh:chatgpt") return;
+ if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
+ acceptStatus(state, { ...m.stamp, status: m.status });
+ if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
+ if (m.invalidate === "all" || m.invalidate === "background") {
+ for (const [controller, background] of state.controllers) if (m.invalidate === "all" || background) controller.abort(m.invalidate === "all" ? "all" : "background");
+ }
+ for (const listener of state.listeners) listener();
+ }
+ if (m.id) {
+ const call = state.pending.get(m.id);
+ if (!call || call.op !== m.op) return;
+ clearTimeout(call.timer); state.pending.delete(m.id);
+ if (m.ok === true) call.resolve(m.value);
+ else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
+ }
+ });
+ process.on("disconnect", () => {
+ state.status = unavailable(); clearCatalog(state); state.catalogRequest++;
+ for (const controller of state.controllers.keys()) controller.abort("all");
+ for (const call of state.pending.values()) { clearTimeout(call.timer); call.reject(new Error("The desktop connection closed.")); }
+ state.pending.clear();
+ for (const listener of state.listeners) listener();
+ });
+ }
+ return global[key];
+}
+function clearCatalog(state: BridgeState) { state.models = []; state.catalogAccount = null; }
+function acceptStatus(state: BridgeState, value: StatusValue) {
+ if (!validStamp(value) || value.revision < state.stamp.revision) return;
+ if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.status.activeId !== state.status.activeId) clearCatalog(state);
+ state.status = value.status; state.stamp = { epoch: value.epoch, authorityRevision: value.authorityRevision, revision: value.revision };
+}
+/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
+export function chatGPTRequest(op: "status" | "models" | "lease", fields: Record = {}): Promise {
+ const state = bridge();
+ if (!process.send || !process.connected) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
+ return new Promise((resolve, reject) => {
+ const id = `${process.pid}:${++state.counter}`;
+ const timer = setTimeout(() => { state.pending.delete(id); reject(new Error("The ChatGPT desktop connection timed out.")); }, 45000);
+ state.pending.set(id, { op, resolve: (v) => resolve(v as T), reject, timer });
+ process.send!({ ...fields, channel: "lh:chatgpt", id, op }, (error) => {
+ if (error) { clearTimeout(timer); state.pending.delete(id); reject(new Error("The ChatGPT desktop connection closed.")); }
+ });
+ });
+}
+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 chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.accounts.some((a) => a.id === s.activeId && a.background); }
+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 chatGPTModel(id: string): Model<"openai-responses"> | undefined { return chatGPTModels().find((m) => m.id === id); }
+export function chatGPTScope(selectedModelId: string): AdmissionScope {
+ const state = bridge();
+ if (!chatGPTConnected() || !state.catalogAccount || state.catalogAccount !== state.status.activeId || !chatGPTModel(selectedModelId)) throw new Error("ChatGPT account model catalog is unavailable");
+ return { accountId: state.catalogAccount, epoch: state.stamp.epoch, authorityRevision: state.stamp.authorityRevision, selectedModelId };
+}
+export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
+ const s = bridge();
+ return !!process.connected && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
+}
+export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise {
+ if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
+ const value = await chatGPTRequest("lease", { background, expectedAccountId: scope.accountId, expectedAuthorityRevision: scope.authorityRevision, selectedModelId: scope.selectedModelId });
+ if (!validStamp(value) || !chatGPTScopeCurrent(scope) || value.epoch !== scope.epoch || value.authorityRevision !== scope.authorityRevision || value.accountId !== scope.accountId || typeof value.accessToken !== "string" || !value.accessToken || value.accessToken.startsWith("sk-")) throw new Error("ChatGPT lease is stale");
+ return value;
+}
+/** Safe status confirmation can establish consent-only denial, never parse error text. */
+export async function chatGPTDenial(scope: AdmissionScope | undefined, controller: AbortController, callerAborted: boolean, background: boolean): Promise {
+ if (callerAborted || controller.signal.reason === "all") return "all";
+ if (!scope || !chatGPTScopeCurrent(scope)) return "other";
+ if (controller.signal.reason === "background") return "background";
+ if (!background) return "other";
+ try { const value = await chatGPTRequest("status"); acceptStatus(bridge(), value); }
+ catch { return "other"; }
+ if (controller.signal.reason === "all" || !chatGPTScopeCurrent(scope)) return "all";
+ return !chatGPTBackgroundAllowed() ? "background" : "other";
+}
+/** Preserve account order; unknown capabilities/windows are never fabricated. */
+export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise {
+ const state = bridge();
+ if (!process.send || !process.connected) { clearCatalog(state); return; }
+ const sequence = ++state.catalogRequest;
+ try {
+ const value = await chatGPTRequest("models");
+ if (sequence !== state.catalogRequest || !validStamp(value)) return;
+ acceptStatus(state, value);
+ if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.accountId !== state.status.activeId || !chatGPTConnected()) return;
+ state.catalogAccount = value.accountId;
+ state.models = value.models.flatMap((entry) => {
+ const known = supported.find((m) => m.id === entry.id && m.api === "openai-responses");
+ return known ? [{ ...known, provider: "chatgpt", name: entry.name, headers: undefined, samplingParams: undefined, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] : [];
+ });
+ } catch { if (sequence === state.catalogRequest) clearCatalog(state); }
+}
+export function chatGPTAbortController(background = true): { controller: AbortController; release: () => void } {
+ const state = bridge(); const controller = new AbortController(); state.controllers.set(controller, background);
+ return { controller, release: () => { state.controllers.delete(controller); } };
+}
diff --git a/src/agent/chatgpt-request-cycle.ts b/src/agent/chatgpt-request-cycle.ts
new file mode 100644
index 0000000..5be9e1e
--- /dev/null
+++ b/src/agent/chatgpt-request-cycle.ts
@@ -0,0 +1,122 @@
+import type { AgentEvent, AgentMessage, PrepareRequestContext } from "@earendil-works/pi-agent-core";
+import type { AssistantMessage } from "@earendil-works/pi-ai";
+import { nativeInputCause, needsContinuation, type LhMessage } from "../core/native-messages.ts";
+
+export interface AdmissionScope { accountId: string; epoch: number; authorityRevision: number; selectedModelId: string }
+export type NoDispatchKind = "background" | "all" | "other";
+export interface ChatGPTDispatchLifecycle {
+ captured(scope: AdmissionScope): void;
+ beforeDispatch(scope: AdmissionScope): void;
+ noDispatch(kind: NoDispatchKind): void;
+}
+interface CycleOptions {
+ initialMessages: AgentMessage[];
+ staged?: AgentMessage[];
+ foregroundOwed: boolean;
+ purpose: { background: boolean };
+ scopeCurrent: (scope: AdmissionScope) => boolean;
+ commitStaged?: () => void;
+ restoreAutomatic: (automaticIds: ReadonlySet, retainedIds: ReadonlySet) => void;
+}
+const idOf = (m: AgentMessage) => (m as { id?: string }).id;
+/** One logical turn, not an SDK loop. No credentials, persisted provenance or wire protocol. */
+export class ChatGPTRequestCycle implements ChatGPTDispatchLifecycle {
+ private release?: (event: AgentEvent) => void;
+ private held: AgentEvent[] = [];
+ private snapshot: AgentMessage[] = [];
+ private seen: Set;
+ private automatic = new Set();
+ private committed = false;
+ private denied?: NoDispatchKind;
+ private scope?: AdmissionScope;
+ private originalScope?: AdmissionScope;
+ private foregroundOwed: boolean;
+ private inheritedBackground: boolean;
+ private resumed = false;
+ private userAborted = false;
+ private stagedCommitted = false;
+ private started = false;
+ private anyDispatch = false;
+ private firstResolved = false;
+ private resolveAdmission!: (admitted: boolean) => void;
+ readonly admission = new Promise((resolve) => { this.resolveAdmission = resolve; });
+ constructor(private readonly options: CycleOptions) {
+ this.seen = new Set(options.initialMessages.map(idOf).filter((id): id is string => !!id));
+ for (const message of options.staged ?? []) { const id = idOf(message); if (id) this.seen.delete(id); }
+ this.foregroundOwed = options.foregroundOwed;
+ this.inheritedBackground = options.purpose.background;
+ }
+ get dispatched(): boolean { return this.anyDispatch; }
+ get deniedBeforeDispatch(): boolean { return this.denied !== undefined; }
+ abort(): void { this.userAborted = true; }
+ bindRelease(release: (event: AgentEvent) => void): () => void { this.release = release; return () => { this.release = undefined; }; }
+ prepare(request: PrepareRequestContext): void {
+ const messages = request.context.messages as AgentMessage[];
+ this.snapshot = [...messages]; this.automatic = new Set();
+ let learner = false;
+ for (const m of messages) {
+ const id = idOf(m); if (!id || this.seen.has(id)) continue;
+ const cause = nativeInputCause(m, messages.slice(0, messages.indexOf(m)));
+ if (cause === "automatic") this.automatic.add(id);
+ if (cause === "learner") learner = true;
+ }
+ if (this.automatic.size > 0) this.options.purpose.background = true;
+ const retained = messages.filter((m) => !this.automatic.has(idOf(m) ?? ""));
+ this.foregroundOwed ||= learner || (!this.inheritedBackground && needsContinuation(retained as LhMessage[]));
+ }
+ hold(event: AgentEvent): boolean {
+ if (event.type === "agent_start") { if (this.started) return true; this.started = true; return false; }
+ if (event.type === "agent_end") return true; // settle only after native activeRun is cleared
+ if (event.type === "turn_start") {
+ this.committed = false; this.denied = undefined; this.scope = undefined;
+ // A retained learner batch from the denied cycle already owns its turn_start.
+ if (!this.held.some((e) => e.type === "turn_start")) this.held.push(event);
+ return true;
+ }
+ if (!this.committed) { this.held.push(event); return true; }
+ return false;
+ }
+ captured(scope: AdmissionScope): void {
+ if (this.originalScope && this.resumed && JSON.stringify(scope) !== JSON.stringify(this.originalScope)) throw new Error("ChatGPT recovery scope changed");
+ this.scope = { ...scope };
+ this.originalScope ??= { ...scope };
+ }
+ beforeDispatch(scope: AdmissionScope): void {
+ if (this.userAborted || this.denied || this.committed || !this.scope || JSON.stringify(scope) !== JSON.stringify(this.scope) || !this.options.scopeCurrent(scope)) throw new Error("ChatGPT admission cancelled");
+ if (!this.stagedCommitted && (this.options.staged?.length ?? 0) > 0) {
+ this.options.commitStaged?.(); // synchronous durable prefix, before visibility and fetch
+ this.stagedCommitted = true;
+ const stagedEvents: AgentEvent[] = this.options.staged!.flatMap((message) => [{ type: "message_start" as const, message }, { type: "message_end" as const, message }]);
+ const boundary = this.held.findIndex((event) => event.type === "turn_start");
+ this.held.splice(boundary < 0 ? 0 : boundary + 1, 0, ...stagedEvents);
+ }
+ this.committed = true; this.anyDispatch = true; this.foregroundOwed = false;
+ this.inheritedBackground = this.options.purpose.background;
+ for (const m of this.snapshot) { const id = idOf(m); if (id) this.seen.add(id); }
+ const events = this.held; this.held = [];
+ for (const event of events) this.release?.(event);
+ this.resolveFirst(true);
+ }
+ noDispatch(kind: NoDispatchKind): void { if (!this.committed) this.denied = this.userAborted ? "all" : kind; }
+ acceptsUsage(_message: AssistantMessage): boolean { return this.committed; }
+ private resolveFirst(value: boolean) { if (!this.firstResolved) { this.firstResolved = true; this.resolveAdmission(value); } }
+ finishNativeCycle(): { action: "settle" | "resumeForeground"; messages: AgentMessage[] } {
+ if (!this.denied) { this.resolveFirst(this.anyDispatch); return { action: "settle", messages: [] }; }
+ const messages = this.snapshot.filter((m) => !this.automatic.has(idOf(m) ?? ""));
+ const retainedIds = new Set(messages.map(idOf).filter((id): id is string => !!id));
+ this.options.restoreAutomatic(this.automatic, retainedIds);
+ this.resolveFirst(false); // queue deferral is installed before initial fire cargo returns
+ this.held = this.held.filter((e) => (e.type === "turn_start") || ((e.type === "message_start" || e.type === "message_end") && e.message.role !== "assistant" && !this.automatic.has(idOf(e.message) ?? "")));
+ const retry = this.denied === "background" && !this.userAborted && !this.resumed && this.foregroundOwed && !!this.scope && this.options.scopeCurrent(this.scope);
+ if (retry) {
+ this.resumed = true; this.options.purpose.background = false;
+ this.seen = retainedIds;
+ this.denied = undefined;
+ // Staged automatic input must not be resurrected on foreground recovery.
+ this.options.staged = undefined;
+ return { action: "resumeForeground", messages };
+ }
+ this.held = [];
+ return { action: "settle", messages };
+ }
+}
diff --git a/src/agent/chatgpt-stream.ts b/src/agent/chatgpt-stream.ts
new file mode 100644
index 0000000..dec7c2d
--- /dev/null
+++ b/src/agent/chatgpt-stream.ts
@@ -0,0 +1,118 @@
+import type { Api, AssistantMessage, Model, SimpleStreamOptions, StreamFunction, TranscriptContext } from "@earendil-works/pi-ai";
+import { streamSimple } from "@earendil-works/pi-ai/api/openai-responses";
+import { AssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
+import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
+import { chatGPTAbortController, chatGPTScope, chatGPTScopeCurrent, chatGPTLease, chatGPTDenial } from "./chatgpt-bridge.ts";
+import type { AdmissionScope, ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
+
+const USAGE = "https://chatgpt.com/settings/usage";
+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 (/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 (/expired|invalid_grant|401|invalid_user/.test(message)) return "Your ChatGPT session expired. Reconnect in Connections. 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 (/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 (/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 (/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.";
+}
+
+/** Restrict the body at the final payload seam, even if caller samplingParams add fields. */
+export function chatGPTPayload(raw: unknown, selectedModelId: string): Record {
+ const payload = raw as Record;
+ const out: Record = { model: selectedModelId, store: false, stream: true };
+ for (const key of ["instructions", "reasoning", "include", "text", "tool_choice", "parallel_tool_calls", "prompt_cache_key"]) {
+ if (payload[key] !== undefined) out[key] = payload[key];
+ }
+ if (!Array.isArray(payload.input)) throw new Error("ChatGPT requires stateless input history");
+ out.input = payload.input.map((item) => item?.role === "system" ? { ...item, role: "developer" } : item);
+ if (Array.isArray(payload.tools) && payload.tools.length) {
+ if (payload.tools.some((tool) => tool.type !== "function" && tool.type !== "custom")) throw new Error("Unsupported ChatGPT tool capability");
+ out.tools = [{ type: "namespace", name: "learnharness", description: "LearnHarness local learning tools", tools: payload.tools }];
+ }
+ return out;
+}
+
+/** Only ChatGPT history is translated. Mixed API-key history keeps its actual identity. */
+export function chatGPTHistory(context: TranscriptContext, logicalModel: Model): TranscriptContext {
+ // Apply Pi's normal foreign-model signature treatment BEFORE the alias
+ // becomes openai, or real API-key turns could look like subscription turns.
+ const messages = transformMessages(context.messages, logicalModel);
+ return { ...context, messages: messages.map((m) => m.role === "assistant" && m.provider === "chatgpt" ? { ...m, provider: "openai" } : m) };
+}
+function empty(model: Model): AssistantMessage {
+ return { role: "assistant", api: model.api, provider: "chatgpt", model: model.id, content: [], stopReason: "error", timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
+}
+export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle): StreamFunction {
+ return (logicalModel, context, options) => {
+ const output = new AssistantMessageEventStream();
+ const purpose = background();
+ const { controller, release } = chatGPTAbortController(purpose);
+ const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
+ void (async () => {
+ let partial = empty(logicalModel);
+ let completed = false;
+ let failure = "";
+ let scope: AdmissionScope | undefined;
+ let committed = false;
+ let terminal = false;
+ const check = () => { if (signal.aborted || !scope || !chatGPTScopeCurrent(scope)) throw new Error("cancelled or catalog authority changed"); };
+ try {
+ if (logicalModel.provider !== "chatgpt") throw new Error("ChatGPT account catalog has no selected model");
+ if (Object.keys(options?.headers ?? {}).some((key) => key.toLowerCase() === "authorization")) throw new Error("Authorization override is forbidden");
+ scope = chatGPTScope(logicalModel.id);
+ lifecycle?.captured(scope);
+ const credentials = await chatGPTLease(scope, purpose);
+ check();
+ // NEVER dispatch through Models under openai: that would expose ambient API-key fallback.
+ const transport: Model<"openai-responses"> = { ...logicalModel, api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", headers: undefined, samplingParams: undefined, compat: { ...logicalModel.compat, supportsDeveloperRole: true } };
+ const delegate = options?.fetch ?? globalThis.fetch;
+ const source = streamSimple(transport, chatGPTHistory(context, logicalModel), {
+ ...options,
+ apiKey: credentials.accessToken,
+ signal,
+ maxRetries: 0,
+ headers: { "OpenAI-Beta": null, ...options?.headers },
+ fetch: (input, init) => {
+ check();
+ if (committed) throw new Error("ChatGPT does not retry admitted requests");
+ lifecycle?.beforeDispatch(scope!);
+ committed = true;
+ return delegate(input, init);
+ },
+ onPayload: async (raw, model) => {
+ const replacement = await options?.onPayload?.(raw, model);
+ const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id);
+ check();
+ return payload;
+ },
+ onProviderStreamEvent: async (raw, model) => {
+ const event = raw as { type?: string; code?: string; error?: { code?: string }; response?: { error?: { code?: string }; status?: string } };
+ if (event.type === "response.completed" && event.response?.status === "completed" && !event.response?.error) completed = true;
+ if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) failure = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response";
+ await options?.onProviderStreamEvent?.(raw, model);
+ },
+ });
+ for await (const event of source) {
+ const message = "partial" in event ? event.partial : event.type === "done" ? event.message : event.error;
+ // Copy the message, not its tool/reasoning blocks: IDs/signatures remain byte-for-byte intact.
+ partial = { ...message, provider: "chatgpt" };
+ if (event.type === "done") {
+ if (!completed || failure) throw new Error(failure || "missing terminal response.completed");
+ terminal = true;
+ output.push({ ...event, message: partial });
+ } else if (event.type === "error") {
+ throw new Error(failure || event.error.errorMessage || "ChatGPT stream error");
+ } else output.push({ ...event, partial });
+ }
+ } catch (err) {
+ if (terminal) return;
+ if (!committed) lifecycle?.noDispatch(await chatGPTDenial(scope, controller, options?.signal?.aborted === true, purpose));
+ partial = { ...partial, stopReason: signal.aborted ? "aborted" : "error", errorMessage: chatGPTStreamError(err instanceof Error ? err.message : "") };
+ output.push({ type: "error", reason: partial.stopReason as "error" | "aborted", error: partial });
+ } finally { release(); output.end(); }
+ })();
+ return output;
+ };
+}
diff --git a/src/agent/models.ts b/src/agent/models.ts
index 80bc11e..5a9d8d0 100644
--- a/src/agent/models.ts
+++ b/src/agent/models.ts
@@ -12,6 +12,9 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { type ModelRole } from "../../config.ts";
import { DEFAULT_THINKING_ORDER } from "../core/settings.ts";
import packageMetadata from "../../package.json" with { type: "json" };
+import { chatGPTModel } from "./chatgpt-bridge.ts";
+import { chatGPTStream } from "./chatgpt-stream.ts";
+import type { ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
/** App identity is immutable and shared by Models and every native Agent.
* Agent's default Pi attribution otherwise overrides the Models identity. */
@@ -93,9 +96,9 @@ function defaultThinkingLevel(model: Model): ThinkingLevel {
* key is resolved by pi-ai itself from the environment per request (the
* keys seam reflects file-saved keys into env), so the engine never
* hardcodes OpenRouter's. */
-export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
+export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
const models: Models = piModels();
- const model = models.getModel(spec.provider, spec.id);
+ const model = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : models.getModel(spec.provider, spec.id);
if (!model) {
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
}
@@ -111,11 +114,10 @@ export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
// bound streamSimple: the agent loop forwards state.thinkingLevel as
// `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps
// the pin honest even when config or settings drift off the map.
- streamFn: (requestModel, context, options) =>
- models.streamSimple(requestModel, context, {
- ...options,
- reasoning: level === "off" ? undefined : level,
- }),
+ streamFn: (requestModel, context, options) => {
+ const stream = spec.provider === "chatgpt" ? chatGPTStream(typeof background === "function" ? background : () => background, lifecycle) : models.streamSimple.bind(models);
+ return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
+ },
};
}
diff --git a/src/agent/subagents/headless.ts b/src/agent/subagents/headless.ts
index 8cbbfc8..2325fe9 100644
--- a/src/agent/subagents/headless.ts
+++ b/src/agent/subagents/headless.ts
@@ -137,6 +137,9 @@ export function browserServiceSpawnEnv(
const out: Record = { ...env };
delete out.ELECTRON_RUN_AS_NODE;
delete out.LH_DEV_URL;
+ // The artifact renderer is not an authorized app API client.
+ delete out.LH_DESKTOP_API_SECRET;
+ delete out.LH_DESKTOP_SERVER;
return out;
}
diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts
index 892320f..fa92731 100644
--- a/src/agent/teacher-agent.ts
+++ b/src/agent/teacher-agent.ts
@@ -18,7 +18,8 @@ import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
import { healToolRecord } from "./tools/heal.ts";
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
-import { toLlmMessages } from "../core/native-messages.ts";
+import { toLlmMessages, nativeInputCause } from "../core/native-messages.ts";
+import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts";
/**
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
@@ -97,6 +98,8 @@ export type StepUsageCollector = (usage: Usage) => void;
export interface AgentBuildOptions {
/** Model + stream function — tests inject a scripted faux engine. */
engine?: PiEngine;
+ callPurpose?: { background: boolean };
+ requestCycle?: ChatGPTRequestCycle;
/** The initial transcript, as pi messages (the record IS agent state). */
messages?: AgentMessage[];
/** Money channel: one call per model request. */
@@ -178,6 +181,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// LH_PROVIDER_EVENTS=1 and the caller handed a chatId; undefined is
// the normal no-observer path (tests, env-off).
const providerEvents = opts.chatId ? providerEventObserver(opts.chatId, kind) : undefined;
+ const seenInputs = new Set((opts.messages ?? []).map((m) => (m as { id?: string }).id).filter(Boolean));
const agent = new Agent({
identity: APP_IDENTITY,
sessionId: opts.chatId,
@@ -211,13 +215,31 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// an abort's dangling question call gets its INTERRUPTED result. It
// must never throw (pi's convertToLlm contract).
convertToLlm: toLlmMessages,
+ prepareRequest: (request) => {
+ if (opts.callPurpose) {
+ let learner = false, automatic = false;
+ const messages = request.context.messages;
+ for (const message of messages) {
+ const id = (message as { id?: string }).id;
+ const cause = nativeInputCause(message, messages.slice(0, messages.indexOf(message)));
+ if (cause === "neutral") continue;
+ if (id && seenInputs.has(id)) continue;
+ if (id) seenInputs.add(id);
+ if (cause === "learner" && id) learner = true;
+ else automatic = true; // missing/unrecognized identity never grants foreground
+ }
+ if (automatic) opts.callPurpose.background = true;
+ else if (learner) opts.callPurpose.background = false;
+ }
+ opts.requestCycle?.prepare(request);
+ },
});
if (opts.onUsage) {
agent.subscribe((event: AgentEvent) => {
if (event.type === "message_end" && event.message.role === "assistant" && "usage" in event.message) {
// A cancelled call is not a billed step (partial/estimated
// usage on an aborted message is not money spent).
- if (event.message.stopReason === "aborted") return;
+ if (event.message.stopReason === "aborted" || (opts.requestCycle && !opts.requestCycle.acceptsUsage(event.message))) return;
opts.onUsage!(event.message.usage);
}
});
diff --git a/src/core/contracts/chatgpt.ts b/src/core/contracts/chatgpt.ts
new file mode 100644
index 0000000..d737449
--- /dev/null
+++ b/src/core/contracts/chatgpt.ts
@@ -0,0 +1,18 @@
+/** Client-safe status only. Credentials and OAuth transaction data never enter this contract. */
+export interface ChatGPTStatus {
+ available: boolean;
+ pending: boolean;
+ activeId: string | null;
+ accounts: { id: string; email: string; registration: string; connected: boolean; background: boolean; planEnabled?: boolean }[];
+ error: string | null;
+ catalogError?: string | null;
+}
+export interface ChatGPTControlBridge {
+ status(): Promise;
+ login(id?: string): Promise;
+ cancel(): Promise;
+ select(id: string): Promise;
+ logout(id: string): Promise;
+ consent(id: string, enabled: boolean): Promise;
+ onChange(callback: () => void): () => void;
+}
diff --git a/src/core/cost-summary.ts b/src/core/cost-summary.ts
index 641acc2..5932179 100644
--- a/src/core/cost-summary.ts
+++ b/src/core/cost-summary.ts
@@ -18,10 +18,12 @@
*/
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
-import { callCost, sumSteps } from "./prices.ts";
+import { callCost, sumSteps, isChatGPTPlan } from "./prices.ts";
/** One pipeline stage's cumulative cost across every run. */
export interface StageCost {
+ /** ChatGPT plan stage-runs, excluded from API dollars and unpriced counts. */
+ subscriptionRuns?: number;
/** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
dollars: number;
/** Stage-runs whose model is missing from PRICES (render "—", never zero). */
@@ -34,6 +36,7 @@ export interface StageCost {
/** One run in the recent-runs list (extraction or naming). */
export interface RecentRun {
+ subscriptionRuns?: number;
startedAt: number;
/** ISO wall-clock. */
at: string;
@@ -51,9 +54,10 @@ export interface RecentRun {
/** The cost pill's whole payload: stage totals + recent runs. */
export interface GraphCostSummary {
+ subscriptionRuns?: number;
/** Inferred USD across all priced stages of all runs. */
totalDollars: number;
- /** Runs where EVERY stage was unpriced (totalDollars excludes them). */
+ /** Runs with no priced API stage and at least one unknown stage. */
unpricedRuns: number;
/** The billable pipeline stages. (Wave 7, owner ruling 2026-09-23:
* the embedding stage is gone with the provider embedder; the
@@ -89,6 +93,7 @@ const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tok
/** One stage's dollars over its steps; null when the model is unpriced
* (all-or-nothing: a stage runs one model). */
function stageDollars(model: string, steps: readonly CallUsage[]): number | null {
+ if (isChatGPTPlan(model)) return null;
let total = 0;
for (const step of steps) {
const c = callCost(step, model);
@@ -121,13 +126,15 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
extraction.runs++;
mergeTokens(extraction.tokens, stages.extractor.steps);
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps);
- if (extDollars === null) extraction.unpricedRuns++;
+ if (isChatGPTPlan(stages.extractor.model)) extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1;
+ else if (extDollars === null) extraction.unpricedRuns++;
else extraction.dollars += extDollars;
audit.runs++;
mergeTokens(audit.tokens, stages.auditor.steps);
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps);
- if (audDollars === null) audit.unpricedRuns++;
+ if (isChatGPTPlan(stages.auditor.model)) audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1;
+ else if (audDollars === null) audit.unpricedRuns++;
else audit.dollars += audDollars;
// Run-level: null only when EVERY stage is unpriced.
@@ -135,8 +142,10 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
const runDollars = allStages.every((d) => d === null)
? null
: allStages.reduce((a, d) => a + (d ?? 0), 0);
+ const subscriptionRuns = [stages.extractor, stages.auditor].filter((stage) => isChatGPTPlan(stage.model)).length;
const allSteps = [...stages.extractor.steps, ...stages.auditor.steps];
runs.push({
+ ...(subscriptionRuns ? { subscriptionRuns } : {}),
startedAt: run.startedAt,
at: run.at,
durationMs: run.durationMs,
@@ -159,8 +168,10 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
const capped = runs.slice(0, input.recentCap ?? 10);
// A run is "unpriced" when it billed nothing countable at all.
- const unpricedRuns = runs.filter((r) => r.dollars === null).length;
+ const unpricedRuns = runs.filter((r) => r.dollars === null && (r.subscriptionRuns ?? 0) < 2).length;
+ const subscriptionRuns = (extraction.subscriptionRuns ?? 0) + (audit.subscriptionRuns ?? 0);
return {
+ ...(subscriptionRuns ? { subscriptionRuns } : {}),
totalDollars: extraction.dollars + audit.dollars,
unpricedRuns,
stages: { extraction, audit },
diff --git a/src/core/index.ts b/src/core/index.ts
index fa1da78..c08e795 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -48,6 +48,7 @@ export * from "./input-events.ts";
export * from "./view.ts";
export * from "./tool-parts.ts";
export * from "./prices.ts";
+export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
export * from "./cost-summary.ts";
export * from "./fuzzy.ts";
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts
index f7a174d..2983e4f 100644
--- a/src/core/native-messages.ts
+++ b/src/core/native-messages.ts
@@ -674,6 +674,57 @@ export function endsInDismissal(messages: readonly LhMessage[]): boolean {
return last !== undefined && isDismissedResult(last);
}
+export type RequestCause = "foreground" | "background";
+export type ReconstructedCause = RequestCause | "ambiguous";
+export type NativeInputCause = "learner" | "automatic" | "neutral" | "unrecognizable";
+/** Native causes only: flattened user roles, IDs' prefixes and prose confer no permission. */
+export function nativeInputCause(message: AgentMessage, history: readonly AgentMessage[]): NativeInputCause {
+ if (message.role === "user") return "learner";
+ if (message.role === "system" || message.role === "assistant") return "neutral";
+ if (message.role === "custom") {
+ if (["probe-handoff", "subagent-steer", "subagent-abort"].includes(message.customType)) return "automatic";
+ if (["practice-thinking", "practice-next"].includes(message.customType)) return "learner";
+ return ["graph-context", "plan-block", "problem-dismissed", "compaction"].includes(message.customType) ? "neutral" : "unrecognizable";
+ }
+ if (message.role === "toolResult") {
+ if (!(QUESTION_TOOLS as readonly string[]).includes(message.toolName) || message.isError || message.details == null || (message.details as { dismissed?: boolean }).dismissed) return "neutral";
+ const details = message.details as Partial;
+ if (!["quiz", "ask", "explain"].includes(details.kind ?? "") || typeof details.questionText !== "string" || typeof details.dontKnow !== "boolean" || !Array.isArray(details.answers) || typeof details.answeredAt !== "string") return "unrecognizable";
+ const calls = history.flatMap((m) => m.role === "assistant" ? m.content.filter((b) => b.type === "toolCall" && b.id === message.toolCallId) : []);
+ return calls.length === 1 && calls[0]?.type === "toolCall" && calls[0].name === message.toolName ? "learner" : "unrecognizable";
+ }
+ return "unrecognizable";
+}
+/** Full persisted native record, including pre-compaction causes. Never guessed foreground. */
+export function reconstructRequestCause(messages: readonly LhMessage[]): ReconstructedCause {
+ let inherited: ReconstructedCause = "ambiguous";
+ let triggers: NativeInputCause[] = [];
+ const seen = new Map();
+ const history: AgentMessage[] = [];
+ const resolve = (): ReconstructedCause => {
+ if (triggers.includes("unrecognizable")) return "ambiguous";
+ const lastLearner = triggers.lastIndexOf("learner"), lastAutomatic = triggers.lastIndexOf("automatic");
+ if (lastLearner < 0 && lastAutomatic < 0) return inherited;
+ if (lastAutomatic < 0) return "foreground";
+ if (lastLearner < lastAutomatic) return "background";
+ return "ambiguous";
+ };
+ for (const message of messages) {
+ const cause = nativeInputCause(message, history);
+ const id = (message as { id?: string }).id;
+ if (id) {
+ const value = JSON.stringify(message);
+ if (seen.has(id)) { if (seen.get(id) !== value) triggers.push("unrecognizable"); continue; }
+ seen.set(id, value);
+ } else if (cause !== "neutral") triggers.push("unrecognizable");
+ if (message.role === "assistant" && !(message.provider === "synthetic" && message.api === "faux" && message.model === "synthetic-conclude" && message.content.some((b) => b.type === "toolCall" && b.name === "conclude" && b.arguments?.outcome === "dismissed"))) {
+ inherited = resolve(); triggers = [];
+ } else if (cause !== "neutral") triggers.push(cause);
+ history.push(message);
+ }
+ return resolve();
+}
+
export function needsContinuation(messages: readonly LhMessage[]): boolean {
const last = messages[messages.length - 1];
if (!last || (last as { role?: string }).role !== "toolResult") return false;
diff --git a/src/core/prices.ts b/src/core/prices.ts
index c7bc803..244c1f7 100644
--- a/src/core/prices.ts
+++ b/src/core/prices.ts
@@ -1661,6 +1661,8 @@ export function sumSteps(steps: readonly CallUsage[]): CallUsage {
/** One model call's inferred cost in USD — null when the model is unknown
* or the usage fields are absent (never guess a price). Cached input bills
* at the cache-read rate; cache writes bill separately where priced. */
+export function isChatGPTPlan(model: string | undefined): boolean { return model?.startsWith("chatgpt:") === true; }
+
export function callCost(
usage: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number },
model: string,
@@ -1681,6 +1683,7 @@ export function callCost(
/** One turn's inferred cost — the sum over its steps; null when unpriceable. */
export function turnCost(turn: TurnUsage): number | null {
+ if (isChatGPTPlan(turn.model)) return null;
let total = 0;
for (const step of turn.steps) {
const c = callCost(step, turn.model);
@@ -1693,15 +1696,17 @@ export function turnCost(turn: TurnUsage): number | null {
/** Fold a chat record's turn log: total inferred session cost. Unknown-model
* or partial-usage turns don't poison the total — they count as unpriced
* and are reported alongside, so the UI can show "n turns unpriced". */
-export function sessionCost(record: Pick): { cost: number; unpriced: number } {
+export function sessionCost(record: Pick): { cost: number; unpriced: number; subscription?: number } {
let cost = 0;
let unpriced = 0;
+ let subscription = 0;
for (const turn of record.turns ?? []) {
+ if (isChatGPTPlan(turn.model)) { subscription++; continue; }
const c = turnCost(turn);
if (c === null) unpriced++;
else cost += c;
}
- return { cost, unpriced };
+ return { cost, unpriced, ...(subscription ? { subscription } : {}) };
}
/** The session's context-window footprint: the LAST model call's
diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts
index e4e5108..e45a228 100644
--- a/src/server/chat-handler.ts
+++ b/src/server/chat-handler.ts
@@ -7,6 +7,10 @@ import {
type TeachingAgent,
} from "../agent/teacher-agent.ts";
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
+import { chatGPTBackgroundAllowed, chatGPTScopeCurrent } from "../agent/chatgpt-bridge.ts";
+import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
+import { ChatGPTRequestCycle, type ChatGPTDispatchLifecycle } from "../agent/chatgpt-request-cycle.ts";
+import { reconstructRequestCause } from "../core/native-messages.ts";
import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import type { ModelRole } from "../../config.ts";
@@ -15,6 +19,7 @@ import {
noteSteerVisible,
reconcileUserSteers,
registerLiveAgent,
+ restoreUncommittedAutomatic,
setJobRespawn,
setTurnRunner,
settleSteers,
@@ -41,7 +46,7 @@ import { persistTurn, triggerExtraction } from "./turn-service.ts";
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
import { composeInstructions } from "../agent/teacher-agent.ts";
import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts";
-import { catalogContextLimits } from "./model-catalog.ts";
+import { catalogContextLimits, refreshConnectionCatalog } from "./model-catalog.ts";
import { emit } from "../core/events.ts";
import { settledTurnResponse } from "./turn-runs.ts";
import { asAgentMessages } from "../core/native-messages.ts";
@@ -201,6 +206,12 @@ export async function handleChatInput(
// persist (nothing durable changes); wake-pipeline turns ride the same
// path. The affected tier is strong — the conversation chain's own tier
// (a chat.model override still resolves through it).
+ if (conversation.provider === "chatgpt") {
+ await refreshConnectionCatalog();
+ 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 });
+ }
+ }
if (!providerKeyPresent(conversation.provider)) {
const envelope = providerKeyMissingEnvelope(conversation.provider, ["strong"]);
console.info(`[chat ${chatId}] turn refused — ${JSON.stringify(envelope)}`);
@@ -216,12 +227,22 @@ export async function handleChatInput(
// placement mirrors the BYOK/key gates — BEFORE appends and
// beginTurn, no liveness effects — and the record re-reads after
// because compaction appends the marker.
- const conversationEngine = piEngineForSpec(conversation);
+ const callPurpose = { background: input.type === "steers" || (input.type === "continue" && reconstructRequestCause(record.messages) !== "foreground") };
+ let requestCycle: ChatGPTRequestCycle | undefined;
+ const lifecycle: ChatGPTDispatchLifecycle = {
+ captured: (scope) => requestCycle?.captured(scope),
+ beforeDispatch: (scope) => requestCycle?.beforeDispatch(scope),
+ noDispatch: (kind) => requestCycle?.noDispatch(kind),
+ };
+ let conversationEngine: PiEngine;
+ try { conversationEngine = piEngineForSpec(conversation, () => callPurpose.background, lifecycle); }
+ catch { return Response.json({ error: conversation.provider === "chatgpt" ? chatGPTStreamError("model catalog") : "The selected model is unavailable." }, { status: 503 }); }
const modelIdForCompaction = `${conversationEngine.model.provider}:${conversationEngine.model.id}`;
await ensureCompacted(
{
chatStore: stores.chatStore,
- engine: conversationEngine,
+ engine: piEngineForSpec(conversation, true),
+ canStart: () => conversation.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
modelId: modelIdForCompaction,
kind: record.kind === "practice" ? "practice" : hasProbeDone(record.messages) ? "teach" : "probe",
thresholdTokens: effectiveThresholdTokens(
@@ -287,12 +308,14 @@ export async function handleChatInput(
// ride this same write-ahead - the pump de-queued them at fire, so this
// is the ONLY copy ever appended.)
const merged = [...record.messages, ...appends];
+ const stagedAutomatic = conversation.provider === "chatgpt" && input.type === "steers";
+ let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
try {
- stores.chatStore.saveMessages(chatId, merged);
+ if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
// Dock fix (owner ruling 2026-09-26): persisted markers are visible
// in the record — flip their jobs now (the wake's steers ride this
// write-ahead; the delivering turn may run for minutes).
- for (const appended of appends) {
+ for (const appended of stagedAutomatic ? [] : appends) {
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
}
} catch (err) {
@@ -339,8 +362,19 @@ export async function handleChatInput(
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
// user: quiz/ask/fs/plan/probe_done are always-on).
+ if (conversation.provider === "chatgpt") requestCycle = new ChatGPTRequestCycle({
+ initialMessages: asAgentMessages(merged),
+ staged: stagedAutomatic ? asAgentMessages(appends) : undefined,
+ foregroundOwed: !callPurpose.background,
+ purpose: callPurpose,
+ scopeCurrent: chatGPTScopeCurrent,
+ commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; },
+ restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
+ });
const agentOpts: AgentBuildOptions = {
engine,
+ callPurpose,
+ requestCycle,
messages: asAgentMessages(merged),
onUsage: collectUsage,
toolToggles: effective.agent.tools,
@@ -439,6 +473,7 @@ export async function handleChatInput(
providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`);
const handle = runPiTurn({
agent: constructed,
+ requestCycle,
// The coalescing window seam (COMPACTION LAW, 2026-10-09): tests
// tighten it via LH_UPDATE_FLUSH_MS; production rides the 100ms
// default in ui-stream.
@@ -454,13 +489,14 @@ export async function handleChatInput(
// route through here: they ride the error-result path and reach the
// transcript directly (the retry context the model saw).
onError: (error) => {
+ if (conversation.provider === "chatgpt") return chatGPTStreamError(String(error));
console.error(`[chat ${chatId}] turn error:`, error);
return "An error occurred.";
},
onFinish: (turnMessages, isAborted) => {
// Wave B: the record write is the agent's own final state —
// the new messages are the slice past the merged base.
- const stamped = persistTurn(stores.chatStore, chatId, merged.length, turnMessages, isAborted);
+ const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted);
// usage log append (idempotent per turn id) — the stats bar
// refolds this on the client after the turn settles. The
// provenance message is the turn's FIRST new assistant message.
@@ -479,7 +515,7 @@ export async function handleChatInput(
// PRACTICE (owner, 2026-10-04): practice sessions write nothing —
// no extraction, no graph, no coverage (v1: the session itself
// is the deliverable).
- if (record.kind !== "practice" && extractionEnabled(effective.graph)) {
+ if ((!requestCycle || requestCycle.dispatched) && record.kind !== "practice" && extractionEnabled(effective.graph)) {
// Fresh per-turn read of the graph section's chunk budget
// (extraction-UX redesign, 2026-09-24): the trigger compares
// against it and the pipeline's batches share it.
@@ -520,18 +556,20 @@ export async function handleChatInput(
// beneath-threshold record is a silent no-op. Skipped under
// tests (the extraction precedent — scripted chats are
// fixtures; the service itself is pinned directly).
- if (process.env.NODE_ENV !== "test")
+ if (process.env.NODE_ENV !== "test" && (!requestCycle || requestCycle.dispatched))
kickCompaction(() => {
const fresh = effectiveSettings();
const spec = resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, fresh.models.strong);
- if (!spec) return null;
- const kickEngine = piEngineForSpec(spec);
+ if (!spec || (spec.provider === "chatgpt" && !chatGPTBackgroundAllowed())) return null;
+ let kickEngine: PiEngine;
+ try { kickEngine = piEngineForSpec(spec, true); } catch { return null; }
const kickModelId = `${kickEngine.model.provider}:${kickEngine.model.id}`;
const freshRecord = stores.chatStore.getChat(chatId);
if (!freshRecord) return null;
return {
chatStore: stores.chatStore,
engine: kickEngine,
+ canStart: () => spec.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
modelId: kickModelId,
kind: freshRecord.kind === "practice" ? "practice" : hasProbeDone(freshRecord.messages) ? "teach" : "probe",
thresholdTokens: effectiveThresholdTokens(
@@ -558,6 +596,7 @@ export async function handleChatInput(
// must cost zero permanently-held slots beyond the one live view. The
// turn itself is a server-side process (ruling 2026-09-25) — it never
// needed this connection, only a view did.
+ if (stagedAutomatic && requestCycle && !(await requestCycle.admission)) return Response.json({ error: "Automatic follow-up remains deferred in its queue." }, { status: 403 });
return Response.json({ ok: true });
} catch (err) {
// The stream never started, so no onFinish will fire — clear the registry
diff --git a/src/server/compaction.ts b/src/server/compaction.ts
index 8cb775e..95aabbd 100644
--- a/src/server/compaction.ts
+++ b/src/server/compaction.ts
@@ -89,6 +89,8 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
/** Everything the service needs from its host (chat-handler wires the
* production versions; tests inject). */
export interface CompactionDeps {
+ /** Eligibility only for NEW runs; structural waits still apply. */
+ canStart?: () => boolean;
chatStore: ChatStore;
/** The chat's conversation engine — also the summarizer's. */
engine: PiEngine;
@@ -267,6 +269,7 @@ export async function ensureCompacted(deps: CompactionDeps, chatId: string): Pro
// injected summarizer.
if (process.env.NODE_ENV === "test" && !deps.summarize) return;
await awaitCompaction(chatId);
+ if (deps.canStart?.() === false) return;
const record = deps.chatStore.getChat(chatId);
if (!record) return;
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
@@ -291,7 +294,7 @@ export async function ensureCompacted(deps: CompactionDeps, chatId: string): Pro
export function kickCompaction(buildDeps: () => CompactionDeps | null, chatId: string): void {
if (state().inFlight.has(chatId)) return;
const deps = buildDeps();
- if (!deps) return;
+ if (!deps || deps.canStart?.() === false) return;
const record = deps.chatStore.getChat(chatId);
if (!record) return;
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
diff --git a/src/server/desktop-access.ts b/src/server/desktop-access.ts
new file mode 100644
index 0000000..7a53fa9
--- /dev/null
+++ b/src/server/desktop-access.ts
@@ -0,0 +1,17 @@
+import { timingSafeEqual } from "node:crypto";
+
+/** Per-launch desktop HTTP capability. This is not an OS-user malware
+ * boundary and is not an OAuth credential. Browser-only mode is unchanged. */
+export function desktopAPIAuthorized(
+ headers: Headers,
+ env: Record = process.env,
+): boolean {
+ const expected = env.LH_DESKTOP_API_SECRET;
+ if (env.LH_DESKTOP_SERVER !== "1" && !expected) return true;
+ if (!expected || expected.length < 64) return false;
+ const supplied = headers.get("x-learnharness-desktop");
+ if (!supplied) return false;
+ const actual = Buffer.from(supplied);
+ const wanted = Buffer.from(expected);
+ return actual.length === wanted.length && timingSafeEqual(actual, wanted);
+}
diff --git a/src/server/jobs.ts b/src/server/jobs.ts
index 40d59ae..c156c78 100644
--- a/src/server/jobs.ts
+++ b/src/server/jobs.ts
@@ -66,7 +66,9 @@ import {
import type { TurnInput } from "../core/input-events.ts";
import { danglingQuestionOf, endsInDismissal, type LhMessage } from "../core/native-messages.ts";
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
-import { stores } from "./stores.ts";
+import { stores, effectiveSettings } from "./stores.ts";
+import { resolveConversationSpec } from "../core/settings-resolution.ts";
+import { chatGPTBackgroundAllowed, onChatGPTChange } from "../agent/chatgpt-bridge.ts";
import { isTurnRunning } from "./turn-liveness.ts";
import type { Agent } from "@earendil-works/pi-agent-core";
@@ -111,6 +113,8 @@ interface WakeState {
pumping: Set;
/** Throttle bookkeeping for the stale-job sweep. */
lastSweepAt: number;
+ stopConnectionWatch?: () => void;
+ automaticDeferred: WeakSet;
}
interface LhWakeGlobal {
@@ -119,7 +123,7 @@ interface LhWakeGlobal {
function state(): WakeState {
const g = globalThis as typeof globalThis & LhWakeGlobal;
- return (g.__lhWakePipeline ??= { queues: new Map(), liveAgents: new Map(), userSteers: new Map(), pumping: new Set(), lastSweepAt: 0 });
+ return (g.__lhWakePipeline ??= { queues: new Map(), liveAgents: new Map(), userSteers: new Map(), pumping: new Set(), lastSweepAt: 0, automaticDeferred: new WeakSet() });
}
/** chat-handler wires this at module init: its adapter around handleChatRequest. */
@@ -210,9 +214,18 @@ export function deliverJobResult(steer: SteerMessage): Promise {
* beats the loop's synchronous start-poll drain and the marker rides the
* run's FIRST LLM call (the native equivalent of the old continuation
* coalescing). */
+export function automaticFollowUpAllowed(chatId: string, agent?: Agent): boolean {
+ if (agent && state().automaticDeferred.has(agent)) return false;
+ const provider = agent?.state?.model?.provider ?? resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, effectiveSettings().models.strong)?.provider;
+ return provider !== "chatgpt" || chatGPTBackgroundAllowed();
+}
+
function handOffEligible(chatId: string, agent: Agent): void {
const queue = state().queues.get(chatId);
- if (queue && queue.length > 0) {
+ // Keep automatic results in their owning registry. Never mix them into a
+ // foreground batch without express account consent. Learner steers below
+ // remain deliverable, including the first poll of an answer run.
+ if (automaticFollowUpAllowed(chatId, agent) && queue && queue.length > 0) {
for (const entry of queue) {
if (entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
agent.steer(entry.steer);
@@ -290,9 +303,25 @@ export function reconcileUserSteers(chatId: string, persisted: readonly LhMessag
/** Clear the live agent at settlement — MUST run before settleSteers so the
* reconciliation's re-pump can never hand a marker to the dying agent. */
export function unregisterLiveAgent(chatId: string): void {
+ const agent = state().liveAgents.get(chatId);
+ if (agent) state().automaticDeferred.delete(agent);
state().liveAgents.delete(chatId);
}
+/** Restore exact registry objects after native drain but before dispatch.
+ * The vehicle is disposable; result ownership/promises are not. */
+export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automaticIds: ReadonlySet, retainedIds: ReadonlySet): void {
+ const s = state();
+ s.automaticDeferred.add(agent);
+ for (const entry of s.queues.get(chatId) ?? []) {
+ if (entry.handedTo === agent && automaticIds.has(entry.steer.id)) entry.handedTo = undefined;
+ }
+ agent.clearSteeringQueue();
+ for (const entry of s.userSteers.get(chatId) ?? []) {
+ if (!retainedIds.has(entry.message.id)) { agent.steer(entry.message); entry.handedTo = agent; }
+ }
+}
+
/** TURN SETTLEMENT RECONCILIATION (Wave C): the handler calls this AFTER
* unregisterLiveAgent, with the run's persisted messages. Two jobs:
*
@@ -419,6 +448,7 @@ async function pump(chatId: string): Promise {
const s = state();
if (s.pumping.has(chatId)) return;
s.pumping.add(chatId);
+ let parked = false;
try {
for (let attempt = 0; ; ) {
const queue = s.queues.get(chatId);
@@ -452,6 +482,7 @@ async function pump(chatId: string): Promise {
return;
}
s.queues.set(chatId, stillPending);
+ if (!automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) { parked = true; return; }
// VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the
// streaming turn takes every eligible marker; pi injects at the
@@ -525,7 +556,8 @@ async function pump(chatId: string): Promise {
// hands them to that turn once the text-claim lands — until then,
// settlement re-pumps and lands them right after it). The pause is
// derived from the tail, so any user message un-holds by construction.
- if (endsInDismissal(fresh.messages)) {
+ if (endsInDismissal(fresh.messages) || !automaticFollowUpAllowed(chatId)) {
+ parked = true;
return;
}
@@ -544,6 +576,15 @@ async function pump(chatId: string): Promise {
console.error(`[steer ${steers.map((x) => x.id).join(",")}] wake turn crashed:`, err);
return -1;
});
+ if (status === 403) {
+ // Consent may have changed after the last gate. Preserve completed
+ // job/result ownership and wait for an explicit connection change.
+ const back = s.queues.get(chatId) ?? [];
+ back.push(...fire);
+ s.queues.set(chatId, back);
+ parked = true;
+ return;
+ }
if (status === 409) {
const back = s.queues.get(chatId) ?? [];
back.push(...fire);
@@ -573,10 +614,30 @@ async function pump(chatId: string): Promise {
// a live run are NOT stragglers (settleSteers owns their resurrection)
// — restarting for them would just park in waitForIdle forever.
const q = state().queues.get(chatId) ?? [];
- if (q.some((e) => e.handedTo === undefined)) void pump(chatId);
+ if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined)) void pump(chatId);
}
}
+// Connection changes are the only wake for consent-deferred automatic work.
+// No retry timer, inference, or API-key fallback is started while consent is off.
+state().stopConnectionWatch?.();
+state().stopConnectionWatch = onChatGPTChange(() => {
+ const s = state();
+ for (const [chatId, agent] of s.liveAgents) {
+ if (agent.state?.model?.provider !== "chatgpt" || automaticFollowUpAllowed(chatId, agent)) continue;
+ const consumed = new Set(agent.state.messages.map((m) => (m as { id?: string }).id));
+ const pending = (s.queues.get(chatId) ?? []).filter((entry) => entry.handedTo === agent && !consumed.has(entry.steer.id));
+ if (pending.length === 0) continue;
+ agent.clearSteeringQueue();
+ for (const entry of pending) entry.handedTo = undefined;
+ for (const entry of s.userSteers.get(chatId) ?? []) if (!consumed.has(entry.message.id)) entry.handedTo = undefined;
+ handOffEligible(chatId, agent); // only unconsumed learner input; never replay consumed steers
+ }
+ for (const chatId of s.queues.keys()) {
+ if (automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) void pump(chatId);
+ }
+});
+
/** Flip the job to its final status now that the lesson has resumed. */
function finalizeJob(jobId: string): void {
const job = stores.jobStore.getJob(jobId);
diff --git a/src/server/keys.ts b/src/server/keys.ts
index 17e79d7..3a61919 100644
--- a/src/server/keys.ts
+++ b/src/server/keys.ts
@@ -52,6 +52,7 @@ import {
validateKeyShape,
} from "../core/auth-keys.ts";
import { authStore } from "../store/auth.ts";
+import { chatGPTConnected } from "../agent/chatgpt-bridge.ts";
// The once-per-process boot record — globalThis anchor (HMR re-imports the
// module; the snapshot must not re-run and misread fill-gapped vars as real
@@ -200,6 +201,7 @@ export function keyValue(id: string): string | undefined {
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
* no key at all: nothing to gate, present by definition. */
export function providerKeyPresent(provider: string): boolean {
+ if (provider === "chatgpt") return chatGPTConnected();
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
return info ? keyValue(info.envVar) !== undefined : true;
}
diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts
index 58b5cfc..b9bbd9a 100644
--- a/src/server/model-catalog.ts
+++ b/src/server/model-catalog.ts
@@ -20,6 +20,15 @@
import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { piModels } from "../agent/models.ts";
+import { chatGPTConnected, chatGPTModel, chatGPTModels, refreshChatGPTModels } from "../agent/chatgpt-bridge.ts";
+
+export async function refreshConnectionCatalog(): Promise {
+ await refreshChatGPTModels(piModels().getModels("openai").filter((m): m is Model<"openai-responses"> => m.api === "openai-responses"));
+}
+function connectionModel(provider: string, id: string): Model | undefined {
+ return provider === "chatgpt" ? chatGPTModel(id) : piModels().getModel(provider, id);
+}
+function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt"]; }
import { settingsStore } from "../store/settings.ts";
import { effectiveSettings } from "./stores.ts";
import { keyValue } from "./keys.ts";
@@ -125,7 +134,7 @@ export function catalogProviders(): CatalogProvider[] {
id: p.id,
envVar: p.envVar,
keyPresent: keyValue(p.envVar) !== undefined,
- })).sort((a, b) => a.id.localeCompare(b.id));
+ })).concat([{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }]).sort((a, b) => a.id.localeCompare(b.id));
}
/** The providers whose key is present — the picker federates exactly
@@ -171,6 +180,7 @@ export function catalogContextLimits(): Map {
index.set(`${provider.id}:${model.id}`, model.contextWindow);
}
}
+ for (const model of chatGPTModels()) index.set(`chatgpt:${model.id}`, model.contextWindow);
return index;
}
@@ -185,7 +195,8 @@ export function modelCatalog(): ModelCatalogResponse {
.filter((p) => keyed.has(p.id))
.flatMap((p) => piModels().getModels(p.id))
.map(toModelDto)
- .sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
+ .sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)))
+ .concat(chatGPTModels().map(toModelDto));
return {
models,
defaults: catalogDefaults(),
@@ -207,13 +218,13 @@ export type SettingsValidation =
* the model the picker writes against (settings.model, else config's
* prober/teacher pin; one controller, so the two specs share it). */
export function validateChatSettings(settings: ChatSettings): SettingsValidation {
- const providers = piModels().getProviders().map((p) => p.id);
+ const providers = connectionProviderIds();
if (settings.model) {
const { provider, id } = settings.model;
if (!providers.includes(provider)) {
return { ok: false, error: `Unknown provider "${provider}" — configured: ${providers.join(", ")}` };
}
- if (!piModels().getModel(provider, id)) {
+ if (!connectionModel(provider, id)) {
return { ok: false, error: `No model "${id}" in the ${provider} catalog` };
}
}
@@ -237,13 +248,13 @@ export function validateModelsSettings(settings: ModelsSettings): SettingsValida
}
function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: string; id: string }; thinkingLevel?: ThinkingLevel }): SettingsValidation {
- const providers = piModels().getProviders().map((p) => p.id);
+ const providers = connectionProviderIds();
if (slot.model) {
const { provider, id } = slot.model;
if (!providers.includes(provider)) {
return { ok: false, error: `Unknown provider "${provider}" — configured: ${providers.join(", ")}` };
}
- if (!piModels().getModel(provider, id)) {
+ if (!connectionModel(provider, id)) {
return { ok: false, error: `No model "${id}" in the ${provider} catalog` };
}
}
@@ -265,7 +276,7 @@ function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: strin
* or the level already supported — the existence gate rejects unknown
* models separately, so this only ever sees real ones in practice). */
function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel {
- const model = piModels().getModel(provider, id);
+ const model = connectionModel(provider, id);
return model ? clampThinkingLevel(model, level) : level;
}
diff --git a/src/server/pi/ui-stream.ts b/src/server/pi/ui-stream.ts
index 7308bd1..b75a570 100644
--- a/src/server/pi/ui-stream.ts
+++ b/src/server/pi/ui-stream.ts
@@ -2,6 +2,7 @@ import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-c
import type { SteerMessage } from "../../core/contracts.ts";
import { noteSteerVisible } from "../jobs.ts";
import { ToolCallIdUniquifier } from "./call-ids.ts";
+import type { ChatGPTRequestCycle } from "../../agent/chatgpt-request-cycle.ts";
/**
* THE NATIVE WIRE (Wave D, owner-approved 2026-09-26): one agent turn, streamed
@@ -71,6 +72,7 @@ export type TurnWireEvent =
| { type: "error"; errorText: string };
export interface PiTurnSpec {
+ requestCycle?: ChatGPTRequestCycle;
/** The per-turn agent (stateless engine — built from the record). */
agent: Agent;
/** The handler's error-masking policy for LOOP-level failures (the
@@ -117,7 +119,7 @@ export interface PiTurnHandle {
}
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
- const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100 } = spec;
+ const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
let settled = false;
@@ -248,11 +250,24 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
settleWith();
}
};
- agent.subscribe(handle);
+ requestCycle?.bindRelease(handle);
+ agent.subscribe((event) => {
+ if (!requestCycle?.hold(event)) handle(event);
+ });
void (async () => {
try {
- await agent.continue();
+ do {
+ await agent.continue();
+ if (!requestCycle) break;
+ const denied = requestCycle.deniedBeforeDispatch;
+ const outcome = requestCycle.finishNativeCycle();
+ if (denied) agent.state.messages = outcome.messages;
+ if (outcome.action !== "resumeForeground") {
+ handle({ type: "agent_end", messages: [...agent.state.messages] });
+ break;
+ }
+ } while (!sawAbort);
} catch (err) {
// A loop-level failure (not a provider error — those ride the
// stream as stopReason "error"): surface, close, persist. If the
@@ -318,6 +333,7 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
abort() {
if (settled) return;
sawAbort = true;
+ requestCycle?.abort();
agent.abort();
},
};
diff --git a/tests/chatgpt-auth.test.ts b/tests/chatgpt-auth.test.ts
new file mode 100644
index 0000000..2034404
--- /dev/null
+++ b/tests/chatgpt-auth.test.ts
@@ -0,0 +1,343 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { createRequire } from "node:module";
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { exportJWK, generateKeyPair, SignJWT } from "jose";
+const require = createRequire(import.meta.url);
+const { createChatGPT, encryptedStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs");
+const { privateKey, publicKey } = await generateKeyPair("RS256");
+const jwk = { ...await exportJWK(publicKey), kid: "scratch-key", alg: "RS256", use: "sig" };
+const tokenUrl = "https://auth.openai.com/api/accounts/oauth/token";
+const initial = () => ({ version: 1, hostId: "urn:uuid:00000000-0000-4000-8000-000000000001", activeId: null as string | null, registrations: [] as any[] });
+async function signed(client: string, nonce: string, subject = "scratch-user", overrides: Record = {}) {
+ return new SignJWT({ nonce, email: "dummy@example.invalid", ...overrides }).setProtectedHeader({ alg: "RS256", kid: "scratch-key" }).setIssuer("https://auth.openai.com").setSubject(subject).setAudience(client).setIssuedAt().setExpirationTime("1h").sign(privateKey);
+}
+function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { resolve, promise }; }
+function fixture(options: { state?: any; expires?: number; timeoutMs?: number; token?: (form: URLSearchParams, url: URL) => Promise; write?: (next: any) => Promise; secure?: () => void; change?: (push: any) => void; provider?: (target: string, init: RequestInit) => Promise } = {}) {
+ let saved = structuredClone(options.state ?? initial());
+ let url!: URL;
+ let writeCount = 0;
+ const changed = deferred();
+ const requests: { url: string; form?: URLSearchParams }[] = [];
+ const service = createChatGPT({
+ store: { secure() { options.secure?.(); }, async read() { return structuredClone(saved); }, async write(next: any) { writeCount++; await options.write?.(next); saved = structuredClone(next); } },
+ openExternal: async (value: string) => { url = new URL(value); },
+ timeoutMs: options.timeoutMs ?? 5000,
+ onChange: (push: any) => { options.change?.(push); if (!push.status.pending) changed.resolve(); },
+ fetch: async (target: string, init: RequestInit = {}) => {
+ const form = init.body instanceof URLSearchParams ? init.body : undefined;
+ requests.push({ url: target, form });
+ const response = await options.provider?.(target, init); if (response) return response;
+ if (target.endsWith("jwks.json")) return Response.json({ keys: [jwk] });
+ if (target === tokenUrl) {
+ if (options.token) return options.token(form!, url);
+ return Response.json({ access_token: "scratch-oauth-token", refresh_token: "scratch-refresh", id_token: await signed(form!.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: options.expires ?? 3600, scope: SCOPES.join(" ") });
+ }
+ if (target.endsWith("openid-configuration")) return Response.json({ issuer: "https://auth.openai.com", revocation_endpoint: "https://auth.openai.com/api/accounts/oauth/revoke" });
+ if (target.endsWith("/revoke")) return new Response(null, { status: 200 });
+ if (target.endsWith("/models")) return Response.json({ models: [{ slug: "gpt-5", display_name: "Scratch model", visibility: "list" }, { slug: "hidden", display_name: "Hidden", visibility: "hide" }] });
+ throw new Error("Unexpected network request in scratch fixture");
+ },
+ });
+ const rawLease = service.lease;
+ let discovered: Promise | undefined;
+ service.lease = async (background: boolean) => {
+ discovered ??= service.models();
+ const catalog = await discovered;
+ return rawLease(background, { expectedAccountId: catalog.accountId ?? saved.activeId ?? "scratch-missing", expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" });
+ };
+ return { service, rawLease, requests, changed, get url() { return url; }, get saved() { return saved; }, get writeCount() { return writeCount; }, async callback(params: Record = {}) {
+ const callback = new URL(url.searchParams.get("redirect_uri")!);
+ callback.search = new URLSearchParams({ state: url.searchParams.get("state")!, code: "scratch-code", client_id: "oaiapp_scratch", ...params }).toString();
+ return fetch(callback);
+ } };
+}
+async function loggedIn(options: Parameters[0] = {}) {
+ const f = fixture(options);
+ await f.service.login();
+ await f.callback();
+ await f.changed.promise;
+ return f;
+}
+
+test("PKCE/state/nonce/host identity and exact ephemeral 127.0.0.1 callback; no secret renderer snapshot", async () => {
+ const f = fixture();
+ try {
+ await f.service.login();
+ const params = f.url.searchParams;
+ assert.equal(params.get("client_id"), "dynamic_agent_client");
+ assert.equal(params.get("agent_name_hint"), "LearnHarness");
+ assert.equal(params.get("ext_agent_host_id"), f.saved.hostId);
+ assert.equal(new URL(params.get("redirect_uri")!).hostname, "127.0.0.1");
+ assert.equal(new URL(params.get("redirect_uri")!).pathname, "/auth/callback");
+ assert.equal((await f.callback({ state: "wrong" })).status, 400);
+ assert.equal(f.requests.length, 0, "unverified callback must never exchange a code");
+ await f.callback(); await f.changed.promise;
+ const form = f.requests.find((r) => r.url === tokenUrl)!.form!;
+ assert.equal(form.get("redirect_uri"), params.get("redirect_uri"));
+ assert.equal(form.get("client_id"), "oaiapp_scratch");
+ 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");
+ const status = await f.service.status();
+ assert.equal(status.accounts[0].background, false);
+ assert.equal(status.activeId, f.saved.activeId);
+ assert(!JSON.stringify(status).includes("scratch-oauth-token"));
+ assert(!JSON.stringify(status).includes("scratch-refresh"));
+ assert(!JSON.stringify(status).includes("idToken"));
+ assert.equal((await f.service.lease(false)).accessToken, "scratch-oauth-token");
+ await assert.rejects(f.service.lease(true), /background/);
+ } finally { f.service.close(); }
+});
+
+test("returning sign-in reuses issued client/host and fresh PKCE; rejects account/client mismatch without replacing active credentials", async () => {
+ const f = await loggedIn();
+ try {
+ const first = f.url;
+ const status = await f.service.status();
+ await f.service.login(status.activeId);
+ assert.equal(f.url.searchParams.get("client_id"), "oaiapp_scratch");
+ assert.equal(f.url.searchParams.get("agent_name_hint"), null);
+ assert(f.url.searchParams.get("id_token_hint"));
+ assert.notEqual(f.url.searchParams.get("state"), first.searchParams.get("state"));
+ assert.notEqual(f.url.searchParams.get("nonce"), first.searchParams.get("nonce"));
+ assert.equal(f.url.searchParams.get("ext_agent_host_id"), first.searchParams.get("ext_agent_host_id"));
+ await f.callback({ client_id: "oaiapp_other" });
+ await new Promise((resolve) => setImmediate(resolve));
+ assert.equal(f.saved.registrations.length, 1);
+ assert.equal((await f.service.lease(false)).accessToken, "scratch-oauth-token");
+ } finally { f.service.close(); }
+});
+
+for (const [label, claims] of [["nonce", { nonce: "wrong" }], ["issuer", { iss: "https://evil.invalid" }], ["audience", { aud: "oaiapp_wrong" }], ["expiry", { exp: 1 }]] as const) {
+ test(`rejects invalid ID-token ${label}`, async () => {
+ const f = fixture({ token: async (form, url) => {
+ const jwt = new SignJWT(Object.assign({ nonce: url.searchParams.get("nonce")!, email: "dummy@example.invalid", iss: "https://auth.openai.com", aud: form.get("client_id")!, sub: "scratch-user", iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600 }, claims));
+ return Response.json({ access_token: "scratch-access", refresh_token: "scratch-refresh", id_token: await jwt.setProtectedHeader({ alg: "RS256", kid: "scratch-key" }).sign(privateKey), token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
+ } });
+ try { await f.service.login(); await f.callback(); await f.changed.promise; assert.equal(f.saved.activeId, null); assert.equal(f.saved.registrations.length, 0); }
+ finally { f.service.close(); }
+ });
+}
+
+test("cancel and timeout close the listener without exchanging or replacing an active account", async () => {
+ const f = fixture({ timeoutMs: 30 });
+ try {
+ await f.service.login(); const callback = f.url.searchParams.get("redirect_uri")!;
+ f.service.cancelLogin();
+ await assert.rejects(fetch(callback));
+ assert.equal(f.saved.activeId, null);
+ const timeout = fixture({ timeoutMs: 15 });
+ try {
+ await timeout.service.login();
+ await timeout.changed.promise;
+ assert.equal((await timeout.service.status()).pending, false);
+ assert.equal(timeout.requests.length, 0);
+ } finally { timeout.service.close(); }
+ assert.equal(f.requests.length, 0);
+ } finally { f.service.close(); }
+});
+
+test("missing plan scope retains verified registration but refuses inference and catalog", async () => {
+ const f = await loggedIn({ token: async (form, url) => Response.json({ id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), scope: "openid profile email", token_type: "Bearer" }) });
+ try {
+ assert.equal(f.saved.registrations.length, 1);
+ assert.equal((await f.service.status()).accounts[0].planEnabled, false);
+ await assert.rejects(f.service.lease(false), /scope/);
+ assert.deepEqual((await f.service.models()).models, []);
+ } finally { f.service.close(); }
+});
+
+test("durable encrypted save failure never activates a pending login", async () => {
+ const f = fixture({ write: async () => { throw new Error("scratch disk failure containing no real data"); } });
+ try {
+ await f.service.login(); await f.callback(); await f.changed.promise;
+ assert.equal(f.saved.activeId, null);
+ await assert.rejects(f.service.lease(false), /disconnected/);
+ } finally { f.service.close(); }
+});
+
+test("serialized concurrent refresh rotates once and persists before leases", async () => {
+ let refreshes = 0;
+ const f = await loggedIn({ expires: 1, token: async (form, url) => {
+ if (form.get("grant_type") === "refresh_token") { refreshes++; assert.equal(form.get("scope"), null); return Response.json({ access_token: "scratch-new-access", refresh_token: "scratch-rotated", token_type: "Bearer", expires_in: 3600 }); }
+ return Response.json({ access_token: "scratch-old-access", refresh_token: "scratch-old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") });
+ } });
+ try {
+ const leases = await Promise.all([f.service.lease(false), f.service.lease(false), f.service.lease(false)]);
+ assert.equal(refreshes, 1);
+ assert(leases.every((lease) => lease.accessToken === "scratch-new-access"));
+ assert.equal(f.saved.registrations[0].tokens.refreshToken, "scratch-rotated");
+ } finally { f.service.close(); }
+});
+
+test("logout wins an in-flight refresh and retains only account/client/host mapping", async () => {
+ const started = deferred(); const release = deferred();
+ const f = await loggedIn({ token: async (form, url) => {
+ if (form.get("grant_type") === "refresh_token") { started.resolve(); await release.promise; return Response.json({ access_token: "scratch-late", refresh_token: "scratch-rotated", token_type: "Bearer", expires_in: 3600 }); }
+ return Response.json({ access_token: "scratch-old", 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 id = f.saved.activeId;
+ const refresh = f.service.lease(false); const rejected = assert.rejects(refresh);
+ await started.promise;
+ const logout = f.service.logout(id);
+ await new Promise((resolve) => setImmediate(resolve)); release.resolve();
+ await rejected; await logout;
+ assert.equal(f.saved.activeId, null);
+ assert.equal(f.saved.registrations[0].clientId, "oaiapp_scratch");
+ assert.equal(f.saved.registrations[0].tokens, undefined);
+ assert(f.requests.some((r) => r.form?.get("token_type_hint") === "refresh_token"));
+ await f.service.login(id);
+ assert.equal(f.url.searchParams.get("id_token_hint"), null);
+ assert.equal(f.url.searchParams.get("client_id"), "oaiapp_scratch");
+ } finally { f.service.close(); }
+});
+
+test("account catalog is scoped, visibility filtered, and cleared on disconnect", async () => {
+ const f = await loggedIn();
+ try {
+ assert.deepEqual((await f.service.models()).models, [{ id: "gpt-5", name: "Scratch model" }]);
+ await f.service.logout(f.saved.activeId);
+ assert.deepEqual((await f.service.models()).models, []);
+ } finally { f.service.close(); }
+});
+
+test("safeStorage refuses basic_text/unavailable; encrypted file owner-only, atomic, no plaintext fallback", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-storage-"));
+ const file = path.join(dir, "credentials.enc.json");
+ const secure = { isEncryptionAvailable: () => true, getSelectedStorageBackend: () => "keychain", encryptString: (s: string) => Buffer.from(s.split("").reverse().join("")), decryptString: (b: Buffer) => b.toString().split("").reverse().join("") };
+ try {
+ const store = encryptedStore(file, secure);
+ const data = { ...initial(), marker: "scratch-secret-never-plaintext" };
+ await store.write(data);
+ assert(!(await fs.readFile(file, "utf8")).includes(data.marker));
+ assert.deepEqual(await store.read(), data);
+ assert.equal((await fs.stat(file)).mode & 0o777, 0o600);
+ assert.deepEqual(await fs.readdir(dir), ["credentials.enc.json"]);
+ for (const backend of [{ ...secure, getSelectedStorageBackend: () => "basic_text" }, { ...secure, isEncryptionAvailable: () => false }]) {
+ await assert.rejects(encryptedStore(file, backend).read(), /secure_storage/);
+ await assert.rejects(encryptedStore(file, backend).write(data), /secure_storage/);
+ }
+ assert(!allowedAuthEndpoint("https://auth.openai.com.evil.invalid/revoke"));
+ assert(!allowedAuthEndpoint("http://127.0.0.1/revoke"));
+ assert(!allowedAuthEndpoint("https://auth.openai.com@evil.invalid/revoke"));
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
+});
+
+test("invalid rotating replacement is durably revocation-only; verified mapping and prior validated ID survive restart", async () => {
+ const f = await loggedIn({ expires: 1, token: async (form, url) => form.get("grant_type") === "refresh_token"
+ ? Response.json({ access_token: "never-save-this-access", refresh_token: "likely-latest-revoke-handle", id_token: "invalid.signature.jwt", token_type: "Bearer", expires_in: 3600 })
+ : Response.json({ access_token: "old-verified-access", refresh_token: "old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") }) });
+ try {
+ const before = structuredClone(f.saved.registrations[0]);
+ await assert.rejects(f.service.lease(false));
+ const restricted = f.saved.registrations[0];
+ assert.equal(restricted.reconnectRequired, true);
+ for (const field of ["id", "issuer", "subject", "email", "clientId"]) assert.equal(restricted[field], before[field]);
+ assert.deepEqual(restricted.tokens, { ...before.tokens, refreshToken: "likely-latest-revoke-handle" });
+ assert(!JSON.stringify(f.saved).includes("never-save-this-access"));
+ const restarted = fixture({ state: f.saved });
+ try {
+ await restarted.service.status();
+ await restarted.service.select(restricted.id);
+ restarted.service.resume(); await restarted.service.consent(restricted.id, true);
+ await assert.rejects(restarted.service.lease(false));
+ assert.equal((await restarted.service.status()).accounts[0].connected, false);
+ assert.deepEqual((await restarted.service.models()).models, []);
+ assert.equal(restarted.requests.filter((r) => r.url === tokenUrl).length, 0, "restricted material cannot refresh for inference/catalog");
+ await restarted.service.logout(restricted.id);
+ assert.equal(restarted.requests.find((r) => r.form?.has("token_type_hint"))!.form!.get("token"), "likely-latest-revoke-handle");
+ assert.equal(restarted.saved.registrations[0].reconnectRequired, true);
+ await restarted.service.login(restricted.id); await restarted.callback(); await restarted.changed.promise;
+ // Wait for callback's asynchronous encrypted activation, not the prior status push.
+ for (let i = 0; i < 30 && restarted.saved.registrations[0].reconnectRequired; i++) await new Promise((resolve) => setImmediate(resolve));
+ assert.equal(restarted.saved.registrations[0].reconnectRequired, undefined);
+ } finally { restarted.service.close(); }
+ } finally { f.service.close(); }
+});
+
+test("detected secure backend loss emits full invalidation, denies all leases and cannot be repaired by consent/resume", async () => {
+ let lost = false; const pushes: any[] = [];
+ const f = await loggedIn({ secure: () => { if (lost) throw Object.assign(new Error("secure_storage"), { code: "secure_storage" }); }, change: (push) => pushes.push(push) });
+ try {
+ await f.service.lease(false); const epoch = f.service.stamp().epoch;
+ lost = true;
+ assert.equal((await f.service.status()).available, false);
+ 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));
+ lost = false; f.service.resume(); await f.service.consent(f.saved.activeId, true);
+ await assert.rejects(f.service.lease(false));
+ assert.equal((await f.service.status()).accounts[0].connected, false);
+ } finally { f.service.close(); }
+});
+
+test("strict encrypted schema rejects malformed marker/duplicates/token/active identity without changing original bytes", async () => {
+ const f = await loggedIn(); f.service.close();
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-corrupt-"));
+ const file = path.join(dir, "credentials.enc.json");
+ const secure = { isEncryptionAvailable: () => true, encryptString: (s: string) => Buffer.from(s), decryptString: (b: Buffer) => b.toString() };
+ 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]) {
+ const state = structuredClone(f.saved); mutate(state);
+ const bytes = JSON.stringify({ version: 1, encrypted: Buffer.from(JSON.stringify(state)).toString("base64") });
+ await fs.writeFile(file, bytes);
+ const service = createChatGPT({ store: encryptedStore(file, secure), openExternal: () => assert.fail("no OAuth"), fetch: () => assert.fail("no provider") });
+ try { assert.equal((await service.status()).available, false); await assert.rejects(service.login()); assert.equal(await fs.readFile(file, "utf8"), bytes); }
+ finally { service.close(); }
+ }
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
+});
+
+test("consent OFF wins an older ON save; foreground catalog authority remains unchanged", async () => {
+ 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; } } });
+ try {
+ const catalog = await f.service.models(); const id = f.saved.activeId;
+ hold = true;
+ const on = f.service.consent(id, true); await entered.promise;
+ const off = f.service.consent(id, false);
+ assert.equal((await f.service.status()).accounts[0].background, false);
+ release.resolve(); await on; await off;
+ assert.equal(f.saved.registrations[0].background, false);
+ assert.equal(f.service.stamp().authorityRevision, catalog.authorityRevision);
+ 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/);
+ } finally { f.service.close(); }
+});
+
+test("cancellation during verified durable save cannot activate late credentials", async () => {
+ const entered = deferred(); const release = deferred(); let blocked = true;
+ const f = fixture({ write: async (next) => { if (blocked && next.registrations.length) { entered.resolve(); await release.promise; blocked = false; } } });
+ try {
+ await f.service.login(); await f.callback(); await entered.promise;
+ f.service.cancelLogin(); release.resolve();
+ for (let i = 0; i < 30 && f.writeCount < 2; i++) await new Promise((resolve) => setImmediate(resolve));
+ assert.equal(f.saved.activeId, null);
+ assert.equal((await f.service.status()).accounts.length, 0);
+ } finally { f.service.close(); }
+});
+
+test("scope-disabled credentials remain revocable and unconfirmed revoke never claims success", async () => {
+ const f = await loggedIn({ token: async (form, url) => Response.json({ id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), refresh_token: "scope-disabled-refresh", scope: "openid profile email", token_type: "Bearer" }), provider: async (url) => url.endsWith("/revoke") ? new Response(null, { status: 500 }) : undefined });
+ try {
+ await assert.rejects(f.service.lease(false));
+ const status = await f.service.logout(f.saved.activeId);
+ assert.match(status.error, /Remote revocation was not confirmed/);
+ assert.equal(f.requests.filter((r) => r.form?.get("token") === "scope-disabled-refresh").length, 2);
+ assert.equal(f.saved.registrations[0].tokens, undefined);
+ } finally { f.service.close(); }
+});
+
+test("private leases bind active account, authority revision and raw visible model membership", async () => {
+ const f = await loggedIn();
+ try {
+ const catalog = await f.service.models();
+ const expected = { expectedAccountId: catalog.accountId, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" };
+ await assert.rejects(f.rawLease(false));
+ for (const mutation of [{ expectedAccountId: "another" }, { expectedAuthorityRevision: catalog.authorityRevision - 1 }, { selectedModelId: "hidden" }]) await assert.rejects(f.rawLease(false, { ...expected, ...mutation }));
+ assert.equal((await f.rawLease(false, expected)).accountId, catalog.accountId);
+ } finally { f.service.close(); }
+});
diff --git a/tests/chatgpt-background.test.ts b/tests/chatgpt-background.test.ts
new file mode 100644
index 0000000..216ae2b
--- /dev/null
+++ b/tests/chatgpt-background.test.ts
@@ -0,0 +1,80 @@
+import { test, after } from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { Agent, type AgentMessage } from "@earendil-works/pi-agent-core";
+import { AssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
+import { piModels } from "../src/agent/models.ts";
+import { toLlmMessages } from "../src/core/native-messages.ts";
+import { buildSubagentSteer } from "../src/core/contracts.ts";
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-chatgpt-background-"));
+process.env.LH_DATA_DIR = path.join(dir, "data");
+process.env.LH_AUTH_FILE = path.join(dir, "scratch-auth.json");
+fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
+const { stores } = await import("../src/server/stores.ts");
+const { beginTurn, endTurn } = await import("../src/server/turn-liveness.ts");
+const { deliverJobResult, registerLiveAgent, unregisterLiveAgent, setTurnRunner, settleSteers, steerLiveChat } = await import("../src/server/jobs.ts");
+const { persistTurn } = await import("../src/server/turn-service.ts");
+const ipc = (value: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", value);
+let revision = 1;
+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 } });
+}
+after(() => { fs.rmSync(dir, { recursive: true, force: true }); });
+
+for (const revokedAfterQueue of [false, true]) {
+ test(`mixed learner+automatic batch: foreground runs, automatic result stays owned/deferred without consent (${revokedAfterQueue ? "revoked after handoff" : "initially off"})`, { timeout: 5000 }, async () => {
+ connection(revokedAfterQueue);
+ const chat = stores.chatStore.createChat("Scratch mixed queue");
+ stores.chatStore.saveSettings(chat.id, { model: { provider: "chatgpt", id: "gpt-5" } });
+ const job = stores.jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "scratch-job-call" });
+ const result = { brief: "Scratch completed job result", sources: [] };
+ stores.jobStore.updateJob(job.id, { status: "running", result });
+ const marker = buildSubagentSteer(job, result);
+ const contexts: string[] = [];
+ const native = piModels().getModel("openai", "gpt-5")!;
+ const model = { ...native, provider: "chatgpt" };
+ const agent = new Agent({ initialState: { model, messages: [{ role: "user", content: "Initial scratch learner input", timestamp: 1, id: "scratch-initial" } as AgentMessage] }, convertToLlm: toLlmMessages,
+ streamFn: () => {
+ contexts.push(JSON.stringify(agent.state.messages));
+ const stream = new AssistantMessageEventStream();
+ const output = { role: "assistant" as const, provider: "chatgpt", api: model.api, model: model.id, content: [{ type: "text" as const, text: "Scratch foreground response" }], stopReason: "stop" as const, timestamp: 2, usage: { input: 2, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
+ stream.push({ type: "start", partial: output }); stream.push({ type: "done", reason: "stop", message: output }); stream.end();
+ return stream;
+ },
+ });
+ let wakes = 0;
+ setTurnRunner(async (chatId, input) => {
+ wakes++;
+ assert.equal(input.type, "steers");
+ if (input.type === "steers") stores.chatStore.saveMessages(chatId, [...stores.chatStore.getChat(chatId)!.messages, ...input.steers]);
+ return 200;
+ });
+ assert(beginTurn(chat.id));
+ registerLiveAgent(chat.id, agent);
+ const delivered = deliverJobResult(marker);
+ const learner = { id: "scratch-live-learner", role: "user", content: "Please continue my learning", timestamp: 3 } as never;
+ assert(steerLiveChat(chat.id, learner));
+ if (revokedAfterQueue) connection(false);
+ await agent.continue();
+ assert.equal(contexts.length, 1, "ordinary foreground tool-free call remains usable");
+ assert(contexts[0]!.includes("Please continue my learning"));
+ assert(!contexts[0]!.includes(marker.id), "automatic marker must not enter the foreground batch");
+ assert(!agent.hasQueuedMessages());
+ // Ordinary lease/catalog replies are not consent changes and must never
+ // reset/replay a learner steer that the agent has already consumed.
+ ipc({ channel: "lh:chatgpt", id: "ordinary-reply-with-no-pending-call", value: { accessToken: "scratch-only" } });
+ assert(!agent.hasQueuedMessages());
+ const persisted = persistTurn(stores.chatStore, chat.id, 0, agent.state.messages, false);
+ unregisterLiveAgent(chat.id); endTurn(chat.id); settleSteers(chat.id, persisted);
+ await new Promise((resolve) => setImmediate(resolve));
+ assert.equal(wakes, 0, "no background spin or spend while consent is absent");
+ assert.equal(stores.jobStore.getJob(job.id)!.status, "running", "delivery ownership must not be finalized or failed by deferral");
+ assert.deepEqual(stores.jobStore.getJob(job.id)!.result, result);
+ connection(true);
+ await delivered;
+ assert.equal(wakes, 1, "explicit consent resumes the preserved result exactly once");
+ assert.equal(stores.jobStore.getJob(job.id)!.status, "done");
+ });
+}
diff --git a/tests/chatgpt-catalog.test.ts b/tests/chatgpt-catalog.test.ts
new file mode 100644
index 0000000..5728303
--- /dev/null
+++ b/tests/chatgpt-catalog.test.ts
@@ -0,0 +1,48 @@
+import { test, before, after } from "node:test";
+import assert from "node:assert/strict";
+import { piModels } from "../src/agent/models.ts";
+import { refreshChatGPTModels, chatGPTModels, chatGPTStatus, chatGPTScope, chatGPTScopeCurrent, chatGPTLease, chatGPTAbortController } from "../src/agent/chatgpt-bridge.ts";
+const send = Object.getOwnPropertyDescriptor(process, "send"), connected = Object.getOwnPropertyDescriptor(process, "connected");
+const pending: any[] = [];
+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 status = (account: string, background = true) => ({ available: true, pending: false, activeId: account, accounts: [{ id: account, connected: true, 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 }] });
+function reply(request: any, v: unknown) { ipc({ channel: "lh:chatgpt", id: request.id, op: request.op, ok: true, value: v }); }
+before(() => {
+ Object.defineProperty(process, "connected", { configurable: true, value: true });
+ Object.defineProperty(process, "send", { configurable: true, value: (m: unknown, cb?: () => void) => { pending.push(m); cb?.(); return true; } });
+});
+after(() => {
+ if (send) Object.defineProperty(process, "send", send); else delete process.send;
+ if (connected) Object.defineProperty(process, "connected", connected); else delete (process as { connected?: boolean }).connected;
+});
+test("overlapping catalog requests only install the newest generation", async () => {
+ const old = refreshChatGPTModels([model as never]), latest = refreshChatGPTModels([model as never]);
+ reply(pending[1], value("newest", 2)); await latest;
+ reply(pending[0], value("stale", 1)); await old;
+ assert.equal(chatGPTModels()[0]?.name, "newest");
+});
+test("consent-only status does not discard current-authority catalog or overwrite newer consent from an old status embedded in models", async () => {
+ 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" });
+ reply(request, value("same authority", 2)); await refresh;
+ assert.equal(chatGPTModels()[0]?.name, "same authority"); assert.equal(chatGPTStatus().accounts[0]?.background, false);
+ const scope = chatGPTScope(model.id); assert(chatGPTScopeCurrent(scope));
+ 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: 5 }, status: status("A", true), invalidate: "none" });
+ assert(!foreground.controller.signal.aborted); assert(background.controller.signal.aborted); assert(chatGPTScopeCurrent(scope));
+ foreground.release(); background.release();
+});
+test("account/full and catalog authority changes reject outstanding old leases without silently switching identity", async () => {
+ const scope = chatGPTScope(model.id); const lease = chatGPTLease(scope, false); const request = pending.at(-1);
+ ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 2, revision: 6 }, status: status("B"), invalidate: "all" });
+ assert.equal(chatGPTModels().length, 0); assert(!chatGPTScopeCurrent(scope));
+ reply(request, { epoch: 1, authorityRevision: 1, revision: 5, accountId: "A", accessToken: "dummy-old" });
+ await assert.rejects(lease, /stale/);
+ const refresh = refreshChatGPTModels([model as never]); reply(pending.at(-1), value("B", 7, "B", 2)); await refresh;
+ const newScope = chatGPTScope(model.id); assert.equal(newScope.accountId, "B");
+ ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 3, revision: 8 }, status: status("B"), invalidate: "none" });
+ assert.equal(chatGPTModels().length, 0); await assert.rejects(chatGPTLease(newScope, false));
+});
diff --git a/tests/chatgpt-request-cycle.test.ts b/tests/chatgpt-request-cycle.test.ts
new file mode 100644
index 0000000..4ef536b
--- /dev/null
+++ b/tests/chatgpt-request-cycle.test.ts
@@ -0,0 +1,154 @@
+import { test, before, after } from "node:test";
+import assert from "node:assert/strict";
+import { type Model } from "@earendil-works/pi-ai";
+import { Type } from "typebox";
+import type { AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
+import { piModels, piEngineForSpec, APP_IDENTITY } from "../src/agent/models.ts";
+import { refreshChatGPTModels, chatGPTScopeCurrent } from "../src/agent/chatgpt-bridge.ts";
+import { ChatGPTRequestCycle } from "../src/agent/chatgpt-request-cycle.ts";
+import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-chatgpt-native-"));
+process.env.LH_DATA_DIR = path.join(scratch, "data");
+process.env.LH_AUTH_FILE = path.join(scratch, "auth.json");
+fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
+const { runPiTurn } = await import("../src/server/pi/ui-stream.ts");
+import { newUserMessage, newNextMessage, newThinkingMessage, newAnswerResult, reconstructRequestCause, concludedByDismissalPair, type LhMessage } from "../src/core/native-messages.ts";
+
+const originalSend = Object.getOwnPropertyDescriptor(process, "send");
+const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
+const realFetch = globalThis.fetch;
+const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">;
+let epoch = 1, revision = 1, background = true;
+let cut: "lease" | "payload" | "full" | "afterFetch" | "none" = "none";
+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 ipc = (m: unknown) => (process.emit as (name: string, m: unknown) => boolean)("message", m);
+function revoke(full = false) {
+ background = false; if (full) epoch++;
+ ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch, authorityRevision: epoch, revision: ++revision }, status: status(), invalidate: full ? "all" : "background" });
+}
+before(() => {
+ Object.defineProperty(process, "connected", { configurable: true, value: true });
+ Object.defineProperty(process, "send", { configurable: true, value: (m: any, callback?: () => void) => {
+ if (m.op === "lease") {
+ leases.push(m.background);
+ assert.equal(m.expectedAccountId, "scratch"); assert.equal(m.expectedAuthorityRevision, epoch); assert.equal(m.selectedModelId, model.id);
+ if (m.background && (cut === "lease" || cut === "full")) { const full = cut === "full"; cut = "none"; revoke(full); }
+ }
+ const value = { epoch, authorityRevision: epoch, revision, ...(m.op === "lease" ? { accountId: "scratch", accessToken: "dummy-oauth-native" } : { status: status(), accountId: "scratch", models: [{ id: model.id, name: "Scratch" }] }) };
+ queueMicrotask(() => ipc({ channel: "lh:chatgpt", id: m.id, op: m.op, ok: true, value })); callback?.(); return true;
+ } });
+ globalThis.fetch = async (input, init) => {
+ const request = new Request(input, init);
+ assert.equal(request.url, "https://api.openai.com/v1/responses");
+ assert.equal(request.headers.get("authorization"), "Bearer dummy-oauth-native");
+ assert.equal(request.headers.get("user-agent"), APP_IDENTITY.userAgent.custom);
+ const body = JSON.parse(await request.text()); assert.equal(body.model, model.id); assert.equal(body.store, false);
+ fetches++;
+ if (cut === "afterFetch") { cut = "none"; revoke(); throw new Error("admitted local abort"); }
+ if (toolFirst && fetches === 1) {
+ const call = { type: "function_call", id: "fc_again", call_id: "call_again", name: "again", namespace: "learnharness", arguments: "{}", status: "completed" };
+ return new Response([{ type: "response.output_item.added", output_index: 0, item: call }, { type: "response.output_item.done", output_index: 0, item: call }, { type: "response.completed", response: { status: "completed", output: [call], usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 } } }].map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
+ }
+ const item = { id: "msg_native", type: "message", role: "assistant", content: [{ type: "output_text", text: "Accepted learner", annotations: [] }] };
+ const events = [{ type: "response.created", response: { id: "resp_native" } }, { type: "response.output_item.added", output_index: 0, item: { ...item, content: [] } }, { type: "response.content_part.added", output_index: 0, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }, { type: "response.output_text.delta", output_index: 0, content_index: 0, delta: "Accepted learner" }, { type: "response.output_item.done", output_index: 0, item }, { type: "response.completed", response: { status: "completed", output: [item], usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 } } }];
+ return new Response(events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
+ };
+});
+after(() => {
+ globalThis.fetch = realFetch;
+ fs.rmSync(scratch, { recursive: true, force: true });
+ if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
+ if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
+});
+const automatic = (id = "auto") => ({ id, role: "custom" as const, customType: "subagent-steer" as const, content: "Automatic result", details: { jobId: "dummy-job" }, display: true, timestamp: 2 });
+async function nativeRun(where: typeof cut, staged: boolean, withLearner = true, commitThrows = false, lateAutomatic = false, withTool = false) {
+ epoch++; revision++; background = true; cut = where; leases = []; fetches = 0; toolFirst = withTool;
+ await refreshChatGPTModels([model]);
+ const seed = newUserMessage("Initial learner"); const marker = automatic(); const learner = { ...newUserMessage("New learner"), id: "learner-late" };
+ const messages = staged ? [seed, marker] : [seed];
+ const purpose = { background: staged };
+ let restored = 0, committed = 0, usage = 0, settled = 0, executed = 0, handedLate = false;
+ let final: AgentMessage[] = [];
+ let resolve!: () => void; const finished = new Promise((done) => { resolve = done; });
+ const cycle = new ChatGPTRequestCycle({ initialMessages: messages, staged: staged ? [marker] : undefined, foregroundOwed: !staged, purpose, scopeCurrent: chatGPTScopeCurrent,
+ commitStaged: () => { if (commitThrows) throw new Error("scratch durable write failure"); committed++; },
+ restoreAutomatic: (ids, retained) => { restored++; assert(ids.has(marker.id)); assert(!retained.has(marker.id)); agent.clearSteeringQueue(); },
+ });
+ const raw = piEngineForSpec({ provider: "chatgpt", id: model.id }, () => purpose.background, cycle);
+ const engine = { ...raw, streamFn: (m: any, context: any, opts: any) => raw.streamFn(m, context, { ...opts, onPayload: async (payload: unknown) => { if (lateAutomatic && !handedLate) { handedLate = true; agent.steer(marker); } if (cut === "payload") { cut = "none"; await Promise.resolve(); revoke(); } return payload; } }) };
+ const agent = createTeacherAgent(undefined, { engine, messages, chatId: "scratch-native-session", tools: withTool ? { again: { name: "again", label: "again", description: "scratch", parameters: Type.Object({}), execute: async () => { executed++; return { content: [{ type: "text" as const, text: "Done" }], details: {} }; } } } : {}, callPurpose: purpose, requestCycle: cycle, onUsage: () => usage++ });
+ if (!staged && !lateAutomatic) agent.steer(marker);
+ if (withLearner) agent.steer(learner);
+ const handle = runPiTurn({ agent, requestCycle: cycle, onFinish: (m) => { settled++; final = m; resolve(); } });
+ const firstView = new Response(handle.subscribe()).text();
+ await finished;
+ const wire = await firstView; const replay = await new Response(handle.subscribe()).text();
+ assert.equal(settled, 1);
+ assert.equal(replay.includes('"id":"auto"'), wire.includes('"id":"auto"'));
+ assert.equal(replay.split('"type":"agent_end"').length, 2);
+ const events = wire.split("\n\n").filter((line) => line.startsWith("data: {")).map((line) => JSON.parse(line.slice(6)) as AgentEvent);
+ assert.equal(events.filter((e) => e.type === "agent_end").length, 1);
+ assert.equal(events.filter((e) => e.type === "agent_start").length, 1);
+ return { cycle, agent, final, wire, events, restored, committed, usage, executed, leases: [...leases], fetches };
+}
+for (const where of ["lease", "payload"] as const) for (const staged of [false, true]) {
+ test(`real native drain -> ${where} denial -> same Agent foreground recovery, staged=${staged}`, { timeout: 5000 }, async () => {
+ const r = await nativeRun(where, staged);
+ assert.equal(r.fetches, 1); assert.deepEqual(r.leases, [true, false]); assert.equal(r.usage, 1); assert.equal(r.restored, 1);
+ assert(!r.wire.includes('"id":"auto"')); assert(!r.wire.includes('"stopReason":"error"'));
+ assert.equal(r.final.filter((m) => (m as { id?: string }).id === "learner-late").length, 1);
+ assert.equal(r.events.filter((e) => e.type === "message_end" && (e.message as { id?: string }).id === "learner-late").length, 1);
+ assert.equal(r.events.filter((e) => e.type === "turn_start").length, 1);
+ assert.equal(await r.cycle.admission, false);
+ });
+}
+test("initial automatic denial without learner creates no usage/visible marker/durable prefix", async () => {
+ const r = await nativeRun("lease", true, false); assert.equal(r.fetches, 0); assert.equal(r.usage, 0); assert.equal(r.committed, 0); assert.equal(await r.cycle.admission, false); assert(!r.wire.includes('"id":"auto"'));
+});
+test("full invalidation never internally retries under a new authority", async () => {
+ const r = await nativeRun("full", true); assert.equal(r.fetches, 0); assert.equal(r.usage, 0); assert.deepEqual(r.leases, [true]);
+});
+test("successful staged prefix commits once before native dispatch and is visible exactly once", async () => {
+ const r = await nativeRun("none", true, false); assert.equal(r.fetches, 1); assert.equal(r.committed, 1); assert.equal(await r.cycle.admission, true);
+ assert.equal(r.events.filter((e) => e.type === "message_end" && (e.message as { id?: string }).id === "auto").length, 1);
+});
+test("durable staged input failure prevents dispatch and does not infer foreground replay", async () => {
+ const r = await nativeRun("none", true, false, true); assert.equal(r.fetches, 0); assert.equal(r.usage, 0); assert.equal(await r.cycle.admission, false);
+});
+test("invalidation after fetch commitment cannot replay an admitted request", async () => {
+ const r = await nativeRun("afterFetch", false); assert.equal(r.fetches, 1); assert.equal(r.restored, 0); assert.deepEqual(r.leases, [true]); assert.equal(await r.cycle.admission, true);
+});
+
+test("automatic arrival after completed foreground prose cannot manufacture another foreground request", async () => {
+ const r = await nativeRun("lease", false, false, false, true);
+ assert.equal(r.fetches, 1); assert.equal(r.usage, 1); assert.equal(r.restored, 1); assert.deepEqual(r.leases, [false, true]);
+ assert(!r.wire.includes('"id":"auto"'));
+});
+test("unfinished foreground ordinary-tool chain recovers without rerunning the completed tool", async () => {
+ const r = await nativeRun("lease", false, false, false, true, true);
+ assert.equal(r.fetches, 2); assert.equal(r.executed, 1); assert.equal(r.usage, 2); assert.deepEqual(r.leases, [false, true, false]);
+ assert.equal(r.final.filter((m) => m.role === "toolResult").length, 1);
+ assert(!r.wire.includes('"id":"auto"'));
+});
+
+test("full native record reconstruction preserves original causes and reports genuine ambiguity", () => {
+ const user = newUserMessage("Learner"); const auto = automatic();
+ const assistant = { role: "assistant", id: "assistant", api: "openai-responses", provider: "chatgpt", model: model.id, content: [{ type: "toolCall", id: "call", name: "read", arguments: {} }], stopReason: "toolUse", timestamp: 3, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } } as LhMessage;
+ const result = { role: "toolResult", id: "result", toolName: "read", toolCallId: "call", content: [], details: {}, isError: false, timestamp: 4 } as LhMessage;
+ for (const [origin, expected] of [[user, "foreground"], [auto, "background"]] as const) assert.equal(reconstructRequestCause(JSON.parse(JSON.stringify([origin, assistant, result]))), expected);
+ assert.equal(reconstructRequestCause([auto, user, assistant, result]), "ambiguous");
+ assert.equal(reconstructRequestCause([user, auto, assistant, result]), "background");
+ assert.equal(reconstructRequestCause([assistant, result]), "ambiguous");
+ assert.equal(reconstructRequestCause([auto, newThinkingMessage("problem", "working")]), "ambiguous");
+ assert.equal(reconstructRequestCause([auto, assistant, newNextMessage()]), "foreground");
+ assert.equal(reconstructRequestCause([user, ...concludedByDismissalPair("problem")]), "foreground");
+ const question = { ...assistant, content: [{ type: "toolCall", id: "question", name: "quiz", arguments: {} }] } as LhMessage;
+ assert.equal(reconstructRequestCause([auto, question, newAnswerResult("question", "quiz", { kind: "quiz", questionText: "Scratch", dontKnow: false, answers: [], answeredAt: "2026-10-24T00:00:00Z" })]), "foreground");
+ assert.equal(reconstructRequestCause([auto, question, { ...newAnswerResult("question", "quiz", {}), details: null }]), "background");
+ assert.equal(reconstructRequestCause([auto, question, newAnswerResult("orphan", "quiz", {})]), "ambiguous");
+ assert.equal(reconstructRequestCause([user, { ...user, content: "conflict" }]), "ambiguous");
+});
diff --git a/tests/chatgpt-stream.test.ts b/tests/chatgpt-stream.test.ts
new file mode 100644
index 0000000..9da13a6
--- /dev/null
+++ b/tests/chatgpt-stream.test.ts
@@ -0,0 +1,174 @@
+import { test, before, after } from "node:test";
+import assert from "node:assert/strict";
+import { normalizeContext, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
+import { Type } from "typebox";
+import { piModels, piEngineForSpec } from "../src/agent/models.ts";
+import { chatGPTAbortController, chatGPTModels, refreshChatGPTModels } from "../src/agent/chatgpt-bridge.ts";
+import { chatGPTHistory, chatGPTPayload } from "../src/agent/chatgpt-stream.ts";
+
+const originalSend = Object.getOwnPropertyDescriptor(process, "send");
+const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
+const realFetch = globalThis.fetch;
+const originalKey = process.env.OPENAI_API_KEY;
+let leaseError: string | null = null;
+let requests: { authorization: string | null; body: Record; url: string }[] = [];
+let responseEvents: unknown[] = [];
+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 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 };
+before(async () => {
+ assert(model);
+ process.env.OPENAI_API_KEY = "sk-scratch-must-never-be-used";
+ Object.defineProperty(process, "connected", { configurable: true, value: true });
+ Object.defineProperty(process, "send", { configurable: true, value: (message: any, callback?: () => void) => {
+ queueMicrotask(() => {
+ ipc({ channel: "lh:chatgpt", id: message.id, op: message.op,
+ ...(message.op === "lease" && leaseError ? { ok: false, error: leaseError, stamp: { epoch: 1, authorityRevision: 1, revision: 1 } } : { ok: true, value: message.op === "lease" ? { accessToken: "scratch-oauth-only", accountId: "scratch-account", epoch: 1, authorityRevision: 1, revision: 1 } : { epoch: 1, authorityRevision: 1, revision: 1, status, accountId: "scratch-account", models: [{ id: model.id, name: "Scratch account model" }, { id: "unknown-capabilities", name: "Do not invent capabilities" }] } }),
+ });
+ });
+ callback?.(); return true;
+ } });
+ await refreshChatGPTModels([model]);
+ globalThis.fetch = async (input, init) => {
+ const req = new Request(input, init);
+ requests.push({ authorization: req.headers.get("authorization"), body: JSON.parse(await req.text()), url: req.url });
+ return new Response(responseEvents.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
+ };
+});
+after(() => {
+ globalThis.fetch = realFetch;
+ if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
+ if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
+ if (originalKey === undefined) delete process.env.OPENAI_API_KEY; else process.env.OPENAI_API_KEY = originalKey;
+});
+const usage = { input_tokens: 10, output_tokens: 4, total_tokens: 14, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 } };
+const complete = (output: unknown[] = []) => ({ type: "response.completed", response: { id: "resp_scratch", status: "completed", output, usage } });
+const context = () => normalizeContext({ systemPrompt: "Scratch teacher instructions", messages: [{ role: "user", content: [{ type: "text", text: "Scratch learning material" }, { type: "image", data: "c2NyYXRjaA==", mimeType: "image/png" }], timestamp: 1 }], tools: [{ name: "scratch_tool", description: "Scratch local tool", parameters: Type.Object({ value: Type.String() }) }] });
+
+test("catalog intersects supported capabilities, without inventing model limits", () => {
+ assert.equal(chatGPTModels().length, 1);
+ assert.equal(chatGPTModels()[0]!.provider, "chatgpt");
+ assert.equal(chatGPTModels()[0]!.contextWindow, model.contextWindow);
+ assert.deepEqual(chatGPTModels()[0]!.input, model.input);
+});
+
+test("alias resolves only OAuth; native Responses body is stateless/namespaced, image/history preserved and logical output retained", async () => {
+ requests = [];
+ const tool = { type: "function_call", id: "fc_scratch", call_id: "call_scratch", name: "scratch_tool", namespace: "learnharness", arguments: '{"value":"yes"}', status: "completed" };
+ const reasoning = { type: "reasoning", id: "rs_scratch", summary: [], encrypted_content: "scratch-encrypted-reasoning" };
+ responseEvents = [{ type: "response.created", response: { id: "resp_scratch" } }, { type: "response.output_item.added", output_index: 0, item: reasoning }, { type: "response.output_item.done", output_index: 0, item: reasoning }, { type: "response.output_item.added", output_index: 1, item: tool }, { type: "response.output_item.done", output_index: 1, item: tool }, complete([reasoning, tool])];
+ const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, false);
+ const stream = engine.streamFn(engine.model, context(), { temperature: 0.7, maxTokens: 1000, samplingParams: { top_p: 0.5 }, onPayload: (payload) => ({ ...payload as object, model: "caller-cannot-change", dangerous: true }) });
+ const events = [];
+ for await (const event of stream) { events.push(event); if ("partial" in event) assert.equal(event.partial.provider, "chatgpt"); }
+ const result = await stream.result();
+ assert.equal(result.provider, "chatgpt"); assert.equal(result.stopReason, "toolUse");
+ assert.equal(requests.length, 1); assert.equal(requests[0]!.url, "https://api.openai.com/v1/responses");
+ assert.equal(requests[0]!.authorization, "Bearer scratch-oauth-only");
+ const body = requests[0]!.body;
+ assert.equal(body.store, false); assert.equal(body.stream, true);
+ for (const key of ["temperature", "top_p", "max_output_tokens", "previous_response_id", "background", "metadata", "prompt_cache_retention"]) assert.equal(body[key], undefined, key);
+ assert.equal(body.input[0].role, "developer");
+ assert.equal(body.input[1].content[1].type, "input_image");
+ assert.equal(body.tools[0].type, "namespace"); assert.equal(body.tools[0].tools[0].name, "scratch_tool");
+ const call = result.content.find((block) => block.type === "toolCall")!;
+ assert.equal(call.id, "call_scratch|fc_scratch"); assert.equal(call.namespace, "learnharness");
+ const thinking = result.content.find((block) => block.type === "thinking")!;
+ assert.equal(thinking.thinkingSignature, JSON.stringify(reasoning));
+ const history = normalizeContext({ messages: [...context().messages, result, { role: "toolResult", toolCallId: call.id, toolName: call.name, isError: false, content: [{ type: "text", text: "Scratch tool result" }], timestamp: 2 }] });
+ responseEvents = [complete()];
+ await engine.streamFn(engine.model, history).result();
+ const replay = requests[1]!.body.input;
+ assert(replay.some((item: any) => item.encrypted_content === "scratch-encrypted-reasoning"));
+ assert(replay.some((item: any) => item.type === "function_call" && item.id === "fc_scratch" && item.namespace === "learnharness"));
+ assert(replay.some((item: any) => item.type === "function_call_output" && item.call_id === "call_scratch"));
+});
+
+test("mixed history never blanket-relabels API providers or reuses foreign reasoning signatures", () => {
+ const native: AssistantMessage = { role: "assistant", provider: "openai", api: "openai-responses", model: model.id, content: [{ type: "thinking", thinking: "", thinkingSignature: '{"encrypted_content":"API-only"}', redacted: true }], stopReason: "stop", usage: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 1, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, timestamp: 1 };
+ const own = { ...native, provider: "chatgpt", content: [{ type: "thinking" as const, thinking: "", thinkingSignature: '{"encrypted_content":"ChatGPT-only"}', redacted: true }] };
+ const transformed = chatGPTHistory(normalizeContext({ messages: [native, own] }), { ...model, provider: "chatgpt" });
+ assert.equal((transformed.messages[0] as AssistantMessage).provider, "openai");
+ assert.deepEqual((transformed.messages[0] as AssistantMessage).content, []);
+ assert.equal((transformed.messages[1] as AssistantMessage).content[0]!.type, "thinking");
+ assert.equal(native.content.length, 1, "source history must not be mutated");
+});
+
+test("no terminal completed is never success; late quota/incomplete streams are actionable/redacted", async () => {
+ const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, false);
+ for (const events of [[{ type: "response.created", response: { id: "resp_partial" } }], [{ type: "response.incomplete", response: { status: "incomplete", output: [], incomplete_details: { reason: "max_output_tokens" }, usage } }], [{ type: "error", code: "subscription_sharing_usage_limit_exceeded", message: "secret scratch provider diagnostic" }]]) {
+ responseEvents = events;
+ const result = await engine.streamFn(engine.model, context()).result();
+ assert.equal(result.stopReason, "error"); assert.equal(result.provider, "chatgpt");
+ assert(!result.errorMessage?.includes("secret scratch provider diagnostic"));
+ if (JSON.stringify(events).includes("usage_limit")) assert(result.errorMessage?.includes("https://chatgpt.com/settings/usage"));
+ }
+});
+
+test("OAuth failure and background denial NEVER use ambient OPENAI_API_KEY", async () => {
+ requests = [];
+ for (const error of ["expired ChatGPT session", "Background use is off", "catalog unavailable", "quota exceeded"]) {
+ leaseError = error;
+ const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, true);
+ assert.equal((await engine.streamFn(engine.model, context()).result()).stopReason, "error");
+ }
+ leaseError = null;
+ assert.equal(requests.length, 0);
+});
+
+test("consent revocation cancels only applicable background streams and preserves foreground", () => {
+ const foreground = chatGPTAbortController(false);
+ const background = chatGPTAbortController(true);
+ ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 2 }, status, invalidate: "background" });
+ assert.equal(foreground.controller.signal.aborted, false);
+ assert.equal(background.controller.signal.aborted, true);
+ foreground.release(); background.release();
+});
+
+test("payload sanitizer omits unsupported fields and rejects unsupported hosted tools", () => {
+ assert.deepEqual(chatGPTPayload({ model: "scratch", input: [], stream: false, store: true, metadata: { test: true }, previous_response_id: "old", background: true }, "scratch"), { model: "scratch", input: [], stream: true, store: false });
+ assert.throws(() => chatGPTPayload({ input: [], tools: [{ type: "tool_search" }] }, "scratch"), /Unsupported/);
+});
+
+test("native identity/session/cache/fetch and awaited caller observers survive enforcement; terminal outcome exactly once", async () => {
+ const { APP_IDENTITY } = await import("../src/agent/models.ts");
+ const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, false);
+ let headers = new Headers(), payload: any, observed = 0, responses = 0, delegated = 0;
+ responseEvents = [complete()];
+ const stream = engine.streamFn(engine.model, context(), {
+ identity: APP_IDENTITY, sessionId: "exact-native-chat-session", timeoutMs: 12345,
+ headers: { "x-native-observer": "retained", "x-suppressed": null },
+ fetch: async (input, init) => { delegated++; const req = new Request(input, init); headers = req.headers; payload = JSON.parse(await req.text()); return new Response(`data: ${JSON.stringify(complete())}\n\n`, { headers: { "content-type": "text/event-stream" } }); },
+ onPayload: async (raw, native) => { await Promise.resolve(); assert.equal(native.provider, "openai"); return { ...raw as object, model: "not-the-selected-model", store: true, metadata: { forbidden: true } }; },
+ onResponse: async (_response, native) => { await Promise.resolve(); assert.equal(native.provider, "openai"); responses++; },
+ onProviderStreamEvent: async (_raw, native) => { await Promise.resolve(); assert.equal(native.provider, "openai"); observed++; },
+ });
+ const events = []; for await (const event of stream) events.push(event);
+ assert.equal(delegated, 1); assert.equal(observed, 1); assert.equal(responses, 1);
+ assert.equal(headers.get("user-agent"), APP_IDENTITY.userAgent.custom);
+ assert.equal(headers.get("session_id"), "exact-native-chat-session");
+ assert.equal(headers.get("x-client-request-id"), "exact-native-chat-session");
+ assert.equal(headers.get("x-native-observer"), "retained"); assert.equal(headers.get("x-suppressed"), null);
+ assert.equal(payload.prompt_cache_key, "exact-native-chat-session"); assert.equal(payload.model, model.id); assert.equal(payload.metadata, undefined);
+ assert.equal(events.filter((e) => e.type === "done" || e.type === "error").length, 1);
+ const failed = engine.streamFn(engine.model, context(), { onProviderStreamEvent: async () => { throw new Error("private observer detail"); } });
+ const failedEvents = []; for await (const event of failed) failedEvents.push(event);
+ assert.equal(failedEvents.filter((e) => e.type === "error").length, 1); assert.equal(failedEvents.filter((e) => e.type === "done").length, 0);
+ assert(!(await failed.result()).errorMessage?.includes("private observer detail"));
+});
+
+test("case-insensitive Authorization overrides including null are refused before native fetch", async () => {
+ const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, false);
+ const before = requests.length;
+ for (const headers of [{ Authorization: "Bearer wrong" }, { authorization: null }, { AUTHORIZATION: "" }] as Record[]) {
+ assert.equal((await engine.streamFn(engine.model, context(), { headers }).result()).stopReason, "error");
+ }
+ assert.equal(requests.length, before);
+});
+
+test("full secure-storage invalidation aborts foreground and background controllers and clears capabilities", () => {
+ const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true);
+ ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 2, revision: 3 }, status: { ...status, available: false }, invalidate: "all" });
+ assert(foreground.controller.signal.aborted); assert(background.controller.signal.aborted); assert.equal(chatGPTModels().length, 0);
+ foreground.release(); background.release();
+});
diff --git a/tests/chatgpt-ui.test.tsx b/tests/chatgpt-ui.test.tsx
new file mode 100644
index 0000000..a9333b8
--- /dev/null
+++ b/tests/chatgpt-ui.test.tsx
@@ -0,0 +1,83 @@
+import React, { act } from "react";
+(globalThis as Record).React = React;
+import assert from "node:assert/strict";
+import { test, afterEach } from "node:test";
+import { createRoot, type Root } from "react-dom/client";
+import { parseHTML } from "linkedom";
+import { useModelCatalog, catalogLimits } from "../components/model-picker.tsx";
+import { useZeroKeys, useStrongKeyless } from "../components/setup-cta.tsx";
+import { notifySettingsSaved } from "../components/settings-signal.ts";
+import { SidebarProvider } from "../components/sidebar.tsx";
+import { ChatGPTConnection } from "../components/settings/chatgpt-connection.tsx";
+import { GraphCostControl } from "../components/graph-view.tsx";
+import { graphCostSummary } from "../src/core/cost-summary.ts";
+import type { ModelCatalogResponse, ChatGPTStatus } from "../src/core/index.ts";
+const dom = parseHTML("");
+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;
+let root: Root | undefined, host: HTMLDivElement | undefined;
+function deferred() { let resolve!: (v: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; }
+function body(id: string, ready = true): ModelCatalogResponse {
+ return { models: ready ? [{ id, name: id, provider: "chatgpt", input: ["text"], contextWindow: 8192, maxTokens: 1024, reasoning: false, supportedLevels: ["off"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] : [], providers: [{ id: "chatgpt", envVar: "", keyPresent: ready }], slots: { strong: { model: { provider: "chatgpt", id: "explicit-pin" } }, cheap: null, vision: null }, defaults: {} } as ModelCatalogResponse;
+}
+let snapshots: Record = {};
+function Reader({ id }: { id: string }) {
+ const { catalog } = useModelCatalog();
+ snapshots[id] = { catalog, zero: useZeroKeys(), strong: useStrongKeyless() };
+ return {catalog ? [...catalogLimits(catalog).keys()].join(",") : "unavailable"};
+}
+async function mount(children: React.ReactNode) { host = document.createElement("div"); document.body.append(host); root = createRoot(host); await act(async () => root!.render(children)); }
+afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; globalThis.fetch = originalFetch; delete window.lh; snapshots = {}; });
+
+test("shared ChatGPT-only readiness admits without API keys; connection invalidation clears every reader and fences old success/error", async () => {
+ const requests: ReturnType>[] = [];
+ globalThis.fetch = async (input) => { assert.equal(input, "/api/models"); const request = deferred(); requests.push(request); return request.promise; };
+ await mount(<>>);
+ assert.equal(requests.length, 1);
+ await act(async () => requests[0]!.resolve(Response.json(body("A"))));
+ for (const value of Object.values(snapshots)) { assert.equal(value.zero, false); assert.equal(value.strong, false); assert.equal(value.catalog?.models[0]?.id, "A"); }
+ await act(async () => notifySettingsSaved("models"));
+ assert.equal(requests.length, 2);
+ await act(async () => notifySettingsSaved("keys"));
+ assert.equal(requests.length, 3);
+ for (const value of Object.values(snapshots)) assert.equal(value.catalog, undefined);
+ await act(async () => requests[2]!.resolve(Response.json(body("B", false))));
+ await act(async () => requests[1]!.resolve(Response.json(body("stale-A"))));
+ for (const value of Object.values(snapshots)) { assert.equal(value.catalog?.models.length, 0); assert.equal(value.zero, true); assert.equal(value.strong, true); }
+ assert(!document.body.textContent?.includes("chatgpt:A"));
+});
+
+test("plan plus unknown graph rows keep the existing unknown suffix without fabricating API dollars", async () => {
+ const step = { inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 };
+ const plan = { model: "chatgpt:gpt-5", steps: [step] };
+ const unknown = { model: "unknown:missing", steps: [step] };
+ const costs = graphCostSummary({ chats: [{ title: "Scratch", extractions: [{ startedAt: 1, at: "2026-10-24T00:00:00Z", durationMs: 1, stages: { extractor: plan, auditor: unknown } }, { startedAt: 2, at: "2026-10-24T00:00:00Z", durationMs: 1, stages: { extractor: unknown, auditor: plan } }] }], sources: [] });
+ await mount();
+ await act(async () => { document.querySelector("button")!.click(); });
+ const recent = [...document.querySelectorAll(".border-t .shrink-0.tabular-nums")];
+ assert.equal(recent.length, 2);
+ for (const row of recent) assert.equal(row.textContent, "ChatGPT plan +1 unpriced", "both recent two-stage runs disclose their unknown stage");
+ assert.equal((document.body.textContent?.match(/\+1 unpriced/g) ?? []).length, 4, "both mixed stage rows also retain their unknown suffix");
+ assert(!document.body.textContent?.includes("$0.00"));
+});
+
+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;
+ 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 };
+ 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); }; } } };
+ globalThis.fetch = async (input) => { assert.equal(input, "/api/models"); fetches++; return Response.json(body(`model-${fetches}`)); };
+ await mount();
+ assert.equal(subscriptions, 1);
+ const before = fetches;
+ await act(async () => { for (const callback of pushes) callback(); });
+ assert.equal(fetches, before + 1);
+ await act(async () => root!.render());
+ assert.equal(subscriptions, 2);
+ const mounted = fetches;
+ await act(async () => { for (const callback of pushes) callback(); });
+ assert.equal(fetches, mounted + 1, "one global publisher even when panel also reads pushed status");
+ await act(async () => root!.render());
+ assert.equal(unsubscriptions, 1);
+ await act(async () => { for (const callback of pushes) callback(); });
+ assert.equal(fetches, mounted + 2);
+});
diff --git a/tests/chatgpt-usage.test.ts b/tests/chatgpt-usage.test.ts
new file mode 100644
index 0000000..3c828a0
--- /dev/null
+++ b/tests/chatgpt-usage.test.ts
@@ -0,0 +1,45 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { callCost, sessionCost, turnCost, jobCost } from "../src/core/prices.ts";
+import { graphCostSummary } from "../src/core/cost-summary.ts";
+import type { TurnUsage } from "../src/core/contracts.ts";
+import { planCostLabel } from "../components/usage-format.ts";
+const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
+const turn = (model: string, steps = [step]): TurnUsage => ({ id: "scratch-turn", messageId: "scratch-message", at: "2026-10-19T00:00:00Z", model, steps });
+
+test("subscription usage is neither API dollars nor an unpriced-model mistake, including empty/partial usage", () => {
+ const plan = turn("chatgpt:gpt-5");
+ assert.equal(callCost(step, plan.model), null);
+ assert.equal(turnCost(turn(plan.model, [])), null);
+ assert.equal(jobCost({ usage: { model: plan.model, ...step, steps: 1 } }), null);
+ assert.deepEqual(sessionCost({ turns: [plan, turn(plan.model, [])] }), { cost: 0, unpriced: 0, subscription: 2 });
+ const api = turn("openai:gpt-5");
+ const mixed = sessionCost({ turns: [plan, api, turn("unknown:missing")] });
+ assert.equal(mixed.cost, turnCost(api)); assert.equal(mixed.unpriced, 1); assert.equal(mixed.subscription, 1);
+ assert.equal(planCostLabel(null, true), "ChatGPT plan");
+ assert.match(planCostLabel(mixed.cost, true), /API \+ ChatGPT plan$/);
+});
+
+test("graph extraction and audit preserve subscription identity separately from priced and unknown stages", () => {
+ const base = { startedAt: 1, at: "2026-10-19T00:00:00Z", durationMs: 1, stages: { extractor: { model: "chatgpt:gpt-5", steps: [step] }, auditor: { model: "chatgpt:gpt-5", steps: [] } } };
+ const plan = graphCostSummary({ chats: [{ title: "Scratch", extractions: [base] }], sources: [] });
+ assert.equal(plan.unpricedRuns, 0); assert.equal(plan.subscriptionRuns, 2);
+ assert.equal(plan.stages.extraction.unpricedRuns, 0); assert.equal(plan.stages.audit.unpricedRuns, 0);
+ assert.equal(plan.runs[0]!.dollars, null);
+ const mixed = graphCostSummary({ chats: [{ title: "Scratch", extractions: [{ ...base, stages: { ...base.stages, auditor: { model: "openai:gpt-5", steps: [step] } } }] }], sources: [] });
+ assert.equal(mixed.subscriptionRuns, 1);
+ assert.equal(mixed.totalDollars, callCost(step, "openai:gpt-5"));
+ assert.equal(mixed.runs[0]!.subscriptionRuns, 1);
+ assert.equal(mixed.unpricedRuns, 0);
+ for (const reverse of [false, true]) {
+ const stages = [{ model: "chatgpt:gpt-5", steps: [step] }, { model: "unknown:not-priced", steps: [step] }];
+ if (reverse) stages.reverse();
+ const unknown = graphCostSummary({ chats: [{ title: "Scratch", extractions: [{ ...base, stages: { extractor: stages[0]!, auditor: stages[1]! } }] }], sources: [] });
+ assert.equal(unknown.unpricedRuns, 1); assert.equal(unknown.runs[0]!.dollars, null); assert.equal(unknown.subscriptionRuns, 1);
+ assert.equal(unknown.stages.extraction.unpricedRuns + unknown.stages.audit.unpricedRuns, 1);
+ }
+ const zero = graphCostSummary({ chats: [{ title: "Scratch", extractions: [{ ...base, stages: { ...base.stages, auditor: { model: "openai:gpt-5", steps: [{ ...step, inputTokens: 0, outputTokens: 0 }] } } }] }], sources: [] });
+ assert.equal(zero.runs[0]!.dollars, 0); assert.equal(zero.unpricedRuns, 0); assert.equal(zero.subscriptionRuns, 1);
+ const capped = graphCostSummary({ recentCap: 0, chats: [{ title: "Scratch", extractions: [{ ...base, stages: { ...base.stages, auditor: { model: "unknown:model", steps: [step] } } }] }], sources: [] });
+ assert.equal(capped.runs.length, 0); assert.equal(capped.unpricedRuns, 1);
+});
diff --git a/tests/compaction-service.test.ts b/tests/compaction-service.test.ts
index e5150b0..34a1f6d 100644
--- a/tests/compaction-service.test.ts
+++ b/tests/compaction-service.test.ts
@@ -74,6 +74,23 @@ const depsOf = (chatId: string, thresholdTokens: number): CompactionDeps => ({
// ── the laws ────────────────────────────────────────────────────────────────
describe("the compaction service", () => {
+ it("background eligibility skips both entrypoints without writes, but still awaits an existing structural run", async () => {
+ const chatId = overThresholdChat("compact: consent off");
+ const before = summarizeCalls;
+ const denied = { ...depsOf(chatId, 40_000), canStart: () => false };
+ await ensureCompacted(denied, chatId); kickCompaction(() => denied, chatId);
+ assert.equal(summarizeCalls, before);
+ assert.equal(activeCompactionOf(chatStore.getChat(chatId)!.messages), null);
+ let release!: () => void, entered!: () => void;
+ const gate = new Promise((done) => { release = done; });
+ const started = new Promise((done) => { entered = done; });
+ const allowed = { ...depsOf(chatId, 40_000), summarize: async (prompt: string) => { entered(); await gate; return depsOf(chatId, 40_000).summarize!(prompt, denied.engine); } };
+ kickCompaction(() => allowed, chatId); await started;
+ let finished = false;
+ const waiter = ensureCompacted(denied, chatId).then(() => { finished = true; });
+ await Promise.resolve(); assert.equal(finished, false);
+ release(); await waiter; assert.equal(finished, true);
+ });
it("over-threshold record → marker appended, usage logged, both events emitted", async () => {
const chatId = overThresholdChat("compact: basic");
const events: string[] = [];
diff --git a/tests/desktop-api.test.ts b/tests/desktop-api.test.ts
new file mode 100644
index 0000000..2007338
--- /dev/null
+++ b/tests/desktop-api.test.ts
@@ -0,0 +1,84 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { createRequire } from "node:module";
+import { NextRequest } from "next/server";
+// Installed Next 16.3.2 retains the old test-helper export despite proxy.md's new name.
+import { unstable_doesMiddlewareMatch as unstable_doesProxyMatch } from "next/experimental/testing/server";
+import { desktopAPIAuthorized } from "../src/server/desktop-access.ts";
+import { proxy, config } from "../proxy.ts";
+import { browserServiceSpawnEnv } from "../src/agent/subagents/headless.ts";
+const require = createRequire(import.meta.url);
+const { desktopAPIHeaders, HEADER } = require("../electron/desktop-api.cjs");
+const { trustedSender, chatGPTControl } = require("../electron/chatgpt-ipc.cjs");
+const secret = "a".repeat(64);
+const origin = "http://127.0.0.1:12345";
+
+function fixture() {
+ const mainFrame = { url: `${origin}/chat/new` };
+ const contents = { id: 5, mainFrame, isDestroyed: () => false };
+ const win = { webContents: contents, isDestroyed: () => false };
+ const details = { url: `${origin}/api/settings`, webContentsId: contents.id, webContents: contents, frame: mainFrame, initiatorOrigin: origin, requestHeaders: { Accept: "application/json" } };
+ return { mainFrame, contents, win, details };
+}
+
+test("desktop capability injects only trusted app mainframe exact-origin API requests and strips every other case", () => {
+ const { details, win, mainFrame, contents } = fixture();
+ assert.equal(desktopAPIHeaders(details, origin, secret, [win])[HEADER], secret);
+ for (const mutation of [
+ { url: "https://thirdparty.invalid/api/settings" },
+ { url: "http://127.0.0.1:12346/api/settings" },
+ { url: `${origin}/chat/new` },
+ { frame: { url: `${origin}/chat/new` } }, // forged frame identity
+ { frame: { url: "about:srcdoc", parent: mainFrame } },
+ { frame: null },
+ { webContentsId: 6 },
+ { webContents: { ...contents } },
+ { initiatorOrigin: "https://thirdparty.invalid" },
+ { initiatorOrigin: "null" },
+ ]) {
+ const result = desktopAPIHeaders({ ...details, ...mutation, requestHeaders: { "X-LearnHarness-Desktop": secret } }, origin, secret, [win]);
+ assert(!Object.keys(result).some((key) => key.toLowerCase() === HEADER), JSON.stringify(mutation));
+ }
+ assert.equal(desktopAPIHeaders(details, origin, secret, [])[HEADER], undefined, "other WebContents are not app windows");
+ mainFrame.url = "https://thirdparty.invalid/after-navigation";
+ assert.equal(desktopAPIHeaders(details, origin, secret, [win])[HEADER], undefined, "navigation revokes trust even for the same native frame");
+ assert.equal(desktopAPIHeaders(details, origin, null, [win])[HEADER], undefined, "externally launched desktop dev receives no packaged capability");
+});
+
+test("all methods fail closed for desktop missing/wrong secret; API-only matcher covers SSE/settings/spending", () => {
+ assert(desktopAPIAuthorized(new Headers(), {}), "browser-only API-key mode unchanged");
+ assert(!desktopAPIAuthorized(new Headers({ [HEADER]: secret }), { LH_DESKTOP_SERVER: "1" }), "missing server secret must not disable authorization");
+ for (const value of [undefined, "", "wrong", secret + ", " + secret]) {
+ assert(!desktopAPIAuthorized(new Headers(value === undefined ? {} : { [HEADER]: value }), { LH_DESKTOP_SERVER: "1", LH_DESKTOP_API_SECRET: secret }));
+ }
+ assert(desktopAPIAuthorized(new Headers({ [HEADER]: secret }), { LH_DESKTOP_SERVER: "1", LH_DESKTOP_API_SECRET: secret }));
+ for (const pathname of ["/api/events", "/api/chat", "/api/settings/models", "/api/chats/scratch/markdown"]) assert(unstable_doesProxyMatch({ config, nextConfig: {}, url: origin + pathname }));
+ assert(!unstable_doesProxyMatch({ config, nextConfig: {}, url: origin + "/chat/new" }));
+ const originalMarker = process.env.LH_DESKTOP_SERVER;
+ const originalSecret = process.env.LH_DESKTOP_API_SECRET;
+ try {
+ process.env.LH_DESKTOP_SERVER = "1";
+ process.env.LH_DESKTOP_API_SECRET = secret;
+ for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) {
+ assert.equal(proxy(new NextRequest(origin + "/api/settings", { method })).status, 403);
+ assert.equal(proxy(new NextRequest(origin + "/api/settings", { method, headers: { [HEADER]: secret } })).headers.get("x-middleware-next"), "1");
+ }
+ } finally {
+ if (originalMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = originalMarker;
+ if (originalSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = originalSecret;
+ }
+});
+
+test("renderer controls have no lease or general token bridge, and require the trusted main frame", async () => {
+ let leased = false;
+ const connection = { lease: async () => { leased = true; return { accessToken: "scratch-private-token" }; } };
+ assert.deepEqual(await chatGPTControl({ op: "lease", background: false }, connection), { error: "Unsupported ChatGPT control." });
+ assert.equal(leased, false);
+ const { contents, mainFrame, win } = fixture();
+ const BrowserWindow = { fromWebContents: (value: unknown) => value === contents ? win : null };
+ assert(trustedSender({ sender: contents, senderFrame: mainFrame }, origin, BrowserWindow));
+ assert(!trustedSender({ sender: contents, senderFrame: { url: mainFrame.url } }, origin, BrowserWindow));
+ mainFrame.url = "https://thirdparty.invalid/";
+ assert(!trustedSender({ sender: contents, senderFrame: mainFrame }, origin, BrowserWindow));
+ assert.equal(browserServiceSpawnEnv({ LH_DESKTOP_API_SECRET: secret, LH_DESKTOP_SERVER: "1" }).LH_DESKTOP_API_SECRET, undefined);
+});
diff --git a/tests/desktop-platform.test.ts b/tests/desktop-platform.test.ts
index db4db5f..54ee5d6 100644
--- a/tests/desktop-platform.test.ts
+++ b/tests/desktop-platform.test.ts
@@ -10,6 +10,7 @@ import { describe, it } from "node:test";
const require = createRequire(import.meta.url);
const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url));
const mainSource = readFileSync(mainFile, "utf8");
+const mainRequire = createRequire(mainFile);
/** Decline the single-instance lock so normal boot never spawns a server.
* Then call the REAL createWindow with a stub Electron API and inspect its
@@ -27,7 +28,7 @@ function windowOptions(platform: string) {
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
};
const context = {
- require: (id: string) => id === "electron" ? electron : require(id),
+ require: (id: string) => id === "electron" ? electron : mainRequire(id),
process: { platform, argv: [], env: {}, on() {} },
__dirname: dirname(mainFile),
};
@@ -77,7 +78,7 @@ describe("desktop window chrome", () => {
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
assert.equal(bridge.platform, "win32");
assert.deepEqual(Object.keys(bridge).sort(), [
- "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform",
+ "chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform",
]);
});
});
diff --git a/tests/zero-keys.test.tsx b/tests/zero-keys.test.tsx
index 16f0fe3..aacb9c8 100644
--- a/tests/zero-keys.test.tsx
+++ b/tests/zero-keys.test.tsx
@@ -76,7 +76,7 @@ describe("SetupKeysBody — stage 1's self-contained first-key screen", () => {
const html = renderToStaticMarkup(
createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: null }),
);
- assert.match(html, /Add a provider key\./);
+ assert.match(html, /Or add an API key\./);
assert.ok(html.includes("Choose your AI provider and paste its API key. You’ll pick your models next."));
assert.doesNotMatch(html, /First, add|keys unlock|—/);
assert.ok(html.includes('data-testid="setup-key-provider"'));