diff --git a/components/settings/chatgpt-connection.tsx b/components/settings/chatgpt-connection.tsx
index 5b02516..86fbe0b 100644
--- a/components/settings/chatgpt-connection.tsx
+++ b/components/settings/chatgpt-connection.tsx
@@ -7,7 +7,7 @@ import { useBackdropDismiss } from "@/components/backdrop-dismiss";
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_NOTICE = "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file 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 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.";
@@ -301,7 +301,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
OpenAI privacy
Manage usage
- {!status.available &&
ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.
}
+ {!status.available && ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.
}
{status.pending && Waiting for browser sign-in…
}
{context.error && {context.error}
}
diff --git a/electron/chatgpt.cjs b/electron/chatgpt.cjs
index e7fe442..a56825d 100644
--- a/electron/chatgpt.cjs
+++ b/electron/chatgpt.cjs
@@ -19,8 +19,7 @@ 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.",
+ storage: "ChatGPT credentials could not be read or saved. Check local file access, then 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 Providers.",
@@ -34,7 +33,7 @@ const publicError = (err) => ({
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. */
+/** Reject the entire credential record when its schema is invalid. */
function validateState(state) {
if (!state || state.version !== 1 || !nonempty(state.hostId) || !Array.isArray(state.registrations) || !(state.activeId === null || nonempty(state.activeId))) throw fail("storage");
if (Object.hasOwn(state, "allowBackgroundUse") && typeof state.allowBackgroundUse !== "boolean") throw fail("storage");
@@ -51,35 +50,25 @@ function validateState(state) {
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");
- }
+/** Private, unencrypted JSON outside learning data. No OS credential-store access. */
+function fileStore(file) {
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);
+ return validateState(JSON.parse(await fs.readFile(file, "utf8")));
} 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.writeFile(`${JSON.stringify(state)}\n`);
await handle.sync();
await handle.close();
handle = null;
@@ -115,7 +104,6 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
let authorityRevision = 0;
let catalogSequence = 0;
let storageDenied = false;
- let secureAvailable = true;
const stamp = () => ({ epoch, authorityRevision, revision });
let backgroundIntent;
let pending;
@@ -174,17 +162,13 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
state = first;
}
})();
- try { await init; checkSecure(); } catch (err) { denyStorage(err); throw err; }
+ try { await init; } 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) {
+ try { validateState(next); await store.write(next); } catch (err) {
// A rotated token may already have invalidated its predecessor. Fail closed.
denyStorage(err);
lastError = publicError(err);
@@ -195,7 +179,7 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
}
function snapshot() {
return {
- available: !!state && secureAvailable && !closed && !paused,
+ available: !!state && !closed && !paused,
pending: !!pending,
allowBackgroundUse: backgroundAllowed(),
activeId: state?.activeId ?? null,
@@ -331,7 +315,7 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
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.
+ await save(next); // Active identity changes only AFTER validation and durable persistence.
if (closed || abort.signal.aborted || epoch !== attemptEpoch) {
await save(previous);
throw fail("cancelled");
@@ -365,7 +349,6 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
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");
@@ -553,4 +536,4 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
return { status, login, cancelLogin, lease, models, select, consent, disconnect, logout, close, pause, resume, stamp };
}
-module.exports = { createChatGPT, encryptedStore, publicError, allowedAuthEndpoint, SCOPES, validateState };
+module.exports = { createChatGPT, fileStore, publicError, allowedAuthEndpoint, SCOPES, validateState };
diff --git a/electron/main.cjs b/electron/main.cjs
index 7f9622b..521f47a 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -24,8 +24,8 @@
* would shadow every settings-page key change until restart.
*/
-const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog, safeStorage } = require("electron");
-const { createChatGPT, encryptedStore } = require("./chatgpt.cjs");
+const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
+const { createChatGPT, fileStore } = require("./chatgpt.cjs");
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
const { desktopAPIHeaders } = require("./desktop-api.cjs");
const { randomBytes } = require("node:crypto");
@@ -589,7 +589,7 @@ async function main() {
callback({ requestHeaders: desktopAPIHeaders(details, ownedOrigin, desktopAPISecret, [...trustedAppWindows]) });
});
chatgpt = createChatGPT({
- store: encryptedStore(path.join(app.getPath("userData"), "chatgpt-credentials.enc.json"), safeStorage),
+ store: fileStore(path.join(app.getPath("userData"), "chatgpt-credentials.json")),
openExternal: (target) => shell.openExternal(target),
onChange: (value) => {
chatgptChild?.send(value);
diff --git a/tests/chatgpt-auth.test.ts b/tests/chatgpt-auth.test.ts
index be44134..1bf1e96 100644
--- a/tests/chatgpt-auth.test.ts
+++ b/tests/chatgpt-auth.test.ts
@@ -7,7 +7,7 @@ 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 { createChatGPT, fileStore, 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";
@@ -16,14 +16,14 @@ async function signed(client: string, nonce: string, subject = "scratch-user", o
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 } = {}) {
+function fixture(options: { state?: any; expires?: number; timeoutMs?: number; token?: (form: URLSearchParams, url: URL) => Promise; write?: (next: any) => Promise; read?: () => 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); } },
+ store: { async read() { options.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(); },
@@ -153,7 +153,7 @@ test("missing plan scope retains verified registration but refuses inference and
} finally { f.service.close(); }
});
-test("durable encrypted save failure never activates a pending login", async () => {
+test("durable file 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;
@@ -162,6 +162,40 @@ test("durable encrypted save failure never activates a pending login", async ()
} finally { f.service.close(); }
});
+test("rotated-token persistence failure invalidates existing leases and never reloads the stale saved token", async () => {
+ let failWrite = false, reads = 0, refreshes = 0;
+ const pushes: any[] = [];
+ const f = await loggedIn({ read: () => { reads++; }, change: (push) => pushes.push(push),
+ write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); },
+ token: async (form, url) => {
+ if (form.get("grant_type") === "refresh_token") {
+ refreshes++;
+ return Response.json({ access_token: "rotated-access", refresh_token: "rotated-refresh", token_type: "Bearer", expires_in: 3600 });
+ }
+ return Response.json({ access_token: "old-access", refresh_token: "old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
+ },
+ });
+ try {
+ const catalog = await f.service.models();
+ const expected = { expectedAccountId: catalog.accountId, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" };
+ await f.rawLease(false, expected);
+ // Advance time rather than mutating the service's private state.
+ const now = Date.now; Date.now = () => now() + 3600000;
+ try {
+ failWrite = true;
+ await assert.rejects(f.rawLease(false, expected), /storage/);
+ } finally { Date.now = now; }
+ assert.equal(refreshes, 1);
+ assert.equal(f.saved.registrations[0].tokens.refreshToken, "old-refresh", "failed write leaves only the stale predecessor on disk");
+ assert(pushes.some((push) => push.invalidate === "all" && push.stamp.epoch > catalog.epoch));
+ failWrite = false;
+ await f.service.status(); await f.service.models(); f.service.resume(); await f.service.consent(true);
+ await assert.rejects(f.rawLease(false, expected)); await assert.rejects(f.rawLease(true, expected));
+ assert.equal((await f.service.status()).accounts[0].connected, false);
+ assert.equal(reads, 1); assert.equal(refreshes, 1, "no stale-token retry after storage recovers");
+ } 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) => {
@@ -208,28 +242,33 @@ test("account catalog is scoped, visibility filtered, and cleared on disconnect"
} finally { f.service.close(); }
});
-test("safeStorage refuses basic_text/unavailable; encrypted file owner-only, atomic, no plaintext fallback", async () => {
+test("private JSON file round-trips global state; creation and atomic replacement are owner-only", 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("") };
+ const file = path.join(dir, "private", "chatgpt-credentials.json");
try {
- const store = encryptedStore(file, secure);
- const data = { ...initial(), marker: "scratch-secret-never-plaintext" };
+ const store = fileStore(file);
+ assert.equal(await store.read(), null);
+ const data = { ...initial(), allowBackgroundUse: false };
await store.write(data);
- assert(!(await fs.readFile(file, "utf8")).includes(data.marker));
+ assert.deepEqual(JSON.parse(await fs.readFile(file, "utf8")), data, "ordinary JSON, not an encrypted envelope");
assert.deepEqual(await store.read(), data);
+ assert.equal((await fs.stat(path.dirname(file))).mode & 0o777, 0o700);
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"));
+ await fs.chmod(file, 0o644);
+ await store.write({ ...data, allowBackgroundUse: true });
+ assert.equal((await fs.stat(file)).mode & 0o777, 0o600, "replacement never inherits a permissive old mode");
+ assert.equal((await store.read()).allowBackgroundUse, true);
+ assert.deepEqual(await fs.readdir(path.dirname(file)), ["chatgpt-credentials.json"]);
} finally { await fs.rm(dir, { recursive: true, force: true }); }
});
+test("revocation endpoint remains restricted to the exact secure issuer", () => {
+ assert(allowedAuthEndpoint("https://auth.openai.com/api/accounts/oauth/revoke"));
+ 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"));
+});
+
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 })
@@ -255,39 +294,43 @@ test("invalid rotating replacement is durably revocation-only; verified mapping
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.
+ // Wait for callback's asynchronous durable 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) });
+test("observed file write failure invalidates all work; status, consent and resume cannot reload stale credentials or repair authority", async () => {
+ let lost = false, reads = 0; const pushes: any[] = [];
+ const f = await loggedIn({ read: () => { reads++; }, write: async () => { if (lost) throw Object.assign(new Error("storage"), { code: "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);
+ await assert.rejects(f.service.consent(false), /storage/);
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(true);
+ await f.service.status(); await f.service.models();
await assert.rejects(f.service.lease(false));
assert.equal((await f.service.status()).accounts[0].connected, false);
+ assert.equal(reads, 1, "a recovered disk never causes a stale credential reload");
+ await f.service.login(f.saved.activeId); await f.callback();
+ for (let i = 0; i < 100 && (await f.service.status()).pending; i++) await new Promise((resolve) => setImmediate(resolve));
+ assert.equal((await f.service.status()).accounts[0].connected, true, "verified login plus durable save can explicitly reconnect");
} finally { f.service.close(); }
});
-test("strict encrypted schema rejects malformed marker/duplicates/token/active identity without changing original bytes", async () => {
+test("strict JSON 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() };
+ const file = path.join(dir, "chatgpt-credentials.json");
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, (s: any) => s.allowBackgroundUse = "true", (s: any) => s.allowBackgroundUse = null, (s: any) => s.registrations[0].background = "false"]) {
const state = structuredClone(f.saved); mutate(state);
- const bytes = JSON.stringify({ version: 1, encrypted: Buffer.from(JSON.stringify(state)).toString("base64") });
+ const bytes = JSON.stringify(state);
await fs.writeFile(file, bytes);
- const service = createChatGPT({ store: encryptedStore(file, secure), openExternal: () => assert.fail("no OAuth"), fetch: () => assert.fail("no provider") });
+ const service = createChatGPT({ store: fileStore(file), 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(); }
}
@@ -459,7 +502,7 @@ test("disconnect racing refresh revokes the latest rotated handle before forgett
} finally { release.resolve(); f.service.close(); }
});
-test("disconnect persistence failure is not reported as local forgetting and leaves encrypted state fail-closed", async () => {
+test("disconnect persistence failure is not reported as local forgetting and leaves stored state fail-closed", async () => {
let failWrite = false;
const f = await loggedIn({ write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); } });
try {
diff --git a/tests/chatgpt-connection-ui.test.tsx b/tests/chatgpt-connection-ui.test.tsx
index 953e9aa..d80e3de 100644
--- a/tests/chatgpt-connection-ui.test.tsx
+++ b/tests/chatgpt-connection-ui.test.tsx
@@ -20,7 +20,7 @@ let root: Root | undefined, host: HTMLDivElement | undefined;
const account = (id = "private-registration-id", email = "learner@example.invalid", connected = true) => ({ id, email, registration: "private-issued-client", connected, planEnabled: connected });
const disconnected = (allowBackgroundUse = true): ChatGPTStatus => ({ available: true, pending: false, allowBackgroundUse, activeId: null, accounts: [account("signed-out-record", "signed-out@example.invalid", false)], error: null });
const connected = (allowBackgroundUse = true): ChatGPTStatus => ({ ...disconnected(allowBackgroundUse), activeId: "private-registration-id", accounts: [account(), ...disconnected().accounts] });
-const storageError = "ChatGPT credentials could not be saved securely. Reconnect before making requests.";
+const storageError = "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.";
const warning = "Account disconnected locally. Remote revocation could not be confirmed.";
const errorReply = () => ({ error: storageError } as ChatGPTStatus);
function deferred() { let resolve!: (v: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; }
diff --git a/tests/chatgpt-file-store.test.ts b/tests/chatgpt-file-store.test.ts
new file mode 100644
index 0000000..b4e56e5
--- /dev/null
+++ b/tests/chatgpt-file-store.test.ts
@@ -0,0 +1,125 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { createRequire } from "node:module";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+const require = createRequire(import.meta.url);
+const { createChatGPT, fileStore } = require("../electron/chatgpt.cjs");
+const initial = () => ({ version: 1, hostId: "urn:uuid:scratch-host", activeId: null, registrations: [] });
+const account = (id: string, subject: string) => ({
+ id, clientId: `oaiapp_${id}`, issuer: "https://auth.openai.com", subject, email: `${subject}@example.invalid`,
+ tokens: { accessToken: `scratch-access-${id}`, refreshToken: `scratch-refresh-${id}`, idToken: `scratch-id-${id}`, expiresAt: Date.now() + 3600000, scopes: ["openid", "profile", "email", "offline_access", "resource.invoke", "chatgpt.tokens.use.direct"] },
+});
+function service(store: unknown) {
+ return createChatGPT({ store, openExternal: () => assert.fail("no browser sign-in expected"), fetch: () => assert.fail("no provider request expected") });
+}
+
+test("ordinary startup restores saved accounts and global OFF from JSON without OAuth or native storage", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-restore-"));
+ const file = path.join(dir, "chatgpt-credentials.json");
+ const state = { ...initial(), activeId: "B", registrations: [account("A", "first"), account("B", "second")], allowBackgroundUse: false };
+ let connection;
+ try {
+ await fileStore(file).write(state);
+ connection = service(fileStore(file));
+ const status = await connection.status();
+ assert.equal(status.available, true);
+ assert.equal(status.activeId, "B");
+ assert.equal(status.allowBackgroundUse, false);
+ assert.deepEqual(status.accounts.map((a: { email: string; connected: boolean }) => [a.email, a.connected]), [["first@example.invalid", true], ["second@example.invalid", true]]);
+ assert.doesNotMatch(JSON.stringify(status), /scratch-access|scratch-refresh|scratch-id/);
+ await connection.status();
+ assert.deepEqual(await fileStore(file).read(), state, "startup/status does not change saved credentials");
+ } finally { connection?.close(); await fs.rm(dir, { recursive: true, force: true }); }
+});
+
+test("new store ignores experimental encrypted files and never alters the separate API-key file", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-fresh-"));
+ const file = path.join(dir, "chatgpt-credentials.json");
+ const experimental = path.join(dir, "chatgpt-credentials.enc.json");
+ const auth = path.join(dir, "auth.json");
+ const oldBytes = "not an envelope to parse, decrypt, migrate or delete";
+ const apiBytes = '{"OPENAI_API_KEY":"scratch-api-key"}\n';
+ let connection;
+ try {
+ await fs.writeFile(experimental, oldBytes);
+ await fs.writeFile(auth, apiBytes);
+ connection = service(fileStore(file));
+ const status = await connection.status();
+ assert.equal(status.available, true); assert.equal(status.allowBackgroundUse, true);
+ assert.equal(status.activeId, null); assert.deepEqual(status.accounts, []);
+ assert.deepEqual((await connection.models()).models, []);
+ await connection.consent(false);
+ connection.close(); connection = service(fileStore(file));
+ assert.equal((await connection.status()).allowBackgroundUse, false, "zero-account preference survives a real file-backed restart");
+ assert.equal(await fs.readFile(experimental, "utf8"), oldBytes);
+ assert.equal(await fs.readFile(auth, "utf8"), apiBytes);
+ } finally { connection?.close(); await fs.rm(dir, { recursive: true, force: true }); }
+});
+
+for (const bytes of ["{broken-json", "null", "[]", '{"version":1,"encrypted":"ZXhwZXJpbWVudA=="}']) {
+ test(`invalid JSON/record fails closed without overwriting it: ${bytes}`, async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-invalid-"));
+ const file = path.join(dir, "chatgpt-credentials.json");
+ let connection;
+ try {
+ await fs.writeFile(file, bytes);
+ connection = service(fileStore(file));
+ const status = await connection.status();
+ assert.equal(status.available, false); assert.equal(status.activeId, null);
+ assert.match(status.error, /could not be read or saved/);
+ assert.deepEqual((await connection.models()).models, []);
+ await assert.rejects(connection.login(), /storage/);
+ assert.equal(await fs.readFile(file, "utf8"), bytes);
+ } finally { connection?.close(); await fs.rm(dir, { recursive: true, force: true }); }
+ });
+}
+
+for (const phase of ["sync", "rename"] as const) {
+ test(`failed ${phase} leaves the previous JSON intact and removes temporary files`, async (t) => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-atomic-"));
+ const file = path.join(dir, "chatgpt-credentials.json");
+ try {
+ const store = fileStore(file);
+ await store.write(initial());
+ const before = await fs.readFile(file, "utf8");
+ if (phase === "rename") t.mock.method(fs, "rename", async () => { throw new Error("scratch rename failure"); });
+ else {
+ const open = fs.open.bind(fs);
+ t.mock.method(fs, "open", async (...args: Parameters) => {
+ const handle = await open(...args);
+ if (args[1] === "wx") t.mock.method(handle, "sync", async () => { throw new Error("scratch sync failure"); });
+ return handle;
+ });
+ }
+ await assert.rejects(store.write({ ...initial(), allowBackgroundUse: false }), /^Error: storage$/);
+ assert.equal(await fs.readFile(file, "utf8"), before);
+ assert.deepEqual(await fs.readdir(dir), ["chatgpt-credentials.json"]);
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
+ });
+}
+
+test("file read/write failures are sanitized and cannot leak credential or path details", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-io-"));
+ try {
+ // Reading a directory and writing beneath a regular file both fail on all supported platforms.
+ await assert.rejects(fileStore(dir).read(), /^Error: storage$/);
+ const blocker = path.join(dir, "scratch-private-path"); await fs.writeFile(blocker, "blocker");
+ const connection = service(fileStore(path.join(blocker, "chatgpt-credentials.json")));
+ try {
+ const status = await connection.status();
+ assert.equal(status.available, false);
+ assert.doesNotMatch(status.error, /scratch-private-path|ENOTDIR|EISDIR/);
+ await assert.rejects(connection.login(), /^Error: storage$/);
+ } finally { connection.close(); }
+ await assert.rejects(fileStore(path.join(blocker, "credentials.json")).write({ ...initial(), secret: "scratch-token" }), /^Error: storage$/);
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
+});
+
+test("desktop wiring uses only the private JSON path, with no Electron safeStorage dependency", async () => {
+ const main = await fs.readFile(new URL("../electron/main.cjs", import.meta.url), "utf8");
+ const auth = await fs.readFile(new URL("../electron/chatgpt.cjs", import.meta.url), "utf8");
+ assert.match(main, /store: fileStore\(path\.join\(app\.getPath\("userData"\), "chatgpt-credentials\.json"\)\)/);
+ assert.doesNotMatch(main + auth, /safeStorage|encryptedStore|chatgpt-credentials\.enc\.json/);
+});
diff --git a/tests/chatgpt-stream.test.ts b/tests/chatgpt-stream.test.ts
index 5563831..b91b023 100644
--- a/tests/chatgpt-stream.test.ts
+++ b/tests/chatgpt-stream.test.ts
@@ -166,7 +166,7 @@ test("case-insensitive Authorization overrides including null are refused before
assert.equal(requests.length, before);
});
-test("full secure-storage invalidation aborts foreground and background controllers and clears capabilities", () => {
+test("full credential-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);