fix(chatgpt): use private JSON credential storage without Keychain
This commit is contained in:
@@ -7,7 +7,7 @@ import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Toggle } from "./controls";
|
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.";
|
export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage.";
|
||||||
const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts";
|
const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts";
|
||||||
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
|
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
|
||||||
@@ -301,7 +301,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a>
|
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a>
|
||||||
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
|
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
|
||||||
</div>
|
</div>
|
||||||
{!status.available && <p className="mt-3 text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.</p>}
|
{!status.available && <p className="mt-3 text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.</p>}
|
||||||
{status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">Waiting for browser sign-in…</p>}
|
{status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">Waiting for browser sign-in…</p>}
|
||||||
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
||||||
<div className="mt-4 flex items-center justify-end gap-2" data-testid="chatgpt-account-footer">
|
<div className="mt-4 flex items-center justify-end gap-2" data-testid="chatgpt-account-footer">
|
||||||
|
|||||||
+11
-28
@@ -19,8 +19,7 @@ const fail = (code) => Object.assign(new Error(code), { code });
|
|||||||
const publicError = (err) => ({
|
const publicError = (err) => ({
|
||||||
cancelled: "ChatGPT sign-in cancelled.",
|
cancelled: "ChatGPT sign-in cancelled.",
|
||||||
timeout: "ChatGPT sign-in timed out. Try again.",
|
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 read or saved. Check local file access, then reconnect before making requests.",
|
||||||
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.",
|
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.",
|
scope: "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.",
|
||||||
disconnected: "This ChatGPT connection is disconnected. Reconnect in Providers.",
|
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 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));
|
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) {
|
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 (!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");
|
if (Object.hasOwn(state, "allowBackgroundUse") && typeof state.allowBackgroundUse !== "boolean") throw fail("storage");
|
||||||
@@ -51,35 +50,25 @@ function validateState(state) {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Encrypted envelope only, outside learning data. No plaintext fallback. */
|
/** Private, unencrypted JSON outside learning data. No OS credential-store access. */
|
||||||
function encryptedStore(file, safeStorage) {
|
function fileStore(file) {
|
||||||
function secure() {
|
|
||||||
if (!safeStorage.isEncryptionAvailable() || safeStorage.getSelectedStorageBackend?.() === "basic_text") throw fail("secure_storage");
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
secure,
|
|
||||||
async read() {
|
async read() {
|
||||||
secure();
|
|
||||||
try {
|
try {
|
||||||
const envelope = JSON.parse(await fs.readFile(file, "utf8"));
|
return validateState(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) {
|
} catch (err) {
|
||||||
if (err.code === "ENOENT") return null;
|
if (err.code === "ENOENT") return null;
|
||||||
throw fail("storage");
|
throw fail("storage");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async write(state) {
|
async write(state) {
|
||||||
secure();
|
|
||||||
validateState(state);
|
validateState(state);
|
||||||
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
||||||
let handle;
|
let handle;
|
||||||
try {
|
try {
|
||||||
const encrypted = safeStorage.encryptString(JSON.stringify(state)).toString("base64");
|
|
||||||
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||||
handle = await fs.open(temp, "wx", 0o600);
|
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.sync();
|
||||||
await handle.close();
|
await handle.close();
|
||||||
handle = null;
|
handle = null;
|
||||||
@@ -115,7 +104,6 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
|
|||||||
let authorityRevision = 0;
|
let authorityRevision = 0;
|
||||||
let catalogSequence = 0;
|
let catalogSequence = 0;
|
||||||
let storageDenied = false;
|
let storageDenied = false;
|
||||||
let secureAvailable = true;
|
|
||||||
const stamp = () => ({ epoch, authorityRevision, revision });
|
const stamp = () => ({ epoch, authorityRevision, revision });
|
||||||
let backgroundIntent;
|
let backgroundIntent;
|
||||||
let pending;
|
let pending;
|
||||||
@@ -174,17 +162,13 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
|
|||||||
state = first;
|
state = first;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
try { await init; checkSecure(); } catch (err) { denyStorage(err); throw err; }
|
try { await init; } catch (err) { denyStorage(err); throw err; }
|
||||||
}
|
}
|
||||||
function denyStorage(err) {
|
function denyStorage(err) {
|
||||||
secureAvailable = false;
|
|
||||||
if (!storageDenied) { storageDenied = true; lastError = publicError(err); stop(); }
|
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) {
|
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.
|
// A rotated token may already have invalidated its predecessor. Fail closed.
|
||||||
denyStorage(err);
|
denyStorage(err);
|
||||||
lastError = publicError(err);
|
lastError = publicError(err);
|
||||||
@@ -195,7 +179,7 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
|
|||||||
}
|
}
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
return {
|
return {
|
||||||
available: !!state && secureAvailable && !closed && !paused,
|
available: !!state && !closed && !paused,
|
||||||
pending: !!pending,
|
pending: !!pending,
|
||||||
allowBackgroundUse: backgroundAllowed(),
|
allowBackgroundUse: backgroundAllowed(),
|
||||||
activeId: state?.activeId ?? null,
|
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.registrations = next.registrations.filter((r) => r.id !== record.id).concat(record);
|
||||||
next.activeId = record.id;
|
next.activeId = record.id;
|
||||||
const previous = structuredClone(state);
|
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) {
|
if (closed || abort.signal.aborted || epoch !== attemptEpoch) {
|
||||||
await save(previous);
|
await save(previous);
|
||||||
throw fail("cancelled");
|
throw fail("cancelled");
|
||||||
@@ -365,7 +349,6 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
|
|||||||
function cancelLogin() { pending?.cancel(); return snapshot(); }
|
function cancelLogin() { pending?.cancel(); return snapshot(); }
|
||||||
|
|
||||||
function authorized(background, expected, capturedEpoch) {
|
function authorized(background, expected, capturedEpoch) {
|
||||||
checkSecure();
|
|
||||||
const account = active();
|
const account = active();
|
||||||
if (!account || (capturedEpoch !== undefined && capturedEpoch !== epoch)) throw fail("disconnected");
|
if (!account || (capturedEpoch !== undefined && capturedEpoch !== epoch)) throw fail("disconnected");
|
||||||
if (!planScopes(account.tokens)) throw fail("scope");
|
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 };
|
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 };
|
||||||
|
|||||||
+3
-3
@@ -24,8 +24,8 @@
|
|||||||
* would shadow every settings-page key change until restart.
|
* would shadow every settings-page key change until restart.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog, safeStorage } = require("electron");
|
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
|
||||||
const { createChatGPT, encryptedStore } = require("./chatgpt.cjs");
|
const { createChatGPT, fileStore } = require("./chatgpt.cjs");
|
||||||
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
|
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
|
||||||
const { desktopAPIHeaders } = require("./desktop-api.cjs");
|
const { desktopAPIHeaders } = require("./desktop-api.cjs");
|
||||||
const { randomBytes } = require("node:crypto");
|
const { randomBytes } = require("node:crypto");
|
||||||
@@ -589,7 +589,7 @@ async function main() {
|
|||||||
callback({ requestHeaders: desktopAPIHeaders(details, ownedOrigin, desktopAPISecret, [...trustedAppWindows]) });
|
callback({ requestHeaders: desktopAPIHeaders(details, ownedOrigin, desktopAPISecret, [...trustedAppWindows]) });
|
||||||
});
|
});
|
||||||
chatgpt = createChatGPT({
|
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),
|
openExternal: (target) => shell.openExternal(target),
|
||||||
onChange: (value) => {
|
onChange: (value) => {
|
||||||
chatgptChild?.send(value);
|
chatgptChild?.send(value);
|
||||||
|
|||||||
+72
-29
@@ -7,7 +7,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||||
const require = createRequire(import.meta.url);
|
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 { privateKey, publicKey } = await generateKeyPair("RS256");
|
||||||
const jwk = { ...await exportJWK(publicKey), kid: "scratch-key", alg: "RS256", use: "sig" };
|
const jwk = { ...await exportJWK(publicKey), kid: "scratch-key", alg: "RS256", use: "sig" };
|
||||||
const tokenUrl = "https://auth.openai.com/api/accounts/oauth/token";
|
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);
|
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<T = void>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { resolve, promise }; }
|
function deferred<T = void>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { resolve, promise }; }
|
||||||
function fixture(options: { state?: any; expires?: number; timeoutMs?: number; token?: (form: URLSearchParams, url: URL) => Promise<Response>; write?: (next: any) => Promise<void>; secure?: () => void; change?: (push: any) => void; provider?: (target: string, init: RequestInit) => Promise<Response | undefined> } = {}) {
|
function fixture(options: { state?: any; expires?: number; timeoutMs?: number; token?: (form: URLSearchParams, url: URL) => Promise<Response>; write?: (next: any) => Promise<void>; read?: () => void; change?: (push: any) => void; provider?: (target: string, init: RequestInit) => Promise<Response | undefined> } = {}) {
|
||||||
let saved = structuredClone(options.state ?? initial());
|
let saved = structuredClone(options.state ?? initial());
|
||||||
let url!: URL;
|
let url!: URL;
|
||||||
let writeCount = 0;
|
let writeCount = 0;
|
||||||
const changed = deferred();
|
const changed = deferred();
|
||||||
const requests: { url: string; form?: URLSearchParams }[] = [];
|
const requests: { url: string; form?: URLSearchParams }[] = [];
|
||||||
const service = createChatGPT({
|
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); },
|
openExternal: async (value: string) => { url = new URL(value); },
|
||||||
timeoutMs: options.timeoutMs ?? 5000,
|
timeoutMs: options.timeoutMs ?? 5000,
|
||||||
onChange: (push: any) => { options.change?.(push); if (!push.status.pending) changed.resolve(); },
|
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(); }
|
} 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"); } });
|
const f = fixture({ write: async () => { throw new Error("scratch disk failure containing no real data"); } });
|
||||||
try {
|
try {
|
||||||
await f.service.login(); await f.callback(); await f.changed.promise;
|
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(); }
|
} 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 () => {
|
test("serialized concurrent refresh rotates once and persists before leases", async () => {
|
||||||
let refreshes = 0;
|
let refreshes = 0;
|
||||||
const f = await loggedIn({ expires: 1, token: async (form, url) => {
|
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(); }
|
} 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 dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-storage-"));
|
||||||
const file = path.join(dir, "credentials.enc.json");
|
const file = path.join(dir, "private", "chatgpt-credentials.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 {
|
try {
|
||||||
const store = encryptedStore(file, secure);
|
const store = fileStore(file);
|
||||||
const data = { ...initial(), marker: "scratch-secret-never-plaintext" };
|
assert.equal(await store.read(), null);
|
||||||
|
const data = { ...initial(), allowBackgroundUse: false };
|
||||||
await store.write(data);
|
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.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.equal((await fs.stat(file)).mode & 0o777, 0o600);
|
||||||
assert.deepEqual(await fs.readdir(dir), ["credentials.enc.json"]);
|
await fs.chmod(file, 0o644);
|
||||||
for (const backend of [{ ...secure, getSelectedStorageBackend: () => "basic_text" }, { ...secure, isEncryptionAvailable: () => false }]) {
|
await store.write({ ...data, allowBackgroundUse: true });
|
||||||
await assert.rejects(encryptedStore(file, backend).read(), /secure_storage/);
|
assert.equal((await fs.stat(file)).mode & 0o777, 0o600, "replacement never inherits a permissive old mode");
|
||||||
await assert.rejects(encryptedStore(file, backend).write(data), /secure_storage/);
|
assert.equal((await store.read()).allowBackgroundUse, true);
|
||||||
}
|
assert.deepEqual(await fs.readdir(path.dirname(file)), ["chatgpt-credentials.json"]);
|
||||||
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 }); }
|
} 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 () => {
|
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"
|
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: "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.requests.find((r) => r.form?.has("token_type_hint"))!.form!.get("token"), "likely-latest-revoke-handle");
|
||||||
assert.equal(restarted.saved.registrations[0].reconnectRequired, true);
|
assert.equal(restarted.saved.registrations[0].reconnectRequired, true);
|
||||||
await restarted.service.login(restricted.id); await restarted.callback(); await restarted.changed.promise;
|
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));
|
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);
|
assert.equal(restarted.saved.registrations[0].reconnectRequired, undefined);
|
||||||
} finally { restarted.service.close(); }
|
} finally { restarted.service.close(); }
|
||||||
} finally { f.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 () => {
|
test("observed file write failure invalidates all work; status, consent and resume cannot reload stale credentials or repair authority", async () => {
|
||||||
let lost = false; const pushes: any[] = [];
|
let lost = false, reads = 0; 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) });
|
const f = await loggedIn({ read: () => { reads++; }, write: async () => { if (lost) throw Object.assign(new Error("storage"), { code: "storage" }); }, change: (push) => pushes.push(push) });
|
||||||
try {
|
try {
|
||||||
await f.service.lease(false); const epoch = f.service.stamp().epoch;
|
await f.service.lease(false); const epoch = f.service.stamp().epoch;
|
||||||
lost = true;
|
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));
|
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));
|
await assert.rejects(f.service.lease(false)); await assert.rejects(f.service.lease(true));
|
||||||
lost = false; f.service.resume(); await f.service.consent(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));
|
await assert.rejects(f.service.lease(false));
|
||||||
assert.equal((await f.service.status()).accounts[0].connected, 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(); }
|
} 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 f = await loggedIn(); f.service.close();
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-corrupt-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-corrupt-"));
|
||||||
const file = path.join(dir, "credentials.enc.json");
|
const file = path.join(dir, "chatgpt-credentials.json");
|
||||||
const secure = { isEncryptionAvailable: () => true, encryptString: (s: string) => Buffer.from(s), decryptString: (b: Buffer) => b.toString() };
|
|
||||||
try {
|
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"]) {
|
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 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);
|
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); }
|
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 { service.close(); }
|
||||||
}
|
}
|
||||||
@@ -459,7 +502,7 @@ test("disconnect racing refresh revokes the latest rotated handle before forgett
|
|||||||
} finally { release.resolve(); f.service.close(); }
|
} 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;
|
let failWrite = false;
|
||||||
const f = await loggedIn({ write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); } });
|
const f = await loggedIn({ write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); } });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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 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 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 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 warning = "Account disconnected locally. Remote revocation could not be confirmed.";
|
||||||
const errorReply = () => ({ error: storageError } as ChatGPTStatus);
|
const errorReply = () => ({ error: storageError } as ChatGPTStatus);
|
||||||
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
|
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
|
||||||
|
|||||||
@@ -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<typeof fs.open>) => {
|
||||||
|
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/);
|
||||||
|
});
|
||||||
@@ -166,7 +166,7 @@ test("case-insensitive Authorization overrides including null are refused before
|
|||||||
assert.equal(requests.length, 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);
|
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" });
|
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);
|
assert(foreground.controller.signal.aborted); assert(background.controller.signal.aborted); assert.equal(chatGPTModels().length, 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user