feat: add verified desktop ChatGPT subscription connection
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createChatGPT, encryptedStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs");
|
||||
const { privateKey, publicKey } = await generateKeyPair("RS256");
|
||||
const jwk = { ...await exportJWK(publicKey), kid: "scratch-key", alg: "RS256", use: "sig" };
|
||||
const tokenUrl = "https://auth.openai.com/api/accounts/oauth/token";
|
||||
const initial = () => ({ version: 1, hostId: "urn:uuid:00000000-0000-4000-8000-000000000001", activeId: null as string | null, registrations: [] as any[] });
|
||||
async function signed(client: string, nonce: string, subject = "scratch-user", overrides: Record<string, unknown> = {}) {
|
||||
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 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> } = {}) {
|
||||
let saved = structuredClone(options.state ?? initial());
|
||||
let url!: URL;
|
||||
let writeCount = 0;
|
||||
const changed = deferred();
|
||||
const requests: { url: string; form?: URLSearchParams }[] = [];
|
||||
const service = createChatGPT({
|
||||
store: { secure() { options.secure?.(); }, async read() { return structuredClone(saved); }, async write(next: any) { writeCount++; await options.write?.(next); saved = structuredClone(next); } },
|
||||
openExternal: async (value: string) => { url = new URL(value); },
|
||||
timeoutMs: options.timeoutMs ?? 5000,
|
||||
onChange: (push: any) => { options.change?.(push); if (!push.status.pending) changed.resolve(); },
|
||||
fetch: async (target: string, init: RequestInit = {}) => {
|
||||
const form = init.body instanceof URLSearchParams ? init.body : undefined;
|
||||
requests.push({ url: target, form });
|
||||
const response = await options.provider?.(target, init); if (response) return response;
|
||||
if (target.endsWith("jwks.json")) return Response.json({ keys: [jwk] });
|
||||
if (target === tokenUrl) {
|
||||
if (options.token) return options.token(form!, url);
|
||||
return Response.json({ access_token: "scratch-oauth-token", refresh_token: "scratch-refresh", id_token: await signed(form!.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: options.expires ?? 3600, scope: SCOPES.join(" ") });
|
||||
}
|
||||
if (target.endsWith("openid-configuration")) return Response.json({ issuer: "https://auth.openai.com", revocation_endpoint: "https://auth.openai.com/api/accounts/oauth/revoke" });
|
||||
if (target.endsWith("/revoke")) return new Response(null, { status: 200 });
|
||||
if (target.endsWith("/models")) return Response.json({ models: [{ slug: "gpt-5", display_name: "Scratch model", visibility: "list" }, { slug: "hidden", display_name: "Hidden", visibility: "hide" }] });
|
||||
throw new Error("Unexpected network request in scratch fixture");
|
||||
},
|
||||
});
|
||||
const rawLease = service.lease;
|
||||
let discovered: Promise<any> | undefined;
|
||||
service.lease = async (background: boolean) => {
|
||||
discovered ??= service.models();
|
||||
const catalog = await discovered;
|
||||
return rawLease(background, { expectedAccountId: catalog.accountId ?? saved.activeId ?? "scratch-missing", expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" });
|
||||
};
|
||||
return { service, rawLease, requests, changed, get url() { return url; }, get saved() { return saved; }, get writeCount() { return writeCount; }, async callback(params: Record<string, string> = {}) {
|
||||
const callback = new URL(url.searchParams.get("redirect_uri")!);
|
||||
callback.search = new URLSearchParams({ state: url.searchParams.get("state")!, code: "scratch-code", client_id: "oaiapp_scratch", ...params }).toString();
|
||||
return fetch(callback);
|
||||
} };
|
||||
}
|
||||
async function loggedIn(options: Parameters<typeof fixture>[0] = {}) {
|
||||
const f = fixture(options);
|
||||
await f.service.login();
|
||||
await f.callback();
|
||||
await f.changed.promise;
|
||||
return f;
|
||||
}
|
||||
|
||||
test("PKCE/state/nonce/host identity and exact ephemeral 127.0.0.1 callback; no secret renderer snapshot", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.service.login();
|
||||
const params = f.url.searchParams;
|
||||
assert.equal(params.get("client_id"), "dynamic_agent_client");
|
||||
assert.equal(params.get("agent_name_hint"), "LearnHarness");
|
||||
assert.equal(params.get("ext_agent_host_id"), f.saved.hostId);
|
||||
assert.equal(new URL(params.get("redirect_uri")!).hostname, "127.0.0.1");
|
||||
assert.equal(new URL(params.get("redirect_uri")!).pathname, "/auth/callback");
|
||||
assert.equal((await f.callback({ state: "wrong" })).status, 400);
|
||||
assert.equal(f.requests.length, 0, "unverified callback must never exchange a code");
|
||||
await f.callback(); await f.changed.promise;
|
||||
const form = f.requests.find((r) => r.url === tokenUrl)!.form!;
|
||||
assert.equal(form.get("redirect_uri"), params.get("redirect_uri"));
|
||||
assert.equal(form.get("client_id"), "oaiapp_scratch");
|
||||
assert.equal(createHash("sha256").update(form.get("code_verifier")!).digest("base64url"), params.get("code_challenge"));
|
||||
assert.equal(form.get("resource"), "https://api.openai.com/v1");
|
||||
const status = await f.service.status();
|
||||
assert.equal(status.accounts[0].background, false);
|
||||
assert.equal(status.activeId, f.saved.activeId);
|
||||
assert(!JSON.stringify(status).includes("scratch-oauth-token"));
|
||||
assert(!JSON.stringify(status).includes("scratch-refresh"));
|
||||
assert(!JSON.stringify(status).includes("idToken"));
|
||||
assert.equal((await f.service.lease(false)).accessToken, "scratch-oauth-token");
|
||||
await assert.rejects(f.service.lease(true), /background/);
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("returning sign-in reuses issued client/host and fresh PKCE; rejects account/client mismatch without replacing active credentials", async () => {
|
||||
const f = await loggedIn();
|
||||
try {
|
||||
const first = f.url;
|
||||
const status = await f.service.status();
|
||||
await f.service.login(status.activeId);
|
||||
assert.equal(f.url.searchParams.get("client_id"), "oaiapp_scratch");
|
||||
assert.equal(f.url.searchParams.get("agent_name_hint"), null);
|
||||
assert(f.url.searchParams.get("id_token_hint"));
|
||||
assert.notEqual(f.url.searchParams.get("state"), first.searchParams.get("state"));
|
||||
assert.notEqual(f.url.searchParams.get("nonce"), first.searchParams.get("nonce"));
|
||||
assert.equal(f.url.searchParams.get("ext_agent_host_id"), first.searchParams.get("ext_agent_host_id"));
|
||||
await f.callback({ client_id: "oaiapp_other" });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(f.saved.registrations.length, 1);
|
||||
assert.equal((await f.service.lease(false)).accessToken, "scratch-oauth-token");
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
for (const [label, claims] of [["nonce", { nonce: "wrong" }], ["issuer", { iss: "https://evil.invalid" }], ["audience", { aud: "oaiapp_wrong" }], ["expiry", { exp: 1 }]] as const) {
|
||||
test(`rejects invalid ID-token ${label}`, async () => {
|
||||
const f = fixture({ token: async (form, url) => {
|
||||
const jwt = new SignJWT(Object.assign({ nonce: url.searchParams.get("nonce")!, email: "dummy@example.invalid", iss: "https://auth.openai.com", aud: form.get("client_id")!, sub: "scratch-user", iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600 }, claims));
|
||||
return Response.json({ access_token: "scratch-access", refresh_token: "scratch-refresh", id_token: await jwt.setProtectedHeader({ alg: "RS256", kid: "scratch-key" }).sign(privateKey), token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
|
||||
} });
|
||||
try { await f.service.login(); await f.callback(); await f.changed.promise; assert.equal(f.saved.activeId, null); assert.equal(f.saved.registrations.length, 0); }
|
||||
finally { f.service.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("cancel and timeout close the listener without exchanging or replacing an active account", async () => {
|
||||
const f = fixture({ timeoutMs: 30 });
|
||||
try {
|
||||
await f.service.login(); const callback = f.url.searchParams.get("redirect_uri")!;
|
||||
f.service.cancelLogin();
|
||||
await assert.rejects(fetch(callback));
|
||||
assert.equal(f.saved.activeId, null);
|
||||
const timeout = fixture({ timeoutMs: 15 });
|
||||
try {
|
||||
await timeout.service.login();
|
||||
await timeout.changed.promise;
|
||||
assert.equal((await timeout.service.status()).pending, false);
|
||||
assert.equal(timeout.requests.length, 0);
|
||||
} finally { timeout.service.close(); }
|
||||
assert.equal(f.requests.length, 0);
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("missing plan scope retains verified registration but refuses inference and catalog", async () => {
|
||||
const f = await loggedIn({ token: async (form, url) => Response.json({ id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), scope: "openid profile email", token_type: "Bearer" }) });
|
||||
try {
|
||||
assert.equal(f.saved.registrations.length, 1);
|
||||
assert.equal((await f.service.status()).accounts[0].planEnabled, false);
|
||||
await assert.rejects(f.service.lease(false), /scope/);
|
||||
assert.deepEqual((await f.service.models()).models, []);
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("durable encrypted save failure never activates a pending login", async () => {
|
||||
const f = fixture({ write: async () => { throw new Error("scratch disk failure containing no real data"); } });
|
||||
try {
|
||||
await f.service.login(); await f.callback(); await f.changed.promise;
|
||||
assert.equal(f.saved.activeId, null);
|
||||
await assert.rejects(f.service.lease(false), /disconnected/);
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("serialized concurrent refresh rotates once and persists before leases", async () => {
|
||||
let refreshes = 0;
|
||||
const f = await loggedIn({ expires: 1, token: async (form, url) => {
|
||||
if (form.get("grant_type") === "refresh_token") { refreshes++; assert.equal(form.get("scope"), null); return Response.json({ access_token: "scratch-new-access", refresh_token: "scratch-rotated", token_type: "Bearer", expires_in: 3600 }); }
|
||||
return Response.json({ access_token: "scratch-old-access", refresh_token: "scratch-old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") });
|
||||
} });
|
||||
try {
|
||||
const leases = await Promise.all([f.service.lease(false), f.service.lease(false), f.service.lease(false)]);
|
||||
assert.equal(refreshes, 1);
|
||||
assert(leases.every((lease) => lease.accessToken === "scratch-new-access"));
|
||||
assert.equal(f.saved.registrations[0].tokens.refreshToken, "scratch-rotated");
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("logout wins an in-flight refresh and retains only account/client/host mapping", async () => {
|
||||
const started = deferred(); const release = deferred();
|
||||
const f = await loggedIn({ token: async (form, url) => {
|
||||
if (form.get("grant_type") === "refresh_token") { started.resolve(); await release.promise; return Response.json({ access_token: "scratch-late", refresh_token: "scratch-rotated", token_type: "Bearer", expires_in: 3600 }); }
|
||||
return Response.json({ access_token: "scratch-old", refresh_token: "scratch-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") });
|
||||
} });
|
||||
try {
|
||||
const id = f.saved.activeId;
|
||||
const refresh = f.service.lease(false); const rejected = assert.rejects(refresh);
|
||||
await started.promise;
|
||||
const logout = f.service.logout(id);
|
||||
await new Promise((resolve) => setImmediate(resolve)); release.resolve();
|
||||
await rejected; await logout;
|
||||
assert.equal(f.saved.activeId, null);
|
||||
assert.equal(f.saved.registrations[0].clientId, "oaiapp_scratch");
|
||||
assert.equal(f.saved.registrations[0].tokens, undefined);
|
||||
assert(f.requests.some((r) => r.form?.get("token_type_hint") === "refresh_token"));
|
||||
await f.service.login(id);
|
||||
assert.equal(f.url.searchParams.get("id_token_hint"), null);
|
||||
assert.equal(f.url.searchParams.get("client_id"), "oaiapp_scratch");
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("account catalog is scoped, visibility filtered, and cleared on disconnect", async () => {
|
||||
const f = await loggedIn();
|
||||
try {
|
||||
assert.deepEqual((await f.service.models()).models, [{ id: "gpt-5", name: "Scratch model" }]);
|
||||
await f.service.logout(f.saved.activeId);
|
||||
assert.deepEqual((await f.service.models()).models, []);
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("safeStorage refuses basic_text/unavailable; encrypted file owner-only, atomic, no plaintext fallback", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-storage-"));
|
||||
const file = path.join(dir, "credentials.enc.json");
|
||||
const secure = { isEncryptionAvailable: () => true, getSelectedStorageBackend: () => "keychain", encryptString: (s: string) => Buffer.from(s.split("").reverse().join("")), decryptString: (b: Buffer) => b.toString().split("").reverse().join("") };
|
||||
try {
|
||||
const store = encryptedStore(file, secure);
|
||||
const data = { ...initial(), marker: "scratch-secret-never-plaintext" };
|
||||
await store.write(data);
|
||||
assert(!(await fs.readFile(file, "utf8")).includes(data.marker));
|
||||
assert.deepEqual(await store.read(), data);
|
||||
assert.equal((await fs.stat(file)).mode & 0o777, 0o600);
|
||||
assert.deepEqual(await fs.readdir(dir), ["credentials.enc.json"]);
|
||||
for (const backend of [{ ...secure, getSelectedStorageBackend: () => "basic_text" }, { ...secure, isEncryptionAvailable: () => false }]) {
|
||||
await assert.rejects(encryptedStore(file, backend).read(), /secure_storage/);
|
||||
await assert.rejects(encryptedStore(file, backend).write(data), /secure_storage/);
|
||||
}
|
||||
assert(!allowedAuthEndpoint("https://auth.openai.com.evil.invalid/revoke"));
|
||||
assert(!allowedAuthEndpoint("http://127.0.0.1/revoke"));
|
||||
assert(!allowedAuthEndpoint("https://auth.openai.com@evil.invalid/revoke"));
|
||||
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("invalid rotating replacement is durably revocation-only; verified mapping and prior validated ID survive restart", async () => {
|
||||
const f = await loggedIn({ expires: 1, token: async (form, url) => form.get("grant_type") === "refresh_token"
|
||||
? Response.json({ access_token: "never-save-this-access", refresh_token: "likely-latest-revoke-handle", id_token: "invalid.signature.jwt", token_type: "Bearer", expires_in: 3600 })
|
||||
: Response.json({ access_token: "old-verified-access", refresh_token: "old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") }) });
|
||||
try {
|
||||
const before = structuredClone(f.saved.registrations[0]);
|
||||
await assert.rejects(f.service.lease(false));
|
||||
const restricted = f.saved.registrations[0];
|
||||
assert.equal(restricted.reconnectRequired, true);
|
||||
for (const field of ["id", "issuer", "subject", "email", "clientId"]) assert.equal(restricted[field], before[field]);
|
||||
assert.deepEqual(restricted.tokens, { ...before.tokens, refreshToken: "likely-latest-revoke-handle" });
|
||||
assert(!JSON.stringify(f.saved).includes("never-save-this-access"));
|
||||
const restarted = fixture({ state: f.saved });
|
||||
try {
|
||||
await restarted.service.status();
|
||||
await restarted.service.select(restricted.id);
|
||||
restarted.service.resume(); await restarted.service.consent(restricted.id, true);
|
||||
await assert.rejects(restarted.service.lease(false));
|
||||
assert.equal((await restarted.service.status()).accounts[0].connected, false);
|
||||
assert.deepEqual((await restarted.service.models()).models, []);
|
||||
assert.equal(restarted.requests.filter((r) => r.url === tokenUrl).length, 0, "restricted material cannot refresh for inference/catalog");
|
||||
await restarted.service.logout(restricted.id);
|
||||
assert.equal(restarted.requests.find((r) => r.form?.has("token_type_hint"))!.form!.get("token"), "likely-latest-revoke-handle");
|
||||
assert.equal(restarted.saved.registrations[0].reconnectRequired, true);
|
||||
await restarted.service.login(restricted.id); await restarted.callback(); await restarted.changed.promise;
|
||||
// Wait for callback's asynchronous encrypted activation, not the prior status push.
|
||||
for (let i = 0; i < 30 && restarted.saved.registrations[0].reconnectRequired; i++) await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(restarted.saved.registrations[0].reconnectRequired, undefined);
|
||||
} finally { restarted.service.close(); }
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("detected secure backend loss emits full invalidation, denies all leases and cannot be repaired by consent/resume", async () => {
|
||||
let lost = false; const pushes: any[] = [];
|
||||
const f = await loggedIn({ secure: () => { if (lost) throw Object.assign(new Error("secure_storage"), { code: "secure_storage" }); }, change: (push) => pushes.push(push) });
|
||||
try {
|
||||
await f.service.lease(false); const epoch = f.service.stamp().epoch;
|
||||
lost = true;
|
||||
assert.equal((await f.service.status()).available, false);
|
||||
assert(pushes.some((p) => p.invalidate === "all" && p.stamp.epoch > epoch));
|
||||
await assert.rejects(f.service.lease(false)); await assert.rejects(f.service.lease(true));
|
||||
lost = false; f.service.resume(); await f.service.consent(f.saved.activeId, true);
|
||||
await assert.rejects(f.service.lease(false));
|
||||
assert.equal((await f.service.status()).accounts[0].connected, false);
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("strict encrypted schema rejects malformed marker/duplicates/token/active identity without changing original bytes", async () => {
|
||||
const f = await loggedIn(); f.service.close();
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-corrupt-"));
|
||||
const file = path.join(dir, "credentials.enc.json");
|
||||
const secure = { isEncryptionAvailable: () => true, encryptString: (s: string) => Buffer.from(s), decryptString: (b: Buffer) => b.toString() };
|
||||
try {
|
||||
for (const mutate of [(s: any) => s.registrations[0].reconnectRequired = false, (s: any) => s.registrations[0].reconnectRequired = "true", (s: any) => s.registrations.push(s.registrations[0]), (s: any) => s.activeId = "missing", (s: any) => s.registrations[0].tokens.scopes = "not-array", (s: any) => s.registrations[0].tokens.expiresAt = -1]) {
|
||||
const state = structuredClone(f.saved); mutate(state);
|
||||
const bytes = JSON.stringify({ version: 1, encrypted: Buffer.from(JSON.stringify(state)).toString("base64") });
|
||||
await fs.writeFile(file, bytes);
|
||||
const service = createChatGPT({ store: encryptedStore(file, secure), openExternal: () => assert.fail("no OAuth"), fetch: () => assert.fail("no provider") });
|
||||
try { assert.equal((await service.status()).available, false); await assert.rejects(service.login()); assert.equal(await fs.readFile(file, "utf8"), bytes); }
|
||||
finally { service.close(); }
|
||||
}
|
||||
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("consent OFF wins an older ON save; foreground catalog authority remains unchanged", async () => {
|
||||
let hold = false; const entered = deferred(); const release = deferred();
|
||||
const f = await loggedIn({ write: async (next) => { if (hold && next.registrations[0]?.background) { entered.resolve(); await release.promise; } } });
|
||||
try {
|
||||
const catalog = await f.service.models(); const id = f.saved.activeId;
|
||||
hold = true;
|
||||
const on = f.service.consent(id, true); await entered.promise;
|
||||
const off = f.service.consent(id, false);
|
||||
assert.equal((await f.service.status()).accounts[0].background, false);
|
||||
release.resolve(); await on; await off;
|
||||
assert.equal(f.saved.registrations[0].background, false);
|
||||
assert.equal(f.service.stamp().authorityRevision, catalog.authorityRevision);
|
||||
assert.equal((await f.rawLease(false, { expectedAccountId: id, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" })).accountId, id);
|
||||
await assert.rejects(f.rawLease(true, { expectedAccountId: id, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" }), /background/);
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("cancellation during verified durable save cannot activate late credentials", async () => {
|
||||
const entered = deferred(); const release = deferred(); let blocked = true;
|
||||
const f = fixture({ write: async (next) => { if (blocked && next.registrations.length) { entered.resolve(); await release.promise; blocked = false; } } });
|
||||
try {
|
||||
await f.service.login(); await f.callback(); await entered.promise;
|
||||
f.service.cancelLogin(); release.resolve();
|
||||
for (let i = 0; i < 30 && f.writeCount < 2; i++) await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(f.saved.activeId, null);
|
||||
assert.equal((await f.service.status()).accounts.length, 0);
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("scope-disabled credentials remain revocable and unconfirmed revoke never claims success", async () => {
|
||||
const f = await loggedIn({ token: async (form, url) => Response.json({ id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), refresh_token: "scope-disabled-refresh", scope: "openid profile email", token_type: "Bearer" }), provider: async (url) => url.endsWith("/revoke") ? new Response(null, { status: 500 }) : undefined });
|
||||
try {
|
||||
await assert.rejects(f.service.lease(false));
|
||||
const status = await f.service.logout(f.saved.activeId);
|
||||
assert.match(status.error, /Remote revocation was not confirmed/);
|
||||
assert.equal(f.requests.filter((r) => r.form?.get("token") === "scope-disabled-refresh").length, 2);
|
||||
assert.equal(f.saved.registrations[0].tokens, undefined);
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
|
||||
test("private leases bind active account, authority revision and raw visible model membership", async () => {
|
||||
const f = await loggedIn();
|
||||
try {
|
||||
const catalog = await f.service.models();
|
||||
const expected = { expectedAccountId: catalog.accountId, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" };
|
||||
await assert.rejects(f.rawLease(false));
|
||||
for (const mutation of [{ expectedAccountId: "another" }, { expectedAuthorityRevision: catalog.authorityRevision - 1 }, { selectedModelId: "hidden" }]) await assert.rejects(f.rawLease(false, { ...expected, ...mutation }));
|
||||
assert.equal((await f.rawLease(false, expected)).accountId, catalog.accountId);
|
||||
} finally { f.service.close(); }
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Agent, type AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import { AssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
|
||||
import { piModels } from "../src/agent/models.ts";
|
||||
import { toLlmMessages } from "../src/core/native-messages.ts";
|
||||
import { buildSubagentSteer } from "../src/core/contracts.ts";
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-chatgpt-background-"));
|
||||
process.env.LH_DATA_DIR = path.join(dir, "data");
|
||||
process.env.LH_AUTH_FILE = path.join(dir, "scratch-auth.json");
|
||||
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
|
||||
const { stores } = await import("../src/server/stores.ts");
|
||||
const { beginTurn, endTurn } = await import("../src/server/turn-liveness.ts");
|
||||
const { deliverJobResult, registerLiveAgent, unregisterLiveAgent, setTurnRunner, settleSteers, steerLiveChat } = await import("../src/server/jobs.ts");
|
||||
const { persistTurn } = await import("../src/server/turn-service.ts");
|
||||
const ipc = (value: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", value);
|
||||
let revision = 1;
|
||||
function connection(background: boolean) {
|
||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: revision++ }, invalidate: background ? "none" : "background", status: { available: true, pending: false, activeId: "scratch-account", accounts: [{ id: "scratch-account", connected: true, planEnabled: true, background }], error: null } });
|
||||
}
|
||||
after(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
for (const revokedAfterQueue of [false, true]) {
|
||||
test(`mixed learner+automatic batch: foreground runs, automatic result stays owned/deferred without consent (${revokedAfterQueue ? "revoked after handoff" : "initially off"})`, { timeout: 5000 }, async () => {
|
||||
connection(revokedAfterQueue);
|
||||
const chat = stores.chatStore.createChat("Scratch mixed queue");
|
||||
stores.chatStore.saveSettings(chat.id, { model: { provider: "chatgpt", id: "gpt-5" } });
|
||||
const job = stores.jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "scratch-job-call" });
|
||||
const result = { brief: "Scratch completed job result", sources: [] };
|
||||
stores.jobStore.updateJob(job.id, { status: "running", result });
|
||||
const marker = buildSubagentSteer(job, result);
|
||||
const contexts: string[] = [];
|
||||
const native = piModels().getModel("openai", "gpt-5")!;
|
||||
const model = { ...native, provider: "chatgpt" };
|
||||
const agent = new Agent({ initialState: { model, messages: [{ role: "user", content: "Initial scratch learner input", timestamp: 1, id: "scratch-initial" } as AgentMessage] }, convertToLlm: toLlmMessages,
|
||||
streamFn: () => {
|
||||
contexts.push(JSON.stringify(agent.state.messages));
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const output = { role: "assistant" as const, provider: "chatgpt", api: model.api, model: model.id, content: [{ type: "text" as const, text: "Scratch foreground response" }], stopReason: "stop" as const, timestamp: 2, usage: { input: 2, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
||||
stream.push({ type: "start", partial: output }); stream.push({ type: "done", reason: "stop", message: output }); stream.end();
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
let wakes = 0;
|
||||
setTurnRunner(async (chatId, input) => {
|
||||
wakes++;
|
||||
assert.equal(input.type, "steers");
|
||||
if (input.type === "steers") stores.chatStore.saveMessages(chatId, [...stores.chatStore.getChat(chatId)!.messages, ...input.steers]);
|
||||
return 200;
|
||||
});
|
||||
assert(beginTurn(chat.id));
|
||||
registerLiveAgent(chat.id, agent);
|
||||
const delivered = deliverJobResult(marker);
|
||||
const learner = { id: "scratch-live-learner", role: "user", content: "Please continue my learning", timestamp: 3 } as never;
|
||||
assert(steerLiveChat(chat.id, learner));
|
||||
if (revokedAfterQueue) connection(false);
|
||||
await agent.continue();
|
||||
assert.equal(contexts.length, 1, "ordinary foreground tool-free call remains usable");
|
||||
assert(contexts[0]!.includes("Please continue my learning"));
|
||||
assert(!contexts[0]!.includes(marker.id), "automatic marker must not enter the foreground batch");
|
||||
assert(!agent.hasQueuedMessages());
|
||||
// Ordinary lease/catalog replies are not consent changes and must never
|
||||
// reset/replay a learner steer that the agent has already consumed.
|
||||
ipc({ channel: "lh:chatgpt", id: "ordinary-reply-with-no-pending-call", value: { accessToken: "scratch-only" } });
|
||||
assert(!agent.hasQueuedMessages());
|
||||
const persisted = persistTurn(stores.chatStore, chat.id, 0, agent.state.messages, false);
|
||||
unregisterLiveAgent(chat.id); endTurn(chat.id); settleSteers(chat.id, persisted);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(wakes, 0, "no background spin or spend while consent is absent");
|
||||
assert.equal(stores.jobStore.getJob(job.id)!.status, "running", "delivery ownership must not be finalized or failed by deferral");
|
||||
assert.deepEqual(stores.jobStore.getJob(job.id)!.result, result);
|
||||
connection(true);
|
||||
await delivered;
|
||||
assert.equal(wakes, 1, "explicit consent resumes the preserved result exactly once");
|
||||
assert.equal(stores.jobStore.getJob(job.id)!.status, "done");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { piModels } from "../src/agent/models.ts";
|
||||
import { refreshChatGPTModels, chatGPTModels, chatGPTStatus, chatGPTScope, chatGPTScopeCurrent, chatGPTLease, chatGPTAbortController } from "../src/agent/chatgpt-bridge.ts";
|
||||
const send = Object.getOwnPropertyDescriptor(process, "send"), connected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||
const pending: any[] = [];
|
||||
const model = piModels().getModels("openai").find((m) => m.api === "openai-responses")!;
|
||||
const ipc = (m: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", m);
|
||||
const status = (account: string, background = true) => ({ available: true, pending: false, activeId: account, accounts: [{ id: account, connected: true, background, planEnabled: true }], error: null });
|
||||
const value = (name: string, revision: number, account = "A", authorityRevision = 1) => ({ epoch: account === "A" ? 1 : 2, authorityRevision, revision, status: status(account), accountId: account, models: [{ id: model.id, name }] });
|
||||
function reply(request: any, v: unknown) { ipc({ channel: "lh:chatgpt", id: request.id, op: request.op, ok: true, value: v }); }
|
||||
before(() => {
|
||||
Object.defineProperty(process, "connected", { configurable: true, value: true });
|
||||
Object.defineProperty(process, "send", { configurable: true, value: (m: unknown, cb?: () => void) => { pending.push(m); cb?.(); return true; } });
|
||||
});
|
||||
after(() => {
|
||||
if (send) Object.defineProperty(process, "send", send); else delete process.send;
|
||||
if (connected) Object.defineProperty(process, "connected", connected); else delete (process as { connected?: boolean }).connected;
|
||||
});
|
||||
test("overlapping catalog requests only install the newest generation", async () => {
|
||||
const old = refreshChatGPTModels([model as never]), latest = refreshChatGPTModels([model as never]);
|
||||
reply(pending[1], value("newest", 2)); await latest;
|
||||
reply(pending[0], value("stale", 1)); await old;
|
||||
assert.equal(chatGPTModels()[0]?.name, "newest");
|
||||
});
|
||||
test("consent-only status does not discard current-authority catalog or overwrite newer consent from an old status embedded in models", async () => {
|
||||
const refresh = refreshChatGPTModels([model as never]); const request = pending.at(-1);
|
||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 3 }, status: status("A", false), invalidate: "background" });
|
||||
reply(request, value("same authority", 2)); await refresh;
|
||||
assert.equal(chatGPTModels()[0]?.name, "same authority"); assert.equal(chatGPTStatus().accounts[0]?.background, false);
|
||||
const scope = chatGPTScope(model.id); assert(chatGPTScopeCurrent(scope));
|
||||
const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true);
|
||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 4 }, status: status("A", false), invalidate: "background" });
|
||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 5 }, status: status("A", true), invalidate: "none" });
|
||||
assert(!foreground.controller.signal.aborted); assert(background.controller.signal.aborted); assert(chatGPTScopeCurrent(scope));
|
||||
foreground.release(); background.release();
|
||||
});
|
||||
test("account/full and catalog authority changes reject outstanding old leases without silently switching identity", async () => {
|
||||
const scope = chatGPTScope(model.id); const lease = chatGPTLease(scope, false); const request = pending.at(-1);
|
||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 2, revision: 6 }, status: status("B"), invalidate: "all" });
|
||||
assert.equal(chatGPTModels().length, 0); assert(!chatGPTScopeCurrent(scope));
|
||||
reply(request, { epoch: 1, authorityRevision: 1, revision: 5, accountId: "A", accessToken: "dummy-old" });
|
||||
await assert.rejects(lease, /stale/);
|
||||
const refresh = refreshChatGPTModels([model as never]); reply(pending.at(-1), value("B", 7, "B", 2)); await refresh;
|
||||
const newScope = chatGPTScope(model.id); assert.equal(newScope.accountId, "B");
|
||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 3, revision: 8 }, status: status("B"), invalidate: "none" });
|
||||
assert.equal(chatGPTModels().length, 0); await assert.rejects(chatGPTLease(newScope, false));
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { type Model } from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import { piModels, piEngineForSpec, APP_IDENTITY } from "../src/agent/models.ts";
|
||||
import { refreshChatGPTModels, chatGPTScopeCurrent } from "../src/agent/chatgpt-bridge.ts";
|
||||
import { ChatGPTRequestCycle } from "../src/agent/chatgpt-request-cycle.ts";
|
||||
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-chatgpt-native-"));
|
||||
process.env.LH_DATA_DIR = path.join(scratch, "data");
|
||||
process.env.LH_AUTH_FILE = path.join(scratch, "auth.json");
|
||||
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
|
||||
const { runPiTurn } = await import("../src/server/pi/ui-stream.ts");
|
||||
import { newUserMessage, newNextMessage, newThinkingMessage, newAnswerResult, reconstructRequestCause, concludedByDismissalPair, type LhMessage } from "../src/core/native-messages.ts";
|
||||
|
||||
const originalSend = Object.getOwnPropertyDescriptor(process, "send");
|
||||
const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||
const realFetch = globalThis.fetch;
|
||||
const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">;
|
||||
let epoch = 1, revision = 1, background = true;
|
||||
let cut: "lease" | "payload" | "full" | "afterFetch" | "none" = "none";
|
||||
let leases: boolean[] = [], fetches = 0, toolFirst = false;
|
||||
const status = () => ({ available: true, pending: false, activeId: "scratch", accounts: [{ id: "scratch", registration: "oaiapp_scratch", email: "dummy@example.invalid", connected: true, planEnabled: true, background }], error: null });
|
||||
const ipc = (m: unknown) => (process.emit as (name: string, m: unknown) => boolean)("message", m);
|
||||
function revoke(full = false) {
|
||||
background = false; if (full) epoch++;
|
||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch, authorityRevision: epoch, revision: ++revision }, status: status(), invalidate: full ? "all" : "background" });
|
||||
}
|
||||
before(() => {
|
||||
Object.defineProperty(process, "connected", { configurable: true, value: true });
|
||||
Object.defineProperty(process, "send", { configurable: true, value: (m: any, callback?: () => void) => {
|
||||
if (m.op === "lease") {
|
||||
leases.push(m.background);
|
||||
assert.equal(m.expectedAccountId, "scratch"); assert.equal(m.expectedAuthorityRevision, epoch); assert.equal(m.selectedModelId, model.id);
|
||||
if (m.background && (cut === "lease" || cut === "full")) { const full = cut === "full"; cut = "none"; revoke(full); }
|
||||
}
|
||||
const value = { epoch, authorityRevision: epoch, revision, ...(m.op === "lease" ? { accountId: "scratch", accessToken: "dummy-oauth-native" } : { status: status(), accountId: "scratch", models: [{ id: model.id, name: "Scratch" }] }) };
|
||||
queueMicrotask(() => ipc({ channel: "lh:chatgpt", id: m.id, op: m.op, ok: true, value })); callback?.(); return true;
|
||||
} });
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
assert.equal(request.url, "https://api.openai.com/v1/responses");
|
||||
assert.equal(request.headers.get("authorization"), "Bearer dummy-oauth-native");
|
||||
assert.equal(request.headers.get("user-agent"), APP_IDENTITY.userAgent.custom);
|
||||
const body = JSON.parse(await request.text()); assert.equal(body.model, model.id); assert.equal(body.store, false);
|
||||
fetches++;
|
||||
if (cut === "afterFetch") { cut = "none"; revoke(); throw new Error("admitted local abort"); }
|
||||
if (toolFirst && fetches === 1) {
|
||||
const call = { type: "function_call", id: "fc_again", call_id: "call_again", name: "again", namespace: "learnharness", arguments: "{}", status: "completed" };
|
||||
return new Response([{ type: "response.output_item.added", output_index: 0, item: call }, { type: "response.output_item.done", output_index: 0, item: call }, { type: "response.completed", response: { status: "completed", output: [call], usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 } } }].map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
const item = { id: "msg_native", type: "message", role: "assistant", content: [{ type: "output_text", text: "Accepted learner", annotations: [] }] };
|
||||
const events = [{ type: "response.created", response: { id: "resp_native" } }, { type: "response.output_item.added", output_index: 0, item: { ...item, content: [] } }, { type: "response.content_part.added", output_index: 0, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }, { type: "response.output_text.delta", output_index: 0, content_index: 0, delta: "Accepted learner" }, { type: "response.output_item.done", output_index: 0, item }, { type: "response.completed", response: { status: "completed", output: [item], usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 } } }];
|
||||
return new Response(events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
|
||||
};
|
||||
});
|
||||
after(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
fs.rmSync(scratch, { recursive: true, force: true });
|
||||
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
|
||||
if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
|
||||
});
|
||||
const automatic = (id = "auto") => ({ id, role: "custom" as const, customType: "subagent-steer" as const, content: "Automatic result", details: { jobId: "dummy-job" }, display: true, timestamp: 2 });
|
||||
async function nativeRun(where: typeof cut, staged: boolean, withLearner = true, commitThrows = false, lateAutomatic = false, withTool = false) {
|
||||
epoch++; revision++; background = true; cut = where; leases = []; fetches = 0; toolFirst = withTool;
|
||||
await refreshChatGPTModels([model]);
|
||||
const seed = newUserMessage("Initial learner"); const marker = automatic(); const learner = { ...newUserMessage("New learner"), id: "learner-late" };
|
||||
const messages = staged ? [seed, marker] : [seed];
|
||||
const purpose = { background: staged };
|
||||
let restored = 0, committed = 0, usage = 0, settled = 0, executed = 0, handedLate = false;
|
||||
let final: AgentMessage[] = [];
|
||||
let resolve!: () => void; const finished = new Promise<void>((done) => { resolve = done; });
|
||||
const cycle = new ChatGPTRequestCycle({ initialMessages: messages, staged: staged ? [marker] : undefined, foregroundOwed: !staged, purpose, scopeCurrent: chatGPTScopeCurrent,
|
||||
commitStaged: () => { if (commitThrows) throw new Error("scratch durable write failure"); committed++; },
|
||||
restoreAutomatic: (ids, retained) => { restored++; assert(ids.has(marker.id)); assert(!retained.has(marker.id)); agent.clearSteeringQueue(); },
|
||||
});
|
||||
const raw = piEngineForSpec({ provider: "chatgpt", id: model.id }, () => purpose.background, cycle);
|
||||
const engine = { ...raw, streamFn: (m: any, context: any, opts: any) => raw.streamFn(m, context, { ...opts, onPayload: async (payload: unknown) => { if (lateAutomatic && !handedLate) { handedLate = true; agent.steer(marker); } if (cut === "payload") { cut = "none"; await Promise.resolve(); revoke(); } return payload; } }) };
|
||||
const agent = createTeacherAgent(undefined, { engine, messages, chatId: "scratch-native-session", tools: withTool ? { again: { name: "again", label: "again", description: "scratch", parameters: Type.Object({}), execute: async () => { executed++; return { content: [{ type: "text" as const, text: "Done" }], details: {} }; } } } : {}, callPurpose: purpose, requestCycle: cycle, onUsage: () => usage++ });
|
||||
if (!staged && !lateAutomatic) agent.steer(marker);
|
||||
if (withLearner) agent.steer(learner);
|
||||
const handle = runPiTurn({ agent, requestCycle: cycle, onFinish: (m) => { settled++; final = m; resolve(); } });
|
||||
const firstView = new Response(handle.subscribe()).text();
|
||||
await finished;
|
||||
const wire = await firstView; const replay = await new Response(handle.subscribe()).text();
|
||||
assert.equal(settled, 1);
|
||||
assert.equal(replay.includes('"id":"auto"'), wire.includes('"id":"auto"'));
|
||||
assert.equal(replay.split('"type":"agent_end"').length, 2);
|
||||
const events = wire.split("\n\n").filter((line) => line.startsWith("data: {")).map((line) => JSON.parse(line.slice(6)) as AgentEvent);
|
||||
assert.equal(events.filter((e) => e.type === "agent_end").length, 1);
|
||||
assert.equal(events.filter((e) => e.type === "agent_start").length, 1);
|
||||
return { cycle, agent, final, wire, events, restored, committed, usage, executed, leases: [...leases], fetches };
|
||||
}
|
||||
for (const where of ["lease", "payload"] as const) for (const staged of [false, true]) {
|
||||
test(`real native drain -> ${where} denial -> same Agent foreground recovery, staged=${staged}`, { timeout: 5000 }, async () => {
|
||||
const r = await nativeRun(where, staged);
|
||||
assert.equal(r.fetches, 1); assert.deepEqual(r.leases, [true, false]); assert.equal(r.usage, 1); assert.equal(r.restored, 1);
|
||||
assert(!r.wire.includes('"id":"auto"')); assert(!r.wire.includes('"stopReason":"error"'));
|
||||
assert.equal(r.final.filter((m) => (m as { id?: string }).id === "learner-late").length, 1);
|
||||
assert.equal(r.events.filter((e) => e.type === "message_end" && (e.message as { id?: string }).id === "learner-late").length, 1);
|
||||
assert.equal(r.events.filter((e) => e.type === "turn_start").length, 1);
|
||||
assert.equal(await r.cycle.admission, false);
|
||||
});
|
||||
}
|
||||
test("initial automatic denial without learner creates no usage/visible marker/durable prefix", async () => {
|
||||
const r = await nativeRun("lease", true, false); assert.equal(r.fetches, 0); assert.equal(r.usage, 0); assert.equal(r.committed, 0); assert.equal(await r.cycle.admission, false); assert(!r.wire.includes('"id":"auto"'));
|
||||
});
|
||||
test("full invalidation never internally retries under a new authority", async () => {
|
||||
const r = await nativeRun("full", true); assert.equal(r.fetches, 0); assert.equal(r.usage, 0); assert.deepEqual(r.leases, [true]);
|
||||
});
|
||||
test("successful staged prefix commits once before native dispatch and is visible exactly once", async () => {
|
||||
const r = await nativeRun("none", true, false); assert.equal(r.fetches, 1); assert.equal(r.committed, 1); assert.equal(await r.cycle.admission, true);
|
||||
assert.equal(r.events.filter((e) => e.type === "message_end" && (e.message as { id?: string }).id === "auto").length, 1);
|
||||
});
|
||||
test("durable staged input failure prevents dispatch and does not infer foreground replay", async () => {
|
||||
const r = await nativeRun("none", true, false, true); assert.equal(r.fetches, 0); assert.equal(r.usage, 0); assert.equal(await r.cycle.admission, false);
|
||||
});
|
||||
test("invalidation after fetch commitment cannot replay an admitted request", async () => {
|
||||
const r = await nativeRun("afterFetch", false); assert.equal(r.fetches, 1); assert.equal(r.restored, 0); assert.deepEqual(r.leases, [true]); assert.equal(await r.cycle.admission, true);
|
||||
});
|
||||
|
||||
test("automatic arrival after completed foreground prose cannot manufacture another foreground request", async () => {
|
||||
const r = await nativeRun("lease", false, false, false, true);
|
||||
assert.equal(r.fetches, 1); assert.equal(r.usage, 1); assert.equal(r.restored, 1); assert.deepEqual(r.leases, [false, true]);
|
||||
assert(!r.wire.includes('"id":"auto"'));
|
||||
});
|
||||
test("unfinished foreground ordinary-tool chain recovers without rerunning the completed tool", async () => {
|
||||
const r = await nativeRun("lease", false, false, false, true, true);
|
||||
assert.equal(r.fetches, 2); assert.equal(r.executed, 1); assert.equal(r.usage, 2); assert.deepEqual(r.leases, [false, true, false]);
|
||||
assert.equal(r.final.filter((m) => m.role === "toolResult").length, 1);
|
||||
assert(!r.wire.includes('"id":"auto"'));
|
||||
});
|
||||
|
||||
test("full native record reconstruction preserves original causes and reports genuine ambiguity", () => {
|
||||
const user = newUserMessage("Learner"); const auto = automatic();
|
||||
const assistant = { role: "assistant", id: "assistant", api: "openai-responses", provider: "chatgpt", model: model.id, content: [{ type: "toolCall", id: "call", name: "read", arguments: {} }], stopReason: "toolUse", timestamp: 3, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } } as LhMessage;
|
||||
const result = { role: "toolResult", id: "result", toolName: "read", toolCallId: "call", content: [], details: {}, isError: false, timestamp: 4 } as LhMessage;
|
||||
for (const [origin, expected] of [[user, "foreground"], [auto, "background"]] as const) assert.equal(reconstructRequestCause(JSON.parse(JSON.stringify([origin, assistant, result]))), expected);
|
||||
assert.equal(reconstructRequestCause([auto, user, assistant, result]), "ambiguous");
|
||||
assert.equal(reconstructRequestCause([user, auto, assistant, result]), "background");
|
||||
assert.equal(reconstructRequestCause([assistant, result]), "ambiguous");
|
||||
assert.equal(reconstructRequestCause([auto, newThinkingMessage("problem", "working")]), "ambiguous");
|
||||
assert.equal(reconstructRequestCause([auto, assistant, newNextMessage()]), "foreground");
|
||||
assert.equal(reconstructRequestCause([user, ...concludedByDismissalPair("problem")]), "foreground");
|
||||
const question = { ...assistant, content: [{ type: "toolCall", id: "question", name: "quiz", arguments: {} }] } as LhMessage;
|
||||
assert.equal(reconstructRequestCause([auto, question, newAnswerResult("question", "quiz", { kind: "quiz", questionText: "Scratch", dontKnow: false, answers: [], answeredAt: "2026-10-24T00:00:00Z" })]), "foreground");
|
||||
assert.equal(reconstructRequestCause([auto, question, { ...newAnswerResult("question", "quiz", {}), details: null }]), "background");
|
||||
assert.equal(reconstructRequestCause([auto, question, newAnswerResult("orphan", "quiz", {})]), "ambiguous");
|
||||
assert.equal(reconstructRequestCause([user, { ...user, content: "conflict" }]), "ambiguous");
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { normalizeContext, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
import { piModels, piEngineForSpec } from "../src/agent/models.ts";
|
||||
import { chatGPTAbortController, chatGPTModels, refreshChatGPTModels } from "../src/agent/chatgpt-bridge.ts";
|
||||
import { chatGPTHistory, chatGPTPayload } from "../src/agent/chatgpt-stream.ts";
|
||||
|
||||
const originalSend = Object.getOwnPropertyDescriptor(process, "send");
|
||||
const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||
const realFetch = globalThis.fetch;
|
||||
const originalKey = process.env.OPENAI_API_KEY;
|
||||
let leaseError: string | null = null;
|
||||
let requests: { authorization: string | null; body: Record<string, any>; url: string }[] = [];
|
||||
let responseEvents: unknown[] = [];
|
||||
const ipc = (value: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", value);
|
||||
const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">;
|
||||
const status = { available: true, pending: false, activeId: "scratch-account", accounts: [{ id: "scratch-account", email: "dummy@example.invalid", registration: "oaiapp_scratch", connected: true, planEnabled: true, background: false }], error: null };
|
||||
before(async () => {
|
||||
assert(model);
|
||||
process.env.OPENAI_API_KEY = "sk-scratch-must-never-be-used";
|
||||
Object.defineProperty(process, "connected", { configurable: true, value: true });
|
||||
Object.defineProperty(process, "send", { configurable: true, value: (message: any, callback?: () => void) => {
|
||||
queueMicrotask(() => {
|
||||
ipc({ channel: "lh:chatgpt", id: message.id, op: message.op,
|
||||
...(message.op === "lease" && leaseError ? { ok: false, error: leaseError, stamp: { epoch: 1, authorityRevision: 1, revision: 1 } } : { ok: true, value: message.op === "lease" ? { accessToken: "scratch-oauth-only", accountId: "scratch-account", epoch: 1, authorityRevision: 1, revision: 1 } : { epoch: 1, authorityRevision: 1, revision: 1, status, accountId: "scratch-account", models: [{ id: model.id, name: "Scratch account model" }, { id: "unknown-capabilities", name: "Do not invent capabilities" }] } }),
|
||||
});
|
||||
});
|
||||
callback?.(); return true;
|
||||
} });
|
||||
await refreshChatGPTModels([model]);
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const req = new Request(input, init);
|
||||
requests.push({ authorization: req.headers.get("authorization"), body: JSON.parse(await req.text()), url: req.url });
|
||||
return new Response(responseEvents.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
|
||||
};
|
||||
});
|
||||
after(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
|
||||
if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
|
||||
if (originalKey === undefined) delete process.env.OPENAI_API_KEY; else process.env.OPENAI_API_KEY = originalKey;
|
||||
});
|
||||
const usage = { input_tokens: 10, output_tokens: 4, total_tokens: 14, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 } };
|
||||
const complete = (output: unknown[] = []) => ({ type: "response.completed", response: { id: "resp_scratch", status: "completed", output, usage } });
|
||||
const context = () => normalizeContext({ systemPrompt: "Scratch teacher instructions", messages: [{ role: "user", content: [{ type: "text", text: "Scratch learning material" }, { type: "image", data: "c2NyYXRjaA==", mimeType: "image/png" }], timestamp: 1 }], tools: [{ name: "scratch_tool", description: "Scratch local tool", parameters: Type.Object({ value: Type.String() }) }] });
|
||||
|
||||
test("catalog intersects supported capabilities, without inventing model limits", () => {
|
||||
assert.equal(chatGPTModels().length, 1);
|
||||
assert.equal(chatGPTModels()[0]!.provider, "chatgpt");
|
||||
assert.equal(chatGPTModels()[0]!.contextWindow, model.contextWindow);
|
||||
assert.deepEqual(chatGPTModels()[0]!.input, model.input);
|
||||
});
|
||||
|
||||
test("alias resolves only OAuth; native Responses body is stateless/namespaced, image/history preserved and logical output retained", async () => {
|
||||
requests = [];
|
||||
const tool = { type: "function_call", id: "fc_scratch", call_id: "call_scratch", name: "scratch_tool", namespace: "learnharness", arguments: '{"value":"yes"}', status: "completed" };
|
||||
const reasoning = { type: "reasoning", id: "rs_scratch", summary: [], encrypted_content: "scratch-encrypted-reasoning" };
|
||||
responseEvents = [{ type: "response.created", response: { id: "resp_scratch" } }, { type: "response.output_item.added", output_index: 0, item: reasoning }, { type: "response.output_item.done", output_index: 0, item: reasoning }, { type: "response.output_item.added", output_index: 1, item: tool }, { type: "response.output_item.done", output_index: 1, item: tool }, complete([reasoning, tool])];
|
||||
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, false);
|
||||
const stream = engine.streamFn(engine.model, context(), { temperature: 0.7, maxTokens: 1000, samplingParams: { top_p: 0.5 }, onPayload: (payload) => ({ ...payload as object, model: "caller-cannot-change", dangerous: true }) });
|
||||
const events = [];
|
||||
for await (const event of stream) { events.push(event); if ("partial" in event) assert.equal(event.partial.provider, "chatgpt"); }
|
||||
const result = await stream.result();
|
||||
assert.equal(result.provider, "chatgpt"); assert.equal(result.stopReason, "toolUse");
|
||||
assert.equal(requests.length, 1); assert.equal(requests[0]!.url, "https://api.openai.com/v1/responses");
|
||||
assert.equal(requests[0]!.authorization, "Bearer scratch-oauth-only");
|
||||
const body = requests[0]!.body;
|
||||
assert.equal(body.store, false); assert.equal(body.stream, true);
|
||||
for (const key of ["temperature", "top_p", "max_output_tokens", "previous_response_id", "background", "metadata", "prompt_cache_retention"]) assert.equal(body[key], undefined, key);
|
||||
assert.equal(body.input[0].role, "developer");
|
||||
assert.equal(body.input[1].content[1].type, "input_image");
|
||||
assert.equal(body.tools[0].type, "namespace"); assert.equal(body.tools[0].tools[0].name, "scratch_tool");
|
||||
const call = result.content.find((block) => block.type === "toolCall")!;
|
||||
assert.equal(call.id, "call_scratch|fc_scratch"); assert.equal(call.namespace, "learnharness");
|
||||
const thinking = result.content.find((block) => block.type === "thinking")!;
|
||||
assert.equal(thinking.thinkingSignature, JSON.stringify(reasoning));
|
||||
const history = normalizeContext({ messages: [...context().messages, result, { role: "toolResult", toolCallId: call.id, toolName: call.name, isError: false, content: [{ type: "text", text: "Scratch tool result" }], timestamp: 2 }] });
|
||||
responseEvents = [complete()];
|
||||
await engine.streamFn(engine.model, history).result();
|
||||
const replay = requests[1]!.body.input;
|
||||
assert(replay.some((item: any) => item.encrypted_content === "scratch-encrypted-reasoning"));
|
||||
assert(replay.some((item: any) => item.type === "function_call" && item.id === "fc_scratch" && item.namespace === "learnharness"));
|
||||
assert(replay.some((item: any) => item.type === "function_call_output" && item.call_id === "call_scratch"));
|
||||
});
|
||||
|
||||
test("mixed history never blanket-relabels API providers or reuses foreign reasoning signatures", () => {
|
||||
const native: AssistantMessage = { role: "assistant", provider: "openai", api: "openai-responses", model: model.id, content: [{ type: "thinking", thinking: "", thinkingSignature: '{"encrypted_content":"API-only"}', redacted: true }], stopReason: "stop", usage: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 1, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, timestamp: 1 };
|
||||
const own = { ...native, provider: "chatgpt", content: [{ type: "thinking" as const, thinking: "", thinkingSignature: '{"encrypted_content":"ChatGPT-only"}', redacted: true }] };
|
||||
const transformed = chatGPTHistory(normalizeContext({ messages: [native, own] }), { ...model, provider: "chatgpt" });
|
||||
assert.equal((transformed.messages[0] as AssistantMessage).provider, "openai");
|
||||
assert.deepEqual((transformed.messages[0] as AssistantMessage).content, []);
|
||||
assert.equal((transformed.messages[1] as AssistantMessage).content[0]!.type, "thinking");
|
||||
assert.equal(native.content.length, 1, "source history must not be mutated");
|
||||
});
|
||||
|
||||
test("no terminal completed is never success; late quota/incomplete streams are actionable/redacted", async () => {
|
||||
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, false);
|
||||
for (const events of [[{ type: "response.created", response: { id: "resp_partial" } }], [{ type: "response.incomplete", response: { status: "incomplete", output: [], incomplete_details: { reason: "max_output_tokens" }, usage } }], [{ type: "error", code: "subscription_sharing_usage_limit_exceeded", message: "secret scratch provider diagnostic" }]]) {
|
||||
responseEvents = events;
|
||||
const result = await engine.streamFn(engine.model, context()).result();
|
||||
assert.equal(result.stopReason, "error"); assert.equal(result.provider, "chatgpt");
|
||||
assert(!result.errorMessage?.includes("secret scratch provider diagnostic"));
|
||||
if (JSON.stringify(events).includes("usage_limit")) assert(result.errorMessage?.includes("https://chatgpt.com/settings/usage"));
|
||||
}
|
||||
});
|
||||
|
||||
test("OAuth failure and background denial NEVER use ambient OPENAI_API_KEY", async () => {
|
||||
requests = [];
|
||||
for (const error of ["expired ChatGPT session", "Background use is off", "catalog unavailable", "quota exceeded"]) {
|
||||
leaseError = error;
|
||||
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, true);
|
||||
assert.equal((await engine.streamFn(engine.model, context()).result()).stopReason, "error");
|
||||
}
|
||||
leaseError = null;
|
||||
assert.equal(requests.length, 0);
|
||||
});
|
||||
|
||||
test("consent revocation cancels only applicable background streams and preserves foreground", () => {
|
||||
const foreground = chatGPTAbortController(false);
|
||||
const background = chatGPTAbortController(true);
|
||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 2 }, status, invalidate: "background" });
|
||||
assert.equal(foreground.controller.signal.aborted, false);
|
||||
assert.equal(background.controller.signal.aborted, true);
|
||||
foreground.release(); background.release();
|
||||
});
|
||||
|
||||
test("payload sanitizer omits unsupported fields and rejects unsupported hosted tools", () => {
|
||||
assert.deepEqual(chatGPTPayload({ model: "scratch", input: [], stream: false, store: true, metadata: { test: true }, previous_response_id: "old", background: true }, "scratch"), { model: "scratch", input: [], stream: true, store: false });
|
||||
assert.throws(() => chatGPTPayload({ input: [], tools: [{ type: "tool_search" }] }, "scratch"), /Unsupported/);
|
||||
});
|
||||
|
||||
test("native identity/session/cache/fetch and awaited caller observers survive enforcement; terminal outcome exactly once", async () => {
|
||||
const { APP_IDENTITY } = await import("../src/agent/models.ts");
|
||||
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, false);
|
||||
let headers = new Headers(), payload: any, observed = 0, responses = 0, delegated = 0;
|
||||
responseEvents = [complete()];
|
||||
const stream = engine.streamFn(engine.model, context(), {
|
||||
identity: APP_IDENTITY, sessionId: "exact-native-chat-session", timeoutMs: 12345,
|
||||
headers: { "x-native-observer": "retained", "x-suppressed": null },
|
||||
fetch: async (input, init) => { delegated++; const req = new Request(input, init); headers = req.headers; payload = JSON.parse(await req.text()); return new Response(`data: ${JSON.stringify(complete())}\n\n`, { headers: { "content-type": "text/event-stream" } }); },
|
||||
onPayload: async (raw, native) => { await Promise.resolve(); assert.equal(native.provider, "openai"); return { ...raw as object, model: "not-the-selected-model", store: true, metadata: { forbidden: true } }; },
|
||||
onResponse: async (_response, native) => { await Promise.resolve(); assert.equal(native.provider, "openai"); responses++; },
|
||||
onProviderStreamEvent: async (_raw, native) => { await Promise.resolve(); assert.equal(native.provider, "openai"); observed++; },
|
||||
});
|
||||
const events = []; for await (const event of stream) events.push(event);
|
||||
assert.equal(delegated, 1); assert.equal(observed, 1); assert.equal(responses, 1);
|
||||
assert.equal(headers.get("user-agent"), APP_IDENTITY.userAgent.custom);
|
||||
assert.equal(headers.get("session_id"), "exact-native-chat-session");
|
||||
assert.equal(headers.get("x-client-request-id"), "exact-native-chat-session");
|
||||
assert.equal(headers.get("x-native-observer"), "retained"); assert.equal(headers.get("x-suppressed"), null);
|
||||
assert.equal(payload.prompt_cache_key, "exact-native-chat-session"); assert.equal(payload.model, model.id); assert.equal(payload.metadata, undefined);
|
||||
assert.equal(events.filter((e) => e.type === "done" || e.type === "error").length, 1);
|
||||
const failed = engine.streamFn(engine.model, context(), { onProviderStreamEvent: async () => { throw new Error("private observer detail"); } });
|
||||
const failedEvents = []; for await (const event of failed) failedEvents.push(event);
|
||||
assert.equal(failedEvents.filter((e) => e.type === "error").length, 1); assert.equal(failedEvents.filter((e) => e.type === "done").length, 0);
|
||||
assert(!(await failed.result()).errorMessage?.includes("private observer detail"));
|
||||
});
|
||||
|
||||
test("case-insensitive Authorization overrides including null are refused before native fetch", async () => {
|
||||
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id }, false);
|
||||
const before = requests.length;
|
||||
for (const headers of [{ Authorization: "Bearer wrong" }, { authorization: null }, { AUTHORIZATION: "" }] as Record<string, string | null>[]) {
|
||||
assert.equal((await engine.streamFn(engine.model, context(), { headers }).result()).stopReason, "error");
|
||||
}
|
||||
assert.equal(requests.length, before);
|
||||
});
|
||||
|
||||
test("full secure-storage invalidation aborts foreground and background controllers and clears capabilities", () => {
|
||||
const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true);
|
||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 2, revision: 3 }, status: { ...status, available: false }, invalidate: "all" });
|
||||
assert(foreground.controller.signal.aborted); assert(background.controller.signal.aborted); assert.equal(chatGPTModels().length, 0);
|
||||
foreground.release(); background.release();
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { act } from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
import assert from "node:assert/strict";
|
||||
import { test, afterEach } from "node:test";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { useModelCatalog, catalogLimits } from "../components/model-picker.tsx";
|
||||
import { useZeroKeys, useStrongKeyless } from "../components/setup-cta.tsx";
|
||||
import { notifySettingsSaved } from "../components/settings-signal.ts";
|
||||
import { SidebarProvider } from "../components/sidebar.tsx";
|
||||
import { ChatGPTConnection } from "../components/settings/chatgpt-connection.tsx";
|
||||
import { GraphCostControl } from "../components/graph-view.tsx";
|
||||
import { graphCostSummary } from "../src/core/cost-summary.ts";
|
||||
import type { ModelCatalogResponse, ChatGPTStatus } from "../src/core/index.ts";
|
||||
const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } });
|
||||
const originalFetch = globalThis.fetch;
|
||||
let root: Root | undefined, host: HTMLDivElement | undefined;
|
||||
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
|
||||
function body(id: string, ready = true): ModelCatalogResponse {
|
||||
return { models: ready ? [{ id, name: id, provider: "chatgpt", input: ["text"], contextWindow: 8192, maxTokens: 1024, reasoning: false, supportedLevels: ["off"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] : [], providers: [{ id: "chatgpt", envVar: "", keyPresent: ready }], slots: { strong: { model: { provider: "chatgpt", id: "explicit-pin" } }, cheap: null, vision: null }, defaults: {} } as ModelCatalogResponse;
|
||||
}
|
||||
let snapshots: Record<string, { catalog?: ModelCatalogResponse; zero: boolean; strong: boolean }> = {};
|
||||
function Reader({ id }: { id: string }) {
|
||||
const { catalog } = useModelCatalog();
|
||||
snapshots[id] = { catalog, zero: useZeroKeys(), strong: useStrongKeyless() };
|
||||
return <span data-testid={id}>{catalog ? [...catalogLimits(catalog).keys()].join(",") : "unavailable"}</span>;
|
||||
}
|
||||
async function mount(children: React.ReactNode) { host = document.createElement("div"); document.body.append(host); root = createRoot(host); await act(async () => root!.render(children)); }
|
||||
afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; globalThis.fetch = originalFetch; delete window.lh; snapshots = {}; });
|
||||
|
||||
test("shared ChatGPT-only readiness admits without API keys; connection invalidation clears every reader and fences old success/error", async () => {
|
||||
const requests: ReturnType<typeof deferred<Response>>[] = [];
|
||||
globalThis.fetch = async (input) => { assert.equal(input, "/api/models"); const request = deferred<Response>(); requests.push(request); return request.promise; };
|
||||
await mount(<><Reader id="first" /><Reader id="second" /></>);
|
||||
assert.equal(requests.length, 1);
|
||||
await act(async () => requests[0]!.resolve(Response.json(body("A"))));
|
||||
for (const value of Object.values(snapshots)) { assert.equal(value.zero, false); assert.equal(value.strong, false); assert.equal(value.catalog?.models[0]?.id, "A"); }
|
||||
await act(async () => notifySettingsSaved("models"));
|
||||
assert.equal(requests.length, 2);
|
||||
await act(async () => notifySettingsSaved("keys"));
|
||||
assert.equal(requests.length, 3);
|
||||
for (const value of Object.values(snapshots)) assert.equal(value.catalog, undefined);
|
||||
await act(async () => requests[2]!.resolve(Response.json(body("B", false))));
|
||||
await act(async () => requests[1]!.resolve(Response.json(body("stale-A"))));
|
||||
for (const value of Object.values(snapshots)) { assert.equal(value.catalog?.models.length, 0); assert.equal(value.zero, true); assert.equal(value.strong, true); }
|
||||
assert(!document.body.textContent?.includes("chatgpt:A"));
|
||||
});
|
||||
|
||||
test("plan plus unknown graph rows keep the existing unknown suffix without fabricating API dollars", async () => {
|
||||
const step = { inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 };
|
||||
const plan = { model: "chatgpt:gpt-5", steps: [step] };
|
||||
const unknown = { model: "unknown:missing", steps: [step] };
|
||||
const costs = graphCostSummary({ chats: [{ title: "Scratch", extractions: [{ startedAt: 1, at: "2026-10-24T00:00:00Z", durationMs: 1, stages: { extractor: plan, auditor: unknown } }, { startedAt: 2, at: "2026-10-24T00:00:00Z", durationMs: 1, stages: { extractor: unknown, auditor: plan } }] }], sources: [] });
|
||||
await mount(<GraphCostControl costs={costs} />);
|
||||
await act(async () => { document.querySelector("button")!.click(); });
|
||||
const recent = [...document.querySelectorAll(".border-t .shrink-0.tabular-nums")];
|
||||
assert.equal(recent.length, 2);
|
||||
for (const row of recent) assert.equal(row.textContent, "ChatGPT plan +1 unpriced", "both recent two-stage runs disclose their unknown stage");
|
||||
assert.equal((document.body.textContent?.match(/\+1 unpriced/g) ?? []).length, 4, "both mixed stage rows also retain their unknown suffix");
|
||||
assert(!document.body.textContent?.includes("$0.00"));
|
||||
});
|
||||
|
||||
test("root-lifetime safe bridge publishes with Connections unmounted; commands/panel pushes do not duplicate global signals", async () => {
|
||||
let pushes: (() => void)[] = [], subscriptions = 0, unsubscriptions = 0, fetches = 0;
|
||||
const safe: ChatGPTStatus = { available: true, pending: false, activeId: "a", accounts: [{ id: "a", email: "dummy@example.invalid", registration: "oaiapp_dummy", connected: true, background: false, planEnabled: true }], error: null };
|
||||
window.lh = { expandWindow() {}, chatgpt: { status: async () => safe, login: async () => safe, cancel: async () => safe, select: async () => safe, logout: async () => safe, consent: async () => safe, onChange: (cb) => { subscriptions++; pushes.push(cb); return () => { unsubscriptions++; pushes = pushes.filter((c) => c !== cb); }; } } };
|
||||
globalThis.fetch = async (input) => { assert.equal(input, "/api/models"); fetches++; return Response.json(body(`model-${fetches}`)); };
|
||||
await mount(<SidebarProvider><Reader id="root" /></SidebarProvider>);
|
||||
assert.equal(subscriptions, 1);
|
||||
const before = fetches;
|
||||
await act(async () => { for (const callback of pushes) callback(); });
|
||||
assert.equal(fetches, before + 1);
|
||||
await act(async () => root!.render(<SidebarProvider><Reader id="root" /><ChatGPTConnection /></SidebarProvider>));
|
||||
assert.equal(subscriptions, 2);
|
||||
const mounted = fetches;
|
||||
await act(async () => { for (const callback of pushes) callback(); });
|
||||
assert.equal(fetches, mounted + 1, "one global publisher even when panel also reads pushed status");
|
||||
await act(async () => root!.render(<SidebarProvider><Reader id="root" /></SidebarProvider>));
|
||||
assert.equal(unsubscriptions, 1);
|
||||
await act(async () => { for (const callback of pushes) callback(); });
|
||||
assert.equal(fetches, mounted + 2);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { callCost, sessionCost, turnCost, jobCost } from "../src/core/prices.ts";
|
||||
import { graphCostSummary } from "../src/core/cost-summary.ts";
|
||||
import type { TurnUsage } from "../src/core/contracts.ts";
|
||||
import { planCostLabel } from "../components/usage-format.ts";
|
||||
const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
|
||||
const turn = (model: string, steps = [step]): TurnUsage => ({ id: "scratch-turn", messageId: "scratch-message", at: "2026-10-19T00:00:00Z", model, steps });
|
||||
|
||||
test("subscription usage is neither API dollars nor an unpriced-model mistake, including empty/partial usage", () => {
|
||||
const plan = turn("chatgpt:gpt-5");
|
||||
assert.equal(callCost(step, plan.model), null);
|
||||
assert.equal(turnCost(turn(plan.model, [])), null);
|
||||
assert.equal(jobCost({ usage: { model: plan.model, ...step, steps: 1 } }), null);
|
||||
assert.deepEqual(sessionCost({ turns: [plan, turn(plan.model, [])] }), { cost: 0, unpriced: 0, subscription: 2 });
|
||||
const api = turn("openai:gpt-5");
|
||||
const mixed = sessionCost({ turns: [plan, api, turn("unknown:missing")] });
|
||||
assert.equal(mixed.cost, turnCost(api)); assert.equal(mixed.unpriced, 1); assert.equal(mixed.subscription, 1);
|
||||
assert.equal(planCostLabel(null, true), "ChatGPT plan");
|
||||
assert.match(planCostLabel(mixed.cost, true), /API \+ ChatGPT plan$/);
|
||||
});
|
||||
|
||||
test("graph extraction and audit preserve subscription identity separately from priced and unknown stages", () => {
|
||||
const base = { startedAt: 1, at: "2026-10-19T00:00:00Z", durationMs: 1, stages: { extractor: { model: "chatgpt:gpt-5", steps: [step] }, auditor: { model: "chatgpt:gpt-5", steps: [] } } };
|
||||
const plan = graphCostSummary({ chats: [{ title: "Scratch", extractions: [base] }], sources: [] });
|
||||
assert.equal(plan.unpricedRuns, 0); assert.equal(plan.subscriptionRuns, 2);
|
||||
assert.equal(plan.stages.extraction.unpricedRuns, 0); assert.equal(plan.stages.audit.unpricedRuns, 0);
|
||||
assert.equal(plan.runs[0]!.dollars, null);
|
||||
const mixed = graphCostSummary({ chats: [{ title: "Scratch", extractions: [{ ...base, stages: { ...base.stages, auditor: { model: "openai:gpt-5", steps: [step] } } }] }], sources: [] });
|
||||
assert.equal(mixed.subscriptionRuns, 1);
|
||||
assert.equal(mixed.totalDollars, callCost(step, "openai:gpt-5"));
|
||||
assert.equal(mixed.runs[0]!.subscriptionRuns, 1);
|
||||
assert.equal(mixed.unpricedRuns, 0);
|
||||
for (const reverse of [false, true]) {
|
||||
const stages = [{ model: "chatgpt:gpt-5", steps: [step] }, { model: "unknown:not-priced", steps: [step] }];
|
||||
if (reverse) stages.reverse();
|
||||
const unknown = graphCostSummary({ chats: [{ title: "Scratch", extractions: [{ ...base, stages: { extractor: stages[0]!, auditor: stages[1]! } }] }], sources: [] });
|
||||
assert.equal(unknown.unpricedRuns, 1); assert.equal(unknown.runs[0]!.dollars, null); assert.equal(unknown.subscriptionRuns, 1);
|
||||
assert.equal(unknown.stages.extraction.unpricedRuns + unknown.stages.audit.unpricedRuns, 1);
|
||||
}
|
||||
const zero = graphCostSummary({ chats: [{ title: "Scratch", extractions: [{ ...base, stages: { ...base.stages, auditor: { model: "openai:gpt-5", steps: [{ ...step, inputTokens: 0, outputTokens: 0 }] } } }] }], sources: [] });
|
||||
assert.equal(zero.runs[0]!.dollars, 0); assert.equal(zero.unpricedRuns, 0); assert.equal(zero.subscriptionRuns, 1);
|
||||
const capped = graphCostSummary({ recentCap: 0, chats: [{ title: "Scratch", extractions: [{ ...base, stages: { ...base.stages, auditor: { model: "unknown:model", steps: [step] } } }] }], sources: [] });
|
||||
assert.equal(capped.runs.length, 0); assert.equal(capped.unpricedRuns, 1);
|
||||
});
|
||||
@@ -74,6 +74,23 @@ const depsOf = (chatId: string, thresholdTokens: number): CompactionDeps => ({
|
||||
// ── the laws ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("the compaction service", () => {
|
||||
it("background eligibility skips both entrypoints without writes, but still awaits an existing structural run", async () => {
|
||||
const chatId = overThresholdChat("compact: consent off");
|
||||
const before = summarizeCalls;
|
||||
const denied = { ...depsOf(chatId, 40_000), canStart: () => false };
|
||||
await ensureCompacted(denied, chatId); kickCompaction(() => denied, chatId);
|
||||
assert.equal(summarizeCalls, before);
|
||||
assert.equal(activeCompactionOf(chatStore.getChat(chatId)!.messages), null);
|
||||
let release!: () => void, entered!: () => void;
|
||||
const gate = new Promise<void>((done) => { release = done; });
|
||||
const started = new Promise<void>((done) => { entered = done; });
|
||||
const allowed = { ...depsOf(chatId, 40_000), summarize: async (prompt: string) => { entered(); await gate; return depsOf(chatId, 40_000).summarize!(prompt, denied.engine); } };
|
||||
kickCompaction(() => allowed, chatId); await started;
|
||||
let finished = false;
|
||||
const waiter = ensureCompacted(denied, chatId).then(() => { finished = true; });
|
||||
await Promise.resolve(); assert.equal(finished, false);
|
||||
release(); await waiter; assert.equal(finished, true);
|
||||
});
|
||||
it("over-threshold record → marker appended, usage logged, both events emitted", async () => {
|
||||
const chatId = overThresholdChat("compact: basic");
|
||||
const events: string[] = [];
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { NextRequest } from "next/server";
|
||||
// Installed Next 16.3.2 retains the old test-helper export despite proxy.md's new name.
|
||||
import { unstable_doesMiddlewareMatch as unstable_doesProxyMatch } from "next/experimental/testing/server";
|
||||
import { desktopAPIAuthorized } from "../src/server/desktop-access.ts";
|
||||
import { proxy, config } from "../proxy.ts";
|
||||
import { browserServiceSpawnEnv } from "../src/agent/subagents/headless.ts";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { desktopAPIHeaders, HEADER } = require("../electron/desktop-api.cjs");
|
||||
const { trustedSender, chatGPTControl } = require("../electron/chatgpt-ipc.cjs");
|
||||
const secret = "a".repeat(64);
|
||||
const origin = "http://127.0.0.1:12345";
|
||||
|
||||
function fixture() {
|
||||
const mainFrame = { url: `${origin}/chat/new` };
|
||||
const contents = { id: 5, mainFrame, isDestroyed: () => false };
|
||||
const win = { webContents: contents, isDestroyed: () => false };
|
||||
const details = { url: `${origin}/api/settings`, webContentsId: contents.id, webContents: contents, frame: mainFrame, initiatorOrigin: origin, requestHeaders: { Accept: "application/json" } };
|
||||
return { mainFrame, contents, win, details };
|
||||
}
|
||||
|
||||
test("desktop capability injects only trusted app mainframe exact-origin API requests and strips every other case", () => {
|
||||
const { details, win, mainFrame, contents } = fixture();
|
||||
assert.equal(desktopAPIHeaders(details, origin, secret, [win])[HEADER], secret);
|
||||
for (const mutation of [
|
||||
{ url: "https://thirdparty.invalid/api/settings" },
|
||||
{ url: "http://127.0.0.1:12346/api/settings" },
|
||||
{ url: `${origin}/chat/new` },
|
||||
{ frame: { url: `${origin}/chat/new` } }, // forged frame identity
|
||||
{ frame: { url: "about:srcdoc", parent: mainFrame } },
|
||||
{ frame: null },
|
||||
{ webContentsId: 6 },
|
||||
{ webContents: { ...contents } },
|
||||
{ initiatorOrigin: "https://thirdparty.invalid" },
|
||||
{ initiatorOrigin: "null" },
|
||||
]) {
|
||||
const result = desktopAPIHeaders({ ...details, ...mutation, requestHeaders: { "X-LearnHarness-Desktop": secret } }, origin, secret, [win]);
|
||||
assert(!Object.keys(result).some((key) => key.toLowerCase() === HEADER), JSON.stringify(mutation));
|
||||
}
|
||||
assert.equal(desktopAPIHeaders(details, origin, secret, [])[HEADER], undefined, "other WebContents are not app windows");
|
||||
mainFrame.url = "https://thirdparty.invalid/after-navigation";
|
||||
assert.equal(desktopAPIHeaders(details, origin, secret, [win])[HEADER], undefined, "navigation revokes trust even for the same native frame");
|
||||
assert.equal(desktopAPIHeaders(details, origin, null, [win])[HEADER], undefined, "externally launched desktop dev receives no packaged capability");
|
||||
});
|
||||
|
||||
test("all methods fail closed for desktop missing/wrong secret; API-only matcher covers SSE/settings/spending", () => {
|
||||
assert(desktopAPIAuthorized(new Headers(), {}), "browser-only API-key mode unchanged");
|
||||
assert(!desktopAPIAuthorized(new Headers({ [HEADER]: secret }), { LH_DESKTOP_SERVER: "1" }), "missing server secret must not disable authorization");
|
||||
for (const value of [undefined, "", "wrong", secret + ", " + secret]) {
|
||||
assert(!desktopAPIAuthorized(new Headers(value === undefined ? {} : { [HEADER]: value }), { LH_DESKTOP_SERVER: "1", LH_DESKTOP_API_SECRET: secret }));
|
||||
}
|
||||
assert(desktopAPIAuthorized(new Headers({ [HEADER]: secret }), { LH_DESKTOP_SERVER: "1", LH_DESKTOP_API_SECRET: secret }));
|
||||
for (const pathname of ["/api/events", "/api/chat", "/api/settings/models", "/api/chats/scratch/markdown"]) assert(unstable_doesProxyMatch({ config, nextConfig: {}, url: origin + pathname }));
|
||||
assert(!unstable_doesProxyMatch({ config, nextConfig: {}, url: origin + "/chat/new" }));
|
||||
const originalMarker = process.env.LH_DESKTOP_SERVER;
|
||||
const originalSecret = process.env.LH_DESKTOP_API_SECRET;
|
||||
try {
|
||||
process.env.LH_DESKTOP_SERVER = "1";
|
||||
process.env.LH_DESKTOP_API_SECRET = secret;
|
||||
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) {
|
||||
assert.equal(proxy(new NextRequest(origin + "/api/settings", { method })).status, 403);
|
||||
assert.equal(proxy(new NextRequest(origin + "/api/settings", { method, headers: { [HEADER]: secret } })).headers.get("x-middleware-next"), "1");
|
||||
}
|
||||
} finally {
|
||||
if (originalMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = originalMarker;
|
||||
if (originalSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = originalSecret;
|
||||
}
|
||||
});
|
||||
|
||||
test("renderer controls have no lease or general token bridge, and require the trusted main frame", async () => {
|
||||
let leased = false;
|
||||
const connection = { lease: async () => { leased = true; return { accessToken: "scratch-private-token" }; } };
|
||||
assert.deepEqual(await chatGPTControl({ op: "lease", background: false }, connection), { error: "Unsupported ChatGPT control." });
|
||||
assert.equal(leased, false);
|
||||
const { contents, mainFrame, win } = fixture();
|
||||
const BrowserWindow = { fromWebContents: (value: unknown) => value === contents ? win : null };
|
||||
assert(trustedSender({ sender: contents, senderFrame: mainFrame }, origin, BrowserWindow));
|
||||
assert(!trustedSender({ sender: contents, senderFrame: { url: mainFrame.url } }, origin, BrowserWindow));
|
||||
mainFrame.url = "https://thirdparty.invalid/";
|
||||
assert(!trustedSender({ sender: contents, senderFrame: mainFrame }, origin, BrowserWindow));
|
||||
assert.equal(browserServiceSpawnEnv({ LH_DESKTOP_API_SECRET: secret, LH_DESKTOP_SERVER: "1" }).LH_DESKTOP_API_SECRET, undefined);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { describe, it } from "node:test";
|
||||
const require = createRequire(import.meta.url);
|
||||
const mainFile = fileURLToPath(new URL("../electron/main.cjs", import.meta.url));
|
||||
const mainSource = readFileSync(mainFile, "utf8");
|
||||
const mainRequire = createRequire(mainFile);
|
||||
|
||||
/** Decline the single-instance lock so normal boot never spawns a server.
|
||||
* Then call the REAL createWindow with a stub Electron API and inspect its
|
||||
@@ -27,7 +28,7 @@ function windowOptions(platform: string) {
|
||||
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
|
||||
};
|
||||
const context = {
|
||||
require: (id: string) => id === "electron" ? electron : require(id),
|
||||
require: (id: string) => id === "electron" ? electron : mainRequire(id),
|
||||
process: { platform, argv: [], env: {}, on() {} },
|
||||
__dirname: dirname(mainFile),
|
||||
};
|
||||
@@ -77,7 +78,7 @@ describe("desktop window chrome", () => {
|
||||
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
|
||||
assert.equal(bridge.platform, "win32");
|
||||
assert.deepEqual(Object.keys(bridge).sort(), [
|
||||
"expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform",
|
||||
"chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("SetupKeysBody — stage 1's self-contained first-key screen", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: null }),
|
||||
);
|
||||
assert.match(html, /Add a provider key\./);
|
||||
assert.match(html, /Or add an API key\./);
|
||||
assert.ok(html.includes("Choose your AI provider and paste its API key. You’ll pick your models next."));
|
||||
assert.doesNotMatch(html, /First, add|keys unlock|—/);
|
||||
assert.ok(html.includes('data-testid="setup-key-provider"'));
|
||||
|
||||
Reference in New Issue
Block a user