fix: distinguish desktop bridge from dev-server IPC
This commit is contained in:
@@ -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<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
||||
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<T>((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<T>(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<LeaseValue> {
|
||||
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<void> {
|
||||
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<ModelsValue>("models");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, any>; 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;
|
||||
|
||||
Reference in New Issue
Block a user