Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions apps/desktop/src/app/CodexAuthCallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Tests exercise the real native loopback listener without an OpenAI account.
import * as NodeHttp from "node:http";
import { describe, expect, it } from "vite-plus/test";
import { codexAuthDeliveryUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff";
import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
import { receiveCodexAuthCallback, cancelCodexAuthCallback } from "./CodexAuthCallback.ts";

async function freePort() {
const server = NodeHttp.createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("address");
await new Promise<void>((resolve) => server.close(() => resolve()));
return address.port;
}
function request(port: number, state = "a".repeat(43)) {
const url = new URL("https://auth.openai.com/api/accounts/authorize");
url.search = new URLSearchParams({
client_id: "dynamic_agent_client",
response_type: "code",
redirect_uri: `http://127.0.0.1:${port}/auth/callback`,
state,
code_challenge_method: "S256",
code_challenge: "b".repeat(43),
}).toString();
return url.toString();
}
function callback(authorizationUrl: string) {
const request = new URL(authorizationUrl);
const url = new URL(request.searchParams.get("redirect_uri")!);
url.search = new URLSearchParams({
state: request.searchParams.get("state")!,
code: "test-code",
client_id: "oaiapp_test",
}).toString();
return url.toString();
}

describe("desktop Codex callback helper", () => {
it("binds before opening sign-in, ignores a foreign response, and returns only the code callback", async () => {
const authorizationUrl = request(await freePort());
const expected = callback(authorizationUrl);
const received = await receiveCodexAuthCallback(authorizationUrl, async () => {
const invalid = new URL(expected);
invalid.searchParams.set("state", "foreign");
expect((await fetch(invalid)).status).toBe(400);
const response = await fetch(expected);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.text()).not.toContain("test-code");
return true;
});
expect(received).toBe(expected);
});
it("returns hosted web to the exact instance and environment without putting the code in its query", async () => {
const authorizationUrl = request(await freePort());
const expected = callback(authorizationUrl);
const input = {
authorizationUrl,
returnUrl: "https://app.t3.codes/settings/providers?environmentId=remote-one&instanceId=work",
environmentId: EnvironmentId.make("remote-one"),
instanceId: ProviderInstanceId.make("work"),
flowId: "flow-one",
};
await receiveCodexAuthCallback(
authorizationUrl,
async () => {
const response = await fetch(expected, { redirect: "manual" });
expect(response.status).toBe(303);
const delivery = response.headers.get("location")!;
expect(new URL(delivery).searchParams.has("code")).toBe(false);
expect(readCodexAuthDelivery(delivery)?.callbackUrl).toBe(expected);
expect(readCodexAuthDelivery(delivery)?.returnUrl).toBe(input.returnUrl);
return true;
},
(url) => codexAuthDeliveryUrl(input, url),
);
});
it("cancels and releases its listener so exact-port reauthorization can run again", async () => {
const authorizationUrl = request(await freePort());
await expect(
receiveCodexAuthCallback(authorizationUrl, async () => {
cancelCodexAuthCallback(authorizationUrl);
return true;
}),
).rejects.toThrow("cancelled");
expect(
await receiveCodexAuthCallback(authorizationUrl, async () => {
await fetch(callback(authorizationUrl));
return true;
}),
).toBe(callback(authorizationUrl));
});
it("allows two accounts to complete independently", async () => {
const a = request(await freePort(), "a".repeat(43));
const b = request(await freePort(), "c".repeat(43));
const openedA = Promise.withResolvers<void>();
const openedB = Promise.withResolvers<void>();
const receiveA = receiveCodexAuthCallback(a, async () => {
openedA.resolve();
await openedB.promise;
await fetch(callback(a));
return true;
});
const receiveB = receiveCodexAuthCallback(b, async () => {
openedB.resolve();
await openedA.promise;
await fetch(callback(b));
return true;
});
expect(await Promise.all([receiveA, receiveB])).toEqual([callback(a), callback(b)]);
});
});
5 changes: 5 additions & 0 deletions apps/desktop/src/app/CodexAuthCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
CodexAuthCallbackError,
cancelCodexAuthCallback,
receiveCodexAuthCallback,
} from "@t3tools/shared/codexAuthCallback";
148 changes: 146 additions & 2 deletions apps/desktop/src/app/DesktopClerk.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Hosted handoff test uses a real localhost listener without an OpenAI account.
import * as NodeHttp from "node:http";
import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff";
import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
import { HostProcessArguments } from "@t3tools/shared/hostProcess";
import { assert, describe, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
Expand All @@ -22,14 +27,24 @@ vi.mock("@clerk/electron/storage", () => ({
storage: storageMock,
}));

import * as Option from "effect/Option";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronShell from "../electron/ElectronShell.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";

const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => {
const makeDesktopClerkLayer = (
isDevelopment = true,
events: string[] = [],
shell: ElectronShell.ElectronShell["Service"] = {
openExternal: () => Effect.succeed(true),
openSystemSettings: () => Effect.succeed(false),
copyText: () => Effect.void,
},
) => {
const environment = DesktopEnvironment.DesktopEnvironment.of({
stateDir: "/tmp/t3-state",
isDevelopment,
Expand All @@ -51,6 +66,7 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => {
Layer.mergeAll(
Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment),
Layer.succeed(ElectronApp.ElectronApp, electronApp),
Layer.succeed(ElectronShell.ElectronShell, shell),
FileSystem.layerNoop({ exists: () => Effect.succeed(false) }),
),
),
Expand Down Expand Up @@ -162,7 +178,7 @@ describe("DesktopClerk", () => {

assert.isTrue(Exit.isSuccess(exit));
assert.equal(quit.mock.calls.length, 0);
assert.deepEqual(registeredEvents, ["second-instance"]);
assert.deepEqual(registeredEvents, ["open-url", "second-instance"]);
}).pipe(
Effect.provide(makeDesktopClerkLayer()),
Effect.provideService(ElectronApp.ElectronApp, electronApp),
Expand Down Expand Up @@ -198,3 +214,131 @@ describe("DesktopClerk", () => {
);
});
});

it.effect(
"provider auth deep links navigate and reveal the running desktop without handling Clerk URLs",
() => {
storageMock.mockReturnValue(storageAdapter);
createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });
const listeners = new Map<string, (...args: unknown[]) => void>();
const revealed = Promise.withResolvers<void>();
const loadURL = vi.fn(async (_url: string) => undefined);
const window = { loadURL };
const electronApp = {
on: (name: string, listener: (...args: unknown[]) => void) =>
Effect.sync(() => {
listeners.set(name, listener);
}),
} as unknown as ElectronApp.ElectronApp["Service"];
const electronWindow = {
currentMainOrFirst: Effect.succeed(Option.some(window)),
reveal: () => Effect.sync(() => revealed.resolve()),
} as unknown as ElectronWindow.ElectronWindow["Service"];
return Effect.gen(function* () {
const clerk = yield* DesktopClerk.DesktopClerk;
yield* clerk.configure;
const event = { preventDefault: vi.fn() };
listeners.get("open-url")!(event, "t3code-dev://app/auth/callback?code=clerk-code");
listeners.get("open-url")!(event, "t3code://app/welcome");
assert.equal(loadURL.mock.calls.length, 0);
assert.equal(event.preventDefault.mock.calls.length, 0);
listeners.get("second-instance")!({}, [
"t3",
"t3code-dev://app/settings/providers?instanceId=work&code=never-forward",
]);
yield* Effect.promise(() => revealed.promise);
assert.deepEqual(loadURL.mock.calls, [
["t3code-dev://app/settings/providers?instanceId=work"],
]);
listeners.get("open-url")!(event, "t3code-dev://app/welcome#agents:machine-id");
assert.equal(event.preventDefault.mock.calls.length, 1);
}).pipe(
Effect.scoped,
Effect.provide(makeDesktopClerkLayer()),
Effect.provideService(ElectronApp.ElectronApp, electronApp),
Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
);
},
);

