feat: enable subscription connections in web and dev
This commit is contained in:
@@ -12,7 +12,7 @@ const { GET } = await import("../app/api/models/route.ts");
|
||||
const { chatGPTRequest, chatGPTConnected, chatGPTModels } = await import("../src/agent/chatgpt-bridge.ts");
|
||||
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
|
||||
|
||||
test("Next-style connected IPC is not desktop transport and never delays keyed API catalogs", async () => {
|
||||
test("Next-style IPC is not desktop transport; web ownership works without delaying keyed catalogs", async () => {
|
||||
const send = Object.getOwnPropertyDescriptor(process, "send");
|
||||
const connected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
|
||||
@@ -35,7 +35,11 @@ test("Next-style connected IPC is not desktop transport and never delays keyed A
|
||||
assert.equal(catalog.providers.find((p: { id: string }) => p.id === "chatgpt").keyPresent, false);
|
||||
assert.equal(chatGPTConnected(), false);
|
||||
assert.deepEqual(chatGPTModels(), []);
|
||||
await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
|
||||
if (!mode && !capability) {
|
||||
const value = await chatGPTRequest<{ status: { available: boolean; accounts: unknown[] } }>("status");
|
||||
assert.equal(value.status.available, true);
|
||||
assert.deepEqual(value.status.accounts, []);
|
||||
} else await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
|
||||
assert.equal(sends, 0, "no desktop request/timer may be started over framework IPC");
|
||||
}
|
||||
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
|
||||
|
||||
@@ -15,7 +15,7 @@ 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
|
||||
* constructor options. No rewritten copy of the production logic. */
|
||||
function windowOptions(platform: string) {
|
||||
function windowOptions(platform: string, env: Record<string, string> = {}) {
|
||||
class FakeWindow {
|
||||
constructor(readonly options: unknown) {}
|
||||
webContents = { on() {}, setWindowOpenHandler() {} };
|
||||
@@ -23,13 +23,13 @@ function windowOptions(platform: string) {
|
||||
once() {}
|
||||
}
|
||||
const electron = {
|
||||
app: { setName() {}, requestSingleInstanceLock: () => false, quit() {} },
|
||||
app: { setName() {}, getPath: () => "/fixture/LearnHarness", setPath() {}, requestSingleInstanceLock: () => false, quit() {} },
|
||||
BrowserWindow: FakeWindow,
|
||||
session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } },
|
||||
};
|
||||
const context = {
|
||||
require: (id: string) => id === "electron" ? electron : mainRequire(id),
|
||||
process: { platform, argv: [], env: {}, on() {} },
|
||||
process: { platform, argv: [], env, on() {} },
|
||||
__dirname: dirname(mainFile),
|
||||
};
|
||||
runInNewContext(mainSource, context, { filename: mainFile });
|
||||
@@ -61,6 +61,24 @@ describe("desktop window chrome", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("only the external dev shell receives the web subscription transport marker", () => {
|
||||
assert.equal(windowOptions("darwin").webPreferences.additionalArguments, undefined);
|
||||
assert.deepEqual(windowOptions("darwin", { LH_DEV_URL: "http://localhost:3000" }).webPreferences.additionalArguments, ["--lh-web-subscriptions"]);
|
||||
});
|
||||
|
||||
it("dev preload routes opaque sign-in actions through its narrow external-browser channel", async () => {
|
||||
let bridge!: { subscriptionTransport: string; openSubscriptionBrowser(action: string): Promise<void> };
|
||||
const invoked: unknown[][] = [];
|
||||
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), {
|
||||
process: { platform: "darwin", argv: ["--lh-web-subscriptions"] },
|
||||
require: () => ({ contextBridge: { exposeInMainWorld: (_: string, value: typeof bridge) => { bridge = value; } }, ipcRenderer: { on() {}, invoke: async (...args: unknown[]) => { invoked.push(args); } } }),
|
||||
});
|
||||
assert.equal(bridge.subscriptionTransport, "web");
|
||||
const action = "/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001";
|
||||
await bridge.openSubscriptionBrowser(action);
|
||||
assert.deepEqual(invoked, [["lh:subscriptions:web-browser", action]]);
|
||||
});
|
||||
|
||||
it("preload exposes OS identity without exposing Node/Electron to the renderer", () => {
|
||||
let bridge: Record<string, unknown> = {};
|
||||
const context = {
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
|
||||
const file = (relative: string) => root + (windows ? "\\" : "/") + (windows ? relative.replaceAll("/", "\\") : relative);
|
||||
const ignored = picomatch(patterns, { contains: true, dot: true, windows });
|
||||
it(`excludes genuine data directories and secrets (${windows ? "Windows" : "POSIX"})`, () => {
|
||||
for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local"]) {
|
||||
for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local", "chatgpt-credentials.json", "github-copilot-credentials.json", "xai-credentials.json", "chatgpt-credentials.json.fixture.tmp", "github-copilot-credentials.json.fixture.tmp", "xai-credentials.json.fixture.tmp"]) {
|
||||
assert.equal(ignored(file(relative)), true, relative);
|
||||
}
|
||||
// The explicit includes remain necessary: this raw ignore still matches SDK data.
|
||||
@@ -37,7 +37,7 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
|
||||
assert.equal(ignore(`${root}/node_modules/gcp-metadata/build/src/index.js`), false);
|
||||
});
|
||||
it("preserves all secret exclusions exactly", () => {
|
||||
assert.deepEqual(patterns, ["**/data/**/*", "auth.json", ".env*"]);
|
||||
assert.deepEqual(patterns, ["**/data/**/*", "auth.json", "chatgpt-credentials.json*", "github-copilot-credentials.json*", "xai-credentials.json*", ".env*"]);
|
||||
});
|
||||
it("retains exactly the narrow native-Turbopack closure workaround and existing runtime includes", () => {
|
||||
assert.deepEqual(nextConfig.outputFileTracingIncludes!["*"], [
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { afterEach } from "node:test";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { createNotifyHub } from "../components/server-events.tsx";
|
||||
import { createWebChatGPTBridge, createWebNativeBridge } from "../components/settings/subscription-client.ts";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.document });
|
||||
const originalFetch = globalThis.fetch;
|
||||
const action = "/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001";
|
||||
afterEach(() => { globalThis.fetch = originalFetch; delete window.lh; });
|
||||
|
||||
test("web ChatGPT reserves a new tab before async login, navigates only to an opaque action and receives push/resync", async () => {
|
||||
const calls: string[] = [];
|
||||
const tab = { opener: {}, closed: false, location: { href: "about:blank" }, close() { this.closed = true; } };
|
||||
window.open = (() => { calls.push("tab"); return tab; }) as unknown as typeof window.open;
|
||||
const safe = { available: true, pending: true, allowBackgroundUse: true, activeId: null, accounts: [], error: null };
|
||||
globalThis.fetch = async (url, options) => {
|
||||
assert.equal(url, "/api/subscriptions"); assert.equal(new Headers(options?.headers).get("x-learnharness-subscriptions"), "1");
|
||||
if (!options?.method) return Response.json({ chatgpt: safe, native: [] });
|
||||
calls.push("login"); assert.equal(tab.opener, null);
|
||||
assert.deepEqual(JSON.parse(String(options.body)), { providerId: "chatgpt", op: "login" });
|
||||
return Response.json({ value: safe, browserAction: action });
|
||||
};
|
||||
const hub = createNotifyHub(), bridge = createWebChatGPTBridge(hub);
|
||||
assert.deepEqual(await bridge.login(), safe); assert.deepEqual(calls, ["tab", "login"]);
|
||||
assert.equal(tab.location.href, action); assert(!tab.closed);
|
||||
let updates = 0; const off = bridge.onChange(() => updates++);
|
||||
hub.dispatch("subscriptions-changed", "{}"); hub.fireOpen(); assert.equal(updates, 2);
|
||||
off(); hub.fireOpen(); assert.equal(updates, 2);
|
||||
assert.deepEqual(await bridge.status(), safe);
|
||||
});
|
||||
|
||||
test("blocked popups do not start OAuth; failed login closes the reserved tab", async () => {
|
||||
const bridge = createWebChatGPTBridge(null);
|
||||
window.open = () => null;
|
||||
globalThis.fetch = async () => assert.fail("blocked popup must not start sign-in");
|
||||
await assert.rejects(bridge.login(), /Allow a sign-in tab/);
|
||||
const tab = { opener: {}, closed: false, close() { this.closed = true; } };
|
||||
window.open = (() => tab) as unknown as typeof window.open;
|
||||
globalThis.fetch = async () => new Response(null, { status: 400 });
|
||||
await assert.rejects(bridge.login()); assert(tab.closed);
|
||||
});
|
||||
|
||||
test("native web login retains device prompts; only the explicit Open browser action opens a tab", async () => {
|
||||
const requests: Record<string, unknown>[] = []; let tabs = 0;
|
||||
const tab = { opener: {}, closed: false, location: { href: "" }, close() {} };
|
||||
window.open = (() => { tabs++; return tab; }) as unknown as typeof window.open;
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
const command = JSON.parse(String(options?.body)); requests.push(command);
|
||||
return Response.json(command.op === "open" ? { browserAction: action } : {});
|
||||
};
|
||||
const bridge = createWebNativeBridge(null);
|
||||
await bridge.login("github-copilot", false); assert.equal(tabs, 0);
|
||||
await bridge.respond("github-copilot", "attempt", "prompt", "");
|
||||
await bridge.open("github-copilot", "attempt", "action"); assert.equal(tabs, 1); assert.equal(tab.location.href, action);
|
||||
assert.deepEqual(requests, [{ providerId: "github-copilot", op: "login", enabled: false }, { providerId: "github-copilot", op: "respond", attemptId: "attempt", promptId: "prompt", value: "" }, { providerId: "github-copilot", op: "open", attemptId: "attempt", actionId: "action" }]);
|
||||
});
|
||||
|
||||
test("Electron dev uses its narrow external-browser bridge, without attempting a renderer popup", async () => {
|
||||
const opened: string[] = [];
|
||||
window.lh = { expandWindow() {}, subscriptionTransport: "web", openSubscriptionBrowser: async (target) => { opened.push(target); } };
|
||||
window.open = () => assert.fail("Electron denies renderer popups");
|
||||
globalThis.fetch = async () => Response.json({ value: { pending: true }, browserAction: action });
|
||||
await createWebChatGPTBridge(null).login(); assert.deepEqual(opened, [action]);
|
||||
});
|
||||
|
||||
test("web notices identify the server machine and same-machine ChatGPT sign-in; desktop copy stays unchanged", async () => {
|
||||
const { CHATGPT_NOTICE, chatGPTNotice } = await import("../components/settings/chatgpt-connection.tsx");
|
||||
const { subscriptionNotice, subscriptionDisconnectCopy } = await import("../components/settings/subscription-connections.tsx");
|
||||
assert.equal(chatGPTNotice(), CHATGPT_NOTICE); assert(CHATGPT_NOTICE.includes("on this device"));
|
||||
assert(chatGPTNotice(true).includes("on the machine running LearnHarness"));
|
||||
assert(chatGPTNotice(true).endsWith("Complete ChatGPT sign-in in a browser on that same machine."));
|
||||
for (const label of ["GitHub Copilot", "Grok"]) {
|
||||
assert(subscriptionNotice(label).includes("stored locally")); assert(subscriptionDisconnectCopy(label).includes("from this device"));
|
||||
assert(subscriptionNotice(label, true).includes("on the machine running LearnHarness"));
|
||||
assert(subscriptionDisconnectCopy(label, true).includes("from the machine running LearnHarness"));
|
||||
}
|
||||
});
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { openWebSubscriptionBrowser } = require("../electron/web-subscription-browser.cjs");
|
||||
test("desktop-dev opener accepts only local action IDs, does not follow redirects or use browser cookies", async () => {
|
||||
const opened: string[] = [];
|
||||
const options = {
|
||||
openExternal: async (url: string) => { opened.push(url); },
|
||||
fetch: async (url: URL, init: RequestInit) => {
|
||||
assert.equal(url.origin, "http://localhost:3000"); assert.equal(url.pathname, "/api/subscriptions/browser");
|
||||
assert.equal(init.method, "POST"); assert.equal(init.redirect, "manual"); assert.equal(init.credentials, "omit");
|
||||
assert.equal(new Headers(init.headers).get("origin"), "http://localhost:3000");
|
||||
return new Response(null, { status: 303, headers: { Location: "https://auth.openai.com/authorize?state=fixture" } });
|
||||
},
|
||||
};
|
||||
await openWebSubscriptionBrowser(action, "http://localhost:3000", options); assert.equal(opened.length, 1);
|
||||
for (const target of ["https://unrelated.invalid", "//unrelated.invalid", "/api/keys", `${action}&target=evil`]) await assert.rejects(openWebSubscriptionBrowser(target, "http://localhost:3000", options), /Invalid sign-in action/);
|
||||
for (const target of ["file:///private/fixture", "http://unrelated.invalid", "https://user:pass@unrelated.invalid"]) await assert.rejects(openWebSubscriptionBrowser(action, "http://localhost:3000", { ...options, fetch: async () => new Response(null, { status: 303, headers: { Location: target } }) }));
|
||||
assert.equal(opened.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Exercise the real curator against synthetic payloads, never the repo payload. */
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, copyFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
for (const name of ["chatgpt-credentials.json", "github-copilot-credentials.json", "xai-credentials.json"]) {
|
||||
for (const secret of [name, `${name}.fixture.tmp`]) test(`payload curator refuses web credentials: ${secret}`, () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "lh-sub-payload-"));
|
||||
try {
|
||||
mkdirSync(path.join(root, "scripts")); mkdirSync(path.join(root, ".next", "standalone"), { recursive: true }); mkdirSync(path.join(root, ".next", "static"));
|
||||
copyFileSync(new URL("../scripts/build-server.mjs", import.meta.url), path.join(root, "scripts", "build-server.mjs"));
|
||||
writeFileSync(path.join(root, ".next", "standalone", "server.js"), "// fixture");
|
||||
writeFileSync(path.join(root, ".next", "standalone", secret), "fixture-not-real-credentials", { mode: 0o600 });
|
||||
const result = spawnSync(process.execPath, [path.join(root, "scripts", "build-server.mjs")], { encoding: "utf8", env: process.env });
|
||||
assert.equal(result.status, 1); assert(result.stderr.includes(`REFUSING: standalone contains ${secret}`));
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after } from "node:test";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createRequire } from "node:module";
|
||||
import { createModels, type OAuthCredential, type ProviderAuthInteraction } from "@earendil-works/pi-ai";
|
||||
import { githubCopilotProvider } from "@earendil-works/pi-ai/providers/github-copilot";
|
||||
import { xaiProvider } from "@earendil-works/pi-ai/providers/xai";
|
||||
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-sub-"));
|
||||
process.env.LH_AUTH_FILE = path.join(directory, "auth.json");
|
||||
process.env.LH_DATA_DIR = path.join(directory, "data");
|
||||
delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET;
|
||||
await fs.writeFile(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 });
|
||||
const require = createRequire(import.meta.url);
|
||||
const { SCOPES } = require("../electron/chatgpt.cjs");
|
||||
const { createWebSubscriptionOwner, webSubscriptionOwner, webSubscriptionsEnabled } = await import("../src/server/web-subscriptions.ts");
|
||||
const { subscriptionControlsResponse, subscriptionBrowserResponse } = await import("../src/server/web-subscription-http.ts");
|
||||
const chat = await import("../src/agent/chatgpt-bridge.ts");
|
||||
const native = await import("../src/agent/subscription-bridge.ts");
|
||||
const { piModels } = await import("../src/agent/models.ts");
|
||||
const { refreshConnectionCatalog } = await import("../src/server/model-catalog.ts");
|
||||
const model = piModels().getModels("openai").find((entry) => entry.api === "openai-responses")!;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input) => {
|
||||
assert.equal(String(input), "https://api.openai.com/v1/models", "unexpected network request forbidden");
|
||||
return Response.json({ models: [{ slug: model.id, display_name: "Fixture ChatGPT", visibility: "list" }] });
|
||||
};
|
||||
const copilot = githubCopilotProvider().getModels()[0]!;
|
||||
const credentials = (id: string): OAuthCredential => ({ type: "oauth", access: id === "xai" ? "fixture-xai-access" : "tid=fixture;proxy-ep=proxy.individual.githubcopilot.com;", refresh: "fixture-refresh", expires: Date.now() + 3600000, ...(id === "github-copilot" ? { availableModelIds: [copilot.id] } : {}) });
|
||||
await fs.writeFile(path.join(directory, "chatgpt-credentials.json"), JSON.stringify({ version: 1, hostId: "fixture-host", allowBackgroundUse: true, activeId: "fixture-account", registrations: [{ id: "fixture-account", clientId: "oaiapp_fixture", issuer: "https://auth.openai.com", subject: "fixture-subject", email: "fixture@example.invalid", tokens: { accessToken: "fixture-chatgpt-access", refreshToken: "fixture-chatgpt-refresh", idToken: "fixture-id-token", scopes: SCOPES, expiresAt: Date.now() + 3600000 } }] }), { mode: 0o600 });
|
||||
for (const id of ["github-copilot", "xai"]) await fs.writeFile(path.join(directory, `${id}-credentials.json`), JSON.stringify({ version: 1, allowBackgroundUse: true, connectionId: "00000000-0000-4000-8000-000000000001", credential: credentials(id) }), { mode: 0o600 });
|
||||
function request(command?: Record<string, unknown>, origin = "http://192.168.1.10:3000") {
|
||||
return new Request(`${origin}/api/subscriptions`, { method: command ? "POST" : "GET", headers: { host: new URL(origin).host, origin, "sec-fetch-site": "same-origin", "x-learnharness-subscriptions": "1", "content-type": "application/json" }, ...(command ? { body: JSON.stringify(command) } : {}) });
|
||||
}
|
||||
after(async () => { webSubscriptionOwner().close(); globalThis.fetch = originalFetch; await fs.rm(directory, { recursive: true, force: true }); });
|
||||
|
||||
test("ordinary web restores configured sibling credentials, private runtime leases work for LAN-owned requests", async () => {
|
||||
await refreshConnectionCatalog();
|
||||
assert(chat.chatGPTConnected()); assert(native.nativeSubscriptionConnected("github-copilot")); assert(native.nativeSubscriptionConnected("xai"));
|
||||
assert.equal((await chat.chatGPTLease(chat.chatGPTScope(model.id), false)).accessToken, "fixture-chatgpt-access");
|
||||
const xaiModel = native.nativeSubscriptionModels().find((entry) => entry.provider === "xai-subscription")!;
|
||||
assert.equal((await native.subscriptionLease(native.subscriptionScope("xai", xaiModel.id), false)).apiKey, "fixture-xai-access");
|
||||
const response = await subscriptionControlsResponse(request());
|
||||
assert.equal(response.status, 200); assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
const publicState = await response.text();
|
||||
for (const secret of ["fixture-chatgpt-access", "fixture-chatgpt-refresh", "fixture-id-token", "fixture-xai-access", "fixture-refresh"]) assert(!publicState.includes(secret));
|
||||
assert(publicState.includes("fixture@example.invalid"));
|
||||
});
|
||||
|
||||
test("web background OFF immediately aborts background streams, retains foreground and rejects private background leases", async () => {
|
||||
const foreground = chat.chatGPTAbortController(false), background = chat.chatGPTAbortController(true);
|
||||
const nativeForeground = native.subscriptionAbortController("xai", false), nativeBackground = native.subscriptionAbortController("xai", true);
|
||||
try {
|
||||
assert.equal((await subscriptionControlsResponse(request({ providerId: "chatgpt", op: "consent", enabled: false }))).status, 200);
|
||||
assert(background.controller.signal.aborted); assert(!foreground.controller.signal.aborted);
|
||||
const scope = chat.chatGPTScope(model.id); await chat.chatGPTLease(scope, false); await assert.rejects(chat.chatGPTLease(scope, true));
|
||||
assert.equal((await subscriptionControlsResponse(request({ providerId: "xai", op: "consent", enabled: false }))).status, 200);
|
||||
assert(nativeBackground.controller.signal.aborted); assert(!nativeForeground.controller.signal.aborted);
|
||||
const xaiModel = native.nativeSubscriptionModels().find((entry) => entry.provider === "xai-subscription")!;
|
||||
const nativeScope = native.subscriptionScope("xai", xaiModel.id); await native.subscriptionLease(nativeScope, false); await assert.rejects(native.subscriptionLease(nativeScope, true));
|
||||
await subscriptionControlsResponse(request({ providerId: "xai", op: "disconnect" }));
|
||||
assert(nativeForeground.controller.signal.aborted); assert(!native.nativeSubscriptionConnected("xai"));
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(path.join(directory, "xai-credentials.json"), "utf8")), { version: 1, allowBackgroundUse: false });
|
||||
} finally { foreground.release(); background.release(); nativeForeground.release(); nativeBackground.release(); }
|
||||
});
|
||||
|
||||
test("HTTP controls reject cross-origin, simple-form and private credential operations; desktop never falls back", async () => {
|
||||
const forged = request({ providerId: "chatgpt", op: "consent", enabled: true }); forged.headers.set("origin", "https://unrelated.invalid");
|
||||
assert.equal((await subscriptionControlsResponse(forged)).status, 403);
|
||||
const noHeader = request(); noHeader.headers.delete("x-learnharness-subscriptions");
|
||||
assert.equal((await subscriptionControlsResponse(noHeader)).status, 403);
|
||||
const noOrigin = request({ providerId: "chatgpt", op: "cancel" }); noOrigin.headers.delete("origin");
|
||||
assert.equal((await subscriptionControlsResponse(noOrigin)).status, 403);
|
||||
for (const op of ["lease", "models", "snapshot", "token", "openExternal"]) assert.equal((await subscriptionControlsResponse(request({ providerId: "chatgpt", op }))).status, 400);
|
||||
const huge = request({ providerId: "chatgpt", op: "cancel", padding: "x".repeat(16384) });
|
||||
assert.equal((await subscriptionControlsResponse(huge)).status, 413);
|
||||
const browser = new Request("http://localhost:3000/api/subscriptions/browser?id=00000000-0000-4000-8000-000000000001");
|
||||
assert.equal((await subscriptionBrowserResponse(browser)).status, 403);
|
||||
for (const [marker, secret] of [["1", undefined], [undefined, "fixture-capability"]]) {
|
||||
if (marker) process.env.LH_DESKTOP_SERVER = marker; else delete process.env.LH_DESKTOP_SERVER;
|
||||
if (secret) process.env.LH_DESKTOP_API_SECRET = secret; else delete process.env.LH_DESKTOP_API_SECRET;
|
||||
assert(!webSubscriptionsEnabled());
|
||||
assert.equal((await subscriptionControlsResponse(request())).status, 404);
|
||||
await assert.rejects(chat.chatGPTRequest("status"), /desktop app/);
|
||||
}
|
||||
delete process.env.LH_DESKTOP_SERVER; delete process.env.LH_DESKTOP_API_SECRET;
|
||||
});
|
||||
|
||||
function fakeSDK(login: (id: string, interaction: ProviderAuthInteraction) => Promise<OAuthCredential>) {
|
||||
const factory = (id: string) => {
|
||||
const provider = id === "xai" ? xaiProvider() : githubCopilotProvider();
|
||||
return { ...provider, auth: { oauth: { ...provider.auth.oauth!, login: (interaction: ProviderAuthInteraction) => login(id, interaction), refresh: async () => credentials(id) } } };
|
||||
};
|
||||
return { createModels, githubCopilotProvider: () => factory("github-copilot"), xaiProvider: () => factory("xai") };
|
||||
}
|
||||
test("device browser actions preserve complete URLs privately, are origin-bound, one-use and cancellation-bound", async () => {
|
||||
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-action-"));
|
||||
let complete!: (credential: OAuthCredential) => void;
|
||||
const pending = new Promise<OAuthCredential>((resolve) => { complete = resolve; });
|
||||
let notified!: () => void;
|
||||
const notification = new Promise<void>((resolve) => { notified = resolve; });
|
||||
const owner = createWebSubscriptionOwner({ directory: scratch, sdk: fakeSDK(async (_id, interaction) => { interaction.notify({ type: "device_code", userCode: "PUBLIC", verificationUri: "https://auth.x.ai/device?user_code=PUBLIC" }); notified(); return pending; }) });
|
||||
try {
|
||||
const login = owner.control("xai", { op: "login" }, "http://localhost:3000"); await notification;
|
||||
const interaction = (await owner.native.status()).find((entry: { providerId: string }) => entry.providerId === "xai")!.interaction;
|
||||
const command = { op: "open", attemptId: interaction.attemptId, actionId: interaction.browser.id };
|
||||
const opened = await owner.control("xai", command, "http://localhost:3000");
|
||||
assert(opened.browserAction); assert(!JSON.stringify(opened).includes("auth.x.ai"));
|
||||
const id = new URL(opened.browserAction, "http://localhost:3000").searchParams.get("id")!;
|
||||
assert.equal(await owner.takeBrowserAction(id, "http://other.invalid"), undefined);
|
||||
assert.equal(await owner.takeBrowserAction(id, "http://localhost:3000"), "https://auth.x.ai/device?user_code=PUBLIC");
|
||||
assert.equal(await owner.takeBrowserAction(id, "http://localhost:3000"), undefined);
|
||||
const stale = await owner.control("xai", command, "http://localhost:3000");
|
||||
await owner.control("xai", { op: "cancel" }, "http://localhost:3000");
|
||||
complete(credentials("xai")); await login;
|
||||
assert.equal(await owner.takeBrowserAction(new URL(stale.browserAction!, "http://localhost:3000").searchParams.get("id")!, "http://localhost:3000"), undefined);
|
||||
} finally { owner.close(); await fs.rm(scratch, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("web ChatGPT login supplies only an opaque browser action; cancel/replacement invalidates earlier launch", async () => {
|
||||
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-chat-action-"));
|
||||
const owner = createWebSubscriptionOwner({ directory: scratch, sdk: fakeSDK(async (id) => credentials(id)) });
|
||||
try {
|
||||
const first = await owner.control("chatgpt", { op: "login" }, "http://localhost:3000");
|
||||
assert(first.browserAction); assert(!JSON.stringify(first).includes("auth.openai.com"));
|
||||
await owner.control("chatgpt", { op: "cancel" }, "http://localhost:3000");
|
||||
const second = await owner.control("chatgpt", { op: "login" }, "http://localhost:3000");
|
||||
assert(second.browserAction);
|
||||
const firstId = new URL(first.browserAction, "http://localhost:3000").searchParams.get("id")!;
|
||||
assert.equal(await owner.takeBrowserAction(firstId, "http://localhost:3000"), undefined);
|
||||
const secondId = new URL(second.browserAction, "http://localhost:3000").searchParams.get("id")!;
|
||||
const target = new URL((await owner.takeBrowserAction(secondId, "http://localhost:3000"))!);
|
||||
assert.equal(target.origin, "https://auth.openai.com");
|
||||
const callback = new URL(target.searchParams.get("redirect_uri")!);
|
||||
assert.equal(callback.hostname, "127.0.0.1"); assert.equal(callback.pathname, "/auth/callback");
|
||||
assert(target.searchParams.has("state")); assert(target.searchParams.has("code_challenge"));
|
||||
} finally { owner.close(); await fs.rm(scratch, { recursive: true, force: true }); }
|
||||
});
|
||||
Reference in New Issue
Block a user