diff --git a/src/agent/chatgpt-bridge.ts b/src/agent/chatgpt-bridge.ts index 7bf1b8c..5412549 100644 --- a/src/agent/chatgpt-bridge.ts +++ b/src/agent/chatgpt-bridge.ts @@ -14,6 +14,12 @@ interface BridgeState { const key = Symbol.for("lh.chatgpt.bridge"); const unavailable = (): ChatGPTStatus => ({ available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null }); const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision].every((v) => Number.isSafeInteger(v) && v >= 0); +/** Next dev also uses parent/child IPC. IPC alone is not our desktop + * transport: require the same launch markers set by Electron startServer. + * These identify the transport, not a substitute for admission/lease checks. */ +function desktopIPCConnected(): boolean { + return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true; +} function bridge(): BridgeState { const global = globalThis as typeof globalThis & { [key]?: BridgeState }; if (!global[key]) { @@ -21,7 +27,7 @@ function bridge(): BridgeState { 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 (!desktopIPCConnected() || 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); } @@ -57,7 +63,7 @@ function acceptStatus(state: BridgeState, value: StatusValue) { /** 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.")); + if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection.")); return new Promise((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); @@ -68,7 +74,7 @@ export function chatGPTRequest(op: "status" | "models" | "lease", fields: Rec }); } export function chatGPTStatus(): ChatGPTStatus { return bridge().status; } -export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); } +export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return desktopIPCConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); } export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; } export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; } export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; } @@ -80,7 +86,7 @@ export function chatGPTScope(selectedModelId: string): AdmissionScope { } 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); + return desktopIPCConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId); } export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise { if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed"); @@ -102,7 +108,7 @@ export async function chatGPTDenial(scope: AdmissionScope | undefined, controlle /** Preserve account order; unknown capabilities/windows are never fabricated. */ export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise { const state = bridge(); - if (!process.send || !process.connected) { clearCatalog(state); return; } + if (!desktopIPCConnected()) { clearCatalog(state); return; } const sequence = ++state.catalogRequest; try { const value = await chatGPTRequest("models"); diff --git a/tests/chatgpt-background.test.ts b/tests/chatgpt-background.test.ts index 9cbc2f1..7bdf602 100644 --- a/tests/chatgpt-background.test.ts +++ b/tests/chatgpt-background.test.ts @@ -1,4 +1,4 @@ -import { test, after } from "node:test"; +import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; @@ -21,7 +21,20 @@ 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, allowBackgroundUse: background, activeId: "scratch-account", accounts: [{ id: "scratch-account", connected: true, planEnabled: true, background: !background }], error: null } }); } -after(() => { fs.rmSync(dir, { recursive: true, force: true }); }); +const originalSend = Object.getOwnPropertyDescriptor(process, "send"), originalConnected = Object.getOwnPropertyDescriptor(process, "connected"); +const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET; +before(() => { + process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); + Object.defineProperty(process, "connected", { configurable: true, value: true }); + Object.defineProperty(process, "send", { configurable: true, value: (_message: unknown, callback?: (error: Error) => void) => { callback?.(new Error("unexpected fixture desktop request")); return false; } }); +}); +after(() => { + fs.rmSync(dir, { 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; + if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker; + if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret; +}); 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 () => { diff --git a/tests/chatgpt-catalog.test.ts b/tests/chatgpt-catalog.test.ts index 975588e..5ff5dad 100644 --- a/tests/chatgpt-catalog.test.ts +++ b/tests/chatgpt-catalog.test.ts @@ -3,6 +3,7 @@ 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 desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET; 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); @@ -10,10 +11,13 @@ const status = (account: string, background = true) => ({ available: true, pendi 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(() => { + process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); 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 (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker; + if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret; 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; }); diff --git a/tests/chatgpt-dev-ipc.test.ts b/tests/chatgpt-dev-ipc.test.ts new file mode 100644 index 0000000..fcb0e44 --- /dev/null +++ b/tests/chatgpt-dev-ipc.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test, { after } from "node:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-dev-ipc-")); +process.env.LH_DATA_DIR = scratch; +process.env.LH_AUTH_FILE = path.join(scratch, "auth.json"); +fs.writeFileSync(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 }); +process.env.OPENAI_API_KEY = "sk-fixture-not-used-for-generation"; +const { GET } = await import("../app/api/models/route.ts"); +const { chatGPTRequest, chatGPTConnected, chatGPTModels } = await import("../src/agent/chatgpt-bridge.ts"); +after(() => fs.rmSync(scratch, { recursive: true, force: true })); + +test("Next-style connected IPC is not desktop transport and never delays keyed API catalogs", async () => { + const send = Object.getOwnPropertyDescriptor(process, "send"); + const connected = Object.getOwnPropertyDescriptor(process, "connected"); + const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET; + const originalFetch = globalThis.fetch; + let sends = 0; + try { + globalThis.fetch = async () => { throw new Error("provider/network calls forbidden in catalog fixture"); }; + Object.defineProperty(process, "send", { configurable: true, value: (_message: unknown, callback?: (error: Error) => void) => { sends++; callback?.(new Error("not a desktop parent")); return false; } }); + Object.defineProperty(process, "connected", { configurable: true, value: true }); + for (const [mode, capability] of [[undefined, undefined], ["1", undefined], [undefined, "d".repeat(64)], ["1", "short"]]) { + if (mode === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = mode; + if (capability === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = capability; + // A framework parent cannot establish ChatGPT state by sharing only IPC. + (process.emit as (name: string, value: unknown) => boolean)("message", { channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 1 }, status: { available: true, activeId: "fixture", accounts: [{ id: "fixture", connected: true, planEnabled: true }] } }); + const response = await GET(); + assert.equal(response.status, 200); + const catalog = await response.json(); + assert.ok(catalog.models.some((m: { provider: string }) => m.provider === "openai")); + assert.equal(catalog.providers.find((p: { id: string }) => p.id === "openai").keyPresent, true); + assert.equal(catalog.providers.find((p: { id: string }) => p.id === "chatgpt").keyPresent, false); + assert.equal(chatGPTConnected(), false); + assert.deepEqual(chatGPTModels(), []); + await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/); + assert.equal(sends, 0, "no desktop request/timer may be started over framework IPC"); + } + process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); + Object.defineProperty(process, "connected", { configurable: true, value: false }); + await GET(); + await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/); + assert.equal(sends, 0, "desktop markers without connected IPC do not suffice"); + } finally { + globalThis.fetch = originalFetch; + 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; + if (marker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = marker; + if (secret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = secret; + } +}); diff --git a/tests/chatgpt-request-cycle.test.ts b/tests/chatgpt-request-cycle.test.ts index a78421b..412c35d 100644 --- a/tests/chatgpt-request-cycle.test.ts +++ b/tests/chatgpt-request-cycle.test.ts @@ -20,6 +20,7 @@ import { newUserMessage, newNextMessage, newThinkingMessage, newAnswerResult, re const originalSend = Object.getOwnPropertyDescriptor(process, "send"); const originalConnected = Object.getOwnPropertyDescriptor(process, "connected"); const realFetch = globalThis.fetch; +const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET; 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"; @@ -31,6 +32,7 @@ function revoke(full = false) { ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch, authorityRevision: epoch, revision: ++revision }, status: status(), invalidate: full ? "all" : "background" }); } before(() => { + process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); Object.defineProperty(process, "connected", { configurable: true, value: true }); Object.defineProperty(process, "send", { configurable: true, value: (m: any, callback?: () => void) => { if (m.op === "lease") { @@ -59,6 +61,8 @@ before(() => { }; }); after(() => { + if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker; + if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret; globalThis.fetch = realFetch; fs.rmSync(scratch, { recursive: true, force: true }); if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send; diff --git a/tests/chatgpt-stream.test.ts b/tests/chatgpt-stream.test.ts index 5563831..07cf180 100644 --- a/tests/chatgpt-stream.test.ts +++ b/tests/chatgpt-stream.test.ts @@ -9,6 +9,7 @@ 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 desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET; const originalKey = process.env.OPENAI_API_KEY; let leaseError: string | null = null; let requests: { authorization: string | null; body: Record; url: string }[] = []; @@ -17,6 +18,7 @@ const ipc = (value: unknown) => (process.emit as (name: string, value: unknown) const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">; const status = { available: true, pending: false, allowBackgroundUse: false, activeId: "scratch-account", accounts: [{ id: "scratch-account", email: "dummy@example.invalid", registration: "oaiapp_scratch", connected: true, planEnabled: true, background: false }], error: null }; before(async () => { + process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64); assert(model); process.env.OPENAI_API_KEY = "sk-scratch-must-never-be-used"; Object.defineProperty(process, "connected", { configurable: true, value: true }); @@ -36,6 +38,8 @@ before(async () => { }; }); after(() => { + if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker; + if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret; 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;