for (const entry of ["startup", "open-url"] as const) {
it.effect(`receives hosted web sign-in through the desktop ${entry} handler`, () =>
Effect.gen(function* () {
storageMock.mockReturnValue(storageAdapter);
createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });
const port = yield* Effect.promise(async () => {
const server = NodeHttp.createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("address");
await new Promise<void>((resolve) => server.close(() => resolve()));
return address.port;
});
const authorize = new URL("https://auth.openai.com/api/accounts/authorize");
authorize.search = new URLSearchParams({
client_id: "dynamic_agent_client",
response_type: "code",
redirect_uri: `http://127.0.0.1:${port}/auth/callback`,
state: "a".repeat(43),
code_challenge_method: "S256",
code_challenge: "b".repeat(43),
}).toString();
const request = {
authorizationUrl: authorize.toString(),
returnUrl: "https://app.t3.codes/welcome#agents:remote-one",
environmentId: EnvironmentId.make("remote-one"),
instanceId: ProviderInstanceId.make("work"),
flowId: "flow-one",
};
const link = codexAuthHandoffUrl(request, true);
const delivered = Promise.withResolvers<string>();
const shell = ElectronShell.ElectronShell.of({
openExternal: (value) =>
Effect.promise(async () => {
const url = new URL(String(value));
const callback = new URL(url.searchParams.get("redirect_uri")!);
callback.search = new URLSearchParams({
state: url.searchParams.get("state")!,
code: "test-code",
client_id: "oaiapp_test",
}).toString();
const response = await fetch(callback, { redirect: "manual" });
delivered.resolve(response.headers.get("location")!);
return true;
}),
openSystemSettings: () => Effect.succeed(false),
copyText: () => Effect.void,
});
const listeners = new Map<string, (...args: unknown[]) => void>();
const electronApp = {
whenReady: Effect.void,
on: (name: string, listener: (...args: unknown[]) => void) =>
Effect.sync(() => {
listeners.set(name, listener);
}),
} as unknown as ElectronApp.ElectronApp["Service"];
yield* Effect.gen(function* () {
const clerk = yield* DesktopClerk.DesktopClerk;
yield* clerk.configure;
if (entry === "open-url") {
const event = { preventDefault: vi.fn() };
listeners.get("open-url")!(event, link);
assert.strictEqual(event.preventDefault.mock.calls.length, 1);
}
const delivery = readCodexAuthDelivery(yield* Effect.promise(() => delivered.promise));
assert.strictEqual(delivery?.environmentId, request.environmentId);
assert.strictEqual(delivery?.instanceId, request.instanceId);
assert.strictEqual(delivery?.flowId, request.flowId);
assert.strictEqual(delivery?.returnUrl, request.returnUrl);
}).pipe(
Effect.provide(makeDesktopClerkLayer(true, [], shell)),
Effect.provideService(HostProcessArguments, entry === "startup" ? ["t3", link] : ["t3"]),
Effect.provideService(ElectronApp.ElectronApp, electronApp),
Effect.provideService(
ElectronWindow.ElectronWindow,
{} as ElectronWindow.ElectronWindow["Service"],
),
);
}).pipe(Effect.scoped),
);
}
63 changes: 59 additions & 4 deletions apps/desktop/src/app/DesktopClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";

