From 4bd20be5291cfc3682761201d825d3635bcc9466 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 24 Oct 2026 06:52:36 +0300 Subject: [PATCH 01/16] fix: keep research available without search keys --- components/settings/agent-section.tsx | 48 +++----- src/agent/subagents/researcher.ts | 5 +- src/agent/subagents/runner.ts | 5 +- src/agent/tools.ts | 18 +-- src/agent/tools/jobs.ts | 35 +++--- src/agent/tools/practice.ts | 8 +- tests/jobs.test.ts | 37 ++++++ tests/practice-agents.test.ts | 9 +- tests/research-settings.test.tsx | 70 ++++++++++++ tests/settings-pure.test.ts | 16 --- tests/web-search.test.ts | 155 ++++++++++++++++++++------ 11 files changed, 277 insertions(+), 129 deletions(-) create mode 100644 tests/research-settings.test.tsx diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 32346ed..307b8fe 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -131,19 +131,6 @@ export function composeGraphTools(agent: AgentSettings | undefined, on: boolean) return { ...agent, graphTools: { enabled: on } }; } -/** The research tool runs on the search port — the teacher registry DROPS - * it when no engine is live (chat-handler's searchAvailable, the same - * /api/search signal the section fetches). Pure — exported for tests. - * Teacher-stream flag 2026-09-22: the settings row grays on the SAME - * signal, so the row and the actual registry availability can never - * disagree. `search === null` (loading/failed) grays NOTHING. */ -export function researchToolUnavailable( - toolId: string, - search: { available: boolean } | null, -): boolean { - return toolId === "research" && search !== null && !search.available; -} - /** Compose: one slot's model/thinking override. Empty model = absent * override (Default — falls to the factory pin); empty thinking = the * tier keeps its resolved thinking budget. Per-field independence, same @@ -792,28 +779,19 @@ export function AgentSection({ ); })()} - {TOGGLE_TOOLS.map((id) => { - // Research needs a live search engine; the row + toggle gray - // on the same signal the registry drops the tool on (see - // researchToolUnavailable). The reason rides as the hint. - const searchDown = researchToolUnavailable(id, search); - return ( - - onSectionChange(composeTool(agent, id, on))} - /> - - ); - })} + {TOGGLE_TOOLS.map((id) => ( + + onSectionChange(composeTool(agent, id, on))} + /> + + ))} {(() => { const engine = agent?.search?.engine ?? null; const anyKey = engineKeys.tavily === true || engineKeys.brave === true; diff --git a/src/agent/subagents/researcher.ts b/src/agent/subagents/researcher.ts index 943d900..c3a979f 100644 --- a/src/agent/subagents/researcher.ts +++ b/src/agent/subagents/researcher.ts @@ -49,6 +49,8 @@ Tool outputs may be error strings (e.g. search unconfigured, a page that failed Adapt: try another URL or another search angle; if research is genuinely impossible, say so in the brief instead of inventing facts. +If web_search reports that search is unavailable because credentials are missing, do not retry it with different queries. Use web_fetch when you have source URLs, and continue with any other available sources. If the missing search capability materially limits your work, state the limitation and the missing-key setup needed in your brief. Never invent search results or sources. + Your FINAL assistant message is your entire deliverable — it must stand alone, using this format: ## Summary @@ -70,8 +72,7 @@ export interface ResearcherRunOptions { /** The search seam (search port wave, 2026-09-20): resolves the LIVE * provider per call, null = honestly unavailable (the web_search tool * reports that in its output — no crash, no silent fallback). Absent - * seam reads as unavailable: the server wiring always injects one; the - * dispatch-level drop (jobTools) keeps doomed jobs from even starting. */ + * seam reads as unavailable; web_fetch remains usable without it. */ search?: () => SearchProvider | null; /** The researcher's PDF dir (PDF wave, 2026-10-07): when present, the * toolset gains web_fetch_pdf + pdf_info/pdf_extract/pdf_search scoped diff --git a/src/agent/subagents/runner.ts b/src/agent/subagents/runner.ts index 042796f..41a3d40 100644 --- a/src/agent/subagents/runner.ts +++ b/src/agent/subagents/runner.ts @@ -82,9 +82,8 @@ export interface JobRunnerDeps { /** The search seam (search port wave, 2026-09-20): resolves the LIVE * provider fresh per job — server/search.ts composition injected by * the wiring; null = honestly unavailable (the researcher's web_search - * reports it). Absent seam (tests) reads the same. The dispatch-level - * drop (jobTools' searchAvailable) uses the same signal — jobs already - * in flight when a key disappears still fail honestly, never crash. */ + * reports it as recoverable text). Absent seam (tests) reads the same; + * research dispatch and web_fetch do not require search credentials. */ searchProvider?: () => SearchProvider | null; /** Key-presence gate (provider-key interlude, 2026-09-25): given the * resolved engine's provider + the kind's slot tier, return the diff --git a/src/agent/tools.ts b/src/agent/tools.ts index a9cc81b..e0d8d3f 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -268,8 +268,7 @@ export interface ProberToolContext extends ProbeDoneContext { jobStore: JobStore; /** Fire the background runner for a freshly created job. */ spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void; - /** Search availability — the research dispatch tool's honest-disable - * signal (same gate as the teacher's). Absent = available. */ + /** Retained search-status seam; research dispatch is available keylessly. */ searchAvailable?: () => boolean; /** The prober's graph-map reader (wave 7): present = the two map tools * (list_sessions / list_propositions) are registered. The composition @@ -331,13 +330,12 @@ export function createProberTools( // research + pdf are ALWAYS available in every phase — the // prober can verify facts or read learner PDFs while assessing. // demo/visual stay teach-phase-only (they need the lesson's plan - // context). Same gating as the teacher: toggle-filtered here, - // research subject to the searchAvailable gate (honestly absent - // when no engine is live). + // context). Same user-toggle filtering as the teacher; research + // remains available without a search-provider key. ...(jobs.research ? { research: jobs.research } : {}), pdf: jobs.pdf!, // The map reader (wave 7): present only when the settings gate - // opened — same registration-time drop as the search gate. + // opened. ...(ctx.graphStore ? proberGraphTools({ chatStore: ctx.chatStore, graphStore: ctx.graphStore }) : {}), @@ -400,9 +398,7 @@ export interface TeacherToolContext extends PlanToolContext { * rides along — the spawn fires mid-stream, before the tool part is * persisted to the transcript (a transcript scan would race it). */ spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void; - /** Search availability (search port wave, 2026-09-20): the research - * dispatch tool's honest-disable signal — no live engine = the tool - * drops from this registry (JobToolContext.searchAvailable). */ + /** Retained search-status seam; research dispatch is available keylessly. */ searchAvailable?: () => boolean; } @@ -462,10 +458,6 @@ export function createTeacherTools( find: findTool, grep: grepTool, ...planTools(ctx), - // Fix wave (2026-09-20): searchAvailable was RECEIVED here but - // DROPPED when building the inner JobToolContext — the drop-gate - // always took its ?? true default. Threaded now: the live signal - // reaches jobTools' research gate. ...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn, searchAvailable: ctx.searchAvailable }), }, withoutMechanics(enabled), diff --git a/src/agent/tools/jobs.ts b/src/agent/tools/jobs.ts index edbb9d1..00a78ca 100644 --- a/src/agent/tools/jobs.ts +++ b/src/agent/tools/jobs.ts @@ -23,11 +23,9 @@ export interface JobToolContext { * input rides along because the spawn happens MID-STREAM, before * onFinish persists the tool part — a transcript scan would race it. */ spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void; - /** Search availability (search port wave, 2026-09-20): the honest - * disable signal for the research dispatch tool — no live engine - * means NO research tool in the registry (owner decision: registry- - * level drop, no doomed dispatch, no silent fallback). Absent = - * available (tests + any seam without search wiring). */ + /** Search-provider availability, retained for existing composition seams. + * Research dispatch does not depend on it: web_fetch is keyless and + * web_search reports a missing provider as a recoverable tool result. */ searchAvailable?: () => boolean; } @@ -127,25 +125,20 @@ const PDF_DESCRIPTION = * context (chatId + jobStore + the spawn seam), so concurrent chats never * share state. chat-handler wires it each turn; tests wire their own. * - * The research tool drops from the registry when ctx.searchAvailable reports - * no live engine (2026-09-20) — the teacher never sees it, so a keyless - * server can't dispatch doomed research jobs. Jobs ALREADY in flight when - * a key disappears still run to their honest failure (runner backstop). + * Research remains available without a search-provider key; the role's + * existing user-toggle filtering still applies outside this registry. */ export function jobTools(ctx: JobToolContext): Record> { - const searchOn = ctx.searchAvailable?.() ?? true; return { - ...(searchOn && { - research: { - name: "research", - label: "research", - description: RESEARCH_DESCRIPTION, - parameters: researchInputSchema, - execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) => - dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input), - ), - }, - }), + research: { + name: "research", + label: "research", + description: RESEARCH_DESCRIPTION, + parameters: researchInputSchema, + execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) => + dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input), + ), + }, demo: { name: "demo", label: "demo", diff --git a/src/agent/tools/practice.ts b/src/agent/tools/practice.ts index b257c19..a3eca54 100644 --- a/src/agent/tools/practice.ts +++ b/src/agent/tools/practice.ts @@ -40,8 +40,8 @@ export interface PracticeToolContext { graphNodes?: () => ReadonlyArray>; /** THE RARELY-NEEDED SET (owner round 10, 2026-10-05): the map readers * behind the SAME settings gate as the prober's (graphToolsEnabled at - * the composition root); the research + pdf jobs with the honest - * searchAvailable drop. Each absent (tests) = absent from the registry. */ + * the composition root); the research + pdf jobs (both keyless). + * Each absent context (tests) = absent from the registry. */ graph?: GraphToolContext; jobs?: JobToolContext; } @@ -225,8 +225,8 @@ export function practiceTools(ctx: PracticeToolContext): Record { assert.deepEqual(search.queries, ["sigma.js latest version (5)"]); }); + it("a keyless fetch-only researcher still completes and delivers a sourced job result", async () => { + const url = "https://example.com/supplied-source"; + const brief = `## Summary\nVerified supplied source. [Source](${url})\n\n## Sources\n- Source (${url})`; + const input = { question: `Verify ${url}` }; + const chat = chatStore.createChat("Test: keyless fetch-only research"); + trackChat(chat.id); + chatStore.saveMessages(chat.id, nativeToolMessages("verify supplied URL", "research", "call-keyless", input)); + const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-keyless" }); + trackJob(job.id); + const delivery = fakeDeliver(); + const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver, searchProvider: () => null }); + const fetched: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (source) => { + fetched.push(String(source)); + assert.equal(String(source), url); + return new Response("Synthetic verified source content", { headers: { "content-type": "text/plain" } }); + }) as typeof fetch; + researchModelOverride.current = fauxEngine([ + fauxAssistantMessage([fauxToolCall("web_fetch", { url }, { id: "keyless-fetch" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText(brief)]), + ]); + try { + await runner.runJob(job.id); + } finally { + researchModelOverride.current = undefined; + globalThis.fetch = realFetch; + } + assert.deepEqual(fetched, [url]); + const done = jobStore.getJob(job.id)!; + assert.equal(done.status, "done"); + assert.equal((done.result as ResearchResult).brief, brief); + assert.ok((done.result as ResearchResult).sources.some((s) => s.url === url)); + assert.equal(delivery.steers.length, 1); + assert.equal(isSubagentFailure("research", done.result as ResearchResult), false); + }); + it("a failing runner still steers (the teacher learns the outcome) and the job lands error", async () => { const chat = chatStore.createChat("Test: runner failure"); trackChat(chat.id); diff --git a/tests/practice-agents.test.ts b/tests/practice-agents.test.ts index ecaa9b4..5606e17 100644 --- a/tests/practice-agents.test.ts +++ b/tests/practice-agents.test.ts @@ -208,14 +208,15 @@ describe("the practice agent", () => { const jobs = { chatId: "c1", jobStore: null as never, spawn: (() => {}) as never, searchAvailable: () => false }; const graph = { chatStore: null as never, graphStore: null as never }; const tools = practiceTools({ ...ctxOf([]), jobs, graph }); - // research honestly absent (no search engine live); pdf always; the - // prober's two map readers behind the settings gate. + // Both jobs remain available keylessly; the prober's two map + // readers stay behind their existing settings gate. Practice does + // not apply the teacher/prober user tool-toggle filter. assert.deepEqual( Object.keys(tools).sort(), - ["conclude", "find", "grep", "list_propositions", "list_sessions", "ls", "pdf", "problem", "read"], + ["conclude", "find", "grep", "list_propositions", "list_sessions", "ls", "pdf", "problem", "read", "research"], ); const withSearch = practiceTools({ ...ctxOf([]), jobs: { ...jobs, searchAvailable: () => true }, graph }); - assert.ok(withSearch.research, "a live engine adds research"); + assert.ok(withSearch.research, "a live engine also keeps research"); }); it("constructs with exactly the two practice tools", () => { diff --git a/tests/research-settings.test.tsx b/tests/research-settings.test.tsx new file mode 100644 index 0000000..0ec1bf3 --- /dev/null +++ b/tests/research-settings.test.tsx @@ -0,0 +1,70 @@ +import React, { act } from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { parseHTML } from "linkedom"; +import { AgentSection } from "../components/settings/agent-section.tsx"; +import type { AgentSettings } from "../src/core/index.ts"; + +it("keyless settings keep Research interactive while search-engine setup retains its own disabled state", async () => { + const dom = parseHTML("
"); + const globals = globalThis as Record; + const previous = new Map(); + const reason = "No search engine is configured."; + const bodies: Record = { + "/api/search": { available: false, engine: null, reason }, + "/api/keys": { keys: [{ id: "TAVILY_API_KEY", present: false }, { id: "BRAVE_API_KEY", present: false }] }, + "/api/models": { models: [], slots: { strong: null, cheap: null, vision: null } }, + "/api/settings/prompts": { prober: [], teacher: [] }, + }; + for (const [key, value] of Object.entries({ + window: dom.window, + document: dom.document, + navigator: globals.navigator ?? { userAgent: "", platform: "" }, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + fetch: async (url: string) => { + assert.ok(url in bodies, `unexpected request: ${url}`); + return new Response(JSON.stringify(bodies[url])); + }, + })) { + previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + } + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.getElementById("root")!); + let written: AgentSettings | undefined; + try { + await act(async () => { + root.render( { written = next; }} + onModelsChange={() => {}} + onCompactionChange={() => {}} + />); + }); + const row = document.querySelector('[data-testid="tool-row-research"]')!; + const toggle = row.querySelector('[data-testid="toggle-tool-research"]')!; + assert.ok(toggle); + assert.equal(toggle.hasAttribute("disabled"), false); + assert.equal(toggle.getAttribute("aria-checked"), "true"); + assert.ok(!row.className.includes("opacity-40")); + assert.ok(!row.textContent?.includes(reason), "search setup hint is no longer a research-disable hint"); + act(() => { toggle.click(); }); + assert.equal(written?.tools?.research, false, "the keyless toggle emits the same stored boolean"); + const engine = document.querySelector('[data-testid="search-engine-trigger"]')!; + assert.ok(engine.className.includes("cursor-not-allowed"), "search engine preference retains its own key gate"); + act(() => { engine.click(); }); + assert.equal(document.querySelector('[data-testid="search-engine-menu"]'), null, "keyless engine preference does not open"); + assert.ok(document.querySelector('[data-testid="search-engine-row"]')?.textContent?.includes(reason)); + } finally { + act(() => { root.unmount(); }); + for (const [key, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globals[key]; + } + } +}); diff --git a/tests/settings-pure.test.ts b/tests/settings-pure.test.ts index 80a241b..d629349 100644 --- a/tests/settings-pure.test.ts +++ b/tests/settings-pure.test.ts @@ -15,7 +15,6 @@ import { composeSlotModel, composeTool, composeQuizMechanics, - researchToolUnavailable, sectionedSegments, } from "../components/settings/agent-section.tsx"; import { nextOverrides } from "../components/settings/keyboard-section.tsx"; @@ -328,21 +327,6 @@ describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", ( }); }); -describe("researchToolUnavailable — the row grays on the registry's own signal", () => { - it("research grays exactly when the live search status says unavailable", () => { - // the SAME /api/search signal chat-handler's searchAvailable uses — - // the row and the registry can never disagree (Teacher flag 2026-09-22) - assert.equal(researchToolUnavailable("research", { available: false }), true); - assert.equal(researchToolUnavailable("research", { available: true }), false); - // loading/failed fetch grays nothing (never a false-negative row) - assert.equal(researchToolUnavailable("research", null), false); - // only research — the other tools never touch the search port - for (const id of ["demo", "visual", "pdf"]) { - assert.equal(researchToolUnavailable(id, { available: false }), false); - } - }); -}); - describe("sectionedSegments — the prompts overhaul (2026-09-26)", () => { // The server contract: BOTH per-agent lists include the shared // segments (same paths) — the view must list them ONCE. diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 62ba397..46a7248 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -1,20 +1,14 @@ -/** - * Search rewiring (search port wave, 2026-09-20) — the web_search tool's - * port injection + the research dispatch tool's registry-level drop. - * - * Google CSE is dead: the tool runs on the injected search port - * (core/search.ts), the runner threads the seam (searchProvider — covered - * end-to-end in jobs.test.ts), and jobTools drops the research tool when - * ctx.searchAvailable reports no live engine (owner decision: registry - * drop, no doomed dispatch, no silent fallback). - * +/** Keyless research availability + the injected search port. * No network, no shared data files — the jobStore fake below is in-memory. + * A missing search key is a recoverable tool result, not a dispatch gate. */ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { createWebTools } from "../src/agent/tools/web.ts"; import { jobTools } from "../src/agent/tools/jobs.ts"; +import { runResearcherAgent } from "../src/agent/subagents/researcher.ts"; +import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai"; import type { JobStore } from "../src/core/ports.ts"; import type { JobRecord } from "../src/core/contracts.ts"; import type { SearchHit, SearchProvider } from "../src/core/search.ts"; @@ -31,9 +25,13 @@ function stubProvider(hits: SearchHit[] = [{ title: "t", url: "https://e.com/", describe("web_search on the injected port (web.ts)", () => { it("null provider → the honest unavailability string, never a crash", async () => { const { web_search } = createWebTools(() => null); + assert.ok(web_search, "web_search is still exposed without a provider"); const out = await web_search!.execute!("c1", { query: "anything" }); - assert.match(String(out.details), /not available: no search engine is configured/); - assert.match(String(out.details), /Tavily or Brave/); + const missing = "web_search is not available: no search engine is configured. " + + "Set a Tavily or Brave search API key in settings. Report in your brief that web search is unavailable."; + assert.equal(out.details, missing); + assert.deepEqual(out.content, [{ type: "text", text: missing }]); + assert.notEqual(out.isError, true, "missing keys remain ordinary recoverable text, not a native error"); }); it("a live provider returns its hits (count defaults to 5)", async () => { @@ -110,11 +108,86 @@ describe("web_search on the injected port (web.ts)", () => { }); }); -/** Minimal in-memory JobStore — the drop tests never touch the fs. */ +describe("keyless researcher loop", () => { + it("sends the exact approved missing-credentials guidance in the researcher system prompt", async () => { + const approved = "If web_search reports that search is unavailable because credentials are missing, do not retry it with different queries. Use web_fetch when you have source URLs, and continue with any other available sources. If the missing search capability materially limits your work, state the limitation and the missing-key setup needed in your brief. Never invent search results or sources."; + const faux = createFauxCore({}); + faux.setResponses([(context) => { + const system = context.messages.find((m) => m.role === "system"); + assert.ok(system && system.role === "system"); + assert.equal(typeof system.content, "string"); + const prompt = system.content as string; + assert.ok(prompt.split("\n\n").includes(approved), "approved paragraph is passed verbatim"); + assert.match(prompt, /Break the question into 2-4 searchable facets/); + assert.match(prompt, /## Gaps/); + assert.ok(system.toolsAdded?.some((t) => t.name === "web_search")); + assert.ok(system.toolsAdded?.some((t) => t.name === "web_fetch")); + return fauxAssistantMessage([fauxText("Prompt verified; no model behavior inferred.")]); + }]); + assert.equal(await runResearcherAgent("Check the prompt only", { + engine: { model: faux.getModel(), streamFn: faux.streamSimple }, + }), "Prompt verified; no model behavior inferred."); + }); + + // These scripts prove tool/loop mechanics, not real-model compliance. + for (const searchFirst of [true, false]) { + it(searchFirst ? "recovers from missing search keys by fetching a supplied URL" : "fetch-only research needs no search credentials", async () => { + const url = "https://example.com/supplied-source"; + const brief = `## Summary\nVerified supplied source. [Source](${url})`; + const steps: FauxResponseStep[] = []; + const fetchCall = () => fauxAssistantMessage( + [fauxToolCall("web_fetch", { url }, { id: "fetch1" })], { stopReason: "toolUse" }, + ); + if (searchFirst) { + steps.push( + () => { + return fauxAssistantMessage( + [fauxToolCall("web_search", { query: "supplied source" }, { id: "search1" })], { stopReason: "toolUse" }, + ); + }, + (context) => { + const result = context.messages.find((m) => m.role === "toolResult" && m.toolCallId === "search1"); + assert.ok(result && result.role === "toolResult"); + assert.equal(result.isError, false, "missing keys do not abort the native loop"); + assert.match(JSON.stringify(result.content), /no search engine is configured/); + return fetchCall(); + }, + ); + } else steps.push(fetchCall()); + steps.push((context) => { + const result = context.messages.find((m) => m.role === "toolResult" && m.toolCallId === "fetch1"); + assert.ok(result && result.role === "toolResult"); + assert.equal(result.isError, false); + assert.match(JSON.stringify(result.content), /Synthetic verified source content/); + return fauxAssistantMessage([fauxText(brief)]); + }); + const faux = createFauxCore({}); + faux.setResponses(steps); + const realFetch = globalThis.fetch; + const fetched: string[] = []; + globalThis.fetch = (async (input) => { + fetched.push(String(input)); + assert.equal(String(input), url, "only the supplied URL is fetched"); + return new Response("Synthetic verified source content", { headers: { "content-type": "text/plain" } }); + }) as typeof fetch; + try { + assert.equal(await runResearcherAgent(`Verify ${url}`, { + engine: { model: faux.getModel(), streamFn: faux.streamSimple }, + // No search seam: this is the keyless backstop, not a search stub. + }), brief); + assert.deepEqual(fetched, [url]); + } finally { + globalThis.fetch = realFetch; + } + }); + } +}); + +/** Minimal in-memory JobStore — registry tests never touch the fs. */ function fakeJobStore(): JobStore { const jobs = new Map(); return { - createJob(init) { + createJob(init) { const job: JobRecord = { id: `job_${jobs.size + 1}`, kind: init.kind, @@ -149,13 +222,28 @@ function fakeCtx(available?: () => boolean) { }; } -describe("the research registry drop (jobTools)", () => { - it("searchUnavailable: false → research drops from the registry (the other job tools stay)", () => { +describe("keyless research dispatch (jobTools)", () => { + it("searchAvailable: false → research remains in the registry", () => { const tools = jobTools(fakeCtx(() => false)); - assert.equal("research" in tools, false, "no research tool — no doomed dispatch"); + assert.ok(tools.research, "research can fetch supplied URLs without a search provider"); assert.ok(tools.demo && tools.visual && tools.pdf, "the search-less server keeps its other sub-agents"); }); + it("keyless dispatch preserves the async job acknowledgement and spawn contract", async () => { + const ctx = fakeCtx(() => false); + const spawned: Array<{ job: JobRecord; input: unknown }> = []; + const tools = jobTools({ ...ctx, spawn: (job, input) => { spawned.push({ job, input }); } }); + const input = { question: "Verify the supplied URL", context: "https://example.com/source" }; + const out = await tools.research!.execute!("keyless-call", input); + assert.match(String(out.details), /^Dispatched \(job job_1\)\./); + assert.match(String(out.details), /fire-and-forget/); + assert.equal(spawned.length, 1); + assert.equal(spawned[0]!.job.kind, "research"); + assert.equal(spawned[0]!.job.toolCallId, "keyless-call"); + assert.equal(spawned[0]!.job.status, "running"); + assert.deepEqual(spawned[0]!.input, input); + }); + it("searchAvailable: true → research present", () => { const tools = jobTools(fakeCtx(() => true)); assert.ok(tools.research, "a keyed server exposes research"); @@ -167,12 +255,8 @@ describe("the research registry drop (jobTools)", () => { }); }); -// ─── the threaded gate (fix wave, 2026-09-20) ──────────────────────────────── -// -// The fix: createTeacherTools/createProberTools used to DROP searchAvailable -// when building the inner JobToolContext, so jobTools' gate always took its -// ?? true default even when the live wiring said unavailable. These pins -// hold the THREADED path (the registry factories, not jobTools directly). +// Exercise live registry factories too: search status must not override +// the existing per-role/user-toggle policy. import { createProberTools, createTeacherTools } from "../src/agent/tools.ts"; import type { ChatStore } from "../src/core/ports.ts"; @@ -186,19 +270,19 @@ const fakeChatStore = { const ctxBase = { chatId: "chat-gate-test", chatStore: fakeChatStore }; -describe("the threaded searchAvailable gate (registry factories)", () => { - it("createTeacherTools: unavailable drops research on the live wiring path", () => { +describe("keyless role registries", () => { + it("createTeacherTools: unavailable keeps research on the live wiring path", () => { const tools = createTeacherTools({ ...ctxBase, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => false, }); - assert.equal("research" in tools, false, "the threaded signal reached jobTools' gate"); + assert.ok(tools.research, "no search key is required to dispatch research"); assert.ok(tools.demo && tools.visual && tools.pdf, "the other job tools survive"); }); - it("createTeacherTools: available keeps research (the gate reads the signal both ways)", () => { + it("createTeacherTools: available keeps research", () => { const tools = createTeacherTools({ ...ctxBase, jobStore: fakeJobStore(), @@ -208,7 +292,7 @@ describe("the threaded searchAvailable gate (registry factories)", () => { assert.ok(tools.research, "a keyed server exposes research through the teacher"); }); - it("createProberTools: research + pdf are phase-omnipresent; the gate drops research only", () => { + it("createProberTools: research + pdf remain phase-omnipresent with or without search keys", () => { const on = createProberTools({ ...ctxBase, deliver: () => {}, @@ -225,16 +309,25 @@ describe("the threaded searchAvailable gate (registry factories)", () => { spawn: () => {}, searchAvailable: () => false, }); - assert.equal("research" in off, false, "no engine = no research dispatch in the prober either"); + assert.ok(off.research, "keyless research dispatch is available in the prober too"); assert.ok(off.pdf && off.probe_done, "pdf is keyless — it survives; probe_done always-on"); }); - it("createProberTools: the user toggle drops research independently of the gate", () => { + it("createProberTools: the user toggle still drops research without search keys", () => { const tools = createProberTools( - { ...ctxBase, deliver: () => {}, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => true }, + { ...ctxBase, deliver: () => {}, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => false }, { research: false }, ); assert.equal("research" in tools, false, "toggle-filtered like the teacher side"); assert.ok(tools.pdf, "pdf unaffected by a research toggle"); }); + + it("createTeacherTools: the user toggle still drops research without search keys", () => { + const tools = createTeacherTools( + { ...ctxBase, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => false }, + { research: false }, + ); + assert.equal("research" in tools, false); + assert.ok(tools.pdf && tools.demo && tools.visual); + }); }); From 7fb891389d377d24d3a568b81c61169ab7334df5 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 24 Oct 2026 08:15:04 +0300 Subject: [PATCH 02/16] fix: retain Google provider dependencies in desktop builds --- .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 21 ++++ next.config.ts | 20 +++- package.json | 2 +- scripts/smoke-provider-runtime.mjs | 130 +++++++++++++++++++++++++ scripts/smoke-server.mjs | 3 + tests/provider-runtime-payload.test.ts | 42 ++++++++ tests/provider-tracing.test.ts | 62 ++++++++++++ 8 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 scripts/smoke-provider-runtime.mjs create mode 100644 tests/provider-runtime-payload.test.ts create mode 100644 tests/provider-tracing.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c26c5c..a4b167c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,5 +63,7 @@ jobs: NODE_ENV: test ELECTRON_RUN_AS_NODE: "1" - run: npm run build + # Check the fresh trace before any static copying/curation. + - run: node scripts/smoke-provider-runtime.mjs - run: node scripts/build-server.mjs - run: node scripts/smoke-server.mjs --electron diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index feb8377..276edf1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,6 +129,8 @@ jobs: NODE_ENV: test ELECTRON_RUN_AS_NODE: "1" - run: npm run build + # Check the fresh trace before any static copying/curation. + - run: node scripts/smoke-provider-runtime.mjs - run: node scripts/build-server.mjs - run: node scripts/smoke-server.mjs --electron - name: Validate macOS release secrets @@ -169,6 +171,25 @@ jobs: - name: Windows/Linux rehearsal (no publish) if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS' run: npx electron-builder ${{ matrix.builder_args }} --publish never + - name: Initialize packaged providers offline (host and packaged Electron Node) + run: | + case "$RUNNER_OS" in + macOS) + app="dist/mac-arm64/LearnHarness.app/Contents" + payload="$app/Resources/server/standalone" + runtime="$app/MacOS/LearnHarness" + ;; + Windows) + payload="dist/win-unpacked/resources/server/standalone" + runtime="dist/win-unpacked/LearnHarness.exe" + ;; + Linux) + payload="dist/linux-unpacked/resources/server/standalone" + runtime="dist/linux-unpacked/learnharness" + ;; + esac + node scripts/smoke-provider-runtime.mjs --standalone "$payload" + node scripts/smoke-provider-runtime.mjs --standalone "$payload" --runtime "$runtime" - name: Exercise packaged demo runtime (own Electron, no system Chrome) run: | case "$RUNNER_OS" in diff --git a/next.config.ts b/next.config.ts index 93ab859..55e5758 100644 --- a/next.config.ts +++ b/next.config.ts @@ -17,7 +17,8 @@ const nextConfig: NextConfig = { // into the DMG (the packaged app never reads it: the shell passes // LH_AUTH_FILE → userData). Excluded here; build-server.mjs also REFUSES // to package any standalone still containing it. .env* belt-and-braces - // the same class (not observed swept, same seam). + // the same class. Next also copies loaded bare .env after tracing; + // the build-server refusal remains required even with these excludes. // // ⚠ EXCLUDE PATTERNS ARE LOAD-BEARING IN WAYS THE DOCS DON'T DESCRIBE // (empirically established 2026-10-08): adding dir excludes like @@ -26,8 +27,11 @@ const nextConfig: NextConfig = { // under their dist/ — the traced copy collapsed to a bare package.json // and the packaged server died on ERR_MODULE_NOT_FOUND; openai/ // @anthropic-ai — pi-ai's deps — vanished from the trace entirely). - // Anchored "./"-prefixed forms behave the same. Only the three entries - // below are verified safe (complete externals trace + clean payload). + // Anchored "./"-prefixed forms behave the same. **/data/**/* fixes + // suffix matching in Next's JavaScript collector, but NOT its native + // Turbopack tracer: that still prunes gcp-metadata and its private deps. + // Keep the narrow closure workaround below AND the native runtime gate. + // Real SDK data/ directories still need their explicit includes too. // The recursive-DMG payload (repo dist/ swept into the bundle, shipping // the previous build inside the next) is instead prevented by the // desktop:dist clean step AND targeted post-trace curation in @@ -37,7 +41,7 @@ const nextConfig: NextConfig = { // stay intact, and the script guards against leaked data/keys. outputFileTracingExcludes: { "*": [ - "data/**/*", + "**/data/**/*", "auth.json", ".env*", ], @@ -71,6 +75,14 @@ const nextConfig: NextConfig = { "node_modules/@resvg/resvg-js-*/**/*", "node_modules/@earendil-works/pi-ai/dist/providers/data/**/*", "node_modules/@earendil-works/pi-agent-core/dist/**/*.json", + // Next 16.3.2 native Turbopack applies unanchored excludes while + // traversing modules; **/data/**/* still prunes gcp-metadata and + // its private runtime chain. Includes are applied after traversal. + // This is the verified GoogleAuth closure, not a general dep copy; + // the offline native Pi gate must catch future SDK closure changes. + "node_modules/gcp-metadata/**/*", + "node_modules/json-bigint/**/*", + "node_modules/bignumber.js/**/*", ], }, // Framework-free core lives in src/ (plain TS, imported by routes and CLI alike). diff --git a/package.json b/package.json index fdd6c84..18b592b 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "typecheck": "tsc --noEmit", "desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"", "dev:fresh": "bash scripts/dev-fresh.sh", - "desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64" + "desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/smoke-provider-runtime.mjs && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64" }, "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", diff --git a/scripts/smoke-provider-runtime.mjs b/scripts/smoke-provider-runtime.mjs new file mode 100644 index 0000000..58f3c3f --- /dev/null +++ b/scripts/smoke-provider-runtime.mjs @@ -0,0 +1,130 @@ +/** + * OFFLINE native Pi Google initialization gate (no generation or provider calls). + * Unlike /api/models, this traverses Pi's lazy adapter and constructs GoogleGenAI. + * The real onPayload hook throws a unique sentinel BEFORE generateContentStream. + * All file modules must resolve inside the payload, including nested dependencies. + * + * node scripts/smoke-provider-runtime.mjs [--standalone ] [--runtime ] + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import { createRequire, registerHooks, syncBuiltinESMExports } from "node:module"; +import * as os from "node:os"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { formatProbeError, probeEnv } from "./smoke-demo-runtime.mjs"; + +export function verifyProviderRuntime(standalone, runtime = process.execPath) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-provider-runtime-")); + try { + fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 }); + const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(standalone)], { + cwd: home, + env: { ...probeEnv(process.env, home), ELECTRON_RUN_AS_NODE: "1" }, + encoding: "utf8", timeout: 30_000, maxBuffer: 128 * 1024, + }); + if (result.status !== 0 || result.error) { + throw new Error(`provider runtime probe failed (${result.error?.message ?? result.status}):\n${result.stdout ?? ""}${result.stderr ?? ""}`); + } + assert.match(result.stdout, /native Pi Google initialization passed/, "probe exited without success marker"); + process.stdout.write(result.stdout); + } finally { fs.rmSync(home, { recursive: true, force: true }); } +} + +// Dedicated child only: nothing delegates to a live transport, even if the SDK +// changes its fetch path. Count attempts as well: a swallowed guard error fails. +function closeNetwork() { + let attempts = 0; + const deny = () => { attempts++; throw new Error("OFFLINE provider gate: network forbidden"); }; + globalThis.fetch = deny; + globalThis.WebSocket = class { constructor() { deny(); } }; + const require = createRequire(import.meta.url); + for (const [name, methods] of [ + ["http", ["request", "get"]], ["https", ["request", "get"]], + ["net", ["connect", "createConnection"]], ["tls", ["connect"]], + ["dgram", ["createSocket"]], + ["dns", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]], + ["dns/promises", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]], + ]) { + const module = require(`node:${name}`); + for (const method of methods) module[method] = deny; + } + require("node:net").Socket.prototype.connect = deny; + syncBuiltinESMExports(); + return () => assert.equal(attempts, 0, "SDK attempted network access"); +} + +async function probe(standalone) { + const noNetwork = closeNetwork(); + const root = fs.realpathSync(standalone); + const inside = (file) => { + const relative = path.relative(root, fs.realpathSync(file)); + assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative), + `dependency escaped standalone payload: ${file}`); + }; + const loaded = new Set(); + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + const result = nextResolve(specifier, context); + if (result.url.startsWith("file:")) { + inside(fileURLToPath(result.url)); + loaded.add(fileURLToPath(result.url)); + } else assert.ok(result.url.startsWith("node:"), `unexpected dependency URL: ${result.url}`); + return result; + }, + }); + try { + // Pi is an explicit server external. Import its shipped public entry and + // factories, never the checkout's module or a direct/fake Google adapter. + const entry = pathToFileURL(path.join(root, "node_modules/@earendil-works/pi-ai/dist/index.js")); + const { createModels } = await import(entry.href); + const models = createModels(); + for (const [name, factory] of [["google", "googleProvider"], ["opencode", "opencodeProvider"], ["opencode-go", "opencodeGoProvider"]]) { + const provider = (await import(new URL(`providers/${name}.js`, entry).href))[factory](); + models.setProvider(provider); + const model = provider.getModels().find((candidate) => candidate.api === "google-generative-ai"); + assert.ok(model, `${name}: missing Google-backed catalog model`); + const sentinel = `LH_OFFLINE_ON_PAYLOAD_${randomUUID()}`; + let reached = 0; + const result = await models.streamSimple(model, { messages: [{ role: "user", content: "Offline initialization only", timestamp: 0 }] }, { + apiKey: "dummy-offline-provider-gate-not-a-key", + onPayload(payload, selected) { + assert.equal(selected.api, "google-generative-ai"); + assert.ok(payload && typeof payload === "object"); + reached++; + throw new Error(sentinel); + }, + }).result(); + assert.equal(reached, 1, `${name}: did not reach onPayload: ${result.errorMessage}`); + assert.equal(result.stopReason, "error"); + assert.equal(result.errorMessage, sentinel, `${name}: unexpected initialization error`); + noNetwork(); + console.log(`[smoke-provider-runtime] ${name}: native lazy Google adapter reached onPayload; generation stopped`); + } + // Resolution/evaluation, not folder-exists checks. This also covers the + // previously missing transitive edge and permits legitimate nested deps. + for (const name of ["@google/genai", "google-auth-library", "gcp-metadata", "json-bigint", "bignumber.js"]) { + assert.ok([...loaded].some((file) => file.includes(`${path.sep}node_modules${path.sep}${name.replaceAll("/", path.sep)}${path.sep}`)), + `Google runtime chain was not loaded: ${name}`); + } + console.log(`[smoke-provider-runtime] native Pi Google initialization passed; ${loaded.size} modules confined to ${root}; zero network attempts`); + } finally { hooks.deregister(); } +} + +async function main() { + const args = process.argv.slice(2); + if (args[0] === "--probe") return probe(args[1]); + const value = (name, fallback) => { + const index = args.indexOf(name); + if (index < 0) return fallback; + assert.ok(args[index + 1] && !args[index + 1].startsWith("--"), `${name} requires a path`); + return path.resolve(args[index + 1]); + }; + verifyProviderRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath)); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { console.error(`[smoke-provider-runtime] FAIL: ${formatProbeError(error)}`); process.exitCode = 1; }); +} diff --git a/scripts/smoke-server.mjs b/scripts/smoke-server.mjs index 53b53e6..91b6cb5 100644 --- a/scripts/smoke-server.mjs +++ b/scripts/smoke-server.mjs @@ -26,6 +26,7 @@ */ import { spawn } from "node:child_process"; import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs"; +import { verifyProviderRuntime } from "./smoke-provider-runtime.mjs"; import { createRequire } from "node:module"; import * as fs from "node:fs"; import * as http from "node:http"; @@ -80,6 +81,8 @@ async function main() { // HTTP boot alone never imports the dynamically loaded demo engine. // Fail before packaging if its standalone dependency graph is incomplete. verifyDemoRuntime(STANDALONE, RUNTIME); + // /api/models only reads catalogs; initialize the real lazy SDK offline too. + verifyProviderRuntime(STANDALONE, RUNTIME); const port = await freePort(); const url = `http://127.0.0.1:${port}`; const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-")); diff --git a/tests/provider-runtime-payload.test.ts b/tests/provider-runtime-payload.test.ts new file mode 100644 index 0000000..dec3213 --- /dev/null +++ b/tests/provider-runtime-payload.test.ts @@ -0,0 +1,42 @@ +/** Real installed Pi/Google SDK only; no provider stubs, keys, or generation. */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const script = fileURLToPath(new URL("../scripts/smoke-provider-runtime.mjs", import.meta.url)); +const checkout = fileURLToPath(new URL("..", import.meta.url)); +const run = (payload: string) => spawnSync(process.execPath, [script, "--standalone", payload], { encoding: "utf8", timeout: 40_000 }); + +describe("offline native Pi Google initialization gate", () => { + it("reaches the sentinel for direct Google and Google-backed OpenCode adapters using the real SDK", () => { + // This is an unbundled positive control, NOT proof of standalone completeness. + const result = run(checkout); + assert.equal(result.status, 0, String(result.stderr)); + for (const provider of ["google", "opencode", "opencode-go"]) assert.ok(String(result.stdout).includes(`${provider}: native lazy Google adapter reached onPayload`)); + assert.match(String(result.stdout), /zero network attempts/); + }); + it("rejects a missing Pi entry instead of treating any error as success", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-provider-missing-")); + try { + const result = run(root); + assert.equal(result.status, 1); + assert.doesNotMatch(String(result.stdout), /initialization passed/); + assert.match(String(result.stderr), /MODULE_NOT_FOUND|ENOENT/); + assert.deepEqual(fs.readdirSync(root), [], "gate modified the payload"); + } finally { fs.rmSync(root, { recursive: true, force: true }); } + }); + it("rejects an otherwise complete SDK outside the payload (no host fallback)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-provider-escape-")); + try { + fs.symlinkSync(path.join(checkout, "node_modules"), path.join(root, "node_modules"), process.platform === "win32" ? "junction" : "dir"); + const result = run(root); + assert.equal(result.status, 1); + assert.match(String(result.stderr), /dependency escaped standalone payload/); + assert.doesNotMatch(String(result.stdout), /initialization passed/); + } finally { fs.rmSync(root, { recursive: true, force: true }); } + }); +}); diff --git a/tests/provider-tracing.test.ts b/tests/provider-tracing.test.ts new file mode 100644 index 0000000..43aae9c --- /dev/null +++ b/tests/provider-tracing.test.ts @@ -0,0 +1,62 @@ +/** JS matcher + config-policy regressions; native Turbopack needs the real payload gate. */ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { describe, it } from "node:test"; +import nextConfig from "../next.config.ts"; + +const require = createRequire(import.meta.url); +// Use Next's actual bundled matcher, not a different glob implementation. +const picomatch = require("next/dist/compiled/picomatch"); +const { makeIgnoreFn } = require("next/dist/build/collect-build-traces"); +const patterns = nextConfig.outputFileTracingExcludes!["*"]; + +describe("standalone JavaScript tracing exclusions and native workaround policy", () => { + for (const windows of [false, true]) { + const root = windows ? "C:\\build\\learnharness" : "/build/learnharness"; + const file = (relative: string) => root + (windows ? "\\" : "/") + (windows ? relative.replaceAll("/", "\\") : relative); + const ignored = picomatch(patterns, { contains: true, dot: true, windows }); + it(`excludes genuine data directories and secrets (${windows ? "Windows" : "POSIX"})`, () => { + for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local"]) { + assert.equal(ignored(file(relative)), true, relative); + } + // The explicit includes remain necessary: this raw ignore still matches SDK data. + assert.equal(ignored(file("node_modules/@earendil-works/pi-ai/dist/providers/data/models.json")), true); + assert.ok(nextConfig.outputFileTracingIncludes!["*"]!.includes("node_modules/@earendil-works/pi-ai/dist/providers/data/**/*")); + }); + it(`does not confuse dependency suffixes with data directories (${windows ? "Windows" : "POSIX"})`, () => { + for (const relative of ["node_modules/gcp-metadata/build/src/index.js", "node_modules/form-data/lib/form_data.js", "node_modules/@swc/helpers/_/_ts_metadata.js", "node_modules/@swc/helpers/_/_ts_metadata/index.js", "node_modules/somedata/runtime.js"]) { + assert.equal(ignored(file(relative)), false, relative); + assert.equal(picomatch("data/**/*", { contains: true, dot: true, windows })(file("node_modules/gcp-metadata/build/src/index.js")), true, "old pattern must reproduce the bug"); + } + }); + } + it("uses the installed Next JavaScript early-ignore function on the host platform", () => { + const root = process.cwd(); + const ignore = makeIgnoreFn(root, patterns); + assert.equal(ignore(`${root}/data/private.json`), true); + assert.equal(ignore(`${root}/node_modules/gcp-metadata/build/src/index.js`), false); + }); + it("preserves all secret exclusions exactly", () => { + assert.deepEqual(patterns, ["**/data/**/*", "auth.json", ".env*"]); + }); + it("retains exactly the narrow native-Turbopack closure workaround and existing runtime includes", () => { + assert.deepEqual(nextConfig.outputFileTracingIncludes!["*"], [ + "node_modules/@puppeteer/browsers/lib/browser-data/**/*.js", + "node_modules/pdfjs-dist/legacy/**/*", + "node_modules/pdfjs-dist/standard_fonts/**/*", + "node_modules/@napi-rs/canvas-*/**/*", + "node_modules/@resvg/resvg-js-*/**/*", + "node_modules/@earendil-works/pi-ai/dist/providers/data/**/*", + "node_modules/@earendil-works/pi-agent-core/dist/**/*.json", + "node_modules/gcp-metadata/**/*", + "node_modules/json-bigint/**/*", + "node_modules/bignumber.js/**/*", + ]); + }); + it("limits the workaround to its three packages, never learner data or unrelated dependencies", () => { + const closure = nextConfig.outputFileTracingIncludes!["*"]!.slice(-3); + const included = picomatch(closure, { dot: true }); + for (const file of ["node_modules/gcp-metadata/build/src/index.js", "node_modules/json-bigint/lib/parse.js", "node_modules/bignumber.js/bignumber.js"]) assert.equal(included(file), true, file); + for (const file of ["data/private.json", "nested/data/private.json", "auth.json", ".env.local", "node_modules/unrelated/runtime.js", "node_modules/gcp-metadata-lookalike/index.js"]) assert.equal(included(file), false, file); + }); +}); From ae2fad56c425cc2d0558b8483c796b229397c153 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 24 Oct 2026 10:43:51 +0300 Subject: [PATCH 03/16] chore: prepare platform preview 0.3.5-preview.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a7d6a2b..faf0a99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "learnharness", - "version": "0.3.4", + "version": "0.3.5-preview.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "learnharness", - "version": "0.3.4", + "version": "0.3.5-preview.1", "license": "AGPL-3.0-only", "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", diff --git a/package.json b/package.json index 18b592b..2b453eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "learnharness", - "version": "0.3.4", + "version": "0.3.5-preview.1", "license": "AGPL-3.0-only", "author": { "name": "Amos Blomqvist", From 0fc6d4ab94b23224d632bee518c7b7f841614abf Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 24 Oct 2026 12:17:08 +0300 Subject: [PATCH 04/16] feat: add verified desktop ChatGPT subscription connection --- app/api/models/route.ts | 5 +- components/activity-dock.tsx | 8 +- components/graph-view.tsx | 22 +- components/model-picker.tsx | 70 ++- components/settings/agent-section.tsx | 15 +- components/settings/chatgpt-connection.tsx | 86 ++++ components/settings/keys-section.tsx | 2 + components/settings/model-menu.tsx | 2 +- components/settings/settings-dialog.tsx | 4 +- components/setup-cta.tsx | 25 +- components/setup-dialog.tsx | 16 +- components/sidebar.tsx | 10 + components/stats-bar.tsx | 11 +- components/usage-format.ts | 5 + electron-builder.yml | 2 + electron/chatgpt-ipc.cjs | 42 ++ electron/chatgpt.cjs | 513 +++++++++++++++++++++ electron/desktop-api.cjs | 25 + electron/main.cjs | 47 +- electron/preload.cjs | 13 + package-lock.json | 8 + package.json | 1 + proxy.ts | 17 + public/siwc/chatgpt-logo-white.svg | 5 + src/agent/chatgpt-bridge.ts | 122 +++++ src/agent/chatgpt-request-cycle.ts | 122 +++++ src/agent/chatgpt-stream.ts | 118 +++++ src/agent/models.ts | 16 +- src/agent/subagents/headless.ts | 3 + src/agent/teacher-agent.ts | 26 +- src/core/contracts/chatgpt.ts | 18 + src/core/cost-summary.ts | 21 +- src/core/index.ts | 1 + src/core/native-messages.ts | 51 ++ src/core/prices.ts | 9 +- src/server/chat-handler.ts | 59 ++- src/server/compaction.ts | 5 +- src/server/desktop-access.ts | 17 + src/server/jobs.ts | 71 ++- src/server/keys.ts | 2 + src/server/model-catalog.ts | 25 +- src/server/pi/ui-stream.ts | 22 +- tests/chatgpt-auth.test.ts | 343 ++++++++++++++ tests/chatgpt-background.test.ts | 80 ++++ tests/chatgpt-catalog.test.ts | 48 ++ tests/chatgpt-request-cycle.test.ts | 154 +++++++ tests/chatgpt-stream.test.ts | 174 +++++++ tests/chatgpt-ui.test.tsx | 83 ++++ tests/chatgpt-usage.test.ts | 45 ++ tests/compaction-service.test.ts | 17 + tests/desktop-api.test.ts | 84 ++++ tests/desktop-platform.test.ts | 5 +- tests/zero-keys.test.tsx | 2 +- 53 files changed, 2557 insertions(+), 140 deletions(-) create mode 100644 components/settings/chatgpt-connection.tsx create mode 100644 electron/chatgpt-ipc.cjs create mode 100644 electron/chatgpt.cjs create mode 100644 electron/desktop-api.cjs create mode 100644 proxy.ts create mode 100644 public/siwc/chatgpt-logo-white.svg create mode 100644 src/agent/chatgpt-bridge.ts create mode 100644 src/agent/chatgpt-request-cycle.ts create mode 100644 src/agent/chatgpt-stream.ts create mode 100644 src/core/contracts/chatgpt.ts create mode 100644 src/server/desktop-access.ts create mode 100644 tests/chatgpt-auth.test.ts create mode 100644 tests/chatgpt-background.test.ts create mode 100644 tests/chatgpt-catalog.test.ts create mode 100644 tests/chatgpt-request-cycle.test.ts create mode 100644 tests/chatgpt-stream.test.ts create mode 100644 tests/chatgpt-ui.test.tsx create mode 100644 tests/chatgpt-usage.test.ts create mode 100644 tests/desktop-api.test.ts diff --git a/app/api/models/route.ts b/app/api/models/route.ts index 90e28b4..160302e 100644 --- a/app/api/models/route.ts +++ b/app/api/models/route.ts @@ -1,4 +1,4 @@ -import { modelCatalog } from "@/src/server"; +import { modelCatalog, refreshConnectionCatalog } from "@/src/server"; /** * The model picker's data (model-control wave): pi-ai's configured-provider @@ -7,5 +7,6 @@ import { modelCatalog } from "@/src/server"; * catalog: no network, stable within a pi-ai version. */ export async function GET() { - return Response.json(modelCatalog()); + await refreshConnectionCatalog(); + return Response.json(modelCatalog(), { headers: { "Cache-Control": "no-store" } }); } diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 2a8bf34..f619042 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -5,7 +5,7 @@ import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, import { cn } from "@/lib/utils"; import type { CallUsage, JobKind, JobRecord } from "@/src/core"; import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core"; -import { formatTokens, formatUsd } from "@/components/usage-format"; +import { formatTokens, planCostLabel } from "@/components/usage-format"; import { useServerEvent, useServerResync } from "@/components/server-events"; /** @@ -348,7 +348,7 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) { Background agents {blobs.length > 0 && ( - {totalCost === null ? "—" : formatUsd(totalCost)} + {planCostLabel(totalCost, blobs.some((blob) => blob.model?.startsWith("chatgpt:")))} )} @@ -423,7 +423,7 @@ function ActivityBlobRow({ )} - {blob.cost === null ? "—" : formatUsd(blob.cost)} + {planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)} {formatElapsed(now - blob.startedAt)} @@ -462,7 +462,7 @@ function ActivityBlobRow({
Cost - {blob.cost === null ? "—" : formatUsd(blob.cost)} + {planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}
diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 606b567..25acc8c 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -294,9 +294,17 @@ function timeAgo(startedAt: number): string { /** Run/stage cost at 3 decimals (pipeline pennies are real money at this * scale); headline totals use 2. Null (unpriced model) renders "—". */ -function fmtRunCost(dollars: number | null): string { +function fmtRunCost(dollars: number | null, subscription = false): string { + if (subscription) return dollars === null ? "ChatGPT plan" : `$${dollars.toFixed(3)} API + ChatGPT plan`; return dollars === null ? "—" : `$${dollars.toFixed(3)}`; } +function graphUsageLabel(costs: Pick): string { + const stages = [costs.stages.extraction, costs.stages.audit]; + const plan = stages.some((stage) => (stage.subscriptionRuns ?? 0) > 0); + const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0); + const dollars = displayCostTotals(costs).totalDollars; + return plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : `$${dollars.toFixed(2)}`; +} /** * The cost figures the UI DISPLAYS (owner refinement round, 2026-09-24): @@ -349,7 +357,7 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) { open ? "text-foreground" : "text-muted-foreground hover:text-foreground" }`} > - ${displayCostTotals(costs).totalDollars.toFixed(2)} + {graphUsageLabel(costs)} {open && ( @@ -378,7 +386,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) { ]; // The displayed figures come from displayCostTotals (naming excluded — // the pill and this panel must agree). - const { totalDollars, unpricedRuns } = displayCostTotals(costs); + const { unpricedRuns } = displayCostTotals(costs); return (
@@ -386,7 +394,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) { Graph pipeline costs - ${totalDollars.toFixed(2)} + {graphUsageLabel(costs)} {unpricedRuns > 0 && ( · {unpricedRuns} unpriced )} @@ -397,8 +405,8 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
{label} - {fmtRunCost(stage.runs === 0 || (stage.unpricedRuns > 0 && stage.dollars === 0) ? null : stage.dollars)} - {stage.unpricedRuns > 0 && stage.dollars > 0 && ( + {fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, (stage.subscriptionRuns ?? 0) > 0)} + {stage.unpricedRuns > 0 && (stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0 || (stage.subscriptionRuns ?? 0) > 0) && ( +{stage.unpricedRuns} unpriced )} @@ -422,7 +430,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) { )} - {fmtRunCost(run.dollars)} + {fmtRunCost(run.dollars, (run.subscriptionRuns ?? 0) > 0)}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}
))}
diff --git a/components/model-picker.tsx b/components/model-picker.tsx index a09d522..3c7a142 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -8,7 +8,7 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men import { composeSlotModel } from "@/components/settings/agent-section"; import { MenuShell } from "@/components/menu-shell"; import { Hint } from "@/components/ui/hint"; -import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { notifySettingsSaved, onSettingsSaved, useSettingsSaved } from "@/components/settings-signal"; import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; /** GET /api/models is cached per page load for instant menu opens, but @@ -17,51 +17,40 @@ import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/t * every model-menu open (stale-while-revalidate: the cached list shows * instantly, the fresh one swaps in while the menu is open). */ let catalogCache: ModelCatalogResponse | undefined; -let catalogInflight: Promise | undefined; - -function fetchCatalog(force = false): Promise { - if (force) { - catalogCache = undefined; - catalogInflight = undefined; - } - catalogInflight ??= fetch("/api/models") - .then((r) => { - if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); - return r.json() as Promise; - }) - .then((c) => { - catalogCache = c; - return c; - }) - .catch((err) => { - catalogInflight = undefined; // retry on next mount/open - throw err; - }); - return catalogInflight; +let catalogInflight: Promise | undefined; +let catalogGeneration = 0; +let stopCatalogSignals: (() => void) | undefined; +const catalogReaders = new Set<() => void>(); +const publishCatalog = () => { for (const reader of catalogReaders) reader(); }; +function fetchCatalog(revalidate = false): void { + if (revalidate) { catalogGeneration++; catalogInflight = undefined; } + if (catalogInflight) return; + const generation = catalogGeneration; + catalogInflight = fetch("/api/models") + .then((r) => { if (!r.ok) throw new Error(`catalog request failed: ${r.status}`); return r.json() as Promise; }) + .then((c) => { if (generation === catalogGeneration) { catalogCache = c; publishCatalog(); } }) + .catch(() => { /* existing unavailable/usage-only posture, no retry timer */ }) + .finally(() => { if (generation === catalogGeneration) catalogInflight = undefined; }); } - export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh: () => void } { const [catalog, setCatalog] = useState(catalogCache); useEffect(() => { - if (catalogCache) return; - let alive = true; - fetchCatalog() - .then((c) => alive && setCatalog(c)) - .catch(() => { - /* the picker stays closed on failure; the buttons still - display the strong slot from the record fetch */ - }); - return () => { - alive = false; - }; - }, []); - const refresh = useCallback(() => { - fetchCatalog(true) - .then(setCatalog) - .catch(() => { - /* refresh is best-effort: the cached list stays on failure */ + const first = catalogReaders.size === 0; + const read = () => setCatalog(catalogCache); + catalogReaders.add(read); + if (first) { + stopCatalogSignals = onSettingsSaved((section) => { + if (section === "keys") { + catalogGeneration++; catalogInflight = undefined; catalogCache = undefined; + publishCatalog(); fetchCatalog(); + } else if (section === "models") fetchCatalog(true); }); + fetchCatalog(true); + } else fetchCatalog(); + read(); + return () => { catalogReaders.delete(read); if (!catalogReaders.size) { stopCatalogSignals?.(); stopCatalogSignals = undefined; } }; }, []); + const refresh = useCallback(() => fetchCatalog(true), []); return { catalog, refresh }; } @@ -322,6 +311,7 @@ export function ModelControl({ chatId }: { chatId?: string }) { return (
+ {effectiveProvider === "chatgpt" && Using ChatGPT plan} { + const modelsRequest = useRef(0); + const modelsMounted = useRef(false); + const readModels = useCallback((connectionChanged = false) => { + const request = ++modelsRequest.current; + if (connectionChanged) setCatalog(null); fetch("/api/models") - .then((r) => r.json()) + .then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); }) .then((body: ModelsBody) => { + if (!modelsMounted.current || request !== modelsRequest.current) return; setCatalog(body.models); setSlots(body.slots); }) - .catch(() => setCatalog(null)); + .catch(() => { if (modelsMounted.current && request === modelsRequest.current) setCatalog(null); }); }, []); useSettingsSaved((section) => { - if (section === "models") readModels(); + if (section === "models" || section === "keys") readModels(section === "keys"); // THE state-echo root cause: the prompt switches write through the // agent section, but this view (the toggles' truth) never re-read — // the settings-saved law (every reader re-reads) was missing this @@ -364,6 +369,7 @@ export function AgentSection({ }); useEffect(() => { + modelsMounted.current = true; readModels(); fetch("/api/settings/prompts") .then((r) => r.json()) @@ -380,6 +386,7 @@ export function AgentSection({ setEngineKeys(present); }) .catch(() => {}); + return () => { modelsMounted.current = false; modelsRequest.current++; }; }, []); // The reason line re-reads /api/search AFTER an engine write lands — diff --git a/components/settings/chatgpt-connection.tsx b/components/settings/chatgpt-connection.tsx new file mode 100644 index 0000000..db7788c --- /dev/null +++ b/components/settings/chatgpt-connection.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { ChatGPTStatus } from "@/src/core"; + +export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and encrypted credentials locally on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness."; +const empty: ChatGPTStatus = { available: false, pending: false, activeId: null, accounts: [], error: null }; + +/** Status/control surface only; OAuth URLs, codes, and tokens never reach React. */ +export function ChatGPTConnection() { + const [status, setStatus] = useState(empty); + const [selected, setSelected] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + const bridge = window.lh?.chatgpt; + if (!bridge) return; + let mounted = true; + const refresh = async () => { + try { + const next = await bridge.status(); + if (!mounted) return; + setStatus(next); + setSelected((old) => old || next.activeId || ""); + } catch { if (mounted) setError("The desktop connection is unavailable. Retry when the app is ready."); } + }; + void refresh(); + const off = bridge.onChange(() => void refresh()); + return () => { mounted = false; off(); }; + }, []); + const account = status.accounts.find((a) => a.id === selected); + async function control(action: () => Promise) { + if (busy) return; + setBusy(true); + setError(null); + try { + const next = await action(); + if (typeof next.available === "boolean") setStatus(next); + setError(next.error ?? null); + } catch { setError("The ChatGPT connection could not be changed. Try again."); } + finally { setBusy(false); } + } + const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined; + return ( +
+
+ ChatGPT + {status.pending ? "Waiting for browser sign-in…" : status.accounts.some((a) => a.id === status.activeId && a.connected) ? "ChatGPT plan connection" : "Not connected"} +
+

{CHATGPT_NOTICE}

+ + {status.accounts.length > 0 && ( + + )} +
+ {/* Official black/white format and unmodified OpenAI-provided mark. */} + + {status.pending && } + {account?.connected && selected !== status.activeId && } + {account && } +
+ {!status.available &&

ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.

} + {account && ( + + )} + {(error || status.error || status.catalogError) &&

{error || status.error || status.catalogError}

} +
+ ); +} diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 3e1c21a..21388eb 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -26,6 +26,7 @@ import { useEffect, useRef, useState } from "react"; import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core"; import { SettingsGroup } from "./controls.tsx"; +import { ChatGPTConnection } from "./chatgpt-connection.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved } from "@/components/settings-signal"; @@ -412,6 +413,7 @@ export function KeysSection({ return ( <> + {providerRows.map((status) => ( {m.name} - {priceLabel(m.cost)} + {m.provider === "chatgpt" ? "ChatGPT plan" : priceLabel(m.cost)} diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index e3979b4..5026908 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -55,7 +55,7 @@ import { KeyboardSection } from "./keyboard-section.tsx"; * real question users ask). */ const SECTIONS = [ { id: "general", label: "General" }, - { id: "keys", label: "Keys" }, + { id: "keys", label: "Connections" }, { id: "agent", label: "Agent" }, { id: "appearance", label: "Appearance" }, { id: "graph", label: "Graph" }, @@ -376,7 +376,7 @@ export function SettingsDialogBody({ )} {active === "keys" && ( - + )} diff --git a/components/setup-cta.tsx b/components/setup-cta.tsx index b97a816..de59b98 100644 --- a/components/setup-cta.tsx +++ b/components/setup-cta.tsx @@ -15,12 +15,10 @@ * its own save), the same re-read pattern chat.tsx uses for "models". */ -import { useCallback, useEffect, useState } from "react"; import type { AuthKeyStatus } from "@/src/core"; import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section"; import { useModelCatalog } from "@/components/model-picker"; import type { ModelCatalogResponse } from "@/src/core"; -import { useSettingsSaved } from "@/components/settings-signal"; /** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses * classifyKeys (the keys section's own classification — no drift): @@ -40,22 +38,8 @@ export function hasProviderKey( * failed read claiming "zero keys" would be a lie the CTA flow then * leans on). Re-reads on the keys signal. */ export function useZeroKeys(): boolean { - const [zeroKeys, setZeroKeys] = useState(false); - const read = useCallback(() => { - fetch("/api/keys") - .then((r) => r.json()) - .then((body: { keys?: AuthKeyStatus[]; providers?: ProviderKeyInfo[] }) => { - setZeroKeys(!hasProviderKey(body.keys ?? [], body.providers)); - }) - .catch(() => {}); - }, []); - useEffect(() => { - read(); - }, [read]); - useSettingsSaved((section) => { - if (section === "keys") read(); - }); - return zeroKeys; + const { catalog } = useModelCatalog(); + return catalog ? !catalog.providers.some((provider) => provider.keyPresent) : false; } /** The pill itself — the models CTA's chrome and placement, the keys @@ -93,9 +77,6 @@ export function strongKeyless(catalog: ModelCatalogResponse | undefined): boolea * (a provider key saved or deleted) and "models" (the strong slot * re-pinned). Live-updates with the page, never mid-send. */ export function useStrongKeyless(): boolean { - const { catalog, refresh } = useModelCatalog(); - useSettingsSaved((section) => { - if (section === "keys" || section === "models") refresh(); - }); + const { catalog } = useModelCatalog(); return strongKeyless(catalog); } diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 5b902b7..d5e2bb7 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -44,6 +44,7 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig import { Hint } from "@/components/ui/hint"; import { type ProviderKeyInfo } from "@/components/settings/keys-section"; import { ProviderMenuList } from "@/components/settings/model-menu"; +import { ChatGPTConnection } from "@/components/settings/chatgpt-connection"; import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; import { ThinkingMenuList } from "@/components/settings/model-menu"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; @@ -147,7 +148,8 @@ export function SetupKeysBody({ }) { return (
-

Add a provider key.

+ +

Or add an API key.

Choose your AI provider and paste its API key. You’ll pick your models next.

@@ -265,16 +267,19 @@ export function SetupDialog({ // moment ago (stage 1's own screen) widens the pickers immediately. useEffect(() => { if (!open) return; + let cancelled = false; + setBody(null); setPicked({}); setLevels({}); setError(null); fetch("/api/keys") .then((r) => r.json()) - .then((b: { providers?: ProviderKeyInfo[] }) => setKeyProviders(b.providers ?? [])) - .catch(() => setKeyProviders([])); + .then((b: { providers?: ProviderKeyInfo[] }) => { if (!cancelled) setKeyProviders(b.providers ?? []); }) + .catch(() => { if (!cancelled) setKeyProviders([]); }); fetch("/api/models") - .then((r) => r.json()) + .then((r) => { if (!r.ok) throw new Error("Catalog unavailable"); return r.json(); }) .then((b: ModelsBody) => { + if (cancelled) return; setBody(b); setFailed(false); // Prefill (owner, 2026-09-22): each pinned tier opens with its @@ -292,7 +297,8 @@ export function SetupDialog({ } setLevels(preLevels); }) - .catch(() => setFailed(true)); + .catch(() => { if (!cancelled) setFailed(true); }); + return () => { cancelled = true; }; }, [open, keysTick]); // Esc + outside-click — layered dismissal, closable WITHOUT diff --git a/components/sidebar.tsx b/components/sidebar.tsx index dfcb898..00f0994 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -21,6 +21,7 @@ import { fuzzyScore } from "@/src/core"; import { useServerEvent, useServerResync } from "@/components/server-events"; import type { ChatListItem } from "@/src/core"; import { kbdEventForAction } from "@/src/core"; +import { notifySettingsSaved } from "@/components/settings-signal"; /** * Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It @@ -47,6 +48,7 @@ const STORAGE_KEY = "lh:sidebar-open"; * * Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync. */ +import type { ChatGPTControlBridge } from "@/src/core"; const SIDEBAR_AUTOCLOSE = 928; // Module scope, guarded for SSR (the server has no navigator). @@ -58,6 +60,7 @@ declare global { lh?: { /** Read-only host OS; determines the native-control gutter. */ platform?: string; + chatgpt?: ChatGPTControlBridge; expandWindow: () => void; /** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */ onZoom?: (cb: (factor: number) => void) => () => void; @@ -79,6 +82,13 @@ export function useSidebar() { } export function SidebarProvider({ children }: { children: React.ReactNode }) { + useEffect(() => { + const bridge = window.lh?.chatgpt; + if (!bridge) return; + const off = bridge.onChange(() => notifySettingsSaved("keys")); + notifySettingsSaved("keys"); // cover changes missed before this root-lifetime subscription + return off; + }, []); // Start closed so server and client first-render agree (no hydration // mismatch); the stored value is applied after mount. const [open, setOpen] = useState(false); diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index b41ec55..cf7a940 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -83,11 +83,11 @@ export function StatsBar({ const { catalog } = useModelCatalog(); const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]); const ctx = contextWindow({ turns }, limits); - const { cost, unpriced } = sessionCost({ turns }); + const { cost, unpriced, subscription = 0 } = sessionCost({ turns }); // Honest cost: show dollars only when at least one turn priced; // otherwise "—" (never a fake $0.0000). - const pricedCount = turns.length - unpriced; - const costLabel = pricedCount > 0 ? formatUsd(cost) : "—"; + const pricedCount = turns.length - unpriced - subscription; + const costLabel = subscription ? (pricedCount > 0 ? `${formatUsd(cost)} API + ChatGPT plan` : "ChatGPT plan") : pricedCount > 0 ? formatUsd(cost) : "—"; const lastModel = turns.at(-1)?.model; return ( @@ -143,9 +143,10 @@ export function StatsBar({
- Total cost + {subscription ? "Usage" : "Total cost"} {costLabel}
+ {subscription > 0 && Manage ChatGPT usage} {unpriced > 0 && (
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"')); From b8abad2b4f21bb9f19594fd1ec83d4acda080228 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 24 Oct 2026 14:02:54 +0300 Subject: [PATCH 05/16] Refine ChatGPT setup and model provider settings --- components/model-picker.tsx | 1 - components/settings/agent-section.tsx | 2 + components/settings/chatgpt-connection.tsx | 266 ++++++++++++++++----- components/settings/keys-section.tsx | 2 +- components/settings/settings-dialog.tsx | 13 +- components/setup-dialog.tsx | 6 +- electron/chatgpt.cjs | 6 +- tests/chatgpt-connection-ui.test.tsx | 172 +++++++++++++ tests/chatgpt-ui.test.tsx | 4 +- tests/render-settings.test.tsx | 7 +- tests/zero-keys.test.tsx | 3 +- 11 files changed, 402 insertions(+), 80 deletions(-) create mode 100644 tests/chatgpt-connection-ui.test.tsx diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 3c7a142..a442d69 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -311,7 +311,6 @@ export function ModelControl({ chatId }: { chatId?: string }) { return (
- {effectiveProvider === "chatgpt" && Using ChatGPT plan} + {slot !== "strong" && } {}, + manage: (_id: string) => {}, + select: (_id: string) => {}, + dismissWarning: () => {}, +}); -/** Status/control surface only; OAuth URLs, codes, and tokens never reach React. */ -export function ChatGPTConnection() { +/** UI lifetime spans setup's keys → models transition. Only existing safe controls + * are used; account IDs remain control values, never displayed account labels. */ +export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { const [status, setStatus] = useState(empty); - const [selected, setSelected] = useState(""); + const [dialog, setDialog] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [dismissedError, setDismissedError] = useState(null); + const [background, setBackground] = useState(false); + const [registration, setRegistration] = useState(""); + const operation = useRef<"login" | "change" | "logout" | null>(null); + const generation = useRef(0); + const statusSequence = useRef(0); + const mounted = useRef(false); + function publishStatus(next: ChatGPTStatus) { statusSequence.current++; setStatus(next); } useEffect(() => { + mounted.current = true; const bridge = window.lh?.chatgpt; - if (!bridge) return; - let mounted = true; const refresh = async () => { + const request = ++statusSequence.current; try { - const next = await bridge.status(); - if (!mounted) return; - setStatus(next); - setSelected((old) => old || next.activeId || ""); - } catch { if (mounted) setError("The desktop connection is unavailable. Retry when the app is ready."); } + const next = await bridge?.status(); + if (next && mounted.current && request === statusSequence.current) setStatus(next); + } catch { if (mounted.current) setError("The desktop connection is unavailable. Retry when the app is ready."); } }; void refresh(); - const off = bridge.onChange(() => void refresh()); - return () => { mounted = false; off(); }; + const off = bridge?.onChange(() => void refresh()); + return () => { mounted.current = false; generation.current++; off?.(); }; }, []); - const account = status.accounts.find((a) => a.id === selected); - async function control(action: () => Promise) { - if (busy) return; - setBusy(true); + + const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId && a.connected) : undefined; + const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null; + const warning = visibleError === REVOCATION_WARNING ? visibleError : null; + function openConnect() { + setBackground(false); + setRegistration(""); setError(null); + setDismissedError(status.error); + setDialog({ kind: "connect" }); + } + function close() { + if (operation.current === "logout" || operation.current === "change") return; + generation.current++; + setDialog(null); + setError(null); + setDismissedError(status.error); + if (operation.current === "login") { + // Closing an in-flight sign-in cancels it and never applies the draft consent. + void window.lh?.chatgpt?.cancel().then((next) => { + if (mounted.current) { publishStatus(next); setDismissedError(next.error); } + }).catch(() => {}); + } + } + async function control(kind: "change" | "logout", action: () => Promise) { + if (operation.current) return; + operation.current = kind; + setBusy(true); setError(null); setDismissedError(null); try { const next = await action(); - if (typeof next.available === "boolean") setStatus(next); - setError(next.error ?? null); - } catch { setError("The ChatGPT connection could not be changed. Try again."); } - finally { setBusy(false); } + if (!mounted.current) return; + publishStatus(next); + if (kind === "logout" && !next.accounts.find((a) => a.id === status.activeId && a.connected)) { + setDialog(next.error === REVOCATION_WARNING ? { kind: "warning" } : null); + } else setError(next.error); + } catch { if (mounted.current) setError("The ChatGPT connection could not be changed. Try again."); } + finally { operation.current = null; if (mounted.current) setBusy(false); } + } + async function login() { + const bridge = window.lh?.chatgpt; + if (!bridge || operation.current || !status.available) return; + const attempt = ++generation.current; + const allowBackground = background; + operation.current = "login"; + setBusy(true); setError(null); setDismissedError(null); + try { + let next = await bridge.login(registration || undefined); + if (!mounted.current || attempt !== generation.current) return; + publishStatus(next); + const account = next.accounts.find((a) => a.id === next.activeId && a.connected); + if (account && !next.error) { + // Explicit initial choice is applied only after verified successful login. + // Choosing OFF also revokes any prior consent on a returning registration. + if (account.background !== allowBackground) { + operation.current = "change"; + next = await bridge.consent(account.id, allowBackground); + } + if (!mounted.current || attempt !== generation.current) return; + publishStatus(next); + if (!next.error) setDialog(null); + } + setError(next.error); + } catch { if (mounted.current && attempt === generation.current) setError("The ChatGPT connection could not be changed. Try again."); } + finally { operation.current = null; if (mounted.current) setBusy(false); } } const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined; - return ( -
-
- ChatGPT - {status.pending ? "Waiting for browser sign-in…" : status.accounts.some((a) => a.id === status.activeId && a.connected) ? "ChatGPT plan connection" : "Not connected"} -
-

{CHATGPT_NOTICE}

- - {status.accounts.length > 0 && ( - - )} -
- {/* Official black/white format and unmodified OpenAI-provided mark. */} - - {status.pending && } - {account?.connected && selected !== status.activeId && } - {account && } -
- {!status.available &&

ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.

} - {account && ( - - )} - {(error || status.error || status.catalogError) &&

{error || status.error || status.catalogError}

} + } + {managed &&

{managed.email || "ChatGPT account"}

} +

{CHATGPT_NOTICE}

+ + {(dialog.kind === "connect" || managed) &&
+ { + if (dialog.kind === "connect") setBackground(enabled); + else if (managed && bridge) void control("change", () => bridge.consent(managed.id, enabled)); + }} /> + Allow background use{CHATGPT_BACKGROUND_HINT} +
} + {!status.available &&

ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.

} + {status.pending &&

Waiting for browser sign-in…

} + {context.error &&

{context.error}

} +
+ + {dialog.kind === "connect" && void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />} + {managed && } +
+ } + } + ; +} + +function ContinueButton({ onClick, disabled, testid = "chatgpt-login" }: { onClick: () => void; disabled: boolean; testid?: string }) { + return ; +} + +function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; onClose: () => void; busy: boolean }) { + const ref = useRef(null); + const closeRef = useRef(onClose); + closeRef.current = onClose; + const backdropDismiss = useBackdropDismiss(onClose); + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + const focusable = () => [...(ref.current?.querySelectorAll('button:not([disabled]), a[href], input:not([disabled]), select:not([disabled])') ?? [])]; + focusable()[0]?.focus(); + const key = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); event.stopImmediatePropagation(); closeRef.current(); + } else if (event.key === "Tab") { + const items = focusable(), first = items[0], last = items.at(-1); + if (!first) { event.preventDefault(); return; } + if (event.shiftKey && (document.activeElement === first || !ref.current?.contains(document.activeElement))) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && (document.activeElement === last || !ref.current?.contains(document.activeElement))) { event.preventDefault(); first.focus(); } + } + }; + // Capture claims the nested dialog's Escape before outer Settings/setup. + document.addEventListener("keydown", key, true); + return () => { document.removeEventListener("keydown", key, true); if (previous?.isConnected) previous.focus(); }; + }, []); + return createPortal(
+
event.stopPropagation()} className="settings-dialog-pop mx-4 max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-md border border-border bg-card p-5 shadow-xl">{children}
+
, document.body); +} + +/** Compact settings row, or setup's secondary action only. */ +export function ChatGPTConnection({ setup = false }: { setup?: boolean }) { + const ui = useContext(Context); + const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected); + const accounts = ui.status.accounts.filter((a) => a.connected); + return
+
+ {!setup && ChatGPT} + {active && !setup ? <> + + + : }
- ); + {ui.warning &&
{ui.warning}
} +
; +} + +/** Contextual consent discovery belongs beside background model choices only. */ +export function ChatGPTBackgroundAccess({ provider }: { provider: string | undefined }) { + const ui = useContext(Context); + const account: Account | undefined = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected); + if (provider !== "chatgpt" || !account || account.background) return null; + return ; } diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 21388eb..8e5e864 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -413,7 +413,6 @@ export function KeysSection({ return ( <> - {providerRows.map((status) => ( void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
)} +
diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index 5026908..f550252 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -49,13 +49,14 @@ import { KeysSection } from "./keys-section.tsx"; import { Hint } from "@/components/ui/hint"; import { AppearanceSection, GraphSection } from "./appearance-section.tsx"; import { KeyboardSection } from "./keyboard-section.tsx"; +import { ChatGPTUIProvider } from "./chatgpt-connection.tsx"; /** Owner (2026-09-20): hints earn their place — mechanics-explaining * prose is out; only the keyboard note stays (storage location is a * real question users ask). */ const SECTIONS = [ + { id: "keys", label: "Model providers" }, { id: "general", label: "General" }, - { id: "keys", label: "Connections" }, { id: "agent", label: "Agent" }, { id: "appearance", label: "Appearance" }, { id: "graph", label: "Graph" }, @@ -83,7 +84,7 @@ export function SettingsDialogProvider({ children: React.ReactNode; }) { const [show, setShow] = useState(false); - const [landOn, setLandOn] = useState("general"); + const [landOn, setLandOn] = useState("keys"); const [landOnRow, setLandOnRow] = useState(null); const open = useCallback((section?: string, row?: string) => { if (section) setLandOn(section as SectionId); @@ -91,12 +92,12 @@ export function SettingsDialogProvider({ setShow(true); }, []); return ( - + {children} {show && ( setShow(false)} initialSection={landOn} initialRow={landOnRow} /> )} - + ); } @@ -133,7 +134,7 @@ function SettingsDialog({ * (portals render nothing under renderToStaticMarkup). */ export function SettingsDialogBody({ onClose, - initialSection = "general", + initialSection = "keys", initialRow = null, }: { onClose: () => void; @@ -376,7 +377,7 @@ export function SettingsDialogBody({ )} {active === "keys" && ( - + )} diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index d5e2bb7..2372d01 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -44,7 +44,7 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig import { Hint } from "@/components/ui/hint"; import { type ProviderKeyInfo } from "@/components/settings/keys-section"; import { ProviderMenuList } from "@/components/settings/model-menu"; -import { ChatGPTConnection } from "@/components/settings/chatgpt-connection"; +import { ChatGPTBackgroundAccess, ChatGPTConnection } from "@/components/settings/chatgpt-connection"; import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; import { ThinkingMenuList } from "@/components/settings/model-menu"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; @@ -148,8 +148,6 @@ export function SetupKeysBody({ }) { return (
- -

Or add an API key.

Choose your AI provider and paste its API key. You’ll pick your models next.

@@ -213,6 +211,7 @@ export function SetupKeysBody({

)}
+
); } @@ -478,6 +477,7 @@ export function SetupDialog({ )} {SLOT_META[slot].roster} + {slot !== "strong" && }
+
: <> {dialog.kind === "connect" && status.accounts.length > 0 &&
+ {ui.inlineError &&

{ui.inlineError}

} {ui.warning &&
{ui.warning}
}
; } diff --git a/tests/chatgpt-connection-ui.test.tsx b/tests/chatgpt-connection-ui.test.tsx index 0f5c759..d08fa4a 100644 --- a/tests/chatgpt-connection-ui.test.tsx +++ b/tests/chatgpt-connection-ui.test.tsx @@ -20,7 +20,7 @@ const connected = (background = false): ChatGPTStatus => ({ ...disconnected(), a function deferred() { let resolve!: (v: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } function fixture(initial = disconnected(), overrides: Partial = {}) { let status = initial; - const calls = { login: 0, cancel: 0, consent: [] as boolean[], logout: 0 }; + const calls = { login: 0, cancel: 0, consent: [] as boolean[], consentIds: [] as string[], logout: 0 }; const listeners = new Set<() => void>(); const bridge: ChatGPTControlBridge = { status: async () => status, @@ -28,7 +28,7 @@ function fixture(initial = disconnected(), overrides: Partial { calls.cancel++; return status = disconnected(); }, select: async (id) => { status = { ...status, activeId: id }; return status; }, logout: async () => { calls.logout++; return status = disconnected(); }, - consent: async (id, enabled) => { calls.consent.push(enabled); status = { ...status, accounts: status.accounts.map((a) => a.id === id ? { ...a, background: enabled } : a) }; return status; }, + consent: async (id, enabled) => { calls.consentIds.push(id); calls.consent.push(enabled); status = { ...status, accounts: status.accounts.map((a) => a.id === id ? { ...a, background: enabled } : a) }; return status; }, onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); }, ...overrides, }; @@ -170,3 +170,160 @@ test("foreground model/dictation toolbar has no plan badge or background-consent assert(!document.body.textContent?.includes("Enable background use")); assert(!document.querySelector('a[href="https://chatgpt.com/settings/usage"]')); }); + +const storageError = "ChatGPT credentials could not be saved securely. Reconnect before making requests."; +const errorReply = () => ({ error: storageError } as ChatGPTStatus); // Actual IPC catch envelope, deliberately not a snapshot. +function pendingFixture(initial = disconnected(), overrides: Partial = {}) { + const f = fixture(initial, { login: async () => { f.calls.login++; const pending = { ...initial, pending: true }; f.push(pending); return pending; }, ...overrides }); + return f; +} + +test("production lifecycle: Cancel after the immediate pending reply cancels OAuth; later events cannot apply the old choice or close a new dialog", async () => { + const f = pendingFixture(); await mount(); await click(byTest("chatgpt-login")); + await click(document.querySelector('[role="switch"]')!); await click(byTest("chatgpt-proceed")); + assert(document.body.textContent?.includes("Waiting for browser sign-in")); + await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal(f.calls.cancel, 1); + await click(byTest("chatgpt-login")); const nextDialog = byTest("chatgpt-account-dialog"); + await act(async () => f.push(connected(false))); + assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.login, 1); assert.equal(byTest("chatgpt-account-dialog"), nextDialog); +}); + +test("production lifecycle: verified onChange applies explicit ON once to the completed account and closes only its disclosure", async () => { + const f = pendingFixture(); await mount(); await click(byTest("chatgpt-login")); + await click(document.querySelector('[role="switch"]')!); await click(byTest("chatgpt-proceed")); + assert.equal(f.calls.login, 1); assert.deepEqual(f.calls.consent, []); + assert((byTest("chatgpt-proceed") as HTMLButtonElement).disabled); + await act(async () => f.push(connected(false))); + assert.deepEqual(f.calls.consent, [true]); assert.deepEqual(f.calls.consentIds, ["private-registration-id"]); + assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); + await act(async () => f.push(connected(true))); assert.deepEqual(f.calls.consent, [true]); +}); + +test("production lifecycle: initial OFF revokes retained ON only after verified returning-account completion", async () => { + const initial = connected(true); initial.activeId = null; initial.accounts[0]!.connected = false; + const f = pendingFixture(initial); await mount(); await click(byTest("chatgpt-login")); + assert.equal(document.querySelector('[role="switch"]')?.getAttribute("aria-checked"), "false"); + await click(byTest("chatgpt-proceed")); assert.deepEqual(f.calls.consent, []); + await act(async () => f.push(connected(true))); + assert.deepEqual(f.calls.consent, [false]); assert.deepEqual(f.calls.consentIds, ["private-registration-id"]); + assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); +}); + +test("error-only logout reply preserves valid status, shows recoverable error, never claims disconnect, and permits explicit retry", async () => { + const f = fixture(connected(), { logout: async () => { f.calls.logout++; return errorReply(); } }); + await mount(); await click(button("Manage…")); + await assert.doesNotReject(() => click(byTest("chatgpt-disconnect"))); + assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); + assert.equal((byTest("chatgpt-disconnect") as HTMLButtonElement).disabled, false); + assert.equal(document.querySelector('select[aria-label="ChatGPT account"] option')?.textContent, "learner@example.invalid"); + assert(!document.querySelector('[data-testid="chatgpt-login"]')); assert(!document.body.textContent?.includes("Signed out locally")); + f.bridge.logout = async () => { f.calls.logout++; return disconnected(); }; + await click(byTest("chatgpt-disconnect")); assert.equal(f.calls.logout, 2); + assert(byTest("chatgpt-login")); assert(!document.querySelector('[data-testid="chatgpt-disconnect"]')); +}); + +test("error-only consent reply leaves the saved toggle unchanged with a contextual error", async () => { + fixture(connected(), { consent: async () => errorReply() }); await mount(); await click(button("Manage…")); + await assert.doesNotReject(() => click(document.querySelector('[role="switch"]')!)); + assert.equal(document.querySelector('[role="switch"]')?.getAttribute("aria-checked"), "false"); + assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert(byTest("chatgpt-disconnect")); +}); + +test("error-only initial login reply is recoverable without corrupting status or automatically retrying", async () => { + const f = fixture(disconnected(), { login: async () => { f.calls.login++; return errorReply(); } }); + await mount(); await click(byTest("chatgpt-login")); await assert.doesNotReject(() => click(byTest("chatgpt-proceed"))); + assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); + assert.equal((byTest("chatgpt-proceed") as HTMLButtonElement).disabled, false); assert.equal(f.calls.login, 1); + assert.deepEqual(f.calls.consent, []); +}); + +test("error-only consent after verified login keeps disclosure/error open and cannot be retried by a duplicate completion push", async () => { + let consentCalls = 0; + const f = pendingFixture(disconnected(), { consent: async () => { consentCalls++; return errorReply(); } }); + await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector('[role="switch"]')!); await click(byTest("chatgpt-proceed")); + await act(async () => f.push(connected(false))); + assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); assert(byTest("chatgpt-account-dialog")); + await act(async () => f.push(connected(false))); assert.equal(consentCalls, 1); assert.equal(f.calls.login, 1); +}); + +test("failed pending attempt never applies draft consent, starts another login, or closes disclosure on a later clean status", async () => { + const f = pendingFixture(); await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector('[role="switch"]')!); await click(byTest("chatgpt-proceed")); + await act(async () => f.push({ ...connected(false), error: "ChatGPT sign-in could not be verified." })); + assert.equal(document.querySelector('[role="alert"]')?.textContent, "ChatGPT sign-in could not be verified."); + await act(async () => f.push(connected(false))); + assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.login, 1); assert(byTest("chatgpt-account-dialog")); +}); + +test("saved-registration attempt cannot apply its choice to a different active account", async () => { + const f = pendingFixture(); await mount(); await click(byTest("chatgpt-login")); + const select = document.querySelector('select[aria-label="Saved ChatGPT account"]')!; + await act(async () => { select.querySelector('option[value="signed-out-record"]')!.selected = true; select.dispatchEvent(new window.Event("change", { bubbles: true })); }); + await click(document.querySelector('[role="switch"]')!); await click(byTest("chatgpt-proceed")); + await act(async () => f.push(connected(false))); + assert.deepEqual(f.calls.consent, []); assert(byTest("chatgpt-account-dialog")); assert(document.querySelector('[role="alert"]')); +}); + +test("account invalidation while initial consent is awaiting IPC fences its stale reply and preserves an unrelated later Manage dialog", async () => { + const consent = deferred(); + const f = pendingFixture(disconnected(), { consent: () => consent.promise }); + await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector('[role="switch"]')!); await click(byTest("chatgpt-proceed")); + await act(async () => f.push(connected(false))); + const other = { ...connected(), activeId: "other", accounts: [account("other", "other@example.invalid")] }; + await act(async () => f.push(other)); + await click(button("Cancel", byTest("chatgpt-account-dialog"))); await click(button("Manage…")); const manage = byTest("chatgpt-account-dialog"); + await act(async () => consent.resolve(connected(true))); + assert.equal(byTest("chatgpt-account-dialog"), manage); assert(manage.textContent?.includes("other@example.invalid")); + assert.equal(document.querySelector('select[aria-label="ChatGPT account"] option')?.textContent, "other@example.invalid"); +}); + +test("verified event before the initial IPC acknowledgement is handled once; the late pending reply cannot reopen or overwrite it", async () => { + const ack = deferred(); + const f = fixture(disconnected(), { login: () => { f.calls.login++; return ack.promise; } }); + await mount(); await click(byTest("chatgpt-login")); await click(byTest("chatgpt-proceed")); + await act(async () => f.push({ ...disconnected(), pending: true })); + await act(async () => f.push(connected(false))); + assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); + await act(async () => ack.resolve({ ...disconnected(), pending: true })); + assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert(button("Manage…")); assert.equal(f.calls.login, 1); +}); + +test("error-only pending cancellation reply remains visible and recoverable without replacing status", async () => { + const f = pendingFixture(disconnected(), { cancel: async () => { f.calls.cancel++; return errorReply(); } }); + await mount(); await click(byTest("chatgpt-login")); await click(byTest("chatgpt-proceed")); + await assert.doesNotReject(() => click(button("Cancel", byTest("chatgpt-account-dialog")))); + assert.equal(f.calls.cancel, 1); assert.equal(document.querySelector('[role="alert"]')?.textContent, storageError); + assert(document.querySelector('[data-testid="chatgpt-connection"]')); assert.deepEqual(f.calls.consent, []); + assert(byTest("chatgpt-account-dialog")); + f.bridge.cancel = async () => { f.calls.cancel++; return disconnected(); }; + await click(button("Cancel", byTest("chatgpt-account-dialog"))); + assert.equal(f.calls.cancel, 2); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); +}); + +test("coalesced completion before the pending acknowledgement is reconciled from fresh post-ack status", async () => { + const ack = deferred(); + const f = fixture(disconnected(), { login: () => { f.calls.login++; return ack.promise; } }); + await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector('[role="switch"]')!); await click(byTest("chatgpt-proceed")); + // A status reader can miss pending entirely when two notifications coalesce. + await act(async () => f.push(connected(false))); assert.deepEqual(f.calls.consent, []); + await act(async () => ack.resolve({ ...disconnected(), pending: true })); + assert.deepEqual(f.calls.consent, [true]); assert.deepEqual(f.calls.consentIds, ["private-registration-id"]); + assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); +}); + +test("a canceled attempt's late IPC reply cannot release a newer pending attempt or apply its old ON choice", async () => { + const oldAck = deferred(); + const f = fixture(disconnected(), { login: async () => { + f.calls.login++; + if (f.calls.login === 1) return oldAck.promise; + const pending = { ...disconnected(), pending: true }; f.push(pending); return pending; + } }); + await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector('[role="switch"]')!); await click(byTest("chatgpt-proceed")); + await act(async () => f.push({ ...disconnected(), pending: true })); await click(button("Cancel", byTest("chatgpt-account-dialog"))); + await click(byTest("chatgpt-login")); await click(byTest("chatgpt-proceed")); + await act(async () => oldAck.resolve(connected(false))); + assert((byTest("chatgpt-proceed") as HTMLButtonElement).disabled); assert.deepEqual(f.calls.consent, []); + const next = { ...connected(), activeId: "new-attempt-account", accounts: [account("new-attempt-account", "new@example.invalid", true, true)] }; + await act(async () => f.push(next)); + assert.deepEqual(f.calls.consent, [false]); assert.deepEqual(f.calls.consentIds, ["new-attempt-account"]); + assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]')); assert.equal(f.calls.login, 2); +}); From 42abeb65ae442ff5daa8d9c28d1b9159e2017014 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 24 Oct 2026 15:28:26 +0300 Subject: [PATCH 07/16] Unify ChatGPT background use and account management --- components/settings/chatgpt-connection.tsx | 154 ++++-- components/settings/keys-section.tsx | 37 +- components/settings/settings-dialog.tsx | 8 +- electron/chatgpt-ipc.cjs | 3 +- electron/chatgpt.cjs | 133 ++++-- electron/preload.cjs | 3 +- src/agent/chatgpt-bridge.ts | 4 +- src/agent/chatgpt-stream.ts | 8 +- src/core/contracts/chatgpt.ts | 8 +- src/server/chat-handler.ts | 2 +- tests/chatgpt-auth.test.ts | 189 +++++++- tests/chatgpt-background.test.ts | 2 +- tests/chatgpt-catalog.test.ts | 4 +- tests/chatgpt-connection-ui.test.tsx | 526 ++++++++++----------- tests/chatgpt-request-cycle.test.ts | 2 +- tests/chatgpt-stream.test.ts | 2 +- tests/chatgpt-ui.test.tsx | 4 +- tests/desktop-api.test.ts | 10 + tests/render-settings.test.tsx | 4 +- 19 files changed, 687 insertions(+), 416 deletions(-) diff --git a/components/settings/chatgpt-connection.tsx b/components/settings/chatgpt-connection.tsx index 2ad34a9..b6fdd9a 100644 --- a/components/settings/chatgpt-connection.tsx +++ b/components/settings/chatgpt-connection.tsx @@ -8,19 +8,21 @@ import { Button } from "@/components/ui/button"; import { Toggle } from "./controls"; export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and encrypted credentials locally on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness."; -export const CHATGPT_BACKGROUND_HINT = "Background work uses your ChatGPT allowance."; -const REVOCATION_WARNING = "Signed out locally. Remote revocation was not confirmed; disconnect LearnHarness in ChatGPT Settings."; -const empty: ChatGPTStatus = { available: false, pending: false, activeId: null, accounts: [], error: null }; +export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage."; +const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts"; +const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed."; +export const CHATGPT_DISCONNECT_COPY = { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." }; +const empty: ChatGPTStatus = { available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null }; type Account = ChatGPTStatus["accounts"][number]; -type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string } | { kind: "warning" }; -type LoginAttempt = { generation: number; dialog: Dialog; registration: string; background: boolean; pending: boolean; accountId?: string; phase: "starting" | "pending" | "consent" }; +type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string; account: Account; confirm?: boolean } | { kind: "warning" }; +type LoginAttempt = { generation: number; dialog: Dialog; registration: string; background: boolean; backgroundDirty: boolean; pending: boolean; accountId?: string; phase: "starting" | "pending" | "consent" }; const CONTROL_ERROR = "The ChatGPT connection could not be changed. Try again."; function isStatus(value: unknown): value is ChatGPTStatus { if (!value || typeof value !== "object") return false; const s = value as ChatGPTStatus; - return typeof s.available === "boolean" && typeof s.pending === "boolean" && (s.activeId === null || typeof s.activeId === "string") && + return typeof s.available === "boolean" && typeof s.pending === "boolean" && typeof s.allowBackgroundUse === "boolean" && (s.activeId === null || typeof s.activeId === "string") && (s.error === null || typeof s.error === "string") && (s.catalogError == null || typeof s.catalogError === "string") && Array.isArray(s.accounts) && - s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && typeof a.background === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean")); + s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean")); } function replyError(value: unknown): string { return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : CONTROL_ERROR; @@ -45,9 +47,10 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [dismissedError, setDismissedError] = useState(null); - const [background, setBackground] = useState(false); + const [background, setBackground] = useState(true); + const [backgroundDirty, setBackgroundDirty] = useState(false); const [registration, setRegistration] = useState(""); - const operation = useRef<"login" | "cancel" | "change" | "logout" | null>(null); + const operation = useRef<"login" | "cancel" | "change" | "disconnect" | null>(null); const generation = useRef(0); const statusSequence = useRef(0); const mounted = useRef(false); @@ -72,18 +75,28 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { finish(attempt); setError(next.error || CONTROL_ERROR); return; } attempt.phase = "consent"; attempt.accountId = account.id; + let replyFailure: string | null = null; try { - if (account.background !== attempt.background) { + if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) { operation.current = "change"; - const result: unknown = await window.lh!.chatgpt!.consent(account.id, attempt.background); + const request = ++statusSequence.current; + const result: unknown = await window.lh!.chatgpt!.consent(attempt.background); if (!owns(attempt)) return; - const saved = publishStatus(result); + if (!isStatus(result)) { setError(replyError(result)); finish(attempt); return; } + replyFailure = result.error; + let saved: ChatGPTStatus | null; + if (request !== statusSequence.current) { + const refresh = ++statusSequence.current; + const value: unknown = await window.lh!.chatgpt!.status(); + if (!owns(attempt)) return; + saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current; + } else saved = publishStatus(result); if (!saved) { finish(attempt); return; } next = saved; } if (!owns(attempt)) return; const current = next.accounts.find((a) => a.id === account.id && a.id === next.activeId && a.connected); - if (next.error || next.pending || !next.available || !current || current.background !== attempt.background) setError(next.error || CONTROL_ERROR); + if (replyFailure || next.error || next.pending || !next.available || !current || (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background)) setError(replyFailure || next.error || CONTROL_ERROR); else if (dialogRef.current === attempt.dialog) showDialog(null); } catch { if (owns(attempt)) setError(CONTROL_ERROR); } finally { finish(attempt); } @@ -121,19 +134,23 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); }; }, []); - const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId && a.connected) : undefined; + const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId) ?? dialog.account : undefined; + const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id); const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null; const warning = visibleError === REVOCATION_WARNING ? visibleError : null; function openConnect() { if (operation.current) return; - setBackground(false); + setBackground(statusRef.current.allowBackgroundUse); + setBackgroundDirty(false); setRegistration(""); setError(null); setDismissedError(status.error); showDialog({ kind: "connect" }); } function close() { - if (operation.current === "logout" || operation.current === "change" || operation.current === "cancel") return; + if (operation.current === "disconnect" || operation.current === "change" || operation.current === "cancel") return; + const current = dialogRef.current; + if (current?.kind === "manage" && current.confirm) { showDialog({ ...current, confirm: false }); setError(null); return; } const cancel = loginAttempt.current !== null || statusRef.current.pending; const intent = ++generation.current; const owner = dialogRef.current; @@ -156,19 +173,36 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { }); } } - async function control(kind: "change" | "logout", action: () => Promise) { + async function control(kind: "change" | "disconnect", action: () => Promise, target?: string) { if (operation.current) return; const intent = ++generation.current; + const request = ++statusSequence.current; + const owner = dialogRef.current; operation.current = kind; - setBusy(true); setError(null); setDismissedError(null); + setBusy(true); setError(null); + if (kind === "disconnect") setDismissedError(null); try { const value: unknown = await action(); if (!mounted.current || intent !== generation.current) return; - const next = publishStatus(value); - if (!next) return; // Error-only IPC replies never replace safe status. - if (kind === "logout" && !next.error && !next.accounts.find((a) => a.id === status.activeId && a.connected)) showDialog(null); - else if (kind === "logout" && next.error === REVOCATION_WARNING) showDialog({ kind: "warning" }); - else setError(next.error); + if (!isStatus(value)) { setError(replyError(value)); return; } + let next: ChatGPTStatus | null; + if (request !== statusSequence.current) { + const refresh = ++statusSequence.current; + const current: unknown = await window.lh!.chatgpt!.status(); + if (!mounted.current || intent !== generation.current) return; + next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current; + } else next = publishStatus(value); + if (!next) return; + // Dismissed status warnings belong to an earlier action. Only a new + // disconnect outcome (or an explicit IPC error envelope above) reopens one. + const failure = [value.error, next.error].find((message) => message && (kind === "disconnect" || message !== dismissedError)) ?? null; + if (kind === "disconnect" && !next.accounts.some((a) => a.id === target)) { + if (dialogRef.current === owner) { + if (failure === REVOCATION_WARNING) showDialog({ kind: "warning" }); + else if (!failure) showDialog(null); + } + setError(failure); + } else setError(failure || (kind === "disconnect" ? CONTROL_ERROR : null)); } catch { if (mounted.current && intent === generation.current) setError(CONTROL_ERROR); } finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } } } @@ -176,7 +210,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { const bridge = window.lh?.chatgpt; const owner = dialogRef.current; if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return; - const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, pending: false, phase: "starting" }; + const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, backgroundDirty, pending: false, phase: "starting" }; loginAttempt.current = attempt; operation.current = "login"; const request = ++statusSequence.current; setBusy(true); setError(null); setDismissedError(null); @@ -204,45 +238,67 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { const context = { status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning, connect: openConnect, - manage: (id: string) => { if (!operation.current) { setError(null); showDialog({ kind: "manage", accountId: id }); } }, + manage: (id: string) => { + const account = statusRef.current.accounts.find((a) => a.id === id); + if (!operation.current && account) { setError(null); showDialog({ kind: "manage", accountId: id, account }); } + }, select: (id: string) => { if (bridge) void control("change", () => bridge.select(id)); }, dismissWarning: () => { setDismissedError(REVOCATION_WARNING); setError(null); }, }; return {children} {dialog && -

{dialog.kind === "connect" ? "Continue with ChatGPT" : "ChatGPT account"}

{dialog.kind === "warning" ? <> -

{REVOCATION_WARNING}

+

{REVOCATION_WARNING}

- : <> - {dialog.kind === "connect" && status.accounts.length > 0 && } - {managed &&

{managed.email || "ChatGPT account"}

} -

{CHATGPT_NOTICE}

-
+ : dialog.kind === "manage" && dialog.confirm ?
+

{CHATGPT_DISCONNECT_COPY.title}

+

{managed?.email || "ChatGPT account"}

+

{CHATGPT_DISCONNECT_COPY.body}

+ {context.error &&

{context.error}

} +
+ + +
+
: <> + {dialog.kind === "connect" ? <> +

Continue with ChatGPT

+ {status.accounts.length > 0 &&
+ + {registration && } +
} +

{CHATGPT_NOTICE}

+ : managed && <> +
+ {status.accounts.length > 1 && managedPresent && !busy ? : {managed.email || "ChatGPT account"}} +
+
+ +
+ } +
+ { + if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); } + else if (bridge) void control("change", () => bridge.consent(enabled)); + }} /> + {BACKGROUND_LABEL}{CHATGPT_BACKGROUND_HINT} +
+ {dialog.kind === "manage" &&

{CHATGPT_NOTICE}

} + - {(dialog.kind === "connect" || managed) &&
- { - if (dialog.kind === "connect") setBackground(enabled); - else if (managed && bridge) void control("change", () => bridge.consent(managed.id, enabled)); - }} /> - Allow background use{CHATGPT_BACKGROUND_HINT} -
} {!status.available &&

ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.

} {status.pending &&

Waiting for browser sign-in…

} {context.error &&

{context.error}

} -
+
{dialog.kind === "connect" && void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />} - {managed && }
} } @@ -293,7 +349,7 @@ export function ChatGPTConnection({ setup = false }: { setup?: boolean }) { const accounts = ui.status.accounts.filter((a) => a.connected); return
- {!setup && ChatGPT} + {!setup && active && ChatGPT} {active && !setup ? <>