authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-29 11:15:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-29 11:15:29-07:00
log27bdf1aa14e6b946b72fb7013062084ca70796a6
tree19fee6ac7d288ff44dfcaad8e6b60cd9da93b611
parentd2c9281b8112dc3b2991642c4bdb985e4b08b9bb
signature Signed by PGP key B5690EEEBB952194

feat(codex): connect ChatGPT accounts with managed authentication (#14290)


120 files changed, 10061 insertions(+), 280 deletions(-)

apps/desktop/src/app/CodexAuthCallback.test.ts created+112
......@@ -0,0 +1,112 @@
1// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Tests exercise the real native loopback listener without an OpenAI account.
2import * as NodeHttp from "node:http";
3import { describe, expect, it } from "vite-plus/test";
4import { codexAuthDeliveryUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff";
5import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
6import { receiveCodexAuthCallback, cancelCodexAuthCallback } from "./CodexAuthCallback.ts";
7
8async function freePort() {
9 const server = NodeHttp.createServer();
10 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
11 const address = server.address();
12 if (!address || typeof address === "string") throw new Error("address");
13 await new Promise<void>((resolve) => server.close(() => resolve()));
14 return address.port;
15}
16function request(port: number, state = "a".repeat(43)) {
17 const url = new URL("https://auth.openai.com/api/accounts/authorize");
18 url.search = new URLSearchParams({
19 client_id: "dynamic_agent_client",
20 response_type: "code",
21 redirect_uri: `http://127.0.0.1:${port}/auth/callback`,
22 state,
23 code_challenge_method: "S256",
24 code_challenge: "b".repeat(43),
25 }).toString();
26 return url.toString();
27}
28function callback(authorizationUrl: string) {
29 const request = new URL(authorizationUrl);
30 const url = new URL(request.searchParams.get("redirect_uri")!);
31 url.search = new URLSearchParams({
32 state: request.searchParams.get("state")!,
33 code: "test-code",
34 client_id: "oaiapp_test",
35 }).toString();
36 return url.toString();
37}
38
39describe("desktop Codex callback helper", () => {
40 it("binds before opening sign-in, ignores a foreign response, and returns only the code callback", async () => {
41 const authorizationUrl = request(await freePort());
42 const expected = callback(authorizationUrl);
43 const received = await receiveCodexAuthCallback(authorizationUrl, async () => {
44 const invalid = new URL(expected);
45 invalid.searchParams.set("state", "foreign");
46 expect((await fetch(invalid)).status).toBe(400);
47 const response = await fetch(expected);
48 expect(response.headers.get("cache-control")).toBe("no-store");
49 expect(await response.text()).not.toContain("test-code");
50 return true;
51 });
52 expect(received).toBe(expected);
53 });
54 it("returns hosted web to the exact instance and environment without putting the code in its query", async () => {
55 const authorizationUrl = request(await freePort());
56 const expected = callback(authorizationUrl);
57 const input = {
58 authorizationUrl,
59 returnUrl: "https://app.t3.codes/settings/providers?environmentId=remote-one&instanceId=work",
60 environmentId: EnvironmentId.make("remote-one"),
61 instanceId: ProviderInstanceId.make("work"),
62 flowId: "flow-one",
63 };
64 await receiveCodexAuthCallback(
65 authorizationUrl,
66 async () => {
67 const response = await fetch(expected, { redirect: "manual" });
68 expect(response.status).toBe(303);
69 const delivery = response.headers.get("location")!;
70 expect(new URL(delivery).searchParams.has("code")).toBe(false);
71 expect(readCodexAuthDelivery(delivery)?.callbackUrl).toBe(expected);
72 expect(readCodexAuthDelivery(delivery)?.returnUrl).toBe(input.returnUrl);
73 return true;
74 },
75 (url) => codexAuthDeliveryUrl(input, url),
76 );
77 });
78 it("cancels and releases its listener so exact-port reauthorization can run again", async () => {
79 const authorizationUrl = request(await freePort());
80 await expect(
81 receiveCodexAuthCallback(authorizationUrl, async () => {
82 cancelCodexAuthCallback(authorizationUrl);
83 return true;
84 }),
85 ).rejects.toThrow("cancelled");
86 expect(
87 await receiveCodexAuthCallback(authorizationUrl, async () => {
88 await fetch(callback(authorizationUrl));
89 return true;
90 }),
91 ).toBe(callback(authorizationUrl));
92 });
93 it("allows two accounts to complete independently", async () => {
94 const a = request(await freePort(), "a".repeat(43));
95 const b = request(await freePort(), "c".repeat(43));
96 const openedA = Promise.withResolvers<void>();
97 const openedB = Promise.withResolvers<void>();
98 const receiveA = receiveCodexAuthCallback(a, async () => {
99 openedA.resolve();
100 await openedB.promise;
101 await fetch(callback(a));
102 return true;
103 });
104 const receiveB = receiveCodexAuthCallback(b, async () => {
105 openedB.resolve();
106 await openedA.promise;
107 await fetch(callback(b));
108 return true;
109 });
110 expect(await Promise.all([receiveA, receiveB])).toEqual([callback(a), callback(b)]);
111 });
112});
apps/desktop/src/app/CodexAuthCallback.ts created+5
......@@ -0,0 +1,5 @@
1export {
2 CodexAuthCallbackError,
3 cancelCodexAuthCallback,
4 receiveCodexAuthCallback,
5} from "@t3tools/shared/codexAuthCallback";
apps/desktop/src/app/DesktopClerk.test.ts+146-2
......@@ -1,3 +1,8 @@
1// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Hosted handoff test uses a real localhost listener without an OpenAI account.
2import * as NodeHttp from "node:http";
3import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff";
4import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
5import { HostProcessArguments } from "@t3tools/shared/hostProcess";
16import { assert, describe, it } from "@effect/vitest";
27import * as Cause from "effect/Cause";
38import * as Effect from "effect/Effect";
......@@ -22,14 +27,24 @@ vi.mock("@clerk/electron/storage", () => ({
2227 storage: storageMock,
2328}));
2429
30import * as Option from "effect/Option";
2531import * as Exit from "effect/Exit";
2632import * as FileSystem from "effect/FileSystem";
2733import * as ElectronApp from "../electron/ElectronApp.ts";
34import * as ElectronShell from "../electron/ElectronShell.ts";
2835import * as ElectronWindow from "../electron/ElectronWindow.ts";
2936import * as DesktopClerk from "./DesktopClerk.ts";
3037import * as DesktopEnvironment from "./DesktopEnvironment.ts";
3138
32const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => {
39const makeDesktopClerkLayer = (
40 isDevelopment = true,
41 events: string[] = [],
42 shell: ElectronShell.ElectronShell["Service"] = {
43 openExternal: () => Effect.succeed(true),
44 openSystemSettings: () => Effect.succeed(false),
45 copyText: () => Effect.void,
46 },
47) => {
3348 const environment = DesktopEnvironment.DesktopEnvironment.of({
3449 stateDir: "/tmp/t3-state",
3550 isDevelopment,
......@@ -51,6 +66,7 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => {
5166 Layer.mergeAll(
5267 Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment),
5368 Layer.succeed(ElectronApp.ElectronApp, electronApp),
69 Layer.succeed(ElectronShell.ElectronShell, shell),
5470 FileSystem.layerNoop({ exists: () => Effect.succeed(false) }),
5571 ),
5672 ),
......@@ -162,7 +178,7 @@ describe("DesktopClerk", () => {
162178
163179 assert.isTrue(Exit.isSuccess(exit));
164180 assert.equal(quit.mock.calls.length, 0);
165 assert.deepEqual(registeredEvents, ["second-instance"]);
181 assert.deepEqual(registeredEvents, ["open-url", "second-instance"]);
166182 }).pipe(
167183 Effect.provide(makeDesktopClerkLayer()),
168184 Effect.provideService(ElectronApp.ElectronApp, electronApp),
......@@ -198,3 +214,131 @@ describe("DesktopClerk", () => {
198214 );
199215 });
200216});
217
218it.effect(
219 "provider auth deep links navigate and reveal the running desktop without handling Clerk URLs",
220 () => {
221 storageMock.mockReturnValue(storageAdapter);
222 createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });
223 const listeners = new Map<string, (...args: unknown[]) => void>();
224 const revealed = Promise.withResolvers<void>();
225 const loadURL = vi.fn(async (_url: string) => undefined);
226 const window = { loadURL };
227 const electronApp = {
228 on: (name: string, listener: (...args: unknown[]) => void) =>
229 Effect.sync(() => {
230 listeners.set(name, listener);
231 }),
232 } as unknown as ElectronApp.ElectronApp["Service"];
233 const electronWindow = {
234 currentMainOrFirst: Effect.succeed(Option.some(window)),
235 reveal: () => Effect.sync(() => revealed.resolve()),
236 } as unknown as ElectronWindow.ElectronWindow["Service"];
237 return Effect.gen(function* () {
238 const clerk = yield* DesktopClerk.DesktopClerk;
239 yield* clerk.configure;
240 const event = { preventDefault: vi.fn() };
241 listeners.get("open-url")!(event, "t3code-dev://app/auth/callback?code=clerk-code");
242 listeners.get("open-url")!(event, "t3code://app/welcome");
243 assert.equal(loadURL.mock.calls.length, 0);
244 assert.equal(event.preventDefault.mock.calls.length, 0);
245 listeners.get("second-instance")!({}, [
246 "t3",
247 "t3code-dev://app/settings/providers?instanceId=work&code=never-forward",
248 ]);
249 yield* Effect.promise(() => revealed.promise);
250 assert.deepEqual(loadURL.mock.calls, [
251 ["t3code-dev://app/settings/providers?instanceId=work"],
252 ]);
253 listeners.get("open-url")!(event, "t3code-dev://app/welcome#agents:machine-id");
254 assert.equal(event.preventDefault.mock.calls.length, 1);
255 }).pipe(
256 Effect.scoped,
257 Effect.provide(makeDesktopClerkLayer()),
258 Effect.provideService(ElectronApp.ElectronApp, electronApp),
259 Effect.provideService(ElectronWindow.ElectronWindow, electronWindow),
260 );
261 },
262);
263
264for (const entry of ["startup", "open-url"] as const) {
265 it.effect(`receives hosted web sign-in through the desktop ${entry} handler`, () =>
266 Effect.gen(function* () {
267 storageMock.mockReturnValue(storageAdapter);
268 createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });
269 const port = yield* Effect.promise(async () => {
270 const server = NodeHttp.createServer();
271 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
272 const address = server.address();
273 if (!address || typeof address === "string") throw new Error("address");
274 await new Promise<void>((resolve) => server.close(() => resolve()));
275 return address.port;
276 });
277 const authorize = new URL("https://auth.openai.com/api/accounts/authorize");
278 authorize.search = new URLSearchParams({
279 client_id: "dynamic_agent_client",
280 response_type: "code",
281 redirect_uri: `http://127.0.0.1:${port}/auth/callback`,
282 state: "a".repeat(43),
283 code_challenge_method: "S256",
284 code_challenge: "b".repeat(43),
285 }).toString();
286 const request = {
287 authorizationUrl: authorize.toString(),
288 returnUrl: "https://app.t3.codes/welcome#agents:remote-one",
289 environmentId: EnvironmentId.make("remote-one"),
290 instanceId: ProviderInstanceId.make("work"),
291 flowId: "flow-one",
292 };
293 const link = codexAuthHandoffUrl(request, true);
294 const delivered = Promise.withResolvers<string>();
295 const shell = ElectronShell.ElectronShell.of({
296 openExternal: (value) =>
297 Effect.promise(async () => {
298 const url = new URL(String(value));
299 const callback = new URL(url.searchParams.get("redirect_uri")!);
300 callback.search = new URLSearchParams({
301 state: url.searchParams.get("state")!,
302 code: "test-code",
303 client_id: "oaiapp_test",
304 }).toString();
305 const response = await fetch(callback, { redirect: "manual" });
306 delivered.resolve(response.headers.get("location")!);
307 return true;
308 }),
309 openSystemSettings: () => Effect.succeed(false),
310 copyText: () => Effect.void,
311 });
312 const listeners = new Map<string, (...args: unknown[]) => void>();
313 const electronApp = {
314 whenReady: Effect.void,
315 on: (name: string, listener: (...args: unknown[]) => void) =>
316 Effect.sync(() => {
317 listeners.set(name, listener);
318 }),
319 } as unknown as ElectronApp.ElectronApp["Service"];
320 yield* Effect.gen(function* () {
321 const clerk = yield* DesktopClerk.DesktopClerk;
322 yield* clerk.configure;
323 if (entry === "open-url") {
324 const event = { preventDefault: vi.fn() };
325 listeners.get("open-url")!(event, link);
326 assert.strictEqual(event.preventDefault.mock.calls.length, 1);
327 }
328 const delivery = readCodexAuthDelivery(yield* Effect.promise(() => delivered.promise));
329 assert.strictEqual(delivery?.environmentId, request.environmentId);
330 assert.strictEqual(delivery?.instanceId, request.instanceId);
331 assert.strictEqual(delivery?.flowId, request.flowId);
332 assert.strictEqual(delivery?.returnUrl, request.returnUrl);
333 }).pipe(
334 Effect.provide(makeDesktopClerkLayer(true, [], shell)),
335 Effect.provideService(HostProcessArguments, entry === "startup" ? ["t3", link] : ["t3"]),
336 Effect.provideService(ElectronApp.ElectronApp, electronApp),
337 Effect.provideService(
338 ElectronWindow.ElectronWindow,
339 {} as ElectronWindow.ElectronWindow["Service"],
340 ),
341 );
342 }).pipe(Effect.scoped),
343 );
344}
apps/desktop/src/app/DesktopClerk.ts+59-4
......@@ -7,6 +7,11 @@ import * as Option from "effect/Option";
77import * as Schema from "effect/Schema";
88import * as Scope from "effect/Scope";
99
10import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/codexAuthHandoff";
11import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCallback.ts";
12import * as ElectronShell from "../electron/ElectronShell.ts";
13import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl";
14import { HostProcessArguments } from "@t3tools/shared/hostProcess";
1015import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";
1116import * as ElectronApp from "../electron/ElectronApp.ts";
1217import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
......@@ -87,6 +92,7 @@ function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) {
8792export const make = Effect.gen(function* () {
8893 const environment = yield* DesktopEnvironment.DesktopEnvironment;
8994 const electronApp = yield* ElectronApp.ElectronApp;
95 const shell = yield* ElectronShell.ElectronShell;
9096
9197 // Electron scopes the single-instance lock to the userData directory and
9298 // creates that directory when the lock is acquired. The SDK bridge takes
......@@ -136,13 +142,62 @@ export const make = Effect.gen(function* () {
136142 return yield* Effect.interrupt;
137143 }
138144
139 yield* electronApp.on("second-instance", () => {
145 const startProviderAuthHandoff = (value: string | undefined) => {
146 if (!value) return false;
147 const request = readCodexAuthHandoff(value, environment.isDevelopment);
148 if (!request) return false;
149 void runPromise(
150 Effect.gen(function* () {
151 yield* electronApp.whenReady;
152 yield* Effect.tryPromise({
153 try: () =>
154 receiveCodexAuthCallback(
155 request.authorizationUrl,
156 (url) => runPromise(shell.openExternal(url)),
157 (callbackUrl) => codexAuthDeliveryUrl(request, callbackUrl),
158 ),
159 catch: () =>
160 new CodexAuthCallbackError({
161 detail:
162 "Could not receive hosted web ChatGPT sign-in. Retry or use the redirect URL in the web app.",
163 }),
164 });
165 }).pipe(
166 Effect.catch(() => Effect.logWarning("Could not complete ChatGPT desktop handoff.")),
167 ),
168 );
169 return true;
170 };
171 const resumeProviderAuth = (value: string | undefined) => {
172 const destination = providerAuthReturnUrl(value);
173 const expectedOrigin = `${ElectronProtocol.getDesktopScheme(environment.isDevelopment)}://app`;
174 if (!destination?.startsWith(`${expectedOrigin}/`)) return false;
175 void runPromise(
176 Effect.gen(function* () {
177 const mainWindow = yield* electronWindow.currentMainOrFirst;
178 if (Option.isNone(mainWindow)) return;
179 yield* Effect.promise(() => mainWindow.value.loadURL(destination));
180 yield* electronWindow.reveal(mainWindow.value);
181 }).pipe(
182 Effect.catchCause((cause) =>
183 Effect.logWarning("Could not return to provider setup", cause),
184 ),
185 ),
186 );
187 return true;
188 };
189 const args = yield* HostProcessArguments;
190 args.some((value) => startProviderAuthHandoff(value));
191 yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => {
192 if (startProviderAuthHandoff(url) || resumeProviderAuth(url)) event.preventDefault();
193 });
194 yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => {
195 if (argv?.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value)))
196 return;
140197 void runPromise(
141198 Effect.gen(function* () {
142199 const mainWindow = yield* electronWindow.currentMainOrFirst;
143 if (Option.isSome(mainWindow)) {
144 yield* electronWindow.reveal(mainWindow.value);
145 }
200 if (Option.isSome(mainWindow)) yield* electronWindow.reveal(mainWindow.value);
146201 }),
147202 );
148203 });
apps/desktop/src/ipc/DesktopIpcHandlers.ts+3
......@@ -1,5 +1,6 @@
11import * as Effect from "effect/Effect";
22
3import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts";
34import * as DesktopIpc from "./DesktopIpc.ts";
45import { installNotificationBadge } from "./methods/notificationBadge.ts";
56import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
......@@ -131,6 +132,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
131132 yield* ipc.handle(setTheme);
132133 yield* ipc.handle(showContextMenu);
133134 yield* ipc.handle(openExternal);
135 yield* ipc.handle(receiveProviderAuthCallback);
136 yield* ipc.handle(cancelProviderAuthCallback);
134137 yield* ipc.handle(openSystemSettings);
135138 yield* ipc.handle(checkSystemPermission);
136139 yield* ipc.handle(pasteAsText);
apps/desktop/src/ipc/channels.ts+3
......@@ -116,3 +116,6 @@ export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper";
116116export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission";
117117
118118export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input";
119
120export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback";
121export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback";
apps/desktop/src/ipc/methods/providerAuth.ts created+49
......@@ -0,0 +1,49 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3import * as Option from "effect/Option";
4import {
5 receiveCodexAuthCallback,
6 cancelCodexAuthCallback,
7 CodexAuthCallbackError,
8} from "../../app/CodexAuthCallback.ts";
9import * as ElectronShell from "../../electron/ElectronShell.ts";
10import * as ElectronWindow from "../../electron/ElectronWindow.ts";
11import * as DesktopIpc from "../DesktopIpc.ts";
12import * as IpcChannels from "../channels.ts";
13
14const Request = Schema.String.check(Schema.isMaxLength(16_384));
15
16export const receiveProviderAuthCallback = DesktopIpc.makeIpcMethod({
17 channel: IpcChannels.RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL,
18 payload: Request,
19 result: Schema.String,
20 handler: Effect.fn("desktop.ipc.providerAuth.receive")(function* (authorizationUrl) {
21 const shell = yield* ElectronShell.ElectronShell;
22 const windows = yield* ElectronWindow.ElectronWindow;
23 const context = yield* Effect.context<ElectronShell.ElectronShell>();
24 const runPromise = Effect.runPromiseWith(context);
25 const callbackUrl = yield* Effect.tryPromise({
26 try: () =>
27 receiveCodexAuthCallback(authorizationUrl, (url) => runPromise(shell.openExternal(url))),
28 catch: () =>
29 new CodexAuthCallbackError({
30 detail:
31 "Could not receive ChatGPT sign-in on this computer. Try again or paste the redirect URL.",
32 }),
33 });
34 const window = yield* windows.currentMainOrFirst;
35 if (Option.isSome(window)) yield* windows.reveal(window.value);
36 return callbackUrl;
37 }),
38});
39
40export const cancelProviderAuthCallback = DesktopIpc.makeIpcMethod({
41 channel: IpcChannels.CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL,
42 payload: Request,
43 result: Schema.Void,
44 handler: (authorizationUrl) =>
45 Effect.try({
46 try: () => cancelCodexAuthCallback(authorizationUrl),
47 catch: () => new CodexAuthCallbackError({ detail: "Invalid ChatGPT sign-in request." }),
48 }),
49});
apps/desktop/src/main.ts+1
......@@ -205,6 +205,7 @@ const desktopApplicationLayer = Layer.mergeAll(
205205);
206206
207207const desktopClerkLayer = DesktopClerk.layer.pipe(
208 Layer.provideMerge(ElectronShell.layer),
208209 Layer.provideMerge(desktopEnvironmentLayer),
209210 Layer.provideMerge(NodeServices.layer),
210211 Layer.provideMerge(ElectronApp.layer),
apps/desktop/src/preload.ts+4
......@@ -182,6 +182,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {
182182 items,
183183 ...(position === undefined ? {} : { position }),
184184 }),
185 receiveProviderAuthCallback: (url: string) =>
186 ipcRenderer.invoke(IpcChannels.RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL, url),
187 cancelProviderAuthCallback: (url: string) =>
188 ipcRenderer.invoke(IpcChannels.CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL, url),
185189 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
186190 checkSystemPermission: (pane: string) =>
187191 ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane),
apps/mobile/src/features/threads/ChatGptSharingStatus.tsx created+39
......@@ -0,0 +1,39 @@
1import type { ServerProvider } from "@t3tools/contracts";
2import { CHATGPT_USAGE_URL, usesChatGptSharing } from "@t3tools/shared/usageLimits";
3import { Alert, Linking, Pressable, View } from "react-native";
4import { AppText as Text } from "../../components/AppText";
5import { ProviderIcon } from "../../components/ProviderIcon";
6
7export function ChatGptSharingStatus({ provider }: { provider: ServerProvider | null }) {
8 if (!usesChatGptSharing(provider)) return null;
9 return (
10 <View className="flex-row items-center justify-between gap-3 px-3 py-1">
11 <Pressable
12 accessibilityRole="button"
13 accessibilityLabel="ChatGPT token sharing is active"
14 className="min-h-11 flex-row items-center gap-2"
15 onPress={() => {
16 Alert.alert(
17 "ChatGPT sharing is on",
18 [
19 provider?.auth.email,
20 "Eligible usage uses your ChatGPT plan. Credit settings and limits are managed in ChatGPT.",
21 ]
22 .filter(Boolean)
23 .join("\n\n"),
24 );
25 }}
26 >
27 <ProviderIcon provider="codex" size={14} />
28 <Text className="text-xs text-foreground-muted">Using ChatGPT plan</Text>
29 </Pressable>
30 <Pressable
31 accessibilityRole="link"
32 className="min-h-11 justify-center"
33 onPress={() => void Linking.openURL(CHATGPT_USAGE_URL).catch(() => undefined)}
34 >
35 <Text className="text-xs font-t3-medium text-primary">Manage usage</Text>
36 </Pressable>
37 </View>
38 );
39}
apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx created+40
......@@ -0,0 +1,40 @@
1import type { EnvironmentId, OrchestrationThreadShell } from "@t3tools/contracts";
2import { CHATGPT_USAGE_URL, isChatGptUsageLimitError } from "@t3tools/shared/usageLimits";
3import * as Option from "effect/Option";
4import { Linking, Pressable, View } from "react-native";
5import { AppText as Text } from "../../components/AppText";
6import { ProviderIcon } from "../../components/ProviderIcon";
7import { useThreadDetail } from "../../state/use-thread-detail";
8
9export function ChatGptUsageLimitNotice({
10 environmentId,
11 thread,
12}: {
13 environmentId: EnvironmentId;
14 thread: OrchestrationThreadShell;
15}) {
16 const state = useThreadDetail({ environmentId, threadId: thread.id });
17 const detail = Option.getOrNull(state.data);
18 if (!isChatGptUsageLimitError(detail?.activities ?? [], thread.session?.lastError)) return null;
19 return (
20 <View
21 accessibilityRole="alert"
22 className="mx-3 mb-2 gap-2 rounded-xl border border-border-subtle bg-composer-panel p-3"
23 >
24 <View className="flex-row items-center gap-2">
25 <ProviderIcon provider="codex" size={16} />
26 <Text className="text-sm font-t3-medium text-foreground">ChatGPT usage limit reached</Text>
27 </View>
28 <Text className="text-xs text-foreground-muted">
29 Review your usage settings in ChatGPT to continue.
30 </Text>
31 <Pressable
32 accessibilityRole="link"
33 className="min-h-11 self-start justify-center rounded-lg bg-primary px-3"
34 onPress={() => void Linking.openURL(CHATGPT_USAGE_URL).catch(() => undefined)}
35 >
36 <Text className="text-sm font-t3-medium text-primary-foreground">Manage usage</Text>
37 </Pressable>
38 </View>
39 );
40}
apps/mobile/src/features/threads/ThreadComposer.tsx+5
......@@ -1,3 +1,4 @@
1import { ChatGptUsageLimitNotice } from "./ChatGptUsageLimitNotice";
12import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
23import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
34import { useAtomValue } from "@effect/atom-react";
......@@ -637,6 +638,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
637638 className="relative w-full self-center"
638639 style={{ maxWidth: props.contentMaxWidth }}
639640 >
641 <ChatGptUsageLimitNotice
642 environmentId={props.environmentId}
643 thread={props.selectedThread}
644 />
640645 {!voiceInput.isBusy &&
641646 composerMenu.trigger &&
642647 (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? (
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+10-1
......@@ -61,7 +61,8 @@ import {
6161 nativeHeaderScrollEdgeEffects,
6262} from "../../native/StackHeader";
6363import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
64import { serverEnvironment } from "../../state/server";
64import { ChatGptSharingStatus } from "./ChatGptSharingStatus";
65import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server";
6566import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
6667import { useAtomCommand } from "../../state/use-atom-command";
6768import { useNewTaskFlow } from "./new-task-flow-provider";
......@@ -683,6 +684,12 @@ function ThreadSettingsOptionsItem(props: {
683684}) {
684685 const insets = useSafeAreaInsets();
685686 const session = useThreadSettingsSession();
687 const configs = useAtomValue(environmentServerConfigsAtom);
688 const selectedProvider = session.environmentId
689 ? (configs
690 .get(session.environmentId)
691 ?.providers.find((provider) => provider.instanceId === session.providerInstanceId) ?? null)
692 : null;
686693 const bottomToolbarInset =
687694 Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
688695 ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET
......@@ -690,6 +697,7 @@ function ThreadSettingsOptionsItem(props: {
690697
691698 return (
692699 <View style={{ paddingBottom: insets.bottom + bottomToolbarInset + 12 }}>
700 <ChatGptSharingStatus provider={selectedProvider} />
693701 <Text className="px-5 pb-2 pt-2 text-sm font-t3-medium text-foreground-muted">Options</Text>
694702 <Animated.View
695703 className="mx-4 overflow-hidden rounded-2xl bg-card"
......@@ -1371,6 +1379,7 @@ export function NewTaskThreadSettingsRouteScreen() {
13711379 return (
13721380 <ThreadSettingsSessionProvider
13731381 environmentId={flow.selectedEnvironmentId}
1382 {...(flow.selectedModel ? { providerInstanceId: flow.selectedModel.instanceId } : {})}
13741383 providerGroups={flow.providerGroups}
13751384 selectedModel={flow.selectedModel}
13761385 onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}
apps/mobile/src/features/usage/ChatGptUsageSummary.tsx created+41
......@@ -0,0 +1,41 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
3import { CHATGPT_USAGE_URL, collectExternalUsageLinks } from "@t3tools/shared/usageLimits";
4import { Linking, Pressable, View } from "react-native";
5import { AppText as Text } from "../../components/AppText";
6import { ProviderIcon } from "../../components/ProviderIcon";
7import { environmentPresentations } from "../../state/presentation";
8
9export function ChatGptUsageSummary({
10 selectedEnvironmentIds,
11}: {
12 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
13}) {
14 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
15 const selected =
16 selectedEnvironmentIds === null
17 ? presentations
18 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
19 const usage = collectExternalUsageLinks(selected).find((link) => link.url === CHATGPT_USAGE_URL);
20 if (!usage) return null;
21 return (
22 <View className="gap-1">
23 <View className="flex-row items-center justify-between gap-3">
24 <View className="flex-row items-center gap-2">
25 <ProviderIcon provider="codex" size={16} />
26 <Text className="text-sm text-foreground">ChatGPT shared usage</Text>
27 </View>
28 <Pressable
29 accessibilityRole="link"
30 className="min-h-11 justify-center"
31 onPress={() => void Linking.openURL(usage.url).catch(() => undefined)}
32 >
33 <Text className="text-sm font-t3-medium text-primary">Manage usage</Text>
34 </Pressable>
35 </View>
36 <Text className="text-xs text-foreground-muted">
37 {usage.accounts.join(", ")}. Open ChatGPT with the account you connected.
38 </Text>
39 </View>
40 );
41}
apps/mobile/src/features/usage/UsageLimitsPooled.tsx+24-2
......@@ -3,6 +3,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"
33import { EnvironmentId } from "@t3tools/contracts";
44import {
55 collectLimitAccounts,
6 collectExternalUsageLinks,
67 collectLimitNotices,
78 collectLimitPools,
89 cursorUsageWindowDetails,
......@@ -14,7 +15,7 @@ import {
1415 type LimitPoolWindow,
1516} from "@t3tools/shared/usageLimits";
1617import { Fragment, type ReactNode, useId, useState } from "react";
17import { Pressable, ScrollView, View } from "react-native";
18import { Linking, Pressable, ScrollView, View } from "react-native";
1819import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";
1920import { useSafeAreaInsets } from "react-native-safe-area-context";
2021
......@@ -217,6 +218,7 @@ export function UsageLimitsSection({
217218 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
218219 const pools = collectLimitPools(collectLimitAccounts(selected), now);
219220 const notices = collectLimitNotices(selected);
221 const externalLinks = collectExternalUsageLinks(selected);
220222 const colors = useProviderColors();
221223 const cursorPromptAt =
222224 Math.max(
......@@ -225,7 +227,11 @@ export function UsageLimitsSection({
225227 ) + 1;
226228 return (
227229 <View className="gap-6">
228 {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 && !cursorPrompt ? (
230 {pools.length === 0 &&
231 notices.length === 0 &&
232 failedLabels.length === 0 &&
233 !cursorPrompt &&
234 externalLinks.length === 0 ? (
229235 <Text className="py-12 text-center text-base text-foreground-muted">
230236 {selected.size === 0
231237 ? "Select an environment to see limits."
......@@ -266,6 +272,22 @@ export function UsageLimitsSection({
266272 );
267273 })}
268274 {cursorPromptAt === pools.length ? cursorPrompt : null}
275 {externalLinks.map((link) => (
276 <View key={link.url} className="gap-3 rounded-xl border border-border-subtle p-4">
277 <Text className="text-base font-t3-medium text-foreground">{link.label}</Text>
278 <Text className="text-xs text-foreground-muted">{link.accounts.join(", ")}</Text>
279 {link.message ? (
280 <Text className="text-sm text-foreground-muted">{link.message}</Text>
281 ) : null}
282 <Pressable
283 accessibilityRole="link"
284 className="min-h-11 justify-center"
285 onPress={() => void Linking.openURL(link.url).catch(() => undefined)}
286 >
287 <Text className="text-sm font-t3-medium text-primary">Manage usage</Text>
288 </Pressable>
289 </View>
290 ))}
269291 {notices.length > 0 || failedLabels.length > 0 ? (
270292 <View
271293 accessible
apps/mobile/src/features/usage/UsageLimitsSection.tsx+11-1
......@@ -18,7 +18,7 @@ import {
1818} from "@t3tools/shared/usageLimits";
1919import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
2020import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";
21import { Alert, Pressable, View } from "react-native";
21import { Alert, Linking, Pressable, View } from "react-native";
2222
2323import { AppText as Text } from "../../components/AppText";
2424import { ProviderIcon } from "../../components/ProviderIcon";
......@@ -140,6 +140,7 @@ export function AccountLimits(props: {
140140 const color = useBarColor(props.driver);
141141 if (!limits) return null;
142142 const notice = limitsNotice(limits);
143 const externalUsage = limits.externalUsage;
143144 const padding = dense ? "px-4 py-3" : "p-4";
144145 return (
145146 <View
......@@ -171,6 +172,15 @@ export function AccountLimits(props: {
171172 ))}
172173 </View>
173174 )}
175 {externalUsage ? (
176 <Pressable
177 accessibilityRole="link"
178 className="min-h-11 justify-center"
179 onPress={() => void Linking.openURL(externalUsage.url).catch(() => undefined)}
180 >
181 <Text className="text-sm font-t3-medium text-primary">Manage usage</Text>
182 </Pressable>
183 ) : null}
174184 {props.footer}
175185 </View>
176186 );
apps/mobile/src/features/usage/UsageRouteScreen.tsx+2
......@@ -1,3 +1,4 @@
1import { ChatGptUsageSummary } from "./ChatGptUsageSummary";
12import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
23import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
34import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";
......@@ -317,6 +318,7 @@ export function UsageRouteScreen() {
317318 className="w-full ios:w-36"
318319 />
319320 </View>
321 <ChatGptUsageSummary selectedEnvironmentIds={selectedEnvironmentIds} />
320322 {merged.duplicateSources.length > 0 ? (
321323 <Text className="text-sm text-foreground-muted">
322324 Counted once across environments sharing a transcript directory:{" "}
apps/server/package.json+3
......@@ -31,7 +31,9 @@
3131 "@opencode-ai/sdk": "^1.3.15",
3232 "diff": "8.0.3",
3333 "effect": "catalog:",
34 "jose": "catalog:",
3435 "node-pty": "^1.2.0-beta.15",
36 "proper-lockfile": "4.1.2",
3537 "stream-chain": "^4.2.5",
3638 "stream-json": "3.6.0",
3739 "yaml": "catalog:",
......@@ -45,6 +47,7 @@
4547 "@t3tools/tailscale": "workspace:*",
4648 "@t3tools/web": "workspace:*",
4749 "@types/node": "catalog:",
50 "@types/proper-lockfile": "^4.1.4",
4851 "@types/yauzl": "^3.4.0",
4952 "effect-acp": "workspace:*",
5053 "effect-codex-app-server": "workspace:*",
apps/server/src/auth/RpcAuthorization.ts+4
......@@ -37,6 +37,10 @@ export const RPC_REQUIRED_SCOPES = {
3737 [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope,
3838 [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope,
3939 [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope,
40 [WS_METHODS.chatGptReconnectProfile]: AuthOrchestrationOperateScope,
41 [WS_METHODS.chatGptImportProfile]: AuthOrchestrationOperateScope,
42 [WS_METHODS.chatGptHandoffSubscribe]: AuthOrchestrationOperateScope,
43 [WS_METHODS.codexAuthCallbackSubscribe]: AuthOrchestrationOperateScope,
4044 [WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope,
4145 [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope,
4246 [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope,
apps/server/src/auth/ServerSecretStore.ts+3
......@@ -138,6 +138,8 @@ export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean =>
138138export class ServerSecretStore extends Context.Service<
139139 ServerSecretStore,
140140 {
141 /** File-backed stores expose their directory for cross-process credential leases. */
142 readonly directory?: string;
141143 readonly get: (name: string) => Effect.Effect<Option.Option<Uint8Array>, SecretStoreError>;
142144 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
143145 readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
......@@ -303,6 +305,7 @@ export const make = Effect.gen(function* () {
303305 );
304306
305307 return ServerSecretStore.of({
308 directory: serverConfig.secretsDir,
306309 get,
307310 set,
308311 create,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+2
......@@ -3944,6 +3944,7 @@ describe("ProviderRuntimeIngestion", () => {
39443944 turnId: asTurnId("turn-runtime-error-activity"),
39453945 payload: {
39463946 message: "runtime activity exploded",
3947 code: "subscription_sharing_usage_limit_exceeded",
39473948 },
39483949 });
39493950
......@@ -3960,6 +3961,7 @@ describe("ProviderRuntimeIngestion", () => {
39603961
39613962 expect(activity?.kind).toBe("runtime.error");
39623963 expect(activityPayload?.message).toBe("runtime activity exploded");
3964 expect(activityPayload?.code).toBe("subscription_sharing_usage_limit_exceeded");
39633965 });
39643966
39653967 it("keeps the session running when a runtime.warning arrives during an active turn", async () => {
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+1
......@@ -571,6 +571,7 @@ export function runtimeEventToActivities(
571571 summary: "Runtime error",
572572 payload: {
573573 message: truncateDetail(event.payload.message),
574 ...(event.payload.code ? { code: event.payload.code } : {}),
574575 },
575576 turnId: toTurnId(event.turnId) ?? null,
576577 ...maybeSequence,
apps/server/src/provider/CodexAuthCallback.test.ts created+105
......@@ -0,0 +1,105 @@
1// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Exercise the real local callback receiver without contacting OpenAI.
2import { expect, it } from "@effect/vitest";
3import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Stream from "effect/Stream";
8import * as NodeHttp from "node:http";
9import { subscribeCodexAuthCallback } from "./CodexAuthCallback.ts";
10
11async function input() {
12 const server = NodeHttp.createServer();
13 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
14 const address = server.address();
15 if (!address || typeof address === "string") throw new Error("address");
16 await new Promise<void>((resolve) => server.close(() => resolve()));
17 const authorizationUrl = new URL("https://auth.openai.com/api/accounts/authorize");
18 authorizationUrl.search = new URLSearchParams({
19 client_id: "dynamic_agent_client",
20 response_type: "code",
21 redirect_uri: `http://127.0.0.1:${address.port}/auth/callback`,
22 state: "a".repeat(43),
23 code_challenge_method: "S256",
24 code_challenge: "b".repeat(43),
25 }).toString();
26 return {
27 authorizationUrl: authorizationUrl.toString(),
28 returnUrl: "http://localhost:7001/welcome#agents:remote-nuc",
29 environmentId: EnvironmentId.make("remote-nuc"),
30 instanceId: ProviderInstanceId.make("work"),
31 flowId: "remote-flow",
32 };
33}
34function callback(authorizationUrl: string) {
35 const request = new URL(authorizationUrl);
36 const url = new URL(request.searchParams.get("redirect_uri")!);
37 url.search = new URLSearchParams({
38 state: request.searchParams.get("state")!,
39 code: "test-code",
40 client_id: "oaiapp_test",
41 }).toString();
42 return url;
43}
44
45it.effect("a local primary environment receives sign-in for a remote secondary environment", () =>
46 Effect.gen(function* () {
47 const request = yield* Effect.promise(input);
48 const ready = yield* Deferred.make<void>();
49 const states: string[] = [];
50 let receivedCallbackUrl: string | undefined;
51 const receiver = yield* subscribeCodexAuthCallback(request).pipe(
52 Stream.runForEach((state) =>
53 Effect.gen(function* () {
54 states.push(state.phase);
55 if (state.phase === "finished") receivedCallbackUrl = state.callbackUrl;
56 if (state.phase === "ready") yield* Deferred.succeed(ready, undefined);
57 }),
58 ),
59 Effect.forkScoped,
60 );
61 yield* Deferred.await(ready);
62 const expected = callback(request.authorizationUrl);
63 const foreign = new URL(expected);
64 foreign.searchParams.set("state", "foreign");
65 expect((yield* Effect.promise(() => fetch(foreign))).status).toBe(400);
66 const response = yield* Effect.promise(() => fetch(expected, { redirect: "manual" }));
67 expect(response.status).toBe(303);
68 expect(response.headers.get("cache-control")).toBe("no-store");
69 expect(response.headers.get("location")).toBe(request.returnUrl);
70 yield* Fiber.join(receiver);
71 expect(receivedCallbackUrl).toBe(expected.toString());
72 expect(states).toEqual(["ready", "finished"]);
73 }).pipe(Effect.scoped),
74);
75
76it.effect("disconnecting the receiving client releases the exact callback port for retry", () =>
77 Effect.gen(function* () {
78 const request = yield* Effect.promise(input);
79 const ready = yield* Deferred.make<void>();
80 const receiver = yield* subscribeCodexAuthCallback(request).pipe(
81 Stream.runForEach(() => Deferred.succeed(ready, undefined)),
82 Effect.forkScoped,
83 );
84 yield* Deferred.await(ready);
85 yield* Fiber.interrupt(receiver);
86 yield* subscribeCodexAuthCallback(request).pipe(
87 Stream.runForEach((state) =>
88 state.phase === "ready"
89 ? Effect.promise(() => fetch(callback(request.authorizationUrl), { redirect: "manual" }))
90 : Effect.void,
91 ),
92 );
93 }).pipe(Effect.scoped),
94);
95
96it.effect("the local server refuses nonlocal return destinations", () =>
97 Effect.gen(function* () {
98 const request = yield* Effect.promise(input);
99 const result = yield* subscribeCodexAuthCallback({
100 ...request,
101 returnUrl: "https://app.t3.codes/welcome",
102 }).pipe(Stream.runDrain, Effect.result);
103 expect(result._tag).toBe("Failure");
104 }),
105);
apps/server/src/provider/CodexAuthCallback.ts created+60
......@@ -0,0 +1,60 @@
1import { ProviderSetupError, type CodexAuthCallbackInput } from "@t3tools/contracts";
2import { receiveCodexAuthCallback } from "@t3tools/shared/codexAuthCallback";
3import { codexAuthorizationRequest } from "@t3tools/shared/codexAuthHandoff";
4import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl";
5import { isLoopbackHost } from "@t3tools/shared/preview";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as Stream from "effect/Stream";
10
11/** A connected local environment receives the callback; only the remote environment owns tokens. */
12export function subscribeCodexAuthCallback(input: CodexAuthCallbackInput) {
13 const failure = (error: unknown) =>
14 new ProviderSetupError({
15 instanceId: input.instanceId,
16 operation: "callback",
17 detail:
18 error instanceof Error ? error.message : "Could not receive sign-in on this computer.",
19 });
20 return Stream.unwrap(
21 Effect.gen(function* () {
22 const destination = yield* Effect.try({
23 try: () => {
24 codexAuthorizationRequest(input.authorizationUrl);
25 const destination = providerAuthReturnUrl(input.returnUrl);
26 if (!destination || !isLoopbackHost(new URL(destination).hostname))
27 throw new Error("The local sign-in receiver needs a local T3 Code return address.");
28 return destination;
29 },
30 catch: failure,
31 });
32 const ready = yield* Deferred.make<void, ProviderSetupError>();
33 const runSync = Effect.runSyncWith(yield* Effect.context<never>());
34 const callback = yield* Effect.tryPromise({
35 try: (signal) =>
36 receiveCodexAuthCallback(
37 input.authorizationUrl,
38 async () => {
39 runSync(Deferred.succeed(ready, undefined));
40 return true;
41 },
42 () => destination,
43 signal,
44 ),
45 catch: failure,
46 }).pipe(
47 Effect.tapError((error) => Deferred.fail(ready, error)),
48 Effect.forkScoped,
49 );
50 return Stream.fromEffect(Deferred.await(ready)).pipe(
51 Stream.map(() => ({ phase: "ready" as const })),
52 Stream.concat(
53 Stream.fromEffect(Fiber.join(callback)).pipe(
54 Stream.map((callbackUrl) => ({ phase: "finished" as const, callbackUrl })),
55 ),
56 ),
57 );
58 }),
59 );
60}
apps/server/src/provider/CodexAuthCallbackPage.ts created+42
......@@ -0,0 +1,42 @@
1import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl";
2
3export const codexAuthReturnUrl = providerAuthReturnUrl;
4
5const escapeHtml = (value: string) =>
6 value.replace(
7 /[&<>"']/gu,
8 (character) =>
9 ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]!,
10 );
11
12export function codexAuthCallbackPage(
13 success: boolean,
14 returnUrl: string | undefined,
15 nonce: string,
16) {
17 const destination = codexAuthReturnUrl(returnUrl);
18 const title = success ? "You're signed in" : "Sign-in couldn't finish";
19 const description = success
20 ? destination
21 ? "Returning to T3 Code. You're ready to continue."
22 : "Return to T3 Code to continue. You can close this tab."
23 : "Return to T3 Code and try signing in again.";
24 return `<!doctype html>
25<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
26<meta name="color-scheme" content="light dark"><title>${escapeHtml(title)} · T3 Code</title>
27${success && destination ? `<meta http-equiv="refresh" content="1;url=${escapeHtml(destination)}">` : ""}
28<style>
29:root{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#18181b;background:#fafafa;color-scheme:light dark}
30*{box-sizing:border-box}body{margin:0;min-height:100svh;display:grid;place-items:center;padding:24px}
31main{width:100%;max-width:440px;background:#fff;border:1px solid #e4e4e7;border-radius:20px;padding:32px;box-shadow:0 12px 40px #00000008}
32.brand{font-size:18px;letter-spacing:-.5px;margin-bottom:36px;color:#71717a}.brand strong{color:#18181b}
33.status{width:40px;height:40px;border-radius:50%;display:grid;place-items:center;background:${success ? "#ecfdf5" : "#fef2f2"};color:${success ? "#059669" : "#dc2626"};margin-bottom:20px}
34h1{font-size:24px;font-weight:600;letter-spacing:-.7px;line-height:1.25;margin:0 0 12px}p{font-size:15px;line-height:1.6;color:#71717a;margin:0}
35a{display:inline-flex;align-items:center;justify-content:center;margin-top:28px;padding:10px 16px;border-radius:8px;background:#2563eb;color:white;font-size:14px;font-weight:500;text-decoration:none}a:focus-visible{outline:3px solid #93c5fd;outline-offset:3px}
36@media(prefers-color-scheme:dark){:root{color:#fafafa;background:#09090b}main{background:#18181b;border-color:#27272a;box-shadow:0 12px 40px #0003}.brand,p{color:#a1a1aa}.brand strong{color:#fafafa}.status{background:${success ? "#064e3b" : "#450a0a"};color:${success ? "#34d399" : "#f87171"}}}
37</style></head><body><main><div class="brand"><strong>T3</strong> Code</div>
38<div class="status" aria-hidden="true"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${success ? '<path d="m5 12 4 4L19 6"/>' : '<path d="m6 6 12 12M6 18 18 6"/>'}</svg></div>
39<h1>${escapeHtml(title)}</h1><p>${description}</p>
40${destination ? `<a href="${escapeHtml(destination)}">Return to T3 Code</a>` : ""}
41</main><script nonce="${escapeHtml(nonce)}">history.replaceState(null,"","/auth/callback");</script></body></html>`;
42}
apps/server/src/provider/CodexChatGptAuth.test.ts created+2063
......@@ -0,0 +1,2063 @@
1// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off preferSchemaOverJson:off - Local mock OAuth server validates the browser callback boundary.
2import * as NodeHttp from "node:http";
3import * as NodeCrypto from "node:crypto";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { assert, it } from "@effect/vitest";
6import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Stream from "effect/Stream";
11import * as Fiber from "effect/Fiber";
12import * as Deferred from "effect/Deferred";
13import * as TestClock from "effect/testing/TestClock";
14import { subscribeChatGptHandoff } from "./CodexChatGptHandoff.ts";
15import { FetchHttpClient } from "effect/unstable/http";
16import { exportJWK, generateKeyPair, SignJWT } from "jose";
17import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
18import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts";
19import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
20import { layerTest as settingsLayerTest } from "../serverSettings.ts";
21import { AnalyticsService } from "../telemetry/AnalyticsService.ts";
22import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts";
23
24const assertSameCallback = (actual: string | null, expected: string | null) => {
25 const left = new URL(actual!);
26 const right = new URL(expected!);
27 assert.strictEqual(left.protocol, right.protocol);
28 assert.strictEqual(left.hostname, right.hostname);
29 assert.strictEqual(left.pathname, right.pathname);
30};
31const environmentIds = new WeakMap<Map<string, Uint8Array>, EnvironmentId>();
32const instanceId = ProviderInstanceId.make("managed-codex-test");
33const makeHarnessFor = Effect.fnUntraced(function* (
34 instanceId: ProviderInstanceId,
35 bytes: Map<string, Uint8Array> = new Map(),
36 failAnalytics = false,
37) {
38 const environmentId = environmentIds.get(bytes) ?? EnvironmentId.make(NodeCrypto.randomUUID());
39 environmentIds.set(bytes, environmentId);
40 const environment = ServerEnvironmentIdentity.of({
41 getEnvironmentId: Effect.succeed(environmentId),
42 });
43 const keys = yield* Effect.promise(() => generateKeyPair("RS256"));
44 const jwk = yield* Effect.promise(() => exportJWK(keys.publicKey));
45 const untrustedKeys = yield* Effect.promise(() => generateKeyPair("RS256"));
46 const secrets = ServerSecretStore.of({
47 get: (name) => Effect.sync(() => Option.fromUndefinedOr(bytes.get(name))),
48 set: (name, value) =>
49 Effect.sync(() => {
50 bytes.set(name, value);
51 }),
52 remove: (name) =>
53 Effect.sync(() => {
54 bytes.delete(name);
55 }),
56 create: () => Effect.die("unused"),
57 getOrCreateRandom: () => Effect.die("Host identity must come from the environment."),
58 });
59 let authorize: URL | undefined;
60 let refreshes = 0;
61 let revoked = false;
62 let refreshError: string | undefined;
63 let revocationStatus = 200;
64 let codeError: string | undefined;
65 const revocations: URLSearchParams[] = [];
66 let transient = false;
67 let invalidNonce = false;
68 let identityFailure: "issuer" | "audience" | "signature" | undefined;
69 let mismatchedState = false;
70 let callbackClientId: string | undefined;
71 let subject = "user-test";
72 let email = "hidden@example.test";
73 let grantScope = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct";
74 let origin = "";
75 const exchanges: URLSearchParams[] = [];
76 const authorizationRequests: URL[] = [];
77 const callbackResponses: { body: string; headers: Headers }[] = [];
78 let returnUrl = "http://localhost:7001/welcome";
79 const server = yield* Effect.acquireRelease(
80 Effect.promise(
81 () =>
82 new Promise<NodeHttp.Server>((resolve) => {
83 const server = NodeHttp.createServer(async (request, response) => {
84 response.setHeader("content-type", "application/json");
85 if (request.url === "/discovery") {
86 response.end(
87 JSON.stringify({
88 issuer: origin,
89 authorization_endpoint: `${origin}/authorize`,
90 token_endpoint: `${origin}/token`,
91 jwks_uri: `${origin}/jwks`,
92 revocation_endpoint: `${origin}/revoke`,
93 }),
94 );
95 return;
96 }
97 if (request.url === "/jwks") {
98 response.end(
99 JSON.stringify({ keys: [{ ...jwk, kid: "test", alg: "RS256", use: "sig" }] }),
100 );
101 return;
102 }
103 if (request.url === "/revoke") {
104 let text = "";
105 for await (const chunk of request) text += chunk.toString();
106 revocations.push(new URLSearchParams(text));
107 response.statusCode = revocationStatus;
108 response.end();
109 return;
110 }
111 if (request.url === "/token") {
112 let text = "";
113 for await (const chunk of request) text += chunk.toString();
114 assert.strictEqual(
115 request.headers["content-type"],
116 "application/x-www-form-urlencoded",
117 );
118 assert.isUndefined(request.headers.authorization);
119 const body = new URLSearchParams(text);
120 assert.isFalse(body.has("client_secret"));
121 assert.strictEqual(body.get("resource"), `${origin}/v1`);
122 exchanges.push(body);
123 if (body.get("grant_type") === "refresh_token") {
124 refreshes++;
125 if (transient) {
126 response.statusCode = 503;
127 response.end(JSON.stringify({ error: "temporarily_unavailable" }));
128 return;
129 }
130 if (revoked || refreshError) {
131 response.statusCode = 400;
132 response.end(JSON.stringify({ error: refreshError ?? "invalid_grant" }));
133 return;
134 }
135 response.end(
136 JSON.stringify({
137 access_token: `access-${refreshes}`,
138 refresh_token: `refresh-${refreshes}`,
139 token_type: "Bearer",
140 expires_in: 3600,
141 scope: grantScope,
142 }),
143 );
144 return;
145 }
146 if (codeError) {
147 response.statusCode = 400;
148 response.end(JSON.stringify({ error: codeError }));
149 return;
150 }
151 if (!authorize) {
152 response.statusCode = 400;
153 response.end("{}");
154 return;
155 }
156 assertSameCallback(
157 body.get("redirect_uri"),
158 authorize.searchParams.get("redirect_uri"),
159 );
160 assert.strictEqual(
161 NodeCrypto.createHash("sha256")
162 .update(body.get("code_verifier")!)
163 .digest("base64url"),
164 authorize.searchParams.get("code_challenge"),
165 );
166 const token = await new SignJWT({
167 nonce: invalidNonce ? "incorrect" : authorize.searchParams.get("nonce"),
168 email,
169 })
170 .setProtectedHeader({ alg: "RS256", kid: "test" })
171 .setIssuer(identityFailure === "issuer" ? "https://untrusted-issuer.test" : origin)
172 .setAudience(
173 identityFailure === "audience"
174 ? "oaiapp_untrusted_audience"
175 : body.get("client_id")!,
176 )
177 .setSubject(subject)
178 .setIssuedAt()
179 .setExpirationTime("1h")
180 .sign(identityFailure === "signature" ? untrustedKeys.privateKey : keys.privateKey);
181 response.end(
182 JSON.stringify({
183 access_token: "initial-access",
184 refresh_token: "initial-refresh",
185 id_token: token,
186 token_type: "Bearer",
187 expires_in: 3600,
188 scope: grantScope,
189 }),
190 );
191 return;
192 }
193 response.statusCode = 404;
194 response.end("{}");
195 });
196 server.listen(0, "127.0.0.1", () => resolve(server));
197 }),
198 ),
199 (server) =>
200 Effect.promise(
201 () =>
202 new Promise<void>((resolve) => {
203 server.closeAllConnections();
204 server.close(() => resolve());
205 }),
206 ),
207 );
208 const address = server.address();
209 if (!address || typeof address === "string") throw new Error("mock address");
210 origin = `http://127.0.0.1:${address.port}`;
211 const analyticsEvents: {
212 event: string;
213 properties: Readonly<Record<string, unknown>> | undefined;
214 }[] = [];
215 const analytics = AnalyticsService.of({
216 record: (event, properties) =>
217 failAnalytics
218 ? Effect.die("analytics unavailable")
219 : Effect.sync(() => {
220 analyticsEvents.push({ event, properties });
221 }),
222 flush: Effect.void,
223 });
224 const auth = yield* makeCodexChatGptAuth({
225 instanceId,
226 discoveryUrl: `${origin}/discovery`,
227 resource: `${origin}/v1`,
228 }).pipe(
229 Effect.provideService(AnalyticsService, analytics),
230 Effect.provideService(ServerSecretStore, secrets),
231 Effect.provideService(ServerEnvironmentIdentity, environment),
232 );
233 const phase = (phase: string) =>
234 auth.controller.subscribe("owner").pipe(
235 Stream.filter((state) => state.phase === phase),
236 Stream.runHead,
237 Effect.map(Option.getOrThrow),
238 );
239 const prepareCallback = (waiting: { authorizationUrl: string | null }) => {
240 authorize = new URL(waiting.authorizationUrl!);
241 authorizationRequests.push(authorize);
242 const callback = new URL(authorize.searchParams.get("redirect_uri")!);
243 callback.search = new URLSearchParams({
244 code: "authorization-code",
245 state: mismatchedState ? "unmatched-state" : authorize.searchParams.get("state")!,
246 ...(callbackClientId
247 ? { client_id: callbackClientId }
248 : authorize.searchParams.get("client_id") === "dynamic_agent_client"
249 ? { client_id: "oaiapp_test" }
250 : {}),
251 }).toString();
252 return callback;
253 };
254 const startRemote = (methodId?: string) =>
255 Effect.gen(function* () {
256 yield* auth.controller.start("owner", Effect.void, methodId, returnUrl, "client");
257 const waiting = yield* phase("waiting");
258 return { waiting, callbackUrl: prepareCallback(waiting).toString() };
259 });
260 const signInWithMethod = (methodId?: string) =>
261 Effect.gen(function* () {
262 yield* auth.controller.start("owner", Effect.void, methodId, returnUrl);
263 const waiting = yield* phase("waiting");
264 assert.strictEqual(waiting.interaction?.type, "browser");
265 const callback = prepareCallback(waiting);
266 yield* Effect.promise(async () => {
267 const response = await fetch(callback);
268 callbackResponses.push({ body: await response.text(), headers: response.headers });
269 });
270 });
271 const seedExpired = Effect.gen(function* () {
272 const stored = yield* auth.read;
273 const record = Option.getOrThrow(stored);
274 const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe(
275 Effect.provideService(ServerSecretStore, secrets),
276 );
277 yield* store.set(new TextEncoder().encode(JSON.stringify({ ...record, expiresAt: 0 })));
278 });
279 return {
280 auth,
281 analyticsEvents,
282 secrets,
283 environmentId,
284 bytes,
285 handoff: (profile: Parameters<typeof subscribeChatGptHandoff>[0]["profile"]) =>
286 subscribeChatGptHandoff(
287 {
288 instanceId,
289 environmentId,
290 attemptId: "test-handoff",
291 returnUrl,
292 profile,
293 },
294 "owner",
295 { discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1` },
296 ).pipe(Stream.provideService(AnalyticsService, analytics)),
297 finishCallback: (state: { authorizationUrl: string | null }) =>
298 Effect.promise(() => fetch(prepareCallback(state))),
299 destination: Effect.gen(function* () {
300 const destinationBytes = new Map<string, Uint8Array>();
301 const destinationStore = ServerSecretStore.of({
302 ...secrets,
303 get: (name) => Effect.sync(() => Option.fromUndefinedOr(destinationBytes.get(name))),
304 set: (name, value) =>
305 Effect.sync(() => {
306 destinationBytes.set(name, value);
307 }),
308 remove: (name) =>
309 Effect.sync(() => {
310 destinationBytes.delete(name);
311 }),
312 });
313 const destination = yield* makeCodexChatGptAuth({
314 instanceId,
315 discoveryUrl: `${origin}/discovery`,
316 resource: `${origin}/v1`,
317 }).pipe(
318 Effect.provideService(AnalyticsService, analytics),
319 Effect.provideService(ServerSecretStore, destinationStore),
320 Effect.provideService(ServerEnvironmentIdentity, environment),
321 );
322 return { auth: destination, bytes: destinationBytes };
323 }),
324 recreateAuth: makeCodexChatGptAuth({
325 instanceId,
326 discoveryUrl: `${origin}/discovery`,
327 resource: `${origin}/v1`,
328 }).pipe(
329 Effect.provideService(ServerSecretStore, secrets),
330 Effect.provideService(ServerEnvironmentIdentity, environment),
331 ),
332 startRemote,
333 signIn: signInWithMethod(),
334 changeAccount: signInWithMethod("chatgpt-change-account"),
335 reconnectProfile: (clientId: string) => signInWithMethod(`chatgpt-profile:${clientId}`),
336 phase,
337 seedExpired,
338 exchanges,
339 authorizationRequests,
340 callbackResponses,
341 setReturnUrl: (value: string) => {
342 returnUrl = value;
343 },
344 origin,
345 storedRecords: () =>
346 Array.from(bytes.entries()).flatMap(([, value]) => {
347 const record = JSON.parse(new TextDecoder().decode(value));
348 return record.sessions ?? [record];
349 }),
350 revocations,
351 setRefreshError: (value: string) => {
352 refreshError = value;
353 },
354 setRevocationStatus: (value: number) => {
355 revocationStatus = value;
356 },
357 setCodeError: (value: string | undefined) => {
358 codeError = value;
359 },
360 refreshes: () => refreshes,
361 setRevoked: () => {
362 revoked = true;
363 },
364 setTransient: (value: boolean) => {
365 transient = value;
366 },
367 setInvalidNonce: () => {
368 invalidNonce = true;
369 },
370 setIdentityFailure: (value: "issuer" | "audience" | "signature") => {
371 identityFailure = value;
372 },
373 mismatchCallbackState: () => {
374 mismatchedState = true;
375 },
376 setCallbackClientId: (value: string) => {
377 callbackClientId = value;
378 },
379 setIdentity: (newSubject: string, newEmail: string) => {
380 subject = newSubject;
381 email = newEmail;
382 },
383 declineSharing: () => {
384 grantScope = "openid profile email";
385 },
386 };
387});
388const makeHarness = makeHarnessFor(instanceId);
389const provision = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
390 effect.pipe(
391 Effect.scoped,
392 Effect.provide(Layer.mergeAll(FetchHttpClient.layer, NodeServices.layer)),
393 );
394it.effect(
395 "changing account registers a new user-owned client and then reuses that registration",
396 () =>
397 provision(
398 Effect.gen(function* () {
399 const h = yield* makeHarness;
400 yield* h.signIn;
401 yield* h.phase("succeeded");
402 h.setCallbackClientId("oaiapp_other_account");
403 h.setIdentity("other-user", "other@example.test");
404 yield* h.changeAccount;
405 yield* h.phase("succeeded");
406 assert.strictEqual(
407 h.authorizationRequests[1]!.searchParams.get("client_id"),
408 "dynamic_agent_client",
409 );
410 assert.strictEqual(h.exchanges[1]!.get("client_id"), "oaiapp_other_account");
411 const saved = Option.getOrThrow(yield* h.auth.read);
412 assert.strictEqual(saved.clientId, "oaiapp_other_account");
413 assert.strictEqual(saved.subject, "other-user");
414 assert.strictEqual(saved.email, "other@example.test");
415 const redirectUri = h.authorizationRequests[1]!.searchParams.get("redirect_uri");
416 yield* h.signIn;
417 yield* h.phase("succeeded");
418 assert.strictEqual(
419 h.authorizationRequests[2]!.searchParams.get("client_id"),
420 "oaiapp_other_account",
421 );
422 assertSameCallback(
423 h.authorizationRequests[2]!.searchParams.get("redirect_uri"),
424 redirectUri,
425 );
426 }),
427 ),
428);
429for (const failure of ["identity", "sharing"] as const) {
430 it.effect(
431 `failed account change preserves the original credentials and registration: ${failure}`,
432 () =>
433 provision(
434 Effect.gen(function* () {
435 const h = yield* makeHarness;
436 yield* h.signIn;
437 yield* h.phase("succeeded");
438 const before = h.storedRecords();
439 h.setCallbackClientId("oaiapp_other_account");
440 if (failure === "identity") h.setInvalidNonce();
441 else h.declineSharing();
442 yield* h.changeAccount;
443 yield* h.phase("failed");
444 assert.strictEqual(
445 h.authorizationRequests[1]!.searchParams.get("client_id"),
446 "dynamic_agent_client",
447 );
448 if (failure === "sharing") {
449 assert.deepEqual(
450 Option.getOrThrow(yield* h.auth.read),
451 before.find((record) => record.accessToken),
452 );
453 assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2);
454 } else assert.deepEqual(h.storedRecords(), before);
455 }),
456 ),
457 );
458}
459it.effect("retains the callback host and path after controller recreation and token removal", () =>
460 provision(
461 Effect.gen(function* () {
462 const bytes = new Map<string, Uint8Array>();
463 const first = yield* makeHarnessFor(instanceId, bytes);
464 yield* first.signIn;
465 yield* first.phase("succeeded");
466 const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri");
467 yield* first.auth.revoke;
468 const restarted = yield* makeHarnessFor(instanceId, bytes);
469 yield* restarted.signIn;
470 yield* restarted.phase("succeeded");
471 assert.strictEqual(
472 restarted.authorizationRequests[0]!.searchParams.get("client_id"),
473 "oaiapp_test",
474 );
475 assertSameCallback(
476 restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"),
477 redirectUri,
478 );
479 assert.strictEqual(
480 restarted.exchanges[0]!.get("redirect_uri"),
481 restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"),
482 );
483 }),
484 ),
485);
486it.effect("reauthorizes on an available port when the original callback port is occupied", () =>
487 provision(
488 Effect.gen(function* () {
489 const h = yield* makeHarness;
490 yield* h.signIn;
491 yield* h.phase("succeeded");
492 const port = Number(
493 new URL(h.authorizationRequests[0]!.searchParams.get("redirect_uri")!).port,
494 );
495 yield* Effect.acquireRelease(
496 Effect.promise(
497 () =>
498 new Promise<NodeHttp.Server>((resolve, reject) => {
499 const server = NodeHttp.createServer();
500 server.once("error", reject);
501 server.listen(port, "127.0.0.1", () => resolve(server));
502 }),
503 ),
504 (server) =>
505 Effect.promise(() => new Promise<void>((resolve) => server.close(() => resolve()))),
506 );
507 yield* h.signIn;
508 yield* h.phase("succeeded");
509 assert.notStrictEqual(
510 Number(new URL(h.authorizationRequests[1]!.searchParams.get("redirect_uri")!).port),
511 port,
512 );
513 assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("client_id"), "oaiapp_test");
514 assert.strictEqual(h.exchanges.length, 2);
515 }),
516 ),
517);
518
519it.effect("registers a fresh client when the original registration is no longer stored", () =>
520 provision(
521 Effect.gen(function* () {
522 const bytes = new Map<string, Uint8Array>();
523 const h = yield* makeHarnessFor(instanceId, bytes);
524 yield* h.signIn;
525 yield* h.phase("succeeded");
526 for (const [key, value] of bytes) {
527 const record = JSON.parse(new TextDecoder().decode(value));
528 if (record.profiles) bytes.delete(key);
529 }
530 h.setCallbackClientId("oaiapp_replacement");
531 yield* h.signIn;
532 yield* h.phase("succeeded");
533 assert.strictEqual(
534 h.authorizationRequests[1]!.searchParams.get("client_id"),
535 "dynamic_agent_client",
536 );
537 assert.strictEqual(h.exchanges[1]!.get("client_id"), "oaiapp_replacement");
538 assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_replacement");
539 }),
540 ),
541);
542it.effect("reauthorizes a registration without persisting its original port", () =>
543 provision(
544 Effect.gen(function* () {
545 const bytes = new Map<string, Uint8Array>();
546 const h = yield* makeHarnessFor(instanceId, bytes);
547 yield* h.signIn;
548 yield* h.phase("succeeded");
549 yield* h.auth.revoke;
550 for (const [key, value] of bytes) {
551 const record = JSON.parse(new TextDecoder().decode(value));
552 if (record.profiles)
553 bytes.set(key, new TextEncoder().encode(JSON.stringify({ clientId: "oaiapp_test" })));
554 }
555 yield* h.signIn;
556 yield* h.phase("succeeded");
557 assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("client_id"), "oaiapp_test");
558 assert.strictEqual(new URL(h.exchanges[1]!.get("redirect_uri")!).hostname, "127.0.0.1");
559 }),
560 ),
561);
562
563it.effect(
564 "separate Codex accounts register independently and disconnect only their own tokens",
565 () =>
566 provision(
567 Effect.gen(function* () {
568 const bytes = new Map<string, Uint8Array>();
569 const personal = yield* makeHarnessFor(ProviderInstanceId.make("codex_personal"), bytes);
570 const work = yield* makeHarnessFor(ProviderInstanceId.make("codex_work"), bytes);
571 yield* personal.signIn;
572 yield* personal.phase("succeeded");
573 const personalCredentials = Option.getOrThrow(yield* personal.auth.read);
574 assert.isTrue(Option.isNone(yield* work.auth.read));
575 yield* work.signIn;
576 yield* work.phase("succeeded");
577 assert.strictEqual(
578 work.authorizationRequests[0]!.searchParams.get("client_id"),
579 "dynamic_agent_client",
580 );
581 assert.notStrictEqual(
582 personal.auth.controller.credentialBinding?.key,
583 work.auth.controller.credentialBinding?.key,
584 );
585 yield* work.auth.controller.logout(Effect.void);
586 assert.isTrue(Option.isNone(yield* work.auth.read));
587 assert.deepEqual(Option.getOrThrow(yield* personal.auth.read), personalCredentials);
588 }),
589 ),
590);
591it.effect(
592 "emits the guide's first-time authorization request and fresh reauthorization values",
593 () =>
594 provision(
595 Effect.gen(function* () {
596 const h = yield* makeHarness;
597 yield* h.signIn;
598 yield* h.phase("succeeded");
599 assert.include(
600 h.callbackResponses[0]!.body,
601 'content="1;url=http://localhost:7001/welcome"',
602 );
603 const first = h.authorizationRequests[0]!;
604 assert.strictEqual(
605 first.searchParams.get("ext_agent_host_id"),
606 `urn:uuid:${h.environmentId}`,
607 );
608 assert.strictEqual(first.origin, h.origin);
609 assert.strictEqual(first.pathname, "/authorize");
610 assert.deepEqual(Array.from(first.searchParams.keys()).sort(), [
611 "agent_name_hint",
612 "client_id",
613 "code_challenge",
614 "code_challenge_method",
615 "ext_agent_host_id",
616 "nonce",
617 "redirect_uri",
618 "resource",
619 "response_type",
620 "scope",
621 "state",
622 ]);
623 assert.strictEqual(first.searchParams.get("client_id"), "dynamic_agent_client");
624 assert.strictEqual(first.searchParams.get("agent_name_hint"), "T3 Code");
625 assert.strictEqual(first.searchParams.get("response_type"), "code");
626 assert.strictEqual(
627 first.searchParams.get("scope"),
628 "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct",
629 );
630 assert.strictEqual(first.searchParams.get("resource"), `${h.origin}/v1`);
631 assert.strictEqual(first.searchParams.get("code_challenge_method"), "S256");
632 const callback = new URL(first.searchParams.get("redirect_uri")!);
633 assert.strictEqual(callback.protocol, "http:");
634 assert.strictEqual(callback.hostname, "127.0.0.1");
635 assert.strictEqual(callback.pathname, "/auth/callback");
636 assert.isAbove(Number(callback.port), 0);
637 for (const key of ["state", "nonce", "code_challenge"]) {
638 assert.match(first.searchParams.get(key)!, /^[A-Za-z0-9_-]{43}$/u);
639 }
640 assert.notStrictEqual(first.searchParams.get("state"), first.searchParams.get("nonce"));
641 const originalIdToken = Option.getOrThrow(yield* h.auth.read).idToken;
642 yield* h.signIn;
643 yield* h.phase("succeeded");
644 const second = h.authorizationRequests[1]!;
645 assert.strictEqual(second.searchParams.get("client_id"), "oaiapp_test");
646 assert.isFalse(second.searchParams.has("agent_name_hint"));
647 assert.strictEqual(second.searchParams.get("login_hint"), "hidden@example.test");
648 assert.strictEqual(second.searchParams.get("id_token_hint"), originalIdToken);
649 assert.strictEqual(
650 second.searchParams.get("ext_agent_host_id"),
651 first.searchParams.get("ext_agent_host_id"),
652 );
653 assertSameCallback(
654 second.searchParams.get("redirect_uri"),
655 first.searchParams.get("redirect_uri"),
656 );
657 assert.deepEqual(
658 Array.from(second.searchParams.keys()).sort(),
659 [
660 ...Array.from(first.searchParams.keys()).filter((key) => key !== "agent_name_hint"),
661 "login_hint",
662 "id_token_hint",
663 ].sort(),
664 );
665 for (const key of ["state", "nonce", "code_challenge"]) {
666 assert.notStrictEqual(first.searchParams.get(key), second.searchParams.get(key));
667 }
668 yield* h.auth.controller.logout(Effect.void);
669 yield* h.signIn;
670 yield* h.phase("succeeded");
671 const reconnect = h.authorizationRequests[2]!;
672 assert.strictEqual(reconnect.searchParams.get("client_id"), "oaiapp_test");
673 assertSameCallback(
674 reconnect.searchParams.get("redirect_uri"),
675 first.searchParams.get("redirect_uri"),
676 );
677 assert.isFalse(reconnect.searchParams.has("agent_name_hint"));
678 assert.isFalse(reconnect.searchParams.has("id_token_hint"));
679 assert.strictEqual(reconnect.searchParams.get("login_hint"), "hidden@example.test");
680 assert.strictEqual(
681 reconnect.searchParams.get("ext_agent_host_id"),
682 first.searchParams.get("ext_agent_host_id"),
683 );
684 assert.strictEqual(
685 h.exchanges[2]!.get("redirect_uri"),
686 reconnect.searchParams.get("redirect_uri"),
687 );
688 }),
689 ),
690);
691it.effect(
692 "returns to the initiating client only after sign-in has verified and saved credentials",
693 () =>
694 provision(
695 Effect.gen(function* () {
696 const h = yield* makeHarness;
697 h.setReturnUrl(
698 "http://localhost:7001/settings/providers?instanceId=codex_work&code=secret-code",
699 );
700 yield* h.signIn;
701 assert.isTrue(Option.isSome(yield* h.auth.read));
702 const response = h.callbackResponses[0]!;
703 assert.include(response.headers.get("content-type")!, "text/html");
704 assert.strictEqual(response.headers.get("cache-control"), "no-store");
705 assert.strictEqual(response.headers.get("referrer-policy"), "no-referrer");
706 assert.include(response.headers.get("content-security-policy")!, "default-src 'none'");
707 assert.include(response.body, "You're signed in".replace("'", "&#39;"));
708 assert.include(
709 response.body,
710 'content="1;url=http://localhost:7001/settings/providers?instanceId=codex_work"',
711 );
712 assert.notInclude(response.body, "secret-code");
713 assert.include(response.body, 'history.replaceState(null,"","/auth/callback")');
714 assert.notInclude(response.body, "authorization-code");
715 assert.notInclude(response.body, "initial-access");
716 assert.notInclude(response.body, "hidden@example.test");
717 }),
718 ),
719);
720it.effect("preserves the Welcome agents step and strips unrelated callback return parameters", () =>
721 provision(
722 Effect.gen(function* () {
723 const h = yield* makeHarness;
724 h.setReturnUrl("http://localhost:7001/welcome?code=never-forward#agents:test-environment");
725 yield* h.signIn;
726 assert.include(
727 h.callbackResponses[0]!.body,
728 'content="1;url=http://localhost:7001/welcome#agents:test-environment"',
729 );
730 assert.notInclude(h.callbackResponses[0]!.body, "never-forward");
731 }),
732 ),
733);
734it.effect(
735 "does not redirect to an arbitrary return URL or claim success after verification fails",
736 () =>
737 provision(
738 Effect.gen(function* () {
739 const h = yield* makeHarness;
740 h.setReturnUrl("https://attacker.example/welcome");
741 h.setInvalidNonce();
742 yield* h.signIn;
743 yield* h.phase("failed");
744 const response = h.callbackResponses[0]!;
745 assert.include(response.body, "Sign-in couldn&#39;t finish");
746 assert.notInclude(response.body, 'http-equiv="refresh"');
747 assert.notInclude(response.body, "attacker.example");
748 assert.isTrue(Option.isNone(yield* h.auth.read));
749 }),
750 ),
751);
752it.effect(
753 "verifies registration, PKCE, identity and persists rotating refresh before concurrent access",
754 () =>
755 provision(
756 Effect.gen(function* () {
757 const h = yield* makeHarness;
758 yield* h.signIn;
759 yield* h.phase("succeeded");
760 assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "user-test");
761 assert.strictEqual(h.exchanges[0]?.get("client_id"), "oaiapp_test");
762 yield* h.seedExpired;
763 const records = yield* Effect.all([h.auth.access, h.auth.access], {
764 concurrency: "unbounded",
765 });
766 assert.strictEqual(h.refreshes(), 1);
767 assert.strictEqual(records[0].accessToken, "access-1");
768 assert.strictEqual(records[1].refreshToken, "refresh-1");
769 assert.isNull(h.exchanges[1]?.get("scope"));
770 yield* h.auth.controller.logout(Effect.void);
771 assert.isTrue(Option.isNone(yield* h.auth.read));
772 }),
773 ),
774);
775it.effect("rejects invalid ID token nonce without saving credentials", () =>
776 provision(
777 Effect.gen(function* () {
778 const h = yield* makeHarness;
779 h.setInvalidNonce();
780 yield* h.signIn;
781 assert.include((yield* h.phase("failed")).message!, "could not be verified");
782 assert.isTrue(Option.isNone(yield* h.auth.read));
783 }),
784 ),
785);
786it.effect("retains identity after declined sharing but never admits inference", () =>
787 provision(
788 Effect.gen(function* () {
789 const h = yield* makeHarness;
790 h.declineSharing();
791 yield* h.signIn;
792 yield* h.phase("failed");
793 assert.isTrue(Option.isSome(yield* h.auth.read));
794 const result = yield* h.auth.access.pipe(Effect.result);
795 assert.strictEqual(result._tag, "Failure");
796 assert.strictEqual(h.refreshes(), 0);
797 }),
798 ),
799);
800it.effect("clears revoked refresh credentials and does not replay them", () =>
801 provision(
802 Effect.gen(function* () {
803 const h = yield* makeHarness;
804 yield* h.signIn;
805 yield* h.phase("succeeded");
806 yield* h.seedExpired;
807 h.setRevoked();
808 yield* h.auth.access.pipe(Effect.result);
809 yield* h.auth.access.pipe(Effect.result);
810 assert.strictEqual(h.refreshes(), 1);
811 assert.isTrue(Option.isNone(yield* h.auth.read));
812 }),
813 ),
814);
815
816it.effect(
817 "preserves credentials through temporary renewal failure and retries the latest refresh",
818 () =>
819 provision(
820 Effect.gen(function* () {
821 const h = yield* makeHarness;
822 yield* h.signIn;
823 yield* h.phase("succeeded");
824 yield* h.seedExpired;
825 h.setTransient(true);
826 yield* h.auth.access.pipe(Effect.result);
827 assert.strictEqual(Option.getOrThrow(yield* h.auth.read).refreshToken, "initial-refresh");
828 h.setTransient(false);
829 const renewed = yield* h.auth.access;
830 assert.strictEqual(renewed.refreshToken, "refresh-2");
831 assert.strictEqual(h.exchanges.at(-1)?.get("refresh_token"), "initial-refresh");
832 }),
833 ),
834);
835
836it.effect(
837 "disconnect retains the remembered account profile while a different account registers afresh",
838 () =>
839 provision(
840 Effect.gen(function* () {
841 const h = yield* makeHarness;
842 yield* h.signIn;
843 yield* h.phase("succeeded");
844 assert.strictEqual(
845 h.authorizationRequests[0]?.searchParams.get("client_id"),
846 "dynamic_agent_client",
847 );
848 yield* h.auth.controller.logout(Effect.void);
849 assert.isTrue(Option.isNone(yield* h.auth.read));
850 assert.deepEqual(h.storedRecords(), [
851 {
852 profiles: [
853 {
854 clientId: "oaiapp_test",
855 connectionLabel: "Connection 1",
856 sharingEnabled: true,
857 redirectUri: h.authorizationRequests[0]!.searchParams.get("redirect_uri"),
858 subject: "user-test",
859 email: "hidden@example.test",
860 },
861 ],
862 lastClientId: "oaiapp_test",
863 },
864 ]);
865 const disconnected = yield* h.auth.controller
866 .subscribe("owner")
867 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
868 assert.include(
869 disconnected.methods!.find((method) => method.id === "chatgpt")!.description!,
870 "hidden@example.test",
871 );
872 assert.strictEqual(
873 disconnected.methods!.find((method) => method.id === "chatgpt")!.accountEmail,
874 "hidden@example.test",
875 );
876 assert.strictEqual(
877 disconnected.methods!.find((method) => method.id === "chatgpt-profile:oaiapp_test")!
878 .accountEmail,
879 "hidden@example.test",
880 );
881 assert.strictEqual((yield* h.auth.access.pipe(Effect.result))._tag, "Failure");
882 assert.strictEqual(h.refreshes(), 0);
883 h.setCallbackClientId("oaiapp_different_workspace");
884 h.setIdentity("different-user", "different@example.test");
885 yield* h.changeAccount;
886 yield* h.phase("succeeded");
887 assert.strictEqual(
888 h.authorizationRequests[1]?.searchParams.get("client_id"),
889 "dynamic_agent_client",
890 );
891 assert.strictEqual(h.exchanges[1]?.get("client_id"), "oaiapp_different_workspace");
892 assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "different-user");
893 }),
894 ),
895);
896it.effect("revoked connection clears tokens but preserves registration for reconnect", () =>
897 provision(
898 Effect.gen(function* () {
899 const h = yield* makeHarness;
900 yield* h.signIn;
901 yield* h.phase("succeeded");
902 yield* h.seedExpired;
903 h.setRevoked();
904 yield* h.auth.access.pipe(Effect.result);
905 assert.isTrue(Option.isNone(yield* h.auth.read));
906 assert.deepEqual(h.storedRecords(), [
907 {
908 profiles: [
909 {
910 clientId: "oaiapp_test",
911 connectionLabel: "Connection 1",
912 sharingEnabled: true,
913 redirectUri: h.authorizationRequests[0]!.searchParams.get("redirect_uri"),
914 subject: "user-test",
915 email: "hidden@example.test",
916 },
917 ],
918 lastClientId: "oaiapp_test",
919 },
920 ]);
921 yield* h.signIn;
922 yield* h.phase("succeeded");
923 assert.strictEqual(h.authorizationRequests[1]?.searchParams.get("client_id"), "oaiapp_test");
924 }),
925 ),
926);
927
928it.effect("fresh sign-in ignores a registration left by the old disconnect behavior", () =>
929 provision(
930 Effect.gen(function* () {
931 const h = yield* makeHarness;
932 yield* h.signIn;
933 yield* h.phase("succeeded");
934 yield* h.auth.revoke;
935 assert.isTrue(Option.isNone(yield* h.auth.read));
936 assert.strictEqual(h.storedRecords().length, 1);
937 h.setCallbackClientId("oaiapp_new_workspace");
938 h.setIdentity("new-workspace-user", "new@example.test");
939 yield* h.changeAccount;
940 yield* h.phase("succeeded");
941 assert.strictEqual(
942 h.authorizationRequests[1]!.searchParams.get("client_id"),
943 "dynamic_agent_client",
944 );
945 assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_new_workspace");
946 assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "new-workspace-user");
947 }),
948 ),
949);
950
951it.effect("rejects mismatched callback state before any token exchange or credential write", () =>
952 provision(
953 Effect.gen(function* () {
954 const h = yield* makeHarness;
955 h.mismatchCallbackState();
956 yield* h.signIn;
957 assert.include((yield* h.phase("failed")).message!, "could not be verified");
958 assert.strictEqual(h.exchanges.length, 0);
959 assert.deepEqual(h.storedRecords(), []);
960 assert.isTrue(Option.isNone(yield* h.auth.read));
961 }),
962 ),
963);
964for (const invalidClaim of ["issuer", "audience", "signature"] as const) {
965 it.effect(
966 `rejects ID token ${invalidClaim} verification before saving tokens or registration`,
967 () =>
968 provision(
969 Effect.gen(function* () {
970 const h = yield* makeHarness;
971 h.setIdentityFailure(invalidClaim);
972 yield* h.signIn;
973 assert.include((yield* h.phase("failed")).message!, "could not be verified");
974 assert.strictEqual(h.exchanges.length, 1);
975 assert.deepEqual(h.storedRecords(), []);
976 assert.isTrue(Option.isNone(yield* h.auth.read));
977 }),
978 ),
979 );
980}
981for (const revoked of [false, true]) {
982 it.effect(
983 `rejects conflicting callback client ID on reauthorization ${revoked ? "after token removal" : "without changing the current account"}`,
984 () =>
985 provision(
986 Effect.gen(function* () {
987 const h = yield* makeHarness;
988 yield* h.signIn;
989 yield* h.phase("succeeded");
990 if (revoked) yield* h.auth.revoke;
991 const before = h.storedRecords();
992 h.setCallbackClientId("oaiapp_untrusted_callback");
993 yield* h.signIn;
994 assert.include((yield* h.phase("failed")).message!, "registration is incomplete");
995 assert.strictEqual(
996 h.authorizationRequests[1]?.searchParams.get("client_id"),
997 "oaiapp_test",
998 );
999 assert.strictEqual(h.exchanges.length, 1);
1000 assert.deepEqual(h.storedRecords(), before);
1001 assert.strictEqual(Option.isNone(yield* h.auth.read), revoked);
1002 }),
1003 ),
1004 );
1005}
1006
1007it.effect("returns successful desktop sign-in to the original Welcome step", () =>
1008 provision(
1009 Effect.gen(function* () {
1010 const h = yield* makeHarness;
1011 h.setReturnUrl("t3code-dev://app/welcome#agents:test-environment");
1012 yield* h.signIn;
1013 yield* h.phase("succeeded");
1014 assert.include(
1015 h.callbackResponses[0]!.body,
1016 'content="1;url=t3code-dev://app/welcome#agents:test-environment"',
1017 );
1018 assert.include(
1019 h.callbackResponses[0]!.body,
1020 'href="t3code-dev://app/welcome#agents:test-environment"',
1021 );
1022 }),
1023 ),
1024);
1025
1026it.effect(
1027 "completes remote sign-in on the owning environment and reuses its exact callback for reauthorization",
1028 () =>
1029 Effect.gen(function* () {
1030 const harness = yield* makeHarnessFor(instanceId);
1031 const first = yield* harness.startRemote();
1032 assert.isTrue(
1033 first.waiting.interaction?.type === "browser" && first.waiting.interaction.acceptsCallback,
1034 );
1035 yield* harness.auth.controller.complete("owner", {
1036 flowId: first.waiting.flowId!,
1037 callbackUrl: first.callbackUrl,
1038 });
1039 yield* harness.phase("succeeded");
1040 assert.strictEqual(Option.getOrThrow(yield* harness.auth.read).email, "hidden@example.test");
1041 const second = yield* harness.startRemote();
1042 assertSameCallback(second.callbackUrl, first.callbackUrl);
1043 assert.strictEqual(
1044 new URL(second.waiting.authorizationUrl!).searchParams.get("client_id"),
1045 "oaiapp_test",
1046 );
1047 yield* harness.auth.controller.complete("owner", {
1048 flowId: second.waiting.flowId!,
1049 callbackUrl: second.callbackUrl,
1050 });
1051 yield* harness.phase("succeeded");
1052 assert.strictEqual(harness.exchanges.length, 2);
1053 }).pipe(
1054 Effect.scoped,
1055 Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)),
1056 ),
1057);
1058
1059it.effect(
1060 "rejects foreign clients and malformed remote callbacks without consuming the owner's sign-in",
1061 () =>
1062 Effect.gen(function* () {
1063 const harness = yield* makeHarnessFor(instanceId);
1064 const { waiting, callbackUrl } = yield* harness.startRemote();
1065 assert.isTrue(Option.isNone(yield* harness.auth.read));
1066 yield* Effect.flip(
1067 harness.auth.controller.complete("other-client", { flowId: waiting.flowId!, callbackUrl }),
1068 );
1069 const wrongPort = new URL(callbackUrl);
1070 wrongPort.port = wrongPort.port === "65535" ? "65534" : "65535";
1071 for (const invalid of [
1072 wrongPort.toString(),
1073 callbackUrl.replace("/auth/callback", "/other"),
1074 callbackUrl + "&state=foreign",
1075 callbackUrl + "&code=duplicate",
1076 callbackUrl + "&error=access_denied",
1077 callbackUrl.replace("127.0.0.1", "attacker.example"),
1078 ]) {
1079 yield* Effect.flip(
1080 harness.auth.controller.complete("owner", {
1081 flowId: waiting.flowId!,
1082 callbackUrl: invalid,
1083 }),
1084 );
1085 }
1086 assert.strictEqual(harness.exchanges.length, 0);
1087 assert.isTrue(Option.isNone(yield* harness.auth.read));
1088 yield* harness.auth.controller.complete("owner", { flowId: waiting.flowId!, callbackUrl });
1089 yield* harness.phase("succeeded");
1090 }).pipe(
1091 Effect.scoped,
1092 Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)),
1093 ),
1094);
1095
1096it.effect("keeps simultaneous remote accounts and environments independent", () =>
1097 Effect.gen(function* () {
1098 const first = yield* makeHarnessFor(instanceId);
1099 const second = yield* makeHarnessFor(instanceId);
1100 second.setIdentity("other-user", "other@example.test");
1101 second.setCallbackClientId("oaiapp_other");
1102 const a = yield* first.startRemote();
1103 const b = yield* second.startRemote();
1104 assert.notStrictEqual(
1105 new URL(a.waiting.authorizationUrl!).searchParams.get("ext_agent_host_id"),
1106 new URL(b.waiting.authorizationUrl!).searchParams.get("ext_agent_host_id"),
1107 );
1108 yield* Effect.flip(
1109 first.auth.controller.complete("owner", {
1110 flowId: a.waiting.flowId!,
1111 callbackUrl: b.callbackUrl,
1112 }),
1113 );
1114 yield* first.auth.controller.complete("owner", {
1115 flowId: a.waiting.flowId!,
1116 callbackUrl: a.callbackUrl,
1117 });
1118 yield* second.auth.controller.complete("owner", {
1119 flowId: b.waiting.flowId!,
1120 callbackUrl: b.callbackUrl,
1121 });
1122 yield* first.phase("succeeded");
1123 yield* second.phase("succeeded");
1124 assert.strictEqual(Option.getOrThrow(yield* first.auth.read).subject, "user-test");
1125 assert.strictEqual(Option.getOrThrow(yield* second.auth.read).subject, "other-user");
1126 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))),
1127);
1128
1129it.effect(
1130 "reconnects after Disconnect and restart with the same client and host on an available port",
1131 () =>
1132 provision(
1133 Effect.gen(function* () {
1134 const bytes = new Map<string, Uint8Array>();
1135 const first = yield* makeHarnessFor(instanceId, bytes);
1136 yield* first.signIn;
1137 yield* first.phase("succeeded");
1138 const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri");
1139 yield* first.auth.controller.logout(Effect.void);
1140 const restarted = yield* makeHarnessFor(instanceId, bytes);
1141 yield* restarted.signIn;
1142 yield* restarted.phase("succeeded");
1143 assert.strictEqual(
1144 restarted.authorizationRequests[0]!.searchParams.get("client_id"),
1145 "oaiapp_test",
1146 );
1147 assertSameCallback(
1148 restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"),
1149 redirectUri,
1150 );
1151 assert.strictEqual(
1152 restarted.exchanges[0]!.get("redirect_uri"),
1153 restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"),
1154 );
1155 assert.strictEqual(Option.getOrThrow(yield* restarted.auth.read).subject, "user-test");
1156 }),
1157 ),
1158);
1159
1160for (const disconnected of [false, true]) {
1161 it.effect(
1162 `rejects a different verified identity during saved-profile reauth ${disconnected ? "after Disconnect" : "while connected"}`,
1163 () =>
1164 provision(
1165 Effect.gen(function* () {
1166 const h = yield* makeHarness;
1167 yield* h.signIn;
1168 yield* h.phase("succeeded");
1169 if (disconnected) yield* h.auth.controller.logout(Effect.void);
1170 const before = h.storedRecords();
1171 h.setIdentity("another-user", "another@example.test");
1172 yield* h.signIn;
1173 assert.include((yield* h.phase("failed")).message!, "different ChatGPT account");
1174 assert.deepEqual(h.storedRecords(), before);
1175 assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected);
1176 }),
1177 ),
1178 );
1179}
1180
1181it.effect(
1182 "retains both profiles and reuses the original account's client and callback when returning from another account",
1183 () =>
1184 provision(
1185 Effect.gen(function* () {
1186 const bytes = new Map<string, Uint8Array>();
1187 const first = yield* makeHarnessFor(instanceId, bytes);
1188 yield* first.signIn;
1189 yield* first.phase("succeeded");
1190 const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri");
1191 first.setIdentity("other-user", "other@example.test");
1192 first.setCallbackClientId("oaiapp_other_account");
1193 yield* first.changeAccount;
1194 const changed = yield* first.phase("succeeded");
1195 assert.isTrue(
1196 changed.methods!.some((method) => method.id === "chatgpt-profile:oaiapp_test"),
1197 );
1198 assert.isTrue(
1199 changed.methods!.some((method) => method.id === "chatgpt-profile:oaiapp_other_account"),
1200 );
1201 yield* first.auth.controller.logout(Effect.void);
1202 const restarted = yield* makeHarnessFor(instanceId, bytes);
1203 yield* restarted.reconnectProfile("oaiapp_test");
1204 yield* restarted.phase("succeeded");
1205 assert.strictEqual(
1206 restarted.authorizationRequests[0]!.searchParams.get("client_id"),
1207 "oaiapp_test",
1208 );
1209 assertSameCallback(
1210 restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"),
1211 redirectUri,
1212 );
1213 assert.strictEqual(Option.getOrThrow(yield* restarted.auth.read).subject, "user-test");
1214 }),
1215 ),
1216);
1217
1218it.effect("fresh sign-in to the same identity preserves separate registration profiles", () =>
1219 provision(
1220 Effect.gen(function* () {
1221 const h = yield* makeHarness;
1222 yield* h.signIn;
1223 yield* h.phase("succeeded");
1224 h.setIdentity("other-user", "other@example.test");
1225 h.setCallbackClientId("oaiapp_other_account");
1226 yield* h.changeAccount;
1227 yield* h.phase("succeeded");
1228 h.setIdentity("user-test", "hidden@example.test");
1229 h.setCallbackClientId("oaiapp_replacement");
1230 yield* h.changeAccount;
1231 const changed = yield* h.phase("succeeded");
1232 assert.deepEqual(
1233 changed
1234 .methods!.filter((method) => method.id.startsWith("chatgpt-profile:"))
1235 .map((method) => method.id),
1236 [
1237 "chatgpt-profile:oaiapp_replacement",
1238 "chatgpt-profile:oaiapp_other_account",
1239 "chatgpt-profile:oaiapp_test",
1240 ],
1241 );
1242 assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_replacement");
1243 const redirectUri = h.authorizationRequests[2]!.searchParams.get("redirect_uri");
1244 yield* h.auth.controller.logout(Effect.void);
1245 yield* h.reconnectProfile("oaiapp_replacement");
1246 yield* h.phase("succeeded");
1247 assert.strictEqual(
1248 h.authorizationRequests[3]!.searchParams.get("client_id"),
1249 "oaiapp_replacement",
1250 );
1251 assertSameCallback(h.authorizationRequests[3]!.searchParams.get("redirect_uri"), redirectUri);
1252 }),
1253 ),
1254);
1255
1256it.effect("preserves legacy profiles with the same email and subject", () =>
1257 provision(
1258 Effect.gen(function* () {
1259 const bytes = new Map<string, Uint8Array>();
1260 const h = yield* makeHarnessFor(instanceId, bytes);
1261 yield* h.signIn;
1262 yield* h.phase("succeeded");
1263 const redirectUri = h.authorizationRequests[0]!.searchParams.get("redirect_uri");
1264 for (const [key, value] of bytes) {
1265 const record = JSON.parse(new TextDecoder().decode(value));
1266 if (record.profiles)
1267 bytes.set(
1268 key,
1269 new TextEncoder().encode(
1270 JSON.stringify({
1271 ...record,
1272 profiles: [
1273 { ...record.profiles[0], clientId: "oaiapp_old_duplicate" },
1274 ...record.profiles,
1275 ],
1276 }),
1277 ),
1278 );
1279 }
1280 const restarted = yield* makeHarnessFor(instanceId, bytes);
1281 const state = yield* restarted.auth.controller.subscribe("owner").pipe(
1282 Stream.filter((state) => state.methods != null),
1283 Stream.runHead,
1284 Effect.map(Option.getOrThrow),
1285 );
1286 assert.deepEqual(
1287 state
1288 .methods!.filter((method) => method.id.startsWith("chatgpt-profile:"))
1289 .map((method) => method.id),
1290 ["chatgpt-profile:oaiapp_test", "chatgpt-profile:oaiapp_old_duplicate"],
1291 );
1292 yield* restarted.signIn;
1293 yield* restarted.phase("succeeded");
1294 assert.strictEqual(
1295 restarted.authorizationRequests[0]!.searchParams.get("client_id"),
1296 "oaiapp_test",
1297 );
1298 assertSameCallback(
1299 restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"),
1300 redirectUri,
1301 );
1302 assert.lengthOf(restarted.storedRecords().find((record) => record.profiles).profiles, 2);
1303 }),
1304 ),
1305);
1306
1307it.effect("keeps distinct verified identities even when their email matches", () =>
1308 provision(
1309 Effect.gen(function* () {
1310 const h = yield* makeHarness;
1311 yield* h.signIn;
1312 yield* h.phase("succeeded");
1313 h.setIdentity("other-user", "hidden@example.test");
1314 h.setCallbackClientId("oaiapp_other_account");
1315 yield* h.changeAccount;
1316 const changed = yield* h.phase("succeeded");
1317 assert.lengthOf(
1318 changed.methods!.filter((method) => method.id.startsWith("chatgpt-profile:")),
1319 2,
1320 );
1321 }),
1322 ),
1323);
1324
1325it.effect(
1326 "migrates a legacy registration and retains its verified identity when credentials are cleared",
1327 () =>
1328 provision(
1329 Effect.gen(function* () {
1330 const bytes = new Map<string, Uint8Array>();
1331 const first = yield* makeHarnessFor(instanceId, bytes);
1332 yield* first.signIn;
1333 yield* first.phase("succeeded");
1334 const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri");
1335 for (const [key, value] of bytes) {
1336 const saved = JSON.parse(new TextDecoder().decode(value));
1337 if (saved.profiles)
1338 bytes.set(
1339 key,
1340 new TextEncoder().encode(JSON.stringify({ clientId: "oaiapp_test", redirectUri })),
1341 );
1342 }
1343 yield* first.auth.revoke;
1344 assert.deepEqual(first.storedRecords(), [
1345 {
1346 profiles: [
1347 {
1348 clientId: "oaiapp_test",
1349 connectionLabel: "Connection 1",
1350 redirectUri,
1351 subject: "user-test",
1352 email: "hidden@example.test",
1353 },
1354 ],
1355 lastClientId: "oaiapp_test",
1356 },
1357 ]);
1358 const restarted = yield* makeHarnessFor(instanceId, bytes);
1359 restarted.setIdentity("another-user", "another@example.test");
1360 yield* restarted.signIn;
1361 assert.include((yield* restarted.phase("failed")).message!, "different ChatGPT account");
1362 assert.isTrue(Option.isNone(yield* restarted.auth.read));
1363 assert.strictEqual(
1364 restarted.authorizationRequests[0]!.searchParams.get("ext_agent_host_id"),
1365 first.authorizationRequests[0]!.searchParams.get("ext_agent_host_id"),
1366 );
1367 }),
1368 ),
1369);
1370
1371it.effect("declined sharing on an older profile does not replace the active account", () =>
1372 provision(
1373 Effect.gen(function* () {
1374 const h = yield* makeHarness;
1375 yield* h.signIn;
1376 yield* h.phase("succeeded");
1377 h.setIdentity("other-user", "other@example.test");
1378 h.setCallbackClientId("oaiapp_other_account");
1379 yield* h.changeAccount;
1380 yield* h.phase("succeeded");
1381 const before = h.storedRecords();
1382 h.setIdentity("user-test", "hidden@example.test");
1383 h.setCallbackClientId("oaiapp_test");
1384 h.declineSharing();
1385 yield* h.reconnectProfile("oaiapp_test");
1386 assert.include(
1387 (yield* h.phase("failed")).message!,
1388 "existing ChatGPT connection is unchanged",
1389 );
1390 assert.deepEqual(
1391 Option.getOrThrow(yield* h.auth.read),
1392 before.find((record) => record.clientId === "oaiapp_other_account"),
1393 );
1394 }),
1395 ),
1396);
1397
1398it.effect(
1399 "Disconnect preserves a remote profile's callback URI for client-delivered reconnect",
1400 () =>
1401 provision(
1402 Effect.gen(function* () {
1403 const h = yield* makeHarness;
1404 const first = yield* h.startRemote();
1405 yield* h.auth.controller.complete("owner", {
1406 flowId: first.waiting.flowId!,
1407 callbackUrl: first.callbackUrl,
1408 });
1409 yield* h.phase("succeeded");
1410 yield* h.auth.controller.logout(Effect.void);
1411 const second = yield* h.startRemote();
1412 assert.strictEqual(
1413 new URL(second.waiting.authorizationUrl!).searchParams.get("client_id"),
1414 "oaiapp_test",
1415 );
1416 assertSameCallback(
1417 new URL(second.waiting.authorizationUrl!).searchParams.get("redirect_uri"),
1418 new URL(first.waiting.authorizationUrl!).searchParams.get("redirect_uri"),
1419 );
1420 yield* h.auth.controller.complete("owner", {
1421 flowId: second.waiting.flowId!,
1422 callbackUrl: second.callbackUrl,
1423 });
1424 yield* h.phase("succeeded");
1425 assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "user-test");
1426 }),
1427 ),
1428);
1429
1430for (const code of [
1431 "invalid_grant",
1432 "invalid_refresh_token",
1433 "token_expired",
1434 "refresh_token_expired",
1435 "refresh_token_invalidated",
1436 "refresh_token_reused",
1437 "invalid_client",
1438 "invalid_token",
1439]) {
1440 it.effect(`refresh recovery follows the machine-readable code: ${code}`, () =>
1441 provision(
1442 Effect.gen(function* () {
1443 const h = yield* makeHarness;
1444 yield* h.signIn;
1445 yield* h.phase("succeeded");
1446 yield* h.seedExpired;
1447 h.setRefreshError(code);
1448 yield* Effect.flip(h.auth.access);
1449 assert.strictEqual(
1450 Option.isNone(yield* h.auth.read),
1451 !["invalid_client", "invalid_token"].includes(code),
1452 );
1453 assert.isTrue(
1454 h
1455 .storedRecords()
1456 .some((record) =>
1457 record.profiles?.some(
1458 (profile: { clientId: string }) => profile.clientId === "oaiapp_test",
1459 ),
1460 ),
1461 );
1462 }),
1463 ),
1464 );
1465}
1466
1467it.effect(
1468 "logout revokes the latest refresh token with the selected client and clears its ID hint",
1469 () =>
1470 provision(
1471 Effect.gen(function* () {
1472 const h = yield* makeHarness;
1473 yield* h.signIn;
1474 yield* h.phase("succeeded");
1475 const host = h.authorizationRequests[0]!.searchParams.get("ext_agent_host_id");
1476 assert.match(host!, /^urn:uuid:[0-9a-f-]{36}$/u);
1477 yield* h.seedExpired;
1478 yield* h.auth.access;
1479 const state = yield* h.auth.controller.logout(Effect.void);
1480 assert.strictEqual(state.message, "Signed out.");
1481 assert.deepEqual(Array.from(h.revocations[0]!), [
1482 ["token", "refresh-1"],
1483 ["token_type_hint", "refresh_token"],
1484 ["client_id", "oaiapp_test"],
1485 ]);
1486 assert.isTrue(Option.isNone(yield* h.auth.read));
1487 yield* h.signIn;
1488 yield* h.phase("succeeded");
1489 assert.isFalse(h.authorizationRequests[1]!.searchParams.has("id_token_hint"));
1490 assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("ext_agent_host_id"), host);
1491 }),
1492 ),
1493);
1494
1495it.live("logout retries temporary revocation failures and reports local-only sign-out", () =>
1496 provision(
1497 Effect.gen(function* () {
1498 const h = yield* makeHarness;
1499 yield* h.signIn;
1500 yield* h.phase("succeeded");
1501 h.setRevocationStatus(503);
1502 const state = yield* h.auth.controller.logout(Effect.void);
1503 assert.lengthOf(h.revocations, 3);
1504 assert.strictEqual(state.phase, "idle");
1505 assert.include(state.message!, "Remote revocation could not be confirmed");
1506 assert.isTrue(Option.isNone(yield* h.auth.read));
1507 assert.isFalse(h.storedRecords().some((record) => record.idToken));
1508 }),
1509 ),
1510);
1511
1512it.effect(
1513 "reauth hints come from the selected profile, including after another profile logs out",
1514 () =>
1515 provision(
1516 Effect.gen(function* () {
1517 const h = yield* makeHarness;
1518 yield* h.signIn;
1519 yield* h.phase("succeeded");
1520 const personal = Option.getOrThrow(yield* h.auth.read);
1521 h.setCallbackClientId("oaiapp_work");
1522 h.setIdentity("work-user", "work@example.test");
1523 yield* h.changeAccount;
1524 yield* h.phase("succeeded");
1525 assert.isFalse(h.authorizationRequests[1]!.searchParams.has("id_token_hint"));
1526 assert.isFalse(h.authorizationRequests[1]!.searchParams.has("login_hint"));
1527 yield* h.auth.controller.logout(Effect.void);
1528 h.setCallbackClientId("oaiapp_test");
1529 h.setIdentity("user-test", "hidden@example.test");
1530 yield* h.reconnectProfile("oaiapp_test");
1531 yield* h.phase("succeeded");
1532 assert.strictEqual(
1533 h.authorizationRequests[2]!.searchParams.get("id_token_hint"),
1534 personal.idToken,
1535 );
1536 assert.strictEqual(
1537 h.authorizationRequests[2]!.searchParams.get("login_hint"),
1538 personal.email,
1539 );
1540 }),
1541 ),
1542);
1543
1544it.effect("an expired initial code retains the issued ID for a fresh authorization", () =>
1545 provision(
1546 Effect.gen(function* () {
1547 const h = yield* makeHarness;
1548 h.setCodeError("invalid_grant");
1549 yield* h.signIn;
1550 const failed = yield* h.phase("failed");
1551 assert.isTrue(Option.isNone(yield* h.auth.read));
1552 assert.isTrue(failed.methods!.some((method) => method.id === "chatgpt-profile:oaiapp_test"));
1553 h.setCodeError(undefined);
1554 yield* h.reconnectProfile("oaiapp_test");
1555 yield* h.phase("succeeded");
1556 assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("client_id"), "oaiapp_test");
1557 assert.notStrictEqual(
1558 h.authorizationRequests[0]!.searchParams.get("state"),
1559 h.authorizationRequests[1]!.searchParams.get("state"),
1560 );
1561 }),
1562 ),
1563);
1564
1565it.effect("identity-only sign-in requests consent on an explicit retry, then rechecks scopes", () =>
1566 provision(
1567 Effect.gen(function* () {
1568 const h = yield* makeHarness;
1569 h.declineSharing();
1570 yield* h.signIn;
1571 yield* h.phase("failed");
1572 yield* h.reconnectProfile("oaiapp_test");
1573 yield* h.phase("failed");
1574 assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("prompt"), "consent");
1575 assert.isFalse(h.authorizationRequests[1]!.searchParams.has("force_reconsent"));
1576 assert.include(
1577 h.authorizationRequests[1]!.searchParams.get("scope")!,
1578 "chatgpt.tokens.use.direct",
1579 );
1580 yield* Effect.flip(h.auth.access);
1581 }),
1582 ),
1583);
1584
1585it.effect(
1586 "controller replacement shares refresh serialization for the same environment session",
1587 () =>
1588 provision(
1589 Effect.gen(function* () {
1590 const h = yield* makeHarness;
1591 yield* h.signIn;
1592 yield* h.phase("succeeded");
1593 const replacement = yield* h.recreateAuth;
1594 yield* h.seedExpired;
1595 const records = yield* Effect.all([h.auth.access, replacement.access], {
1596 concurrency: "unbounded",
1597 });
1598 assert.strictEqual(h.refreshes(), 1);
1599 assert.strictEqual(records[0].refreshToken, "refresh-1");
1600 assert.deepEqual(records[0], records[1]);
1601 }),
1602 ),
1603);
1604
1605it.effect(
1606 "old localhost registrations retain their profile while a new client gets its own identity",
1607 () =>
1608 provision(
1609 Effect.gen(function* () {
1610 const bytes = new Map<string, Uint8Array>();
1611 const h = yield* makeHarnessFor(instanceId, bytes);
1612 yield* h.signIn;
1613 yield* h.phase("succeeded");
1614 for (const [key, value] of bytes) {
1615 const record = JSON.parse(new TextDecoder().decode(value));
1616 if (record.profiles)
1617 bytes.set(
1618 key,
1619 new TextEncoder().encode(
1620 JSON.stringify({
1621 ...record,
1622 profiles: record.profiles.map((profile: { redirectUri: string }) => ({
1623 ...profile,
1624 redirectUri: profile.redirectUri.replace("127.0.0.1", "localhost"),
1625 })),
1626 }),
1627 ),
1628 );
1629 }
1630 h.setCallbackClientId("oaiapp_migrated");
1631 h.setIdentity("new-client-subject", "hidden@example.test");
1632 yield* h.signIn;
1633 yield* h.phase("succeeded");
1634 assert.strictEqual(
1635 h.authorizationRequests[1]!.searchParams.get("client_id"),
1636 "dynamic_agent_client",
1637 );
1638 assert.strictEqual(
1639 new URL(h.authorizationRequests[1]!.searchParams.get("redirect_uri")!).hostname,
1640 "127.0.0.1",
1641 );
1642 assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "new-client-subject");
1643 const profiles = (yield* h.phase("succeeded")).methods!;
1644 assert.isTrue(profiles.some((profile) => profile.id === "chatgpt-profile:oaiapp_test"));
1645 assert.isTrue(profiles.some((profile) => profile.id === "chatgpt-profile:oaiapp_migrated"));
1646 yield* h.signIn;
1647 yield* h.phase("succeeded");
1648 assert.strictEqual(
1649 h.authorizationRequests[2]!.searchParams.get("client_id"),
1650 "oaiapp_migrated",
1651 );
1652 assert.strictEqual(
1653 h.authorizationRequests[2]!.searchParams.get("login_hint"),
1654 "hidden@example.test",
1655 );
1656 assert.isTrue(h.authorizationRequests[2]!.searchParams.has("id_token_hint"));
1657 }),
1658 ),
1659);
1660
1661it.effect("keeps the active connection first when another profile declines sharing", () =>
1662 provision(
1663 Effect.gen(function* () {
1664 const h = yield* makeHarness;
1665 yield* h.signIn;
1666 yield* h.phase("succeeded");
1667 h.setCallbackClientId("oaiapp_identity_only");
1668 h.setIdentity("other-user", "other@example.test");
1669 h.declineSharing();
1670 yield* h.changeAccount;
1671 const state = yield* h.phase("failed");
1672 assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_test");
1673 const profiles = state.methods!.filter((method) => method.id.startsWith("chatgpt-profile:"));
1674 assert.strictEqual(profiles[0]!.id, "chatgpt-profile:oaiapp_test");
1675 assert.include(profiles[0]!.name, "Connection 1");
1676 assert.include(profiles[1]!.name, "Connection 2");
1677 }),
1678 ),
1679);
1680
1681it.effect("primary completes OAuth and destination imports and owns the refresh session", () =>
1682 provision(
1683 Effect.gen(function* () {
1684 const h = yield* makeHarness;
1685 const destination = yield* h.destination;
1686 const before = h.bytes.size;
1687 const waiting = yield* Deferred.make<{ authorizationUrl: string | null }>();
1688 const transferred = yield* h.handoff(null).pipe(
1689 Stream.tap((state) =>
1690 state.phase === "auth" && state.state.phase === "waiting"
1691 ? Deferred.succeed(waiting, state.state)
1692 : Effect.void,
1693 ),
1694 Stream.filter((state) => state.phase === "finished"),
1695 Stream.runHead,
1696 Effect.map(Option.getOrThrow),
1697 Effect.forkChild,
1698 );
1699 yield* h.finishCallback(yield* Deferred.await(waiting));
1700 const result = yield* Fiber.join(transferred);
1701 assert.strictEqual(result.phase, "finished");
1702 if (result.phase !== "finished") return;
1703 assert.strictEqual(h.bytes.size, before);
1704 assert.isTrue(Option.isNone(yield* h.auth.read));
1705 let stopped = false;
1706 const imported = yield* destination.auth.controller.importProfile!(
1707 result.profile,
1708 Effect.sync(() => {
1709 stopped = true;
1710 }),
1711 );
1712 assert.isTrue(stopped);
1713 assert.strictEqual(imported.phase, "succeeded");
1714 assert.deepStrictEqual(
1715 h.analyticsEvents.map(({ event }) => event),
1716 [
1717 "chatgpt.auth.started",
1718 "chatgpt.auth.completed",
1719 "chatgpt.transfer.started",
1720 "chatgpt.transfer.completed",
1721 ],
1722 );
1723 assert.isTrue(
1724 h.analyticsEvents.every(({ properties }) => properties?.flow === "primary_handoff"),
1725 );
1726 assert.strictEqual(h.analyticsEvents[1]?.properties?.outcome, "succeeded");
1727 assert.strictEqual(h.analyticsEvents[3]?.properties?.outcome, "succeeded");
1728 assert.notProperty(h.analyticsEvents[1]?.properties ?? {}, "connectedAccountCount");
1729 assert.strictEqual(h.analyticsEvents[1]?.properties?.intent, "different_account");
1730 assert.strictEqual(h.analyticsEvents[3]?.properties?.connectedAccountCount, 1);
1731 assert.strictEqual(h.analyticsEvents[3]?.properties?.savedConnectionCount, 1);
1732 const saved = Option.getOrThrow(yield* destination.auth.read);
1733 assert.strictEqual(saved.clientId, result.profile.registration.clientId);
1734 assert.strictEqual(saved.refreshToken, "initial-refresh");
1735 const reconnect = yield* destination.auth.controller.reconnectProfile!("chatgpt");
1736 assert.strictEqual(reconnect?.idTokenHint, result.profile.credentials.idToken);
1737 assert.isFalse("refreshToken" in reconnect!);
1738 const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe(
1739 Effect.provideService(
1740 ServerSecretStore,
1741 ServerSecretStore.of({
1742 get: (name) => Effect.sync(() => Option.fromUndefinedOr(destination.bytes.get(name))),
1743 set: (name, value) =>
1744 Effect.sync(() => {
1745 destination.bytes.set(name, value);
1746 }),
1747 remove: () => Effect.void,
1748 create: () => Effect.die("unused"),
1749 getOrCreateRandom: () => Effect.die("unused"),
1750 }),
1751 ),
1752 );
1753 yield* store.set(new TextEncoder().encode(JSON.stringify({ ...saved, expiresAt: 0 })));
1754 yield* destination.auth.access;
1755 assert.strictEqual(
1756 h.exchanges.filter((entry) => entry.get("grant_type") === "authorization_code").length,
1757 1,
1758 );
1759 assert.strictEqual(
1760 h.exchanges.filter((entry) => entry.get("grant_type") === "refresh_token").length,
1761 1,
1762 );
1763 }),
1764 ),
1765);
1766
1767it.effect("transferred profiles reject mismatched identities without overwriting credentials", () =>
1768 provision(
1769 Effect.gen(function* () {
1770 const h = yield* makeHarness;
1771 yield* h.signIn;
1772 yield* h.phase("succeeded");
1773 const profile = yield* h.auth.exportProfile;
1774 const destination = yield* h.destination;
1775 yield* destination.auth.controller.importProfile!(profile, Effect.void);
1776 const original = Option.getOrThrow(yield* destination.auth.read);
1777 for (const credentials of [
1778 { ...profile.credentials, subject: "wrong-user" },
1779 { ...profile.credentials, clientId: "oaiapp_wrong" },
1780 { ...profile.credentials, idToken: "not-a-jwt" },
1781 { ...profile.credentials, scopes: ["openid"] },
1782 { ...profile.credentials, expiresAt: 0 },
1783 ]) {
1784 const result = yield* destination.auth.controller.importProfile!(
1785 { ...profile, credentials },
1786 Effect.void,
1787 ).pipe(Effect.result);
1788 assert.strictEqual(result._tag, "Failure");
1789 assert.deepEqual(Option.getOrThrow(yield* destination.auth.read), original);
1790 }
1791 }),
1792 ),
1793);
1794
1795it.effect("primary handoff reuses the selected registration and ID token hint", () =>
1796 provision(
1797 Effect.gen(function* () {
1798 const h = yield* makeHarness;
1799 yield* h.signIn;
1800 yield* h.phase("succeeded");
1801 const profile = yield* h.auth.controller.reconnectProfile!("chatgpt");
1802 const waiting = yield* Deferred.make<{ authorizationUrl: string | null }>();
1803 const resultFiber = yield* h.handoff(profile).pipe(
1804 Stream.tap((state) =>
1805 state.phase === "auth" && state.state.phase === "waiting"
1806 ? Deferred.succeed(waiting, state.state)
1807 : Effect.void,
1808 ),
1809 Stream.runCollect,
1810 Effect.forkChild,
1811 );
1812 const state = yield* Deferred.await(waiting);
1813 const url = new URL(state.authorizationUrl!);
1814 assert.strictEqual(url.searchParams.get("client_id"), profile!.clientId);
1815 assert.strictEqual(url.searchParams.get("id_token_hint"), profile!.idTokenHint);
1816 assert.strictEqual(url.searchParams.get("login_hint"), profile!.email);
1817 assert.strictEqual(url.searchParams.get("ext_agent_host_id"), `urn:uuid:${h.environmentId}`);
1818 yield* h.finishCallback(state);
1819 const results = yield* Fiber.join(resultFiber);
1820 assert.strictEqual(results.at(-1)?.phase, "finished");
1821 }),
1822 ),
1823);
1824
1825it.effect(
1826 "cancelling primary handoff closes its callback listener without saving credentials",
1827 () =>
1828 provision(
1829 Effect.gen(function* () {
1830 const h = yield* makeHarness;
1831 const waiting = yield* Deferred.make<{ authorizationUrl: string | null }>();
1832 const flow = yield* h.handoff(null).pipe(
1833 Stream.tap((state) =>
1834 state.phase === "auth" && state.state.phase === "waiting"
1835 ? Deferred.succeed(waiting, state.state)
1836 : Effect.void,
1837 ),
1838 Stream.runDrain,
1839 Effect.forkChild,
1840 );
1841 const state = yield* Deferred.await(waiting);
1842 yield* Fiber.interrupt(flow);
1843 const callback = new URL(
1844 new URL(state.authorizationUrl!).searchParams.get("redirect_uri")!,
1845 );
1846 const request = yield* Effect.tryPromise(() => fetch(callback)).pipe(Effect.result);
1847 assert.strictEqual(request._tag, "Failure");
1848 assert.isTrue(Option.isNone(yield* h.auth.read));
1849 }),
1850 ),
1851);
1852
1853it.effect("records one anonymous auth outcome per success, failure, or cancellation", () =>
1854 provision(
1855 Effect.gen(function* () {
1856 const h = yield* makeHarness;
1857 yield* h.signIn;
1858 yield* h.phase("succeeded");
1859 h.setInvalidNonce();
1860 yield* h.signIn;
1861 yield* h.phase("failed");
1862 const { waiting } = yield* h.startRemote();
1863 yield* h.auth.controller.cancel("owner", waiting.flowId!);
1864 yield* h.phase("cancelled");
1865 const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed");
1866 assert.deepStrictEqual(
1867 completed.map(({ properties }) => properties?.outcome),
1868 ["succeeded", "failed", "cancelled"],
1869 );
1870 assert.strictEqual(
1871 h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.started").length,
1872 3,
1873 );
1874 assert.strictEqual(completed[1]?.properties?.failureStage, "verify");
1875 for (const { properties } of completed) {
1876 assert.isNumber(properties?.durationMs);
1877 assert.strictEqual(properties?.flow, "direct");
1878 assert.deepStrictEqual(
1879 Object.keys(properties!).sort(),
1880 [
1881 ...(properties?.outcome === "succeeded"
1882 ? [
1883 "accountCountScope",
1884 "connectedAccountCount",
1885 "connectedConnectionCount",
1886 "savedConnectionCount",
1887 "unidentifiedConnectedConnectionCount",
1888 ]
1889 : []),
1890 "callbackMode",
1891 "durationMs",
1892 ...(properties?.failureStage ? ["failureStage"] : []),
1893 "flow",
1894 "intent",
1895 "outcome",
1896 ].sort(),
1897 );
1898 }
1899 const serialized = JSON.stringify(h.analyticsEvents);
1900 for (const secret of [
1901 "hidden@example.test",
1902 "oaiapp_test",
1903 "user-test",
1904 "access_token",
1905 "refresh_token",
1906 ]) {
1907 assert.notInclude(serialized, secret);
1908 }
1909 }),
1910 ),
1911);
1912
1913it.effect("telemetry failures do not fail a verified ChatGPT sign-in", () =>
1914 provision(
1915 Effect.gen(function* () {
1916 const h = yield* makeHarnessFor(instanceId, new Map(), true);
1917 yield* h.signIn;
1918 const state = yield* h.phase("succeeded");
1919 assert.strictEqual(state.phase, "succeeded");
1920 assert.isTrue(Option.isSome(yield* h.auth.read));
1921 }),
1922 ),
1923);
1924
1925it.effect("counts accounts separately from saved connections across additions and reconnects", () =>
1926 provision(
1927 Effect.gen(function* () {
1928 const h = yield* makeHarness;
1929 yield* h.signIn;
1930 yield* h.phase("succeeded");
1931 yield* h.signIn;
1932 yield* h.phase("succeeded");
1933 h.setCallbackClientId("oaiapp_same_account");
1934 h.setIdentity("another-client-subject", "HIDDEN@example.test");
1935 yield* h.changeAccount;
1936 yield* h.phase("succeeded");
1937 h.setCallbackClientId("oaiapp_other_account");
1938 h.setIdentity("other-user", "other@example.test");
1939 yield* h.changeAccount;
1940 yield* h.phase("succeeded");
1941 yield* h.auth.controller.logout(Effect.void);
1942 h.setCallbackClientId("oaiapp_test");
1943 h.setIdentity("user-test", "hidden@example.test");
1944 yield* h.reconnectProfile("oaiapp_test");
1945 yield* h.phase("succeeded");
1946 const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed");
1947 assert.deepStrictEqual(
1948 completed.map(({ properties }) => [
1949 properties?.connectedAccountCount,
1950 properties?.connectedConnectionCount,
1951 properties?.savedConnectionCount,
1952 ]),
1953 [
1954 [1, 1, 1],
1955 [1, 1, 1],
1956 [1, 2, 2],
1957 [2, 3, 3],
1958 [1, 2, 3],
1959 ],
1960 );
1961 assert.isTrue(
1962 completed.every(({ properties }) => properties?.unidentifiedConnectedConnectionCount === 0),
1963 );
1964 assert.notInclude(JSON.stringify(h.analyticsEvents), "example.test");
1965 }),
1966 ),
1967);
1968
1969it.effect("includes accounts from other Codex instances in the environment", () =>
1970 provision(
1971 Effect.gen(function* () {
1972 const bytes = new Map<string, Uint8Array>();
1973 const personal = yield* makeHarnessFor(instanceId, bytes);
1974 const work = yield* makeHarnessFor(ProviderInstanceId.make("managed-work"), bytes);
1975 yield* personal.signIn;
1976 yield* personal.phase("succeeded");
1977 work.setCallbackClientId("oaiapp_work");
1978 work.setIdentity("work-user", "work@example.test");
1979 yield* work.signIn;
1980 yield* work.phase("succeeded");
1981 const completed = work.analyticsEvents.find(
1982 ({ event }) => event === "chatgpt.auth.completed",
1983 );
1984 assert.strictEqual(completed?.properties?.accountCountScope, "environment");
1985 assert.strictEqual(completed?.properties?.connectedAccountCount, 2);
1986 assert.strictEqual(completed?.properties?.connectedConnectionCount, 2);
1987 assert.strictEqual(completed?.properties?.savedConnectionCount, 2);
1988 }).pipe(
1989 Effect.provide(
1990 settingsLayerTest({
1991 providerInstances: {
1992 [instanceId]: { driver: "codex", enabled: true },
1993 [ProviderInstanceId.make("managed-work")]: { driver: "codex", enabled: true },
1994 },
1995 }),
1996 ),
1997 ),
1998 ),
1999);
2000
2001it.effect("an unreadable unrelated profile omits counts without failing sign-in", () =>
2002 provision(
2003 Effect.gen(function* () {
2004 const h = yield* makeHarness;
2005 const unrelated = yield* ProviderCredentialStore.make("codex-chatgpt", "codex").pipe(
2006 Effect.provideService(ServerSecretStore, h.secrets),
2007 );
2008 // The harness owns its store; seed the corresponding binding in that store.
2009 h.bytes.set(unrelated.binding.key, new TextEncoder().encode("invalid"));
2010 yield* h.signIn;
2011 yield* h.phase("succeeded");
2012 const completed = h.analyticsEvents.find(({ event }) => event === "chatgpt.auth.completed");
2013 assert.strictEqual(completed?.properties?.outcome, "succeeded");
2014 assert.notProperty(completed?.properties ?? {}, "connectedAccountCount");
2015 }).pipe(Effect.provide(settingsLayerTest())),
2016 ),
2017);
2018
2019it.effect("reports connections without an email separately from identifiable accounts", () =>
2020 provision(
2021 Effect.gen(function* () {
2022 const h = yield* makeHarness;
2023 yield* h.signIn;
2024 yield* h.phase("succeeded");
2025 const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe(
2026 Effect.provideService(ServerSecretStore, h.secrets),
2027 );
2028 yield* store.set(
2029 new TextEncoder().encode(
2030 JSON.stringify({
2031 activeClientId: "oaiapp_test",
2032 sessions: [{ ...Option.getOrThrow(yield* h.auth.read), email: null }],
2033 }),
2034 ),
2035 );
2036 h.setCallbackClientId("oaiapp_other_account");
2037 h.setIdentity("other-user", "other@example.test");
2038 yield* h.changeAccount;
2039 yield* h.phase("succeeded");
2040 const completed = h.analyticsEvents.findLast(
2041 ({ event }) => event === "chatgpt.auth.completed",
2042 );
2043 assert.strictEqual(completed?.properties?.connectedAccountCount, 1);
2044 assert.strictEqual(completed?.properties?.connectedConnectionCount, 2);
2045 assert.strictEqual(completed?.properties?.unidentifiedConnectedConnectionCount, 1);
2046 }),
2047 ),
2048);
2049
2050it.effect("records an expired auth outcome when sign-in reaches its deadline", () =>
2051 provision(
2052 Effect.gen(function* () {
2053 const h = yield* makeHarness;
2054 yield* h.startRemote();
2055 yield* TestClock.adjust(300_001);
2056 assert.include((yield* h.phase("failed")).message ?? "", "expired");
2057 const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed");
2058 assert.strictEqual(completed.length, 1);
2059 assert.strictEqual(completed[0]?.properties?.outcome, "expired");
2060 assert.isAtLeast(completed[0]?.properties?.durationMs as number, 300_000);
2061 }),
2062 ),
2063);
apps/server/src/provider/CodexChatGptAuth.ts created+985
......@@ -0,0 +1,985 @@
1// @effect-diagnostics nodeBuiltinImport:off - OAuth loopback listener and PKCE use Node APIs.
2import * as NodeCrypto from "node:crypto";
3import * as NodeHttp from "node:http";
4import { createRemoteJWKSet, jwtVerify } from "jose";
5import {
6 ProviderSetupError,
7 type ChatGptReconnectProfile,
8 type ChatGptTransferredProfile,
9 type ProviderInstanceId,
10} from "@t3tools/contracts";
11import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff";
12import * as Clock from "effect/Clock";
13import * as Cause from "effect/Cause";
14import { AnalyticsService } from "../telemetry/AnalyticsService.ts";
15import * as Exit from "effect/Exit";
16import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts";
17import * as Effect from "effect/Effect";
18import * as Option from "effect/Option";
19import * as Schema from "effect/Schema";
20import * as Semaphore from "effect/Semaphore";
21import * as ProviderAuthFlow from "./ProviderAuthFlow.ts";
22import type { ProviderAuthFlowContext } from "./ProviderAuthFlow.ts";
23import { HttpClient, HttpClientRequest } from "effect/unstable/http";
24import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
25import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts";
26import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
27import { ServerSettingsService } from "../serverSettings.ts";
28import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts";
29
30const isSetupError = Schema.is(ProviderSetupError);
31const RESOURCE = "https://api.openai.com/v1";
32const REQUIRED_SCOPE = "chatgpt.tokens.use.direct";
33const DISCOVERY = "https://auth.openai.com/.well-known/openid-configuration";
34const TokenResponse = Schema.Struct({
35 access_token: Schema.NonEmptyString,
36 refresh_token: Schema.optionalKey(Schema.NonEmptyString),
37 id_token: Schema.optionalKey(Schema.String),
38 token_type: Schema.String,
39 expires_in: Schema.Int.check(Schema.isGreaterThan(0)),
40 scope: Schema.String,
41 earliest_refresh_at: Schema.optionalKey(Schema.Union([Schema.Finite, Schema.String])),
42});
43const Record = Schema.Struct({
44 clientId: Schema.String,
45 accessToken: Schema.String,
46 refreshToken: Schema.NullOr(Schema.String),
47 expiresAt: Schema.Finite,
48 earliestRefreshAt: Schema.NullOr(Schema.Finite),
49 scopes: Schema.Array(Schema.String),
50 subject: Schema.String,
51 email: Schema.NullOr(Schema.String),
52 idToken: Schema.optionalKey(Schema.String),
53 issuer: Schema.optionalKey(Schema.String),
54});
55export type CodexChatGptCredentials = typeof Record.Type;
56const Sessions = Schema.Struct({
57 activeClientId: Schema.NullOr(Schema.String),
58 sessions: Schema.Array(Record),
59});
60const sessionLocks = new WeakMap<
61 typeof ServerSecretStore.Service,
62 Map<string, Semaphore.Semaphore>
63>();
64const Registration = Schema.Struct({
65 clientId: Schema.String.check(Schema.isPattern(/^oaiapp_/u)),
66 subject: Schema.optionalKey(Schema.String),
67 connectionLabel: Schema.optionalKey(Schema.String),
68 sharingEnabled: Schema.optionalKey(Schema.Boolean),
69 email: Schema.optionalKey(Schema.NullOr(Schema.String)),
70 redirectUri: Schema.optionalKey(
71 Schema.String.check(
72 Schema.isPattern(/^http:\/\/(?:127\.0\.0\.1|localhost):[1-9]\d{0,4}\/auth\/callback$/u),
73 ),
74 ),
75});
76const RegistrationProfiles = Schema.Struct({
77 profiles: Schema.Array(Registration),
78 lastClientId: Schema.NullOr(Schema.String),
79});
80const decodeRegistration = Schema.decodeUnknownEffect(
81 Schema.fromJsonString(Schema.Union([RegistrationProfiles, Registration])),
82);
83const encodeRegistration = Schema.encodeEffect(Schema.fromJsonString(RegistrationProfiles));
84const profileMethodId = (clientId: string) => `chatgpt-profile:${clientId}`;
85const Discovery = Schema.Struct({
86 issuer: Schema.String,
87 authorization_endpoint: Schema.String,
88 token_endpoint: Schema.String,
89 jwks_uri: Schema.String,
90 revocation_endpoint: Schema.optionalKey(Schema.String),
91});
92const OAuthError = Schema.Struct({ error: Schema.String });
93const decodeSessions = Schema.decodeUnknownEffect(
94 Schema.fromJsonString(Schema.Union([Sessions, Record])),
95);
96const encodeSessions = Schema.encodeEffect(Schema.fromJsonString(Sessions));
97const decodeTokens = Schema.decodeUnknownSync(TokenResponse);
98const decodeDiscoveryEffect = Schema.decodeUnknownEffect(Discovery);
99const decodeOAuthError = Schema.decodeUnknownSync(OAuthError);
100
101export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* (options: {
102 readonly instanceId: ProviderInstanceId;
103 readonly discoveryUrl?: string;
104 readonly resource?: string;
105 readonly defaultReturnUrl?: string;
106 readonly reconnectProfile?: ChatGptReconnectProfile | null;
107 readonly telemetryFlow?: "direct" | "primary_handoff";
108}) {
109 const analytics = yield* Effect.serviceOption(AnalyticsService);
110 const settings = yield* Effect.serviceOption(ServerSettingsService);
111 const track = <A, E extends ProviderSetupError, R>(
112 event: "auth" | "transfer",
113 properties: Readonly<Record<string, string>>,
114 task: Effect.Effect<A, E, R>,
115 expiresAt?: number,
116 ) =>
117 Effect.gen(function* () {
118 if (Option.isNone(analytics)) return yield* task;
119 const record = (name: string, properties: Readonly<Record<string, unknown>>) =>
120 analytics.value.record(name, properties).pipe(Effect.ignoreCause);
121 const startedAt = yield* Clock.currentTimeMillis;
122 yield* record(`chatgpt.${event}.started`, properties);
123 return yield* task.pipe(
124 Effect.onExit((result) =>
125 Effect.gen(function* () {
126 const endedAt = yield* Clock.currentTimeMillis;
127 const error = Exit.isFailure(result)
128 ? Cause.findErrorOption(result.cause)
129 : Option.none();
130 const counts =
131 Exit.isSuccess(result) &&
132 (event === "transfer" || options.telemetryFlow !== "primary_handoff")
133 ? yield* accountCounts.pipe(Effect.catchCause(() => Effect.succeed({})))
134 : {};
135 yield* record(`chatgpt.${event}.completed`, {
136 ...counts,
137 ...properties,
138 outcome: Exit.isSuccess(result)
139 ? "succeeded"
140 : Cause.hasInterruptsOnly(result.cause)
141 ? expiresAt !== undefined && endedAt >= expiresAt
142 ? "expired"
143 : "cancelled"
144 : "failed",
145 durationMs: Math.max(0, endedAt - startedAt),
146 ...(Option.isSome(error) && isSetupError(error.value)
147 ? { failureStage: error.value.operation }
148 : {}),
149 });
150 }),
151 ),
152 );
153 });
154 const http = yield* HttpClient.HttpClient;
155 const store = yield* ProviderCredentialStore.make("codex-chatgpt", options.instanceId);
156 const registrationStore = yield* ProviderCredentialStore.make(
157 "codex-chatgpt-registration",
158 options.instanceId,
159 );
160 const secrets = yield* ServerSecretStore;
161 const environmentLocks = sessionLocks.get(secrets) ?? new Map<string, Semaphore.Semaphore>();
162 sessionLocks.set(secrets, environmentLocks);
163 const lock = environmentLocks.get(store.binding.key) ?? (yield* Semaphore.make(1));
164 environmentLocks.set(store.binding.key, lock);
165 const withSessionLock = <A, E, R>(task: Effect.Effect<A, E, R>) =>
166 withChatGptSessionLock(secrets.directory, store.binding.key, options.instanceId, task);
167 const environment = yield* ServerEnvironmentIdentity;
168 const hostId = `urn:uuid:${yield* environment.getEnvironmentId}`;
169 const resource = options.resource ?? RESOURCE;
170 const failure = (operation: string, detail: string) =>
171 new ProviderSetupError({ instanceId: options.instanceId, operation, detail });
172 let metadata: typeof Discovery.Type | undefined;
173 let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;
174 const discover = Effect.gen(function* () {
175 if (metadata) return metadata;
176 const result = yield* http.get(options.discoveryUrl ?? DISCOVERY).pipe(
177 Effect.flatMap((response) =>
178 Effect.gen(function* () {
179 if (response.status < 200 || response.status >= 300)
180 return yield* failure("discover", "Could not reach ChatGPT sign-in. Try again.");
181 return yield* response.json;
182 }),
183 ),
184 Effect.flatMap(decodeDiscoveryEffect),
185 Effect.mapError(() => failure("discover", "Could not reach ChatGPT sign-in. Try again.")),
186 );
187 if (
188 !options.discoveryUrl &&
189 (result.issuer !== "https://auth.openai.com" ||
190 [
191 result.authorization_endpoint,
192 result.token_endpoint,
193 result.jwks_uri,
194 ...(result.revocation_endpoint ? [result.revocation_endpoint] : []),
195 ].some((url) => new URL(url).origin !== result.issuer))
196 )
197 return yield* failure("discover", "ChatGPT sign-in configuration could not be verified.");
198 metadata = result;
199 jwks = createRemoteJWKSet(new URL(result.jwks_uri));
200 return result;
201 });
202 const readSessions = store.get.pipe(
203 Effect.mapError(() => failure("read", "Could not read the saved ChatGPT connection.")),
204 Effect.flatMap((bytes) =>
205 Option.isNone(bytes)
206 ? Effect.succeed<typeof Sessions.Type>({ activeClientId: null, sessions: [] })
207 : decodeSessions(new TextDecoder().decode(bytes.value)).pipe(
208 Effect.map((saved) =>
209 "sessions" in saved ? saved : { activeClientId: saved.clientId, sessions: [saved] },
210 ),
211 Effect.mapError(() =>
212 failure("read", "The saved ChatGPT connection is invalid. Sign in again."),
213 ),
214 ),
215 ),
216 );
217 const read = readSessions.pipe(
218 Effect.map((saved) =>
219 Option.fromUndefinedOr(
220 saved.sessions.find((session) => session.clientId === saved.activeClientId),
221 ),
222 ),
223 );
224 const writeSessions = (saved: typeof Sessions.Type) =>
225 encodeSessions(saved).pipe(
226 Effect.flatMap((json) => store.set(new TextEncoder().encode(json))),
227 Effect.mapError(() => failure("save", "Could not save the ChatGPT connection.")),
228 );
229 // Registration profiles outlive tokens, but belong only to this environment/instance.
230 // Accept the original single-registration record until it is next saved.
231 const readRegistrations = registrationStore.get.pipe(
232 Effect.mapError(() =>
233 failure("registration", "Could not read the ChatGPT sign-in registration. Try again."),
234 ),
235 Effect.flatMap((bytes) =>
236 Option.isNone(bytes)
237 ? Effect.succeed<typeof RegistrationProfiles.Type>({ profiles: [], lastClientId: null })
238 : decodeRegistration(new TextDecoder().decode(bytes.value)).pipe(
239 Effect.map((record) => {
240 const saved =
241 "profiles" in record
242 ? record
243 : { profiles: [record], lastClientId: record.clientId };
244 const last = saved.profiles.find(
245 (profile) => profile.clientId === saved.lastClientId,
246 );
247 const ordered = last
248 ? [last, ...saved.profiles.filter((profile) => profile.clientId !== last.clientId)]
249 : saved.profiles;
250 return {
251 ...saved,
252 profiles: ordered.map((profile) => ({
253 ...profile,
254 connectionLabel:
255 profile.connectionLabel ?? `Connection ${saved.profiles.indexOf(profile) + 1}`,
256 })),
257 };
258 }),
259 Effect.mapError(() =>
260 failure("registration", "The saved ChatGPT sign-in registration is invalid."),
261 ),
262 ),
263 ),
264 );
265 const accountCounts = Effect.gen(function* () {
266 const instanceIds = new Set<string>([options.instanceId]);
267 if (Option.isSome(settings)) {
268 const current = yield* settings.value.getSettings;
269 // Include the legacy default instance as well as explicitly configured ones.
270 instanceIds.add("codex");
271 for (const [id, instance] of Object.entries(current.providerInstances)) {
272 if (instance.driver === "codex") instanceIds.add(id);
273 }
274 }
275 const accounts = new Set<string>();
276 const connections = new Set<string>();
277 const savedConnections = new Set<string>();
278 let unidentifiedConnectedConnectionCount = 0;
279 for (const id of instanceIds) {
280 const registrations = yield* ProviderCredentialStore.make("codex-chatgpt-registration", id);
281 const registrationBytes = yield* registrations.get;
282 if (Option.isSome(registrationBytes)) {
283 const saved = yield* decodeRegistration(new TextDecoder().decode(registrationBytes.value));
284 for (const profile of "profiles" in saved ? saved.profiles : [saved]) {
285 savedConnections.add(profile.clientId);
286 }
287 }
288 const credentials = yield* ProviderCredentialStore.make("codex-chatgpt", id);
289 const credentialBytes = yield* credentials.get;
290 if (Option.isNone(credentialBytes)) continue;
291 const saved = yield* decodeSessions(new TextDecoder().decode(credentialBytes.value));
292 for (const session of "sessions" in saved ? saved.sessions : [saved]) {
293 if (!session.scopes.includes(REQUIRED_SCOPE) || connections.has(session.clientId)) continue;
294 connections.add(session.clientId);
295 // Subjects are client-scoped. Use verified email only for counting locally,
296 // never export it or merge the underlying profiles.
297 const email = session.email?.trim().toLowerCase();
298 if (email) accounts.add(email);
299 else unidentifiedConnectedConnectionCount++;
300 }
301 }
302 return {
303 accountCountScope: Option.isSome(settings) ? "environment" : "provider_instance",
304 connectedAccountCount: accounts.size,
305 connectedConnectionCount: connections.size,
306 savedConnectionCount: savedConnections.size,
307 unidentifiedConnectedConnectionCount,
308 };
309 }).pipe(Effect.provideService(ServerSecretStore, secrets));
310 const saveRegistration = Effect.fnUntraced(function* (profile: typeof Registration.Type) {
311 const saved = yield* readRegistrations;
312 profile = {
313 ...profile,
314 connectionLabel:
315 saved.profiles.find((entry) => entry.clientId === profile.clientId)?.connectionLabel ??
316 profile.connectionLabel ??
317 `Connection ${saved.profiles.length + 1}`,
318 };
319 yield* encodeRegistration({
320 profiles: [profile, ...saved.profiles.filter((entry) => entry.clientId !== profile.clientId)],
321 lastClientId: profile.clientId,
322 }).pipe(
323 Effect.flatMap((json) => registrationStore.set(new TextEncoder().encode(json))),
324 Effect.mapError(() =>
325 failure("registration", "Could not save the ChatGPT sign-in registration. Try again."),
326 ),
327 );
328 });
329 // The active pointer and all profile token sets change in one protected atomic write.
330 const save = Effect.fnUntraced(function* (record: CodexChatGptCredentials, activate = true) {
331 const saved = yield* readSessions;
332 yield* writeSessions({
333 activeClientId: activate ? record.clientId : saved.activeClientId,
334 sessions: [
335 ...saved.sessions.filter((session) => session.clientId !== record.clientId),
336 record,
337 ],
338 });
339 });
340 const clearTokens = Effect.gen(function* () {
341 const saved = yield* readSessions;
342 const sessions = saved.sessions.filter((session) => session.clientId !== saved.activeClientId);
343 if (sessions.length) yield* writeSessions({ activeClientId: null, sessions });
344 else
345 yield* store.remove.pipe(
346 Effect.mapError(() =>
347 failure("disconnect", "Could not clear the ChatGPT connection. Try again."),
348 ),
349 );
350 });
351 const remove = Effect.gen(function* () {
352 // Promote legacy identity into the retained profile before deleting credentials.
353 const credentials = yield* read;
354 if (Option.isSome(credentials)) {
355 const { clientId, subject, email } = credentials.value;
356 const saved = yield* readRegistrations;
357 const profile = saved.profiles.find((entry) => entry.clientId === clientId);
358 if (profile && (profile.subject === undefined || profile.email === undefined))
359 yield* saveRegistration({ ...profile, subject, email });
360 }
361 yield* clearTokens;
362 });
363 const exchange = Effect.fn("CodexChatGptAuth.exchange")(function* (body: URLSearchParams) {
364 const endpoints = yield* discover;
365 const response = yield* http
366 .execute(
367 HttpClientRequest.post(endpoints.token_endpoint).pipe(
368 HttpClientRequest.setHeader("accept", "application/json"),
369 HttpClientRequest.bodyText(body.toString(), "application/x-www-form-urlencoded"),
370 ),
371 )
372 .pipe(
373 Effect.flatMap((result) =>
374 result.json.pipe(
375 Effect.map((raw) => ({
376 ok: result.status >= 200 && result.status < 300,
377 status: result.status,
378 raw,
379 })),
380 ),
381 ),
382 Effect.mapError(() =>
383 failure("exchange", "ChatGPT sign-in is temporarily unavailable. Try again."),
384 ),
385 );
386 if (!response.ok) {
387 const error = yield* Effect.try({
388 try: () => decodeOAuthError(response.raw).error,
389 catch: () => failure("exchange", "ChatGPT did not accept this sign-in. Try again."),
390 });
391 if (
392 body.get("grant_type") === "refresh_token" &&
393 [
394 "invalid_grant",
395 "invalid_refresh_token",
396 "token_expired",
397 "refresh_token_expired",
398 "refresh_token_invalidated",
399 "refresh_token_reused",
400 ].includes(error)
401 ) {
402 yield* remove;
403 return yield* failure(
404 "refresh",
405 "Your ChatGPT connection expired or was disconnected. Sign in again.",
406 );
407 }
408 if (error === "invalid_client")
409 return yield* failure(
410 "client",
411 "OpenAI rejected this app's client registration. Check the ChatGPT connection configuration.",
412 );
413 if (body.get("grant_type") === "authorization_code" && error === "invalid_grant")
414 return yield* failure("code-expired", "This sign-in code expired. Start again.");
415 return yield* failure(
416 "exchange",
417 error === "access_denied"
418 ? "ChatGPT sign-in was declined. Sign in again when you are ready."
419 : "ChatGPT could not complete sign-in. Try again.",
420 );
421 }
422 return yield* Effect.try({
423 try: () => decodeTokens(response.raw),
424 catch: () =>
425 failure("exchange", "ChatGPT returned an invalid token response. Sign in again."),
426 });
427 });
428 const earliest = (value: (typeof TokenResponse.Type)["earliest_refresh_at"]) => {
429 if (value === undefined) return null;
430 const time = typeof value === "number" ? value * 1000 : Date.parse(value);
431 return Number.isFinite(time) ? time : null;
432 };
433 const authenticate = Effect.fn("CodexChatGptAuth.authenticate")(function* (
434 method: string,
435 context: ProviderAuthFlowContext,
436 ) {
437 const endpoints = yield* discover;
438 const existing = yield* read;
439 const previous = Option.getOrUndefined(existing);
440 const registrations = yield* readRegistrations;
441 const changingAccount = method === "chatgpt-change-account";
442 const selectedClientId = method.startsWith("chatgpt-profile:")
443 ? method.slice("chatgpt-profile:".length)
444 : (previous?.clientId ?? registrations.lastClientId);
445 const savedRegistration = changingAccount
446 ? undefined
447 : registrations.profiles.find((profile) => profile.clientId === selectedClientId);
448 // Older development registrations used localhost, which cannot be changed on reauth.
449 // Register a 127.0.0.1 connection instead, preserving the verified account.
450 const legacyCallback = savedRegistration?.redirectUri?.includes("//localhost:") === true;
451 const registeredClientId = legacyCallback ? undefined : savedRegistration?.clientId;
452 const selectedTokens = (yield* readSessions).sessions.find(
453 (session) => session.clientId === selectedClientId,
454 );
455 const state = NodeCrypto.randomBytes(32).toString("base64url");
456 const nonce = NodeCrypto.randomBytes(32).toString("base64url");
457 const verifier = NodeCrypto.randomBytes(64).toString("base64url");
458 const returnUrl =
459 codexAuthReturnUrl(context.returnUrl) ?? codexAuthReturnUrl(options.defaultReturnUrl);
460 const pageNonce = NodeCrypto.randomBytes(24).toString("base64url");
461 const callback = Promise.withResolvers<{ url: URL; response?: NodeHttp.ServerResponse }>();
462 const clientCallback = context.callbackMode === "client";
463 let used = false;
464 const server = clientCallback
465 ? undefined
466 : yield* Effect.acquireRelease(
467 Effect.tryPromise({
468 try: () =>
469 new Promise<NodeHttp.Server>((resolve, reject) => {
470 const server = NodeHttp.createServer((request, response) => {
471 const url = new URL(request.url ?? "/", "http://127.0.0.1");
472 if (request.method !== "GET" || url.pathname !== "/auth/callback") {
473 response.writeHead(404).end();
474 return;
475 }
476 if (used) {
477 response.writeHead(410).end("Sign-in is no longer active.");
478 return;
479 }
480 used = true;
481 callback.resolve({ url, response });
482 });
483 server.once("error", reject);
484 server.listen(0, "127.0.0.1", () => resolve(server));
485 }),
486 catch: () =>
487 failure("callback", "Could not start the local sign-in callback. Try again."),
488 }),
489 (server) =>
490 Effect.promise(
491 () =>
492 new Promise<void>((resolve) => {
493 server.closeAllConnections();
494 server.close(() => resolve());
495 }),
496 ),
497 );
498 const address = server?.address();
499 if (!clientCallback && (!address || typeof address === "string"))
500 return yield* failure("callback", "Could not start the local sign-in callback.");
501 // Only the port may vary between attempts; token exchange uses this exact URI.
502 const port =
503 address && typeof address !== "string" ? address.port : NodeCrypto.randomInt(49_152, 65_536);
504 const redirectUri = `http://127.0.0.1:${port}/auth/callback`;
505 const idTokenHint = selectedTokens?.idToken ?? options.reconnectProfile?.idTokenHint;
506 const url = new URL(endpoints.authorization_endpoint);
507 url.search = new URLSearchParams({
508 client_id: registeredClientId ?? "dynamic_agent_client",
509 ...(registeredClientId
510 ? {
511 ...(savedRegistration?.email ? { login_hint: savedRegistration.email } : {}),
512 ...(idTokenHint ? { id_token_hint: idTokenHint } : {}),
513 ...(savedRegistration?.sharingEnabled === false ||
514 (selectedTokens && !selectedTokens.scopes.includes(REQUIRED_SCOPE))
515 ? { prompt: "consent" }
516 : {}),
517 }
518 : { agent_name_hint: "T3 Code" }),
519 ext_agent_host_id: hostId,
520 response_type: "code",
521 redirect_uri: redirectUri,
522 scope: "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct",
523 resource,
524 state,
525 nonce,
526 code_challenge_method: "S256",
527 code_challenge: NodeCrypto.createHash("sha256").update(verifier).digest("base64url"),
528 }).toString();
529 yield* context.setInteraction(
530 {
531 type: "browser",
532 id: context.flowId,
533 url: url.toString(),
534 requiresConsent: false,
535 acceptsCallback: true,
536 },
537 undefined,
538 (callbackUrl) =>
539 Effect.try({
540 try: () => {
541 const returned = codexCallbackUrl(callbackUrl, redirectUri, state);
542 if (used) throw new Error("used");
543 used = true;
544 callback.resolve({ url: returned });
545 },
546 catch: () =>
547 failure("complete", "This redirect URL does not belong to the active ChatGPT sign-in."),
548 }),
549 );
550 const received = yield* Effect.tryPromise({
551 try: () => callback.promise,
552 catch: () => failure("callback", "ChatGPT sign-in could not be completed."),
553 });
554 const returned = received.url;
555 yield* Effect.gen(function* () {
556 if (returned.searchParams.get("state") !== state)
557 return yield* failure("callback", "ChatGPT sign-in could not be verified. Start again.");
558 if (returned.searchParams.has("error"))
559 return yield* failure(
560 "callback",
561 returned.searchParams.get("error") === "access_denied"
562 ? "ChatGPT sign-in was declined. Sign in again when you are ready."
563 : "ChatGPT sign-in could not be completed. Start again.",
564 );
565 const code = returned.searchParams.get("code");
566 const clientId = registeredClientId ?? returned.searchParams.get("client_id");
567 if (
568 !code ||
569 !clientId ||
570 !clientId.startsWith("oaiapp_") ||
571 (registeredClientId &&
572 returned.searchParams.has("client_id") &&
573 returned.searchParams.get("client_id") !== registeredClientId)
574 )
575 return yield* failure(
576 "callback",
577 "ChatGPT registration is incomplete. Start sign-in again.",
578 );
579 yield* context.verifying;
580 const tokens = yield* exchange(
581 new URLSearchParams({
582 grant_type: "authorization_code",
583 client_id: clientId,
584 code,
585 code_verifier: verifier,
586 redirect_uri: redirectUri,
587 resource,
588 }),
589 ).pipe(
590 Effect.catch((error) =>
591 Effect.gen(function* () {
592 if (error.operation === "code-expired") {
593 if (!registeredClientId)
594 yield* withSessionLock(saveRegistration({ clientId, redirectUri }));
595 return yield* failure(
596 "exchange",
597 "This sign-in code expired. Reconnect the saved ChatGPT profile to start a fresh sign-in.",
598 );
599 }
600 return yield* error;
601 }),
602 ),
603 );
604 if (!tokens.id_token)
605 return yield* failure(
606 "verify",
607 "ChatGPT did not return a verified identity. Sign in again.",
608 );
609 const identity = yield* Effect.tryPromise({
610 try: async () => {
611 const { payload } = await jwtVerify(tokens.id_token!, jwks!, {
612 issuer: endpoints.issuer,
613 audience: clientId,
614 algorithms: ["RS256", "ES256"],
615 clockTolerance: 5,
616 });
617 if (payload.nonce !== nonce || !payload.sub || !payload.exp) throw new Error("identity");
618 return {
619 subject: payload.sub,
620 email: typeof payload.email === "string" ? payload.email : null,
621 };
622 },
623 catch: () => failure("verify", "ChatGPT sign-in could not be verified. Start again."),
624 });
625 const expectedSubject =
626 savedRegistration?.subject ??
627 (previous?.clientId === registeredClientId ? previous?.subject : undefined);
628 // Subjects are checked within the same registration. Legacy callback migration
629 // registers a new client, whose subject cannot be compared with the old client.
630 if (registeredClientId && expectedSubject && identity.subject !== expectedSubject)
631 return yield* failure(
632 "verify",
633 "This sign-in returned a different ChatGPT account. Use the different-account sign-in option instead. Your saved connection is unchanged.",
634 );
635 const knownProfile = registrations.profiles.find((profile) => profile.clientId === clientId);
636 if (
637 knownProfile &&
638 ((knownProfile.redirectUri &&
639 (() => {
640 const original = new URL(knownProfile.redirectUri);
641 const current = new URL(redirectUri);
642 return (
643 original.protocol !== current.protocol ||
644 original.hostname !== current.hostname ||
645 original.pathname !== current.pathname
646 );
647 })()) ||
648 (knownProfile.subject && knownProfile.subject !== identity.subject))
649 )
650 return yield* failure(
651 "verify",
652 "ChatGPT returned a conflicting account registration. Start again.",
653 );
654 if (tokens.token_type.toLowerCase() !== "bearer")
655 return yield* failure(
656 "verify",
657 "ChatGPT returned an unsupported connection. Sign in again.",
658 );
659 const scopes = tokens.scope.split(/\s+/).filter(Boolean);
660 yield* withSessionLock(
661 Effect.gen(function* () {
662 yield* saveRegistration({
663 clientId,
664 redirectUri,
665 sharingEnabled: scopes.includes(REQUIRED_SCOPE),
666 ...identity,
667 });
668 yield* save(
669 {
670 clientId,
671 accessToken: tokens.access_token,
672 idToken: tokens.id_token!,
673 issuer: endpoints.issuer,
674 refreshToken: tokens.refresh_token ?? null,
675 expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000,
676 earliestRefreshAt: earliest(tokens.earliest_refresh_at),
677 scopes,
678 ...identity,
679 },
680 scopes.includes(REQUIRED_SCOPE) || Option.isNone(yield* read),
681 );
682 }),
683 );
684 if (!scopes.includes(REQUIRED_SCOPE))
685 return yield* failure(
686 "sharing",
687 previous && previous.clientId !== clientId
688 ? "Token sharing was not enabled for the new account. Your existing ChatGPT connection is unchanged."
689 : "Signed in with ChatGPT, but token sharing is disabled. Sign in again and enable token sharing, or use another provider.",
690 );
691 }).pipe(
692 Effect.onExit((result) =>
693 Effect.promise(
694 () =>
695 new Promise<void>((resolve) => {
696 const response = received.response;
697 if (!response || response.destroyed) {
698 resolve();
699 return;
700 }
701 response.once("close", resolve);
702 response
703 .writeHead(200, {
704 "content-type": "text/html; charset=utf-8",
705 "cache-control": "no-store",
706 "referrer-policy": "no-referrer",
707 "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${pageNonce}'; base-uri 'none'; frame-ancestors 'none'`,
708 "x-content-type-options": "nosniff",
709 })
710 .end(codexAuthCallbackPage(Exit.isSuccess(result), returnUrl, pageNonce), resolve);
711 }),
712 ),
713 ),
714 );
715 });
716 const access = lock.withPermit(
717 withSessionLock(
718 Effect.uninterruptible(
719 Effect.gen(function* () {
720 const saved = yield* read;
721 if (Option.isNone(saved))
722 return yield* failure("access", "Sign in with ChatGPT to use managed Codex.");
723 let record = saved.value;
724 if (!record.scopes.includes(REQUIRED_SCOPE))
725 return yield* failure(
726 "sharing",
727 "Token sharing is disabled. Sign in with ChatGPT and enable token sharing.",
728 );
729 const now = yield* Clock.currentTimeMillis;
730 if (record.expiresAt - now > 60_000) return record;
731 if (record.earliestRefreshAt !== null && record.earliestRefreshAt > now) {
732 if (record.expiresAt > now) return record;
733 return yield* failure(
734 "refresh",
735 "ChatGPT cannot renew this connection yet. Try again shortly.",
736 );
737 }
738 if (!record.refreshToken) {
739 yield* remove;
740 return yield* failure("refresh", "Your ChatGPT connection expired. Sign in again.");
741 }
742 const tokens = yield* exchange(
743 new URLSearchParams({
744 grant_type: "refresh_token",
745 client_id: record.clientId,
746 refresh_token: record.refreshToken,
747 resource,
748 }),
749 ).pipe(
750 Effect.interruptible,
751 Effect.timeout("20 seconds"),
752 Effect.mapError((error) =>
753 error._tag === "ProviderSetupError" && error.operation === "client"
754 ? error
755 : failure(
756 "refresh",
757 "Could not renew the ChatGPT connection. Retry, or sign in again.",
758 ),
759 ),
760 );
761 if (!tokens.refresh_token || tokens.token_type.toLowerCase() !== "bearer") {
762 return yield* failure("refresh", "ChatGPT returned an invalid renewal. Sign in again.");
763 }
764 record = {
765 ...record,
766 accessToken: tokens.access_token,
767 refreshToken: tokens.refresh_token,
768 expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000,
769 earliestRefreshAt: earliest(tokens.earliest_refresh_at),
770 scopes: tokens.scope.split(/\s+/).filter(Boolean),
771 };
772 yield* save(record);
773 if (!record.scopes.includes(REQUIRED_SCOPE))
774 return yield* failure(
775 "sharing",
776 "ChatGPT token sharing is no longer enabled. Sign in again.",
777 );
778 return record;
779 }),
780 ),
781 ),
782 );
783 const logout = Effect.gen(function* () {
784 const credentials = Option.getOrUndefined(yield* read);
785 let confirmed = !credentials?.refreshToken;
786 if (credentials?.refreshToken) {
787 for (let attempt = 0; attempt < 3; attempt++) {
788 const result = yield* Effect.gen(function* () {
789 const endpoints = yield* discover;
790 if (!endpoints.revocation_endpoint) return { confirmed: false, retry: false };
791 const response = yield* http.execute(
792 HttpClientRequest.post(endpoints.revocation_endpoint).pipe(
793 HttpClientRequest.bodyText(
794 new URLSearchParams({
795 token: credentials.refreshToken!,
796 token_type_hint: "refresh_token",
797 client_id: credentials.clientId,
798 }).toString(),
799 "application/x-www-form-urlencoded",
800 ),
801 ),
802 );
803 return { confirmed: response.status === 200, retry: response.status >= 500 };
804 }).pipe(
805 Effect.timeout("10 seconds"),
806 Effect.orElseSucceed(() => ({ confirmed: false, retry: true })),
807 );
808 confirmed = result.confirmed;
809 if (confirmed || !result.retry || attempt === 2) break;
810 yield* Effect.sleep(attempt === 0 ? "250 millis" : "1 second");
811 }
812 }
813 yield* remove;
814 return confirmed
815 ? undefined
816 : "Signed out locally. Remote revocation could not be confirmed. Disconnect the app in ChatGPT Settings.";
817 });
818 if (options.reconnectProfile) {
819 const { idTokenHint: _hint, ...registration } = options.reconnectProfile;
820 yield* saveRegistration(registration).pipe(Effect.orDie);
821 }
822 const controller = yield* ProviderAuthFlow.make({
823 instanceId: options.instanceId,
824 credentialBinding: store.binding,
825 refreshMethodsAfterAuth: true,
826 methods: Effect.gen(function* () {
827 const saved = yield* readRegistrations;
828 const active = Option.getOrUndefined(yield* read);
829 const current = saved.profiles.find(
830 (profile) => profile.clientId === (active?.clientId ?? saved.lastClientId),
831 );
832 const profiles = current
833 ? [current, ...saved.profiles.filter((profile) => profile.clientId !== current.clientId)]
834 : saved.profiles;
835 return [
836 {
837 id: "chatgpt",
838 name: "Sign in with ChatGPT",
839 description: current?.email ? `Reconnect ${current.email}.` : null,
840 ...(current?.email ? { accountEmail: current.email } : {}),
841 type: "agent" as const,
842 },
843 {
844 id: "chatgpt-change-account",
845 name: "Use a different ChatGPT account",
846 description: "Register a connection for another ChatGPT account.",
847 type: "agent" as const,
848 },
849 // The wire contract allows 32 methods; advertise the 30 most recent profiles.
850 ...profiles.slice(0, 30).map((profile) => ({
851 id: profileMethodId(profile.clientId),
852 name: `${profile.email ?? "ChatGPT account"} · ${profile.connectionLabel}`,
853 ...(profile.email ? { accountEmail: profile.email } : {}),
854 description: "Reuse this account's original sign-in registration.",
855 type: "agent" as const,
856 })),
857 ];
858 }),
859 authenticate: (method, context) =>
860 lock.withPermit(
861 track(
862 "auth",
863 {
864 flow: options.telemetryFlow ?? "direct",
865 intent:
866 options.telemetryFlow === "primary_handoff"
867 ? options.reconnectProfile
868 ? "saved_profile"
869 : "different_account"
870 : method === "chatgpt-change-account"
871 ? "different_account"
872 : method.startsWith("chatgpt-profile:")
873 ? "saved_profile"
874 : "default",
875 callbackMode: context.callbackMode ?? "server",
876 },
877 authenticate(method, context),
878 context.expiresAt,
879 ),
880 ),
881 logout: lock.withPermit(withSessionLock(logout)),
882 });
883 const reconnectProfile = Effect.fnUntraced(function* (methodId: string) {
884 if (methodId === "chatgpt-change-account") return null;
885 const registrations = yield* readRegistrations;
886 const active = Option.getOrUndefined(yield* read);
887 const clientId = methodId.startsWith("chatgpt-profile:")
888 ? methodId.slice("chatgpt-profile:".length)
889 : (active?.clientId ?? registrations.lastClientId);
890 const registration = registrations.profiles.find((profile) => profile.clientId === clientId);
891 if (!registration) {
892 if (methodId.startsWith("chatgpt-profile:"))
893 return yield* failure("export", "This saved connection is no longer available.");
894 return null;
895 }
896 const session = (yield* readSessions).sessions.find((session) => session.clientId === clientId);
897 return { ...registration, ...(session?.idToken ? { idTokenHint: session.idToken } : {}) };
898 });
899 const exportProfile = Effect.gen(function* () {
900 const credentials = Option.getOrUndefined(yield* read);
901 const registration =
902 credentials &&
903 (yield* readRegistrations).profiles.find(
904 (profile) => profile.clientId === credentials.clientId,
905 );
906 if (!credentials?.idToken || !credentials.issuer || !registration)
907 return yield* failure(
908 "export",
909 "Complete ChatGPT sign-in before transferring this connection.",
910 );
911 return {
912 registration,
913 credentials: { ...credentials, idToken: credentials.idToken, issuer: credentials.issuer },
914 } satisfies ChatGptTransferredProfile;
915 });
916 const importProfile = (
917 profile: ChatGptTransferredProfile,
918 stopSessions: Effect.Effect<void, ProviderSetupError>,
919 ) => {
920 const validate = Effect.gen(function* () {
921 const endpoints = yield* discover;
922 const credentials = profile.credentials;
923 if (
924 credentials.clientId !== profile.registration.clientId ||
925 credentials.issuer !== endpoints.issuer ||
926 !credentials.scopes.includes(REQUIRED_SCOPE) ||
927 credentials.expiresAt <= (yield* Clock.currentTimeMillis)
928 )
929 return yield* failure(
930 "import",
931 "The transferred ChatGPT connection is invalid or expired.",
932 );
933 const identity = yield* Effect.tryPromise({
934 try: () =>
935 jwtVerify(credentials.idToken, jwks!, {
936 issuer: endpoints.issuer,
937 audience: credentials.clientId,
938 algorithms: ["RS256", "ES256"],
939 clockTolerance: 5,
940 }),
941 catch: () => failure("import", "The transferred ChatGPT identity could not be verified."),
942 });
943 if (
944 identity.payload.sub !== credentials.subject ||
945 profile.registration.subject !== credentials.subject ||
946 (identity.payload.email ?? null) !== credentials.email ||
947 (profile.registration.email !== undefined &&
948 profile.registration.email !== credentials.email)
949 )
950 return yield* failure(
951 "import",
952 "The transferred ChatGPT identity does not match its profile.",
953 );
954 });
955 const saveImported = Effect.gen(function* () {
956 const { idTokenHint: _hint, ...registration } = profile.registration;
957 const existing = (yield* readRegistrations).profiles.find(
958 (saved) => saved.clientId === registration.clientId,
959 );
960 if (existing?.subject && existing.subject !== profile.credentials.subject)
961 return yield* failure(
962 "import",
963 "The transferred identity conflicts with the saved ChatGPT connection.",
964 );
965 yield* saveRegistration(registration);
966 yield* save(profile.credentials, true);
967 });
968 return lock.withPermit(
969 track(
970 "transfer",
971 { flow: "primary_handoff" },
972 validate.pipe(
973 Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)),
974 ),
975 ),
976 );
977 };
978 return {
979 controller: { ...controller, reconnectProfile, importProfile },
980 read,
981 access,
982 exportProfile,
983 revoke: lock.withPermit(withSessionLock(remove)),
984 };
985});
apps/server/src/provider/CodexChatGptHandoff.ts created+63
......@@ -0,0 +1,63 @@
1import type { ChatGptHandoffInput, ChatGptHandoffState } from "@t3tools/contracts";
2import { ProviderSetupError } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Stream from "effect/Stream";
6import { FetchHttpClient } from "effect/unstable/http";
7import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
8import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts";
9import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts";
10
11// The primary owns OAuth for this stream; the destination owns the refresh session.
12export function subscribeChatGptHandoff(
13 input: ChatGptHandoffInput,
14 owner: string,
15 endpoints: { discoveryUrl: string; resource: string } | undefined = undefined,
16) {
17 return Stream.unwrap(
18 Effect.gen(function* () {
19 const bytes = new Map<string, Uint8Array>();
20 yield* Effect.addFinalizer(() => Effect.sync(() => bytes.clear()));
21 const store = ServerSecretStore.of({
22 get: (key) => Effect.sync(() => Option.fromUndefinedOr(bytes.get(key))),
23 set: (key, value) =>
24 Effect.sync(() => {
25 bytes.set(key, value);
26 }),
27 remove: (key) =>
28 Effect.sync(() => {
29 bytes.delete(key);
30 }),
31 create: () => Effect.die("Handoff does not create persistent secrets."),
32 getOrCreateRandom: () => Effect.die("Handoff uses the destination environment identity."),
33 });
34 const auth = yield* makeCodexChatGptAuth({
35 ...endpoints,
36 instanceId: input.instanceId,
37 reconnectProfile: input.profile,
38 telemetryFlow: "primary_handoff",
39 defaultReturnUrl: input.returnUrl,
40 }).pipe(
41 Effect.provideService(ServerSecretStore, store),
42 Effect.provideService(
43 ServerEnvironmentIdentity,
44 ServerEnvironmentIdentity.of({
45 getEnvironmentId: Effect.succeed(input.environmentId),
46 }),
47 ),
48 Effect.provide(FetchHttpClient.layer),
49 );
50 yield* auth.controller.start(owner, Effect.void, "chatgpt", input.returnUrl, "server");
51 return auth.controller.subscribe(owner).pipe(
52 Stream.takeUntil((state) => ["succeeded", "failed", "cancelled"].includes(state.phase)),
53 Stream.mapEffect((state): Effect.Effect<ChatGptHandoffState, ProviderSetupError> =>
54 state.phase === "succeeded"
55 ? auth.exportProfile.pipe(
56 Effect.map((profile) => ({ phase: "finished" as const, profile })),
57 )
58 : Effect.succeed({ phase: "auth" as const, state }),
59 ),
60 );
61 }),
62 );
63}
apps/server/src/provider/CodexChatGptModels.test.ts created+75
......@@ -0,0 +1,75 @@
1// @effect-diagnostics preferSchemaOverJson:off - Mock HTTP responses use JSON fixtures.
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import { HttpClient, HttpClientResponse } from "effect/unstable/http";
5import { chatGptModels } from "./CodexChatGptModels.ts";
6
7it.effect(
8 "uses each selected profile's token and the server's visible catalog order and names",
9 () =>
10 Effect.gen(function* () {
11 const requests: string[] = [];
12 const http = HttpClient.make((request) =>
13 Effect.sync(() => {
14 assert.strictEqual(request.url, "https://api.openai.com/v1/models");
15 requests.push(request.headers.authorization!);
16 return HttpClientResponse.fromWeb(
17 request,
18 new Response(
19 JSON.stringify({
20 models:
21 request.headers.authorization === "Bearer account-a"
22 ? [
23 { slug: "second", display_name: "Second from OpenAI", visibility: "list" },
24 { slug: "hidden", display_name: "Hidden", visibility: "hidden" },
25 { slug: "first", display_name: "First from OpenAI", visibility: "list" },
26 ]
27 : [{ slug: "account-b-only", display_name: "B", visibility: "list" }],
28 }),
29 ),
30 );
31 }),
32 );
33 const native = [
34 {
35 slug: "first",
36 name: "Cached first",
37 isCustom: false,
38 capabilities: { optionDescriptors: [] },
39 },
40 { slug: "not-entitled", name: "Cached other", isCustom: false, capabilities: null },
41 ];
42 const a = yield* chatGptModels("account-a", native).pipe(
43 Effect.provideService(HttpClient.HttpClient, http),
44 );
45 assert.deepEqual(
46 a.map((model) => [model.slug, model.name]),
47 [
48 ["second", "Second from OpenAI"],
49 ["first", "First from OpenAI"],
50 ],
51 );
52 assert.deepEqual(a[1]!.capabilities, native[0]!.capabilities);
53 assert.isNull(a[0]!.capabilities);
54 const b = yield* chatGptModels("account-b", native).pipe(
55 Effect.provideService(HttpClient.HttpClient, http),
56 );
57 assert.deepEqual(
58 b.map((model) => model.slug),
59 ["account-b-only"],
60 );
61 assert.deepEqual(requests, ["Bearer account-a", "Bearer account-b"]);
62 }),
63);
64
65it.effect("does not present a cached catalog as account entitlements when discovery fails", () =>
66 Effect.gen(function* () {
67 const http = HttpClient.make((request) =>
68 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 503 }))),
69 );
70 const error = yield* Effect.flip(
71 chatGptModels("account-a", []).pipe(Effect.provideService(HttpClient.HttpClient, http)),
72 );
73 assert.strictEqual(error._tag, "ChatGptCatalogError");
74 }),
75);
apps/server/src/provider/CodexChatGptModels.ts created+47
......@@ -0,0 +1,47 @@
1import type { ServerProviderModel } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import { HttpClient, HttpClientRequest } from "effect/unstable/http";
5
6export class ChatGptCatalogError extends Schema.TaggedError<ChatGptCatalogError>()(
7 "ChatGptCatalogError",
8 { status: Schema.Int },
9) {}
10
11const Catalog = Schema.Struct({
12 models: Schema.Array(
13 Schema.Struct({
14 slug: Schema.NonEmptyString,
15 display_name: Schema.NonEmptyString,
16 visibility: Schema.String,
17 }),
18 ),
19});
20
21/** Account choices come from OpenAI; native model/list contributes capability metadata only. */
22export const chatGptModels = Effect.fn("chatGptModels")(function* (
23 accessToken: string,
24 nativeModels: ReadonlyArray<ServerProviderModel>,
25) {
26 const http = yield* HttpClient.HttpClient;
27 const response = yield* http.execute(
28 HttpClientRequest.get("https://api.openai.com/v1/models").pipe(
29 HttpClientRequest.bearerToken(accessToken),
30 ),
31 );
32 if (response.status !== 200) return yield* new ChatGptCatalogError({ status: response.status });
33 const catalog = yield* response.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Catalog)));
34 return catalog.models
35 .filter((model) => model.visibility === "list")
36 .map(
37 (model) =>
38 ({
39 ...nativeModels.find((native) => native.slug === model.slug),
40 capabilities:
41 nativeModels.find((native) => native.slug === model.slug)?.capabilities ?? null,
42 slug: model.slug,
43 name: model.display_name,
44 isCustom: false,
45 }) satisfies ServerProviderModel,
46 );
47}, Effect.timeout("15 seconds"));
apps/server/src/provider/CodexChatGptSessionLock.test.ts created+75
......@@ -0,0 +1,75 @@
1// @effect-diagnostics nodeBuiltinImport:off - A separate Node process proves filesystem exclusion.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeModule from "node:module";
4import * as NodePath from "node:path";
5import * as NodeUtil from "node:util";
6import { assert, it } from "@effect/vitest";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { ProviderInstanceId } from "@t3tools/contracts";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as FileSystem from "effect/FileSystem";
13import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts";
14
15const execFile = NodeUtil.promisify(NodeChildProcess.execFile);
16const lockModule = NodeModule.createRequire(import.meta.url).resolve("proper-lockfile");
17const instanceId = ProviderInstanceId.make("cross-process-test");
18const probe = (directory: string) =>
19 Effect.promise(async () => {
20 const { stdout } = await execFile(process.execPath, [
21 "-e",
22 `
23 const { lock } = require(process.argv[1]);
24 lock(process.argv[2], { realpath: false }).then(async release => {
25 await release(); process.stdout.write('acquired');
26 }, error => {
27 if (error.code === 'ELOCKED') process.stdout.write('blocked');
28 else { console.error(error); process.exitCode = 1; }
29 });
30 `,
31 lockModule,
32 NodePath.join(directory, "session.bin"),
33 ]);
34 return stdout;
35 });
36
37it.live("excludes another process and releases after the credential update", () =>
38 Effect.scoped(
39 Effect.gen(function* () {
40 const fs = yield* FileSystem.FileSystem;
41 const directory = yield* fs.makeTempDirectoryScoped();
42 const blocked = yield* withChatGptSessionLock(
43 directory,
44 "session",
45 instanceId,
46 probe(directory),
47 );
48 assert.strictEqual(blocked, "blocked");
49 assert.strictEqual(yield* probe(directory), "acquired");
50 }),
51 ).pipe(Effect.provide(NodeServices.layer)),
52);
53
54it.live("releases the cross-process lease when the operation is interrupted", () =>
55 Effect.scoped(
56 Effect.gen(function* () {
57 const fs = yield* FileSystem.FileSystem;
58 const directory = yield* fs.makeTempDirectoryScoped();
59 const entered = yield* Deferred.make<void>();
60 const operation = yield* withChatGptSessionLock(
61 directory,
62 "session",
63 instanceId,
64 Effect.gen(function* () {
65 yield* Deferred.succeed(entered, undefined);
66 return yield* Effect.never;
67 }),
68 ).pipe(Effect.forkScoped);
69 yield* Deferred.await(entered);
70 assert.strictEqual(yield* probe(directory), "blocked");
71 yield* Fiber.interrupt(operation);
72 assert.strictEqual(yield* probe(directory), "acquired");
73 }),
74 ).pipe(Effect.provide(NodeServices.layer)),
75);
apps/server/src/provider/CodexChatGptSessionLock.ts created+45
......@@ -0,0 +1,45 @@
1// @effect-diagnostics nodeBuiltinImport:off - Credential leases coordinate Node server processes.
2import * as NodePath from "node:path";
3import { lock } from "proper-lockfile";
4import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7
8/** Keep rotating tokens and their active profile atomic across servers sharing a secret store. */
9export const withChatGptSessionLock = <A, E, R>(
10 directory: string | undefined,
11 key: string,
12 instanceId: ProviderInstanceId,
13 task: Effect.Effect<A, E, R>,
14): Effect.Effect<A, E | ProviderSetupError, R> => {
15 // In-memory stores have no shared filesystem; the auth controller still serializes its callers.
16 if (!directory) return task;
17 const failure = () =>
18 new ProviderSetupError({
19 instanceId,
20 operation: "credential-lock",
21 detail: "Could not lock the ChatGPT connection for an update. Try again.",
22 });
23 return Effect.scoped(
24 Effect.gen(function* () {
25 const compromised = yield* Deferred.make<never, ProviderSetupError>();
26 const services = yield* Effect.context<never>();
27 yield* Effect.acquireRelease(
28 Effect.tryPromise({
29 try: () =>
30 lock(NodePath.join(directory, `${key}.bin`), {
31 realpath: false,
32 stale: 120_000,
33 update: 10_000,
34 retries: { retries: 80, factor: 1, minTimeout: 500, maxTimeout: 500 },
35 onCompromised: () =>
36 Effect.runSyncWith(services)(Deferred.fail(compromised, failure())),
37 }),
38 catch: failure,
39 }),
40 (release) => Effect.promise(() => release()).pipe(Effect.ignore),
41 );
42 return yield* Effect.raceFirst(task, Deferred.await(compromised));
43 }),
44 );
45};
apps/server/src/provider/CodexInstallation.test.ts created+364
......@@ -0,0 +1,364 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { BUNDLED_MODEL_MANIFEST, ModelManifest, type ModelManifestData } from "./ModelManifest.ts";
3import { expect, it } from "@effect/vitest";
4import {
5 HostProcessArchitecture,
6 HostProcessEnvironment,
7 HostProcessPlatform,
8} from "@t3tools/shared/hostProcess";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Option from "effect/Option";
13import * as Exit from "effect/Exit";
14import * as Scope from "effect/Scope";
15import * as Stream from "effect/Stream";
16import { HttpClient, HttpClientResponse } from "effect/unstable/http";
17import * as NodeCrypto from "node:crypto";
18import {
19 makeCodexInstallation,
20 type CodexInstallation,
21 type CodexInstallationOptions,
22 resolveCodexReleaseAsset,
23} from "./CodexInstallation.ts";
24
25const archive = Buffer.from(
26 "H4sIAAAAAAAC/+3W0W6DIBQGYB/FcD0sKNSkD7J7aom6tmAQtzXL3n3QZM3qdWVt+n8XoCckJp78wLY3q8bu9Ge2HBbUUp7nYD4H8s9zrNdSsCxnWQLT6JULn8ye09K9h/u2/c0/jSM9xqGzo0+bf3Gdf15WQiD/Kdy61/CA+z8dlO9Wrv2387+c5Z9VZY38p7BY0+Gh8t/sVauLt9Ga9Pnnop7ln1e4/6fxRQ7qZCf/qt3YW0M2OX/JyfvljbCCy3XBSaiGH9VqH4tKuaZbC6qG4aDpTrmP3sQV2nh3Gmxvzqsud0vyjaABAAAAAAAAAAAAAAAk8gOq19rvACgAAA==",
27 "base64",
28);
29const asset = {
30 version: "0.156.1",
31 target: "aarch64-apple-darwin",
32 url: "https://github.com/openai/codex/releases/download/test/package.tar.gz",
33 sha256: NodeCrypto.createHash("sha256").update(archive).digest("hex"),
34 archiveBytes: archive.length,
35};
36const makeHarness = Effect.fn("test.makeCodexInstallation")(function* (
37 input: {
38 options?: Partial<CodexInstallationOptions>;
39 manifestCurrent?: Effect.Effect<ModelManifestData>;
40 body?: Stream.Stream<Uint8Array>;
41 baseDir?: string;
42 local?: { version: string; appServerFails?: boolean; versionFails?: boolean };
43 } = {},
44) {
45 const fs = yield* FileSystem.FileSystem;
46 const baseDir =
47 input.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-install-test-" }));
48 const localDirectory = `${baseDir}/local`;
49 const localBinaryPath = `${localDirectory}/codex`;
50 const probeLog = `${baseDir}/local-probes.txt`;
51 if (input.local) {
52 yield* fs.makeDirectory(localDirectory, { recursive: true });
53 yield* fs.writeFileString(
54 localBinaryPath,
55 `#!/bin/sh\nprintf '%s\\n' "$*" >> '${probeLog}'\ncase "$1" in\n--version) ${input.local.versionFails ? "exit 1" : `printf '%s\\n' 'codex-cli ${input.local.version}'`};;\napp-server) exit ${input.local.appServerFails ? "1" : "0"};;\nesac\n`,
56 { mode: 0o755 },
57 );
58 }
59 let downloads = 0;
60 const installation = yield* makeCodexInstallation({
61 baseDir,
62 releaseAsset: asset,
63 validate: () => Effect.void,
64 ...input.options,
65 }).pipe(
66 Effect.provideService(ModelManifest, {
67 current: input.manifestCurrent ?? Effect.succeed(BUNDLED_MODEL_MANIFEST),
68 refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST),
69 forceRefresh: Effect.succeed(BUNDLED_MODEL_MANIFEST),
70 refreshInBackground: Effect.void,
71 }),
72 Effect.provideService(HostProcessPlatform, "darwin"),
73 Effect.provideService(HostProcessArchitecture, "arm64"),
74 Effect.provideService(HostProcessEnvironment, { PATH: input.local ? localDirectory : "" }),
75 Effect.provideService(
76 HttpClient.HttpClient,
77 HttpClient.make((request) =>
78 Effect.sync(() => {
79 downloads++;
80 return Object.defineProperty(
81 HttpClientResponse.fromWeb(request, new Response(null)),
82 "stream",
83 {
84 value: input.body ?? Stream.succeed(archive),
85 },
86 );
87 }),
88 ),
89 ),
90 );
91 return { installation, fs, baseDir, localBinaryPath, probeLog, downloads: () => downloads };
92});
93const terminalState = (installation: CodexInstallation["Service"]) =>
94 installation.changes.pipe(
95 Stream.filter((state) => ["succeeded", "failed", "cancelled"].includes(state.phase)),
96 Stream.runHead,
97 Effect.map(Option.getOrThrow),
98 );
99
100for (const version of ["0.156.0", "0.156.1", "0.156.2", "0.157.0"]) {
101 it.effect(`reuses installed Codex ${version} without downloading or taking ownership of it`, () =>
102 Effect.gen(function* () {
103 const h = yield* makeHarness({ local: { version } });
104 expect(yield* h.installation.start).toMatchObject({
105 source: "local",
106 phase: "succeeded",
107 installedVersion: version,
108 executablePath: h.localBinaryPath,
109 canRemove: false,
110 });
111 expect(yield* h.installation.resolve()).toMatchObject({
112 source: "local",
113 executablePath: h.localBinaryPath,
114 managedVersionDirectory: null,
115 version,
116 });
117 yield* h.installation.acquire().pipe(Effect.scoped);
118 expect(h.downloads()).toBe(0);
119 expect(yield* h.fs.exists(h.installation.managedDirectory)).toBe(false);
120 expect((yield* h.fs.readFileString(h.probeLog)).trim().split("\n")).toEqual([
121 "--version",
122 "app-server --help",
123 ]);
124 yield* h.installation.remove();
125 expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true);
126 expect((yield* h.installation.state).source).toBe("local");
127 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
128 );
129}
130for (const local of [
131 { version: "0.128.9" },
132 { version: "0.145.0" },
133 { version: "0.155.1" },
134 { version: "0.155.9" },
135 { version: "0.156.1-alpha.1" },
136 { version: "unknown" },
137 { version: "0.156.1", appServerFails: true },
138 { version: "0.156.1", versionFails: true },
139]) {
140 it.effect(
141 `downloads the pinned release when the local CLI is unsupported or broken: ${JSON.stringify(local)}`,
142 () =>
143 Effect.gen(function* () {
144 const h = yield* makeHarness({ local });
145 expect((yield* h.installation.state).installedVersion).toBeNull();
146 yield* h.installation.start;
147 const installed = yield* terminalState(h.installation);
148 expect(installed.phase).toBe("succeeded");
149 const executable = yield* h.installation.resolve();
150 expect(executable.source).toBe("managed");
151 expect(installed.executablePath).toBe(executable.executablePath);
152 expect(h.downloads()).toBe(1);
153 expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true);
154 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
155 );
156}
157it.effect("falls back to a managed download when the reused local executable disappears", () =>
158 Effect.gen(function* () {
159 const h = yield* makeHarness({ local: { version: "0.156.1" } });
160 expect((yield* h.installation.resolve()).source).toBe("local");
161 yield* h.fs.remove(h.localBinaryPath);
162 yield* h.installation.start;
163 expect((yield* terminalState(h.installation)).phase).toBe("succeeded");
164 expect((yield* h.installation.resolve()).source).toBe("managed");
165 expect(h.downloads()).toBe(1);
166 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
167);
168
169it.effect("rechecks compatibility after the local executable is replaced", () =>
170 Effect.gen(function* () {
171 const h = yield* makeHarness({ local: { version: "0.156.1" } });
172 expect((yield* h.installation.resolve()).source).toBe("local");
173 yield* h.fs.remove(h.localBinaryPath);
174 yield* h.fs.writeFileString(h.localBinaryPath, "#!/bin/sh\nprintf 'codex-cli 0.128.9\\n'\n", {
175 mode: 0o755,
176 });
177 yield* h.installation.start;
178 expect((yield* terminalState(h.installation)).phase).toBe("succeeded");
179 expect((yield* h.installation.resolve()).source).toBe("managed");
180 expect(h.downloads()).toBe(1);
181 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
182);
183
184it.effect("rechecks a cached local executable when the shared manifest policy changes", () =>
185 Effect.gen(function* () {
186 let manifest = BUNDLED_MODEL_MANIFEST;
187 const h = yield* makeHarness({
188 local: { version: "0.156.0" },
189 manifestCurrent: Effect.sync(() => manifest),
190 });
191 expect((yield* h.installation.resolve()).source).toBe("local");
192 manifest = {
193 ...manifest,
194 compatibility: [
195 {
196 driver: "codex",
197 t3CodeRange: ">=0.0.42",
198 ranges: [
199 { range: ">=0.156.1", status: "supported" },
200 { range: "<0.156.1", status: "broken" },
201 ],
202 },
203 ],
204 };
205 yield* h.installation.start;
206 expect((yield* terminalState(h.installation)).phase).toBe("succeeded");
207 expect((yield* h.installation.resolve()).source).toBe("managed");
208 expect(h.downloads()).toBe(1);
209 expect((yield* h.fs.readFileString(h.probeLog)).trim().split("\n")).toEqual([
210 "--version",
211 "app-server --help",
212 ]);
213 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
214);
215
216it.effect("uses bundled Codex compatibility when the remote manifest omits its policy", () =>
217 Effect.gen(function* () {
218 const h = yield* makeHarness({
219 local: { version: "0.156.0" },
220 manifestCurrent: Effect.succeed({ ...BUNDLED_MODEL_MANIFEST, compatibility: [] }),
221 });
222 expect((yield* h.installation.start).source).toBe("local");
223 expect(h.downloads()).toBe(0);
224 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
225);
226
227it.effect("preserves the invoked name of version-manager launcher symlinks", () =>
228 Effect.gen(function* () {
229 const h = yield* makeHarness({ local: { version: "0.156.1" } });
230 const launcher = `${h.baseDir}/launcher`;
231 yield* h.fs.writeFileString(
232 launcher,
233 '#!/bin/sh\ncase "$0" in */codex) ;; *) exit 1;; esac\ncase "$1" in --version) printf "codex-cli 0.156.1\\n";; app-server) exit 0;; esac\n',
234 { mode: 0o755 },
235 );
236 yield* h.fs.remove(h.localBinaryPath);
237 yield* h.fs.symlink(launcher, h.localBinaryPath);
238 expect(yield* h.installation.start).toMatchObject({ source: "local", phase: "succeeded" });
239 expect((yield* h.installation.resolve()).executablePath).toBe(h.localBinaryPath);
240 expect(h.downloads()).toBe(0);
241 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
242);
243
244it.effect(
245 "installs the complete verified package in tools/codex/version and survives a restart",
246 () =>
247 Effect.gen(function* () {
248 const { installation, fs, baseDir } = yield* makeHarness();
249 yield* installation.start;
250 expect((yield* terminalState(installation)).phase).toBe("succeeded");
251 const executable = yield* installation.resolve();
252 expect(executable.executablePath).toBe(`${baseDir}/tools/codex/0.156.1/bin/codex`);
253 expect(
254 yield* fs.readFileString(`${executable.managedVersionDirectory}/bin/codex-code-mode-host`),
255 ).toBe("host");
256 expect(yield* fs.readFileString(`${executable.managedVersionDirectory}/codex-path/rg`)).toBe(
257 "rg",
258 );
259 const restarted = yield* makeHarness({ baseDir });
260 expect((yield* restarted.installation.resolve()).version).toBe("0.156.1");
261 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
262);
263
264it.effect("requires an update before running an older activated managed installation", () =>
265 Effect.gen(function* () {
266 const first = yield* makeHarness();
267 yield* first.installation.start;
268 yield* terminalState(first.installation);
269 const executable = yield* first.installation.resolve();
270 const oldDirectory = `${first.installation.managedDirectory}/0.155.1`;
271 yield* first.fs.rename(executable.managedVersionDirectory!, oldDirectory);
272 for (const name of ["codex-package.json", ".install-complete.json"]) {
273 const file = `${oldDirectory}/${name}`;
274 yield* first.fs.writeFileString(
275 file,
276 (yield* first.fs.readFileString(file)).replaceAll("0.156.1", "0.155.1"),
277 );
278 }
279 yield* first.fs.writeFileString(
280 `${first.installation.managedDirectory}/active.json`,
281 '{"version":"0.155.1"}',
282 );
283 const restarted = yield* makeHarness({ baseDir: first.baseDir });
284 expect((yield* Effect.flip(restarted.installation.resolve())).detail).toContain("0.156.0");
285 yield* restarted.installation.start;
286 expect((yield* terminalState(restarted.installation)).phase).toBe("succeeded");
287 expect((yield* restarted.installation.resolve()).version).toBe("0.156.1");
288 expect(restarted.downloads()).toBe(1);
289 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
290);
291
292it.effect("rejects corrupted downloads without publishing a runtime", () =>
293 Effect.gen(function* () {
294 const { installation, fs } = yield* makeHarness({
295 options: { releaseAsset: { ...asset, sha256: "0".repeat(64) } },
296 });
297 yield* installation.start;
298 const state = yield* terminalState(installation);
299 expect(state.phase).toBe("failed");
300 expect(state.message).toContain("SHA-256");
301 expect(yield* fs.exists(`${installation.managedDirectory}/active.json`)).toBe(false);
302 expect(yield* fs.exists(`${installation.managedDirectory}/0.156.1`)).toBe(false);
303 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
304);
305
306it.effect("cancels an in-flight download and cleans the staging directory", () =>
307 Effect.gen(function* () {
308 const downloading = yield* Deferred.make<void>();
309 const body = Stream.fromEffect(
310 Deferred.succeed(downloading, undefined).pipe(Effect.andThen(Effect.never)),
311 );
312 const { installation, fs } = yield* makeHarness({ body });
313 const started = yield* installation.start;
314 yield* Deferred.await(downloading);
315 expect((yield* installation.cancel(started.operationId!)).phase).toBe("cancelled");
316 expect(
317 (yield* fs.readDirectory(installation.managedDirectory)).filter((name) =>
318 name.startsWith(".install-"),
319 ),
320 ).toEqual([]);
321 expect(yield* fs.exists(`${installation.managedDirectory}/active.json`)).toBe(false);
322 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
323);
324
325it.effect("protects active process leases and removes the runtime after release", () =>
326 Effect.gen(function* () {
327 const { installation, fs } = yield* makeHarness();
328 yield* installation.start;
329 yield* terminalState(installation);
330 const leaseScope = yield* Scope.make();
331 yield* installation.acquire().pipe(Effect.provideService(Scope.Scope, leaseScope));
332 expect((yield* Effect.flip(installation.remove())).detail).toContain("Stop Codex sessions");
333 yield* Scope.close(leaseScope, Exit.void);
334 yield* installation.remove();
335 expect(yield* fs.exists(installation.managedDirectory)).toBe(false);
336 expect((yield* installation.state).installedVersion).toBeNull();
337 expect((yield* installation.state).executablePath).toBeNull();
338 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
339);
340
341it.effect("keeps an activated runtime when a later update fails verification", () =>
342 Effect.gen(function* () {
343 const first = yield* makeHarness();
344 yield* first.installation.start;
345 yield* terminalState(first.installation);
346 const update = yield* makeHarness({
347 baseDir: first.baseDir,
348 options: { releaseAsset: { ...asset, version: "0.156.2", sha256: "0".repeat(64) } },
349 });
350 expect((yield* update.installation.state).executablePath).toBe(
351 (yield* first.installation.resolve()).executablePath,
352 );
353 yield* update.installation.start;
354 expect((yield* terminalState(update.installation)).phase).toBe("failed");
355 expect((yield* update.installation.resolve()).version).toBe("0.156.1");
356 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
357);
358
359it("publishes complete packages for all supported platforms", () => {
360 for (const platform of ["darwin", "linux", "win32"] as const)
361 for (const arch of ["x64", "arm64"])
362 expect(resolveCodexReleaseAsset(platform, arch)?.url).toContain("codex-package-");
363 expect(resolveCodexReleaseAsset("linux", "riscv64")).toBeNull();
364});
apps/server/src/provider/CodexInstallation.ts created+734
......@@ -0,0 +1,734 @@
1// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest.
2import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts";
3import {
4 HostProcessArchitecture,
5 HostProcessEnvironment,
6 HostProcessPlatform,
7} from "@t3tools/shared/hostProcess";
8import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell";
9import * as Clock from "effect/Clock";
10import * as Cause from "effect/Cause";
11import * as Context from "effect/Context";
12import * as Crypto from "effect/Crypto";
13import * as Effect from "effect/Effect";
14import * as Exit from "effect/Exit";
15import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as Path from "effect/Path";
20import * as Schema from "effect/Schema";
21import * as Scope from "effect/Scope";
22import * as Semaphore from "effect/Semaphore";
23import * as Stream from "effect/Stream";
24import * as SubscriptionRef from "effect/SubscriptionRef";
25import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
26import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
27import * as NodeCrypto from "node:crypto";
28import { ServerConfig } from "../config.ts";
29import { BUNDLED_MODEL_MANIFEST, ModelManifest } from "./ModelManifest.ts";
30import { resolveProviderCompatibility } from "./providerCompatibility.ts";
31
32const DRIVER = ProviderDriverKind.make("codex");
33const Version = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/u));
34const ActiveRelease = Schema.Struct({ version: Version });
35const InstalledRelease = Schema.Struct({
36 version: Version,
37 target: Schema.String,
38 sha256: Schema.String,
39});
40const PackageManifest = Schema.Struct({
41 layoutVersion: Schema.Literal(1),
42 version: Version,
43 target: Schema.String,
44 entrypoint: Schema.String,
45});
46const decodeVersion = Schema.decodeUnknownEffect(Version);
47const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease));
48const encodeActive = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease));
49export interface CodexReleaseAsset {
50 readonly version: string;
51 readonly target: string;
52 readonly url: string;
53 readonly sha256: string;
54 readonly archiveBytes: number;
55}
56// Official complete packages retain the code-mode, search, and resource companions.
57const RELEASES: Readonly<Record<string, CodexReleaseAsset>> = {
58 "darwin-arm64": {
59 version: "0.156.1",
60 target: "aarch64-apple-darwin",
61 url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-apple-darwin.tar.gz",
62 sha256: "fea42f9625091f011e38f059da974d52e57ba31831648bb1c7f0b1a385fde547",
63 archiveBytes: 127394863,
64 },
65 "darwin-x64": {
66 version: "0.156.1",
67 target: "x86_64-apple-darwin",
68 url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-apple-darwin.tar.gz",
69 sha256: "618dbcd55419fa041871f777a14b107ceb3fe2d339ef81e21e6ab5374420dc71",
70 archiveBytes: 138670455,
71 },
72 "linux-arm64": {
73 version: "0.156.1",
74 target: "aarch64-unknown-linux-musl",
75 url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-unknown-linux-musl.tar.gz",
76 sha256: "fdd47ed6aade0360796fd3f6f95a45096f327c15e19e8c7339f9dc5633041786",
77 archiveBytes: 136933361,
78 },
79 "linux-x64": {
80 version: "0.156.1",
81 target: "x86_64-unknown-linux-musl",
82 url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-unknown-linux-musl.tar.gz",
83 sha256: "8b711520beddf385467b8da4d2c93736637c6ba1e46811cf0d8606b7c490b6f6",
84 archiveBytes: 145976992,
85 },
86 "win32-arm64": {
87 version: "0.156.1",
88 target: "aarch64-pc-windows-msvc",
89 url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-pc-windows-msvc.tar.gz",
90 sha256: "85994caecdc7609c49fd585c1cdb5677fa9d0acbf789650cff23a13afc9505db",
91 archiveBytes: 142037952,
92 },
93 "win32-x64": {
94 version: "0.156.1",
95 target: "x86_64-pc-windows-msvc",
96 url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-pc-windows-msvc.tar.gz",
97 sha256: "a2e017db9807e6a2269a26fea0e1d9546469cef4d472a33016bc9f3ad7d3b733",
98 archiveBytes: 153839991,
99 },
100};
101export const resolveCodexReleaseAsset = (platform: NodeJS.Platform, arch: string) =>
102 RELEASES[`${platform}-${arch}`] ?? null;
103export class CodexInstallationError extends Schema.TaggedError<CodexInstallationError>()(
104 "CodexInstallationError",
105 {
106 operation: Schema.String,
107 detail: Schema.String,
108 cause: Schema.optional(Schema.Defect()),
109 },
110) {
111 override get message() {
112 return this.detail;
113 }
114}
115const isInstallationError = Schema.is(CodexInstallationError);
116const installationError = (operation: string, detail: string, cause?: unknown) =>
117 new CodexInstallationError({ operation, detail, ...(cause === undefined ? {} : { cause }) });
118const wrapFailure = (operation: string, detail: string) => (cause: unknown) =>
119 isInstallationError(cause) ? cause : installationError(operation, detail, cause);
120export interface CodexExecutable {
121 readonly executablePath: string;
122 readonly source: "managed" | "local";
123 readonly version: string;
124 readonly managedVersionDirectory: string | null;
125}
126interface CodexInstallationService {
127 readonly managedDirectory: string;
128 readonly resolve: () => Effect.Effect<CodexExecutable, CodexInstallationError>;
129 readonly acquire: () => Effect.Effect<CodexExecutable, CodexInstallationError, Scope.Scope>;
130 readonly start: Effect.Effect<ProviderInstallState, CodexInstallationError>;
131 readonly cancel: (
132 operationId: string,
133 ) => Effect.Effect<ProviderInstallState, CodexInstallationError>;
134 readonly state: Effect.Effect<ProviderInstallState>;
135 readonly changes: Stream.Stream<ProviderInstallState>;
136 readonly remove: (
137 protectedBinaryPaths?: ReadonlyArray<string>,
138 ) => Effect.Effect<void, CodexInstallationError>;
139}
140export class CodexInstallation extends Context.Service<
141 CodexInstallation,
142 CodexInstallationService
143>()("t3/provider/CodexInstallation") {
144 static readonly layer = Layer.effect(
145 CodexInstallation,
146 Effect.gen(function* () {
147 const config = yield* ServerConfig;
148 return yield* makeCodexInstallation({ baseDir: config.baseDir });
149 }),
150 );
151}
152export interface CodexInstallationOptions {
153 readonly baseDir: string;
154 readonly releaseAsset?: CodexReleaseAsset | null;
155 readonly validate?: (
156 executable: CodexExecutable,
157 expectedVersion: string,
158 ) => Effect.Effect<void, CodexInstallationError, Scope.Scope>;
159}
160const isRunning = (state: ProviderInstallState) =>
161 ["downloading", "extracting", "verifying"].includes(state.phase);
162export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function* (
163 options: CodexInstallationOptions,
164) {
165 const manifestService = yield* ModelManifest;
166 const fs = yield* FileSystem.FileSystem;
167 const path = yield* Path.Path;
168 const crypto = yield* Crypto.Crypto;
169 const http = yield* HttpClient.HttpClient;
170 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
171 const serviceScope = yield* Effect.scope;
172 const platform = yield* HostProcessPlatform;
173 const environment = yield* HostProcessEnvironment;
174 const arch = yield* HostProcessArchitecture;
175 const asset =
176 options.releaseAsset === undefined
177 ? resolveCodexReleaseAsset(platform, arch)
178 : options.releaseAsset;
179 const managedDirectory = path.join(options.baseDir, "tools", "codex");
180 const activePath = path.join(managedDirectory, "active.json");
181 const executableName = platform === "win32" ? "codex.exe" : "codex";
182 const gate = yield* Semaphore.make(1);
183 let leases = 0;
184 let running: { readonly operationId: string; readonly fiber: Fiber.Fiber<void> } | undefined;
185 const state = yield* SubscriptionRef.make<ProviderInstallState>({
186 driver: DRIVER,
187 operationId: null,
188 phase: "idle",
189 downloadedBytes: 0,
190 totalBytes: asset?.archiveBytes ?? null,
191 version: asset?.version ?? null,
192 installedVersion: null,
193 executablePath: null,
194 canRemove: false,
195 message: null,
196 });
197 const readRecord = Effect.fn("CodexInstallation.readRecord")(function* <A>(
198 file: string,
199 schema: Schema.Codec<A>,
200 ) {
201 const info = yield* fs.stat(file);
202 if (info.type !== "File" || Number(info.size) > 8192)
203 return yield* installationError(
204 "resolve",
205 "The managed Codex installation record is invalid. Reinstall Codex.",
206 );
207 return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(
208 yield* fs.readFileString(file),
209 );
210 });
211 const fromDirectory = Effect.fn("CodexInstallation.fromDirectory")(function* (
212 directory: string,
213 version: string,
214 target: string,
215 ) {
216 const manifest = yield* readRecord(path.join(directory, "codex-package.json"), PackageManifest);
217 if (
218 manifest.version !== version ||
219 manifest.target !== target ||
220 manifest.entrypoint !== `bin/${executableName}`
221 )
222 return yield* installationError(
223 "verify",
224 "The downloaded Codex package does not match the expected release.",
225 );
226 for (const name of [
227 `bin/${executableName}`,
228 `bin/codex-code-mode-host${platform === "win32" ? ".exe" : ""}`,
229 `codex-path/rg${platform === "win32" ? ".exe" : ""}`,
230 ]) {
231 const info = yield* fs.stat(path.join(directory, name));
232 if (
233 info.type !== "File" ||
234 Number(info.size) === 0 ||
235 (platform !== "win32" && (info.mode & 0o111) === 0)
236 )
237 return yield* installationError(
238 "verify",
239 "The managed Codex package is incomplete. Reinstall Codex.",
240 );
241 }
242 return {
243 executablePath: path.join(directory, "bin", executableName),
244 source: "managed",
245 version,
246 managedVersionDirectory: directory,
247 } satisfies CodexExecutable;
248 });
249 const completedRelease = Effect.fn("CodexInstallation.completedRelease")(function* (
250 version: string,
251 ) {
252 yield* decodeVersion(version);
253 const directory = path.join(managedDirectory, version);
254 const record = yield* readRecord(
255 path.join(directory, ".install-complete.json"),
256 InstalledRelease,
257 );
258 if (record.version !== version)
259 return yield* installationError(
260 "resolve",
261 "The managed Codex installation record has the wrong version.",
262 );
263 return yield* fromDirectory(directory, version, record.target);
264 });
265 const compatibility = Effect.fn("CodexInstallation.compatibility")(function* (version: string) {
266 const manifest = yield* manifestService.current;
267 return (
268 resolveProviderCompatibility(manifest.compatibility, DRIVER, version) ??
269 resolveProviderCompatibility(BUNDLED_MODEL_MANIFEST.compatibility, DRIVER, version)
270 );
271 });
272 const resolveManaged = Effect.fn("CodexInstallation.resolveManaged")(
273 function* () {
274 const active = yield* readRecord(activePath, ActiveRelease);
275 const executable = yield* completedRelease(active.version);
276 const advisory = yield* compatibility(executable.version);
277 if (advisory?.status !== "supported")
278 return yield* installationError(
279 "resolve",
280 advisory?.message ?? "Update the managed Codex installation to continue.",
281 );
282 return executable;
283 },
284 Effect.mapError(
285 wrapFailure("resolve", "Codex is not installed in T3 Code. Install it to continue."),
286 ),
287 );
288 const acquire = Effect.fn("CodexInstallation.acquire")(function* () {
289 return yield* Effect.acquireRelease(
290 gate.withPermit(
291 resolve().pipe(
292 Effect.tap(() =>
293 Effect.sync(() => {
294 leases += 1;
295 }),
296 ),
297 ),
298 ),
299 () =>
300 Effect.sync(() => {
301 leases -= 1;
302 }),
303 );
304 });
305 const runCommand = Effect.fn("CodexInstallation.runCommand")(function* (
306 command: string,
307 args: ReadonlyArray<string>,
308 ) {
309 const resolved = yield* resolveSpawnCommand(command, args).pipe(
310 Effect.provideService(HostProcessPlatform, platform),
311 );
312 const child = yield* spawner.spawn(
313 ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell }),
314 );
315 const [output, , exitCode] = yield* Effect.all(
316 [
317 child.stdout.pipe(Stream.decodeText(), Stream.mkString),
318 child.stderr.pipe(Stream.runDrain),
319 child.exitCode,
320 ],
321 { concurrency: "unbounded" },
322 );
323 if (exitCode !== 0)
324 return yield* installationError(
325 "verify",
326 "Could not unpack or start the downloaded Codex package.",
327 );
328 return output;
329 }, Effect.scoped);
330 const localCache = new Map<string, { fingerprint: string; executable: CodexExecutable | null }>();
331 const resolveLocal = Effect.fn("CodexInstallation.resolveLocal")(
332 function* () {
333 const executablePath = yield* resolveCommandPath("codex", { env: environment }).pipe(
334 Effect.provideService(HostProcessPlatform, platform),
335 Effect.provideService(FileSystem.FileSystem, fs),
336 Effect.provideService(Path.Path, path),
337 );
338 // Keep launcher symlinks intact: version-manager shims dispatch by their invoked name.
339 const realExecutablePath = yield* fs.realPath(executablePath);
340 // A PATH entry pointing into T3's download remains a managed installation.
341 const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option);
342 if (
343 realExecutablePath.startsWith(
344 `${Option.getOrElse(realManaged, () => managedDirectory)}${path.sep}`,
345 )
346 )
347 return null;
348 const info = yield* fs.stat(executablePath);
349 const fingerprint = `${realExecutablePath}:${info.size}:${Option.getOrUndefined(info.mtime)?.getTime()}:${Option.getOrUndefined(info.ino)}`;
350 const cached = localCache.get(executablePath);
351 const executable =
352 cached?.fingerprint === fingerprint
353 ? cached.executable
354 : yield* Effect.gen(function* () {
355 const output = yield* runCommand(executablePath, ["--version"]);
356 const version = /^codex-cli (\d+\.\d+\.\d+)$/u.exec(output.trim())?.[1];
357 if (!version) return null;
358 yield* runCommand(executablePath, ["app-server", "--help"]);
359 return {
360 executablePath,
361 source: "local",
362 version,
363 managedVersionDirectory: null,
364 } satisfies CodexExecutable;
365 }).pipe(
366 Effect.timeout("5 seconds"),
367 Effect.orElseSucceed(() => null),
368 );
369 localCache.set(executablePath, { fingerprint, executable });
370 if (!executable) return null;
371 // Cache executable probes, but reclassify against the current manifest on every resolve.
372 const advisory = yield* compatibility(executable.version);
373 return advisory?.status === "supported" ? executable : null;
374 },
375 Effect.orElseSucceed(() => null),
376 );
377 const resolve = Effect.fn("CodexInstallation.resolve")(function* () {
378 const local = yield* resolveLocal();
379 return local ?? (yield* resolveManaged());
380 });
381 const reuseLocal = Effect.fn("CodexInstallation.reuseLocal")(function* () {
382 const local = yield* resolveLocal();
383 if (!local) return false;
384 yield* SubscriptionRef.update(
385 state,
386 (current) =>
387 ({
388 ...current,
389 phase: "succeeded",
390 source: "local",
391 operationId: null,
392 installedVersion: local.version,
393 executablePath: local.executablePath,
394 downloadedBytes: 0,
395 totalBytes: null,
396 message: null,
397 }) satisfies ProviderInstallState,
398 );
399 return true;
400 });
401 const validate =
402 options.validate ??
403 Effect.fn("CodexInstallation.validate")(
404 function* (executable: CodexExecutable, version: string) {
405 const output = yield* runCommand(executable.executablePath, ["--version"]);
406 if (output.trim() !== `codex-cli ${version}`)
407 return yield* installationError(
408 "verify",
409 "The downloaded Codex executable has the wrong version.",
410 );
411 },
412 Effect.mapError(wrapFailure("verify", "The downloaded Codex runtime could not start.")),
413 );
414 const install = Effect.fn("CodexInstallation.install")(
415 function* (release: CodexReleaseAsset) {
416 yield* decodeVersion(release.version);
417 yield* fs.makeDirectory(managedDirectory, { recursive: true });
418 yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove: true }));
419 const destination = path.join(managedDirectory, release.version);
420 const activate = Effect.fn("CodexInstallation.activate")(
421 function* () {
422 const executable = yield* completedRelease(release.version);
423 const temporary = yield* fs.makeTempDirectoryScoped({
424 directory: managedDirectory,
425 prefix: ".active-",
426 });
427 const pointer = path.join(temporary, "active.json");
428 yield* fs.writeFileString(pointer, yield* encodeActive({ version: release.version }), {
429 mode: 0o600,
430 flag: "wx",
431 });
432 yield* fs.rename(pointer, activePath);
433 yield* SubscriptionRef.update(
434 state,
435 (current) =>
436 ({
437 ...current,
438 phase: "succeeded",
439 source: "managed",
440 installedVersion: release.version,
441 executablePath: executable.executablePath,
442 message: null,
443 }) satisfies ProviderInstallState,
444 );
445 },
446 Effect.scoped,
447 Effect.uninterruptible,
448 );
449 if (yield* fs.exists(destination)) {
450 const existing = yield* completedRelease(release.version);
451 const record = yield* readRecord(
452 path.join(destination, ".install-complete.json"),
453 InstalledRelease,
454 );
455 if (record.sha256 !== release.sha256 || record.target !== release.target)
456 return yield* installationError(
457 "verify",
458 "The existing managed Codex release differs from the official package. Remove it and reinstall.",
459 );
460 yield* validate(existing, release.version).pipe(
461 Effect.scoped,
462 Effect.timeout("90 seconds"),
463 );
464 yield* activate();
465 return;
466 }
467 const staging = yield* fs.makeTempDirectoryScoped({
468 directory: managedDirectory,
469 prefix: ".install-",
470 });
471 const archivePath = path.join(staging, "download.tar.gz");
472 const runtime = path.join(staging, "runtime");
473 yield* fs.makeDirectory(runtime);
474 const hash = NodeCrypto.createHash("sha256");
475 let downloadedBytes = 0;
476 let lastProgressAt = yield* Clock.currentTimeMillis;
477 const response = yield* http
478 .execute(HttpClientRequest.get(release.url))
479 .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
480 yield* response.stream.pipe(
481 Stream.tap((chunk) =>
482 Effect.gen(function* () {
483 downloadedBytes += chunk.byteLength;
484 if (downloadedBytes > release.archiveBytes)
485 return yield* installationError(
486 "download",
487 "The Codex download exceeded the expected release size.",
488 );
489 hash.update(chunk);
490 const now = yield* Clock.currentTimeMillis;
491 if (now - lastProgressAt >= 250 || downloadedBytes === release.archiveBytes) {
492 lastProgressAt = now;
493 yield* SubscriptionRef.update(state, (current) => ({ ...current, downloadedBytes }));
494 }
495 }),
496 ),
497 Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })),
498 Effect.timeout("45 minutes"),
499 );
500 if (downloadedBytes !== release.archiveBytes || hash.digest("hex") !== release.sha256)
501 return yield* installationError(
502 "download",
503 "The Codex download failed its size or SHA-256 check. Nothing was installed.",
504 );
505 yield* SubscriptionRef.update(
506 state,
507 (current) =>
508 ({
509 ...current,
510 phase: "extracting",
511 message: "Extracting Codex.",
512 }) satisfies ProviderInstallState,
513 );
514 const entries = (yield* runCommand("tar", ["-tzf", archivePath])).trim().split("\n");
515 const types = (yield* runCommand("tar", ["-tvzf", archivePath])).trim().split("\n");
516 if (
517 entries.length > 10000 ||
518 entries.length !== types.length ||
519 types.some((line) => !["-", "d"].includes(line[0] ?? "")) ||
520 entries.some((entry) => {
521 const parts = entry.replace(/\/$/u, "").split("/");
522 return (
523 !entry ||
524 entry.includes("\\") ||
525 entry.startsWith("/") ||
526 parts.some((part) => part === ".." || part === "." || part === "" || part.includes(":"))
527 );
528 })
529 )
530 return yield* installationError("extract", "The Codex archive contains unsafe entries.");
531 yield* runCommand("tar", ["-xzf", archivePath, "-C", runtime]);
532 yield* SubscriptionRef.update(
533 state,
534 (current) =>
535 ({
536 ...current,
537 phase: "verifying",
538 message: "Checking Codex.",
539 }) satisfies ProviderInstallState,
540 );
541 const executable = yield* fromDirectory(runtime, release.version, release.target);
542 yield* validate(executable, release.version).pipe(
543 Effect.scoped,
544 Effect.timeout("90 seconds"),
545 );
546 yield* fs.writeFileString(
547 path.join(runtime, ".install-complete.json"),
548 yield* encodeRecord({
549 version: release.version,
550 target: release.target,
551 sha256: release.sha256,
552 }),
553 { flag: "wx", mode: 0o600 },
554 );
555 yield* fs.rename(runtime, destination);
556 yield* activate();
557 },
558 Effect.scoped,
559 Effect.mapError(
560 wrapFailure(
561 "install",
562 "Could not install Codex. Check disk space and directory access, then try again.",
563 ),
564 ),
565 );
566 const start = gate
567 .withPermit(
568 Effect.gen(function* () {
569 const current = yield* SubscriptionRef.get(state);
570 if (isRunning(current)) return current;
571 if (yield* reuseLocal()) return yield* SubscriptionRef.get(state);
572 if (!asset) {
573 return yield* installationError(
574 "start",
575 `OpenAI does not publish a Codex runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`,
576 );
577 }
578 const operationId = yield* crypto.randomUUIDv4;
579 const next: ProviderInstallState = {
580 driver: DRIVER,
581 operationId,
582 phase: "downloading",
583 downloadedBytes: 0,
584 totalBytes: asset.archiveBytes,
585 version: asset.version,
586 installedVersion: current.installedVersion,
587 canRemove: current.canRemove,
588 message: "Downloading Codex.",
589 };
590 yield* SubscriptionRef.set(state, next);
591 const work = install(asset).pipe(
592 Effect.onExit((exit) =>
593 Exit.isFailure(exit)
594 ? SubscriptionRef.update(state, (value) => {
595 if (value.operationId !== operationId || value.phase === "succeeded")
596 return value;
597 const error = Cause.findErrorOption(exit.cause);
598 const cancelled = Cause.hasInterruptsOnly(exit.cause);
599 return {
600 ...value,
601 phase: cancelled ? "cancelled" : "failed",
602 message: cancelled
603 ? "Installation cancelled. The previous runtime is unchanged."
604 : Option.isSome(error)
605 ? error.value.detail
606 : "Could not finish the Codex installation. Check disk space and directory access.",
607 } satisfies ProviderInstallState;
608 })
609 : Effect.void,
610 ),
611 Effect.ignoreCause,
612 Effect.ensuring(
613 Effect.sync(() => {
614 if (running?.operationId === operationId) running = undefined;
615 }),
616 ),
617 );
618 const fiber = yield* Effect.forkIn(Effect.interruptible(work), serviceScope);
619 running = { operationId, fiber };
620 return next;
621 }).pipe(Effect.uninterruptible),
622 )
623 .pipe(Effect.mapError(wrapFailure("start", "Could not start the Codex installation.")));
624
625 const cancel = Effect.fn("CodexInstallation.cancel")(function* (operationId: string) {
626 return yield* gate.withPermit(
627 Effect.gen(function* () {
628 const current = yield* SubscriptionRef.get(state);
629 if (current.operationId !== operationId) {
630 return yield* installationError(
631 "cancel",
632 "This installation is no longer current. Refresh its status before cancelling.",
633 );
634 }
635 if (running?.operationId === operationId && isRunning(current)) {
636 yield* Fiber.interrupt(running.fiber);
637 }
638 return yield* SubscriptionRef.get(state);
639 }),
640 );
641 });
642
643 const remove = Effect.fn("CodexInstallation.remove")(
644 function* (protectedBinaryPaths: ReadonlyArray<string> = []) {
645 yield* gate.withPermit(
646 Effect.gen(function* () {
647 if (isRunning(yield* SubscriptionRef.get(state)) || leases > 0) {
648 return yield* installationError(
649 "remove",
650 "Stop Codex sessions and sign-in flows before removing its managed runtime.",
651 );
652 }
653 const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option);
654 if (Option.isSome(realManaged)) {
655 for (const binary of protectedBinaryPaths) {
656 const resolved = yield* fs.realPath(binary).pipe(Effect.option);
657 const candidate = Option.getOrElse(resolved, () => path.resolve(binary));
658 if (candidate.startsWith(`${realManaged.value}${path.sep}`))
659 return yield* installationError(
660 "remove",
661 "A provider instance uses a custom path inside managed Codex. Clear that path before removing it.",
662 );
663 }
664 }
665 yield* fs.remove(managedDirectory, { recursive: true, force: true });
666 yield* SubscriptionRef.update(
667 state,
668 (current) =>
669 ({
670 ...current,
671 operationId: null,
672 phase: "idle",
673 downloadedBytes: 0,
674 installedVersion: null,
675 executablePath: null,
676 source: null,
677 canRemove: false,
678 message: null,
679 }) satisfies ProviderInstallState,
680 );
681 yield* reuseLocal();
682 }).pipe(Effect.uninterruptible),
683 );
684 },
685 Effect.mapError(
686 wrapFailure(
687 "remove",
688 "Could not remove the managed Codex runtime. Check for open processes and try again.",
689 ),
690 ),
691 );
692
693 yield* Effect.gen(function* () {
694 const canRemove = yield* fs.exists(managedDirectory);
695 yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove }));
696 if (yield* reuseLocal()) return;
697 if (!(yield* fs.exists(activePath))) return;
698 const active = yield* readRecord(activePath, ActiveRelease);
699 const installed = yield* completedRelease(active.version);
700 yield* SubscriptionRef.update(
701 state,
702 (current) =>
703 ({
704 ...current,
705 installedVersion: installed.version,
706 executablePath: installed.executablePath,
707 source: "managed",
708 }) satisfies ProviderInstallState,
709 );
710 }).pipe(
711 Effect.catch(() =>
712 SubscriptionRef.update(
713 state,
714 (current) =>
715 ({
716 ...current,
717 phase: "failed",
718 message: "The managed Codex runtime is incomplete. Remove it and reinstall.",
719 }) satisfies ProviderInstallState,
720 ),
721 ),
722 );
723
724 return CodexInstallation.of({
725 managedDirectory,
726 resolve,
727 acquire,
728 start,
729 cancel,
730 state: SubscriptionRef.get(state),
731 changes: SubscriptionRef.changes(state),
732 remove,
733 });
734});
apps/server/src/provider/CodexManagedErrors.test.ts created+64
......@@ -0,0 +1,64 @@
1import { assert, it } from "@effect/vitest";
2import { classifyCodexManagedError } from "./CodexManagedErrors.ts";
3it("maps streamed failures to safe actionable messages and reconnect decisions", () => {
4 assert.deepEqual(
5 classifyCodexManagedError({
6 error: {
7 code: "subscription_sharing_v2_invalid_user",
8 message: "opaque token dummy-sensitive",
9 },
10 }),
11 {
12 message:
13 "ChatGPT could not validate this connection. Check the selected account and sharing permissions.",
14 revoke: false,
15 code: "subscription_sharing_v2_invalid_user",
16 },
17 );
18 assert.include(
19 classifyCodexManagedError("subscription_sharing_usage_limit_exceeded")!.message,
20 "Usage settings",
21 );
22 assert.equal(
23 classifyCodexManagedError("subscription_sharing_usage_limit_exceeded")!.code,
24 "subscription_sharing_usage_limit_exceeded",
25 );
26 assert.isFalse(classifyCodexManagedError("subscription_sharing_usage_unavailable")!.revoke);
27 assert.include(
28 classifyCodexManagedError("subscription_sharing_unsupported_capability")!.message,
29 "feature",
30 );
31 assert.isUndefined(classifyCodexManagedError(undefined));
32 assert.isUndefined(classifyCodexManagedError({ error: "unknown" }));
33});
34
35it("distinguishes unsupported tools from input items without exposing the raw response", () => {
36 const code = "subscription_sharing_unsupported_capability";
37 for (const [detail, expected] of [
38 ["tool 'namespace' is not supported", "tool namespace"],
39 ["input item 'additional_tools' is not supported", "input item"],
40 ]) {
41 const response = { error: { code, message: `${detail}; dummy-sensitive` } };
42 for (const value of [response, JSON.stringify(response)]) {
43 const failure = classifyCodexManagedError(value);
44 assert.isDefined(failure);
45 assert.include(failure!.message, expected!);
46 assert.notInclude(failure!.message, "dummy-sensitive");
47 assert.isFalse(failure!.revoke);
48 }
49 }
50});
51
52it("preserves credentials for the current subscriber and permission error codes", () => {
53 for (const code of [
54 "subscription_sharing_invalid_user",
55 "subscription_sharing_user_not_eligible",
56 "subscription_sharing_route_not_supported",
57 "subscription_sharing_user_unavailable",
58 "chatpass_v2_scope_not_authorized",
59 "chatpass_v2_invalid_authorization_context",
60 ]) {
61 assert.strictEqual(classifyCodexManagedError({ error: { code } })?.code, code);
62 assert.isFalse(classifyCodexManagedError({ error: { code } })!.revoke);
63 }
64});
apps/server/src/provider/CodexManagedErrors.ts created+87
......@@ -0,0 +1,87 @@
1const legacyFailures = {
2 subscription_sharing_v2_user_not_eligible: {
3 message:
4 "ChatGPT sharing is unavailable for this account or workspace. Use another provider or check its sharing policy.",
5 revoke: false,
6 },
7 subscription_sharing_usage_limit_exceeded: {
8 message:
9 "Your ChatGPT usage limit was reached. Check ChatGPT Usage settings for your available allowance.",
10 revoke: false,
11 },
12 subscription_sharing_usage_unavailable: {
13 message: "ChatGPT usage is temporarily unavailable. Try again shortly.",
14 revoke: false,
15 },
16 subscription_sharing_unsupported_capability: {
17 message:
18 "Codex used a feature that ChatGPT sharing does not support. Use another provider for this request.",
19 revoke: false,
20 },
21 subscription_sharing_v2_client_not_enabled: {
22 message:
23 "This app is not enabled for this ChatGPT connection. Use your existing CLI or another provider.",
24 revoke: false,
25 },
26 subscription_sharing_v2_route_not_supported: {
27 message: "ChatGPT does not support this request route.",
28 revoke: false,
29 },
30 subscription_sharing_v2_invalid_user: {
31 message:
32 "ChatGPT could not validate this connection. Check the selected account and sharing permissions.",
33 revoke: false,
34 },
35 subscription_sharing_v2_user_unavailable: {
36 message: "ChatGPT is temporarily unavailable. Try again shortly.",
37 revoke: false,
38 },
39} as const;
40const failures = {
41 ...legacyFailures,
42 subscription_sharing_user_not_eligible: legacyFailures.subscription_sharing_v2_user_not_eligible,
43 subscription_sharing_route_not_supported:
44 legacyFailures.subscription_sharing_v2_route_not_supported,
45 subscription_sharing_invalid_user: legacyFailures.subscription_sharing_v2_invalid_user,
46 subscription_sharing_user_unavailable: legacyFailures.subscription_sharing_v2_user_unavailable,
47 chatpass_v2_scope_not_authorized: {
48 message:
49 "This ChatGPT grant does not authorize the request. Check the connection's sharing permissions.",
50 revoke: false,
51 },
52 chatpass_v2_invalid_authorization_context: {
53 message:
54 "ChatGPT could not authorize this connection. Check the client and sharing permissions.",
55 revoke: false,
56 },
57};
58export function classifyCodexManagedError(value: unknown) {
59 let text: string;
60 try {
61 text = typeof value === "string" ? value : JSON.stringify(value);
62 } catch {
63 return undefined;
64 }
65 if (typeof text !== "string") return undefined;
66 for (const [code, failure] of Object.entries(failures)) {
67 if (!text.includes(code)) continue;
68 if (code === "subscription_sharing_unsupported_capability") {
69 if (text.includes("tool 'namespace'"))
70 return {
71 ...failure,
72 code,
73 message:
74 "Codex sent a tool namespace that ChatGPT sharing does not support. Use another provider for this request.",
75 };
76 if (text.includes("additional_tools"))
77 return {
78 ...failure,
79 code,
80 message:
81 "Codex sent an input item that ChatGPT sharing does not support. Use another provider for this request.",
82 };
83 }
84 return { ...failure, code };
85 }
86 return undefined;
87}
apps/server/src/provider/CodexManagedHome.ts created+20
......@@ -0,0 +1,20 @@
1import type { CodexSettings, ProviderInstanceId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Path from "effect/Path";
4import { resolveCodexHomeLayout } from "./Drivers/CodexHomeLayout.ts";
5
6export const resolveManagedCodexHomeLayout = Effect.fn("resolveManagedCodexHomeLayout")(function* (
7 stateDir: string,
8 instanceId: ProviderInstanceId,
9 config: CodexSettings,
10) {
11 const path = yield* Path.Path;
12 return yield* resolveCodexHomeLayout({
13 ...config,
14 shadowHomePath:
15 config.shadowHomePath.trim() ||
16 (instanceId === "codex"
17 ? ""
18 : path.join(stateDir, "providers", "codex", instanceId, "shadow")),
19 });
20});
apps/server/src/provider/CodexManagedRuntime.test.ts created+225
......@@ -0,0 +1,225 @@
1// @effect-diagnostics nodeBuiltinImport:off - checks the host's default Codex directory without writing to it.
2import * as NodeOS from "node:os";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, it } from "@effect/vitest";
5import { CodexSettings, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
6import * as Clock from "effect/Clock";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Schema from "effect/Schema";
13import { HttpClient, HttpClientResponse } from "effect/unstable/http";
14import { ServerConfig } from "../config.ts";
15import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
16import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts";
17import { CodexInstallation } from "./CodexInstallation.ts";
18import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts";
19import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
20import { codexAppServerArgs } from "./Layers/codexLaunchArgs.ts";
21import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts";
22
23const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
24const decodeSettings = Schema.decodeSync(CodexSettings);
25it.effect("managed home defaults to the global Codex home and honors configured home paths", () =>
26 Effect.gen(function* () {
27 const path = yield* Path.Path;
28 const primary = yield* resolveManagedCodexHomeLayout(
29 "/t3-state",
30 ProviderInstanceId.make("codex"),
31 decodeSettings({}),
32 );
33 assert.equal(primary.sharedHomePath, path.join(NodeOS.homedir(), ".codex"));
34 assert.equal(primary.mode, "direct");
35 const additional = yield* resolveManagedCodexHomeLayout(
36 "/t3-state",
37 ProviderInstanceId.make("codex-work"),
38 decodeSettings({}),
39 );
40 assert.equal(additional.sharedHomePath, primary.sharedHomePath);
41 assert.equal(additional.mode, "authOverlay");
42 const configured = yield* resolveManagedCodexHomeLayout(
43 "/t3-state",
44 ProviderInstanceId.make("codex-work"),
45 decodeSettings({ homePath: "/custom/shared", shadowHomePath: "/custom/shadow" }),
46 );
47 assert.equal(configured.sharedHomePath, "/custom/shared");
48 assert.equal(configured.effectiveHomePath, "/custom/shadow");
49 }).pipe(Effect.provide(NodeServices.layer)),
50);
51for (const source of ["managed", "local"] as const)
52 for (const account of ["primary", "additional"] as const)
53 it.effect(
54 `${source} Codex ${account} account shares home state without routing owned tokens through ambient CLI overrides`,
55 () =>
56 Effect.gen(function* () {
57 const instanceId = ProviderInstanceId.make(
58 account === "primary" ? "codex" : "codex-personal",
59 );
60 const fs = yield* FileSystem.FileSystem;
61 const path = yield* Path.Path;
62 const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-home-" });
63 yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged");
64 yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n");
65 const data = new Map<string, Uint8Array>();
66 const secrets = ServerSecretStore.of({
67 get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))),
68 set: (key, value) =>
69 Effect.sync(() => {
70 data.set(key, value);
71 }),
72 remove: (key) =>
73 Effect.sync(() => {
74 data.delete(key);
75 }),
76 create: () => Effect.die("unused"),
77 getOrCreateRandom: () => Effect.die("unused"),
78 });
79 let leases = 0;
80 const executable = {
81 executablePath:
82 source === "managed" ? "/isolated/tools/codex/0.156.1/bin/codex" : "/user/bin/codex",
83 managedVersionDirectory: source === "managed" ? "/isolated/tools/codex/0.156.1" : null,
84 source,
85 version: "0.156.1",
86 };
87 const installerLayer = Layer.mock(CodexInstallation)({
88 managedDirectory: "/isolated/tools/codex",
89 resolve: () => Effect.succeed(executable),
90 acquire: () =>
91 Effect.gen(function* () {
92 leases++;
93 yield* Effect.addFinalizer(() =>
94 Effect.sync(() => {
95 leases--;
96 }),
97 );
98 return executable;
99 }),
100 });
101 yield* Effect.gen(function* () {
102 const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId);
103 const json = yield* encodeJson({
104 clientId: "oaiapp_test",
105 accessToken: "dummy-owned-access",
106 refreshToken: "dummy-refresh",
107 expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000,
108 earliestRefreshAt: null,
109 scopes: ["chatgpt.tokens.use.direct"],
110 subject: "test-user",
111 email: null,
112 });
113 yield* store.set(new TextEncoder().encode(json));
114 const ambient = {
115 CODEX_HOME: "/user/.codex",
116 OPENAI_API_KEY: "dummy-global-key",
117 OPENAI_BASE_URL: "https://user-proxy.test",
118 T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy",
119 PATH: "/usr/bin",
120 };
121 const runtime = yield* makeCodexManagedRuntime({
122 instanceId,
123 enabled: true,
124 config: decodeSettings({ setupMode: "managed", homePath: sharedHome }),
125 environment: ambient,
126 });
127 yield* Effect.gen(function* () {
128 const effective = yield* runtime.resolve;
129 assert.strictEqual(leases, 1);
130 assert.strictEqual(effective.config.binaryPath, executable.executablePath);
131 assert.notStrictEqual(effective.config.homePath, ambient.CODEX_HOME);
132 assert.equal(runtime.homeLayout.sharedHomePath, sharedHome);
133 if (account === "primary") {
134 assert.equal(effective.config.homePath, sharedHome);
135 assert.equal(runtime.homeLayout.mode, "direct");
136 } else {
137 assert.include(effective.config.homePath, instanceId);
138 assert.include(effective.config.homePath, "userdata/providers/codex");
139 assert.equal(runtime.homeLayout.mode, "authOverlay");
140 assert.equal(
141 yield* fs.readLink(path.join(effective.config.homePath, "sessions")),
142 path.join(sharedHome, "sessions"),
143 );
144 assert.equal(
145 yield* fs.readLink(path.join(effective.config.homePath, "config.toml")),
146 path.join(sharedHome, "config.toml"),
147 );
148 assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json")));
149 }
150 assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access");
151 assert.isUndefined(effective.environment.OPENAI_API_KEY);
152 assert.isUndefined(effective.environment.OPENAI_BASE_URL);
153 assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS);
154 const args = codexAppServerArgs(effective.config.launchArgs);
155 assert.include(
156 args,
157 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"',
158 );
159 assert.include(
160 args,
161 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"',
162 );
163 assert.include(args, "features.api_key_model_discovery=true");
164 assert.notInclude(effective.config.launchArgs, "model_catalog_json");
165 assert.notInclude(effective.config.launchArgs, "x-openai-chatpass-test");
166 assert.include(
167 args,
168 "model_providers.openai_token_sharing.supports_websockets=false",
169 );
170 assert.include(
171 args,
172 "model_providers.openai_token_sharing.requires_openai_auth=false",
173 );
174 assert.notInclude(effective.config.launchArgs, "dummy-owned-access");
175 assert.strictEqual(ambient.CODEX_HOME, "/user/.codex");
176 }).pipe(Effect.scoped);
177 assert.strictEqual(leases, 0);
178 yield* runtime.auth.controller.logout(Effect.void);
179 assert.equal(
180 yield* fs.readFileString(path.join(sharedHome, "auth.json")),
181 "native-auth-unchanged",
182 );
183 }).pipe(
184 Effect.provideService(ServerSecretStore, secrets),
185 Effect.provideService(ServerEnvironmentIdentity, {
186 getEnvironmentId: Effect.succeed(
187 EnvironmentId.make("00000000-0000-4000-8000-000000000001"),
188 ),
189 }),
190 Effect.provide(installerLayer),
191 Effect.provideService(
192 HttpClient.HttpClient,
193 HttpClient.make((request) =>
194 Effect.sync(() => {
195 assert.isTrue(
196 [
197 "https://auth.openai.com/.well-known/openid-configuration",
198 "https://auth.openai.com/revoke",
199 ].includes(request.url),
200 );
201 return HttpClientResponse.fromWeb(
202 request,
203 request.url.endsWith("/revoke")
204 ? new Response(null, { status: 200 })
205 : Response.json({
206 issuer: "https://auth.openai.com",
207 authorization_endpoint: "https://auth.openai.com/api/accounts/authorize",
208 token_endpoint: "https://auth.openai.com/api/accounts/oauth/token",
209 jwks_uri: "https://auth.openai.com/jwks",
210 revocation_endpoint: "https://auth.openai.com/revoke",
211 }),
212 );
213 }),
214 ),
215 ),
216 );
217 }).pipe(
218 Effect.scoped,
219 Effect.provide(
220 ServerConfig.layerTest(process.cwd(), { prefix: "t3-managed-runtime-" }).pipe(
221 Layer.provideMerge(NodeServices.layer),
222 ),
223 ),
224 ),
225 );
apps/server/src/provider/CodexManagedRuntime.ts created+112
......@@ -0,0 +1,112 @@
1import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts";
2import { CodexSettings, ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";
7import { ServerConfig } from "../config.ts";
8import { CodexInstallation } from "./CodexInstallation.ts";
9import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts";
10import { materializeCodexShadowHome } from "./Drivers/CodexHomeLayout.ts";
11
12export interface CodexEffectiveRuntime {
13 readonly config: CodexSettings;
14 readonly environment: NodeJS.ProcessEnv;
15 readonly revision: string;
16}
17const decodeSettings = Schema.decodeSync(CodexSettings);
18// Managed sign-in stores tokens in T3's credential store and never writes native auth.json.
19const managedCodexLaunchArgs = [
20 'model_provider="openai_token_sharing"',
21 'model_providers.openai_token_sharing.name="OpenAI Token Sharing"',
22 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"',
23 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"',
24 "features.api_key_model_discovery=true",
25 'model_providers.openai_token_sharing.env_key="ACCESS_TOKEN"',
26 'model_providers.openai_token_sharing.wire_api="responses"',
27 "model_providers.openai_token_sharing.requires_openai_auth=false",
28 "model_providers.openai_token_sharing.supports_websockets=false",
29]
30 .map((value) => `-c '${value}'`)
31 .join(" ");
32
33export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(function* (options: {
34 readonly instanceId: ProviderInstanceId;
35 readonly enabled: boolean;
36 readonly environment: NodeJS.ProcessEnv;
37 readonly config: CodexSettings;
38}) {
39 const installation = yield* CodexInstallation;
40 const config = yield* ServerConfig;
41 const fs = yield* FileSystem.FileSystem;
42 const path = yield* Path.Path;
43 const auth = yield* makeCodexChatGptAuth({
44 instanceId: options.instanceId,
45 defaultReturnUrl: new URL(
46 "/welcome",
47 config.devUrl ?? `http://localhost:${config.port}`,
48 ).toString(),
49 });
50 const homeLayout = yield* resolveManagedCodexHomeLayout(
51 config.stateDir,
52 options.instanceId,
53 options.config,
54 );
55 const homePath = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath;
56 const resolve = Effect.gen(function* () {
57 const executable = yield* installation.acquire().pipe(
58 Effect.mapError(
59 () =>
60 new ProviderSetupError({
61 instanceId: options.instanceId,
62 operation: "install",
63 detail: "Set up managed Codex before starting a session.",
64 }),
65 ),
66 );
67 const credentials = yield* auth.access;
68 yield* materializeCodexShadowHome(homeLayout).pipe(
69 Effect.provideService(FileSystem.FileSystem, fs),
70 Effect.provideService(Path.Path, path),
71 Effect.mapError(
72 (cause) =>
73 new ProviderSetupError({
74 instanceId: options.instanceId,
75 operation: "runtime",
76 detail: cause.message,
77 }),
78 ),
79 );
80 yield* fs.makeDirectory(homePath, { recursive: true }).pipe(
81 Effect.mapError(
82 () =>
83 new ProviderSetupError({
84 instanceId: options.instanceId,
85 operation: "runtime",
86 detail: "Could not prepare the managed Codex runtime.",
87 }),
88 ),
89 );
90 // Ambient CLI overrides cannot redirect a T3-owned token to a different provider.
91 const environment: NodeJS.ProcessEnv = {
92 ...options.environment,
93 ACCESS_TOKEN: credentials.accessToken,
94 CODEX_HOME: homePath,
95 };
96 delete environment.T3CODE_CODEX_LAUNCH_ARGS;
97 delete environment.OPENAI_API_KEY;
98 delete environment.OPENAI_BASE_URL;
99 return {
100 config: decodeSettings({
101 enabled: options.enabled,
102 setupMode: "managed",
103 binaryPath: executable.executablePath,
104 homePath,
105 launchArgs: managedCodexLaunchArgs,
106 }),
107 environment,
108 revision: credentials.accessToken,
109 } satisfies CodexEffectiveRuntime;
110 });
111 return { auth, resolve, installation, homePath, homeLayout };
112});
apps/server/src/provider/Drivers/CodexDriver.test.ts+165-7
......@@ -1,16 +1,24 @@
1import { CodexInstallation } from "../CodexInstallation.ts";
2import { ServerSecretStore } from "../../auth/ServerSecretStore.ts";
3import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts";
14// @effect-diagnostics nodeBuiltinImport:off
25import * as NodeServices from "@effect/platform-node/NodeServices";
36import * as NodeOS from "node:os";
47import * as NodePath from "node:path";
58import { expect, it } from "@effect/vitest";
6import { ProviderInstanceId } from "@t3tools/contracts";
9import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
710import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
811import * as Effect from "effect/Effect";
12import * as Deferred from "effect/Deferred";
13import * as Fiber from "effect/Fiber";
914import * as FileSystem from "effect/FileSystem";
1015import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as PlatformError from "effect/PlatformError";
18import * as Schema from "effect/Schema";
1119import * as Sink from "effect/Sink";
1220import * as Stream from "effect/Stream";
13import { HttpClient } from "effect/unstable/http";
21import { HttpClient, HttpClientResponse } from "effect/unstable/http";
1422import * as ChildProcess from "effect/unstable/process/ChildProcess";
1523import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1624
......@@ -26,11 +34,21 @@ import {
2634 resolveLatestProviderVersion,
2735} from "../providerMaintenance.ts";
2836import { CodexDriver } from "./CodexDriver.ts";
37import * as ProviderCredentialStore from "../ProviderCredentialStore.ts";
2938
3039const testLayer = ServerConfig.layerTest(process.cwd(), {
3140 prefix: "t3-codex-driver-maintenance-",
3241}).pipe(
3342 Layer.provideMerge(NodeServices.layer),
43 Layer.provideMerge(
44 Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }),
45 ),
46 Layer.provideMerge(Layer.mock(ServerSecretStore)({})),
47 Layer.provideMerge(
48 Layer.succeed(ServerEnvironmentIdentity, {
49 getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")),
50 }),
51 ),
3452 Layer.provideMerge(ServerSettingsService.layerTest()),
3553 Layer.provideMerge(ModelManifest.layerTest),
3654 Layer.provideMerge(ResetCreditCoordinator.layerTest),
......@@ -54,14 +72,144 @@ const windowsHost = HostProcessPlatform.defaultValue() === "win32";
5472const noSpawn = ChildProcessSpawner.make(() =>
5573 Effect.die("Disabled Codex must not spawn a process"),
5674);
75const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
5776
5877it.layer(testLayer)("CodexDriver", (it) => {
78 it.effect("disconnect refreshes a restored managed account while its auth flow is idle", () =>
79 Effect.gen(function* () {
80 const instanceId = ProviderInstanceId.make("restored-managed-account");
81 const credentials = new Map<string, Uint8Array>();
82 const secrets = ServerSecretStore.of({
83 get: (key) => Effect.sync(() => Option.fromUndefinedOr(credentials.get(key))),
84 set: (key, value) =>
85 Effect.sync(() => {
86 credentials.set(key, value);
87 }),
88 remove: (key) =>
89 Effect.sync(() => {
90 credentials.delete(key);
91 }),
92 create: () => Effect.die("unused"),
93 getOrCreateRandom: () => Effect.die("unused"),
94 });
95 yield* Effect.gen(function* () {
96 const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId);
97 const json = yield* encodeCredentials({
98 clientId: "oaiapp_test",
99 accessToken: "dummy-owned-access",
100 refreshToken: "dummy-refresh",
101 expiresAt: Number.MAX_SAFE_INTEGER,
102 earliestRefreshAt: null,
103 scopes: ["chatgpt.tokens.use.direct"],
104 subject: "test-user",
105 email: "account@example.test",
106 });
107 yield* store.set(new TextEncoder().encode(json));
108 const executable = {
109 executablePath: "/user/bin/codex",
110 managedVersionDirectory: null,
111 source: "local" as const,
112 version: "0.156.1",
113 };
114 const installation = yield* CodexInstallation;
115 const serverConfig = yield* ServerConfig;
116 const sharedHome = NodePath.join(serverConfig.stateDir, "shared-codex-home");
117 const instance = yield* CodexDriver.create({
118 instanceId,
119 displayName: "Restored account",
120 enabled: true,
121 environment: [],
122 config: { ...CodexDriver.defaultConfig(), setupMode: "managed", homePath: sharedHome },
123 }).pipe(
124 Effect.provideService(
125 CodexInstallation,
126 CodexInstallation.of({
127 ...installation,
128 managedDirectory: "unused-managed-installation",
129 resolve: () => Effect.succeed(executable),
130 acquire: () => Effect.succeed(executable),
131 }),
132 ),
133 Effect.provideService(
134 ChildProcessSpawner.ChildProcessSpawner,
135 ChildProcessSpawner.make(() =>
136 Effect.fail(
137 PlatformError.badArgument({
138 module: "ChildProcessSpawner",
139 method: "spawn",
140 description: "The fixture app-server is unavailable",
141 }),
142 ),
143 ),
144 ),
145 );
146 const observedAccount = yield* Deferred.make<void>();
147 const disconnected = yield* instance.snapshot.streamChanges.pipe(
148 Stream.tap((provider) =>
149 provider.auth.status === "authenticated"
150 ? Deferred.succeed(observedAccount, undefined).pipe(Effect.asVoid)
151 : Effect.void,
152 ),
153 Stream.filter((provider) => provider.auth.status === "unauthenticated"),
154 Stream.runHead,
155 Effect.forkScoped,
156 );
157 const restored = yield* instance.snapshot.refresh;
158 expect(restored.auth.email).toBe("account@example.test");
159 expect(restored.runtimePaths?.homePath).toBe(sharedHome);
160 expect(restored.runtimePaths?.shadowHomePath).toContain(
161 `providers/codex/${instanceId}/shadow`,
162 );
163 yield* Deferred.await(observedAccount);
164 const before = yield* instance.auth!.subscribe("test-owner").pipe(Stream.runHead);
165 expect(Option.getOrThrow(before).phase).toBe("idle");
166 yield* instance.auth!.logout(Effect.void);
167 const after = Option.getOrThrow(yield* Fiber.join(disconnected));
168 expect(after.auth.status).toBe("unauthenticated");
169 expect(after.auth.email).toBeUndefined();
170 expect(after.installed).toBe(true);
171 expect(after.models).toEqual([]);
172 expect(Option.isNone(yield* store.get)).toBe(true);
173 }).pipe(
174 Effect.provideService(ServerSecretStore, secrets),
175 Effect.provideService(
176 HttpClient.HttpClient,
177 HttpClient.make((request) =>
178 Effect.sync(() => {
179 expect([
180 "https://auth.openai.com/.well-known/openid-configuration",
181 "https://auth.openai.com/revoke",
182 "https://api.openai.com/v1/models",
183 ]).toContain(request.url);
184 if (request.url.endsWith("/models"))
185 return HttpClientResponse.fromWeb(request, Response.json({ models: [] }));
186 return HttpClientResponse.fromWeb(
187 request,
188 request.url.endsWith("/revoke")
189 ? new Response(null, { status: 200 })
190 : Response.json({
191 issuer: "https://auth.openai.com",
192 authorization_endpoint: "https://auth.openai.com/api/accounts/authorize",
193 token_endpoint: "https://auth.openai.com/api/accounts/oauth/token",
194 jwks_uri: "https://auth.openai.com/jwks",
195 revocation_endpoint: "https://auth.openai.com/revoke",
196 }),
197 );
198 }),
199 ),
200 ),
201 );
202 }).pipe(Effect.scoped),
203 );
204
59205 it.effect.skipIf(windowsHost)(
60206 "runs the standalone updater against the shared home, not the shadow home",
61207 () =>
62208 Effect.gen(function* () {
63209 const fs = yield* FileSystem.FileSystem;
64 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-driver-" });
210 const tempDir = yield* fs
211 .makeTempDirectoryScoped({ prefix: "t3-codex-driver-" })
212 .pipe(Effect.flatMap((directory) => fs.realPath(directory)));
65213 const sharedHome = NodePath.join(tempDir, "codex-home");
66214 const shadowHome = NodePath.join(tempDir, "codex-shadow");
67215 const binaryPath = NodePath.join(sharedHome, "packages", "standalone", "bin", "codex");
......@@ -136,7 +284,9 @@ it.layer(testLayer)("CodexDriver", (it) => {
136284 it.effect.skipIf(windowsHost)(fixture.name, () =>
137285 Effect.gen(function* () {
138286 const fs = yield* FileSystem.FileSystem;
139 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-installer-" });
287 const tempDir = yield* fs
288 .makeTempDirectoryScoped({ prefix: "t3-codex-installer-" })
289 .pipe(Effect.flatMap((directory) => fs.realPath(directory)));
140290 const installPath = NodePath.join(tempDir, ...fixture.installSegments);
141291 const realBinaryPath = NodePath.join(
142292 installPath,
......@@ -193,7 +343,9 @@ it.layer(testLayer)("CodexDriver", (it) => {
193343 it.effect.skipIf(windowsHost)(`leaves a mise ${layout} installation manual-only`, () =>
194344 Effect.gen(function* () {
195345 const fs = yield* FileSystem.FileSystem;
196 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` });
346 const tempDir = yield* fs
347 .makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` })
348 .pipe(Effect.flatMap((directory) => fs.realPath(directory)));
197349 const binaryPath =
198350 layout === "direct"
199351 ? NodePath.join(tempDir, "mise", "installs", "codex", "0.110.0", "codex")
......@@ -268,7 +420,9 @@ it.layer(testLayer)("CodexDriver", (it) => {
268420 (fixture) =>
269421 Effect.gen(function* () {
270422 const fs = yield* FileSystem.FileSystem;
271 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-mise-shim-" });
423 const tempDir = yield* fs
424 .makeTempDirectoryScoped({ prefix: "t3-codex-mise-shim-" })
425 .pipe(Effect.flatMap((directory) => fs.realPath(directory)));
272426 const brewPrefix = NodePath.join(tempDir, "homebrew");
273427 const brewPath = NodePath.join(brewPrefix, "bin", "brew");
274428 const misePath = NodePath.join(brewPrefix, "Cellar", "mise", "2026.9.1", "bin", "mise");
......@@ -361,7 +515,11 @@ it.layer(testLayer)("CodexDriver", (it) => {
361515 if (fixture.nodeFirst) {
362516 expect(capabilities.update).toMatchObject({
363517 executable: "npm",
364 args: expect.arrayContaining(["--prefix", npmPrefix, "@openai/codex@latest"]),
518 args: expect.arrayContaining([
519 "--prefix",
520 yield* fs.realPath(npmPrefix),
521 "@openai/codex@latest",
522 ]),
365523 });
366524 } else {
367525 expect(capabilities.update).toBeNull();
apps/server/src/provider/Drivers/CodexDriver.ts+17-1
......@@ -68,6 +68,10 @@ import {
6868 materializeCodexShadowHome,
6969 resolveCodexHomeLayout,
7070} from "./CodexHomeLayout.ts";
71import { makeManagedCodexProvider } from "./CodexManagedProvider.ts";
72import { CodexInstallation } from "../CodexInstallation.ts";
73import { ServerSecretStore } from "../../auth/ServerSecretStore.ts";
74import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts";
7175const decodeCodexSettings = Schema.decodeSync(CodexSettings);
7276
7377const DRIVER_KIND = ProviderDriverKind.make("codex");
......@@ -111,7 +115,10 @@ export type CodexDriverEnv =
111115 | Path.Path
112116 | ProviderEventLoggers
113117 | ServerConfig
114 | ServerSettingsService;
118 | ServerSettingsService
119 | ServerSecretStore
120 | ServerEnvironmentIdentity
121 | CodexInstallation;
115122
116123export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
117124 driverKind: DRIVER_KIND,
......@@ -123,6 +130,15 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
123130 defaultConfig: (): CodexSettings => decodeCodexSettings({}),
124131 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
125132 Effect.gen(function* () {
133 if (config.setupMode === "managed")
134 return yield* makeManagedCodexProvider({
135 instanceId,
136 displayName,
137 accentColor,
138 environment,
139 enabled,
140 config,
141 });
126142 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
127143 const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator;
128144 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/provider/Drivers/CodexManagedProvider.ts created+280
......@@ -0,0 +1,280 @@
1import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import * as Option from "effect/Option";
5import { ChildProcessSpawner } from "effect/unstable/process";
6import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
7import { ServerSettingsService } from "../../serverSettings.ts";
8import { chatGptModels } from "../CodexChatGptModels.ts";
9import { makeCodexManagedRuntime } from "../CodexManagedRuntime.ts";
10import { ProviderDriverError } from "../Errors.ts";
11import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
12import {
13 checkCodexProviderStatus,
14 makePendingCodexProvider,
15 probeCodexSkillsForCwd,
16} from "../Layers/CodexProvider.ts";
17import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
18import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
19import { type ProviderDriverCreateInput, type ProviderInstance } from "../ProviderDriver.ts";
20import { codexContinuationIdentity } from "./CodexHomeLayout.ts";
21import { withInstanceIdentity } from "./instanceIdentity.ts";
22import { HttpClient } from "effect/unstable/http";
23const DRIVER = ProviderDriverKind.make("codex");
24
25export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(function* (
26 input: ProviderDriverCreateInput<CodexSettings>,
27) {
28 const { instanceId, enabled, displayName, accentColor, config } = input;
29 const http = yield* HttpClient.HttpClient;
30 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
31 const settings = yield* ServerSettingsService;
32 const runtime = yield* makeCodexManagedRuntime({
33 instanceId,
34 enabled,
35 config,
36 environment: mergeProviderInstanceEnvironment(input.environment),
37 });
38 const continuationIdentity = codexContinuationIdentity(runtime.homeLayout);
39 const stamp = withInstanceIdentity({
40 instanceId,
41 driverKind: DRIVER,
42 displayName,
43 accentColor,
44 continuationGroupKey: continuationIdentity.continuationKey,
45 });
46 const setup = { canAuthenticate: true, canInstall: true };
47 const runtimePaths = {
48 homePath: runtime.homeLayout.sharedHomePath,
49 shadowHomePath: runtime.homeLayout.mode === "authOverlay" ? runtime.homePath : null,
50 };
51 const pending = makePendingCodexProvider({ ...config, customModels: [] }).pipe(
52 Effect.map((draft) =>
53 stamp({
54 ...draft,
55 models: [],
56 setup,
57 runtimePaths,
58 }),
59 ),
60 );
61 const check = Effect.gen(function* () {
62 const base = yield* pending;
63 if (!enabled) return base;
64 const executable = yield* runtime.installation.resolve().pipe(Effect.option);
65 if (Option.isNone(executable))
66 return {
67 ...base,
68 installed: false,
69 models: [],
70 message: "Set up Codex to get started.",
71 auth: { status: "unauthenticated" as const },
72 };
73 const saved = yield* runtime.auth.read.pipe(Effect.orElseSucceed(() => Option.none()));
74 if (Option.isSome(saved) && !saved.value.scopes.includes("chatgpt.tokens.use.direct"))
75 return {
76 ...base,
77 installed: true,
78 version: executable.value.version,
79 models: [],
80 message:
81 "Signed in with ChatGPT, but token sharing is disabled. Sign in again and enable token sharing, or use another provider.",
82 auth: {
83 status: "unauthenticated" as const,
84 label: "ChatGPT",
85 ...(saved.value.email?.trim() ? { email: saved.value.email.trim() } : {}),
86 },
87 };
88 if (Option.isNone(saved))
89 return {
90 ...base,
91 installed: true,
92 version: executable.value.version,
93 models: [],
94 message: "Sign in with ChatGPT to use Codex.",
95 auth: { status: "unauthenticated" as const },
96 };
97 const usageLimits = {
98 checkedAt: base.checkedAt,
99 windows: [],
100 unavailable: {
101 reason: "unsupported" as const,
102 message:
103 "ChatGPT tracks subscription usage across connected apps. Open Usage settings with the account you connected to Codex.",
104 },
105 externalUsage: { label: "ChatGPT usage", url: "https://chatgpt.com/#settings/Usage" },
106 };
107 const managedAuth = {
108 subscriptionSharing: true,
109 profileId: saved.value.clientId,
110 status: "authenticated" as const,
111 type: "chatgpt",
112 label: "ChatGPT",
113 ...(saved.value.email?.trim() ? { email: saved.value.email.trim() } : {}),
114 };
115 return yield* runtime.auth.controller.withAccess!(runtime.resolve).pipe(
116 Effect.flatMap((effective) =>
117 Effect.gen(function* () {
118 const draft = yield* checkCodexProviderStatus(
119 effective.config,
120 undefined,
121 effective.environment,
122 managedAuth,
123 );
124 const models = yield* chatGptModels(
125 effective.environment.ACCESS_TOKEN!,
126 draft.models,
127 ).pipe(Effect.provideService(HttpClient.HttpClient, http));
128 return { ...draft, models };
129 }),
130 ),
131 Effect.map((draft) =>
132 stamp({
133 ...draft,
134 auth: managedAuth,
135 version: draft.version ?? executable.value.version,
136 usageLimits,
137 models: draft.models.map((model) => ({
138 ...model,
139 ...(model.capabilities
140 ? {
141 capabilities: {
142 ...model.capabilities,
143 optionDescriptors: (model.capabilities.optionDescriptors ?? []).filter(
144 (option) => option.id !== "serviceTier",
145 ),
146 },
147 }
148 : {}),
149 })),
150 setup,
151 runtimePaths,
152 ...(draft.slashCommands
153 ? {
154 slashCommands: draft.slashCommands.filter((command) => command.name !== "feedback"),
155 }
156 : {}),
157 }),
158 ),
159 Effect.catch(() =>
160 runtime.auth.read.pipe(
161 Effect.orElseSucceed(() => Option.none()),
162 Effect.map((current) => ({
163 ...base,
164 installed: true,
165 version: executable.value.version,
166 models: [],
167 auth: {
168 status:
169 Option.isSome(current) && current.value.scopes.includes("chatgpt.tokens.use.direct")
170 ? ("authenticated" as const)
171 : ("unauthenticated" as const),
172 label: "ChatGPT",
173 ...(Option.isSome(current) &&
174 current.value.scopes.includes("chatgpt.tokens.use.direct")
175 ? { subscriptionSharing: true, profileId: current.value.clientId }
176 : {}),
177 ...(Option.isSome(current) && current.value.email?.trim()
178 ? { email: current.value.email.trim() }
179 : {}),
180 },
181 ...(Option.isSome(current) && current.value.scopes.includes("chatgpt.tokens.use.direct")
182 ? { usageLimits }
183 : {}),
184 message: "Could not check Codex right now. Retry, or reconnect in provider settings.",
185 })),
186 ),
187 ),
188 Effect.scoped,
189 );
190 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
191 const snapshot = yield* makeManagedServerProvider({
192 resolveMaintenance: () => Effect.succeed({ provider: DRIVER, packageName: null, update: null }),
193 getSettings: settings.getSettings,
194 streamSettings: settings.streamChanges,
195 haveSettingsChanged: () => false,
196 initialSnapshot: () => pending,
197 checkProvider: check,
198 }).pipe(
199 Effect.mapError(
200 (cause) =>
201 new ProviderDriverError({
202 driver: DRIVER,
203 instanceId,
204 detail: "Could not prepare managed Codex.",
205 cause,
206 }),
207 ),
208 );
209 yield* runtime.auth.controller.subscribe("managed-codex-snapshot").pipe(
210 // Disconnect also publishes idle when a saved account has no active sign-in flow.
211 Stream.filter((state) => ["idle", "succeeded", "failed", "cancelled"].includes(state.phase)),
212 Stream.runForEach(() => snapshot.refresh.pipe(Effect.asVoid)),
213 Effect.forkScoped,
214 );
215 const resolveRuntime = runtime.auth.controller.withAccess!(runtime.resolve);
216 const adapter = yield* makeCodexAdapter(config, {
217 instanceId,
218 resolveRuntime,
219 onManagedConnectionRevoked: runtime.auth.revoke.pipe(Effect.ignore),
220 });
221 const nativeGeneration = yield* makeCodexTextGeneration(
222 config,
223 undefined,
224 snapshot.getSnapshot.pipe(Effect.map((value) => value.models)),
225 resolveRuntime,
226 );
227 const protect = <A>(operation: string, effect: Effect.Effect<A, TextGenerationError>) =>
228 runtime.auth.controller.withAccess!(effect).pipe(
229 Effect.scoped,
230 Effect.mapError(
231 (cause) =>
232 new TextGenerationError({
233 operation,
234 detail: "detail" in cause ? cause.detail : "Codex text generation failed.",
235 }),
236 ),
237 );
238 const textGeneration: ProviderInstance["textGeneration"] = {
239 generateCommitMessage: (value) =>
240 protect("generateCommitMessage", nativeGeneration.generateCommitMessage(value)),
241 generatePrContent: (value) =>
242 protect("generatePrContent", nativeGeneration.generatePrContent(value)),
243 generateBranchName: (value) =>
244 protect("generateBranchName", nativeGeneration.generateBranchName(value)),
245 generateThreadTitle: (value) =>
246 protect("generateThreadTitle", nativeGeneration.generateThreadTitle(value)),
247 };
248 return {
249 instanceId,
250 driverKind: DRIVER,
251 continuationIdentity,
252 displayName,
253 accentColor,
254 enabled,
255 snapshot,
256 adapter,
257 textGeneration,
258 auth: runtime.auth.controller,
259 snapshotForCwd: (cwd: string) =>
260 enabled
261 ? resolveRuntime.pipe(
262 Effect.flatMap((effective) =>
263 probeCodexSkillsForCwd({
264 binaryPath: effective.config.binaryPath,
265 homePath: effective.config.homePath,
266 launchArgs: effective.config.launchArgs,
267 cwd,
268 environment: effective.environment,
269 }),
270 ),
271 Effect.flatMap((skills) =>
272 snapshot.getSnapshot.pipe(Effect.map((draft) => ({ ...draft, skills }))),
273 ),
274 Effect.scoped,
275 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
276 Effect.catch(() => snapshot.getSnapshot),
277 )
278 : snapshot.getSnapshot,
279 } satisfies ProviderInstance;
280});
apps/server/src/provider/Layers/CodexAdapter.test.ts+109
......@@ -3084,3 +3084,112 @@ usageLimitLayer("CodexAdapterLive usage limits", (it) => {
30843084 }),
30853085 );
30863086});
3087
3088it.effect("managed runtime rotation restarts app-server and resumes the same native thread", () => {
3089 const runtimes: FakeCodexRuntime[] = [];
3090 let revision = "first";
3091 const layer = Layer.effect(
3092 CodexAdapter,
3093 Effect.gen(function* () {
3094 return yield* makeCodexAdapter(decodeCodexSettings({}), {
3095 resolveRuntime: Effect.sync(() => ({
3096 config: decodeCodexSettings({
3097 binaryPath: "/t3/tools/codex/0.155.1/bin/codex",
3098 homePath: "/t3/caches/codex/home",
3099 launchArgs: "-c 'model_provider=managed'",
3100 }),
3101 environment: { ACCESS_TOKEN: `dummy-${revision}` },
3102 revision,
3103 })),
3104 makeRuntime: (options) => {
3105 const runtime = new FakeCodexRuntime(options);
3106 runtime.startImpl.mockImplementation(() =>
3107 Promise.resolve({
3108 provider: ProviderDriverKind.make("codex"),
3109 threadId: options.threadId,
3110 runtimeMode: options.runtimeMode,
3111 cwd: options.cwd,
3112 status: "ready",
3113 createdAt: "2026-01-01T00:00:00Z",
3114 updatedAt: "2026-01-01T00:00:00Z",
3115 resumeCursor: { threadId: "native-managed-thread" },
3116 }),
3117 );
3118 runtimes.push(runtime);
3119 return Effect.succeed(runtime);
3120 },
3121 });
3122 }),
3123 ).pipe(
3124 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
3125 Layer.provideMerge(ServerSettingsService.layerTest()),
3126 Layer.provideMerge(providerSessionDirectoryTestLayer),
3127 Layer.provideMerge(NodeServices.layer),
3128 );
3129 return Effect.gen(function* () {
3130 const adapter = yield* CodexAdapter;
3131 const threadId = asThreadId("managed-token-rotation");
3132 yield* adapter.startSession({ threadId, runtimeMode: "full-access" });
3133 yield* adapter.sendTurn({ threadId, input: "first" });
3134 NodeAssert.equal(runtimes.length, 1);
3135 revision = "rotated";
3136 yield* adapter.sendTurn({ threadId, input: "second" });
3137 NodeAssert.equal(runtimes.length, 2);
3138 NodeAssert.equal(runtimes[0]?.closeImpl.mock.calls.length, 1);
3139 NodeAssert.deepEqual(runtimes[1]?.options.resumeCursor, { threadId: "native-managed-thread" });
3140 NodeAssert.equal(runtimes[1]?.options.environment?.ACCESS_TOKEN, "dummy-rotated");
3141 NodeAssert.equal(runtimes[1]?.options.binaryPath, "/t3/tools/codex/0.155.1/bin/codex");
3142 }).pipe(Effect.provide(layer));
3143});
3144
3145it.effect("managed turn failures preserve the sharing-limit code for client notices", () => {
3146 const factory = makeRuntimeFactory();
3147 const layer = Layer.effect(
3148 CodexAdapter,
3149 Effect.gen(function* () {
3150 return yield* makeCodexAdapter(decodeCodexSettings({}), {
3151 makeRuntime: factory.factory,
3152 resolveRuntime: Effect.succeed({
3153 config: decodeCodexSettings({}),
3154 environment: {},
3155 revision: "managed",
3156 }),
3157 });
3158 }),
3159 ).pipe(
3160 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
3161 Layer.provideMerge(ServerSettingsService.layerTest()),
3162 Layer.provideMerge(providerSessionDirectoryTestLayer),
3163 Layer.provideMerge(NodeServices.layer),
3164 );
3165 return Effect.gen(function* () {
3166 const adapter = yield* CodexAdapter;
3167 yield* adapter.startSession({ threadId: asThreadId("thread-1"), runtimeMode: "full-access" });
3168 const eventsFiber = yield* adapter.streamEvents.pipe(
3169 Stream.take(2),
3170 Stream.runCollect,
3171 Effect.forkChild,
3172 );
3173 const notification = codexUsageLimitTurnFailed("managed-sharing-limit");
3174 yield* factory.lastRuntime!.emit({
3175 ...notification,
3176 payload: {
3177 threadId: "thread-1",
3178 turn: {
3179 id: "turn-limit",
3180 items: [],
3181 status: "failed",
3182 error: { message: "subscription_sharing_usage_limit_exceeded", codexErrorInfo: "other" },
3183 },
3184 },
3185 });
3186 const events = Array.from(yield* Fiber.join(eventsFiber));
3187 NodeAssert.equal(events[0]?.type, "runtime.error");
3188 if (events[0]?.type === "runtime.error") {
3189 NodeAssert.equal(events[0].payload.code, "subscription_sharing_usage_limit_exceeded");
3190 NodeAssert.match(events[0].payload.message, /ChatGPT usage limit/);
3191 }
3192 NodeAssert.equal(events[1]?.type, "turn.completed");
3193 if (events[1]?.type === "turn.completed") NodeAssert.equal(events[1].payload.state, "failed");
3194 }).pipe(Effect.provide(layer));
3195});
apps/server/src/provider/Layers/CodexAdapter.ts+93-16
......@@ -67,7 +67,6 @@ import {
6767 makeCodexSessionRuntime,
6868 type CodexSessionRuntimeError,
6969 type CodexSessionRuntimeOptions,
70 type CodexSessionRuntimeSendTurnInput,
7170 type CodexSessionRuntimeShape,
7271} from "./CodexSessionRuntime.ts";
7372import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
......@@ -85,6 +84,7 @@ const isCodexSessionRuntimeThreadIdMissingError = Schema.is(
8584);
8685const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema);
8786
87import { classifyCodexManagedError } from "../CodexManagedErrors.ts";
8888const PROVIDER = ProviderDriverKind.make("codex");
8989
9090export interface CodexAdapterLiveOptions {
......@@ -99,6 +99,12 @@ export interface CodexAdapterLiveOptions {
9999 CodexSessionRuntimeError,
100100 ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
101101 >;
102 readonly resolveRuntime?: Effect.Effect<
103 import("../CodexManagedRuntime.ts").CodexEffectiveRuntime,
104 import("@t3tools/contracts").ProviderSetupError,
105 Scope.Scope
106 >;
107 readonly onManagedConnectionRevoked?: Effect.Effect<void>;
102108 readonly nativeEventLogPath?: string;
103109 readonly nativeEventLogger?: EventNdjsonLogger;
104110}
......@@ -109,6 +115,8 @@ interface CodexAdapterSessionContext {
109115 readonly runtime: CodexSessionRuntimeShape;
110116 readonly eventFiber: Fiber.Fiber<void, never>;
111117 readonly turnTokenUsage: CodexTurnTokenUsageState;
118 readonly startInput: Parameters<CodexAdapterShape["startSession"]>[0];
119 readonly runtimeRevision?: string;
112120 stopped: boolean;
113121}
114122
......@@ -2270,8 +2278,28 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
22702278 yield* Effect.suspend(() => stopSessionInternal(existing));
22712279 }
22722280
2281 const sessionScope = yield* Scope.make("sequential");
2282 let sessionScopeTransferred = false;
2283 yield* Effect.addFinalizer(() =>
2284 sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void),
2285 );
2286 const resolved = options?.resolveRuntime
2287 ? yield* options.resolveRuntime.pipe(
2288 Effect.provideService(Scope.Scope, sessionScope),
2289 Effect.mapError(
2290 (cause) =>
2291 new ProviderAdapterValidationError({
2292 provider: PROVIDER,
2293 operation: "startSession",
2294 issue: cause.detail,
2295 }),
2296 ),
2297 )
2298 : undefined;
2299 const effectiveConfig = resolved?.config ?? codexConfig;
2300 const effectiveEnvironment = resolved?.environment ?? options?.environment;
22732301 const serviceTier =
2274 input.modelSelection?.instanceId === boundInstanceId
2302 !resolved && input.modelSelection?.instanceId === boundInstanceId
22752303 ? getCodexServiceTierOptionValue(input.modelSelection)
22762304 : undefined;
22772305 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
......@@ -2279,11 +2307,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
22792307 threadId: input.threadId,
22802308 providerInstanceId: boundInstanceId,
22812309 cwd: input.cwd ?? process.cwd(),
2282 binaryPath: codexConfig.binaryPath,
22832310 ...(options?.models ? { models: options.models } : {}),
2284 launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment),
2285 ...(options?.environment ? { environment: options.environment } : {}),
2286 ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}),
2311 binaryPath: effectiveConfig.binaryPath,
2312 launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, effectiveEnvironment),
2313 ...(effectiveEnvironment ? { environment: effectiveEnvironment } : {}),
2314 ...(effectiveConfig.homePath ? { homePath: effectiveConfig.homePath } : {}),
22872315 ...(isCodexResumeCursorSchema(input.resumeCursor)
22882316 ? { resumeCursor: input.resumeCursor }
22892317 : {}),
......@@ -2296,7 +2324,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
22962324 ? {
22972325 environment: {
22982326 ...McpProviderSession.withAgentDeviceEnvironment(
2299 options?.environment ?? process.env,
2327 effectiveEnvironment ?? process.env,
23002328 mcpSession,
23012329 ),
23022330 T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""),
......@@ -2318,11 +2346,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
23182346 // after the stop and often sparse, so keep the session's merged view of
23192347 // it and read it when a turn fails on the limit.
23202348 let rateLimits: CodexRateLimitSnapshot | undefined;
2321 const sessionScope = yield* Scope.make("sequential");
2322 let sessionScopeTransferred = false;
2323 yield* Effect.addFinalizer(() =>
2324 sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void),
2325 );
23262349 const createRuntime = options?.makeRuntime ?? makeCodexSessionRuntime;
23272350 const runtime = yield* createRuntime(runtimeInput).pipe(
23282351 Effect.provideService(Scope.Scope, sessionScope),
......@@ -2393,6 +2416,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
23932416 if (errorPayload?.error.codexErrorInfo === "usageLimitExceeded") return;
23942417 }
23952418
2419 const managedError = options?.resolveRuntime
2420 ? classifyCodexManagedError(event.payload)
2421 : undefined;
2422 if (managedError?.revoke && options?.onManagedConnectionRevoked)
2423 yield* options.onManagedConnectionRevoked;
23962424 let usageLimitError: ProviderRuntimeEvent | undefined;
23972425 let usageLimitMessage: string | undefined;
23982426 if (event.method === "turn/completed") {
......@@ -2404,7 +2432,18 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
24042432 completedPayload?.turn.status === "failed"
24052433 ? completedPayload.turn.error
24062434 : undefined;
2407 if (turnError?.codexErrorInfo === "usageLimitExceeded") {
2435 if (turnError && managedError) {
2436 usageLimitMessage = managedError.message;
2437 usageLimitError = {
2438 ...runtimeEventBase(event, event.threadId),
2439 type: "runtime.error",
2440 payload: {
2441 message: managedError.message,
2442 code: managedError.code,
2443 class: "provider_error",
2444 },
2445 };
2446 } else if (turnError?.codexErrorInfo === "usageLimitExceeded") {
24082447 usageLimitMessage = codexUsageLimitMessage(rateLimits, event.createdAt);
24092448 usageLimitError = {
24102449 ...runtimeEventBase(event, event.threadId),
......@@ -2419,12 +2458,27 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
24192458 }
24202459
24212460 const mappedEvents = mapToRuntimeEvents(event, event.threadId).map((runtimeEvent) => {
2461 if (managedError && runtimeEvent.type === "runtime.error")
2462 return {
2463 ...runtimeEvent,
2464 payload: {
2465 ...runtimeEvent.payload,
2466 message: managedError.message,
2467 detail: managedError.message,
2468 code: managedError.code,
2469 },
2470 } satisfies ProviderRuntimeEvent;
2471
24222472 if (runtimeEvent.type === "turn.completed" && runtimeEvent.turnId) {
24232473 return {
24242474 ...runtimeEvent,
24252475 payload: {
24262476 ...runtimeEvent.payload,
2427 ...(usageLimitMessage ? { errorMessage: usageLimitMessage } : {}),
2477 ...(managedError
2478 ? { errorMessage: managedError.message }
2479 : usageLimitMessage
2480 ? { errorMessage: usageLimitMessage }
2481 : {}),
24282482 tokenUsage: completeCodexTurnTokenUsage(
24292483 turnTokenUsage,
24302484 String(runtimeEvent.turnId),
......@@ -2489,6 +2543,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
24892543 runtime,
24902544 eventFiber,
24912545 turnTokenUsage,
2546 startInput: input,
2547 ...(resolved ? { runtimeRevision: resolved.revision } : {}),
24922548 stopped: false,
24932549 });
24942550 sessionScopeTransferred = true;
......@@ -2530,13 +2586,34 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
25302586 { concurrency: 1 },
25312587 );
25322588
2533 const session = yield* requireSession(input.threadId);
2589 let session = yield* requireSession(input.threadId);
2590 if (options?.resolveRuntime) {
2591 const next = yield* options.resolveRuntime.pipe(
2592 Effect.scoped,
2593 Effect.mapError(
2594 (cause) =>
2595 new ProviderAdapterValidationError({
2596 provider: PROVIDER,
2597 operation: "sendTurn",
2598 issue: cause.detail,
2599 }),
2600 ),
2601 );
2602 if (next.revision !== session.runtimeRevision) {
2603 const previous = yield* session.runtime.getSession;
2604 yield* startSession({
2605 ...session.startInput,
2606 ...(previous.resumeCursor ? { resumeCursor: previous.resumeCursor } : {}),
2607 });
2608 session = yield* requireSession(input.threadId);
2609 }
2610 }
25342611 const reasoningEffort =
25352612 input.modelSelection?.instanceId === boundInstanceId
25362613 ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort")
25372614 : undefined;
25382615 const serviceTier =
2539 input.modelSelection?.instanceId === boundInstanceId
2616 !options?.resolveRuntime && input.modelSelection?.instanceId === boundInstanceId
25402617 ? getCodexServiceTierOptionValue(input.modelSelection)
25412618 : undefined;
25422619 return yield* session.runtime
apps/server/src/provider/Layers/CodexProvider.ts+30-22
......@@ -343,8 +343,8 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* (
343343export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
344344 return {
345345 clientInfo: {
346 name: "t3code_desktop",
347 title: "T3 Code Desktop",
346 name: "T3 Code",
347 title: "T3 Code",
348348 version: packageJson.version,
349349 },
350350 capabilities: {
......@@ -416,6 +416,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
416416 readonly cwd: string;
417417 readonly customModels?: ReadonlyArray<CustomModelSetting>;
418418 readonly environment?: NodeJS.ProcessEnv;
419 readonly skipNativeUsage?: boolean;
419420}) {
420421 const { client, initialize } = yield* withCodexAppServerClient(input);
421422
......@@ -441,31 +442,33 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
441442 requestAllCodexModels(client),
442443 // Usage is an enrichment: a failure or a slow answer degrades to "no
443444 // usage this probe" rather than costing the account and models.
444 client.request("account/rateLimits/read", null).pipe(
445 Effect.map((response): CodexRateLimitsProbe => ({
446 snapshot: response.rateLimits,
447 rateLimitsByLimitId: response.rateLimitsByLimitId,
448 resetCredits: response.rateLimitResetCredits,
449 })),
450 Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)),
451 Effect.map(
452 Option.getOrElse((): CodexRateLimitsProbe => ({
453 failure: "Codex did not answer the usage request.",
454 })),
455 ),
456 Effect.catch((error) =>
457 Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe(
458 Effect.as<CodexRateLimitsProbe>({ failure: codexRateLimitsFailureMessage(error) }),
445 input.skipNativeUsage
446 ? Effect.succeed(undefined)
447 : client.request("account/rateLimits/read", null).pipe(
448 Effect.map((response): CodexRateLimitsProbe => ({
449 snapshot: response.rateLimits,
450 rateLimitsByLimitId: response.rateLimitsByLimitId,
451 resetCredits: response.rateLimitResetCredits,
452 })),
453 Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)),
454 Effect.map(
455 Option.getOrElse((): CodexRateLimitsProbe => ({
456 failure: "Codex did not answer the usage request.",
457 })),
458 ),
459 Effect.catch((error) =>
460 Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe(
461 Effect.as<CodexRateLimitsProbe>({ failure: codexRateLimitsFailureMessage(error) }),
462 ),
463 ),
459464 ),
460 ),
461 ),
462465 ],
463466 { concurrency: "unbounded" },
464467 );
465468
466469 return {
467470 account: accountResponse,
468 rateLimits,
471 ...(rateLimits ? { rateLimits } : {}),
469472 version,
470473 models: applyPreferredCodexDefaultModel(
471474 appendCustomCodexModels(models, input.customModels ?? []),
......@@ -567,12 +570,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
567570 readonly cwd: string;
568571 readonly customModels: ReadonlyArray<CustomModelSetting>;
569572 readonly environment?: NodeJS.ProcessEnv;
573 readonly skipNativeUsage?: boolean;
570574 }) => Effect.Effect<
571575 CodexAppServerProviderSnapshot,
572576 CodexErrors.CodexAppServerError,
573577 ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
574578 > = probeCodexAppServerProvider,
575579 environment?: NodeJS.ProcessEnv,
580 managedAuth?: ServerProvider["auth"],
576581): Effect.fn.Return<
577582 ServerProviderDraft,
578583 ServerSettingsError,
......@@ -606,6 +611,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
606611 cwd: process.cwd(),
607612 customModels: codexSettings.customModels,
608613 environment: resolvedEnvironment,
614 ...(managedAuth ? { skipNativeUsage: true } : {}),
609615 }).pipe(
610616 Effect.scoped,
611617 Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)),
......@@ -654,7 +660,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
654660 }
655661
656662 const snapshot = probeResult.success.value;
657 const accountStatus = accountProbeStatus(snapshot.account);
663 const accountStatus = managedAuth
664 ? { status: "ready" as const, auth: managedAuth, message: undefined }
665 : accountProbeStatus(snapshot.account);
658666 const usageLimits =
659667 snapshot.account.account?.type === "apiKey"
660668 ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" })
......@@ -691,7 +699,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
691699 status: accountStatus.status,
692700 auth: accountStatus.auth,
693701 ...(accountStatus.message ? { message: accountStatus.message } : {}),
694 usageLimits,
702 ...(managedAuth ? {} : { usageLimits }),
695703 },
696704 });
697705});
apps/server/src/provider/Layers/ProviderAuthService.ts+29
......@@ -144,6 +144,33 @@ export const makeProviderAuthService = Effect.gen(function* () {
144144 });
145145
146146 return ProviderAuthService.ProviderAuthService.of({
147 reconnectProfile: Effect.fnUntraced(function* (input) {
148 const auth = yield* getController(input.instanceId, "export");
149 if (!auth.reconnectProfile)
150 return yield* new ProviderSetupError({
151 instanceId: input.instanceId,
152 operation: "export",
153 detail: "This provider does not support ChatGPT profile transfer.",
154 });
155 return yield* auth.reconnectProfile(input.methodId);
156 }),
157 importProfile: (input) =>
158 credentialChanges.withPermit(
159 Effect.gen(function* () {
160 const auth = yield* getController(input.instanceId, "import");
161 yield* checkSharedBinding(input.instanceId, "start", auth);
162 if (!auth.importProfile)
163 return yield* new ProviderSetupError({
164 instanceId: input.instanceId,
165 operation: "import",
166 detail: "This provider does not support ChatGPT profile transfer.",
167 });
168 return yield* auth.importProfile(
169 input.profile,
170 stopSessions(input.instanceId, auth.credentialBinding),
171 );
172 }),
173 ),
147174 start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) {
148175 return yield* credentialChanges.withPermit(
149176 Effect.gen(function* () {
......@@ -153,6 +180,8 @@ export const makeProviderAuthService = Effect.gen(function* () {
153180 ownerSessionId,
154181 stopSessions(input.instanceId, auth.credentialBinding),
155182 input.methodId,
183 input.returnUrl,
184 input.callbackMode,
156185 );
157186 }),
158187 );
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+29-1
......@@ -1,3 +1,6 @@
1import { CodexInstallation } from "../CodexInstallation.ts";
2import { ServerSecretStore } from "../../auth/ServerSecretStore.ts";
3import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts";
14/**
25 * Multi-instance validation slices for `ProviderInstanceRegistryLive`.
36 *
......@@ -25,6 +28,7 @@
2528import { describe, expect, it } from "@effect/vitest";
2629import * as NodeServices from "@effect/platform-node/NodeServices";
2730import {
31 EnvironmentId,
2832 type ClaudeSettings,
2933 type CodexSettings,
3034 type CursorSettings,
......@@ -246,6 +250,17 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
246250 prefix: "provider-instance-registry-test",
247251 }).pipe(
248252 Layer.provideMerge(NodeServices.layer),
253 Layer.provideMerge(
254 Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }),
255 ),
256 Layer.provideMerge(Layer.mock(ServerSecretStore)({})),
257 Layer.provideMerge(
258 Layer.succeed(ServerEnvironmentIdentity, {
259 getEnvironmentId: Effect.succeed(
260 EnvironmentId.make("00000000-0000-4000-8000-000000000001"),
261 ),
262 }),
263 ),
249264 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
250265 Layer.provideMerge(ServerSettingsService.layerTest()),
251266 Layer.provideMerge(TestHttpClientLive),
......@@ -594,7 +609,20 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
594609 // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs
595610 // surfaced; that merged layer then provides `ServerConfig.layerTest`'s
596611 // `FileSystem` dep while keeping everything else surfaced to the test.
597 const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer));
612 const infraLayer = OpenCodeRuntimeLive.pipe(
613 Layer.provideMerge(NodeServices.layer),
614 Layer.provideMerge(
615 Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }),
616 ),
617 Layer.provideMerge(Layer.mock(ServerSecretStore)({})),
618 Layer.provideMerge(
619 Layer.succeed(ServerEnvironmentIdentity, {
620 getEnvironmentId: Effect.succeed(
621 EnvironmentId.make("00000000-0000-4000-8000-000000000001"),
622 ),
623 }),
624 ),
625 );
598626 const testLayer = AntigravityInstallation.layer.pipe(
599627 Layer.provideMerge(
600628 ServerConfig.layerTest(process.cwd(), {
apps/server/src/provider/Layers/ProviderRegistry.test.ts+14-1
......@@ -1,3 +1,6 @@
1import { CodexInstallation } from "../CodexInstallation.ts";
2import { ServerSecretStore } from "../../auth/ServerSecretStore.ts";
3import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts";
14import * as NodeServices from "@effect/platform-node/NodeServices";
25import { describe, it, assert } from "@effect/vitest";
36import * as DateTime from "effect/DateTime";
......@@ -16,6 +19,7 @@ import * as Stream from "effect/Stream";
1619import * as TestClock from "effect/testing/TestClock";
1720import * as CodexErrors from "effect-codex-app-server/errors";
1821import {
22 EnvironmentId,
1923 ClaudeSettings,
2024 CodexSettings,
2125 DEFAULT_SERVER_SETTINGS,
......@@ -369,7 +373,16 @@ const awaitPersistedProvider = (
369373 Effect.forkScoped,
370374 );
371375
372it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), TestHttpClientLive))(
376const TestNodeServices = Layer.mergeAll(
377 NodeServices.layer,
378 Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }),
379 Layer.mock(ServerSecretStore)({}),
380 Layer.succeed(ServerEnvironmentIdentity, {
381 getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")),
382 }),
383);
384
385it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), TestHttpClientLive))(
373386 "ProviderRegistry",
374387 (it) => {
375388 describe("checkCodexProviderStatus", () => {
apps/server/src/provider/Layers/ProviderService.test.ts+88-1
......@@ -418,6 +418,7 @@ function makeProviderServiceLayer(
418418 readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"];
419419 readonly supportsConversationRollback?: boolean;
420420 readonly analyticsLayer?: Layer.Layer<AnalyticsService.AnalyticsService>;
421 readonly settingsLayer?: typeof defaultServerSettingsLayer;
421422 readonly registry?: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"];
422423 } = {},
423424) {
......@@ -450,7 +451,7 @@ function makeProviderServiceLayer(
450451 Layer.provide(NodeServices.layer),
451452 Layer.provide(providerAdapterLayer),
452453 Layer.provide(directoryLayer),
453 Layer.provide(defaultServerSettingsLayer),
454 Layer.provide(input.settingsLayer ?? defaultServerSettingsLayer),
454455 Layer.provide(serverConfigTestLayer),
455456 Layer.provideMerge(input.analyticsLayer ?? AnalyticsService.layerTest),
456457 Layer.provide(
......@@ -5272,3 +5273,89 @@ describe("agent browser access", () => {
52725273 }).pipe(Effect.provide(NodeServices.layer)),
52735274 );
52745275});
5276
5277const chatGptAnalytics = makeRecordingAnalytics();
5278const chatGptAdapter = makeFakeCodexAdapter();
5279const chatGptTelemetry = makeProviderServiceLayer({
5280 analyticsLayer: chatGptAnalytics.layer,
5281 settingsLayer: ServerSettings.ServerSettingsService.layerTest({
5282 providerInstances: {
5283 [secondaryCodexInstanceId]: { driver: CODEX_DRIVER, config: { setupMode: "managed" } },
5284 },
5285 }),
5286 registry: makeStaticInstanceRegistry([[secondaryCodexInstanceId, chatGptAdapter.adapter]]),
5287});
5288chatGptTelemetry.layer("ChatGPT connector turn analytics", (it) => {
5289 it.effect("tags attempts, sends, and one terminal outcome without recording the prompt", () =>
5290 Effect.gen(function* () {
5291 chatGptAnalytics.reset();
5292 const provider = yield* ProviderService.ProviderService;
5293 const threadId = asThreadId("chatgpt-analytics-success");
5294 yield* provider.startSession(threadId, {
5295 provider: CODEX_DRIVER,
5296 providerInstanceId: secondaryCodexInstanceId,
5297 threadId,
5298 runtimeMode: "full-access",
5299 });
5300 const turn = yield* provider.sendTurn({ threadId, input: "private test prompt" });
5301 const drain = yield* Stream.take(provider.streamEvents, 2).pipe(
5302 Stream.runDrain,
5303 Effect.forkChild,
5304 );
5305 yield* Effect.yieldNow;
5306 const completion: LegacyProviderRuntimeEvent = {
5307 type: "turn.completed",
5308 eventId: asEventId("chatgpt-completed"),
5309 provider: CODEX_DRIVER,
5310 createdAt: "2026-01-01T00:00:00.000Z",
5311 threadId,
5312 turnId: turn.turnId,
5313 payload: { state: "completed" },
5314 };
5315 chatGptAdapter.emit(completion);
5316 chatGptAdapter.emit({ ...completion, eventId: asEventId("chatgpt-completed-duplicate") });
5317 yield* Fiber.join(drain);
5318 for (const event of [
5319 "provider.turn.attempted",
5320 "provider.turn.sent",
5321 "provider.turn.completed",
5322 ]) {
5323 const events = chatGptAnalytics.eventsByName(event);
5324 assert.equal(events.length, 1);
5325 assert.equal(events[0]?.properties?.subscriptionSharing, true);
5326 assert.notProperty(events[0]?.properties ?? {}, "input");
5327 assert.notProperty(events[0]?.properties ?? {}, "threadId");
5328 assert.notProperty(events[0]?.properties ?? {}, "providerInstanceId");
5329 }
5330 }),
5331 );
5332 it.effect("records rejected sends as failures without an accepted-turn event", () =>
5333 Effect.gen(function* () {
5334 chatGptAnalytics.reset();
5335 const provider = yield* ProviderService.ProviderService;
5336 const threadId = asThreadId("chatgpt-analytics-rejection");
5337 yield* provider.startSession(threadId, {
5338 provider: CODEX_DRIVER,
5339 providerInstanceId: secondaryCodexInstanceId,
5340 threadId,
5341 runtimeMode: "full-access",
5342 });
5343 chatGptAdapter.sendTurn.mockImplementationOnce(() =>
5344 Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: CODEX_DRIVER, threadId })),
5345 );
5346 const result = yield* provider
5347 .sendTurn({ threadId, input: "private rejected prompt" })
5348 .pipe(Effect.result);
5349 assert.equal(result._tag, "Failure");
5350 assert.equal(chatGptAnalytics.eventsByName("provider.turn.attempted").length, 1);
5351 assert.equal(chatGptAnalytics.eventsByName("provider.turn.sent").length, 0);
5352 const rejected = chatGptAnalytics.eventsByName("provider.turn.rejected");
5353 assert.equal(rejected.length, 1);
5354 assert.deepStrictEqual(rejected[0]?.properties, {
5355 provider: CODEX_DRIVER,
5356 subscriptionSharing: true,
5357 errorType: "ProviderAdapterSessionNotFoundError",
5358 });
5359 }),
5360 );
5361});
apps/server/src/provider/Layers/ProviderService.ts+36-1
......@@ -263,6 +263,7 @@ interface TurnAnalyticsMetadata {
263263 readonly provider: ProviderDriverKind;
264264 readonly startedAtMs: number;
265265 readonly mixedModels: boolean;
266 readonly subscriptionSharing?: boolean;
266267 readonly model?: string;
267268 readonly effort?: string;
268269 readonly interactionMode?: string;
......@@ -543,6 +544,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
543544
544545 return {
545546 ...input.completion.terminalProperties,
547 ...(metadata?.subscriptionSharing ? { subscriptionSharing: true } : {}),
546548 ...(metadata?.model ? { model: metadata.model } : {}),
547549 ...(metadata?.effort ? { effort: metadata.effort } : {}),
548550 ...(metadata?.interactionMode ? { interactionMode: metadata.interactionMode } : {}),
......@@ -588,6 +590,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
588590 readonly runtimeMode: string | undefined;
589591 }) {
590592 const startedAtMs = DateTime.toEpochMillis(yield* DateTime.now);
593 const settings = yield* serverSettings.getSettings.pipe(Effect.option);
594 const instance = Option.isSome(settings)
595 ? settings.value.providerInstances[input.providerInstanceId]
596 : undefined;
597 const subscriptionSharing =
598 input.provider === "codex" &&
599 (instance
600 ? instance.driver === "codex" &&
601 typeof instance.config === "object" &&
602 instance.config !== null &&
603 "setupMode" in instance.config &&
604 instance.config.setupMode === "managed"
605 : input.providerInstanceId === "codex" &&
606 Option.isSome(settings) &&
607 settings.value.providers.codex.setupMode === "managed");
591608 turnAnalyticsRequestId += 1;
592609 const requestId = turnAnalyticsRequestId;
593610 const effort = turnEffort(input.modelSelection);
......@@ -600,6 +617,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
600617 };
601618 const metadata: TurnAnalyticsMetadata = {
602619 provider: input.provider,
620 ...(subscriptionSharing ? { subscriptionSharing: true } : {}),
603621 startedAtMs,
604622 mixedModels: false,
605623 requestId,
......@@ -1731,6 +1749,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
17311749 yield* McpSessionRegistry.touchActiveMcpThread(input.threadId);
17321750 const analyticsModelSelection =
17331751 input.modelSelection?.instanceId === routed.instanceId ? input.modelSelection : undefined;
1752 let subscriptionSharing = false;
17341753 const turn = yield* Effect.acquireUseRelease(
17351754 beginTurnAnalytics({
17361755 providerInstanceId: routed.instanceId,
......@@ -1742,7 +1761,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
17421761 }),
17431762 (turnMetadata) =>
17441763 Effect.gen(function* () {
1745 const turn = yield* routed.adapter.sendTurn(input);
1764 subscriptionSharing = turnMetadata.subscriptionSharing === true;
1765 yield* analytics.record("provider.turn.attempted", {
1766 provider: routed.adapter.provider,
1767 ...(turnMetadata.subscriptionSharing ? { subscriptionSharing: true } : {}),
1768 model: input.modelSelection?.model,
1769 runtimeMode: routed.runtimeMode,
1770 });
1771 const turn = yield* routed.adapter.sendTurn(input).pipe(
1772 Effect.tapError((error) =>
1773 analytics.record("provider.turn.rejected", {
1774 provider: routed.adapter.provider,
1775 ...(turnMetadata.subscriptionSharing ? { subscriptionSharing: true } : {}),
1776 errorType: error._tag,
1777 }),
1778 ),
1779 );
17461780 yield* associateTurnAnalytics({
17471781 providerInstanceId: routed.instanceId,
17481782 threadId: input.threadId,
......@@ -1776,6 +1810,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
17761810 });
17771811 yield* analytics.record("provider.turn.sent", {
17781812 provider: routed.adapter.provider,
1813 ...(subscriptionSharing ? { subscriptionSharing: true } : {}),
17791814 model: input.modelSelection?.model,
17801815 interactionMode: input.interactionMode,
17811816 // Session-start events alone skew runtime mode toward users who toggle
apps/server/src/provider/ModelManifest.test.ts+24
......@@ -441,6 +441,30 @@ describe("ModelManifest service", () => {
441441 ),
442442 );
443443
444 it.live("ignores older remote edits without replacing the current manifest or disk cache", () => {
445 let remote = { ...REMOTE_MANIFEST, updatedAt: "2000-01-01T00:00:00Z" };
446 return Effect.gen(function* () {
447 const service = yield* make;
448 assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST);
449 assert.deepStrictEqual(yield* service.current, BUNDLED_MODEL_MANIFEST);
450
451 remote = { ...REMOTE_MANIFEST, updatedAt: REMOTE_UPDATED_AT };
452 assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST);
453 remote = { ...REMOTE_MANIFEST, updatedAt: BUNDLED_MODEL_MANIFEST.updatedAt! };
454 assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST);
455 const rebooted = yield* make;
456 assert.deepStrictEqual(yield* rebooted.current, REMOTE_MANIFEST);
457 }).pipe(
458 Effect.scoped,
459 Effect.provide(
460 serviceLayers({
461 prefix: "model-manifest-stale-fetch-test",
462 response: () => Response.json(remote),
463 }),
464 ),
465 );
466 });
467
444468 it.live("keeps the bundled manifest when the remote payload is malformed", () =>
445469 Effect.gen(function* () {
446470 const service = yield* make;
apps/server/src/provider/ModelManifest.ts+5-1
......@@ -402,7 +402,11 @@ export const make = Effect.gen(function* () {
402402 Effect.timeout(FETCH_TIMEOUT_MS),
403403 Effect.catchCause(() => Effect.succeed(null)),
404404 );
405 if (fetched === null) return manifest;
405 // A CDN can still serve an earlier edit after a release. Apply the same
406 // freshness rule as the disk cache so it cannot undo bundled version gates.
407 if (fetched === null || manifestUpdatedAtMs(fetched) < manifestUpdatedAtMs(manifest)) {
408 return manifest;
409 }
406410
407411 manifest = fetched;
408412 fetchedAtMs = now;
apps/server/src/provider/ProviderAuthFlow.test.ts+39
......@@ -518,3 +518,42 @@ it.effect("rebuilding a controller leaves sessions it admitted to their owners",
518518 assert.isFalse(closed);
519519 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
520520);
521
522it.effect("profile import stops owned sessions and gates access until credentials are saved", () =>
523 Effect.gen(function* () {
524 const controller = yield* ProviderAuthFlow.make({
525 instanceId,
526 credentialBinding: { owner: "t3", key: "handoff-binding" },
527 methods: Effect.succeed([method]),
528 authenticate: () => Effect.void,
529 logout: Effect.void,
530 });
531 let closed = false;
532 yield* controller.withAccess!(
533 Effect.addFinalizer(() =>
534 Effect.sync(() => {
535 closed = true;
536 }),
537 ),
538 );
539 const writing = yield* Deferred.make<void>();
540 const release = yield* Deferred.make<void>();
541 const imported = yield* controller.adoptCredentials!(
542 Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))),
543 Effect.sync(() => {
544 assert.isTrue(closed);
545 }),
546 ).pipe(Effect.forkChild);
547 yield* Deferred.await(writing);
548 const denied = yield* controller.withAccess!(Effect.succeed("old credential process")).pipe(
549 Effect.result,
550 );
551 assert.strictEqual(denied._tag, "Failure");
552 yield* Deferred.succeed(release, undefined);
553 assert.strictEqual((yield* Fiber.join(imported)).phase, "succeeded");
554 assert.strictEqual(
555 yield* controller.withAccess!(Effect.succeed("new credential process")),
556 "new credential process",
557 );
558 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
559);
apps/server/src/provider/ProviderAuthFlow.ts+75-11
......@@ -25,9 +25,14 @@ import type * as ProviderAuthService from "./Services/ProviderAuthService.ts";
2525
2626export interface ProviderAuthFlowContext {
2727 readonly flowId: string;
28 /** The lifetime timeout interrupts authenticate before publishing its expired result. */
29 readonly expiresAt: number;
30 readonly returnUrl?: string;
31 readonly callbackMode?: "server" | "client";
2832 readonly setInteraction: (
2933 interaction: ProviderAuthInteraction,
3034 respond?: (response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>,
35 complete?: (callbackUrl: string) => Effect.Effect<void, ProviderSetupError>,
3136 ) => Effect.Effect<void>;
3237 readonly verifying: Effect.Effect<void>;
3338}
......@@ -45,6 +50,7 @@ interface Flow {
4550 readonly id: string;
4651 readonly owner: string;
4752 readonly expiresAt: number;
53 complete?: ((callbackUrl: string) => Effect.Effect<void, ProviderSetupError>) | undefined;
4854 fiber?: Fiber.Fiber<void>;
4955 responseFiber?: Fiber.Fiber<void, ProviderSetupError>;
5056 respond:
......@@ -60,12 +66,14 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
6066 >;
6167 readonly methods: Effect.Effect<ReadonlyArray<ProviderAuthMethod>, ProviderSetupError>;
6268 readonly defaultMethodId?: string;
69 /** Stored account profiles can change the advertised methods after auth/logout. */
70 readonly refreshMethodsAfterAuth?: boolean;
6371 /** Fail with ProviderSetupError containing safe text for the user, never native token data. */
6472 readonly authenticate: (
6573 methodId: string,
6674 context: ProviderAuthFlowContext,
6775 ) => Effect.Effect<void, ProviderSetupError, Scope.Scope>;
68 readonly logout: Effect.Effect<void, ProviderSetupError>;
76 readonly logout: Effect.Effect<void | string, ProviderSetupError>;
6977 readonly timeoutMs?: number;
7078}) {
7179 const scope = yield* Scope.Scope;
......@@ -139,6 +147,40 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
139147
140148 const controller: ProviderAuthService.ProviderAuthController = {
141149 credentialBinding: options.credentialBinding,
150 adoptCredentials: (update, stopSessions) =>
151 Effect.gen(function* () {
152 yield* lock.withPermit(
153 Effect.gen(function* () {
154 if (operation !== "idle")
155 return yield* new ProviderSetupError({
156 instanceId: options.instanceId,
157 operation: "import",
158 detail: "Finish or cancel the existing sign-in first.",
159 });
160 operation = "stopping";
161 }),
162 );
163 const result = yield* Effect.gen(function* () {
164 yield* stopOwnedSessions;
165 yield* stopSessions;
166 yield* update;
167 yield* refreshMethods;
168 }).pipe(Effect.exit);
169 const state: ProviderAuthState = {
170 ...empty,
171 methods: snapshot.value.state.methods ?? [],
172 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
173 message: Exit.isSuccess(result) ? null : failureMessage(result.cause),
174 };
175 yield* lock.withPermit(
176 Effect.gen(function* () {
177 yield* SubscriptionRef.set(snapshot, { owner: null, state });
178 operation = "idle";
179 }),
180 );
181 if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause);
182 return state;
183 }).pipe(Effect.uninterruptible),
142184 refreshMethods,
143185 invalidate: lock.withPermit(
144186 Effect.gen(function* () {
......@@ -191,7 +233,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
191233 );
192234 }),
193235 ),
194 start: (owner, stopSessions = Effect.void, selectedMethodId) =>
236 start: (owner, stopSessions = Effect.void, selectedMethodId, returnUrl, callbackMode) =>
195237 lock.withPermit(
196238 Effect.gen(function* () {
197239 if (operation === "auth" && active?.owner === owner) return snapshot.value.state;
......@@ -241,10 +283,14 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
241283 yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions));
242284 yield* options.authenticate(methodId, {
243285 flowId: id,
244 setInteraction: (interaction, respond) =>
286 expiresAt: flow.expiresAt,
287 ...(returnUrl ? { returnUrl } : {}),
288 ...(callbackMode ? { callbackMode } : {}),
289 setInteraction: (interaction, respond, complete) =>
245290 Effect.gen(function* () {
246291 if (active !== flow) return;
247292 flow.respond = respond;
293 flow.complete = complete;
248294 yield* publish(flow, {
249295 phase: "waiting",
250296 interaction,
......@@ -257,6 +303,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
257303 }),
258304 verifying: Effect.gen(function* () {
259305 flow.respond = undefined;
306 flow.complete = undefined;
260307 yield* publish(flow, {
261308 phase: "verifying",
262309 interaction: null,
......@@ -293,6 +340,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
293340 yield* lock.withPermit(
294341 Effect.gen(function* () {
295342 if (active !== flow) return;
343 if (options.refreshMethodsAfterAuth) yield* refreshMethods;
296344 yield* publish(flow, {
297345 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
298346 interaction: null,
......@@ -363,12 +411,24 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
363411 return snapshot.value.state;
364412 }),
365413 ),
366 complete: () =>
367 Effect.fail(
368 new ProviderSetupError({
369 instanceId: options.instanceId,
370 operation: "complete",
371 detail: "This provider does not accept a pasted redirect URL.",
414 complete: (owner, input) =>
415 lock.withPermit(
416 Effect.gen(function* () {
417 const flow = yield* requireFlow(owner, input.flowId);
418 const interaction = snapshot.value.state.interaction;
419 if (
420 snapshot.value.state.phase !== "waiting" ||
421 interaction?.type !== "browser" ||
422 !interaction.acceptsCallback ||
423 !flow.complete
424 )
425 return yield* new ProviderSetupError({
426 instanceId: options.instanceId,
427 operation: "complete",
428 detail: "This sign-in does not accept a redirect URL.",
429 });
430 yield* flow.complete(input.callbackUrl);
431 return snapshot.value.state;
372432 }),
373433 ),
374434 cancel: (owner, id) =>
......@@ -413,13 +473,17 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
413473 if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
414474 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
415475 yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions));
416 yield* options.logout;
476 const message = yield* options.logout;
477 if (options.refreshMethodsAfterAuth) yield* refreshMethods;
478 return message;
417479 }).pipe(Effect.exit);
418480 const state: ProviderAuthState = {
419481 ...empty,
420482 methods: snapshot.value.state.methods ?? [],
421483 phase: Exit.isSuccess(result) ? "idle" : "failed",
422 message: Exit.isSuccess(result) ? "Signed out." : "Could not sign out. Try again.",
484 message: Exit.isSuccess(result)
485 ? (result.value ?? "Signed out.")
486 : "Could not sign out. Try again.",
423487 };
424488 yield* lock.withPermit(
425489 Effect.gen(function* () {
apps/server/src/provider/Services/ProviderAuthService.ts+21
......@@ -1,4 +1,6 @@
11import type {
2 ChatGptReconnectProfile,
3 ChatGptTransferredProfile,
24 ProviderAuthRespondInput,
35 ProviderAuthStartInput,
46 ProviderAuthState,
......@@ -13,6 +15,17 @@ import type * as Scope from "effect/Scope";
1315export interface ProviderAuthController {
1416 /** Equal keys mean these instances share credentials on this environment. */
1517 readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string };
18 readonly reconnectProfile?: (
19 methodId: string,
20 ) => Effect.Effect<ChatGptReconnectProfile | null, ProviderSetupError>;
21 readonly importProfile?: (
22 profile: ChatGptTransferredProfile,
23 stopSessions: Effect.Effect<void, ProviderSetupError>,
24 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
25 readonly adoptCredentials?: (
26 update: Effect.Effect<void, ProviderSetupError>,
27 stopSessions: Effect.Effect<void, ProviderSetupError>,
28 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
1629 readonly isChangingCredentials?: Effect.Effect<boolean>;
1730 readonly invalidate?: Effect.Effect<void>;
1831 readonly refreshMethods?: Effect.Effect<void>;
......@@ -23,6 +36,8 @@ export interface ProviderAuthController {
2336 ownerSessionId: string,
2437 stopSessions?: Effect.Effect<void, ProviderSetupError>,
2538 methodId?: string,
39 returnUrl?: string,
40 callbackMode?: "server" | "client",
2641 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
2742 readonly complete: (
2843 ownerSessionId: string,
......@@ -49,6 +64,12 @@ interface ProviderAuthTarget {
4964}
5065
5166export interface ProviderAuthServiceShape {
67 readonly reconnectProfile: (
68 input: ProviderAuthTarget & { methodId: string },
69 ) => Effect.Effect<ChatGptReconnectProfile | null, ProviderSetupError>;
70 readonly importProfile: (
71 input: ProviderAuthTarget & { profile: ChatGptTransferredProfile },
72 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
5273 readonly start: (
5374 input: ProviderAuthStartInput,
5475 ownerSessionId: string,
apps/server/src/provider/providerInstallation.test.ts+53-2
......@@ -13,6 +13,7 @@ import * as Path from "effect/Path";
1313import * as Stream from "effect/Stream";
1414
1515import { layerTest as settingsLayerTest } from "../serverSettings.ts";
16import { CodexInstallation } from "./CodexInstallation.ts";
1617import { AntigravityInstallation } from "./AntigravityInstallation.ts";
1718import type { ProviderInstance } from "./ProviderDriver.ts";
1819import { makeProviderInstallation } from "./providerInstallation.ts";
......@@ -33,9 +34,9 @@ const state: ProviderInstallState = {
3334 message: null,
3435};
3536
36function instance(kind = driver): ProviderInstance {
37function instance(kind = driver, id = instanceId): ProviderInstance {
3738 return {
38 instanceId,
39 instanceId: id,
3940 driverKind: kind,
4041 enabled: false,
4142 displayName: undefined,
......@@ -77,6 +78,25 @@ const makeHarness = Effect.fn("providerInstallation.test.makeHarness")(function*
7778 return [];
7879 }),
7980 }),
81 Layer.mock(CodexInstallation)({
82 managedDirectory: "/unused-managed-codex",
83 start: Effect.sync(() => {
84 calls.push("codex-start");
85 return { ...state, driver: ProviderDriverKind.make("codex") };
86 }),
87 cancel: () =>
88 Effect.sync(() => {
89 calls.push("codex-cancel");
90 return { ...state, driver: ProviderDriverKind.make("codex") };
91 }),
92 state: Effect.succeed({ ...state, driver: ProviderDriverKind.make("codex") }),
93 changes: Stream.succeed({ ...state, driver: ProviderDriverKind.make("codex") }),
94 remove: (paths) =>
95 Effect.sync(() => {
96 protectedPaths = paths ?? [];
97 calls.push("codex-remove");
98 }),
99 }),
80100 Layer.mock(AntigravityInstallation)({
81101 managedDirectory: "/unused-managed-runtime",
82102 start: Effect.sync(() => {
......@@ -139,6 +159,37 @@ describe("provider installation routing", () => {
139159 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
140160 );
141161
162 it.effect("routes a managed Codex instance through its own installer and refreshes removal", () =>
163 Effect.gen(function* () {
164 const codexId = ProviderInstanceId.make("codex");
165 const harness = yield* makeHarness({
166 instance: instance(ProviderDriverKind.make("codex"), codexId),
167 settings: { providers: { codex: { setupMode: "managed" } } },
168 });
169 assert.equal((yield* harness.router.start({ instanceId: codexId })).driver, "codex");
170 yield* harness.router.cancel({ instanceId: codexId, operationId: "operation" });
171 const observed = yield* Stream.runCollect(harness.router.subscribe({ instanceId: codexId }));
172 assert.equal(Array.from(observed)[0]?.driver, "codex");
173 yield* harness.router.remove({ instanceId: codexId });
174 assert.deepEqual(harness.calls, ["codex-start", "codex-cancel", "codex-remove", "refresh"]);
175 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
176 );
177
178 it.effect("keeps native Codex installation outside managed setup", () =>
179 Effect.gen(function* () {
180 const codexId = ProviderInstanceId.make("codex");
181 const harness = yield* makeHarness({
182 instance: instance(ProviderDriverKind.make("codex"), codexId),
183 settings: { providers: { codex: { setupMode: "existing" } } },
184 });
185 assert.include(
186 (yield* Effect.flip(harness.router.start({ instanceId: codexId }))).detail,
187 "Choose managed setup",
188 );
189 assert.deepEqual(harness.calls, []);
190 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
191 );
192
142193 it.effect("protects another instance's binary found through its own PATH", () =>
143194 Effect.gen(function* () {
144195 const fs = yield* FileSystem.FileSystem;
apps/server/src/provider/providerInstallation.ts+45-21
......@@ -1,5 +1,6 @@
11import {
22 AntigravitySettings,
3 CodexSettings,
34 ProviderDriverKind,
45 type ProviderInstallCancelInput,
56 type ProviderInstanceId,
......@@ -11,6 +12,7 @@ import * as Effect from "effect/Effect";
1112import * as Schema from "effect/Schema";
1213import * as Stream from "effect/Stream";
1314
15import { CodexInstallation, type CodexInstallationError } from "./CodexInstallation.ts";
1416import { ServerSettingsService } from "../serverSettings.ts";
1517import {
1618 AntigravityInstallation,
......@@ -23,11 +25,13 @@ import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.
2325
2426const ANTIGRAVITY = ProviderDriverKind.make("antigravity");
2527const hasBinaryPath = Schema.is(Schema.Struct({ binaryPath: Schema.String }));
28const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings);
2629const decodeAntigravitySettings = Schema.decodeUnknownEffect(AntigravitySettings);
2730
2831/** Route instance setup to the environment-owned installer without owning the download. */
2932export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(function* () {
30 const installation = yield* AntigravityInstallation;
33 const antigravityInstallation = yield* AntigravityInstallation;
34 const codexInstallation = yield* CodexInstallation;
3135 const instances = yield* ProviderInstanceRegistry;
3236 const providers = yield* ProviderRegistry;
3337 const settings = yield* ServerSettingsService;
......@@ -55,26 +59,37 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu
5559 managedOnly = false,
5660 ) {
5761 const instance = yield* instances.getInstance(instanceId);
58 if (instance?.driverKind !== ANTIGRAVITY) {
62 const isCodex = instance?.driverKind === ProviderDriverKind.make("codex");
63 if (instance?.driverKind !== ANTIGRAVITY && !isCodex) {
5964 return yield* new ProviderSetupError({
6065 instanceId,
6166 operation,
6267 detail: "Managed installation is not available for this provider instance.",
6368 });
6469 }
65 if (!managedOnly) return;
70 const installation = isCodex ? codexInstallation : antigravityInstallation;
6671 const entries = yield* readEntries(instanceId, operation);
67 const config = yield* decodeAntigravitySettings(entries[instanceId]?.config ?? {}).pipe(
68 Effect.mapError(
69 () =>
70 new ProviderSetupError({
71 instanceId,
72 operation,
73 detail: "The Antigravity instance configuration is invalid.",
74 }),
75 ),
76 );
77 if (config.binaryPath) {
72 const invalidConfig = () =>
73 new ProviderSetupError({
74 instanceId,
75 operation,
76 detail: "The provider instance configuration is invalid.",
77 });
78 const config = isCodex
79 ? yield* decodeCodexSettings(entries[instanceId]?.config ?? {}).pipe(
80 Effect.mapError(invalidConfig),
81 )
82 : yield* decodeAntigravitySettings(entries[instanceId]?.config ?? {}).pipe(
83 Effect.mapError(invalidConfig),
84 );
85 if (isCodex && (!("setupMode" in config) || config.setupMode !== "managed")) {
86 return yield* new ProviderSetupError({
87 instanceId,
88 operation,
89 detail: "Choose managed setup to install Codex in T3 Code.",
90 });
91 }
92 if (managedOnly && config.binaryPath && (!isCodex || config.binaryPath !== "codex")) {
7893 return yield* new ProviderSetupError({
7994 instanceId,
8095 operation,
......@@ -82,20 +97,23 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu
8297 "This instance uses a custom executable. Clear its binary path to manage installation in T3 Code.",
8398 });
8499 }
100 return { installation, driver: instance.driverKind };
85101 });
86102
87 const failure = (instanceId: ProviderInstanceId) => (error: AntigravityInstallationError) =>
88 new ProviderSetupError({ instanceId, operation: error.operation, detail: error.detail });
103 const failure =
104 (instanceId: ProviderInstanceId) =>
105 (error: AntigravityInstallationError | CodexInstallationError) =>
106 new ProviderSetupError({ instanceId, operation: error.operation, detail: error.detail });
89107
90108 const start = Effect.fn("ProviderInstallation.start")(function* (input: ProviderSetupInput) {
91 yield* requireInstance(input.instanceId, "install", true);
109 const { installation } = yield* requireInstance(input.instanceId, "install", true);
92110 return yield* installation.start.pipe(Effect.mapError(failure(input.instanceId)));
93111 });
94112
95113 const cancel = Effect.fn("ProviderInstallation.cancel")(function* (
96114 input: ProviderInstallCancelInput,
97115 ) {
98 yield* requireInstance(input.instanceId, "cancel-install");
116 const { installation } = yield* requireInstance(input.instanceId, "cancel-install");
99117 return yield* installation
100118 .cancel(input.operationId)
101119 .pipe(Effect.mapError(failure(input.instanceId)));
......@@ -103,11 +121,17 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu
103121
104122 const subscribe = (input: ProviderSetupInput) =>
105123 Stream.unwrap(
106 requireInstance(input.instanceId, "observe-install").pipe(Effect.as(installation.changes)),
124 requireInstance(input.instanceId, "observe-install").pipe(
125 Effect.map(({ installation }) => installation.changes),
126 ),
107127 );
108128
109129 const remove = Effect.fn("ProviderInstallation.remove")(function* (input: ProviderSetupInput) {
110 yield* requireInstance(input.instanceId, "remove-install", true);
130 const { installation, driver } = yield* requireInstance(
131 input.instanceId,
132 "remove-install",
133 true,
134 );
111135 const entries = yield* readEntries(input.instanceId, "remove-install");
112136 const protectedPaths = yield* Effect.forEach(Object.values(entries), (entry) => {
113137 if (!hasBinaryPath(entry.config) || !entry.config.binaryPath.trim()) {
......@@ -126,7 +150,7 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu
126150 .pipe(Effect.mapError(failure(input.instanceId)));
127151 const allInstances = yield* instances.listInstances;
128152 yield* Effect.forEach(
129 allInstances.filter((instance) => instance.driverKind === ANTIGRAVITY),
153 allInstances.filter((instance) => instance.driverKind === driver),
130154 (instance) => providers.refreshInstance(instance.instanceId),
131155 { discard: true },
132156 );
apps/server/src/server.test.ts+6
......@@ -140,6 +140,7 @@ import {
140140 AntigravityInstallation,
141141 AntigravityInstallationError,
142142} from "./provider/AntigravityInstallation.ts";
143import { CodexInstallation } from "./provider/CodexInstallation.ts";
143144import type { ProviderInstance } from "./provider/ProviderDriver.ts";
144145import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";
145146import { ProviderAdapterRequestError } from "./provider/Errors.ts";
......@@ -529,6 +530,7 @@ const buildAppUnderTest = (options?: {
529530 providerAuth?: Partial<ProviderAuthService["Service"]>;
530531 providerInstanceRegistry?: Partial<ProviderInstanceRegistry["Service"]>;
531532 antigravityInstallation?: Partial<AntigravityInstallation["Service"]>;
533 codexInstallation?: Partial<CodexInstallation["Service"]>;
532534 serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>;
533535 externalLauncher?: Partial<ExternalLauncher.ExternalLauncher["Service"]>;
534536 vcsDriver?: Partial<VcsDriver.VcsDriver["Service"]>;
......@@ -828,6 +830,10 @@ const buildAppUnderTest = (options?: {
828830 listInstances: Effect.succeed([]),
829831 ...options?.layers?.providerInstanceRegistry,
830832 }),
833 Layer.mock(CodexInstallation)({
834 managedDirectory: "unused-test-codex-runtime",
835 ...options?.layers?.codexInstallation,
836 }),
831837 Layer.mock(AntigravityInstallation)({
832838 managedDirectory: "unused-test-antigravity-runtime",
833839 ...options?.layers?.antigravityInstallation,
apps/server/src/server.ts+18-12
......@@ -55,6 +55,7 @@ import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinato
5555import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";
5656import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";
5757import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts";
58import { CodexInstallation } from "./provider/CodexInstallation.ts";
5859import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts";
5960import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";
6061import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";
......@@ -475,22 +476,27 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(
475476 Layer.provideMerge(OrchestrationLayerLive),
476477);
477478
478const AntigravityInstallationRefreshLive = Layer.effectDiscard(
479const ProviderInstallationRefreshLive = Layer.effectDiscard(
479480 Effect.gen(function* () {
480 const installation = yield* AntigravityInstallation;
481 const antigravity = yield* AntigravityInstallation;
482 const codex = yield* CodexInstallation;
481483 const instances = yield* ProviderInstanceRegistry;
482484 const providers = yield* ProviderRegistry;
483 yield* installation.changes.pipe(
484 Stream.map((state) => state.installedVersion),
485 Stream.changes,
486 Stream.drop(1),
487 Stream.runForEach(() =>
485 yield* Stream.merge(
486 antigravity.changes.pipe(
487 Stream.changesWith((a, b) => a.installedVersion === b.installedVersion),
488 Stream.drop(1),
489 ),
490 codex.changes.pipe(
491 Stream.changesWith((a, b) => a.installedVersion === b.installedVersion),
492 Stream.drop(1),
493 ),
494 ).pipe(
495 Stream.runForEach((state) =>
488496 instances.listInstances.pipe(
489497 Effect.flatMap((entries) =>
490498 Effect.forEach(
491 entries.filter(
492 (instance) => instance.driverKind === ProviderDriverKind.make("antigravity"),
493 ),
499 entries.filter((instance) => instance.driverKind === state.driver),
494500 (instance) => providers.refreshInstance(instance.instanceId),
495501 { discard: true },
496502 ),
......@@ -503,7 +509,7 @@ const AntigravityInstallationRefreshLive = Layer.effectDiscard(
503509);
504510
505511const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
506 Layer.provideMerge(AntigravityInstallationRefreshLive),
512 Layer.provideMerge(ProviderInstallationRefreshLive),
507513 Layer.provideMerge(ReplayMarkers.layer),
508514 Layer.provideMerge(ProviderAuthServiceLive),
509515 // Core Services
......@@ -532,7 +538,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
532538 // with explicit `providerInstances` entries on boot.
533539 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
534540).pipe(
535 Layer.provideMerge(AntigravityInstallation.layer),
541 Layer.provideMerge(Layer.mergeAll(AntigravityInstallation.layer, CodexInstallation.layer)),
536542 // Shared native/canonical NDJSON writers used by both the per-instance
537543 // drivers (native stream, written from inside each `<X>Adapter`) and
538544 // `ProviderService` (canonical stream, written after event normalization).
apps/server/src/textGeneration/CodexTextGeneration.test.ts+28
......@@ -135,6 +135,7 @@ function withFakeCodexEnv<A, E, R>(
135135 launchArgs?: string;
136136 environment?: NodeJS.ProcessEnv;
137137 models?: ReadonlyArray<string>;
138 managedRuntime?: boolean;
138139 },
139140 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
140141) {
......@@ -154,6 +155,13 @@ function withFakeCodexEnv<A, E, R>(
154155 capabilities: null,
155156 })),
156157 ),
158 input.managedRuntime
159 ? Effect.succeed({
160 config,
161 environment: input.environment ?? process.env,
162 revision: "test",
163 })
164 : undefined,
157165 );
158166 return yield* effectFn(textGeneration);
159167 }).pipe(Effect.scoped);
......@@ -236,6 +244,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
236244 ),
237245 );
238246
247 it.effect("omits a persisted service tier for managed ChatGPT text generation", () =>
248 withFakeCodexEnv(
249 {
250 output: JSON.stringify({ subject: "Update project", body: "" }),
251 managedRuntime: true,
252 forbidArg: 'service_tier="priority"',
253 },
254 (textGeneration) =>
255 textGeneration.generateCommitMessage({
256 cwd: process.cwd(),
257 branch: "feature/chatgpt",
258 stagedSummary: "M README.md",
259 stagedPatch: "diff --git a/README.md b/README.md",
260 modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
261 { id: "serviceTier", value: "priority" },
262 ]),
263 }),
264 ),
265 );
266
239267 it.effect("passes exec-safe launch args into codex exec", () =>
240268 withFakeCodexEnv(
241269 {
apps/server/src/textGeneration/CodexTextGeneration.ts+22-6
......@@ -48,6 +48,11 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
4848 codexConfig: CodexSettings,
4949 environment?: NodeJS.ProcessEnv,
5050 getModels: Effect.Effect<ReadonlyArray<ServerProviderModel>> = Effect.succeed([]),
51 resolveRuntime?: Effect.Effect<
52 import("../provider/CodexManagedRuntime.ts").CodexEffectiveRuntime,
53 import("@t3tools/contracts").ProviderSetupError,
54 Scope.Scope
55 >,
5156) {
5257 const fileSystem = yield* FileSystem.FileSystem;
5358 const path = yield* Path.Path;
......@@ -180,6 +185,15 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
180185 const outputPath = yield* writeTempFile(operation, "codex-output", "");
181186
182187 const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () {
188 const resolved = resolveRuntime
189 ? yield* resolveRuntime.pipe(
190 Effect.mapError(
191 (cause) => new TextGenerationError({ operation, detail: cause.detail }),
192 ),
193 )
194 : undefined;
195 const effectiveConfig = resolved?.config ?? codexConfig;
196 const effectiveEnvironment = resolved?.environment ?? resolvedEnvironment;
183197 const models = yield* getModels;
184198 const requestedModel = modelSelection.model;
185199 const model =
......@@ -188,13 +202,13 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
188202 (candidate) => !candidate.isCustom && codexModelFamily(candidate.slug) === requestedModel,
189203 )?.slug ??
190204 requestedModel;
191 const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment);
205 const launchArgs = resolveCodexLaunchArgs(effectiveConfig.launchArgs, effectiveEnvironment);
192206 const reasoningEffort =
193207 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
194208 DEFAULT_TEXT_GENERATION_REASONING_EFFORT;
195 const serviceTier = getCodexServiceTierOptionValue(modelSelection);
209 const serviceTier = resolved ? undefined : getCodexServiceTierOptionValue(modelSelection);
196210 const spawnCommand = yield* resolveSpawnCommand(
197 codexConfig.binaryPath || "codex",
211 effectiveConfig.binaryPath || "codex",
198212 [
199213 "exec",
200214 ...codexExecLaunchArgs(launchArgs),
......@@ -214,12 +228,14 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
214228 ...imagePaths.flatMap((imagePath) => ["--image", imagePath]),
215229 "-",
216230 ],
217 { env: resolvedEnvironment },
231 { env: effectiveEnvironment },
218232 );
219233 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
220234 env: {
221 ...resolvedEnvironment,
222 ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}),
235 ...effectiveEnvironment,
236 ...(effectiveConfig.homePath
237 ? { CODEX_HOME: expandHomePath(effectiveConfig.homePath) }
238 : {}),
223239 },
224240 cwd,
225241 shell: spawnCommand.shell,
apps/server/src/usage/UsageService.test.ts+89
......@@ -121,6 +121,95 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens
121121}
122122
123123describe("UsageService", () => {
124 for (const explicitDefault of [true, false]) {
125 it.live(
126 `reads shared managed ${explicitDefault ? "explicit" : "legacy"} default and disabled extra account history once`,
127 () =>
128 Effect.gen(function* () {
129 const { home, settings } = yield* setup;
130 const summary = yield* Effect.gen(function* () {
131 for (const [id, output] of [
132 ["codex", 17],
133 ["codex-personal", 23],
134 ] as const) {
135 const sessions = NodePath.join(home, "shared-codex", "sessions");
136 yield* Effect.promise(async () => {
137 await NodeFSP.mkdir(sessions, { recursive: true });
138 await NodeFSP.writeFile(
139 NodePath.join(sessions, `${id}-rollout.jsonl`),
140 [
141 { type: "session_meta", payload: { id } },
142 { type: "turn_context", payload: { model: "gpt-5.6-sol" } },
143 {
144 type: "event_msg",
145 timestamp: "2026-08-01T10:00:00Z",
146 payload: {
147 type: "token_count",
148 info: { last_token_usage: { input_tokens: 10, output_tokens: output } },
149 },
150 },
151 ]
152 .map((line) => encodeUnknownJsonString(line))
153 .join("\n") + "\n",
154 );
155 });
156 }
157 const service = yield* UsageService.make;
158 return yield* service.readSummary(WINDOW);
159 }).pipe(
160 Effect.provide(
161 serviceLayers({
162 prefix: "usage-managed-accounts",
163 home,
164 settings: {
165 ...settings,
166 providers: {
167 ...settings.providers,
168 codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") },
169 },
170 providerInstances: {
171 ...(explicitDefault
172 ? {
173 [ProviderInstanceId.make("codex")]: {
174 driver: ProviderDriverKind.make("codex"),
175 config: {
176 setupMode: "managed",
177 homePath: NodePath.join(home, "shared-codex"),
178 },
179 },
180 }
181 : {}),
182 [ProviderInstanceId.make("codex-personal")]: {
183 driver: ProviderDriverKind.make("codex"),
184 enabled: false,
185 config: {
186 setupMode: "managed",
187 homePath: NodePath.join(home, "shared-codex"),
188 shadowHomePath: NodePath.join(home, "personal-shadow"),
189 },
190 environment: [
191 {
192 name: "CODEX_HOME",
193 value: NodePath.join(home, "ignored-environment"),
194 sensitive: false,
195 },
196 ],
197 },
198 },
199 },
200 }),
201 ),
202 );
203 assert.strictEqual(totalOutputTokens(summary), 40);
204 assert.strictEqual(
205 summary.sources.filter(
206 (source) => source.fingerprint.provider === "codex" && source.status === "ok",
207 ).length,
208 1,
209 );
210 }).pipe(Effect.scoped),
211 );
212 }
124213 it.live("omits Cursor account usage when no file login is saved", () =>
125214 Effect.gen(function* () {
126215 const { settings, home } = yield* setup;
apps/server/src/usage/UsageService.ts+17-8
......@@ -18,8 +18,8 @@ import {
1818 ClaudeSettings,
1919 CodexSettings,
2020 type ProviderInstanceConfig,
21 USAGE_CONTRACT_VERSION,
2221 ProviderInstanceId,
22 USAGE_CONTRACT_VERSION,
2323 type ServerSettings as ServerSettingsValue,
2424 type UsageProviderKind,
2525 type UsageSource,
......@@ -271,10 +271,16 @@ export const make = Effect.gen(function* () {
271271 for (const driver of ["claudeAgent", "codex", "grok"] as const) {
272272 // Disabled accounts still have history. Explicit default slots replace
273273 // the legacy settings, just as they do in the provider registry.
274 const instances: Array<Pick<ProviderInstanceConfig, "config" | "environment">> =
275 Object.values(settings.providerInstances).filter((instance) => instance.driver === driver);
274 const instances: Array<
275 Pick<ProviderInstanceConfig, "config" | "environment"> & { instanceId: ProviderInstanceId }
276 > = Object.entries(settings.providerInstances)
277 .filter(([, instance]) => instance.driver === driver)
278 .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) }));
276279 if (!Object.hasOwn(settings.providerInstances, driver)) {
277 instances.push({ config: settings.providers[driver] });
280 instances.push({
281 config: settings.providers[driver],
282 instanceId: ProviderInstanceId.make(driver),
283 });
278284 }
279285 for (const instance of instances) {
280286 const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment);
......@@ -283,12 +289,15 @@ export const make = Effect.gen(function* () {
283289 if (driver === "codex") {
284290 const decoded = decodeCodexSettings(instance.config ?? {});
285291 if (Option.isNone(decoded)) continue;
286 const config = decoded.value;
292 const codexConfig = decoded.value;
287293 const environmentHome = environment.CODEX_HOME?.trim();
288294 const layout = yield* resolveCodexHomeLayout(
289 !config.homePath.trim() && !config.shadowHomePath.trim() && environmentHome
290 ? { ...config, homePath: environmentHome }
291 : config,
295 codexConfig.setupMode !== "managed" &&
296 !codexConfig.homePath.trim() &&
297 !codexConfig.shadowHomePath.trim() &&
298 environmentHome
299 ? { ...codexConfig, homePath: environmentHome }
300 : codexConfig,
292301 );
293302 home = layout.sharedHomePath;
294303 } else if (driver === "claudeAgent") {
apps/server/src/ws.ts+14
......@@ -17,6 +17,8 @@ import * as Ref from "effect/Ref";
1717import * as Schedule from "effect/Schedule";
1818import * as Schema from "effect/Schema";
1919import * as Stream from "effect/Stream";
20import { subscribeChatGptHandoff } from "./provider/CodexChatGptHandoff.ts";
21import { subscribeCodexAuthCallback } from "./provider/CodexAuthCallback.ts";
2022import {
2123 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
2224 AuthAccessStreamError,
......@@ -2508,6 +2510,18 @@ const makeWsRpcLayer = (
25082510 providerAuth.complete(input, currentSessionId),
25092511 { "rpc.aggregate": "provider" },
25102512 ),
2513 [WS_METHODS.chatGptReconnectProfile]: (input) => providerAuth.reconnectProfile(input),
2514 [WS_METHODS.chatGptImportProfile]: (input) => providerAuth.importProfile(input),
2515 [WS_METHODS.chatGptHandoffSubscribe]: (input) =>
2516 subscribeChatGptHandoff(input, currentSessionId),
2517 [WS_METHODS.codexAuthCallbackSubscribe]: (input) =>
2518 observeRpcStream(
2519 WS_METHODS.codexAuthCallbackSubscribe,
2520 subscribeCodexAuthCallback(input),
2521 {
2522 "rpc.aggregate": "provider",
2523 },
2524 ),
25112525 [WS_METHODS.providerAuthCancel]: (input) =>
25122526 observeRpcEffect(
25132527 WS_METHODS.providerAuthCancel,
apps/web/src/components/ChatView.tsx+2
......@@ -1,3 +1,4 @@
1import { isChatGptUsageLimitError } from "@t3tools/shared/usageLimits";
12import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment";
23import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
34import type { UsageLimitSourceSnapshots } from "@t3tools/contracts";
......@@ -9809,6 +9810,7 @@ export default function ChatView(props: ChatViewProps) {
98099810 />
98109811 <ThreadErrorBanner
98119812 error={visibleThreadError}
9813 chatGptUsageLimit={isChatGptUsageLimitError(threadActivities, visibleThreadError)}
98129814 onDismiss={() => {
98139815 setThreadError(activeThread.id, null);
98149816 dismissThreadErrorBannerForSession(threadErrorBannerKey);
apps/web/src/components/Icons.tsx+1-1
......@@ -548,7 +548,7 @@ export const OpenAI: Icon = ({ className, ...props }) => (
548548 {...props}
549549 preserveAspectRatio="xMidYMid"
550550 viewBox="100 100 411 411"
551 className={cn("fill-black dark:fill-white", className)}
551 className={cn("fill-current", className)}
552552 >
553553 <path
554554 fillRule="evenodd"
apps/web/src/components/chat/ChatGptSharingControl.tsx created+17
......@@ -0,0 +1,17 @@
1import { usesChatGptSharing } from "@t3tools/shared/usageLimits";
2import type { ServerProvider } from "@t3tools/contracts";
3import { OpenAI } from "../Icons";
4import { ChatGptUsageButton } from "../settings/ChatGptUsageButton";
5
6export function ChatGptSharingControl({ provider }: { provider: ServerProvider | null }) {
7 if (!usesChatGptSharing(provider)) return null;
8 return (
9 <div className="flex items-center justify-between gap-4 border-t px-3 py-2">
10 <span className="flex items-center gap-2 text-xs text-muted-foreground">
11 <OpenAI className="size-3.5" aria-hidden="true" />
12 Using ChatGPT plan
13 </span>
14 <ChatGptUsageButton size="xs" />
15 </div>
16 );
17}
apps/web/src/components/chat/ComposerUsageLimits.tsx+13
......@@ -2,6 +2,8 @@ import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts";
22import { limitsNotice } from "@t3tools/shared/usageLimits";
33import { GaugeIcon } from "lucide-react";
44
5import { ensureLocalApi } from "../../localApi";
6import { Button } from "../ui/button";
57import { getDriverOption } from "../settings/providerDriverMeta";
68import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";
79import { LimitWindows, ResetCredits } from "../usage/UsageLimits";
......@@ -85,6 +87,7 @@ function UsageLimitsBannerBody({
8587 account.resetCreditInput ??
8688 (account.instanceId ? { instanceId: account.instanceId } : undefined);
8789 const notice = limitsNotice(account.limits);
90 const externalUsage = account.limits.externalUsage;
8891 return (
8992 <div key={account.id} className="flex min-w-0 flex-col gap-1">
9093 {report.accounts.length > 1 ? (
......@@ -102,6 +105,16 @@ function UsageLimitsBannerBody({
102105 now={now}
103106 />
104107 )}
108 {externalUsage ? (
109 <Button
110 size="sm"
111 variant="link"
112 className="self-start"
113 onClick={() => void ensureLocalApi().shell.openExternal(externalUsage.url)}
114 >
115 Manage usage
116 </Button>
117 ) : null}
105118 {resetCreditInput && account.limits.resetCredits ? (
106119 <ResetCredits
107120 environmentId={environmentId}
apps/web/src/components/chat/ProviderModelPicker.tsx+4
......@@ -10,6 +10,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
1010import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1111import { cn } from "~/lib/utils";
1212import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent";
13import { ChatGptSharingControl } from "./ChatGptSharingControl";
1314import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
1415import {
1516 ModelEsque,
......@@ -308,6 +309,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
308309 : {})}
309310 onInstanceModelChange={handleInstanceModelChange}
310311 />
312 {props.selectedModels === undefined ? (
313 <ChatGptSharingControl provider={activeEntry?.snapshot ?? null} />
314 ) : null}
311315 </PopoverPopup>
312316 </Popover>
313317 );
apps/web/src/components/chat/ThreadErrorBanner.tsx+29-11
......@@ -3,6 +3,8 @@ import { Alert, AlertAction, AlertDescription } from "../ui/alert";
33import { Button } from "../ui/button";
44import { CircleAlertIcon, XIcon } from "lucide-react";
55import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
6import { OpenAI } from "../Icons";
7import { ChatGptUsageButton } from "../settings/ChatGptUsageButton";
68
79export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null {
810 return error === null ? null : `${threadKey}\u0000${error}`;
......@@ -36,28 +38,44 @@ export function isThreadErrorBannerDismissedForSession(bannerKey: string | null)
3638export const ThreadErrorBanner = memo(function ThreadErrorBanner({
3739 error,
3840 onDismiss,
41 chatGptUsageLimit = false,
3942}: {
4043 error: string | null;
4144 onDismiss?: () => void;
45 chatGptUsageLimit?: boolean;
4246}) {
4347 if (!error) return null;
4448 return (
4549 <div className="pointer-events-auto mx-auto w-fit max-w-[min(48rem,calc(100%-2rem))] pt-3">
4650 <Alert variant="error" surface="glass" controlAlignment="first-line">
47 <CircleAlertIcon />
51 {chatGptUsageLimit ? (
52 <OpenAI className="size-4 text-foreground!" aria-hidden="true" />
53 ) : (
54 <CircleAlertIcon />
55 )}
4856 <AlertDescription>
49 <Tooltip>
50 <TooltipTrigger render={<div className="line-clamp-3" />}>{error}</TooltipTrigger>
51 <TooltipPopup side="top" className="whitespace-pre-wrap">
52 {error}
53 </TooltipPopup>
54 </Tooltip>
57 {chatGptUsageLimit ? (
58 <div className="space-y-1">
59 <p className="font-medium">ChatGPT usage limit reached</p>
60 <p>Review your usage settings in ChatGPT to continue.</p>
61 </div>
62 ) : (
63 <Tooltip>
64 <TooltipTrigger render={<div className="line-clamp-3" />}>{error}</TooltipTrigger>
65 <TooltipPopup side="top" className="whitespace-pre-wrap">
66 {error}
67 </TooltipPopup>
68 </Tooltip>
69 )}
5570 </AlertDescription>
56 {onDismiss && (
71 {(chatGptUsageLimit || onDismiss) && (
5772 <AlertAction>
58 <Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={onDismiss}>
59 <XIcon className="text-destructive" />
60 </Button>
73 {chatGptUsageLimit ? <ChatGptUsageButton variant="default" size="sm" /> : null}
74 {onDismiss ? (
75 <Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={onDismiss}>
76 <XIcon className="text-destructive" />
77 </Button>
78 ) : null}
6179 </AlertAction>
6280 )}
6381 </Alert>
apps/web/src/components/onboarding/WelcomeWizard.test.tsx+5
......@@ -74,6 +74,11 @@ vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} }));
7474vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() }));
7575vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false }));
7676vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null }));
77vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null }));
78vi.mock("../settings/CodexSetupSection", () => ({
79 CodexSetupSection: () => null,
80 AddManagedCodexAccountDialog: () => null,
81}));
7782vi.mock("../cloud/CloudEnvironmentConnectList", () => ({
7883 CloudEnvironmentConnectRows: () => null,
7984}));
apps/web/src/components/onboarding/WelcomeWizard.tsx+228-51
......@@ -4,6 +4,7 @@ import type {
44 AgentSessionProjectCandidate,
55 EnvironmentId,
66 ProjectId,
7 ProviderInstanceId,
78 ScopedProjectRef,
89 ServerConfig,
910 ServerProvider,
......@@ -13,7 +14,12 @@ import {
1314 isAtomCommandInterrupted,
1415 squashAtomCommandFailure,
1516} from "@t3tools/client-runtime/state/runtime";
16import { CommandId, ProviderDriverKind, ThreadId } from "@t3tools/contracts";
17import {
18 CommandId,
19 defaultInstanceIdForDriver,
20 ProviderDriverKind,
21 ThreadId,
22} from "@t3tools/contracts";
1723import * as Schema from "effect/Schema";
1824import {
1925 ArrowRightIcon,
......@@ -60,6 +66,10 @@ import { useAtomCommand } from "../../state/use-atom-command";
6066import { connectPairing } from "../../connection/onboarding";
6167import { getProviderSummary } from "../settings/providerStatus";
6268import { getDriverOption } from "../settings/providerDriverMeta";
69import { ChatGptWelcomeCoordinator } from "../settings/ChatGptWelcomeCoordinator";
70import { AddManagedCodexAccountDialog, CodexSetupSection } from "../settings/CodexSetupSection";
71import { readCodexSetupMode } from "../settings/CodexSetupSection.logic";
72import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic";
6373import { TerminalViewport } from "../ThreadTerminalDrawer";
6474import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
6575import { ClaudeAI, OpenAI } from "../Icons";
......@@ -81,7 +91,7 @@ import { formatRelativeTime } from "../../timestampFormat";
8191 * First-run welcome wizard. Rendered over the workspace at `/welcome` on a
8292 * fresh install (no completed-onboarding flag, empty workspace). Flow per the
8393 * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) →
84 * agent setup with inline install terminal → project import → main screen.
94 * managed Codex setup or an inline CLI terminal → project import → main screen.
8595 * Every step past the connection gate is skippable; the whole wizard is
8696 * re-runnable by clearing the flag.
8797 */
......@@ -96,17 +106,21 @@ const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations m
96106export function WelcomeWizard({
97107 localAvailable,
98108 onDone,
109 resumeEnvironmentId,
99110}: {
100111 /** Whether this client is authenticated to the server serving the app. */
101112 readonly localAvailable: boolean;
113 readonly resumeEnvironmentId?: EnvironmentId | undefined;
102114 readonly onDone: (projectRef?: ScopedProjectRef) => void | Promise<void>;
103115}) {
104116 const completeOnboarding = useCompleteOnboarding();
105 const [step, setStep] = useState<WizardStep>("connection");
117 const [step, setStep] = useState<WizardStep>(resumeEnvironmentId ? "agents" : "connection");
106118 const { environments } = useEnvironments();
107119 const [selection, setSelection] = useState<ReadonlySet<EnvironmentId> | null>(null);
108120 const autoSelectedComputers = useRef(new Set<EnvironmentId>());
109 const [setupIds, setSetupIds] = useState<readonly EnvironmentId[]>([]);
121 const [setupIds, setSetupIds] = useState<readonly EnvironmentId[]>(
122 resumeEnvironmentId ? [resumeEnvironmentId] : [],
123 );
110124 const [isImporting, setIsImporting] = useState(false);
111125 const finishingPromiseRef = useRef<Promise<boolean> | null>(null);
112126 const completionErrorToastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null);
......@@ -197,6 +211,7 @@ export function WelcomeWizard({
197211 return (
198212 <Dialog open disablePointerDismissal onOpenChange={(_, event) => event.cancel()}>
199213 <WizardPopup
214 size="wide"
200215 bottomStickOnMobile={false}
201216 showCloseButton={false}
202217 initialFocus={() => document.getElementById("onboarding-pairing-url") ?? true}
......@@ -261,6 +276,7 @@ export function WelcomeWizard({
261276 )}
262277 </WizardPanel>
263278 </WizardPopup>
279 <ChatGptWelcomeCoordinator />
264280 </Dialog>
265281 );
266282}
......@@ -613,7 +629,7 @@ function PairingForm({
613629
614630// ── Step 3: agents ───────────────────────────────────────────
615631
616const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const;
632const PRIMARY_AGENT_DRIVERS = ["codex", "claudeAgent"] as const;
617633type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number];
618634
619635/** Setup values stay fixed while provider probes refresh the surrounding cards. */
......@@ -626,13 +642,7 @@ interface AgentTerminalSession {
626642 readonly keybindings: ServerConfig["keybindings"];
627643}
628644
629/**
630 * Claude Code and Codex use live probe status. Install opens the built-in
631 * terminal inline with the vendor's standalone installer pre-typed. The update
632 * RPC can't install a binary that isn't there yet (it infers the installer from
633 * the installed binary's path), and the terminal also handles the interactive
634 * login that follows.
635 */
645/** Codex uses managed setup; existing CLI installs retain the terminal path. */
636646function AgentsStep({
637647 environmentIds,
638648 onContinue,
......@@ -642,8 +652,11 @@ function AgentsStep({
642652}) {
643653 const { environments } = useEnvironments();
644654 return (
645 <StepShell title="Your agents" description="Agents available on your selected computers.">
646 <ScrollArea scrollFade className="mt-5 h-auto max-h-96">
655 <StepShell
656 title="Connect your agents"
657 description="Choose an agent to start coding. You can add more later."
658 >
659 <ScrollArea scrollFade className="mt-5 h-auto max-h-[min(32rem,55dvh)]">
647660 <div className="space-y-5 pr-3">
648661 {environmentIds.map((environmentId) => (
649662 <ConnectedAgentsStep
......@@ -680,6 +693,12 @@ function ConnectedAgentsStep({
680693 });
681694 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
682695 const [terminalSession, setTerminalSession] = useState<AgentTerminalSession | null>(null);
696 const [addingAccount, setAddingAccount] = useState(false);
697 const [createdAccount, setCreatedAccount] = useState<{
698 instanceId: ProviderInstanceId;
699 displayName: string;
700 autoStart: boolean;
701 } | null>(null);
683702
684703 // Re-probe on entry so freshly installed CLIs show up without a manual
685704 // refresh; harmless when nothing changed (single-flighted per environment).
......@@ -689,44 +708,114 @@ function ConnectedAgentsStep({
689708
690709 const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]);
691710
692 const primaryAgents = PRIMARY_AGENT_DRIVERS.map((driver) => ({
693 driver,
694 provider: byDriver.get(driver),
695 }));
711 const primaryAgents = PRIMARY_AGENT_DRIVERS.flatMap((driver) => {
712 const instances =
713 driver === "codex" ? providers?.filter((provider) => provider.driver === driver) : undefined;
714 return instances?.length
715 ? instances.map((provider) => ({ driver, provider, instanceId: provider.instanceId }))
716 : [{ driver, provider: byDriver.get(driver), instanceId: byDriver.get(driver)?.instanceId }];
717 });
718 // Keep the newly created row mounted while settings and provider snapshots catch up.
719 if (createdAccount) {
720 const index = primaryAgents.findIndex(
721 (agent) => agent.instanceId === createdAccount.instanceId,
722 );
723 const [existing] = index >= 0 ? primaryAgents.splice(index, 1) : [];
724 primaryAgents.unshift(
725 existing ?? {
726 driver: "codex",
727 provider: undefined,
728 instanceId: createdAccount.instanceId,
729 },
730 );
731 }
696732 return (
697733 <section>
698734 <h2 className="mb-2 text-sm font-medium">{machineLabel}</h2>
699735 <div className="space-y-1.5">
700 {primaryAgents.map(({ driver, provider }) => (
701 <AgentCard
702 key={driver}
703 driver={driver}
704 provider={provider}
705 terminalOpen={terminalSession?.driver === driver}
706 terminalAvailable={serverConfig !== null}
707 onOpenTerminal={() => {
708 if (provider === undefined || serverConfig === null) return;
709 setTerminalSession({
710 environmentId,
711 driver,
712 providerInstanceId: provider.instanceId,
713 cwd: serverConfig.cwd,
714 command: provider.installed
715 ? resolveOnboardingProviderLoginCommand(
716 provider,
717 serverConfig.settings,
718 serverConfig.environment.platform.os,
719 )
720 : resolveOnboardingProviderInstallCommand(
721 driver,
722 serverConfig.environment.platform.os,
723 ),
724 keybindings: serverConfig.keybindings,
725 });
726 }}
727 />
728 ))}
736 {primaryAgents.map(({ driver, provider, instanceId }) =>
737 driver === "codex" && serverConfig !== null ? (
738 <OnboardingCodexSetup
739 key={instanceId ?? driver}
740 environmentId={environmentId}
741 provider={provider}
742 serverConfig={serverConfig}
743 createdAccount={instanceId === createdAccount?.instanceId ? createdAccount : null}
744 onAutoStartConsumed={() =>
745 setCreatedAccount((account) => (account ? { ...account, autoStart: false } : null))
746 }
747 terminalOpen={terminalSession?.driver === driver}
748 onOpenTerminal={() => {
749 if (provider === undefined) return;
750 setTerminalSession({
751 environmentId,
752 driver,
753 providerInstanceId: provider.instanceId,
754 cwd: serverConfig.cwd,
755 command: provider.installed
756 ? resolveOnboardingProviderLoginCommand(
757 provider,
758 serverConfig.settings,
759 serverConfig.environment.platform.os,
760 )
761 : resolveOnboardingProviderInstallCommand(
762 driver,
763 serverConfig.environment.platform.os,
764 ),
765 keybindings: serverConfig.keybindings,
766 });
767 }}
768 />
769 ) : (
770 <AgentCard
771 key={driver}
772 driver={driver}
773 provider={provider}
774 terminalOpen={terminalSession?.driver === driver}
775 terminalAvailable={serverConfig !== null}
776 onOpenTerminal={() => {
777 if (provider === undefined || serverConfig === null) return;
778 setTerminalSession({
779 environmentId,
780 driver,
781 providerInstanceId: provider.instanceId,
782 cwd: serverConfig.cwd,
783 command: provider.installed
784 ? resolveOnboardingProviderLoginCommand(
785 provider,
786 serverConfig.settings,
787 serverConfig.environment.platform.os,
788 )
789 : resolveOnboardingProviderInstallCommand(
790 driver,
791 serverConfig.environment.platform.os,
792 ),
793 keybindings: serverConfig.keybindings,
794 });
795 }}
796 />
797 ),
798 )}
729799 </div>
800 {providers?.some(
801 (provider) =>
802 provider.driver === "codex" && getOnboardingProviderState(provider) === "ready",
803 ) ? (
804 <div className="mt-3">
805 <Button size="xs" variant="ghost-muted" onClick={() => setAddingAccount(true)}>
806 Connect another ChatGPT account
807 </Button>
808 </div>
809 ) : null}
810 {addingAccount ? (
811 <AddManagedCodexAccountDialog
812 environmentId={environmentId}
813 onClose={() => setAddingAccount(false)}
814 onAccountCreated={(instanceId, displayName) =>
815 setCreatedAccount({ instanceId, displayName, autoStart: true })
816 }
817 />
818 ) : null}
730819 {terminalSession !== null ? (
731820 <AgentInstallTerminal
732821 key={`${terminalSession.environmentId}:${terminalSession.providerInstanceId}:${terminalSession.driver}`}
......@@ -741,6 +830,93 @@ function ConnectedAgentsStep({
741830 );
742831}
743832
833function OnboardingCodexSetup({
834 createdAccount,
835 onAutoStartConsumed,
836 environmentId,
837 provider,
838 serverConfig,
839 terminalOpen,
840 onOpenTerminal,
841}: {
842 readonly environmentId: EnvironmentId;
843 readonly provider: ServerProvider | undefined;
844 readonly serverConfig: ServerConfig;
845 readonly terminalOpen: boolean;
846 readonly onOpenTerminal: () => void;
847 readonly createdAccount: {
848 instanceId: ProviderInstanceId;
849 displayName: string;
850 autoStart: boolean;
851 } | null;
852 readonly onAutoStartConsumed: () => void;
853}) {
854 const update = useAtomCommand(serverEnvironment.updateSettings, "Codex setup settings");
855 const instanceId =
856 createdAccount?.instanceId ??
857 provider?.instanceId ??
858 defaultInstanceIdForDriver(ProviderDriverKind.make("codex"));
859 const settings = serverConfig.settings;
860 const instance = settings.providerInstances[instanceId] ?? {
861 driver: ProviderDriverKind.make("codex"),
862 enabled: settings.providers.codex.enabled,
863 config: createdAccount ? { enabled: true, setupMode: "managed" } : settings.providers.codex,
864 };
865 const mode = readCodexSetupMode(instance.config);
866 const existingChosen =
867 mode === "existing" &&
868 instance.config !== null &&
869 typeof instance.config === "object" &&
870 "setupMode" in instance.config &&
871 instance.config.setupMode === "existing";
872 const changeMode = (setupMode: "managed" | "existing") => {
873 void update({
874 environmentId,
875 input: {
876 patch: buildProviderInstanceUpdatePatch({
877 settings,
878 instanceId,
879 driver: ProviderDriverKind.make("codex"),
880 isDefault: instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("codex")),
881 instance: {
882 ...instance,
883 enabled: true,
884 config: {
885 ...(instance.config !== null && typeof instance.config === "object"
886 ? instance.config
887 : {}),
888 enabled: true,
889 setupMode,
890 },
891 },
892 }),
893 },
894 });
895 };
896 return existingChosen ? (
897 <AgentCard
898 driver="codex"
899 provider={provider}
900 terminalOpen={terminalOpen}
901 terminalAvailable
902 onOpenTerminal={onOpenTerminal}
903 />
904 ) : (
905 <CodexSetupSection
906 presentation="onboarding"
907 autoStart={createdAccount?.autoStart === true}
908 displayName={createdAccount?.displayName}
909 onAutoStartConsumed={onAutoStartConsumed}
910 environmentId={environmentId}
911 instanceId={instanceId}
912 provider={provider}
913 mode={mode}
914 enabled={provider?.enabled ?? true}
915 onModeChange={changeMode}
916 />
917 );
918}
919
744920function AgentCard({
745921 driver,
746922 provider,
......@@ -756,20 +932,21 @@ function AgentCard({
756932}) {
757933 const meta = getDriverOption(ProviderDriverKind.make(driver));
758934 const Icon = meta?.icon;
759 const displayName = driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver);
935 const displayName =
936 provider?.displayName || (driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver));
760937 const summary = getProviderSummary(provider);
761938 const providerState = getOnboardingProviderState(provider);
762939
763940 return (
764 <div className="flex items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5">
941 <div className="flex items-center gap-3 rounded-lg border border-border bg-background px-4 py-4">
765942 {Icon ? (
766943 <Icon className={cn("size-5 shrink-0", driver !== "claudeAgent" && "fill-foreground")} />
767944 ) : null}
768945 <div className="min-w-0 flex-1">
769946 <span className="block text-sm font-medium text-foreground">{displayName}</span>
770947 <p className="mt-0.5 text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground">
771 {summary.headline}
772 {summary.detail ? ` · ${summary.detail}` : ""}
948 {providerState === "ready" ? "Ready to code." : summary.headline}
949 {providerState !== "ready" && summary.detail ? ` · ${summary.detail}` : ""}
773950 </p>
774951 </div>
775952 <div className="shrink-0">
apps/web/src/components/settings/AddCodexAccountDialog.tsx created+143
......@@ -0,0 +1,143 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 ProviderDriverKind,
4 ProviderInstanceId,
5 type EnvironmentId,
6 type ServerProvider,
7} from "@t3tools/contracts";
8import { useEffect, useEffectEvent, useState, type ReactNode } from "react";
9import { usesChatGptSharing } from "@t3tools/shared/usageLimits";
10
11import { useEnvironmentSettings } from "../../hooks/useSettings";
12import { randomUUID } from "../../lib/utils";
13import { serverEnvironment } from "../../state/server";
14import { useAtomCommand } from "../../state/use-atom-command";
15import { Button } from "../ui/button";
16import { Dialog } from "../ui/dialog";
17import { Input } from "../ui/input";
18import { WizardFooter, WizardHeader, WizardPanel, WizardPopup } from "../ui/wizard";
19import { SettingsRow } from "./settingsLayout";
20
21export function AddCodexAccountDialog({
22 environmentId,
23 onClose,
24 renderSetup,
25 onAccountCreated,
26}: {
27 readonly environmentId: EnvironmentId;
28 readonly onClose: () => void;
29 readonly onAccountCreated?:
30 | ((instanceId: ProviderInstanceId, displayName: string) => void)
31 | undefined;
32 readonly renderSetup: (instanceId: ProviderInstanceId, provider: ServerProvider) => ReactNode;
33}) {
34 const settings = useEnvironmentSettings(environmentId);
35 const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId));
36 const update = useAtomCommand(serverEnvironment.updateSettings, "Add ChatGPT account");
37 const [name, setName] = useState("Personal");
38 const displayName = `ChatGPT - ${name.trim()}`;
39 const [instanceId, setInstanceId] = useState<ProviderInstanceId | null>(null);
40 const [pending, setPending] = useState(false);
41 const provider = providers?.find((candidate) => candidate.instanceId === instanceId);
42 const connected = usesChatGptSharing(provider);
43 const closeAfterConnection = useEffectEvent(onClose);
44 useEffect(() => {
45 // The destination snapshot confirms remote transfer as well as local sign-in.
46 if (connected) closeAfterConnection();
47 }, [connected]);
48
49 const createAccount = async () => {
50 if (pending || !name.trim()) return;
51 setPending(true);
52 // The ID is routing identity; the name is editable and need not be unique.
53 const id = ProviderInstanceId.make(`codex_${randomUUID()}`);
54 const result = await update({
55 environmentId,
56 input: {
57 patch: {
58 providerInstances: {
59 ...settings.providerInstances,
60 [id]: {
61 driver: ProviderDriverKind.make("codex"),
62 displayName,
63 enabled: true,
64 config: { enabled: true, setupMode: "managed" },
65 },
66 },
67 },
68 },
69 });
70 if (result._tag === "Success") {
71 if (onAccountCreated) {
72 onAccountCreated(id, displayName);
73 onClose();
74 } else {
75 setInstanceId(id);
76 }
77 }
78 setPending(false);
79 };
80
81 return (
82 <Dialog
83 open={!connected}
84 onOpenChange={(open) => {
85 if (!open) onClose();
86 }}
87 >
88 <WizardPopup size="wide">
89 <WizardHeader
90 title={instanceId ? displayName : "Add ChatGPT account"}
91 description="Each account has its own Codex instance and sign-in. Choose the other account on the sign-in page."
92 />
93 <WizardPanel>
94 {instanceId ? (
95 provider?.setup ? (
96 renderSetup(instanceId, provider)
97 ) : (
98 <SettingsRow title="Codex runtime" description="Preparing managed setup." />
99 )
100 ) : (
101 <form
102 id="add-codex-account"
103 onSubmit={(event) => {
104 event.preventDefault();
105 void createAccount();
106 }}
107 >
108 <SettingsRow
109 title="Account name"
110 description="Shown in the provider list and model picker."
111 control={
112 <Input
113 aria-label="Account name"
114 value={name}
115 disabled={pending}
116 onChange={(event) => setName(event.target.value)}
117 placeholder="e.g. Personal or Work"
118 />
119 }
120 />
121 </form>
122 )}
123 </WizardPanel>
124 <WizardFooter>
125 {instanceId ? (
126 <Button variant="outline" onClick={onClose}>
127 Finish later
128 </Button>
129 ) : (
130 <>
131 <Button variant="outline" disabled={pending} onClick={onClose}>
132 Cancel
133 </Button>
134 <Button type="submit" form="add-codex-account" disabled={pending || !name.trim()}>
135 {pending ? "Adding account…" : "Continue"}
136 </Button>
137 </>
138 )}
139 </WizardFooter>
140 </WizardPopup>
141 </Dialog>
142 );
143}
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+27-10
......@@ -14,6 +14,7 @@ import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hook
1414import { cn } from "../../lib/utils";
1515import { normalizeProviderAccentColor } from "../../providerInstances";
1616import { Button } from "../ui/button";
17import { ChatGptConnectionButton } from "./ChatGptConnectionButton";
1718import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
1819import { Dialog } from "../ui/dialog";
1920import { Badge } from "../ui/badge";
......@@ -30,6 +31,7 @@ import {
3031 type WizardNavigation,
3132} from "./AddProviderInstanceDialog.logic";
3233import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps";
34import { AddManagedCodexAccountDialog } from "./CodexSetupSection";
3335
3436const PROVIDER_ACCENT_SWATCHES = [
3537 "#2563eb",
......@@ -126,6 +128,7 @@ export function AddProviderInstanceDialog({
126128 const updateSettings = useUpdateEnvironmentSettings(environmentId);
127129
128130 const [wizardStep, setWizardStep] = useState(0);
131 const [addingChatGptAccount, setAddingChatGptAccount] = useState(false);
129132 const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND);
130133 const [label, setLabel] = useState("");
131134 const [accentColor, setAccentColor] = useState<string>("");
......@@ -185,7 +188,10 @@ export function AddProviderInstanceDialog({
185188 setHasAttemptedSubmit(true);
186189 if (instanceIdError !== null) return;
187190
188 const config = configByDriver[driver] ?? {};
191 const config =
192 driver === "codex"
193 ? { ...configByDriver[driver], setupMode: "existing" }
194 : (configByDriver[driver] ?? {});
189195 const hasConfig = Object.keys(config).length > 0;
190196 const normalizedAccentColor = normalizeProviderAccentColor(accentColor);
191197
......@@ -222,17 +228,21 @@ export function AddProviderInstanceDialog({
222228 }
223229 };
224230
231 if (addingChatGptAccount) {
232 return (
233 <AddManagedCodexAccountDialog
234 environmentId={environmentId}
235 onClose={() => onOpenChange(false)}
236 />
237 );
238 }
239
225240 return (
226241 <Dialog open={open} onOpenChange={onOpenChange}>
227 <WizardPopup>
242 <WizardPopup size="wide">
228243 <WizardHeader
229244 title="Add provider instance"
230 description={
231 <>
232 Configure an additional provider instance on {environmentLabel} — for example, a
233 second Codex install pointed at a different workspace.
234 </>
235 }
245 description={<>Add an account or configure a provider on {environmentLabel}.</>}
236246 >
237247 <AddProviderInstanceWizardSteps
238248 currentStep={wizardStep}
......@@ -400,7 +410,7 @@ export function AddProviderInstanceDialog({
400410
401411 <WizardFooter>
402412 <Button
403 variant="outline"
413 variant={wizardStep === 0 ? "ghost-muted" : "outline"}
404414 onClick={() => {
405415 if (wizardStep === 0) {
406416 onOpenChange(false);
......@@ -411,7 +421,14 @@ export function AddProviderInstanceDialog({
411421 >
412422 {wizardStep === 0 ? "Cancel" : "Back"}
413423 </Button>
414 {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? (
424 {wizardStep === 0 && driver === "codex" ? (
425 <>
426 <Button variant="outline" onClick={() => navigateToStep(1)}>
427 Configure manually
428 </Button>
429 <ChatGptConnectionButton onClick={() => setAddingChatGptAccount(true)} />
430 </>
431 ) : wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? (
415432 <Button onClick={() => navigateToStep(wizardStep + 1)}>Next</Button>
416433 ) : (
417434 <Button onClick={handleSave}>Add instance</Button>
apps/web/src/components/settings/ChatGptAccountPicker.tsx created+74
......@@ -0,0 +1,74 @@
1import { useState } from "react";
2import type { ProviderAuthMethod } from "@t3tools/contracts";
3import { RadioGroup, Radio } from "../ui/radio-group";
4import {
5 Dialog,
6 DialogPopup,
7 DialogHeader,
8 DialogTitle,
9 DialogDescription,
10 DialogFooter,
11} from "../ui/dialog";
12import { ChatGptConnectionButton } from "./ChatGptConnectionButton";
13
14export function ChatGptAccountPicker({
15 open,
16 methods,
17 onClose,
18 onSelect,
19}: {
20 open: boolean;
21 methods: readonly ProviderAuthMethod[];
22 onClose: () => void;
23 onSelect: (methodId: string) => void;
24}) {
25 const profiles = methods.filter((method) => method.id.startsWith("chatgpt-profile:"));
26 const [selection, setSelection] = useState<string | null>(null);
27 const selectedMethodId =
28 selection === "chatgpt-change-account"
29 ? selection
30 : (profiles.find((profile) => profile.id === selection)?.id ??
31 profiles[0]?.id ??
32 "chatgpt-change-account");
33 return (
34 <Dialog
35 open={open}
36 onOpenChange={(value) => {
37 if (!value) onClose();
38 }}
39 >
40 <DialogPopup>
41 <DialogHeader>
42 <DialogTitle>Reconnect ChatGPT</DialogTitle>
43 <DialogDescription>
44 On OpenAI, sign in with the account you choose here.
45 </DialogDescription>
46 </DialogHeader>
47 <div className="px-6 pb-6">
48 <RadioGroup
49 aria-label="ChatGPT account to connect"
50 value={selectedMethodId}
51 onValueChange={(value) => setSelection(value)}
52 >
53 {profiles.map((profile) => (
54 <label
55 key={profile.id}
56 className="flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-sm"
57 >
58 <Radio value={profile.id} />
59 <span className="min-w-0 break-all font-medium">{profile.name}</span>
60 </label>
61 ))}
62 <label className="flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-sm">
63 <Radio value="chatgpt-change-account" />
64 <span className="font-medium">Use a different account</span>
65 </label>
66 </RadioGroup>
67 </div>
68 <DialogFooter>
69 <ChatGptConnectionButton onClick={() => onSelect(selectedMethodId)} />
70 </DialogFooter>
71 </DialogPopup>
72 </Dialog>
73 );
74}
apps/web/src/components/settings/ChatGptConnectionButton.tsx created+12
......@@ -0,0 +1,12 @@
1import type { ComponentProps } from "react";
2import { OpenAI } from "../Icons";
3import { Button } from "../ui/button";
4
5export function ChatGptConnectionButton({ children, ...props }: ComponentProps<typeof Button>) {
6 return (
7 <Button {...props}>
8 <OpenAI className="size-4 shrink-0" aria-hidden="true" />
9 {children ?? "Continue with ChatGPT"}
10 </Button>
11 );
12}
apps/web/src/components/settings/ChatGptUsageButton.tsx created+19
......@@ -0,0 +1,19 @@
1import type { ComponentProps } from "react";
2import { ExternalLinkIcon } from "lucide-react";
3import { CHATGPT_USAGE_URL } from "@t3tools/shared/usageLimits";
4import { ensureLocalApi } from "../../localApi";
5import { Button } from "../ui/button";
6
7export function ChatGptUsageButton(props: Omit<ComponentProps<typeof Button>, "onClick">) {
8 return (
9 <Button
10 variant="ghost-muted"
11 size="sm"
12 {...props}
13 onClick={() => void ensureLocalApi().shell.openExternal(CHATGPT_USAGE_URL)}
14 >
15 Manage usage
16 <ExternalLinkIcon className="size-3.5" aria-hidden="true" />
17 </Button>
18 );
19}
apps/web/src/components/settings/ChatGptWelcomeCoordinator.tsx created+83
......@@ -0,0 +1,83 @@
1import { useAtomValue } from "@effect/atom-react";
2import { usesChatGptSharing } from "@t3tools/shared/usageLimits";
3import { useState } from "react";
4import { environmentPresentations } from "../../state/presentation";
5import { OpenAI } from "../Icons";
6import { Button } from "../ui/button";
7import {
8 Dialog,
9 DialogPopup,
10 DialogHeader,
11 DialogTitle,
12 DialogDescription,
13 DialogFooter,
14} from "../ui/dialog";
15import { ChatGptUsageButton } from "./ChatGptUsageButton";
16
17const STORAGE_KEY = "t3:chatgpt-sharing-welcome:v1";
18function readAcknowledgedProfiles(): string[] {
19 try {
20 const value: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
21 return Array.isArray(value)
22 ? value.filter((key): key is string => typeof key === "string")
23 : [];
24 } catch {
25 return [];
26 }
27}
28
29/** Read the verified environment snapshot, never the browser callback acknowledgment. */
30export function ChatGptWelcomeCoordinator() {
31 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
32 const [acknowledged, setAcknowledged] = useState(readAcknowledgedProfiles);
33 const profiles = [...presentations].flatMap(([environmentId, presentation]) =>
34 presentation.connection.phase !== "connected"
35 ? []
36 : (presentation.serverConfig?.providers ?? []).filter(usesChatGptSharing).map((provider) => ({
37 key: JSON.stringify([
38 environmentId,
39 provider.instanceId,
40 provider.auth.profileId ?? provider.auth.email ?? "default",
41 ]),
42 environmentLabel: presentation.entry.target.label,
43 providerName: provider.displayName ?? "Codex",
44 })),
45 );
46 const next = profiles.find((profile) => !acknowledged.includes(profile.key));
47 const dismiss = () => {
48 if (!next) return;
49 const updated = [...acknowledged, next.key];
50 setAcknowledged(updated);
51 try {
52 localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
53 } catch {
54 /* Session dismissal still works. */
55 }
56 };
57 return (
58 <Dialog
59 open={next !== undefined}
60 onOpenChange={(open) => {
61 if (!open) dismiss();
62 }}
63 >
64 <DialogPopup>
65 <DialogHeader>
66 <OpenAI className="mb-2 size-8" aria-hidden="true" />
67 <DialogTitle>Your ChatGPT plan is connected</DialogTitle>
68 <DialogDescription>
69 Eligible usage in T3 Code uses your ChatGPT plan. Manage your shared usage and any
70 credit settings in ChatGPT.
71 </DialogDescription>
72 <p className="text-xs text-muted-foreground">
73 {next?.providerName} on {next?.environmentLabel}
74 </p>
75 </DialogHeader>
76 <DialogFooter>
77 <ChatGptUsageButton />
78 <Button onClick={dismiss}>Continue</Button>
79 </DialogFooter>
80 </DialogPopup>
81 </Dialog>
82 );
83}
apps/web/src/components/settings/CodexSetupSection.logic.ts created+8
......@@ -0,0 +1,8 @@
1export function readCodexSetupMode(config: unknown): "managed" | "existing" {
2 return config !== null &&
3 typeof config === "object" &&
4 "setupMode" in config &&
5 config.setupMode === "managed"
6 ? "managed"
7 : "existing";
8}
apps/web/src/components/settings/CodexSetupSection.tsx created+1144
......@@ -0,0 +1,1144 @@
1import {
2 isAtomCommandInterrupted,
3 squashAtomCommandFailure,
4 type AtomCommandResult,
5} from "@t3tools/client-runtime/state/runtime";
6import type {
7 ChatGptHandoffInput,
8 ChatGptTransferredProfile,
9 EnvironmentId,
10 ProviderInstanceId,
11 ServerProvider,
12} from "@t3tools/contracts";
13import { codexAuthHandoffUrl } from "@t3tools/shared/codexAuthHandoff";
14import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl";
15import { isLoopbackHost } from "@t3tools/shared/preview";
16import { CheckIcon, ChevronRightIcon, ExternalLinkIcon } from "lucide-react";
17import { Children, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
18
19import { ensureLocalApi } from "../../localApi";
20import {
21 useEnvironmentHttpBaseUrl,
22 usePrimaryEnvironmentId,
23 useEnvironment,
24} from "../../state/environments";
25import { useEnvironmentQuery } from "../../state/query";
26import { serverEnvironment } from "../../state/server";
27import { useAtomCommand } from "../../state/use-atom-command";
28import { Button } from "../ui/button";
29import { ChatGptConnectionButton } from "./ChatGptConnectionButton";
30import { ChatGptUsageButton } from "./ChatGptUsageButton";
31import { ChatGptAccountPicker } from "./ChatGptAccountPicker";
32import { Input } from "../ui/input";
33import { OpenAI } from "../Icons";
34import { RedactedSensitiveText } from "./RedactedSensitiveText";
35import { SettingsRow } from "./settingsLayout";
36import { AddCodexAccountDialog } from "./AddCodexAccountDialog";
37import { getOnboardingProviderState } from "../../onboarding/providerReadiness.logic";
38import { getProviderSummary } from "./providerStatus";
39
40const noop = () => undefined;
41
42interface CodexSetupSectionProps {
43 readonly environmentId: EnvironmentId;
44 readonly instanceId: ProviderInstanceId;
45 readonly provider: ServerProvider | undefined;
46 readonly mode: "managed" | "existing";
47 readonly enabled: boolean;
48 readonly readOnly?: boolean;
49 readonly presentation?: "settings" | "onboarding";
50 readonly onModeChange: (mode: "managed" | "existing") => void;
51 readonly autoStart?: boolean;
52 readonly displayName?: string | undefined;
53 readonly onAutoStartConsumed?: () => void;
54 readonly onSignInCancelled?: (() => void) | undefined;
55}
56
57/** Welcome and provider settings run the same environment-owned setup flow. */
58export function CodexSetupSection(props: CodexSetupSectionProps) {
59 const [requested, setRequested] = useState(false);
60 const existingState = getOnboardingProviderState(props.provider);
61 const existingReady = props.enabled && existingState === "ready";
62 const existingAuthenticated = props.provider?.auth.status === "authenticated";
63 const existingChecking = existingState === "checking";
64 const existingSummary = getProviderSummary(props.provider);
65 const content =
66 props.mode === "existing" && props.presentation === "onboarding" ? (
67 <CodexWelcomeCard
68 title={props.displayName || props.provider?.displayName || "Codex"}
69 description={
70 existingReady ? (
71 props.provider?.auth.email?.trim() ? (
72 <>
73 Signed in as{" "}
74 <RedactedSensitiveText
75 value={props.provider.auth.email.trim()}
76 ariaLabel="Toggle account email visibility"
77 revealTooltip="Click to reveal email"
78 hideTooltip="Click to hide email"
79 className="break-all"
80 />
81 .
82 </>
83 ) : (
84 "Connected with your Codex CLI."
85 )
86 ) : existingChecking ? (
87 "Checking your Codex CLI..."
88 ) : props.provider?.installed ? (
89 existingSummary.headline
90 ) : (
91 "Code with your ChatGPT subscription."
92 )
93 }
94 control={
95 existingReady ? (
96 <span className="inline-flex items-center gap-1.5 text-xs font-medium text-success-foreground">
97 <CheckIcon className="size-3.5" />
98 Ready
99 </span>
100 ) : existingChecking ? (
101 <span className="text-xs text-muted-foreground">Checking...</span>
102 ) : existingAuthenticated ? (
103 <span className="text-xs text-muted-foreground">{existingSummary.headline}</span>
104 ) : (
105 <ChatGptConnectionButton
106 size="sm"
107 disabled={props.readOnly}
108 onClick={() => {
109 setRequested(true);
110 props.onModeChange("managed");
111 }}
112 >
113 Continue with ChatGPT
114 </ChatGptConnectionButton>
115 )
116 }
117 secondaryControl={
118 !existingAuthenticated && !existingReady && !existingChecking ? (
119 <Button size="sm" variant="ghost-muted" onClick={() => props.onModeChange("existing")}>
120 Use existing CLI
121 </Button>
122 ) : null
123 }
124 />
125 ) : props.mode === "existing" ? null : props.provider?.setup === undefined ? (
126 props.presentation === "onboarding" ? (
127 <CodexWelcomeCard
128 title={props.displayName || props.provider?.displayName || "Codex"}
129 description={<CodexSignInDescription />}
130 control={
131 <Button size="sm" variant="outline" className="min-w-44" disabled>
132 Open sign-in page
133 </Button>
134 }
135 secondaryControl={
136 <Button size="sm" variant="ghost-muted" disabled>
137 Cancel
138 </Button>
139 }
140 />
141 ) : (
142 <SettingsRow title="ChatGPT account" description="Preparing sign-in." />
143 )
144 ) : (
145 <ManagedCodexSetup
146 key={`${props.environmentId}:${props.instanceId}`}
147 {...props}
148 autoStart={requested || props.autoStart === true}
149 onAutoStartConsumed={() => {
150 setRequested(false);
151 props.onAutoStartConsumed?.();
152 }}
153 />
154 );
155 return content;
156}
157
158/** All add-Codex entry points use the same managed account setup. */
159export function AddManagedCodexAccountDialog({
160 environmentId,
161 onClose,
162 onAccountCreated,
163}: {
164 readonly environmentId: EnvironmentId;
165 readonly onClose: () => void;
166 readonly onAccountCreated?:
167 | ((instanceId: ProviderInstanceId, displayName: string) => void)
168 | undefined;
169}) {
170 return (
171 <AddCodexAccountDialog
172 environmentId={environmentId}
173 onClose={onClose}
174 onAccountCreated={onAccountCreated}
175 renderSetup={(instanceId, provider) => (
176 <ManagedCodexSetup
177 key={instanceId}
178 environmentId={environmentId}
179 instanceId={instanceId}
180 provider={provider}
181 mode="managed"
182 enabled
183 autoStart
184 onAutoStartConsumed={noop}
185 onModeChange={noop}
186 onSignInCancelled={onClose}
187 allowExistingCli={false}
188 />
189 )}
190 />
191 );
192}
193
194function ManagedCodexSetup({
195 environmentId,
196 instanceId,
197 provider,
198 enabled,
199 readOnly,
200 onModeChange,
201 autoStart,
202 onAutoStartConsumed,
203 allowExistingCli = true,
204 presentation,
205 displayName,
206 onSignInCancelled,
207}: CodexSetupSectionProps & {
208 readonly autoStart: boolean;
209 readonly onAutoStartConsumed: () => void;
210 readonly allowExistingCli?: boolean;
211}) {
212 const target = { environmentId, input: { instanceId } };
213 const authQuery = useEnvironmentQuery(serverEnvironment.providerAuthState(target));
214 const installQuery = useEnvironmentQuery(serverEnvironment.providerInstallState(target));
215 const [handoff, setHandoff] = useState<{
216 environmentId: EnvironmentId;
217 input: ChatGptHandoffInput;
218 } | null>(null);
219 const handoffQuery = useEnvironmentQuery(
220 handoff ? serverEnvironment.chatGptHandoffState(handoff) : null,
221 );
222 const auth = handoffQuery.data?.phase === "auth" ? handoffQuery.data.state : authQuery.data;
223 const [accountPickerOpen, setAccountPickerOpen] = useState(false);
224 const [requestedMethodId, setRequestedMethodId] = useState("chatgpt");
225 const reconnectEmail = auth?.methods?.find((method) => method.id === "chatgpt")?.accountEmail;
226 const requestedAccountEmail = auth?.methods?.find(
227 (method) => method.id === requestedMethodId,
228 )?.accountEmail;
229 const hasSavedAccount = auth?.methods?.some((method) => method.id.startsWith("chatgpt-profile:"));
230 const url = auth?.interaction?.type === "browser" ? auth.interaction.url : auth?.authorizationUrl;
231 const installation = installQuery.data;
232 const httpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
233 const local = httpBaseUrl !== null && isLoopbackHost(new URL(httpBaseUrl).hostname);
234 const options = { reportFailure: false, reportDefect: false };
235 const startAuth = useAtomCommand(serverEnvironment.startProviderAuth, options);
236 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, options);
237 const completeAuth = useAtomCommand(serverEnvironment.completeProviderAuth, options);
238 const cancelAuth = useAtomCommand(serverEnvironment.cancelProviderAuth, options);
239 const logoutAuth = useAtomCommand(serverEnvironment.logoutProviderAuth, options);
240 const startInstall = useAtomCommand(serverEnvironment.startProviderInstall, options);
241 const cancelInstall = useAtomCommand(serverEnvironment.cancelProviderInstall, options);
242 const reconnectProfile = useAtomCommand(serverEnvironment.chatGptReconnectProfile, options);
243 const importProfile = useAtomCommand(serverEnvironment.chatGptImportProfile, options);
244 const clientCallback =
245 !handoff && (!local || window.desktopBridge?.receiveProviderAuthCallback !== undefined);
246 const remoteWeb = clientCallback && !window.desktopBridge?.receiveProviderAuthCallback;
247 const primaryEnvironmentId = usePrimaryEnvironmentId();
248 const primaryEnvironment = useEnvironment(primaryEnvironmentId);
249 const primaryHttpBaseUrl = useEnvironmentHttpBaseUrl(primaryEnvironmentId);
250 const primaryAuthEnvironmentId =
251 !local &&
252 primaryEnvironmentId &&
253 primaryEnvironment?.connection.phase === "connected" &&
254 primaryHttpBaseUrl &&
255 isLoopbackHost(new URL(primaryHttpBaseUrl).hostname) &&
256 (window.desktopBridge !== undefined || isLoopbackHost(window.location.hostname))
257 ? primaryEnvironmentId
258 : null;
259 const returnUrl = new URL(window.location.href);
260 if (returnUrl.pathname === "/welcome") returnUrl.hash = `agents:${environmentId}`;
261 if (returnUrl.pathname === "/settings/providers")
262 returnUrl.searchParams.set("instanceId", instanceId);
263 const needsManualCallback = remoteWeb;
264 const callbackHelpId = useId();
265 const [callbackHelpOpen, setCallbackHelpOpen] = useState(false);
266 const [callbackDraft, setCallbackDraft] = useState({ flowId: "", value: "" });
267 const callbackUrl = callbackDraft.flowId === auth?.flowId ? callbackDraft.value : "";
268 const [pending, setPending] = useState(false);
269 const [awaitingProvider, setAwaitingProvider] = useState<"sign-in" | "handoff" | null>(null);
270 const pendingRef = useRef(false);
271 const [error, setError] = useState<string | null>(null);
272 const continueWithSignIn = useRef<string | null>(null);
273 const openRequested = useRef(false);
274 const openedFlow = useRef<string | null>(null);
275 const [autoStartHandled, setAutoStartHandled] = useState(false);
276 const installed = installation?.installedVersion != null;
277 const authenticated = provider?.auth.status === "authenticated";
278 const updateAvailable =
279 installed &&
280 installation?.source !== "local" &&
281 installation?.version != null &&
282 installation.version !== installation.installedVersion;
283 // Auth receipts and provider snapshots arrive independently. Keep the current
284 // attempt pending until its authenticated snapshot arrives, even after success.
285 const finishingSignIn =
286 awaitingProvider !== null &&
287 !authenticated &&
288 (auth?.phase === "succeeded" || awaitingProvider === "handoff");
289 useEffect(() => {
290 if (authenticated || auth?.phase === "failed" || auth?.phase === "cancelled") {
291 setAwaitingProvider(null);
292 }
293 }, [authenticated, auth?.phase]);
294 useEffect(() => {
295 if (awaitingProvider && auth?.phase === "succeeded") {
296 void refreshProviders({ environmentId, input: { instanceId } });
297 }
298 }, [awaitingProvider, auth?.phase, refreshProviders, environmentId, instanceId]);
299 const startingAutomatically = autoStart && !autoStartHandled;
300 const waitingForAuthState =
301 awaitingProvider === "sign-in" && !authenticated && (auth === null || auth.phase === "idle");
302 const authInProgress =
303 finishingSignIn ||
304 waitingForAuthState ||
305 handoff !== null ||
306 auth?.phase === "starting" ||
307 auth?.phase === "waiting" ||
308 auth?.phase === "verifying";
309 const installActive =
310 installation?.phase === "downloading" ||
311 installation?.phase === "extracting" ||
312 installation?.phase === "verifying";
313 const authActive = startingAutomatically || authInProgress || (pending && !installActive);
314 const unavailable =
315 readOnly || !enabled || pending || authQuery.error !== null || installQuery.error !== null;
316 const busy = pending || authInProgress || installActive;
317
318 const run = useCallback(
319 async <A, E>(
320 request: () => Promise<AtomCommandResult<A, E>>,
321 onSuccess?: (value: A) => void,
322 ) => {
323 if (pendingRef.current) return false;
324 pendingRef.current = true;
325 setPending(true);
326 setError(null);
327 let succeeded = false;
328 try {
329 const result = await request();
330 if (result._tag === "Failure") {
331 if (!isAtomCommandInterrupted(result)) {
332 const failure = squashAtomCommandFailure(result);
333 setError(failure instanceof Error ? failure.message : "Codex setup failed. Try again.");
334 }
335 } else {
336 succeeded = true;
337 onSuccess?.(result.value);
338 }
339 } catch {
340 setError("Codex setup failed. Try again.");
341 }
342 pendingRef.current = false;
343 setPending(false);
344 return succeeded;
345 },
346 [],
347 );
348
349 const handoffId = useId();
350 const handoffSequence = useRef(0);
351 const importedAttempt = useRef<string | null>(null);
352 const [transferFailed, setTransferFailed] = useState(false);
353 const transferProfile = useCallback(
354 async (profile: ChatGptTransferredProfile) => {
355 const succeeded = await run(() =>
356 importProfile({ environmentId, input: { instanceId, profile } }),
357 );
358 if (succeeded) {
359 setAwaitingProvider("handoff");
360 setHandoff(null);
361 }
362 setTransferFailed(!succeeded);
363 },
364 [run, importProfile, environmentId, instanceId],
365 );
366 useEffect(() => {
367 if (!handoff || handoffQuery.data?.phase !== "finished") return;
368 const attemptId = handoff.input.attemptId;
369 if (importedAttempt.current === attemptId) return;
370 importedAttempt.current = attemptId;
371 void transferProfile(handoffQuery.data.profile);
372 }, [handoff, handoffQuery.data, transferProfile]);
373 useEffect(() => {
374 if (!handoff) return;
375 if (
376 handoffQuery.error ||
377 (handoffQuery.data?.phase === "auth" &&
378 ["failed", "cancelled"].includes(handoffQuery.data.state.phase))
379 ) {
380 setError(
381 handoffQuery.data?.phase === "auth"
382 ? (handoffQuery.data.state.message ?? "ChatGPT sign-in could not finish. Try again.")
383 : "ChatGPT sign-in on the primary environment was interrupted. Try again.",
384 );
385 setHandoff(null);
386 }
387 }, [handoff, handoffQuery.data, handoffQuery.error]);
388 const cancelSignIn = useCallback(() => {
389 setAwaitingProvider(null);
390 if (handoff) {
391 setHandoff(null);
392 return Promise.resolve();
393 }
394 return run(() => cancelAuth({ environmentId, input: { instanceId, flowId: auth!.flowId! } }));
395 }, [handoff, run, cancelAuth, environmentId, instanceId, auth]);
396
397 const signIn = useCallback(
398 async (methodId = "chatgpt") => {
399 if (pendingRef.current) return;
400 openRequested.current = true;
401 setTransferFailed(false);
402 setRequestedMethodId(methodId);
403 const returnUrl = new URL(window.location.href);
404 if (returnUrl.pathname === "/welcome") returnUrl.hash = `agents:${environmentId}`;
405 if (returnUrl.pathname === "/settings/providers")
406 returnUrl.searchParams.set("instanceId", instanceId);
407 if (primaryAuthEnvironmentId) {
408 const attemptId = `${handoffId}:${++handoffSequence.current}`;
409 const succeeded = await run(
410 () => reconnectProfile({ environmentId, input: { instanceId, methodId } }),
411 (profile) =>
412 setHandoff({
413 environmentId: primaryAuthEnvironmentId,
414 input: {
415 instanceId,
416 environmentId,
417 attemptId,
418 returnUrl: returnUrl.toString(),
419 profile,
420 },
421 }),
422 );
423 if (!succeeded) openRequested.current = false;
424 return;
425 }
426 if (
427 !(await run(
428 () =>
429 startAuth({
430 environmentId,
431 input: {
432 instanceId,
433 methodId,
434 returnUrl: returnUrl.toString(),
435 callbackMode: clientCallback ? "client" : "server",
436 },
437 }),
438 () => setAwaitingProvider("sign-in"),
439 ))
440 ) {
441 openRequested.current = false;
442 }
443 },
444 [
445 environmentId,
446 instanceId,
447 run,
448 startAuth,
449 clientCallback,
450 primaryAuthEnvironmentId,
451 reconnectProfile,
452 handoffId,
453 ],
454 );
455
456 const setup = useCallback(
457 async (methodId = "chatgpt") => {
458 if (unavailable || busy) return;
459 if (installed && !updateAvailable) {
460 await signIn(methodId);
461 } else {
462 continueWithSignIn.current = methodId;
463 if (!(await run(() => startInstall({ environmentId, input: { instanceId } })))) {
464 continueWithSignIn.current = null;
465 }
466 }
467 },
468 [
469 unavailable,
470 busy,
471 installed,
472 updateAvailable,
473 signIn,
474 run,
475 startInstall,
476 environmentId,
477 instanceId,
478 ],
479 );
480
481 useEffect(() => {
482 if (
483 !autoStart ||
484 autoStartHandled ||
485 unavailable ||
486 busy ||
487 installation === null ||
488 !provider?.setup?.canInstall
489 )
490 return;
491 setAutoStartHandled(true);
492 onAutoStartConsumed();
493 void setup();
494 }, [
495 autoStart,
496 autoStartHandled,
497 unavailable,
498 busy,
499 installation,
500 provider?.setup?.canInstall,
501 onAutoStartConsumed,
502 setup,
503 ]);
504
505 useEffect(() => {
506 if (!continueWithSignIn.current || pending || installActive) return;
507 if (installation?.phase === "failed" || installation?.phase === "cancelled") {
508 continueWithSignIn.current = null;
509 } else if (installed) {
510 const methodId = continueWithSignIn.current;
511 continueWithSignIn.current = null;
512 void signIn(methodId);
513 }
514 }, [pending, installActive, installation?.phase, installed, signIn]);
515
516 const receivingCallback = useRef<string | null>(null);
517 const flowId = auth?.flowId;
518 const openPage = useCallback(
519 async (authorizationUrl: string) => {
520 try {
521 const receive = window.desktopBridge?.receiveProviderAuthCallback;
522 if (receive && clientCallback && flowId) {
523 if (receivingCallback.current === authorizationUrl) {
524 await ensureLocalApi().shell.openExternal(authorizationUrl);
525 return;
526 }
527 receivingCallback.current = authorizationUrl;
528 const callbackUrl = await receive(authorizationUrl);
529 if (receivingCallback.current !== authorizationUrl) return;
530 receivingCallback.current = null;
531 await run(() =>
532 completeAuth({ environmentId, input: { instanceId, flowId, callbackUrl } }),
533 );
534 } else {
535 await ensureLocalApi().shell.openExternal(authorizationUrl);
536 }
537 } catch {
538 setError(
539 "Could not finish sign-in on this computer. Try again or paste the redirect URL below.",
540 );
541 }
542 },
543 [clientCallback, flowId, run, completeAuth, environmentId, instanceId],
544 );
545
546 useEffect(() => {
547 if (!clientCallback || !url || !window.desktopBridge?.cancelProviderAuthCallback) return;
548 return () => {
549 if (receivingCallback.current === url) receivingCallback.current = null;
550 void window.desktopBridge?.cancelProviderAuthCallback?.(url).catch(() => undefined);
551 };
552 }, [clientCallback, url]);
553
554 useEffect(() => {
555 if (
556 !openRequested.current ||
557 auth?.phase !== "waiting" ||
558 !auth.flowId ||
559 !url ||
560 openedFlow.current === auth.flowId
561 )
562 return;
563 openedFlow.current = auth.flowId;
564 openRequested.current = false;
565 if (!remoteWeb) void openPage(url);
566 }, [auth?.phase, auth?.flowId, url, openPage, remoteWeb]);
567
568 const runtimeDescription =
569 installation?.phase === "downloading"
570 ? `Downloading ${(installation.downloadedBytes / 1_000_000).toFixed(1)}${installation.totalBytes === null ? "" : ` of ${(installation.totalBytes / 1_000_000).toFixed(1)}`} MB.`
571 : installation?.phase === "extracting"
572 ? "Installing Codex."
573 : installation?.phase === "verifying"
574 ? "Checking Codex."
575 : installed
576 ? `${installation?.source === "local" ? "Using your installed Codex" : "Managed by T3 Code"}${installation?.installedVersion ? ` · v${installation.installedVersion}` : ""}.`
577 : (installation?.message ?? "T3 Code downloads and manages Codex for you.");
578 const accountDescription = finishingSignIn ? (
579 "Finishing sign-in..."
580 ) : installActive ? (
581 runtimeDescription
582 ) : authActive || auth?.phase === "failed" || auth?.phase === "cancelled" ? (
583 auth?.phase === "waiting" && requestedAccountEmail ? (
584 `Continue as ${requestedAccountEmail} on OpenAI.`
585 ) : (
586 (auth?.message ?? "Finish signing in in your browser.")
587 )
588 ) : authenticated ? (
589 provider?.auth.email?.trim() ? (
590 <>
591 Signed in as{" "}
592 <RedactedSensitiveText
593 value={provider.auth.email.trim()}
594 ariaLabel="Toggle account email visibility"
595 revealTooltip="Click to reveal email"
596 hideTooltip="Click to hide email"
597 />
598 .
599 </>
600 ) : (
601 "Signed in with ChatGPT."
602 )
603 ) : (
604 (reconnectEmail ?? "Use your ChatGPT subscription.")
605 );
606
607 const handoffUrl =
608 needsManualCallback && url && auth?.flowId && providerAuthReturnUrl(returnUrl.toString())
609 ? codexAuthHandoffUrl(
610 {
611 authorizationUrl: url,
612 returnUrl: returnUrl.toString(),
613 environmentId,
614 instanceId,
615 flowId: auth.flowId,
616 },
617 import.meta.env.DEV,
618 )
619 : null;
620 const waitingControl = (
621 <Button
622 size="sm"
623 variant={remoteWeb ? "default" : "outline"}
624 className={presentation === "onboarding" ? "min-w-44" : undefined}
625 disabled={
626 handoffQuery.data?.phase === "finished"
627 ? pending || !transferFailed
628 : !url || auth?.phase !== "waiting"
629 }
630 onClick={() => {
631 if (handoffQuery.data?.phase === "finished")
632 void transferProfile(handoffQuery.data.profile);
633 else if (url) void openPage(url);
634 }}
635 >
636 {finishingSignIn
637 ? "Finishing sign-in..."
638 : handoffQuery.data?.phase === "finished"
639 ? transferFailed
640 ? "Retry connection"
641 : "Finishing sign-in..."
642 : auth?.phase === "waiting"
643 ? remoteWeb
644 ? "Open ChatGPT sign-in"
645 : "Open sign-in page"
646 : presentation === "onboarding"
647 ? "Open sign-in page"
648 : "Signing in..."}
649 </Button>
650 );
651 const callbackCompletion =
652 !handoff && auth?.phase === "waiting" && url ? (
653 <div className="flex w-full flex-col gap-3 text-xs leading-relaxed text-muted-foreground">
654 <p>If sign-in doesn't return to T3 Code, paste the URL from the final localhost page.</p>
655 <form
656 className="flex flex-col gap-2 sm:flex-row sm:items-center"
657 onSubmit={(event) => {
658 event.preventDefault();
659 if (!auth.flowId || !callbackUrl.trim()) return;
660 const value = callbackUrl.trim();
661 const submittedFlowId = auth.flowId;
662 void run(() =>
663 completeAuth({
664 environmentId,
665 input: { instanceId, flowId: submittedFlowId, callbackUrl: value },
666 }),
667 ).then((connected) => {
668 if (connected) setCallbackDraft({ flowId: submittedFlowId, value: "" });
669 });
670 }}
671 >
672 <div className="min-w-0 flex-1">
673 <Input
674 aria-label="ChatGPT sign-in redirect URL"
675 type="password"
676 autoComplete="off"
677 placeholder="Paste the URL from the sign-in tab"
678 value={callbackUrl}
679 maxLength={16_384}
680 disabled={pending || readOnly}
681 onChange={(event) =>
682 setCallbackDraft({ flowId: auth.flowId ?? "", value: event.target.value })
683 }
684 />
685 </div>
686 <Button
687 variant="outline"
688 type="submit"
689 disabled={pending || readOnly || !callbackUrl.trim()}
690 >
691 Connect
692 </Button>
693 </form>
694 {!remoteWeb ? (
695 <div>
696 <Button
697 size="xs"
698 variant="ghost-muted"
699 onClick={() => void ensureLocalApi().shell.openExternal(url)}
700 >
701 Try sign-in in your browser
702 <ExternalLinkIcon className="size-3.5" />
703 </Button>
704 </div>
705 ) : null}
706 {handoffUrl ? (
707 <details>
708 <summary className="cursor-pointer">Other ways to connect</summary>
709 <Button className="mt-2" size="sm" variant="outline" render={<a href={handoffUrl} />}>
710 Use T3 desktop for automatic return
711 </Button>
712 </details>
713 ) : null}
714 </div>
715 ) : null;
716 const callbackHelpContent =
717 callbackCompletion && callbackHelpOpen ? (
718 <div id={callbackHelpId} className="w-full border-t border-border/50 pt-3">
719 {callbackCompletion}
720 </div>
721 ) : null;
722 const callbackFallback = needsManualCallback ? (
723 callbackCompletion
724 ) : callbackCompletion ? (
725 <div className="space-y-3 text-xs leading-relaxed text-muted-foreground">
726 <CodexSignInDescription
727 label="Having trouble signing in?"
728 expanded={callbackHelpOpen}
729 controls={callbackHelpId}
730 onToggle={() => setCallbackHelpOpen((open) => !open)}
731 />
732 {callbackHelpContent}
733 </div>
734 ) : null;
735
736 const accountPicker = (
737 <ChatGptAccountPicker
738 open={accountPickerOpen}
739 methods={auth?.methods ?? []}
740 onClose={() => setAccountPickerOpen(false)}
741 onSelect={(methodId) => {
742 setAccountPickerOpen(false);
743 void setup(
744 auth?.methods?.some((method) => method.id === methodId)
745 ? methodId
746 : "chatgpt-change-account",
747 );
748 }}
749 />
750 );
751
752 const logoutWarning =
753 auth?.phase === "idle" && auth.message?.startsWith("Signed out locally.") ? auth.message : null;
754
755 if (presentation === "onboarding") {
756 const setupError =
757 logoutWarning ??
758 error ??
759 (authQuery.error || installQuery.error
760 ? "Could not read setup status. Reconnect and try again."
761 : installation?.phase === "failed"
762 ? installation.message
763 : null);
764 return (
765 <>
766 {accountPicker}
767 <CodexWelcomeCard
768 title={displayName || provider?.displayName || "Codex"}
769 description={
770 installActive ? (
771 runtimeDescription
772 ) : authenticated ? (
773 provider?.auth.email?.trim() ? (
774 <>
775 Signed in as{" "}
776 <RedactedSensitiveText
777 value={provider.auth.email.trim()}
778 ariaLabel="Toggle account email visibility"
779 revealTooltip="Click to reveal email"
780 hideTooltip="Click to hide email"
781 className="break-all"
782 />
783 .
784 </>
785 ) : (
786 "Connected to ChatGPT."
787 )
788 ) : callbackCompletion && !needsManualCallback ? (
789 <CodexSignInDescription
790 expanded={callbackHelpOpen}
791 controls={callbackHelpId}
792 onToggle={() => setCallbackHelpOpen((open) => !open)}
793 />
794 ) : startingAutomatically ||
795 waitingForAuthState ||
796 auth?.phase === "starting" ||
797 auth?.phase === "waiting" ||
798 (pending && !installActive) ? (
799 <CodexSignInDescription />
800 ) : authActive || auth?.phase === "failed" || auth?.phase === "cancelled" ? (
801 accountDescription
802 ) : (
803 "Code with your ChatGPT subscription."
804 )
805 }
806 control={
807 authenticated && !busy ? (
808 <span className="inline-flex items-center gap-1.5 text-xs font-medium text-success-foreground">
809 <CheckIcon className="size-3.5" />
810 Ready
811 </span>
812 ) : authActive ? (
813 waitingControl
814 ) : (
815 <ChatGptConnectionButton
816 size="sm"
817 disabled={
818 unavailable ||
819 busy ||
820 (!installed ? !provider?.setup?.canInstall : !provider?.setup?.canAuthenticate)
821 }
822 onClick={() => {
823 if (hasSavedAccount) setAccountPickerOpen(true);
824 else void setup();
825 }}
826 >
827 {installActive || pending
828 ? "Setting up..."
829 : hasSavedAccount
830 ? "Reconnect account"
831 : "Continue with ChatGPT"}
832 </ChatGptConnectionButton>
833 )
834 }
835 secondaryControl={
836 (authActive && !finishingSignIn) || installActive ? (
837 <Button
838 size="sm"
839 variant="ghost-muted"
840 disabled={pending || readOnly}
841 onClick={() => {
842 openRequested.current = false;
843 continueWithSignIn.current = null;
844 if (handoff || (authActive && auth?.flowId)) void cancelSignIn();
845 else if (installation?.operationId)
846 void run(() =>
847 cancelInstall({
848 environmentId,
849 input: { instanceId, operationId: installation.operationId! },
850 }),
851 );
852 }}
853 >
854 Cancel
855 </Button>
856 ) : !authActive && !authenticated && hasSavedAccount ? (
857 <Button
858 size="sm"
859 variant="ghost-muted"
860 disabled={unavailable || busy}
861 onClick={() => void setup("chatgpt-change-account")}
862 >
863 Use a different account
864 </Button>
865 ) : !authActive && !authenticated && allowExistingCli ? (
866 <Button
867 size="sm"
868 variant="ghost-muted"
869 disabled={readOnly || busy}
870 onClick={() => onModeChange("existing")}
871 >
872 Use existing CLI
873 </Button>
874 ) : null
875 }
876 >
877 {needsManualCallback ? callbackFallback : callbackHelpContent}
878 {setupError ? (
879 <p role="alert" className="text-xs text-destructive">
880 {setupError}
881 </p>
882 ) : null}
883 </CodexWelcomeCard>
884 </>
885 );
886 }
887
888 return (
889 <section aria-label="Codex setup" className="divide-y divide-border/50">
890 {accountPicker}
891 <SettingsRow
892 title="ChatGPT account"
893 description={accountDescription}
894 control={
895 <div className="flex flex-wrap items-center justify-end gap-2">
896 {authActive ? (
897 <>
898 {waitingControl}
899 {!finishingSignIn && (
900 <Button
901 size="sm"
902 variant="ghost"
903 disabled={pending || readOnly}
904 onClick={() => {
905 if (!handoff && !auth?.flowId) return;
906 openRequested.current = false;
907 void cancelSignIn();
908 onSignInCancelled?.();
909 }}
910 >
911 Cancel
912 </Button>
913 )}
914 </>
915 ) : installActive ? (
916 <Button
917 size="sm"
918 variant="ghost"
919 disabled={pending || readOnly}
920 onClick={() => {
921 if (!installation?.operationId) return;
922 continueWithSignIn.current = null;
923 void run(() =>
924 cancelInstall({
925 environmentId,
926 input: { instanceId, operationId: installation.operationId! },
927 }),
928 );
929 }}
930 >
931 Cancel
932 </Button>
933 ) : authenticated ? (
934 <>
935 <Button
936 size="sm"
937 variant="outline"
938 disabled={unavailable || busy}
939 onClick={() => setAccountPickerOpen(true)}
940 >
941 Change account
942 </Button>
943 <Button
944 size="sm"
945 variant="ghost"
946 disabled={unavailable || busy}
947 onClick={() => void run(() => logoutAuth(target))}
948 >
949 Disconnect
950 </Button>
951 </>
952 ) : (
953 <>
954 <ChatGptConnectionButton
955 size="sm"
956 disabled={
957 unavailable ||
958 busy ||
959 (!installed ? !provider?.setup?.canInstall : !provider?.setup?.canAuthenticate)
960 }
961 onClick={() => {
962 if (hasSavedAccount) setAccountPickerOpen(true);
963 else void setup();
964 }}
965 >
966 {pending
967 ? "Setting up..."
968 : hasSavedAccount
969 ? "Reconnect account"
970 : "Continue with ChatGPT"}
971 </ChatGptConnectionButton>
972 {hasSavedAccount ? (
973 <Button
974 size="sm"
975 variant="ghost"
976 disabled={unavailable || busy}
977 onClick={() => void setup("chatgpt-change-account")}
978 >
979 Use a different account
980 </Button>
981 ) : null}
982 </>
983 )}
984 </div>
985 }
986 />
987 {authenticated ? (
988 <div className="flex justify-end px-3 py-1 sm:px-4">
989 <ChatGptUsageButton />
990 </div>
991 ) : null}
992 {logoutWarning ? (
993 <p role="alert" className="px-3 py-2 text-xs text-muted-foreground sm:px-4">
994 {logoutWarning}
995 </p>
996 ) : null}
997 {callbackFallback ? <div className="px-3 py-3 sm:px-4">{callbackFallback}</div> : null}
998 {error || authQuery.error || installQuery.error || installation?.phase === "failed" ? (
999 <p role="alert" className="px-3 py-2 text-xs text-destructive sm:px-4">
1000 {error ??
1001 (installation?.phase === "failed"
1002 ? installation.message
1003 : "Could not read Codex setup status. Reconnect and try again.")}
1004 </p>
1005 ) : null}
1006 </section>
1007 );
1008}
1009
1010/** The server selects the executable for managed instances; local config cannot override it. */
1011export function CodexManagedRuntimeFields({
1012 environmentId,
1013 instanceId,
1014 provider,
1015}: {
1016 readonly environmentId: EnvironmentId;
1017 readonly instanceId: ProviderInstanceId;
1018 readonly provider: ServerProvider | undefined;
1019}) {
1020 const installation = useEnvironmentQuery(
1021 serverEnvironment.providerInstallState({
1022 environmentId,
1023 input: { instanceId },
1024 }),
1025 );
1026 const executablePath = installation.data?.executablePath ?? "";
1027 return (
1028 <>
1029 <SettingsRow
1030 title="Binary path"
1031 description="Selected by T3 Code."
1032 control={
1033 <div className="w-full sm:w-80">
1034 <Input
1035 aria-label="Codex binary path"
1036 value={executablePath}
1037 title={executablePath}
1038 placeholder={installation.error ? "Could not read runtime path" : "Not installed"}
1039 disabled
1040 />
1041 </div>
1042 }
1043 />
1044 <SettingsRow
1045 title="CODEX_HOME path"
1046 description="Shared Codex config, sessions, and state."
1047 control={
1048 <div className="w-full sm:w-80">
1049 <Input
1050 aria-label="Codex home path"
1051 value={provider?.runtimePaths?.homePath ?? ""}
1052 title={provider?.runtimePaths?.homePath}
1053 placeholder="Unavailable"
1054 disabled
1055 />
1056 </div>
1057 }
1058 />
1059 <SettingsRow
1060 title="Shadow home path"
1061 description={
1062 provider?.runtimePaths?.shadowHomePath
1063 ? "Account-specific home sharing the Codex state above."
1064 : "This instance uses the shared Codex home directly."
1065 }
1066 control={
1067 <div className="w-full sm:w-80">
1068 <Input
1069 aria-label="Codex shadow home path"
1070 value={provider?.runtimePaths?.shadowHomePath ?? ""}
1071 title={provider?.runtimePaths?.shadowHomePath ?? undefined}
1072 placeholder={provider?.runtimePaths ? "Not used" : "Unavailable"}
1073 disabled
1074 />
1075 </div>
1076 }
1077 />
1078 </>
1079 );
1080}
1081
1082function CodexSignInDescription({
1083 label = "Complete sign-in in your browser.",
1084 expanded = false,
1085 controls,
1086 onToggle,
1087}: {
1088 readonly label?: string;
1089 readonly expanded?: boolean;
1090 readonly controls?: string;
1091 readonly onToggle?: () => void;
1092}) {
1093 if (!onToggle) return <div className="leading-relaxed">{label}</div>;
1094 return (
1095 <button
1096 type="button"
1097 className="inline-flex items-center gap-1.5 rounded-sm text-left leading-relaxed outline-none focus-visible:ring-2 focus-visible:ring-ring"
1098 aria-label="Having trouble signing in?"
1099 aria-expanded={expanded}
1100 aria-controls={controls}
1101 onClick={onToggle}
1102 >
1103 {label}
1104 <ChevronRightIcon aria-hidden className={`size-3 shrink-0 ${expanded ? "rotate-90" : ""}`} />
1105 </button>
1106 );
1107}
1108
1109/** A single, calm setup row for the first-run welcome screen. */
1110function CodexWelcomeCard({
1111 title,
1112 description,
1113 control,
1114 secondaryControl,
1115 children,
1116}: {
1117 readonly title: string;
1118 readonly description: ReactNode;
1119 readonly control?: ReactNode;
1120 readonly secondaryControl?: ReactNode;
1121 readonly children?: ReactNode;
1122}) {
1123 const footer = Children.toArray(children);
1124 return (
1125 <div className="rounded-lg border border-border bg-background px-4 py-4">
1126 <div className="flex flex-wrap items-center gap-3">
1127 <OpenAI className="size-5 shrink-0 fill-foreground" />
1128 <div className="min-w-0 flex-1">
1129 <h3 className="text-sm font-medium">{title}</h3>
1130 <div className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{description}</div>
1131 </div>
1132 {control || secondaryControl ? (
1133 <div className="flex shrink-0 flex-wrap items-center justify-end gap-2 has-[button]:w-full has-[button]:flex-col-reverse has-[button]:items-end sm:has-[button]:w-auto sm:has-[button]:flex-row sm:has-[button]:items-center">
1134 {secondaryControl}
1135 {control}
1136 </div>
1137 ) : null}
1138 </div>
1139 {footer.length > 0 ? (
1140 <div className="ml-8 mt-2 flex flex-wrap items-center gap-2">{footer}</div>
1141 ) : null}
1142 </div>
1143 );
1144}
apps/web/src/components/settings/FoldedSettingsSection.tsx+34
......@@ -16,12 +16,14 @@ export function FoldedSettingsSection({
1616 title,
1717 summary,
1818 control,
19 headerPlacement = "inside",
1920 children,
2021}: {
2122 readonly id: string;
2223 readonly title: string;
2324 readonly summary?: string | null;
2425 readonly control?: ReactNode;
26 readonly headerPlacement?: "inside" | "outside";
2527 readonly children: ReactNode;
2628}) {
2729 const [open, setOpen] = useState(false);
......@@ -34,6 +36,38 @@ export function FoldedSettingsSection({
3436 if (!open) setOpen(true);
3537 }
3638
39 if (headerPlacement === "outside") {
40 return (
41 <section id={id} ref={targetRef} tabIndex={-1} className="outline-none">
42 <Collapsible open={open} onOpenChange={setOpen}>
43 <div className="space-y-2.5">
44 <div
45 data-settings-scroll-target
46 className="flex min-h-7 items-start justify-between gap-4 px-3 sm:px-4"
47 >
48 <h2>
49 <CollapsibleTrigger className="flex min-h-7 items-center gap-2 rounded-md text-sm font-normal text-foreground/70 outline-none focus-visible:ring-2 focus-visible:ring-ring">
50 {title}
51 <ChevronRightIcon
52 aria-hidden
53 className={cn(
54 "size-4 shrink-0 transition-transform duration-150 motion-reduce:transition-none",
55 open && "rotate-90",
56 )}
57 />
58 </CollapsibleTrigger>
59 </h2>
60 {control}
61 </div>
62 <CollapsiblePanel>
63 <SettingsGroup>{children}</SettingsGroup>
64 </CollapsiblePanel>
65 </div>
66 </Collapsible>
67 </section>
68 );
69 }
70
3771 return (
3872 <section id={id} ref={targetRef} tabIndex={-1} className="outline-none">
3973 <Collapsible open={open} onOpenChange={setOpen} render={<SettingsGroup divided={false} />}>
apps/web/src/components/settings/ProviderAuthCallbackCoordinator.tsx created+50
......@@ -0,0 +1,50 @@
1import { useEffect, useRef, useState } from "react";
2import { pendingProviderAuthDelivery, clearProviderAuthDelivery } from "../../providerAuthDelivery";
3import { serverEnvironment } from "../../state/server";
4import { useEnvironments } from "../../state/environments";
5import { useAtomCommand } from "../../state/use-atom-command";
6import { toastManager } from "../ui/toast";
7
8/** Hosted web receives only the one-time code; the selected environment verifies and stores tokens. */
9export function ProviderAuthCallbackCoordinator() {
10 const completeAuth = useAtomCommand(serverEnvironment.completeProviderAuth, {
11 reportFailure: false,
12 });
13 const { environments } = useEnvironments();
14 const [delivery, setDelivery] = useState(pendingProviderAuthDelivery);
15 const started = useRef(false);
16 const environmentId = delivery?.environmentId;
17 const connected = environments.some(
18 (environment) =>
19 environment.environmentId === environmentId && environment.connection.phase === "connected",
20 );
21 useEffect(() => {
22 const input = delivery;
23 if (!input || started.current || !connected) return;
24 started.current = true;
25 void completeAuth({
26 environmentId: input.environmentId,
27 input: { instanceId: input.instanceId, flowId: input.flowId, callbackUrl: input.callbackUrl },
28 })
29 .then((result) => {
30 if (result._tag === "Failure")
31 toastManager.add({
32 type: "error",
33 title: "ChatGPT sign-in couldn't finish",
34 description: "Return to the provider and try again.",
35 });
36 })
37 .catch(() =>
38 toastManager.add({
39 type: "error",
40 title: "ChatGPT sign-in couldn't finish",
41 description: "Reconnect to the environment and try again.",
42 }),
43 )
44 .finally(() => {
45 setDelivery(undefined);
46 clearProviderAuthDelivery();
47 });
48 }, [completeAuth, connected, delivery]);
49 return null;
50}
apps/web/src/components/settings/ProviderInstanceCard.tsx+49-32
......@@ -49,6 +49,8 @@ import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/Provider
4949import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
5050import { RedactedSensitiveText } from "./RedactedSensitiveText";
5151import { SettingsRow, SettingsSection } from "./settingsLayout";
52import { FoldedSettingsSection } from "./FoldedSettingsSection";
53import { readCodexSetupMode } from "./CodexSetupSection.logic";
5254import {
5355 getProviderVersionAdvisoryPresentation,
5456 PROVIDER_STATUS_STYLES,
......@@ -387,6 +389,7 @@ interface ProviderInstanceCardProps {
387389 */
388390 readonly headerAction?: ReactNode | undefined;
389391 readonly setup?: ReactNode;
392 readonly runtime?: ReactNode;
390393 readonly hiddenModels: ReadonlyArray<string>;
391394 readonly favoriteModels: ReadonlyArray<string>;
392395 readonly modelOrder: ReadonlyArray<string>;
......@@ -430,6 +433,7 @@ export function ProviderInstanceCard({
430433 onDelete,
431434 headerAction,
432435 setup,
436 runtime,
433437 hiddenModels,
434438 favoriteModels,
435439 modelOrder,
......@@ -654,11 +658,6 @@ export function ProviderInstanceCard({
654658 <span className="min-w-0 flex-1">
655659 <span className="flex min-w-0 items-center gap-2">
656660 <span className="truncate text-sm font-medium text-foreground">{displayName}</span>
657 {String(instanceId) !== String(instance.driver) ? (
658 <code className="min-w-0 truncate rounded bg-muted/60 px-1 py-0.5 text-3xs text-muted-foreground">
659 {instanceId}
660 </code>
661 ) : null}
662661 {versionLabel ? (
663662 <code className="max-w-24 shrink-0 truncate text-xs text-muted-foreground">
664663 {versionLabel}
......@@ -860,6 +859,26 @@ export function ProviderInstanceCard({
860859 </div>
861860 );
862861
862 const runtimeFields = driverOption ? (
863 <ProviderSettingsForm
864 definition={driverOption}
865 value={instance.config}
866 idPrefix={`provider-instance-${instanceId}`}
867 variant="settings"
868 onChange={updateConfig}
869 />
870 ) : (
871 <SettingsRow
872 title="Driver"
873 description={
874 <span>
875 This instance uses <code className="text-foreground">{String(instance.driver)}</code>,
876 which is not available in this build. Its configuration is preserved.
877 </span>
878 }
879 />
880 );
881
863882 return (
864883 <>
865884 <SettingsSection title={displayName} icon={titleIconNode} headerAction={editorHeaderAction}>
......@@ -907,33 +926,31 @@ export function ProviderInstanceCard({
907926
908927 {setup ? <SettingsSection title="Setup">{setup}</SettingsSection> : null}
909928
910 <SettingsSection
911 title="Runtime"
912 inert={readOnly}
913 aria-disabled={readOnly || undefined}
914 className={readOnly ? "opacity-50 select-none" : undefined}
915 >
916 {driverOption ? (
917 <ProviderSettingsForm
918 definition={driverOption}
919 value={instance.config}
920 idPrefix={`provider-instance-${instanceId}`}
921 variant="settings"
922 onChange={updateConfig}
923 />
924 ) : (
925 <SettingsRow
926 title="Driver"
927 description={
928 <span>
929 This instance uses{" "}
930 <code className="text-foreground">{String(instance.driver)}</code>, which is not
931 available in this build. Its configuration is preserved.
932 </span>
933 }
934 />
935 )}
936 </SettingsSection>
929 {instance.driver === "codex" && readCodexSetupMode(instance.config) === "managed" ? (
930 <div
931 inert={readOnly}
932 aria-disabled={readOnly || undefined}
933 className={readOnly ? "opacity-50 select-none" : undefined}
934 >
935 <FoldedSettingsSection
936 key={instanceId}
937 id={`provider-instance-${instanceId}-runtime`}
938 title="Runtime"
939 headerPlacement="outside"
940 >
941 {runtime ?? runtimeFields}
942 </FoldedSettingsSection>
943 </div>
944 ) : (
945 <SettingsSection
946 title="Runtime"
947 inert={readOnly}
948 aria-disabled={readOnly || undefined}
949 className={readOnly ? "opacity-50 select-none" : undefined}
950 >
951 {runtimeFields}
952 </SettingsSection>
953 )}
937954
938955 <SettingsSection
939956 title="Environment"
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts+15-11
......@@ -20,18 +20,22 @@ export function isProviderSettingsEnvironmentAvailable(input: {
2020export function buildProviderEnvironmentOptions<T extends ProviderEnvironmentOptionLike>(
2121 environments: ReadonlyArray<T>,
2222 primaryEnvironmentId: EnvironmentId | null,
23 environmentIds?: readonly EnvironmentId[],
2324): ReadonlyArray<T> {
24 return environments.toSorted((left, right) => {
25 const leftIsPrimary = left.environmentId === primaryEnvironmentId;
26 const rightIsPrimary = right.environmentId === primaryEnvironmentId;
27 if (leftIsPrimary !== rightIsPrimary) {
28 return leftIsPrimary ? -1 : 1;
29 }
30 return (
31 left.label.localeCompare(right.label) ||
32 String(left.environmentId).localeCompare(String(right.environmentId))
33 );
34 });
25 const allowed = environmentIds ? new Set(environmentIds) : null;
26 return environments
27 .filter((environment) => !allowed || allowed.has(environment.environmentId))
28 .toSorted((left, right) => {
29 const leftIsPrimary = left.environmentId === primaryEnvironmentId;
30 const rightIsPrimary = right.environmentId === primaryEnvironmentId;
31 if (leftIsPrimary !== rightIsPrimary) {
32 return leftIsPrimary ? -1 : 1;
33 }
34 return (
35 left.label.localeCompare(right.label) ||
36 String(left.environmentId).localeCompare(String(right.environmentId))
37 );
38 });
3539}
3640
3741export function resolveSelectedProviderEnvironmentId(
apps/web/src/components/settings/ProviderSettingsPanel.tsx+51-7
......@@ -84,6 +84,8 @@ import { ExpandableText } from "./ExpandableText";
8484import { ProviderInstanceCard } from "./ProviderInstanceCard";
8585import { UsageProviderSettings } from "./UsageProviderSettings";
8686import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection";
87import { CodexSetupSection, CodexManagedRuntimeFields } from "./CodexSetupSection";
88import { readCodexSetupMode } from "./CodexSetupSection.logic";
8789import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
8890import { searchableSetting } from "./settingsSearch";
8991import {
......@@ -265,6 +267,7 @@ interface ProviderSettingsTarget {
265267 readonly environmentId?: EnvironmentId;
266268 readonly instanceId?: ProviderInstanceId;
267269 readonly scoped?: boolean;
270 readonly environmentIds?: readonly EnvironmentId[];
268271}
269272
270273export function ProviderSettingsPanel(target: ProviderSettingsTarget) {
......@@ -283,8 +286,9 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
283286 const primaryEnvironmentId = usePrimaryEnvironmentId();
284287 const searchTargetId = useSettingsSearchTargetId();
285288 const options = useMemo(
286 () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId),
287 [environments, primaryEnvironmentId],
289 () =>
290 buildProviderEnvironmentOptions(environments, primaryEnvironmentId, target.environmentIds),
291 [environments, primaryEnvironmentId, target.environmentIds],
288292 );
289293 // Raw user intent; the effective selection is re-derived every render so a
290294 // device that drops out of the catalog falls back without erasing the pick —
......@@ -935,6 +939,17 @@ export function EnvironmentProviderSettings({
935939 selected={mode === "list" && selectedRow?.instanceId === row.instanceId}
936940 onSelect={mode === "list" ? () => setSelectedInstanceId(row.instanceId) : undefined}
937941 readOnly={readOnly}
942 runtime={
943 mode === "editor" &&
944 row.driver === "codex" &&
945 readCodexSetupMode(row.instance.config) === "managed" ? (
946 <CodexManagedRuntimeFields
947 environmentId={environmentId}
948 instanceId={row.instanceId}
949 provider={liveProvider}
950 />
951 ) : undefined
952 }
938953 setup={
939954 mode === "editor" && row.driver === "antigravity" ? (
940955 <ProviderSetupSection
......@@ -948,6 +963,30 @@ export function EnvironmentProviderSettings({
948963 readOnly={readOnly}
949964 onEnable={() => updateProviderInstance(row, { ...row.instance, enabled: true })}
950965 />
966 ) : mode === "editor" &&
967 row.driver === "codex" &&
968 readCodexSetupMode(row.instance.config) === "managed" ? (
969 <CodexSetupSection
970 environmentId={environmentId}
971 instanceId={row.instanceId}
972 provider={liveProvider}
973 mode={readCodexSetupMode(row.instance.config)}
974 enabled={resolveProviderInstanceEnabled(row.instance)}
975 readOnly={readOnly}
976 onModeChange={(setupMode) =>
977 updateProviderInstance(row, {
978 ...row.instance,
979 enabled: true,
980 config: {
981 ...(row.instance.config !== null && typeof row.instance.config === "object"
982 ? row.instance.config
983 : {}),
984 enabled: true,
985 setupMode,
986 },
987 })
988 }
989 />
951990 ) : null
952991 }
953992 onUpdate={(next) => {
......@@ -1021,10 +1060,11 @@ export function EnvironmentProviderSettings({
10211060
10221061 return (
10231062 <>
1024 <SettingsSection {...searchableSetting("providers")} variant="plain">
1025 <div className="flex min-h-11 min-w-0 items-center gap-2 px-3 sm:px-4">
1026 {deviceTabs}
1027 <div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">
1063 <SettingsSection
1064 {...searchableSetting("providers")}
1065 variant="plain"
1066 headerAction={
1067 <div className="flex min-w-0 items-center gap-2">
10281068 {readOnly ? (
10291069 <span className="min-w-0 truncate text-xs text-muted-foreground">
10301070 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
......@@ -1073,7 +1113,11 @@ export function EnvironmentProviderSettings({
10731113 </>
10741114 )}
10751115 </div>
1076 </div>
1116 }
1117 >
1118 {deviceTabs ? (
1119 <div className="flex min-h-11 min-w-0 items-center gap-2 px-3 sm:px-4">{deviceTabs}</div>
1120 ) : null}
10771121 {readOnly ? (
10781122 <SettingsGroup divided={false} className="overflow-hidden">
10791123 <SettingsRow
apps/web/src/components/settings/SettingsScopeContext.tsx+22-6
......@@ -10,6 +10,7 @@ import { getProjectFileQueryAtom, optimisticFileAtom } from "../files/projectFil
1010import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
1111import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings";
1212import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
13import { selectSingleEnvironmentScope } from "./settingsScopeAxis";
1314
1415/**
1516 * Each member's decoded t3.json, so file-backed settings show the file as a
......@@ -53,10 +54,22 @@ function useMemberProjectFiles(scope: ReturnType<typeof resolveSettingsScope>) {
5354 );
5455}
5556
56function useResolvedSettingsScope(search: SettingsScopeSearch) {
57function useResolvedSettingsScope(rawSearch: SettingsScopeSearch, singleEnvironment: boolean) {
5758 const groups = useSettingsProjectGroups();
5859 const { environments: availableEnvironments } = useEnvironments();
5960 const primaryEnvironmentId = usePrimaryEnvironmentId();
61 const search = useMemo(
62 () =>
63 singleEnvironment
64 ? selectSingleEnvironmentScope(
65 rawSearch,
66 resolveSettingsScope(rawSearch, groups, availableEnvironments),
67 availableEnvironments,
68 primaryEnvironmentId,
69 )
70 : rawSearch,
71 [availableEnvironments, groups, primaryEnvironmentId, rawSearch, singleEnvironment],
72 );
6073 const scope = useMemo(
6174 () => resolveSettingsScope(search, groups, availableEnvironments),
6275 [availableEnvironments, groups, search],
......@@ -81,12 +94,13 @@ function useResolvedSettingsScope(search: SettingsScopeSearch) {
8194 ) ??
8295 targets[0] ??
8396 null;
84 return { scope, groups, ...selected, targets, target };
85 }, [availableEnvironments, groups, primaryEnvironmentId, projectFiles, scope]);
97 return { scope, groups, search, ...selected, targets, target };
98 }, [availableEnvironments, groups, primaryEnvironmentId, projectFiles, scope, search]);
8699}
87100
88101const SettingsScopeContext = createContext<
89102 | (ReturnType<typeof useResolvedSettingsScope> & {
103 singleEnvironment: boolean;
90104 search: SettingsScopeSearch;
91105 selectScope: (next: SettingsScopeSearch) => void;
92106 })
......@@ -97,15 +111,17 @@ export function SettingsScopeProvider({
97111 search,
98112 onChange,
99113 children,
114 singleEnvironment = false,
100115}: {
116 singleEnvironment?: boolean;
101117 search: SettingsScopeSearch;
102118 onChange: (next: SettingsScopeSearch) => void;
103119 children: ReactNode;
104120}) {
105 const resolved = useResolvedSettingsScope(search);
121 const resolved = useResolvedSettingsScope(search, singleEnvironment);
106122 const value = useMemo(
107 () => ({ ...resolved, search, selectScope: onChange }),
108 [onChange, resolved, search],
123 () => ({ ...resolved, singleEnvironment, selectScope: onChange }),
124 [onChange, resolved, singleEnvironment],
109125 );
110126 return <SettingsScopeContext value={value}>{children}</SettingsScopeContext>;
111127}
apps/web/src/components/settings/SettingsScopeSentence.tsx+24-10
......@@ -40,6 +40,7 @@ interface SettingsScopeMenuProps {
4040 readonly value: SettingsScopeSearch;
4141 readonly groups: readonly SidebarProjectSnapshot[];
4242 readonly environments: readonly EnvironmentPresentation[];
43 readonly singleEnvironment: boolean;
4344 readonly onChange: (next: SettingsScopeSearch) => void;
4445}
4546
......@@ -56,6 +57,7 @@ export function SettingsScopeSentence() {
5657 if (scope === null || SETTINGS_DEVICE_ONLY_PATHS.has(pathname)) return null;
5758 const props: SettingsScopeMenuProps = {
5859 value: scope.search,
60 singleEnvironment: scope.singleEnvironment,
5961 groups: scope.groups,
6062 environments,
6163 onChange: scope.selectScope,
......@@ -105,7 +107,13 @@ function ScopeMenu({
105107 );
106108}
107109
108function EnvironmentScopeMenu({ value, groups, environments, onChange }: SettingsScopeMenuProps) {
110function EnvironmentScopeMenu({
111 value,
112 groups,
113 environments,
114 onChange,
115 singleEnvironment,
116}: SettingsScopeMenuProps) {
109117 const resolved = resolveSettingsScope(value, groups, environments);
110118 const environmentValue = environmentAxisValue(
111119 value,
......@@ -131,7 +139,9 @@ function EnvironmentScopeMenu({ value, groups, environments, onChange }: Setting
131139 ? settingsScopeEnvironmentLabel(selected, environments)
132140 : environmentValue !== ALL_ENVIRONMENTS_VALUE
133141 ? "Unavailable environment"
134 : "All environments"
142 : singleEnvironment
143 ? "No environments"
144 : "All environments"
135145 }
136146 >
137147 <MenuRadioGroup
......@@ -140,14 +150,18 @@ function EnvironmentScopeMenu({ value, groups, environments, onChange }: Setting
140150 if (typeof next === "string") onChange(selectEnvironmentAxis(value, next));
141151 }}
142152 >
143 <MenuRadioItem value={ALL_ENVIRONMENTS_VALUE}>
144 <span className="flex min-w-0 items-center gap-2">
145 <LayersIcon aria-hidden className="size-3.5" />
146 <span className="min-w-0 flex-1 truncate">All environments</span>
147 <MenuRadioItemIndicator />
148 </span>
149 </MenuRadioItem>
150 <MenuSeparator />
153 {!singleEnvironment ? (
154 <>
155 <MenuRadioItem value={ALL_ENVIRONMENTS_VALUE}>
156 <span className="flex min-w-0 items-center gap-2">
157 <LayersIcon aria-hidden className="size-3.5" />
158 <span className="min-w-0 flex-1 truncate">All environments</span>
159 <MenuRadioItemIndicator />
160 </span>
161 </MenuRadioItem>
162 <MenuSeparator />
163 </>
164 ) : null}
151165 {environments.map((environment) => (
152166 <MenuRadioItem key={environment.environmentId} value={environment.environmentId}>
153167 <span className="flex min-w-0 items-center gap-2">
apps/web/src/components/settings/settingsScopeAxis.ts+23-1
......@@ -1,5 +1,5 @@
11import type { EnvironmentPresentation } from "../../state/environments";
2import type { SettingsScopeSearch } from "./settingsScope";
2import type { ResolvedSettingsScope, SettingsScopeSearch } from "./settingsScope";
33
44type ScopeEnvironment = Pick<EnvironmentPresentation, "environmentId" | "label" | "displayUrl">;
55
......@@ -53,3 +53,25 @@ export function selectProjectAxis(search: SettingsScopeSearch, value: string): S
5353 if (search.machine) next.machine = search.machine;
5454 return next;
5555}
56
57/** Provider configuration always belongs to one environment, including project scopes. */
58export function selectSingleEnvironmentScope(
59 search: SettingsScopeSearch,
60 scope: ResolvedSettingsScope,
61 environments: readonly {
62 readonly environmentId: EnvironmentPresentation["environmentId"];
63 readonly connection: Pick<EnvironmentPresentation["connection"], "phase">;
64 }[],
65 primaryEnvironmentId: string | null,
66): SettingsScopeSearch {
67 if (search.machine || scope.kind === "unavailable") return search;
68 const selectedIds = new Set(scope.environmentIds);
69 const candidates = environments.filter((environment) =>
70 selectedIds.has(environment.environmentId),
71 );
72 const selected =
73 candidates.find((environment) => environment.environmentId === primaryEnvironmentId) ??
74 candidates.find((environment) => environment.connection.phase === "connected") ??
75 candidates[0];
76 return selected ? { ...search, machine: selected.environmentId } : search;
77}
apps/web/src/components/ui/wizard.tsx+11-2
......@@ -8,10 +8,19 @@ import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter
88/** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */
99export function WizardPopup({
1010 children,
11 size = "default",
1112 ...props
12}: Omit<ComponentProps<typeof DialogPopup>, "className" | "style">) {
13}: Omit<ComponentProps<typeof DialogPopup>, "className" | "style"> & {
14 readonly size?: "default" | "wide";
15}) {
1316 return (
14 <DialogPopup {...props} className="max-w-xl overflow-x-hidden overflow-y-auto">
17 <DialogPopup
18 {...props}
19 className={cn(
20 "overflow-x-hidden overflow-y-auto",
21 size === "wide" ? "max-w-3xl" : "max-w-xl",
22 )}
23 >
1524 <div className="flex min-h-0 flex-col">{children}</div>
1625 </DialogPopup>
1726 );
apps/web/src/components/usage/UsageLimitsPooled.tsx+37-2
......@@ -1,5 +1,7 @@
11import {
2 CHATGPT_USAGE_URL,
23 collectLimitAccounts,
4 collectExternalUsageLinks,
35 collectLimitNotices,
46 collectLimitPools,
57 cursorUsageWindowDetails,
......@@ -11,9 +13,10 @@ import {
1113 type LimitPoolWindow,
1214 remainingPercent,
1315} from "@t3tools/shared/usageLimits";
14import { AlertTriangleIcon, TicketIcon } from "lucide-react";
16import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react";
1517import { Fragment, type ReactNode, useState } from "react";
1618
19import { ensureLocalApi } from "../../localApi";
1720import { usePrimarySettings } from "../../hooks/useSettings";
1821import { cn } from "../../lib/utils";
1922import { formatUpcomingTimestamp } from "../../timestampFormat";
......@@ -21,6 +24,7 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
2124import { getDriverOption } from "../settings/providerDriverMeta";
2225import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";
2326import { Button } from "../ui/button";
27import { OpenAI } from "../Icons";
2428import { Alert, AlertTitle } from "../ui/alert";
2529import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
2630import {
......@@ -573,6 +577,7 @@ export function UsageLimitsPooled({
573577}) {
574578 const pools = collectLimitPools(collectLimitAccounts(presentations), now);
575579 const notices = collectLimitNotices(presentations);
580 const externalLinks = collectExternalUsageLinks(presentations);
576581 const cursorPromptAt =
577582 Math.max(
578583 pools.findIndex((pool) => pool.driver === "codex"),
......@@ -580,7 +585,7 @@ export function UsageLimitsPooled({
580585 ) + 1;
581586 return (
582587 <div className="flex flex-col gap-8">
583 {pools.length === 0 && notices.length === 0 && !cursorPrompt ? (
588 {pools.length === 0 && notices.length === 0 && !cursorPrompt && externalLinks.length === 0 ? (
584589 <p className="text-sm text-muted-foreground">
585590 No provider on the selected environments reports subscription limits.
586591 </p>
......@@ -592,6 +597,36 @@ export function UsageLimitsPooled({
592597 </Fragment>
593598 ))}
594599 {cursorPromptAt === pools.length ? cursorPrompt : null}
600 {externalLinks.map((link) => (
601 <section
602 key={link.url}
603 className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-4"
604 >
605 <div className="flex min-w-0 flex-1 items-center gap-3">
606 {link.url === CHATGPT_USAGE_URL ? (
607 <OpenAI className="size-5 shrink-0" aria-hidden="true" />
608 ) : null}
609 <div className="min-w-0 space-y-1">
610 <h2 className="text-sm font-medium">{link.label}</h2>
611 {link.url === CHATGPT_USAGE_URL ? (
612 <p className="text-xs text-muted-foreground">
613 View usage in ChatGPT with your connected account.
614 </p>
615 ) : link.message ? (
616 <p className="max-w-xl text-xs text-muted-foreground">{link.message}</p>
617 ) : null}
618 </div>
619 </div>
620 <Button
621 variant="ghost-muted"
622 size="xs"
623 onClick={() => void ensureLocalApi().shell.openExternal(link.url)}
624 >
625 Manage usage
626 <ExternalLinkIcon className="size-3.5" aria-hidden="true" />
627 </Button>
628 </section>
629 ))}
595630 <LimitNotices notices={notices} />
596631 </div>
597632 );
apps/web/src/components/usage/UsagePage.tsx+13
......@@ -1,3 +1,5 @@
1import { ChatGptUsageButton } from "../settings/ChatGptUsageButton";
2import { usesChatGptSharing } from "@t3tools/shared/usageLimits";
13import { RefreshIcon } from "~/components/ui/refresh-icon";
24import { useAtomValue } from "@effect/atom-react";
35import {
......@@ -516,6 +518,17 @@ export function UsagePage() {
516518 </span>
517519 </div>
518520
521 {[...presentations].some(
522 ([id, presentation]) =>
523 (selectedEnvironmentIds === null || selectedEnvironmentIds.has(id)) &&
524 presentation.serverConfig?.providers.some(usesChatGptSharing),
525 ) ? (
526 <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
527 <span>ChatGPT shared usage</span>
528 <ChatGptUsageButton size="xs" />
529 </div>
530 ) : null}
531
519532 {summaryRows.map((row) => {
520533 if (row.kind === "enable") {
521534 return (
apps/web/src/main.tsx+3
......@@ -4,6 +4,7 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"
44
55import "./index.css";
66
7import { prepareProviderAuthDelivery } from "./providerAuthDelivery";
78import { isElectron } from "./env";
89import { hasCloudPublicConfig } from "./cloud/publicConfig";
910import { getRouter } from "./router";
......@@ -14,6 +15,8 @@ import {
1415import { AppRoot } from "./AppRoot";
1516import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard";
1617
18prepareProviderAuthDelivery();
19
1720// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
1821const history = isElectron ? createHashHistory() : createBrowserHistory();
1922
apps/web/src/onboarding/providerReadiness.logic.ts+2
......@@ -36,6 +36,8 @@ function quoteProviderBinary(
3636export function getOnboardingProviderState(provider: ServerProvider | undefined) {
3737 if (provider === undefined) return "checking";
3838 if (!provider.enabled || provider.status === "disabled") return "disabled";
39 if (!provider.installed && provider.status === "warning" && provider.auth.status === "unknown")
40 return "checking";
3941 if (!provider.installed) return "install";
4042 if (provider.auth.status === "unauthenticated") return "signIn";
4143 if (provider.status === "ready") return "ready";
apps/web/src/providerAuthDelivery.ts created+19
......@@ -0,0 +1,19 @@
1import { readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff";
2
3let pending: ReturnType<typeof readCodexAuthDelivery>;
4
5/** Remove the code before the router, tracing, or welcome screen sees the incoming address. */
6export function prepareProviderAuthDelivery() {
7 if (!window.location.hash.startsWith("#codex-auth=")) return;
8 pending = readCodexAuthDelivery(window.location.href);
9 window.history.replaceState(
10 window.history.state,
11 "",
12 pending?.returnUrl ?? `${window.location.pathname}${window.location.search}`,
13 );
14}
15
16export const pendingProviderAuthDelivery = () => pending;
17export const clearProviderAuthDelivery = () => {
18 pending = undefined;
19};
apps/web/src/routes/__root.tsx+5
......@@ -32,6 +32,8 @@ import { ThreadNotificationCoordinator } from "../components/ThreadNotificationC
3232import { QueuedMessageSender } from "../components/QueuedMessageSender";
3333import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";
3434import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
35import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator";
36import { ProviderAuthCallbackCoordinator } from "../components/settings/ProviderAuthCallbackCoordinator";
3537import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
3638import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
3739import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme";
......@@ -176,6 +178,7 @@ function RootRouteView() {
176178 <EnvironmentThemeSync />
177179 <GlassAppearanceSync />
178180 <FontAppearanceSync />
181 <ProviderAuthCallbackCoordinator />
179182 <CustomSnoozeDialogHost />
180183 <CommandPalette>
181184 <AppSidebarLayout>
......@@ -216,6 +219,8 @@ function RootRouteView() {
216219 <EnvironmentThemeSync />
217220 <GlassAppearanceSync />
218221 <FontAppearanceSync />
222 <ProviderAuthCallbackCoordinator />
223 <ChatGptWelcomeCoordinator />
219224 <FirstRunGate
220225 enabled={primaryEnvironmentAuthenticated}
221226 hostedStatic={authGateState.status === "hosted-static"}
apps/web/src/routes/settings.providers.tsx+2-2
......@@ -6,8 +6,8 @@ import { useSettingsScope } from "../components/settings/SettingsScopeContext";
66
77/**
88 * Providers are machine state, so the page shows one environment at a time:
9 * the chosen one, or the representative of the selection. A project crumb
10 * narrows the candidates to the environments that project is registered on.
9 * the chosen one from the settings scope selector.
10 * A project crumb narrows candidates to where that project is registered.
1111 */
1212function SettingsProvidersRoute() {
1313 const target = Route.useSearch();
apps/web/src/routes/settings.tsx+1
......@@ -153,6 +153,7 @@ function SettingsRouteLayout() {
153153 return (
154154 <SettingsScopeProvider
155155 search={rawSearch}
156 singleEnvironment={pathname === "/settings/providers"}
156157 onChange={(next) => {
157158 // Send every axis so the retain middleware sees an explicit target
158159 // even when the choice is "all", which is the absence of a key.
apps/web/src/routes/welcome.tsx+10
......@@ -1,10 +1,15 @@
11import { createFileRoute, redirect, useLocation, useNavigate } from "@tanstack/react-router";
22import { useState } from "react";
3import { EnvironmentId } from "@t3tools/contracts";
4import * as Schema from "effect/Schema";
5import * as Option from "effect/Option";
36
47import { NoProjectsHero } from "../components/NoProjectsHero";
58import { WelcomeWizard } from "../components/onboarding/WelcomeWizard";
69import { useNewThreadHandler } from "../hooks/useHandleNewThread";
710
11const decodeEnvironmentId = Schema.decodeOption(EnvironmentId);
12
813/** Onboarding overlays the workspace. Visiting /welcome reopens setup. */
914export const Route = createFileRoute("/welcome")({
1015 beforeLoad: ({ context }) => {
......@@ -19,6 +24,10 @@ export const Route = createFileRoute("/welcome")({
1924function WelcomeRouteView() {
2025 const { authGateState } = Route.useRouteContext();
2126 const navigate = useNavigate();
27 const hash = useLocation({ select: (location) => location.hash });
28 const resumeEnvironmentId = hash.startsWith("agents:")
29 ? Option.getOrUndefined(decodeEnvironmentId(hash.slice("agents:".length)))
30 : undefined;
2231 // The root shell can remount this pending outlet after the location changes.
2332 // Never reopen setup while the destination route is still loading.
2433 const isWelcomeRoute = useLocation({ select: (location) => location.pathname === "/welcome" });
......@@ -35,6 +44,7 @@ function WelcomeRouteView() {
3544 {isWelcomeRoute && !dismissed ? (
3645 <WelcomeWizard
3746 localAvailable={localAvailable}
47 resumeEnvironmentId={resumeEnvironmentId}
3848 onDone={async (projectRef) => {
3949 setDismissed(true);
4050 if (projectRef !== undefined) {
docs/internals/providers.md+7
......@@ -48,6 +48,13 @@ loopback listener may be on another machine. Forward only the callback for the o
4848a successful callback HTTP request is not proof that provider authentication finished. The native
4949process owns token exchange and storage.
5050
51Managed ChatGPT sign-in for a remote environment can finish on a local primary. The
52[primary handoff](../../apps/server/src/provider/CodexChatGptHandoff.ts) uses an ephemeral
53credential store and the destination's environment ID. It exchanges and verifies the code before
54transferring the issued client registration and tokens. Only the destination persists and refreshes
55that session; retaining a primary refresh session would race refresh-token rotation. Without a local
56primary, the client uses the remote callback completion flow.
57
5158Antigravity sign-out closes admission to new processes and stops existing processes before clearing account
5259metadata. Otherwise a helper or resumed session could retain the old account. Cached model lists
5360do not establish current access, and an authoritative empty catalog must clear the old list.
docs/user/install.md+10-9
......@@ -111,19 +111,20 @@ and enable the provider you want. Installation, login, and configuration belong
111111to that environment's machine, even when you connect from a phone or another
112112computer.
113113
114| Provider | Install and authenticate |
115| ----------- | -------------------------------------------------------------------------------------------- |
116| Codex | Install [Codex CLI](https://developers.openai.com/codex/cli), then run `codex login`. |
117| Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. |
118| Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. |
119| Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. |
120| OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. |
121| Antigravity | Install and sign in with Google from T3 Code's provider settings. |
114| Provider | Install and authenticate |
115| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
116| Codex | [Connect with ChatGPT](./providers-codex.md#connect-with-chatgpt), or install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`. |
117| Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. |
118| Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. |
119| Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. |
120| OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. |
121| Antigravity | Install and sign in with Google from T3 Code's provider settings. |
122122
123123Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its
124124**Binary path** in provider settings, especially when using a version manager.
125125Cursor's executable is `cursor-agent`, although its login command is
126`agent login`. Antigravity can use its managed runtime without a `PATH` entry.
126`agent login`. Codex connected through ChatGPT and Antigravity can use their
127managed runtimes without a `PATH` entry.
127128
128129T3 Code warns when a provider version has known compatibility problems with your
129130release. Check **Settings → Providers** on that environment for the recommended
docs/user/providers-codex.md+30-3
......@@ -1,11 +1,38 @@
11# Codex
22
3For one account, use the default Codex provider with your normal Codex login.
4[Provider setup](./install.md#providers) covers installation, Settings > Providers,
5and custom binaries or environment variables.
3Use your ChatGPT plan or an existing Codex CLI login to code in T3 Code.
4
5## Connect with ChatGPT
6
7Connect during onboarding or in **Settings → Providers**. For a remote machine,
8select that environment first. T3 Code handles Codex installation; sign in on
9OpenAI and allow sharing of your ChatGPT plan.
10
11Manage shared usage and credits in ChatGPT through **Manage usage** in T3 Code.
12If a request uses a feature that ChatGPT sharing does not support, use another
13provider for that request.
14
15When reconnecting, choose the same account in T3 Code and on OpenAI's sign-in
16page. Disconnecting stops running threads but keeps their history and lets you
17reconnect later.
18
19If remote sign-in cannot return automatically, paste the full URL from the final
20localhost page into the sign-in panel, even if that page could not load.
21
22## Use an existing Codex login
23
24T3 Code can use your installed Codex and its existing login. Run `codex login`
25on the environment's machine to sign in. [Provider setup](./install.md#providers)
26covers installation and custom configuration.
627
728## Use multiple accounts
829
30Add another ChatGPT account in **Settings → Providers**, then select the account
31from the thread's model picker. Compatible accounts can continue the same thread.
32Connecting accounts through T3 Code leaves your CLI login unchanged.
33
34### Multiple CLI logins
35
936A shared Codex home with a shadow home lets work and personal accounts continue
1037the same threads. The accounts share Codex sessions and configuration while keeping
1138their own login and available models.
packages/client-runtime/src/rpc/client.ts+2
......@@ -41,6 +41,7 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string;
4141type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];
4242
4343export type EnvironmentSubscriptionRpcTag =
44 | typeof WS_METHODS.codexAuthCallbackSubscribe
4445 | typeof WS_METHODS.providerAuthSubscribe
4546 | typeof WS_METHODS.providerInstallSubscribe
4647 | typeof ORCHESTRATION_WS_METHODS.subscribeShell
......@@ -62,6 +63,7 @@ export type EnvironmentSubscriptionRpcTag =
6263 | typeof WS_METHODS.terminalAttach;
6364
6465export type EnvironmentStreamCommandRpcTag =
66 | typeof WS_METHODS.chatGptHandoffSubscribe
6567 | typeof WS_METHODS.cloudInstallRelayClient
6668 | typeof WS_METHODS.serverUpdateServerWithProgress
6769 | typeof WS_METHODS.gitRunStackedAction;
packages/client-runtime/src/state/runtime.ts+6-1
......@@ -60,6 +60,7 @@ interface EnvironmentQueryAtomOptions<Input, A, E, R> extends EnvironmentAtomOpt
6060}
6161
6262interface EnvironmentSubscriptionAtomOptions<Input, A, E, R> {
63 readonly sensitiveInput?: boolean;
6364 readonly label: string;
6465 readonly subscribe: (input: Input) => Stream.Stream<A, E, R>;
6566 readonly idleTtlMs?: number;
......@@ -581,7 +582,11 @@ export function createEnvironmentSubscriptionAtomFamily<R, ER, Input, A, E>(
581582 .atom(followStreamInEnvironment(target.environmentId, options.subscribe(target.input)))
582583 .pipe(
583584 Atom.setIdleTTL(options.idleTtlMs ?? 5 * 60_000),
584 Atom.withLabel(`${options.label}:${key}`),
585 Atom.withLabel(
586 options.sensitiveInput
587 ? `${options.label}:${target.environmentId}`
588 : `${options.label}:${key}`,
589 ),
585590 );
586591 });
587592 return (target: { readonly environmentId: EnvironmentIdType; readonly input: Input }) =>
packages/client-runtime/src/state/server.ts+22
......@@ -29,6 +29,7 @@ import {
2929 createEnvironmentQueryAtomFamily,
3030 createEnvironmentRpcQueryAtomFamily,
3131 createEnvironmentRpcSubscriptionAtomFamily,
32 createEnvironmentSubscriptionAtomFamily,
3233 createRuntimeCommand,
3334 scheduleAtomCommandEffect,
3435} from "./runtime.ts";
......@@ -994,6 +995,27 @@ export function createServerEnvironmentAtoms<R, E>(
994995 label: "environment-data:provider:auth-complete",
995996 tag: WS_METHODS.providerAuthComplete,
996997 }),
998 chatGptReconnectProfile: createEnvironmentRpcCommand(runtime, {
999 label: "environment-data:chatgpt:reconnect-profile",
1000 tag: WS_METHODS.chatGptReconnectProfile,
1001 }),
1002 chatGptImportProfile: createEnvironmentRpcCommand(runtime, {
1003 label: "environment-data:chatgpt:import-profile",
1004 tag: WS_METHODS.chatGptImportProfile,
1005 }),
1006 chatGptHandoffState: createEnvironmentSubscriptionAtomFamily(runtime, {
1007 label: "environment-data:chatgpt:handoff",
1008 sensitiveInput: true,
1009 // OAuth must not be replayed when the connection recovers.
1010 subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.chatGptHandoffSubscribe>) =>
1011 runStream(WS_METHODS.chatGptHandoffSubscribe, input),
1012 idleTtlMs: 0,
1013 }),
1014 codexAuthCallbackState: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
1015 label: "environment-data:codex:auth-callback",
1016 tag: WS_METHODS.codexAuthCallbackSubscribe,
1017 idleTtlMs: 0,
1018 }),
9971019 cancelProviderAuth: createEnvironmentRpcCommand(runtime, {
9981020 label: "environment-data:provider:auth-cancel",
9991021 tag: WS_METHODS.providerAuthCancel,
packages/contracts/src/ipc.ts+3
......@@ -1212,6 +1212,9 @@ export interface DesktopBridge {
12121212 items: readonly ContextMenuItem<T>[],
12131213 position?: { x: number; y: number },
12141214 ) => Promise<T | null>;
1215 /** Receives a local OAuth code for a sign-in owned by a remote environment. */
1216 receiveProviderAuthCallback?: (authorizationUrl: string) => Promise<string>;
1217 cancelProviderAuthCallback?: (authorizationUrl: string) => Promise<void>;
12151218 openExternal: (url: string) => Promise<boolean>;
12161219 /**
12171220 * Open a System Settings pane by identifier. Optional: older desktop builds
packages/contracts/src/providerRuntime.ts+1
......@@ -809,6 +809,7 @@ export type RuntimeWarningPayload = typeof RuntimeWarningPayload.Type;
809809
810810const RuntimeErrorPayload = Schema.Struct({
811811 message: TrimmedNonEmptyStringSchema,
812 code: Schema.optional(TrimmedNonEmptyStringSchema),
812813 class: Schema.optional(RuntimeErrorClass),
813814 detail: Schema.optional(Schema.Unknown),
814815});
packages/contracts/src/providerSetup.ts+75
......@@ -1,6 +1,7 @@
11import * as Schema from "effect/Schema";
22
33import {
4 EnvironmentId,
45 ForwardCompatibleArray,
56 ForwardCompatibleOptional,
67 IsoDateTime,
......@@ -13,10 +14,27 @@ export const ProviderSetupInput = Schema.Struct({
1314});
1415export type ProviderSetupInput = typeof ProviderSetupInput.Type;
1516
17export const CodexAuthCallbackInput = Schema.Struct({
18 authorizationUrl: Schema.String.check(Schema.isMaxLength(16_384)),
19 returnUrl: Schema.String.check(Schema.isMaxLength(4_096)),
20 environmentId: EnvironmentId,
21 instanceId: ProviderInstanceId,
22 flowId: TrimmedNonEmptyString.check(Schema.isMaxLength(128)),
23});
24export type CodexAuthCallbackInput = typeof CodexAuthCallbackInput.Type;
25export const CodexAuthCallbackState = Schema.Union([
26 Schema.Struct({ phase: Schema.Literal("ready") }),
27 Schema.Struct({
28 phase: Schema.Literal("finished"),
29 callbackUrl: Schema.String.check(Schema.isMaxLength(16_384)),
30 }),
31]);
32
1633const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
1734
1835export const ProviderAuthMethod = Schema.Struct({
1936 id: SetupOperationId,
37 accountEmail: Schema.optional(TrimmedNonEmptyString),
2038 name: TrimmedNonEmptyString,
2139 description: Schema.NullOr(Schema.String),
2240 type: Schema.Literals(["agent", "terminal", "credentials"]),
......@@ -86,6 +104,8 @@ export type ProviderAuthResponse = typeof ProviderAuthResponse.Type;
86104export const ProviderAuthStartInput = Schema.Struct({
87105 instanceId: ProviderInstanceId,
88106 methodId: Schema.optionalKey(SetupOperationId),
107 returnUrl: Schema.optionalKey(Schema.String),
108 callbackMode: Schema.optionalKey(Schema.Literals(["server", "client"])),
89109});
90110export type ProviderAuthStartInput = typeof ProviderAuthStartInput.Type;
91111
......@@ -153,6 +173,8 @@ export const ProviderInstallState = Schema.Struct({
153173 totalBytes: Schema.NullOr(ByteCount),
154174 version: Schema.NullOr(TrimmedNonEmptyString),
155175 installedVersion: Schema.NullOr(TrimmedNonEmptyString),
176 executablePath: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)),
177 source: Schema.optionalKey(Schema.NullOr(Schema.Literals(["managed", "local"]))),
156178 canRemove: Schema.Boolean,
157179 message: Schema.NullOr(Schema.String),
158180});
......@@ -178,3 +200,56 @@ export class ProviderSetupError extends Schema.TaggedError<ProviderSetupError>()
178200 return this.detail;
179201 }
180202}
203
204// A selected registration is reused on the primary without copying refresh ownership.
205export const ChatGptReconnectProfile = Schema.Struct({
206 clientId: Schema.String.check(Schema.isPattern(/^oaiapp_[\w-]+$/u)),
207 subject: Schema.optionalKey(Schema.String),
208 email: Schema.optionalKey(Schema.NullOr(Schema.String)),
209 redirectUri: Schema.optionalKey(
210 Schema.String.check(
211 Schema.isPattern(/^http:\/\/(?:127\.0\.0\.1|localhost):[1-9]\d{0,4}\/auth\/callback$/u),
212 ),
213 ),
214 connectionLabel: Schema.optionalKey(Schema.String),
215 sharingEnabled: Schema.optionalKey(Schema.Boolean),
216 idTokenHint: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(16_384))),
217});
218export type ChatGptReconnectProfile = typeof ChatGptReconnectProfile.Type;
219export const ChatGptTransferredProfile = Schema.Struct({
220 registration: ChatGptReconnectProfile,
221 credentials: Schema.Struct({
222 clientId: Schema.String,
223 accessToken: Schema.NonEmptyString.check(Schema.isMaxLength(16_384)),
224 refreshToken: Schema.NullOr(Schema.String.check(Schema.isMaxLength(16_384))),
225 idToken: Schema.NonEmptyString.check(Schema.isMaxLength(16_384)),
226 issuer: Schema.String,
227 expiresAt: Schema.Finite,
228 earliestRefreshAt: Schema.NullOr(Schema.Finite),
229 scopes: Schema.Array(Schema.String),
230 subject: Schema.String,
231 email: Schema.NullOr(Schema.String),
232 }),
233});
234export type ChatGptTransferredProfile = typeof ChatGptTransferredProfile.Type;
235export const ChatGptReconnectProfileInput = Schema.Struct({
236 instanceId: ProviderInstanceId,
237 methodId: Schema.String,
238});
239export const ChatGptImportProfileInput = Schema.Struct({
240 instanceId: ProviderInstanceId,
241 profile: ChatGptTransferredProfile,
242});
243export const ChatGptHandoffInput = Schema.Struct({
244 instanceId: ProviderInstanceId,
245 environmentId: EnvironmentId,
246 attemptId: Schema.String.check(Schema.isMaxLength(128)),
247 returnUrl: Schema.String.check(Schema.isMaxLength(4_096)),
248 profile: Schema.NullOr(ChatGptReconnectProfile),
249});
250export type ChatGptHandoffInput = typeof ChatGptHandoffInput.Type;
251export const ChatGptHandoffState = Schema.Union([
252 Schema.Struct({ phase: Schema.Literal("auth"), state: ProviderAuthState }),
253 Schema.Struct({ phase: Schema.Literal("finished"), profile: ChatGptTransferredProfile }),
254]);
255export type ChatGptHandoffState = typeof ChatGptHandoffState.Type;
packages/contracts/src/providerUsageLimits.ts+7
......@@ -51,6 +51,13 @@ export const ServerProviderUsageLimits = Schema.Struct({
5151 checkedAt: IsoDateTime,
5252 windows: ForwardCompatibleArray(ServerProviderUsageWindow),
5353 resetCredits: Schema.optional(ServerProviderResetCredits),
54 /** Provider-owned usage settings when quota windows are not available to the client. */
55 externalUsage: Schema.optional(
56 Schema.Struct({
57 label: TrimmedNonEmptyString,
58 url: TrimmedNonEmptyString,
59 }),
60 ),
5461 unavailable: Schema.optional(
5562 Schema.Struct({
5663 reason: Schema.Literals(["unsupported", "probeFailed"]),
packages/contracts/src/rpc.ts+40
......@@ -1,8 +1,17 @@
1import {
2 ChatGptReconnectProfileInput,
3 ChatGptReconnectProfile,
4 ChatGptImportProfileInput,
5 ChatGptHandoffInput,
6 ChatGptHandoffState,
7} from "./providerSetup.ts";
18import * as Schema from "effect/Schema";
29import * as Rpc from "effect/unstable/rpc/Rpc";
310import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
411import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
512import {
13 CodexAuthCallbackInput,
14 CodexAuthCallbackState,
615 ProviderAuthCancelInput,
716 ProviderAuthCompleteInput,
817 ProviderAuthState,
......@@ -301,6 +310,10 @@ export const WS_METHODS = {
301310 providerAuthStart: "provider.auth.start",
302311 providerConsumeResetCredit: "provider.consumeResetCredit",
303312 providerAuthComplete: "provider.auth.complete",
313 chatGptReconnectProfile: "provider.chatgpt.reconnect-profile",
314 chatGptImportProfile: "provider.chatgpt.import-profile",
315 chatGptHandoffSubscribe: "provider.chatgpt.handoff.subscribe",
316 codexAuthCallbackSubscribe: "provider.codex.auth-callback.subscribe",
304317 providerAuthRespond: "provider.auth.respond",
305318 providerAuthCancel: "provider.auth.cancel",
306319 providerAuthLogout: "provider.auth.logout",
......@@ -519,6 +532,29 @@ const WsProviderAuthCompleteRpc = Rpc.make(WS_METHODS.providerAuthComplete, {
519532 error: ProviderSetupRpcError,
520533});
521534
535const WsChatGptReconnectProfileRpc = Rpc.make(WS_METHODS.chatGptReconnectProfile, {
536 payload: ChatGptReconnectProfileInput,
537 success: Schema.NullOr(ChatGptReconnectProfile),
538 error: ProviderSetupRpcError,
539});
540const WsChatGptImportProfileRpc = Rpc.make(WS_METHODS.chatGptImportProfile, {
541 payload: ChatGptImportProfileInput,
542 success: ProviderAuthState,
543 error: ProviderSetupRpcError,
544});
545const WsChatGptHandoffSubscribeRpc = Rpc.make(WS_METHODS.chatGptHandoffSubscribe, {
546 payload: ChatGptHandoffInput,
547 success: ChatGptHandoffState,
548 error: ProviderSetupRpcError,
549 stream: true,
550});
551const WsCodexAuthCallbackSubscribeRpc = Rpc.make(WS_METHODS.codexAuthCallbackSubscribe, {
552 payload: CodexAuthCallbackInput,
553 success: CodexAuthCallbackState,
554 error: ProviderSetupRpcError,
555 stream: true,
556});
557
522558const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, {
523559 payload: ProviderAuthCancelInput,
524560 success: ProviderAuthState,
......@@ -1399,6 +1435,10 @@ export const WsRpcGroup = RpcGroup.make(
13991435 WsProviderConsumeResetCreditRpc,
14001436 WsProviderAuthStartRpc,
14011437 WsProviderAuthCompleteRpc,
1438 WsChatGptReconnectProfileRpc,
1439 WsChatGptImportProfileRpc,
1440 WsChatGptHandoffSubscribeRpc,
1441 WsCodexAuthCallbackSubscribeRpc,
14021442 WsProviderAuthRespondRpc,
14031443 WsProviderAuthCancelRpc,
14041444 WsProviderAuthLogoutRpc,
packages/contracts/src/server.ts+8
......@@ -63,6 +63,8 @@ export const ServerProviderAuth = Schema.Struct({
6363 type: Schema.optional(TrimmedNonEmptyString),
6464 label: Schema.optional(TrimmedNonEmptyString),
6565 email: Schema.optional(TrimmedNonEmptyString),
66 subscriptionSharing: Schema.optional(Schema.Boolean),
67 profileId: Schema.optional(TrimmedNonEmptyString),
6668});
6769export type ServerProviderAuth = typeof ServerProviderAuth.Type;
6870
......@@ -226,6 +228,12 @@ export const ServerProvider = Schema.Struct({
226228 canInstall: Schema.Boolean,
227229 }),
228230 ),
231 runtimePaths: Schema.optionalKey(
232 Schema.Struct({
233 homePath: TrimmedNonEmptyString,
234 shadowHomePath: Schema.NullOr(TrimmedNonEmptyString),
235 }),
236 ),
229237 enabled: Schema.Boolean,
230238 installed: Schema.Boolean,
231239 version: Schema.NullOr(TrimmedNonEmptyString),
packages/contracts/src/settings.ts+3
......@@ -576,6 +576,9 @@ function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fields>(
576576
577577export const CodexSettings = makeProviderSettingsSchema(
578578 {
579 setupMode: Schema.optionalKey(Schema.Literals(["managed", "existing"])).pipe(
580 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
581 ),
579582 enabled: Schema.Boolean.pipe(
580583 Schema.withDecodingDefault(Effect.succeed(true)),
581584 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
packages/shared/package.json+12
......@@ -346,6 +346,18 @@
346346 "./gitPatchPath": {
347347 "types": "./src/gitPatchPath.ts",
348348 "import": "./src/gitPatchPath.ts"
349 },
350 "./providerAuthReturnUrl": {
351 "types": "./src/providerAuthReturnUrl.ts",
352 "import": "./src/providerAuthReturnUrl.ts"
353 },
354 "./codexAuthCallback": {
355 "types": "./src/codexAuthCallback.ts",
356 "import": "./src/codexAuthCallback.ts"
357 },
358 "./codexAuthHandoff": {
359 "types": "./src/codexAuthHandoff.ts",
360 "import": "./src/codexAuthHandoff.ts"
349361 }
350362 },
351363 "scripts": {
packages/shared/src/codexAuthCallback.ts created+100
......@@ -0,0 +1,100 @@
1// @effect-diagnostics nodeBuiltinImport:off globalTimers:off - Native loopback helper uses a bounded Node listener with AbortController cleanup.
2import * as Schema from "effect/Schema";
3import * as NodeHttp from "node:http";
4import { codexAuthorizationRequest, codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff";
5
6export class CodexAuthCallbackError extends Schema.TaggedError<CodexAuthCallbackError>()(
7 "CodexAuthCallbackError",
8 { detail: Schema.String },
9) {
10 override get message() {
11 return this.detail;
12 }
13}
14
15const listeners = new Map<string, AbortController>();
16
17export function cancelCodexAuthCallback(authorizationUrl: string) {
18 const { state } = codexAuthorizationRequest(authorizationUrl);
19 listeners.get(state)?.abort();
20}
21
22/** Receive an authorization code locally. Credentials and PKCE stay on the target environment. */
23export async function receiveCodexAuthCallback(
24 authorizationUrl: string,
25 openBrowser: (url: string) => Promise<boolean>,
26 destination?: (callbackUrl: string) => string,
27 signal?: AbortSignal,
28) {
29 const request = codexAuthorizationRequest(authorizationUrl);
30 if (listeners.has(request.state))
31 throw new Error("This sign-in is already open on this computer.");
32 const abort = new AbortController();
33 const interrupted = () => abort.abort();
34 signal?.addEventListener("abort", interrupted, { once: true });
35 listeners.set(request.state, abort);
36 const callback = Promise.withResolvers<string>();
37 // Keep early open/bind failures from leaving an unobserved rejection behind.
38 void callback.promise.catch(() => undefined);
39 const server = NodeHttp.createServer((incoming, response) => {
40 try {
41 if (incoming.method !== "GET") throw new Error("method");
42 const url = codexCallbackUrl(
43 new URL(incoming.url ?? "/", request.redirectUri).toString(),
44 request.redirectUri,
45 request.state,
46 ).toString();
47 const returnUrl = destination?.(url);
48 response.setHeader("cache-control", "no-store");
49 response.setHeader("referrer-policy", "no-referrer");
50 response.setHeader("x-content-type-options", "nosniff");
51 if (returnUrl) {
52 response.writeHead(303, { location: returnUrl }).end();
53 } else {
54 response.setHeader(
55 "content-security-policy",
56 "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'",
57 );
58 response
59 .writeHead(200, { "content-type": "text/html; charset=utf-8" })
60 .end(
61 '<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>T3 Code</title><style>body{font-family:system-ui;display:grid;place-items:center;min-height:90vh;margin:0}main{max-width:360px;padding:32px}h1{font-size:24px}p{line-height:1.6;opacity:.7}</style></head><body><main><h1>Return to T3 Code</h1><p>Your sign-in response has been received. T3 Code is finishing the connection. You can close this tab.</p></main></body></html>',
62 );
63 }
64 callback.resolve(url);
65 } catch {
66 response.writeHead(400).end("This response does not belong to the active sign-in.");
67 }
68 });
69 const cancelled = () => callback.reject(new Error("Sign-in cancelled on this computer."));
70 abort.signal.addEventListener("abort", cancelled, { once: true });
71 const timer = setTimeout(
72 () => callback.reject(new Error("Sign-in expired. Try again.")),
73 300_000,
74 );
75 timer.unref();
76 try {
77 if (signal?.aborted) throw new Error("Sign-in cancelled on this computer.");
78 await new Promise<void>((resolve, reject) => {
79 server.once("error", () =>
80 reject(
81 new Error(
82 "The ChatGPT callback port is in use on this computer. Close the other sign-in and try again, or paste the redirect URL in T3 Code.",
83 ),
84 ),
85 );
86 server.listen(Number(new URL(request.redirectUri).port), "127.0.0.1", resolve);
87 });
88 if (abort.signal.aborted) throw new Error("Sign-in cancelled on this computer.");
89 if (!(await openBrowser(request.authorizationUrl)))
90 throw new Error("Could not open your sign-in browser.");
91 return await callback.promise;
92 } finally {
93 clearTimeout(timer);
94 signal?.removeEventListener("abort", interrupted);
95 abort.signal.removeEventListener("abort", cancelled);
96 listeners.delete(request.state);
97 server.closeAllConnections();
98 await new Promise<void>((resolve) => server.close(() => resolve()));
99 }
100}
packages/shared/src/codexAuthHandoff.test.ts created+79
......@@ -0,0 +1,79 @@
1import { describe, expect, it } from "vite-plus/test";
2import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
3import {
4 codexAuthorizationRequest,
5 codexAuthDeliveryUrl,
6 codexAuthHandoffUrl,
7 readCodexAuthDelivery,
8 readCodexAuthHandoff,
9} from "./codexAuthHandoff.ts";
10
11const authorizationUrl = () => {
12 const url = new URL("https://auth.openai.com/api/accounts/authorize");
13 url.search = new URLSearchParams({
14 client_id: "dynamic_agent_client",
15 response_type: "code",
16 redirect_uri: "http://127.0.0.1:54213/auth/callback",
17 state: "a".repeat(43),
18 code_challenge_method: "S256",
19 code_challenge: "b".repeat(43),
20 }).toString();
21 return url.toString();
22};
23const input = {
24 authorizationUrl: authorizationUrl(),
25 returnUrl: "https://app.t3.codes/welcome#agents:remote-environment",
26 environmentId: EnvironmentId.make("remote-environment"),
27 instanceId: ProviderInstanceId.make("work-codex"),
28 flowId: "flow-one",
29};
30const callbackUrl = `http://127.0.0.1:54213/auth/callback?state=${"a".repeat(43)}&code=one-time-code&client_id=oaiapp_test`;
31
32describe("Codex desktop handoff", () => {
33 it("keeps the hosted return route, account, and environment with the code in a fragment", () => {
34 expect(readCodexAuthHandoff(codexAuthHandoffUrl(input), false)).toEqual(input);
35 const delivery = codexAuthDeliveryUrl(input, callbackUrl);
36 expect(new URL(delivery).search).toBe("");
37 expect(readCodexAuthDelivery(delivery)).toEqual({
38 callbackUrl,
39 environmentId: input.environmentId,
40 instanceId: input.instanceId,
41 flowId: input.flowId,
42 returnHash: "#agents:remote-environment",
43 returnUrl: input.returnUrl,
44 });
45 });
46 it("rejects other handlers, schemes, arbitrary return sites, and non-OpenAI authorization", () => {
47 const link = codexAuthHandoffUrl(input);
48 expect(readCodexAuthHandoff(link, true)).toBeUndefined();
49 expect(readCodexAuthHandoff(link.replace("auth/codex", "auth/other"), false)).toBeUndefined();
50 expect(
51 readCodexAuthHandoff(
52 codexAuthHandoffUrl({ ...input, returnUrl: "https://attacker.example/welcome" }),
53 false,
54 ),
55 ).toBeUndefined();
56 expect(
57 readCodexAuthHandoff(
58 codexAuthHandoffUrl({
59 ...input,
60 authorizationUrl: input.authorizationUrl.replace("auth.openai.com", "attacker.example"),
61 }),
62 false,
63 ),
64 ).toBeUndefined();
65 });
66 it("rejects duplicated authorization parameters and non-loopback callback addresses", () => {
67 expect(() =>
68 codexAuthorizationRequest(
69 input.authorizationUrl + "&redirect_uri=http://localhost:1/auth/callback",
70 ),
71 ).toThrow();
72 const url = new URL(input.authorizationUrl);
73 url.searchParams.set("redirect_uri", "http://localhost:54213/auth/callback");
74 expect(() => codexAuthorizationRequest(url.toString())).toThrow();
75 url.searchParams.set("redirect_uri", "https://attacker.example/auth/callback");
76 expect(() => codexAuthorizationRequest(url.toString())).toThrow();
77 expect(() => codexAuthDeliveryUrl(input, callbackUrl + "&state=another")).toThrow();
78 });
79});
packages/shared/src/codexAuthHandoff.ts created+149
......@@ -0,0 +1,149 @@
1import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3import { providerAuthReturnUrl } from "./providerAuthReturnUrl.ts";
4
5export const CodexAuthHandoff = Schema.Struct({
6 authorizationUrl: Schema.String.check(Schema.isMaxLength(16_384)),
7 returnUrl: Schema.String.check(Schema.isMaxLength(4_096)),
8 environmentId: EnvironmentId,
9 instanceId: ProviderInstanceId,
10 flowId: Schema.NonEmptyString.check(Schema.isMaxLength(128)),
11});
12export type CodexAuthHandoff = typeof CodexAuthHandoff.Type;
13const Delivery = Schema.Struct({
14 environmentId: EnvironmentId,
15 instanceId: ProviderInstanceId,
16 flowId: Schema.NonEmptyString.check(Schema.isMaxLength(128)),
17 callbackUrl: Schema.String.check(Schema.isMaxLength(16_384)),
18 returnHash: Schema.String.check(Schema.isMaxLength(128)),
19});
20
21const encodeHandoff = Schema.encodeSync(Schema.fromJsonString(CodexAuthHandoff));
22const decodeHandoff = Schema.decodeUnknownSync(Schema.fromJsonString(CodexAuthHandoff));
23const encodeDelivery = Schema.encodeSync(Schema.fromJsonString(Delivery));
24const decodeDelivery = Schema.decodeUnknownSync(Schema.fromJsonString(Delivery));
25
26/** The helper only opens OpenAI's authorize endpoint and receives a loopback callback. */
27export function codexAuthorizationRequest(value: string) {
28 const url = new URL(value);
29 if (
30 value.length > 16_384 ||
31 url.origin !== "https://auth.openai.com" ||
32 url.pathname !== "/api/accounts/authorize" ||
33 url.username ||
34 url.password ||
35 url.hash
36 )
37 throw new Error("Invalid ChatGPT sign-in request.");
38 const single = (key: string) => {
39 const values = url.searchParams.getAll(key);
40 if (values.length !== 1 || !values[0]) throw new Error("Invalid ChatGPT sign-in request.");
41 return values[0];
42 };
43 const redirectUri = single("redirect_uri");
44 const redirect = new URL(redirectUri);
45 const state = single("state");
46 if (
47 !/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}\/auth\/callback$/u.test(redirectUri) ||
48 Number(redirect.port) > 65_535 ||
49 !/^[\w-]{16,128}$/u.test(state) ||
50 single("response_type") !== "code" ||
51 single("code_challenge_method") !== "S256" ||
52 !/^[\w-]{43}$/u.test(single("code_challenge")) ||
53 !/^(dynamic_agent_client|oaiapp_[\w-]+)$/u.test(single("client_id"))
54 )
55 throw new Error("Invalid ChatGPT sign-in request.");
56 return { authorizationUrl: url.toString(), redirectUri, state };
57}
58
59/** Validation is shared by the desktop listener and the environment receiving the code. */
60export function codexCallbackUrl(value: string, redirectUri: string, state: string) {
61 const callback = new URL(value);
62 const expected = new URL(redirectUri);
63 const states = callback.searchParams.getAll("state");
64 const codes = callback.searchParams.getAll("code");
65 const errors = callback.searchParams.getAll("error");
66 const clients = callback.searchParams.getAll("client_id");
67 if (
68 value.length > 16_384 ||
69 callback.origin !== expected.origin ||
70 callback.pathname !== expected.pathname ||
71 callback.username ||
72 callback.password ||
73 callback.hash ||
74 states.length !== 1 ||
75 states[0] !== state ||
76 clients.length > 1 ||
77 (clients.length === 1 && !/^oaiapp_[\w-]+$/u.test(clients[0]!)) ||
78 !(
79 (codes.length === 1 && Boolean(codes[0]) && errors.length === 0) ||
80 (errors.length === 1 && Boolean(errors[0]) && codes.length === 0)
81 )
82 )
83 throw new Error("This redirect URL does not belong to the current sign-in.");
84 return callback;
85}
86
87export function codexAuthHandoffUrl(input: CodexAuthHandoff, development = false) {
88 const url = new URL(`${development ? "t3code-dev" : "t3code"}://auth/codex`);
89 url.searchParams.set("request", encodeHandoff(input));
90 return url.toString();
91}
92
93export function readCodexAuthHandoff(value: string, development: boolean) {
94 try {
95 const url = new URL(value);
96 if (
97 value.length > 32_768 ||
98 url.protocol !== (development ? "t3code-dev:" : "t3code:") ||
99 url.host !== "auth" ||
100 url.pathname !== "/codex" ||
101 url.username ||
102 url.password ||
103 url.hash ||
104 url.searchParams.getAll("request").length !== 1
105 )
106 return undefined;
107 const input = decodeHandoff(url.searchParams.get("request"));
108 codexAuthorizationRequest(input.authorizationUrl);
109 if (!providerAuthReturnUrl(input.returnUrl)) return undefined;
110 return input;
111 } catch {
112 return undefined;
113 }
114}
115
116/** Codes travel in a fragment, never in hosted web requests or a token store on the helper. */
117export function codexAuthDeliveryUrl(input: CodexAuthHandoff, callbackUrl: string) {
118 const request = codexAuthorizationRequest(input.authorizationUrl);
119 codexCallbackUrl(callbackUrl, request.redirectUri, request.state);
120 const destination = providerAuthReturnUrl(input.returnUrl);
121 if (!destination) throw new Error("Invalid T3 Code return address.");
122 const url = new URL(destination);
123 const delivery = {
124 environmentId: input.environmentId,
125 instanceId: input.instanceId,
126 flowId: input.flowId,
127 callbackUrl,
128 returnHash: url.hash,
129 };
130 url.hash = `codex-auth=${encodeURIComponent(encodeDelivery(delivery))}`;
131 return url.toString();
132}
133
134export function readCodexAuthDelivery(value: string) {
135 try {
136 const url = new URL(value);
137 if (!url.hash.startsWith("#codex-auth=") || url.hash.length > 32_768) return undefined;
138 const input = decodeDelivery(decodeURIComponent(url.hash.slice("#codex-auth=".length)));
139 const destination = providerAuthReturnUrl(value);
140 if (!destination) return undefined;
141 const returnUrl = new URL(destination);
142 returnUrl.hash = input.returnHash;
143 const sanitized = providerAuthReturnUrl(returnUrl.toString());
144 if (!sanitized) return undefined;
145 return { ...input, returnUrl: sanitized };
146 } catch {
147 return undefined;
148 }
149}
packages/shared/src/providerAuthReturnUrl.test.ts created+26
......@@ -0,0 +1,26 @@
1import { describe, expect, it } from "vite-plus/test";
2import { providerAuthReturnUrl } from "./providerAuthReturnUrl.ts";
3
4describe("provider auth return destinations", () => {
5 it.each(["t3code", "t3code-dev"])(
6 "returns to %s Welcome and the selected settings instance",
7 (scheme) => {
8 expect(providerAuthReturnUrl(`${scheme}://app/welcome?code=secret#agents:machine-id`)).toBe(
9 `${scheme}://app/welcome#agents:machine-id`,
10 );
11 expect(
12 providerAuthReturnUrl(`${scheme}://app/settings/providers?instanceId=work&code=secret`),
13 ).toBe(`${scheme}://app/settings/providers?instanceId=work`);
14 },
15 );
16 it.each([
17 "t3code://attacker/welcome",
18 "t3code://app:123/welcome",
19 "t3code://app/auth/callback",
20 "t3code://user@ app/welcome",
21 "t3code://app/welcome/../evil",
22 "https://attacker.example/welcome",
23 "file:///welcome",
24 "javascript:alert(1)",
25 ])("rejects %s", (url) => expect(providerAuthReturnUrl(url)).toBeUndefined());
26});
packages/shared/src/providerAuthReturnUrl.ts created+34
......@@ -0,0 +1,34 @@
1import { isLoopbackHost } from "./preview.ts";
2
3/** Only return to a local client or the hosted T3 client, never an arbitrary OAuth-supplied URL. */
4export function providerAuthReturnUrl(value: string | undefined): string | undefined {
5 if (!value) return undefined;
6 try {
7 const url = new URL(value);
8 const desktop = ["t3code:", "t3code-dev:"].includes(url.protocol) && url.host === "app";
9 const web =
10 ["http:", "https:"].includes(url.protocol) &&
11 (isLoopbackHost(url.hostname) || url.origin === "https://app.t3.codes");
12 if (
13 url.username ||
14 url.password ||
15 (!desktop && !web) ||
16 (url.pathname !== "/welcome" &&
17 url.pathname !== "/settings" &&
18 !url.pathname.startsWith("/settings/"))
19 )
20 return undefined;
21 for (const key of Array.from(url.searchParams.keys())) {
22 if (
23 url.pathname === "/welcome" ||
24 !["machine", "project", "checkout", "environmentId", "instanceId"].includes(key)
25 ) {
26 url.searchParams.delete(key);
27 }
28 }
29 if (url.pathname !== "/welcome" || !/^#agents:[\w-]+$/u.test(url.hash)) url.hash = "";
30 return url.toString();
31 } catch {
32 return undefined;
33 }
34}
packages/shared/src/usageLimits.test.ts+110
......@@ -3,6 +3,8 @@ import {
33 ProviderDriverKind,
44 ProviderInstanceId,
55 type ServerProvider,
6 EventId,
7 type OrchestrationThreadActivity,
68 UsageLimitSourceId,
79} from "@t3tools/contracts";
810import { describe, expect, it } from "vite-plus/test";
......@@ -14,6 +16,7 @@ import {
1416 sameUsageLimitCommandCoverage,
1517 withUsageLimitsCommands,
1618 collectLimitAccounts,
19 collectExternalUsageLinks,
1720 collectLimitNotices,
1821 collectLimitPools,
1922 displayLimitWindows,
......@@ -23,6 +26,8 @@ import {
2326 paceOf,
2427 providersWithLimits,
2528 remainingPercent,
29 isChatGptUsageLimitError,
30 usesChatGptSharing,
2631} from "./usageLimits.ts";
2732
2833const now = Date.parse("2026-09-03T12:00:00.000Z");
......@@ -1056,3 +1061,108 @@ describe("isUsageLimitsCommand", () => {
10561061 expect(isUsageLimitsCommand("/usage")).toBe(false);
10571062 });
10581063});
1064
1065describe("external usage settings", () => {
1066 it("deduplicates destinations across accounts and environments without inventing quota pools", () => {
1067 const managed = provider({
1068 usageLimits: {
1069 checkedAt: "2026-09-03T11:00:00.000Z",
1070 windows: [],
1071 unavailable: { reason: "unsupported", message: "Track usage in ChatGPT." },
1072 externalUsage: { label: "ChatGPT usage", url: "https://chatgpt.com/#settings/Usage" },
1073 },
1074 });
1075 const presentations = new Map([
1076 [
1077 EnvironmentId.make("a"),
1078 {
1079 entry: { target: { label: "A" } },
1080 serverConfig: {
1081 providers: [managed, { ...managed, instanceId: ProviderInstanceId.make("personal") }],
1082 },
1083 },
1084 ],
1085 [
1086 EnvironmentId.make("b"),
1087 { entry: { target: { label: "B" } }, serverConfig: { providers: [managed] } },
1088 ],
1089 ]);
1090 expect(collectExternalUsageLinks(presentations)).toEqual([
1091 {
1092 ...managed.usageLimits!.externalUsage,
1093 message: "Track usage in ChatGPT.",
1094 accounts: [`${managed.instanceId} on A`, "personal on A", `${managed.instanceId} on B`],
1095 },
1096 ]);
1097 expect(collectLimitAccounts(presentations)).toEqual([]);
1098 expect(collectLimitNotices(presentations)).toEqual([]);
1099 });
1100 it("omits disabled, uninstalled and signed-out providers", () => {
1101 const managed = provider({
1102 usageLimits: {
1103 checkedAt: "2026-09-03T11:00:00.000Z",
1104 windows: [],
1105 externalUsage: { label: "ChatGPT usage", url: "https://chatgpt.com/#settings/Usage" },
1106 },
1107 });
1108 const presentations = new Map([
1109 [
1110 EnvironmentId.make("a"),
1111 {
1112 entry: { target: { label: "A" } },
1113 serverConfig: {
1114 providers: [
1115 { ...managed, enabled: false },
1116 { ...managed, installed: false },
1117 { ...managed, auth: { status: "unauthenticated" as const } },
1118 provider({}),
1119 ],
1120 },
1121 },
1122 ],
1123 ]);
1124 expect(collectExternalUsageLinks(presentations)).toEqual([]);
1125 });
1126});
1127
1128describe("ChatGPT sharing presentation", () => {
1129 it("requires verified sharing metadata rather than the Codex driver or login type", () => {
1130 const codex = provider({ auth: { status: "authenticated", type: "chatgpt" } });
1131 expect(usesChatGptSharing(codex)).toBe(false);
1132 expect(
1133 usesChatGptSharing({ ...codex, auth: { ...codex.auth, subscriptionSharing: true } }),
1134 ).toBe(true);
1135 expect(
1136 usesChatGptSharing({
1137 ...codex,
1138 auth: { status: "unauthenticated", subscriptionSharing: true },
1139 }),
1140 ).toBe(false);
1141 });
1142 it("only gives the matching current structured limit error a management action", () => {
1143 const limit: OrchestrationThreadActivity = {
1144 id: EventId.make("sharing-limit"),
1145 tone: "error",
1146 kind: "runtime.error",
1147 summary: "Runtime error",
1148 turnId: null,
1149 createdAt: "2026-09-03T12:00:00.000Z",
1150 payload: { code: "subscription_sharing_usage_limit_exceeded", message: "Limit reached" },
1151 };
1152 expect(isChatGptUsageLimitError([limit], "Limit reached")).toBe(true);
1153 expect(isChatGptUsageLimitError([limit], "A different failure")).toBe(false);
1154 expect(isChatGptUsageLimitError([limit], null)).toBe(false);
1155 expect(
1156 isChatGptUsageLimitError(
1157 [{ ...limit, payload: { message: "Limit reached" } }],
1158 "Limit reached",
1159 ),
1160 ).toBe(false);
1161 expect(
1162 isChatGptUsageLimitError(
1163 [limit, { ...limit, payload: { code: "unrelated", message: "Limit reached" } }],
1164 "Limit reached",
1165 ),
1166 ).toBe(false);
1167 });
1168});
packages/shared/src/usageLimits.ts+57
......@@ -13,6 +13,7 @@ import {
1313 type ServerProviderSlashCommand,
1414 isProviderAvailable,
1515 type ServerProvider,
16 type OrchestrationThreadActivity,
1617 type ServerProviderUsageLimits,
1718 type ServerProviderUsageWindow,
1819 type UsageLimitSourceSnapshots,
......@@ -24,6 +25,35 @@ const MINUTE = 60_000;
2425const HOUR = 60 * MINUTE;
2526const DAY = 24 * HOUR;
2627
28export const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage";
29const CHATGPT_USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded";
30
31export function usesChatGptSharing(provider: ServerProvider | null | undefined): boolean {
32 return provider?.auth.status === "authenticated" && provider.auth.subscriptionSharing === true;
33}
34
35/** A historical limit must not turn an unrelated current failure into a usage notice. */
36export function isChatGptUsageLimitError(
37 activities: readonly OrchestrationThreadActivity[],
38 error: string | null | undefined,
39): boolean {
40 if (!error) return false;
41 for (let index = activities.length - 1; index >= 0; index--) {
42 const activity = activities[index]!;
43 if (activity.kind !== "runtime.error") continue;
44 const payload = activity.payload;
45 return (
46 typeof payload === "object" &&
47 payload !== null &&
48 "code" in payload &&
49 payload.code === CHATGPT_USAGE_LIMIT_CODE &&
50 "message" in payload &&
51 payload.message === error
52 );
53 }
54 return false;
55}
56
2757export const CURSOR_USAGE_WINDOWS = [
2858 {
2959 id: "totalPercentUsed",
......@@ -79,6 +109,33 @@ export type LimitPresentations = ReadonlyMap<
79109 }
80110>;
81111
112/** One destination per service, even when several accounts or environments use it. */
113export function collectExternalUsageLinks(presentations: LimitPresentations) {
114 const links = new Map<
115 string,
116 {
117 readonly label: string;
118 readonly url: string;
119 readonly message: string | undefined;
120 readonly accounts: readonly string[];
121 }
122 >();
123 for (const presentation of presentations.values()) {
124 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
125 const external = provider.usageLimits?.externalUsage;
126 if (external && provider.auth.status === "authenticated") {
127 const account = `${provider.displayName ?? provider.instanceId} on ${presentation.entry.target.label}`;
128 links.set(external.url, {
129 ...external,
130 message: provider.usageLimits?.unavailable?.message,
131 accounts: [...new Set([...(links.get(external.url)?.accounts ?? []), account])],
132 });
133 }
134 }
135 }
136 return [...links.values()];
137}
138
82139function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null {
83140 const normalizedEmail = email?.trim().toLowerCase();
84141 return normalizedEmail ? `${driver}:${normalizedEmail}` : null;
pnpm-lock.yaml+21
......@@ -531,9 +531,15 @@ importers:
531531 effect:
532532 specifier: 4.0.0-rc.115
533533 version: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)
534 jose:
535 specifier: 'catalog:'
536 version: 6.2.2
534537 node-pty:
535538 specifier: ^1.2.0-beta.15
536539 version: 1.2.0-beta.15(patch_hash=f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414)
540 proper-lockfile:
541 specifier: 4.1.2
542 version: 4.1.2
537543 stream-chain:
538544 specifier: ^4.2.5
539545 version: 4.2.5
......@@ -568,6 +574,9 @@ importers:
568574 '@types/node':
569575 specifier: 24.12.4
570576 version: 24.12.4
577 '@types/proper-lockfile':
578 specifier: ^4.1.4
579 version: 4.1.4
571580 '@types/yauzl':
572581 specifier: ^3.4.0
573582 version: 3.4.0
......@@ -5411,6 +5420,9 @@ packages:
54115420 '@types/pngjs@6.0.5':
54125421 resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
54135422
5423 '@types/proper-lockfile@4.1.4':
5424 resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==}
5425
54145426 '@types/qs@6.15.1':
54155427 resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
54165428
......@@ -5431,6 +5443,9 @@ packages:
54315443 '@types/responselike@1.0.3':
54325444 resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
54335445
5446 '@types/retry@0.12.5':
5447 resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==}
5448
54345449 '@types/send@1.2.1':
54355450 resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
54365451
......@@ -15799,6 +15814,10 @@ snapshots:
1579915814 dependencies:
1580015815 '@types/node': 24.12.4
1580115816
15817 '@types/proper-lockfile@4.1.4':
15818 dependencies:
15819 '@types/retry': 0.12.5
15820
1580215821 '@types/qs@6.15.1': {}
1580315822
1580415823 '@types/range-parser@1.2.7': {}
......@@ -15819,6 +15838,8 @@ snapshots:
1581915838 dependencies:
1582015839 '@types/node': 24.12.4
1582115840
15841 '@types/retry@0.12.5': {}
15842
1582215843 '@types/send@1.2.1':
1582315844 dependencies:
1582415845 '@types/node': 24.12.4