import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/codexAuthHandoff";
import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCallback.ts";
import * as ElectronShell from "../electron/ElectronShell.ts";
import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl";
import { HostProcessArguments } from "@t3tools/shared/hostProcess";
import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
Expand Down Expand Up @@ -87,6 +92,7 @@ function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) {
export const make = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const electronApp = yield* ElectronApp.ElectronApp;
const shell = yield* ElectronShell.ElectronShell;

// Electron scopes the single-instance lock to the userData directory and
// creates that directory when the lock is acquired. The SDK bridge takes
Expand Down Expand Up @@ -136,13 +142,62 @@ export const make = Effect.gen(function* () {
return yield* Effect.interrupt;
}

yield* electronApp.on("second-instance", () => {
const startProviderAuthHandoff = (value: string | undefined) => {
if (!value) return false;
const request = readCodexAuthHandoff(value, environment.isDevelopment);
if (!request) return false;
void runPromise(
Effect.gen(function* () {
yield* electronApp.whenReady;
yield* Effect.tryPromise({
try: () =>
receiveCodexAuthCallback(
request.authorizationUrl,
(url) => runPromise(shell.openExternal(url)),
(callbackUrl) => codexAuthDeliveryUrl(request, callbackUrl),
),
catch: () =>
new CodexAuthCallbackError({
detail:
"Could not receive hosted web ChatGPT sign-in. Retry or use the redirect URL in the web app.",
}),
});
}).pipe(
Effect.catch(() => Effect.logWarning("Could not complete ChatGPT desktop handoff.")),
),
);
return true;
};
const resumeProviderAuth = (value: string | undefined) => {
const destination = providerAuthReturnUrl(value);
const expectedOrigin = `${ElectronProtocol.getDesktopScheme(environment.isDevelopment)}://app`;
if (!destination?.startsWith(`${expectedOrigin}/`)) return false;
void runPromise(
Effect.gen(function* () {
const mainWindow = yield* electronWindow.currentMainOrFirst;
if (Option.isNone(mainWindow)) return;
yield* Effect.promise(() => mainWindow.value.loadURL(destination));
yield* electronWindow.reveal(mainWindow.value);
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("Could not return to provider setup", cause),
),
),
);
return true;
};
const args = yield* HostProcessArguments;
args.some((value) => startProviderAuthHandoff(value));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High app/DesktopClerk.ts:190

A cold-launched t3code://app/settings/providers?... callback never resumes provider setup: HostProcessArguments only invokes startProviderAuthHandoff, so providerAuthReturnUrl is not loaded and no window is revealed until the app is already running. Apply the same resumeProviderAuth handling used by second-instance when processing startup arguments.

Suggested change
args.some((value) => startProviderAuthHandoff(value));
args.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value));
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/desktop/src/app/DesktopClerk.ts around line 190:

A cold-launched `t3code://app/settings/providers?...` callback never resumes provider setup: `HostProcessArguments` only invokes `startProviderAuthHandoff`, so `providerAuthReturnUrl` is not loaded and no window is revealed until the app is already running. Apply the same `resumeProviderAuth` handling used by `second-instance` when processing startup arguments.

yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => {
if (startProviderAuthHandoff(url) || resumeProviderAuth(url)) event.preventDefault();
});
yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => {
if (argv?.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value)))
return;
void runPromise(
Effect.gen(function* () {
const mainWindow = yield* electronWindow.currentMainOrFirst;
if (Option.isSome(mainWindow)) {
yield* electronWindow.reveal(mainWindow.value);
}
if (Option.isSome(mainWindow)) yield* electronWindow.reveal(mainWindow.value);
}),
);
});
Expand Down
Loading
Loading