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,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";
1import { assert, describe, it } from "@effect/vitest";6import { assert, describe, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";7import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
...@@ -22,14 +27,24 @@ vi.mock("@clerk/electron/storage", () => ({...@@ -22,14 +27,24 @@ vi.mock("@clerk/electron/storage", () => ({
22 storage: storageMock,27 storage: storageMock,
23}));28}));
2429
30import * as Option from "effect/Option";
25import * as Exit from "effect/Exit";31import * as Exit from "effect/Exit";
26import * as FileSystem from "effect/FileSystem";32import * as FileSystem from "effect/FileSystem";
27import * as ElectronApp from "../electron/ElectronApp.ts";33import * as ElectronApp from "../electron/ElectronApp.ts";
34import * as ElectronShell from "../electron/ElectronShell.ts";
28import * as ElectronWindow from "../electron/ElectronWindow.ts";35import * as ElectronWindow from "../electron/ElectronWindow.ts";
29import * as DesktopClerk from "./DesktopClerk.ts";36import * as DesktopClerk from "./DesktopClerk.ts";
30import * as DesktopEnvironment from "./DesktopEnvironment.ts";37import * 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) => {
33 const environment = DesktopEnvironment.DesktopEnvironment.of({48 const environment = DesktopEnvironment.DesktopEnvironment.of({
34 stateDir: "/tmp/t3-state",49 stateDir: "/tmp/t3-state",
35 isDevelopment,50 isDevelopment,
...@@ -51,6 +66,7 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => {...@@ -51,6 +66,7 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => {
51 Layer.mergeAll(66 Layer.mergeAll(
52 Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment),67 Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment),
53 Layer.succeed(ElectronApp.ElectronApp, electronApp),68 Layer.succeed(ElectronApp.ElectronApp, electronApp),
69 Layer.succeed(ElectronShell.ElectronShell, shell),
54 FileSystem.layerNoop({ exists: () => Effect.succeed(false) }),70 FileSystem.layerNoop({ exists: () => Effect.succeed(false) }),
55 ),71 ),
56 ),72 ),
...@@ -162,7 +178,7 @@ describe("DesktopClerk", () => {...@@ -162,7 +178,7 @@ describe("DesktopClerk", () => {
162178
163 assert.isTrue(Exit.isSuccess(exit));179 assert.isTrue(Exit.isSuccess(exit));
164 assert.equal(quit.mock.calls.length, 0);180 assert.equal(quit.mock.calls.length, 0);
165 assert.deepEqual(registeredEvents, ["second-instance"]);181 assert.deepEqual(registeredEvents, ["open-url", "second-instance"]);
166 }).pipe(182 }).pipe(
167 Effect.provide(makeDesktopClerkLayer()),183 Effect.provide(makeDesktopClerkLayer()),
168 Effect.provideService(ElectronApp.ElectronApp, electronApp),184 Effect.provideService(ElectronApp.ElectronApp, electronApp),
...@@ -198,3 +214,131 @@ describe("DesktopClerk", () => {...@@ -198,3 +214,131 @@ describe("DesktopClerk", () => {
198 );214 );
199 });215 });
200});216});
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";...@@ -7,6 +7,11 @@ import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
8import * as Scope from "effect/Scope";8import * 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";
10import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";15import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";
11import * as ElectronApp from "../electron/ElectronApp.ts";16import * as ElectronApp from "../electron/ElectronApp.ts";
12import * as ElectronProtocol from "../electron/ElectronProtocol.ts";17import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
...@@ -87,6 +92,7 @@ function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) {...@@ -87,6 +92,7 @@ function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) {
87export const make = Effect.gen(function* () {92export const make = Effect.gen(function* () {
88 const environment = yield* DesktopEnvironment.DesktopEnvironment;93 const environment = yield* DesktopEnvironment.DesktopEnvironment;
89 const electronApp = yield* ElectronApp.ElectronApp;94 const electronApp = yield* ElectronApp.ElectronApp;
95 const shell = yield* ElectronShell.ElectronShell;
9096
91 // Electron scopes the single-instance lock to the userData directory and97 // Electron scopes the single-instance lock to the userData directory and
92 // creates that directory when the lock is acquired. The SDK bridge takes98 // creates that directory when the lock is acquired. The SDK bridge takes
...@@ -136,13 +142,62 @@ export const make = Effect.gen(function* () {...@@ -136,13 +142,62 @@ export const make = Effect.gen(function* () {
136 return yield* Effect.interrupt;142 return yield* Effect.interrupt;
137 }143 }
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;
140 void runPromise(197 void runPromise(
141 Effect.gen(function* () {198 Effect.gen(function* () {
142 const mainWindow = yield* electronWindow.currentMainOrFirst;199 const mainWindow = yield* electronWindow.currentMainOrFirst;
143 if (Option.isSome(mainWindow)) {200 if (Option.isSome(mainWindow)) yield* electronWindow.reveal(mainWindow.value);
144 yield* electronWindow.reveal(mainWindow.value);
145 }
146 }),201 }),
147 );202 );
148 });203 });
apps/desktop/src/ipc/DesktopIpcHandlers.ts+3
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
22
3import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts";
3import * as DesktopIpc from "./DesktopIpc.ts";4import * as DesktopIpc from "./DesktopIpc.ts";
4import { installNotificationBadge } from "./methods/notificationBadge.ts";5import { installNotificationBadge } from "./methods/notificationBadge.ts";
5import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";6import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
...@@ -131,6 +132,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"...@@ -131,6 +132,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
131 yield* ipc.handle(setTheme);132 yield* ipc.handle(setTheme);
132 yield* ipc.handle(showContextMenu);133 yield* ipc.handle(showContextMenu);
133 yield* ipc.handle(openExternal);134 yield* ipc.handle(openExternal);
135 yield* ipc.handle(receiveProviderAuthCallback);
136 yield* ipc.handle(cancelProviderAuthCallback);
134 yield* ipc.handle(openSystemSettings);137 yield* ipc.handle(openSystemSettings);
135 yield* ipc.handle(checkSystemPermission);138 yield* ipc.handle(checkSystemPermission);
136 yield* ipc.handle(pasteAsText);139 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";...@@ -116,3 +116,6 @@ export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper";
116export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission";116export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission";
117117
118export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input";118export 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(...@@ -205,6 +205,7 @@ const desktopApplicationLayer = Layer.mergeAll(
205);205);
206206
207const desktopClerkLayer = DesktopClerk.layer.pipe(207const desktopClerkLayer = DesktopClerk.layer.pipe(
208 Layer.provideMerge(ElectronShell.layer),
208 Layer.provideMerge(desktopEnvironmentLayer),209 Layer.provideMerge(desktopEnvironmentLayer),
209 Layer.provideMerge(NodeServices.layer),210 Layer.provideMerge(NodeServices.layer),
210 Layer.provideMerge(ElectronApp.layer),211 Layer.provideMerge(ElectronApp.layer),
apps/desktop/src/preload.ts+4
...@@ -182,6 +182,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -182,6 +182,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {
182 items,182 items,
183 ...(position === undefined ? {} : { position }),183 ...(position === undefined ? {} : { position }),
184 }),184 }),
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),
185 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),189 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
186 checkSystemPermission: (pane: string) =>190 checkSystemPermission: (pane: string) =>
187 ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane),191 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 @@...@@ -1,3 +1,4 @@
1import { ChatGptUsageLimitNotice } from "./ChatGptUsageLimitNotice";
1import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";2import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
2import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";3import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
3import { useAtomValue } from "@effect/atom-react";4import { useAtomValue } from "@effect/atom-react";
...@@ -637,6 +638,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -637,6 +638,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
637 className="relative w-full self-center"638 className="relative w-full self-center"
638 style={{ maxWidth: props.contentMaxWidth }}639 style={{ maxWidth: props.contentMaxWidth }}
639 >640 >
641 <ChatGptUsageLimitNotice
642 environmentId={props.environmentId}
643 thread={props.selectedThread}
644 />
640 {!voiceInput.isBusy &&645 {!voiceInput.isBusy &&
641 composerMenu.trigger &&646 composerMenu.trigger &&
642 (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? (647 (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? (
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+10-1
...@@ -61,7 +61,8 @@ import {...@@ -61,7 +61,8 @@ import {
61 nativeHeaderScrollEdgeEffects,61 nativeHeaderScrollEdgeEffects,
62} from "../../native/StackHeader";62} from "../../native/StackHeader";
63import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";63import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
64import { serverEnvironment } from "../../state/server";64import { ChatGptSharingStatus } from "./ChatGptSharingStatus";
65import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server";
65import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";66import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
66import { useAtomCommand } from "../../state/use-atom-command";67import { useAtomCommand } from "../../state/use-atom-command";
67import { useNewTaskFlow } from "./new-task-flow-provider";68import { useNewTaskFlow } from "./new-task-flow-provider";
...@@ -683,6 +684,12 @@ function ThreadSettingsOptionsItem(props: {...@@ -683,6 +684,12 @@ function ThreadSettingsOptionsItem(props: {
683}) {684}) {
684 const insets = useSafeAreaInsets();685 const insets = useSafeAreaInsets();
685 const session = useThreadSettingsSession();686 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;
686 const bottomToolbarInset =693 const bottomToolbarInset =
687 Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED694 Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
688 ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET695 ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET
...@@ -690,6 +697,7 @@ function ThreadSettingsOptionsItem(props: {...@@ -690,6 +697,7 @@ function ThreadSettingsOptionsItem(props: {
690697
691 return (698 return (
692 <View style={{ paddingBottom: insets.bottom + bottomToolbarInset + 12 }}>699 <View style={{ paddingBottom: insets.bottom + bottomToolbarInset + 12 }}>
700 <ChatGptSharingStatus provider={selectedProvider} />
693 <Text className="px-5 pb-2 pt-2 text-sm font-t3-medium text-foreground-muted">Options</Text>701 <Text className="px-5 pb-2 pt-2 text-sm font-t3-medium text-foreground-muted">Options</Text>
694 <Animated.View702 <Animated.View
695 className="mx-4 overflow-hidden rounded-2xl bg-card"703 className="mx-4 overflow-hidden rounded-2xl bg-card"
...@@ -1371,6 +1379,7 @@ export function NewTaskThreadSettingsRouteScreen() {...@@ -1371,6 +1379,7 @@ export function NewTaskThreadSettingsRouteScreen() {
1371 return (1379 return (
1372 <ThreadSettingsSessionProvider1380 <ThreadSettingsSessionProvider
1373 environmentId={flow.selectedEnvironmentId}1381 environmentId={flow.selectedEnvironmentId}
1382 {...(flow.selectedModel ? { providerInstanceId: flow.selectedModel.instanceId } : {})}
1374 providerGroups={flow.providerGroups}1383 providerGroups={flow.providerGroups}
1375 selectedModel={flow.selectedModel}1384 selectedModel={flow.selectedModel}
1376 onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)}1385 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"...@@ -3,6 +3,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"
3import { EnvironmentId } from "@t3tools/contracts";3import { EnvironmentId } from "@t3tools/contracts";
4import {4import {
5 collectLimitAccounts,5 collectLimitAccounts,
6 collectExternalUsageLinks,
6 collectLimitNotices,7 collectLimitNotices,
7 collectLimitPools,8 collectLimitPools,
8 cursorUsageWindowDetails,9 cursorUsageWindowDetails,
...@@ -14,7 +15,7 @@ import {...@@ -14,7 +15,7 @@ import {
14 type LimitPoolWindow,15 type LimitPoolWindow,
15} from "@t3tools/shared/usageLimits";16} from "@t3tools/shared/usageLimits";
16import { Fragment, type ReactNode, useId, useState } from "react";17import { Fragment, type ReactNode, useId, useState } from "react";
17import { Pressable, ScrollView, View } from "react-native";18import { Linking, Pressable, ScrollView, View } from "react-native";
18import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";19import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg";
19import { useSafeAreaInsets } from "react-native-safe-area-context";20import { useSafeAreaInsets } from "react-native-safe-area-context";
2021
...@@ -217,6 +218,7 @@ export function UsageLimitsSection({...@@ -217,6 +218,7 @@ export function UsageLimitsSection({
217 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));218 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
218 const pools = collectLimitPools(collectLimitAccounts(selected), now);219 const pools = collectLimitPools(collectLimitAccounts(selected), now);
219 const notices = collectLimitNotices(selected);220 const notices = collectLimitNotices(selected);
221 const externalLinks = collectExternalUsageLinks(selected);
220 const colors = useProviderColors();222 const colors = useProviderColors();
221 const cursorPromptAt =223 const cursorPromptAt =
222 Math.max(224 Math.max(
...@@ -225,7 +227,11 @@ export function UsageLimitsSection({...@@ -225,7 +227,11 @@ export function UsageLimitsSection({
225 ) + 1;227 ) + 1;
226 return (228 return (
227 <View className="gap-6">229 <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 ? (
229 <Text className="py-12 text-center text-base text-foreground-muted">235 <Text className="py-12 text-center text-base text-foreground-muted">
230 {selected.size === 0236 {selected.size === 0
231 ? "Select an environment to see limits."237 ? "Select an environment to see limits."
...@@ -266,6 +272,22 @@ export function UsageLimitsSection({...@@ -266,6 +272,22 @@ export function UsageLimitsSection({
266 );272 );
267 })}273 })}
268 {cursorPromptAt === pools.length ? cursorPrompt : null}274 {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 ))}
269 {notices.length > 0 || failedLabels.length > 0 ? (291 {notices.length > 0 || failedLabels.length > 0 ? (
270 <View292 <View
271 accessible293 accessible
apps/mobile/src/features/usage/UsageLimitsSection.tsx+11-1
...@@ -18,7 +18,7 @@ import {...@@ -18,7 +18,7 @@ import {
18} from "@t3tools/shared/usageLimits";18} from "@t3tools/shared/usageLimits";
19import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";19import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
20import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";20import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage";
21import { Alert, Pressable, View } from "react-native";21import { Alert, Linking, Pressable, View } from "react-native";
2222
23import { AppText as Text } from "../../components/AppText";23import { AppText as Text } from "../../components/AppText";
24import { ProviderIcon } from "../../components/ProviderIcon";24import { ProviderIcon } from "../../components/ProviderIcon";
...@@ -140,6 +140,7 @@ export function AccountLimits(props: {...@@ -140,6 +140,7 @@ export function AccountLimits(props: {
140 const color = useBarColor(props.driver);140 const color = useBarColor(props.driver);
141 if (!limits) return null;141 if (!limits) return null;
142 const notice = limitsNotice(limits);142 const notice = limitsNotice(limits);
143 const externalUsage = limits.externalUsage;
143 const padding = dense ? "px-4 py-3" : "p-4";144 const padding = dense ? "px-4 py-3" : "p-4";
144 return (145 return (
145 <View146 <View
...@@ -171,6 +172,15 @@ export function AccountLimits(props: {...@@ -171,6 +172,15 @@ export function AccountLimits(props: {
171 ))}172 ))}
172 </View>173 </View>
173 )}174 )}
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}
174 {props.footer}184 {props.footer}
175 </View>185 </View>
176 );186 );
apps/mobile/src/features/usage/UsageRouteScreen.tsx+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { ChatGptUsageSummary } from "./ChatGptUsageSummary";
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";2import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";3import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
3import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";4import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";
...@@ -317,6 +318,7 @@ export function UsageRouteScreen() {...@@ -317,6 +318,7 @@ export function UsageRouteScreen() {
317 className="w-full ios:w-36"318 className="w-full ios:w-36"
318 />319 />
319 </View>320 </View>
321 <ChatGptUsageSummary selectedEnvironmentIds={selectedEnvironmentIds} />
320 {merged.duplicateSources.length > 0 ? (322 {merged.duplicateSources.length > 0 ? (
321 <Text className="text-sm text-foreground-muted">323 <Text className="text-sm text-foreground-muted">
322 Counted once across environments sharing a transcript directory:{" "}324 Counted once across environments sharing a transcript directory:{" "}
apps/server/package.json+3
...@@ -31,7 +31,9 @@...@@ -31,7 +31,9 @@
31 "@opencode-ai/sdk": "^1.3.15",31 "@opencode-ai/sdk": "^1.3.15",
32 "diff": "8.0.3",32 "diff": "8.0.3",
33 "effect": "catalog:",33 "effect": "catalog:",
34 "jose": "catalog:",
34 "node-pty": "^1.2.0-beta.15",35 "node-pty": "^1.2.0-beta.15",
36 "proper-lockfile": "4.1.2",
35 "stream-chain": "^4.2.5",37 "stream-chain": "^4.2.5",
36 "stream-json": "3.6.0",38 "stream-json": "3.6.0",
37 "yaml": "catalog:",39 "yaml": "catalog:",
...@@ -45,6 +47,7 @@...@@ -45,6 +47,7 @@
45 "@t3tools/tailscale": "workspace:*",47 "@t3tools/tailscale": "workspace:*",
46 "@t3tools/web": "workspace:*",48 "@t3tools/web": "workspace:*",
47 "@types/node": "catalog:",49 "@types/node": "catalog:",
50 "@types/proper-lockfile": "^4.1.4",
48 "@types/yauzl": "^3.4.0",51 "@types/yauzl": "^3.4.0",
49 "effect-acp": "workspace:*",52 "effect-acp": "workspace:*",
50 "effect-codex-app-server": "workspace:*",53 "effect-codex-app-server": "workspace:*",
apps/server/src/auth/RpcAuthorization.ts+4
...@@ -37,6 +37,10 @@ export const RPC_REQUIRED_SCOPES = {...@@ -37,6 +37,10 @@ export const RPC_REQUIRED_SCOPES = {
37 [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope,37 [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope,
38 [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope,38 [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope,
39 [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope,39 [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope,
40 [WS_METHODS.chatGptReconnectProfile]: AuthOrchestrationOperateScope,
41 [WS_METHODS.chatGptImportProfile]: AuthOrchestrationOperateScope,
42 [WS_METHODS.chatGptHandoffSubscribe]: AuthOrchestrationOperateScope,
43 [WS_METHODS.codexAuthCallbackSubscribe]: AuthOrchestrationOperateScope,
40 [WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope,44 [WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope,
41 [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope,45 [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope,
42 [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope,46 [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope,
apps/server/src/auth/ServerSecretStore.ts+3
...@@ -138,6 +138,8 @@ export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean =>...@@ -138,6 +138,8 @@ export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean =>
138export class ServerSecretStore extends Context.Service<138export class ServerSecretStore extends Context.Service<
139 ServerSecretStore,139 ServerSecretStore,
140 {140 {
141 /** File-backed stores expose their directory for cross-process credential leases. */
142 readonly directory?: string;
141 readonly get: (name: string) => Effect.Effect<Option.Option<Uint8Array>, SecretStoreError>;143 readonly get: (name: string) => Effect.Effect<Option.Option<Uint8Array>, SecretStoreError>;
142 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;144 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
143 readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;145 readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
...@@ -303,6 +305,7 @@ export const make = Effect.gen(function* () {...@@ -303,6 +305,7 @@ export const make = Effect.gen(function* () {
303 );305 );
304306
305 return ServerSecretStore.of({307 return ServerSecretStore.of({
308 directory: serverConfig.secretsDir,
306 get,309 get,
307 set,310 set,
308 create,311 create,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+2
...@@ -3944,6 +3944,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -3944,6 +3944,7 @@ describe("ProviderRuntimeIngestion", () => {
3944 turnId: asTurnId("turn-runtime-error-activity"),3944 turnId: asTurnId("turn-runtime-error-activity"),
3945 payload: {3945 payload: {
3946 message: "runtime activity exploded",3946 message: "runtime activity exploded",
3947 code: "subscription_sharing_usage_limit_exceeded",
3947 },3948 },
3948 });3949 });
39493950
...@@ -3960,6 +3961,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -3960,6 +3961,7 @@ describe("ProviderRuntimeIngestion", () => {
39603961
3961 expect(activity?.kind).toBe("runtime.error");3962 expect(activity?.kind).toBe("runtime.error");
3962 expect(activityPayload?.message).toBe("runtime activity exploded");3963 expect(activityPayload?.message).toBe("runtime activity exploded");
3964 expect(activityPayload?.code).toBe("subscription_sharing_usage_limit_exceeded");
3963 });3965 });
39643966
3965 it("keeps the session running when a runtime.warning arrives during an active turn", async () => {3967 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(...@@ -571,6 +571,7 @@ export function runtimeEventToActivities(
571 summary: "Runtime error",571 summary: "Runtime error",
572 payload: {572 payload: {
573 message: truncateDetail(event.payload.message),573 message: truncateDetail(event.payload.message),
574 ...(event.payload.code ? { code: event.payload.code } : {}),
574 },575 },
575 turnId: toTurnId(event.turnId) ?? null,576 turnId: toTurnId(event.turnId) ?? null,
576 ...maybeSequence,577 ...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 @@...@@ -1,16 +1,24 @@
1import { CodexInstallation } from "../CodexInstallation.ts";
2import { ServerSecretStore } from "../../auth/ServerSecretStore.ts";
3import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts";
1// @effect-diagnostics nodeBuiltinImport:off4// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeServices from "@effect/platform-node/NodeServices";5import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeOS from "node:os";6import * as NodeOS from "node:os";
4import * as NodePath from "node:path";7import * as NodePath from "node:path";
5import { expect, it } from "@effect/vitest";8import { expect, it } from "@effect/vitest";
6import { ProviderInstanceId } from "@t3tools/contracts";9import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
7import { HostProcessPlatform } from "@t3tools/shared/hostProcess";10import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
12import * as Deferred from "effect/Deferred";
13import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";14import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as PlatformError from "effect/PlatformError";
18import * as Schema from "effect/Schema";
11import * as Sink from "effect/Sink";19import * as Sink from "effect/Sink";
12import * as Stream from "effect/Stream";20import * as Stream from "effect/Stream";
13import { HttpClient } from "effect/unstable/http";21import { HttpClient, HttpClientResponse } from "effect/unstable/http";
14import * as ChildProcess from "effect/unstable/process/ChildProcess";22import * as ChildProcess from "effect/unstable/process/ChildProcess";
15import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";23import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1624
...@@ -26,11 +34,21 @@ import {...@@ -26,11 +34,21 @@ import {
26 resolveLatestProviderVersion,34 resolveLatestProviderVersion,
27} from "../providerMaintenance.ts";35} from "../providerMaintenance.ts";
28import { CodexDriver } from "./CodexDriver.ts";36import { CodexDriver } from "./CodexDriver.ts";
37import * as ProviderCredentialStore from "../ProviderCredentialStore.ts";
2938
30const testLayer = ServerConfig.layerTest(process.cwd(), {39const testLayer = ServerConfig.layerTest(process.cwd(), {
31 prefix: "t3-codex-driver-maintenance-",40 prefix: "t3-codex-driver-maintenance-",
32}).pipe(41}).pipe(
33 Layer.provideMerge(NodeServices.layer),42 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 ),
34 Layer.provideMerge(ServerSettingsService.layerTest()),52 Layer.provideMerge(ServerSettingsService.layerTest()),
35 Layer.provideMerge(ModelManifest.layerTest),53 Layer.provideMerge(ModelManifest.layerTest),
36 Layer.provideMerge(ResetCreditCoordinator.layerTest),54 Layer.provideMerge(ResetCreditCoordinator.layerTest),
...@@ -54,14 +72,144 @@ const windowsHost = HostProcessPlatform.defaultValue() === "win32";...@@ -54,14 +72,144 @@ const windowsHost = HostProcessPlatform.defaultValue() === "win32";
54const noSpawn = ChildProcessSpawner.make(() =>72const noSpawn = ChildProcessSpawner.make(() =>
55 Effect.die("Disabled Codex must not spawn a process"),73 Effect.die("Disabled Codex must not spawn a process"),
56);74);
75const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
5776
58it.layer(testLayer)("CodexDriver", (it) => {77it.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
59 it.effect.skipIf(windowsHost)(205 it.effect.skipIf(windowsHost)(
60 "runs the standalone updater against the shared home, not the shadow home",206 "runs the standalone updater against the shared home, not the shadow home",
61 () =>207 () =>
62 Effect.gen(function* () {208 Effect.gen(function* () {
63 const fs = yield* FileSystem.FileSystem;209 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)));
65 const sharedHome = NodePath.join(tempDir, "codex-home");213 const sharedHome = NodePath.join(tempDir, "codex-home");
66 const shadowHome = NodePath.join(tempDir, "codex-shadow");214 const shadowHome = NodePath.join(tempDir, "codex-shadow");
67 const binaryPath = NodePath.join(sharedHome, "packages", "standalone", "bin", "codex");215 const binaryPath = NodePath.join(sharedHome, "packages", "standalone", "bin", "codex");
...@@ -136,7 +284,9 @@ it.layer(testLayer)("CodexDriver", (it) => {...@@ -136,7 +284,9 @@ it.layer(testLayer)("CodexDriver", (it) => {
136 it.effect.skipIf(windowsHost)(fixture.name, () =>284 it.effect.skipIf(windowsHost)(fixture.name, () =>
137 Effect.gen(function* () {285 Effect.gen(function* () {
138 const fs = yield* FileSystem.FileSystem;286 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)));
140 const installPath = NodePath.join(tempDir, ...fixture.installSegments);290 const installPath = NodePath.join(tempDir, ...fixture.installSegments);
141 const realBinaryPath = NodePath.join(291 const realBinaryPath = NodePath.join(
142 installPath,292 installPath,
...@@ -193,7 +343,9 @@ it.layer(testLayer)("CodexDriver", (it) => {...@@ -193,7 +343,9 @@ it.layer(testLayer)("CodexDriver", (it) => {
193 it.effect.skipIf(windowsHost)(`leaves a mise ${layout} installation manual-only`, () =>343 it.effect.skipIf(windowsHost)(`leaves a mise ${layout} installation manual-only`, () =>
194 Effect.gen(function* () {344 Effect.gen(function* () {
195 const fs = yield* FileSystem.FileSystem;345 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)));
197 const binaryPath =349 const binaryPath =
198 layout === "direct"350 layout === "direct"
199 ? NodePath.join(tempDir, "mise", "installs", "codex", "0.110.0", "codex")351 ? NodePath.join(tempDir, "mise", "installs", "codex", "0.110.0", "codex")
...@@ -268,7 +420,9 @@ it.layer(testLayer)("CodexDriver", (it) => {...@@ -268,7 +420,9 @@ it.layer(testLayer)("CodexDriver", (it) => {
268 (fixture) =>420 (fixture) =>
269 Effect.gen(function* () {421 Effect.gen(function* () {
270 const fs = yield* FileSystem.FileSystem;422 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)));
272 const brewPrefix = NodePath.join(tempDir, "homebrew");426 const brewPrefix = NodePath.join(tempDir, "homebrew");
273 const brewPath = NodePath.join(brewPrefix, "bin", "brew");427 const brewPath = NodePath.join(brewPrefix, "bin", "brew");
274 const misePath = NodePath.join(brewPrefix, "Cellar", "mise", "2026.9.1", "bin", "mise");428 const misePath = NodePath.join(brewPrefix, "Cellar", "mise", "2026.9.1", "bin", "mise");
...@@ -361,7 +515,11 @@ it.layer(testLayer)("CodexDriver", (it) => {...@@ -361,7 +515,11 @@ it.layer(testLayer)("CodexDriver", (it) => {
361 if (fixture.nodeFirst) {515 if (fixture.nodeFirst) {
362 expect(capabilities.update).toMatchObject({516 expect(capabilities.update).toMatchObject({
363 executable: "npm",517 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 ]),
365 });523 });
366 } else {524 } else {
367 expect(capabilities.update).toBeNull();525 expect(capabilities.update).toBeNull();
apps/server/src/provider/Drivers/CodexDriver.ts+17-1
...@@ -68,6 +68,10 @@ import {...@@ -68,6 +68,10 @@ import {
68 materializeCodexShadowHome,68 materializeCodexShadowHome,
69 resolveCodexHomeLayout,69 resolveCodexHomeLayout,
70} from "./CodexHomeLayout.ts";70} 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";
71const decodeCodexSettings = Schema.decodeSync(CodexSettings);75const decodeCodexSettings = Schema.decodeSync(CodexSettings);
7276
73const DRIVER_KIND = ProviderDriverKind.make("codex");77const DRIVER_KIND = ProviderDriverKind.make("codex");
...@@ -111,7 +115,10 @@ export type CodexDriverEnv =...@@ -111,7 +115,10 @@ export type CodexDriverEnv =
111 | Path.Path115 | Path.Path
112 | ProviderEventLoggers116 | ProviderEventLoggers
113 | ServerConfig117 | ServerConfig
114 | ServerSettingsService;118 | ServerSettingsService
119 | ServerSecretStore
120 | ServerEnvironmentIdentity
121 | CodexInstallation;
115122
116export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {123export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
117 driverKind: DRIVER_KIND,124 driverKind: DRIVER_KIND,
...@@ -123,6 +130,15 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -123,6 +130,15 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
123 defaultConfig: (): CodexSettings => decodeCodexSettings({}),130 defaultConfig: (): CodexSettings => decodeCodexSettings({}),
124 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>131 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
125 Effect.gen(function* () {132 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 });
126 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;142 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
127 const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator;143 const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator;
128 const fileSystem = yield* FileSystem.FileSystem;144 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) => {...@@ -3084,3 +3084,112 @@ usageLimitLayer("CodexAdapterLive usage limits", (it) => {
3084 }),3084 }),
3085 );3085 );
3086});3086});
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 {...@@ -67,7 +67,6 @@ import {
67 makeCodexSessionRuntime,67 makeCodexSessionRuntime,
68 type CodexSessionRuntimeError,68 type CodexSessionRuntimeError,
69 type CodexSessionRuntimeOptions,69 type CodexSessionRuntimeOptions,
70 type CodexSessionRuntimeSendTurnInput,
71 type CodexSessionRuntimeShape,70 type CodexSessionRuntimeShape,
72} from "./CodexSessionRuntime.ts";71} from "./CodexSessionRuntime.ts";
73import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";72import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
...@@ -85,6 +84,7 @@ const isCodexSessionRuntimeThreadIdMissingError = Schema.is(...@@ -85,6 +84,7 @@ const isCodexSessionRuntimeThreadIdMissingError = Schema.is(
85);84);
86const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema);85const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema);
8786
87import { classifyCodexManagedError } from "../CodexManagedErrors.ts";
88const PROVIDER = ProviderDriverKind.make("codex");88const PROVIDER = ProviderDriverKind.make("codex");
8989
90export interface CodexAdapterLiveOptions {90export interface CodexAdapterLiveOptions {
...@@ -99,6 +99,12 @@ export interface CodexAdapterLiveOptions {...@@ -99,6 +99,12 @@ export interface CodexAdapterLiveOptions {
99 CodexSessionRuntimeError,99 CodexSessionRuntimeError,
100 ChildProcessSpawner.ChildProcessSpawner | Scope.Scope100 ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
101 >;101 >;
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>;
102 readonly nativeEventLogPath?: string;108 readonly nativeEventLogPath?: string;
103 readonly nativeEventLogger?: EventNdjsonLogger;109 readonly nativeEventLogger?: EventNdjsonLogger;
104}110}
...@@ -109,6 +115,8 @@ interface CodexAdapterSessionContext {...@@ -109,6 +115,8 @@ interface CodexAdapterSessionContext {
109 readonly runtime: CodexSessionRuntimeShape;115 readonly runtime: CodexSessionRuntimeShape;
110 readonly eventFiber: Fiber.Fiber<void, never>;116 readonly eventFiber: Fiber.Fiber<void, never>;
111 readonly turnTokenUsage: CodexTurnTokenUsageState;117 readonly turnTokenUsage: CodexTurnTokenUsageState;
118 readonly startInput: Parameters<CodexAdapterShape["startSession"]>[0];
119 readonly runtimeRevision?: string;
112 stopped: boolean;120 stopped: boolean;
113}121}
114122
...@@ -2270,8 +2278,28 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2270,8 +2278,28 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2270 yield* Effect.suspend(() => stopSessionInternal(existing));2278 yield* Effect.suspend(() => stopSessionInternal(existing));
2271 }2279 }
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;
2273 const serviceTier =2301 const serviceTier =
2274 input.modelSelection?.instanceId === boundInstanceId2302 !resolved && input.modelSelection?.instanceId === boundInstanceId
2275 ? getCodexServiceTierOptionValue(input.modelSelection)2303 ? getCodexServiceTierOptionValue(input.modelSelection)
2276 : undefined;2304 : undefined;
2277 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);2305 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
...@@ -2279,11 +2307,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2279,11 +2307,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2279 threadId: input.threadId,2307 threadId: input.threadId,
2280 providerInstanceId: boundInstanceId,2308 providerInstanceId: boundInstanceId,
2281 cwd: input.cwd ?? process.cwd(),2309 cwd: input.cwd ?? process.cwd(),
2282 binaryPath: codexConfig.binaryPath,
2283 ...(options?.models ? { models: options.models } : {}),2310 ...(options?.models ? { models: options.models } : {}),
2284 launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment),2311 binaryPath: effectiveConfig.binaryPath,
2285 ...(options?.environment ? { environment: options.environment } : {}),2312 launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, effectiveEnvironment),
2286 ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}),2313 ...(effectiveEnvironment ? { environment: effectiveEnvironment } : {}),
2314 ...(effectiveConfig.homePath ? { homePath: effectiveConfig.homePath } : {}),
2287 ...(isCodexResumeCursorSchema(input.resumeCursor)2315 ...(isCodexResumeCursorSchema(input.resumeCursor)
2288 ? { resumeCursor: input.resumeCursor }2316 ? { resumeCursor: input.resumeCursor }
2289 : {}),2317 : {}),
...@@ -2296,7 +2324,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2296,7 +2324,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2296 ? {2324 ? {
2297 environment: {2325 environment: {
2298 ...McpProviderSession.withAgentDeviceEnvironment(2326 ...McpProviderSession.withAgentDeviceEnvironment(
2299 options?.environment ?? process.env,2327 effectiveEnvironment ?? process.env,
2300 mcpSession,2328 mcpSession,
2301 ),2329 ),
2302 T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""),2330 T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""),
...@@ -2318,11 +2346,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2318,11 +2346,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2318 // after the stop and often sparse, so keep the session's merged view of2346 // after the stop and often sparse, so keep the session's merged view of
2319 // it and read it when a turn fails on the limit.2347 // it and read it when a turn fails on the limit.
2320 let rateLimits: CodexRateLimitSnapshot | undefined;2348 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 );
2326 const createRuntime = options?.makeRuntime ?? makeCodexSessionRuntime;2349 const createRuntime = options?.makeRuntime ?? makeCodexSessionRuntime;
2327 const runtime = yield* createRuntime(runtimeInput).pipe(2350 const runtime = yield* createRuntime(runtimeInput).pipe(
2328 Effect.provideService(Scope.Scope, sessionScope),2351 Effect.provideService(Scope.Scope, sessionScope),
...@@ -2393,6 +2416,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2393,6 +2416,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2393 if (errorPayload?.error.codexErrorInfo === "usageLimitExceeded") return;2416 if (errorPayload?.error.codexErrorInfo === "usageLimitExceeded") return;
2394 }2417 }
23952418
2419 const managedError = options?.resolveRuntime
2420 ? classifyCodexManagedError(event.payload)
2421 : undefined;
2422 if (managedError?.revoke && options?.onManagedConnectionRevoked)
2423 yield* options.onManagedConnectionRevoked;
2396 let usageLimitError: ProviderRuntimeEvent | undefined;2424 let usageLimitError: ProviderRuntimeEvent | undefined;
2397 let usageLimitMessage: string | undefined;2425 let usageLimitMessage: string | undefined;
2398 if (event.method === "turn/completed") {2426 if (event.method === "turn/completed") {
...@@ -2404,7 +2432,18 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2404,7 +2432,18 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2404 completedPayload?.turn.status === "failed"2432 completedPayload?.turn.status === "failed"
2405 ? completedPayload.turn.error2433 ? completedPayload.turn.error
2406 : undefined;2434 : 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") {
2408 usageLimitMessage = codexUsageLimitMessage(rateLimits, event.createdAt);2447 usageLimitMessage = codexUsageLimitMessage(rateLimits, event.createdAt);
2409 usageLimitError = {2448 usageLimitError = {
2410 ...runtimeEventBase(event, event.threadId),2449 ...runtimeEventBase(event, event.threadId),
...@@ -2419,12 +2458,27 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2419,12 +2458,27 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2419 }2458 }
24202459
2421 const mappedEvents = mapToRuntimeEvents(event, event.threadId).map((runtimeEvent) => {2460 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
2422 if (runtimeEvent.type === "turn.completed" && runtimeEvent.turnId) {2472 if (runtimeEvent.type === "turn.completed" && runtimeEvent.turnId) {
2423 return {2473 return {
2424 ...runtimeEvent,2474 ...runtimeEvent,
2425 payload: {2475 payload: {
2426 ...runtimeEvent.payload,2476 ...runtimeEvent.payload,
2427 ...(usageLimitMessage ? { errorMessage: usageLimitMessage } : {}),2477 ...(managedError
2478 ? { errorMessage: managedError.message }
2479 : usageLimitMessage
2480 ? { errorMessage: usageLimitMessage }
2481 : {}),
2428 tokenUsage: completeCodexTurnTokenUsage(2482 tokenUsage: completeCodexTurnTokenUsage(
2429 turnTokenUsage,2483 turnTokenUsage,
2430 String(runtimeEvent.turnId),2484 String(runtimeEvent.turnId),
...@@ -2489,6 +2543,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2489,6 +2543,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2489 runtime,2543 runtime,
2490 eventFiber,2544 eventFiber,
2491 turnTokenUsage,2545 turnTokenUsage,
2546 startInput: input,
2547 ...(resolved ? { runtimeRevision: resolved.revision } : {}),
2492 stopped: false,2548 stopped: false,
2493 });2549 });
2494 sessionScopeTransferred = true;2550 sessionScopeTransferred = true;
...@@ -2530,13 +2586,34 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2530,13 +2586,34 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2530 { concurrency: 1 },2586 { concurrency: 1 },
2531 );2587 );
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 }
2534 const reasoningEffort =2611 const reasoningEffort =
2535 input.modelSelection?.instanceId === boundInstanceId2612 input.modelSelection?.instanceId === boundInstanceId
2536 ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort")2613 ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort")
2537 : undefined;2614 : undefined;
2538 const serviceTier =2615 const serviceTier =
2539 input.modelSelection?.instanceId === boundInstanceId2616 !options?.resolveRuntime && input.modelSelection?.instanceId === boundInstanceId
2540 ? getCodexServiceTierOptionValue(input.modelSelection)2617 ? getCodexServiceTierOptionValue(input.modelSelection)
2541 : undefined;2618 : undefined;
2542 return yield* session.runtime2619 return yield* session.runtime
apps/server/src/provider/Layers/CodexProvider.ts+30-22
...@@ -343,8 +343,8 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* (...@@ -343,8 +343,8 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* (
343export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {343export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
344 return {344 return {
345 clientInfo: {345 clientInfo: {
346 name: "t3code_desktop",346 name: "T3 Code",
347 title: "T3 Code Desktop",347 title: "T3 Code",
348 version: packageJson.version,348 version: packageJson.version,
349 },349 },
350 capabilities: {350 capabilities: {
...@@ -416,6 +416,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun...@@ -416,6 +416,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
416 readonly cwd: string;416 readonly cwd: string;
417 readonly customModels?: ReadonlyArray<CustomModelSetting>;417 readonly customModels?: ReadonlyArray<CustomModelSetting>;
418 readonly environment?: NodeJS.ProcessEnv;418 readonly environment?: NodeJS.ProcessEnv;
419 readonly skipNativeUsage?: boolean;
419}) {420}) {
420 const { client, initialize } = yield* withCodexAppServerClient(input);421 const { client, initialize } = yield* withCodexAppServerClient(input);
421422
...@@ -441,31 +442,33 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun...@@ -441,31 +442,33 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
441 requestAllCodexModels(client),442 requestAllCodexModels(client),
442 // Usage is an enrichment: a failure or a slow answer degrades to "no443 // Usage is an enrichment: a failure or a slow answer degrades to "no
443 // usage this probe" rather than costing the account and models.444 // usage this probe" rather than costing the account and models.
444 client.request("account/rateLimits/read", null).pipe(445 input.skipNativeUsage
445 Effect.map((response): CodexRateLimitsProbe => ({446 ? Effect.succeed(undefined)
446 snapshot: response.rateLimits,447 : client.request("account/rateLimits/read", null).pipe(
447 rateLimitsByLimitId: response.rateLimitsByLimitId,448 Effect.map((response): CodexRateLimitsProbe => ({
448 resetCredits: response.rateLimitResetCredits,449 snapshot: response.rateLimits,
449 })),450 rateLimitsByLimitId: response.rateLimitsByLimitId,
450 Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)),451 resetCredits: response.rateLimitResetCredits,
451 Effect.map(452 })),
452 Option.getOrElse((): CodexRateLimitsProbe => ({453 Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)),
453 failure: "Codex did not answer the usage request.",454 Effect.map(
454 })),455 Option.getOrElse((): CodexRateLimitsProbe => ({
455 ),456 failure: "Codex did not answer the usage request.",
456 Effect.catch((error) =>457 })),
457 Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe(458 ),
458 Effect.as<CodexRateLimitsProbe>({ failure: codexRateLimitsFailureMessage(error) }),459 Effect.catch((error) =>
460 Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe(
461 Effect.as<CodexRateLimitsProbe>({ failure: codexRateLimitsFailureMessage(error) }),
462 ),
463 ),
459 ),464 ),
460 ),
461 ),
462 ],465 ],
463 { concurrency: "unbounded" },466 { concurrency: "unbounded" },
464 );467 );
465468
466 return {469 return {
467 account: accountResponse,470 account: accountResponse,
468 rateLimits,471 ...(rateLimits ? { rateLimits } : {}),
469 version,472 version,
470 models: applyPreferredCodexDefaultModel(473 models: applyPreferredCodexDefaultModel(
471 appendCustomCodexModels(models, input.customModels ?? []),474 appendCustomCodexModels(models, input.customModels ?? []),
...@@ -567,12 +570,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -567,12 +570,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
567 readonly cwd: string;570 readonly cwd: string;
568 readonly customModels: ReadonlyArray<CustomModelSetting>;571 readonly customModels: ReadonlyArray<CustomModelSetting>;
569 readonly environment?: NodeJS.ProcessEnv;572 readonly environment?: NodeJS.ProcessEnv;
573 readonly skipNativeUsage?: boolean;
570 }) => Effect.Effect<574 }) => Effect.Effect<
571 CodexAppServerProviderSnapshot,575 CodexAppServerProviderSnapshot,
572 CodexErrors.CodexAppServerError,576 CodexErrors.CodexAppServerError,
573 ChildProcessSpawner.ChildProcessSpawner | Scope.Scope577 ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
574 > = probeCodexAppServerProvider,578 > = probeCodexAppServerProvider,
575 environment?: NodeJS.ProcessEnv,579 environment?: NodeJS.ProcessEnv,
580 managedAuth?: ServerProvider["auth"],
576): Effect.fn.Return<581): Effect.fn.Return<
577 ServerProviderDraft,582 ServerProviderDraft,
578 ServerSettingsError,583 ServerSettingsError,
...@@ -606,6 +611,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -606,6 +611,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
606 cwd: process.cwd(),611 cwd: process.cwd(),
607 customModels: codexSettings.customModels,612 customModels: codexSettings.customModels,
608 environment: resolvedEnvironment,613 environment: resolvedEnvironment,
614 ...(managedAuth ? { skipNativeUsage: true } : {}),
609 }).pipe(615 }).pipe(
610 Effect.scoped,616 Effect.scoped,
611 Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)),617 Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)),
...@@ -654,7 +660,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -654,7 +660,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
654 }660 }
655661
656 const snapshot = probeResult.success.value;662 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);
658 const usageLimits =666 const usageLimits =
659 snapshot.account.account?.type === "apiKey"667 snapshot.account.account?.type === "apiKey"
660 ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" })668 ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" })
...@@ -691,7 +699,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -691,7 +699,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
691 status: accountStatus.status,699 status: accountStatus.status,
692 auth: accountStatus.auth,700 auth: accountStatus.auth,
693 ...(accountStatus.message ? { message: accountStatus.message } : {}),701 ...(accountStatus.message ? { message: accountStatus.message } : {}),
694 usageLimits,702 ...(managedAuth ? {} : { usageLimits }),
695 },703 },
696 });704 });
697});705});
apps/server/src/provider/Layers/ProviderAuthService.ts+29
...@@ -144,6 +144,33 @@ export const makeProviderAuthService = Effect.gen(function* () {...@@ -144,6 +144,33 @@ export const makeProviderAuthService = Effect.gen(function* () {
144 });144 });
145145
146 return ProviderAuthService.ProviderAuthService.of({146 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 ),
147 start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) {174 start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) {
148 return yield* credentialChanges.withPermit(175 return yield* credentialChanges.withPermit(
149 Effect.gen(function* () {176 Effect.gen(function* () {
...@@ -153,6 +180,8 @@ export const makeProviderAuthService = Effect.gen(function* () {...@@ -153,6 +180,8 @@ export const makeProviderAuthService = Effect.gen(function* () {
153 ownerSessionId,180 ownerSessionId,
154 stopSessions(input.instanceId, auth.credentialBinding),181 stopSessions(input.instanceId, auth.credentialBinding),
155 input.methodId,182 input.methodId,
183 input.returnUrl,
184 input.callbackMode,
156 );185 );
157 }),186 }),
158 );187 );
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+29-1
...@@ -1,3 +1,6 @@...@@ -1,3 +1,6 @@
1import { CodexInstallation } from "../CodexInstallation.ts";
2import { ServerSecretStore } from "../../auth/ServerSecretStore.ts";
3import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts";
1/**4/**
2 * Multi-instance validation slices for `ProviderInstanceRegistryLive`.5 * Multi-instance validation slices for `ProviderInstanceRegistryLive`.
3 *6 *
...@@ -25,6 +28,7 @@...@@ -25,6 +28,7 @@
25import { describe, expect, it } from "@effect/vitest";28import { describe, expect, it } from "@effect/vitest";
26import * as NodeServices from "@effect/platform-node/NodeServices";29import * as NodeServices from "@effect/platform-node/NodeServices";
27import {30import {
31 EnvironmentId,
28 type ClaudeSettings,32 type ClaudeSettings,
29 type CodexSettings,33 type CodexSettings,
30 type CursorSettings,34 type CursorSettings,
...@@ -246,6 +250,17 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {...@@ -246,6 +250,17 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
246 prefix: "provider-instance-registry-test",250 prefix: "provider-instance-registry-test",
247 }).pipe(251 }).pipe(
248 Layer.provideMerge(NodeServices.layer),252 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 ),
249 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),264 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
250 Layer.provideMerge(ServerSettingsService.layerTest()),265 Layer.provideMerge(ServerSettingsService.layerTest()),
251 Layer.provideMerge(TestHttpClientLive),266 Layer.provideMerge(TestHttpClientLive),
...@@ -594,7 +609,20 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {...@@ -594,7 +609,20 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
594 // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs609 // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs
595 // surfaced; that merged layer then provides `ServerConfig.layerTest`'s610 // surfaced; that merged layer then provides `ServerConfig.layerTest`'s
596 // `FileSystem` dep while keeping everything else surfaced to the test.611 // `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 );
598 const testLayer = AntigravityInstallation.layer.pipe(626 const testLayer = AntigravityInstallation.layer.pipe(
599 Layer.provideMerge(627 Layer.provideMerge(
600 ServerConfig.layerTest(process.cwd(), {628 ServerConfig.layerTest(process.cwd(), {
apps/server/src/provider/Layers/ProviderRegistry.test.ts+14-1
...@@ -1,3 +1,6 @@...@@ -1,3 +1,6 @@
1import { CodexInstallation } from "../CodexInstallation.ts";
2import { ServerSecretStore } from "../../auth/ServerSecretStore.ts";
3import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts";
1import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, it, assert } from "@effect/vitest";5import { describe, it, assert } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";6import * as DateTime from "effect/DateTime";
...@@ -16,6 +19,7 @@ import * as Stream from "effect/Stream";...@@ -16,6 +19,7 @@ import * as Stream from "effect/Stream";
16import * as TestClock from "effect/testing/TestClock";19import * as TestClock from "effect/testing/TestClock";
17import * as CodexErrors from "effect-codex-app-server/errors";20import * as CodexErrors from "effect-codex-app-server/errors";
18import {21import {
22 EnvironmentId,
19 ClaudeSettings,23 ClaudeSettings,
20 CodexSettings,24 CodexSettings,
21 DEFAULT_SERVER_SETTINGS,25 DEFAULT_SERVER_SETTINGS,
...@@ -369,7 +373,16 @@ const awaitPersistedProvider = (...@@ -369,7 +373,16 @@ const awaitPersistedProvider = (
369 Effect.forkScoped,373 Effect.forkScoped,
370 );374 );
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))(
373 "ProviderRegistry",386 "ProviderRegistry",
374 (it) => {387 (it) => {
375 describe("checkCodexProviderStatus", () => {388 describe("checkCodexProviderStatus", () => {
apps/server/src/provider/Layers/ProviderService.test.ts+88-1
...@@ -418,6 +418,7 @@ function makeProviderServiceLayer(...@@ -418,6 +418,7 @@ function makeProviderServiceLayer(
418 readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"];418 readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"];
419 readonly supportsConversationRollback?: boolean;419 readonly supportsConversationRollback?: boolean;
420 readonly analyticsLayer?: Layer.Layer<AnalyticsService.AnalyticsService>;420 readonly analyticsLayer?: Layer.Layer<AnalyticsService.AnalyticsService>;
421 readonly settingsLayer?: typeof defaultServerSettingsLayer;
421 readonly registry?: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"];422 readonly registry?: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"];
422 } = {},423 } = {},
423) {424) {
...@@ -450,7 +451,7 @@ function makeProviderServiceLayer(...@@ -450,7 +451,7 @@ function makeProviderServiceLayer(
450 Layer.provide(NodeServices.layer),451 Layer.provide(NodeServices.layer),
451 Layer.provide(providerAdapterLayer),452 Layer.provide(providerAdapterLayer),
452 Layer.provide(directoryLayer),453 Layer.provide(directoryLayer),
453 Layer.provide(defaultServerSettingsLayer),454 Layer.provide(input.settingsLayer ?? defaultServerSettingsLayer),
454 Layer.provide(serverConfigTestLayer),455 Layer.provide(serverConfigTestLayer),
455 Layer.provideMerge(input.analyticsLayer ?? AnalyticsService.layerTest),456 Layer.provideMerge(input.analyticsLayer ?? AnalyticsService.layerTest),
456 Layer.provide(457 Layer.provide(
...@@ -5272,3 +5273,89 @@ describe("agent browser access", () => {...@@ -5272,3 +5273,89 @@ describe("agent browser access", () => {
5272 }).pipe(Effect.provide(NodeServices.layer)),5273 }).pipe(Effect.provide(NodeServices.layer)),
5273 );5274 );
5274});5275});
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 {...@@ -263,6 +263,7 @@ interface TurnAnalyticsMetadata {
263 readonly provider: ProviderDriverKind;263 readonly provider: ProviderDriverKind;
264 readonly startedAtMs: number;264 readonly startedAtMs: number;
265 readonly mixedModels: boolean;265 readonly mixedModels: boolean;
266 readonly subscriptionSharing?: boolean;
266 readonly model?: string;267 readonly model?: string;
267 readonly effort?: string;268 readonly effort?: string;
268 readonly interactionMode?: string;269 readonly interactionMode?: string;
...@@ -543,6 +544,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -543,6 +544,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
543544
544 return {545 return {
545 ...input.completion.terminalProperties,546 ...input.completion.terminalProperties,
547 ...(metadata?.subscriptionSharing ? { subscriptionSharing: true } : {}),
546 ...(metadata?.model ? { model: metadata.model } : {}),548 ...(metadata?.model ? { model: metadata.model } : {}),
547 ...(metadata?.effort ? { effort: metadata.effort } : {}),549 ...(metadata?.effort ? { effort: metadata.effort } : {}),
548 ...(metadata?.interactionMode ? { interactionMode: metadata.interactionMode } : {}),550 ...(metadata?.interactionMode ? { interactionMode: metadata.interactionMode } : {}),
...@@ -588,6 +590,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -588,6 +590,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
588 readonly runtimeMode: string | undefined;590 readonly runtimeMode: string | undefined;
589 }) {591 }) {
590 const startedAtMs = DateTime.toEpochMillis(yield* DateTime.now);592 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");
591 turnAnalyticsRequestId += 1;608 turnAnalyticsRequestId += 1;
592 const requestId = turnAnalyticsRequestId;609 const requestId = turnAnalyticsRequestId;
593 const effort = turnEffort(input.modelSelection);610 const effort = turnEffort(input.modelSelection);
...@@ -600,6 +617,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -600,6 +617,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
600 };617 };
601 const metadata: TurnAnalyticsMetadata = {618 const metadata: TurnAnalyticsMetadata = {
602 provider: input.provider,619 provider: input.provider,
620 ...(subscriptionSharing ? { subscriptionSharing: true } : {}),
603 startedAtMs,621 startedAtMs,
604 mixedModels: false,622 mixedModels: false,
605 requestId,623 requestId,
...@@ -1731,6 +1749,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1731,6 +1749,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1731 yield* McpSessionRegistry.touchActiveMcpThread(input.threadId);1749 yield* McpSessionRegistry.touchActiveMcpThread(input.threadId);
1732 const analyticsModelSelection =1750 const analyticsModelSelection =
1733 input.modelSelection?.instanceId === routed.instanceId ? input.modelSelection : undefined;1751 input.modelSelection?.instanceId === routed.instanceId ? input.modelSelection : undefined;
1752 let subscriptionSharing = false;
1734 const turn = yield* Effect.acquireUseRelease(1753 const turn = yield* Effect.acquireUseRelease(
1735 beginTurnAnalytics({1754 beginTurnAnalytics({
1736 providerInstanceId: routed.instanceId,1755 providerInstanceId: routed.instanceId,
...@@ -1742,7 +1761,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1742,7 +1761,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1742 }),1761 }),
1743 (turnMetadata) =>1762 (turnMetadata) =>
1744 Effect.gen(function* () {1763 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 );
1746 yield* associateTurnAnalytics({1780 yield* associateTurnAnalytics({
1747 providerInstanceId: routed.instanceId,1781 providerInstanceId: routed.instanceId,
1748 threadId: input.threadId,1782 threadId: input.threadId,
...@@ -1776,6 +1810,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1776,6 +1810,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1776 });1810 });
1777 yield* analytics.record("provider.turn.sent", {1811 yield* analytics.record("provider.turn.sent", {
1778 provider: routed.adapter.provider,1812 provider: routed.adapter.provider,
1813 ...(subscriptionSharing ? { subscriptionSharing: true } : {}),
1779 model: input.modelSelection?.model,1814 model: input.modelSelection?.model,
1780 interactionMode: input.interactionMode,1815 interactionMode: input.interactionMode,
1781 // Session-start events alone skew runtime mode toward users who toggle1816 // 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", () => {...@@ -441,6 +441,30 @@ describe("ModelManifest service", () => {
441 ),441 ),
442 );442 );
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
444 it.live("keeps the bundled manifest when the remote payload is malformed", () =>468 it.live("keeps the bundled manifest when the remote payload is malformed", () =>
445 Effect.gen(function* () {469 Effect.gen(function* () {
446 const service = yield* make;470 const service = yield* make;
apps/server/src/provider/ModelManifest.ts+5-1
...@@ -402,7 +402,11 @@ export const make = Effect.gen(function* () {...@@ -402,7 +402,11 @@ export const make = Effect.gen(function* () {
402 Effect.timeout(FETCH_TIMEOUT_MS),402 Effect.timeout(FETCH_TIMEOUT_MS),
403 Effect.catchCause(() => Effect.succeed(null)),403 Effect.catchCause(() => Effect.succeed(null)),
404 );404 );
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
407 manifest = fetched;411 manifest = fetched;
408 fetchedAtMs = now;412 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",...@@ -518,3 +518,42 @@ it.effect("rebuilding a controller leaves sessions it admitted to their owners",
518 assert.isFalse(closed);518 assert.isFalse(closed);
519 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),519 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
520);520);
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";...@@ -25,9 +25,14 @@ import type * as ProviderAuthService from "./Services/ProviderAuthService.ts";
2525
26export interface ProviderAuthFlowContext {26export interface ProviderAuthFlowContext {
27 readonly flowId: string;27 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";
28 readonly setInteraction: (32 readonly setInteraction: (
29 interaction: ProviderAuthInteraction,33 interaction: ProviderAuthInteraction,
30 respond?: (response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>,34 respond?: (response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>,
35 complete?: (callbackUrl: string) => Effect.Effect<void, ProviderSetupError>,
31 ) => Effect.Effect<void>;36 ) => Effect.Effect<void>;
32 readonly verifying: Effect.Effect<void>;37 readonly verifying: Effect.Effect<void>;
33}38}
...@@ -45,6 +50,7 @@ interface Flow {...@@ -45,6 +50,7 @@ interface Flow {
45 readonly id: string;50 readonly id: string;
46 readonly owner: string;51 readonly owner: string;
47 readonly expiresAt: number;52 readonly expiresAt: number;
53 complete?: ((callbackUrl: string) => Effect.Effect<void, ProviderSetupError>) | undefined;
48 fiber?: Fiber.Fiber<void>;54 fiber?: Fiber.Fiber<void>;
49 responseFiber?: Fiber.Fiber<void, ProviderSetupError>;55 responseFiber?: Fiber.Fiber<void, ProviderSetupError>;
50 respond:56 respond:
...@@ -60,12 +66,14 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {...@@ -60,12 +66,14 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
60 >;66 >;
61 readonly methods: Effect.Effect<ReadonlyArray<ProviderAuthMethod>, ProviderSetupError>;67 readonly methods: Effect.Effect<ReadonlyArray<ProviderAuthMethod>, ProviderSetupError>;
62 readonly defaultMethodId?: string;68 readonly defaultMethodId?: string;
69 /** Stored account profiles can change the advertised methods after auth/logout. */
70 readonly refreshMethodsAfterAuth?: boolean;
63 /** Fail with ProviderSetupError containing safe text for the user, never native token data. */71 /** Fail with ProviderSetupError containing safe text for the user, never native token data. */
64 readonly authenticate: (72 readonly authenticate: (
65 methodId: string,73 methodId: string,
66 context: ProviderAuthFlowContext,74 context: ProviderAuthFlowContext,
67 ) => Effect.Effect<void, ProviderSetupError, Scope.Scope>;75 ) => Effect.Effect<void, ProviderSetupError, Scope.Scope>;
68 readonly logout: Effect.Effect<void, ProviderSetupError>;76 readonly logout: Effect.Effect<void | string, ProviderSetupError>;
69 readonly timeoutMs?: number;77 readonly timeoutMs?: number;
70}) {78}) {
71 const scope = yield* Scope.Scope;79 const scope = yield* Scope.Scope;
...@@ -139,6 +147,40 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {...@@ -139,6 +147,40 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
139147
140 const controller: ProviderAuthService.ProviderAuthController = {148 const controller: ProviderAuthService.ProviderAuthController = {
141 credentialBinding: options.credentialBinding,149 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),
142 refreshMethods,184 refreshMethods,
143 invalidate: lock.withPermit(185 invalidate: lock.withPermit(
144 Effect.gen(function* () {186 Effect.gen(function* () {
...@@ -191,7 +233,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {...@@ -191,7 +233,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
191 );233 );
192 }),234 }),
193 ),235 ),
194 start: (owner, stopSessions = Effect.void, selectedMethodId) =>236 start: (owner, stopSessions = Effect.void, selectedMethodId, returnUrl, callbackMode) =>
195 lock.withPermit(237 lock.withPermit(
196 Effect.gen(function* () {238 Effect.gen(function* () {
197 if (operation === "auth" && active?.owner === owner) return snapshot.value.state;239 if (operation === "auth" && active?.owner === owner) return snapshot.value.state;
...@@ -241,10 +283,14 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {...@@ -241,10 +283,14 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
241 yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions));283 yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions));
242 yield* options.authenticate(methodId, {284 yield* options.authenticate(methodId, {
243 flowId: id,285 flowId: id,
244 setInteraction: (interaction, respond) =>286 expiresAt: flow.expiresAt,
287 ...(returnUrl ? { returnUrl } : {}),
288 ...(callbackMode ? { callbackMode } : {}),
289 setInteraction: (interaction, respond, complete) =>
245 Effect.gen(function* () {290 Effect.gen(function* () {
246 if (active !== flow) return;291 if (active !== flow) return;
247 flow.respond = respond;292 flow.respond = respond;
293 flow.complete = complete;
248 yield* publish(flow, {294 yield* publish(flow, {
249 phase: "waiting",295 phase: "waiting",
250 interaction,296 interaction,
...@@ -257,6 +303,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {...@@ -257,6 +303,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
257 }),303 }),
258 verifying: Effect.gen(function* () {304 verifying: Effect.gen(function* () {
259 flow.respond = undefined;305 flow.respond = undefined;
306 flow.complete = undefined;
260 yield* publish(flow, {307 yield* publish(flow, {
261 phase: "verifying",308 phase: "verifying",
262 interaction: null,309 interaction: null,
...@@ -293,6 +340,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {...@@ -293,6 +340,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
293 yield* lock.withPermit(340 yield* lock.withPermit(
294 Effect.gen(function* () {341 Effect.gen(function* () {
295 if (active !== flow) return;342 if (active !== flow) return;
343 if (options.refreshMethodsAfterAuth) yield* refreshMethods;
296 yield* publish(flow, {344 yield* publish(flow, {
297 phase: Exit.isSuccess(result) ? "succeeded" : "failed",345 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
298 interaction: null,346 interaction: null,
...@@ -363,12 +411,24 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {...@@ -363,12 +411,24 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
363 return snapshot.value.state;411 return snapshot.value.state;
364 }),412 }),
365 ),413 ),
366 complete: () =>414 complete: (owner, input) =>
367 Effect.fail(415 lock.withPermit(
368 new ProviderSetupError({416 Effect.gen(function* () {
369 instanceId: options.instanceId,417 const flow = yield* requireFlow(owner, input.flowId);
370 operation: "complete",418 const interaction = snapshot.value.state.interaction;
371 detail: "This provider does not accept a pasted redirect URL.",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;
372 }),432 }),
373 ),433 ),
374 cancel: (owner, id) =>434 cancel: (owner, id) =>
...@@ -413,13 +473,17 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {...@@ -413,13 +473,17 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
413 if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber);473 if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
414 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);474 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
415 yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions));475 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;
417 }).pipe(Effect.exit);479 }).pipe(Effect.exit);
418 const state: ProviderAuthState = {480 const state: ProviderAuthState = {
419 ...empty,481 ...empty,
420 methods: snapshot.value.state.methods ?? [],482 methods: snapshot.value.state.methods ?? [],
421 phase: Exit.isSuccess(result) ? "idle" : "failed",483 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.",
423 };487 };
424 yield* lock.withPermit(488 yield* lock.withPermit(
425 Effect.gen(function* () {489 Effect.gen(function* () {
apps/server/src/provider/Services/ProviderAuthService.ts+21
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import type {1import type {
2 ChatGptReconnectProfile,
3 ChatGptTransferredProfile,
2 ProviderAuthRespondInput,4 ProviderAuthRespondInput,
3 ProviderAuthStartInput,5 ProviderAuthStartInput,
4 ProviderAuthState,6 ProviderAuthState,
...@@ -13,6 +15,17 @@ import type * as Scope from "effect/Scope";...@@ -13,6 +15,17 @@ import type * as Scope from "effect/Scope";
13export interface ProviderAuthController {15export interface ProviderAuthController {
14 /** Equal keys mean these instances share credentials on this environment. */16 /** Equal keys mean these instances share credentials on this environment. */
15 readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string };17 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>;
16 readonly isChangingCredentials?: Effect.Effect<boolean>;29 readonly isChangingCredentials?: Effect.Effect<boolean>;
17 readonly invalidate?: Effect.Effect<void>;30 readonly invalidate?: Effect.Effect<void>;
18 readonly refreshMethods?: Effect.Effect<void>;31 readonly refreshMethods?: Effect.Effect<void>;
...@@ -23,6 +36,8 @@ export interface ProviderAuthController {...@@ -23,6 +36,8 @@ export interface ProviderAuthController {
23 ownerSessionId: string,36 ownerSessionId: string,
24 stopSessions?: Effect.Effect<void, ProviderSetupError>,37 stopSessions?: Effect.Effect<void, ProviderSetupError>,
25 methodId?: string,38 methodId?: string,
39 returnUrl?: string,
40 callbackMode?: "server" | "client",
26 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;41 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
27 readonly complete: (42 readonly complete: (
28 ownerSessionId: string,43 ownerSessionId: string,
...@@ -49,6 +64,12 @@ interface ProviderAuthTarget {...@@ -49,6 +64,12 @@ interface ProviderAuthTarget {
49}64}
5065
51export interface ProviderAuthServiceShape {66export 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>;
52 readonly start: (73 readonly start: (
53 input: ProviderAuthStartInput,74 input: ProviderAuthStartInput,
54 ownerSessionId: string,75 ownerSessionId: string,
apps/server/src/provider/providerInstallation.test.ts+53-2
...@@ -13,6 +13,7 @@ import * as Path from "effect/Path";...@@ -13,6 +13,7 @@ import * as Path from "effect/Path";
13import * as Stream from "effect/Stream";13import * as Stream from "effect/Stream";
1414
15import { layerTest as settingsLayerTest } from "../serverSettings.ts";15import { layerTest as settingsLayerTest } from "../serverSettings.ts";
16import { CodexInstallation } from "./CodexInstallation.ts";
16import { AntigravityInstallation } from "./AntigravityInstallation.ts";17import { AntigravityInstallation } from "./AntigravityInstallation.ts";
17import type { ProviderInstance } from "./ProviderDriver.ts";18import type { ProviderInstance } from "./ProviderDriver.ts";
18import { makeProviderInstallation } from "./providerInstallation.ts";19import { makeProviderInstallation } from "./providerInstallation.ts";
...@@ -33,9 +34,9 @@ const state: ProviderInstallState = {...@@ -33,9 +34,9 @@ const state: ProviderInstallState = {
33 message: null,34 message: null,
34};35};
3536
36function instance(kind = driver): ProviderInstance {37function instance(kind = driver, id = instanceId): ProviderInstance {
37 return {38 return {
38 instanceId,39 instanceId: id,
39 driverKind: kind,40 driverKind: kind,
40 enabled: false,41 enabled: false,
41 displayName: undefined,42 displayName: undefined,
...@@ -77,6 +78,25 @@ const makeHarness = Effect.fn("providerInstallation.test.makeHarness")(function*...@@ -77,6 +78,25 @@ const makeHarness = Effect.fn("providerInstallation.test.makeHarness")(function*
77 return [];78 return [];
78 }),79 }),
79 }),80 }),
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 }),
80 Layer.mock(AntigravityInstallation)({100 Layer.mock(AntigravityInstallation)({
81 managedDirectory: "/unused-managed-runtime",101 managedDirectory: "/unused-managed-runtime",
82 start: Effect.sync(() => {102 start: Effect.sync(() => {
...@@ -139,6 +159,37 @@ describe("provider installation routing", () => {...@@ -139,6 +159,37 @@ describe("provider installation routing", () => {
139 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),159 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
140 );160 );
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
142 it.effect("protects another instance's binary found through its own PATH", () =>193 it.effect("protects another instance's binary found through its own PATH", () =>
143 Effect.gen(function* () {194 Effect.gen(function* () {
144 const fs = yield* FileSystem.FileSystem;195 const fs = yield* FileSystem.FileSystem;
apps/server/src/provider/providerInstallation.ts+45-21
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 AntigravitySettings,2 AntigravitySettings,
3 CodexSettings,
3 ProviderDriverKind,4 ProviderDriverKind,
4 type ProviderInstallCancelInput,5 type ProviderInstallCancelInput,
5 type ProviderInstanceId,6 type ProviderInstanceId,
...@@ -11,6 +12,7 @@ import * as Effect from "effect/Effect";...@@ -11,6 +12,7 @@ import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";13import * as Stream from "effect/Stream";
1314
15import { CodexInstallation, type CodexInstallationError } from "./CodexInstallation.ts";
14import { ServerSettingsService } from "../serverSettings.ts";16import { ServerSettingsService } from "../serverSettings.ts";
15import {17import {
16 AntigravityInstallation,18 AntigravityInstallation,
...@@ -23,11 +25,13 @@ import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment....@@ -23,11 +25,13 @@ import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.
2325
24const ANTIGRAVITY = ProviderDriverKind.make("antigravity");26const ANTIGRAVITY = ProviderDriverKind.make("antigravity");
25const hasBinaryPath = Schema.is(Schema.Struct({ binaryPath: Schema.String }));27const hasBinaryPath = Schema.is(Schema.Struct({ binaryPath: Schema.String }));
28const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings);
26const decodeAntigravitySettings = Schema.decodeUnknownEffect(AntigravitySettings);29const decodeAntigravitySettings = Schema.decodeUnknownEffect(AntigravitySettings);
2730
28/** Route instance setup to the environment-owned installer without owning the download. */31/** Route instance setup to the environment-owned installer without owning the download. */
29export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(function* () {32export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(function* () {
30 const installation = yield* AntigravityInstallation;33 const antigravityInstallation = yield* AntigravityInstallation;
34 const codexInstallation = yield* CodexInstallation;
31 const instances = yield* ProviderInstanceRegistry;35 const instances = yield* ProviderInstanceRegistry;
32 const providers = yield* ProviderRegistry;36 const providers = yield* ProviderRegistry;
33 const settings = yield* ServerSettingsService;37 const settings = yield* ServerSettingsService;
...@@ -55,26 +59,37 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu...@@ -55,26 +59,37 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu
55 managedOnly = false,59 managedOnly = false,
56 ) {60 ) {
57 const instance = yield* instances.getInstance(instanceId);61 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) {
59 return yield* new ProviderSetupError({64 return yield* new ProviderSetupError({
60 instanceId,65 instanceId,
61 operation,66 operation,
62 detail: "Managed installation is not available for this provider instance.",67 detail: "Managed installation is not available for this provider instance.",
63 });68 });
64 }69 }
65 if (!managedOnly) return;70 const installation = isCodex ? codexInstallation : antigravityInstallation;
66 const entries = yield* readEntries(instanceId, operation);71 const entries = yield* readEntries(instanceId, operation);
67 const config = yield* decodeAntigravitySettings(entries[instanceId]?.config ?? {}).pipe(72 const invalidConfig = () =>
68 Effect.mapError(73 new ProviderSetupError({
69 () =>74 instanceId,
70 new ProviderSetupError({75 operation,
71 instanceId,76 detail: "The provider instance configuration is invalid.",
72 operation,77 });
73 detail: "The Antigravity instance configuration is invalid.",78 const config = isCodex
74 }),79 ? yield* decodeCodexSettings(entries[instanceId]?.config ?? {}).pipe(
75 ),80 Effect.mapError(invalidConfig),
76 );81 )
77 if (config.binaryPath) {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")) {
78 return yield* new ProviderSetupError({93 return yield* new ProviderSetupError({
79 instanceId,94 instanceId,
80 operation,95 operation,
...@@ -82,20 +97,23 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu...@@ -82,20 +97,23 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu
82 "This instance uses a custom executable. Clear its binary path to manage installation in T3 Code.",97 "This instance uses a custom executable. Clear its binary path to manage installation in T3 Code.",
83 });98 });
84 }99 }
100 return { installation, driver: instance.driverKind };
85 });101 });
86102
87 const failure = (instanceId: ProviderInstanceId) => (error: AntigravityInstallationError) =>103 const failure =
88 new ProviderSetupError({ instanceId, operation: error.operation, detail: error.detail });104 (instanceId: ProviderInstanceId) =>
105 (error: AntigravityInstallationError | CodexInstallationError) =>
106 new ProviderSetupError({ instanceId, operation: error.operation, detail: error.detail });
89107
90 const start = Effect.fn("ProviderInstallation.start")(function* (input: ProviderSetupInput) {108 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);
92 return yield* installation.start.pipe(Effect.mapError(failure(input.instanceId)));110 return yield* installation.start.pipe(Effect.mapError(failure(input.instanceId)));
93 });111 });
94112
95 const cancel = Effect.fn("ProviderInstallation.cancel")(function* (113 const cancel = Effect.fn("ProviderInstallation.cancel")(function* (
96 input: ProviderInstallCancelInput,114 input: ProviderInstallCancelInput,
97 ) {115 ) {
98 yield* requireInstance(input.instanceId, "cancel-install");116 const { installation } = yield* requireInstance(input.instanceId, "cancel-install");
99 return yield* installation117 return yield* installation
100 .cancel(input.operationId)118 .cancel(input.operationId)
101 .pipe(Effect.mapError(failure(input.instanceId)));119 .pipe(Effect.mapError(failure(input.instanceId)));
...@@ -103,11 +121,17 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu...@@ -103,11 +121,17 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu
103121
104 const subscribe = (input: ProviderSetupInput) =>122 const subscribe = (input: ProviderSetupInput) =>
105 Stream.unwrap(123 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 ),
107 );127 );
108128
109 const remove = Effect.fn("ProviderInstallation.remove")(function* (input: ProviderSetupInput) {129 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 );
111 const entries = yield* readEntries(input.instanceId, "remove-install");135 const entries = yield* readEntries(input.instanceId, "remove-install");
112 const protectedPaths = yield* Effect.forEach(Object.values(entries), (entry) => {136 const protectedPaths = yield* Effect.forEach(Object.values(entries), (entry) => {
113 if (!hasBinaryPath(entry.config) || !entry.config.binaryPath.trim()) {137 if (!hasBinaryPath(entry.config) || !entry.config.binaryPath.trim()) {
...@@ -126,7 +150,7 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu...@@ -126,7 +150,7 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu
126 .pipe(Effect.mapError(failure(input.instanceId)));150 .pipe(Effect.mapError(failure(input.instanceId)));
127 const allInstances = yield* instances.listInstances;151 const allInstances = yield* instances.listInstances;
128 yield* Effect.forEach(152 yield* Effect.forEach(
129 allInstances.filter((instance) => instance.driverKind === ANTIGRAVITY),153 allInstances.filter((instance) => instance.driverKind === driver),
130 (instance) => providers.refreshInstance(instance.instanceId),154 (instance) => providers.refreshInstance(instance.instanceId),
131 { discard: true },155 { discard: true },
132 );156 );
apps/server/src/server.test.ts+6
...@@ -140,6 +140,7 @@ import {...@@ -140,6 +140,7 @@ import {
140 AntigravityInstallation,140 AntigravityInstallation,
141 AntigravityInstallationError,141 AntigravityInstallationError,
142} from "./provider/AntigravityInstallation.ts";142} from "./provider/AntigravityInstallation.ts";
143import { CodexInstallation } from "./provider/CodexInstallation.ts";
143import type { ProviderInstance } from "./provider/ProviderDriver.ts";144import type { ProviderInstance } from "./provider/ProviderDriver.ts";
144import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";145import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";
145import { ProviderAdapterRequestError } from "./provider/Errors.ts";146import { ProviderAdapterRequestError } from "./provider/Errors.ts";
...@@ -529,6 +530,7 @@ const buildAppUnderTest = (options?: {...@@ -529,6 +530,7 @@ const buildAppUnderTest = (options?: {
529 providerAuth?: Partial<ProviderAuthService["Service"]>;530 providerAuth?: Partial<ProviderAuthService["Service"]>;
530 providerInstanceRegistry?: Partial<ProviderInstanceRegistry["Service"]>;531 providerInstanceRegistry?: Partial<ProviderInstanceRegistry["Service"]>;
531 antigravityInstallation?: Partial<AntigravityInstallation["Service"]>;532 antigravityInstallation?: Partial<AntigravityInstallation["Service"]>;
533 codexInstallation?: Partial<CodexInstallation["Service"]>;
532 serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>;534 serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>;
533 externalLauncher?: Partial<ExternalLauncher.ExternalLauncher["Service"]>;535 externalLauncher?: Partial<ExternalLauncher.ExternalLauncher["Service"]>;
534 vcsDriver?: Partial<VcsDriver.VcsDriver["Service"]>;536 vcsDriver?: Partial<VcsDriver.VcsDriver["Service"]>;
...@@ -828,6 +830,10 @@ const buildAppUnderTest = (options?: {...@@ -828,6 +830,10 @@ const buildAppUnderTest = (options?: {
828 listInstances: Effect.succeed([]),830 listInstances: Effect.succeed([]),
829 ...options?.layers?.providerInstanceRegistry,831 ...options?.layers?.providerInstanceRegistry,
830 }),832 }),
833 Layer.mock(CodexInstallation)({
834 managedDirectory: "unused-test-codex-runtime",
835 ...options?.layers?.codexInstallation,
836 }),
831 Layer.mock(AntigravityInstallation)({837 Layer.mock(AntigravityInstallation)({
832 managedDirectory: "unused-test-antigravity-runtime",838 managedDirectory: "unused-test-antigravity-runtime",
833 ...options?.layers?.antigravityInstallation,839 ...options?.layers?.antigravityInstallation,
apps/server/src/server.ts+18-12
...@@ -55,6 +55,7 @@ import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinato...@@ -55,6 +55,7 @@ import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinato
55import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";55import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";
56import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";56import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";
57import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts";57import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts";
58import { CodexInstallation } from "./provider/CodexInstallation.ts";
58import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts";59import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts";
59import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";60import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";
60import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";61import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";
...@@ -475,22 +476,27 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(...@@ -475,22 +476,27 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(
475 Layer.provideMerge(OrchestrationLayerLive),476 Layer.provideMerge(OrchestrationLayerLive),
476);477);
477478
478const AntigravityInstallationRefreshLive = Layer.effectDiscard(479const ProviderInstallationRefreshLive = Layer.effectDiscard(
479 Effect.gen(function* () {480 Effect.gen(function* () {
480 const installation = yield* AntigravityInstallation;481 const antigravity = yield* AntigravityInstallation;
482 const codex = yield* CodexInstallation;
481 const instances = yield* ProviderInstanceRegistry;483 const instances = yield* ProviderInstanceRegistry;
482 const providers = yield* ProviderRegistry;484 const providers = yield* ProviderRegistry;
483 yield* installation.changes.pipe(485 yield* Stream.merge(
484 Stream.map((state) => state.installedVersion),486 antigravity.changes.pipe(
485 Stream.changes,487 Stream.changesWith((a, b) => a.installedVersion === b.installedVersion),
486 Stream.drop(1),488 Stream.drop(1),
487 Stream.runForEach(() =>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) =>
488 instances.listInstances.pipe(496 instances.listInstances.pipe(
489 Effect.flatMap((entries) =>497 Effect.flatMap((entries) =>
490 Effect.forEach(498 Effect.forEach(
491 entries.filter(499 entries.filter((instance) => instance.driverKind === state.driver),
492 (instance) => instance.driverKind === ProviderDriverKind.make("antigravity"),
493 ),
494 (instance) => providers.refreshInstance(instance.instanceId),500 (instance) => providers.refreshInstance(instance.instanceId),
495 { discard: true },501 { discard: true },
496 ),502 ),
...@@ -503,7 +509,7 @@ const AntigravityInstallationRefreshLive = Layer.effectDiscard(...@@ -503,7 +509,7 @@ const AntigravityInstallationRefreshLive = Layer.effectDiscard(
503);509);
504510
505const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(511const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
506 Layer.provideMerge(AntigravityInstallationRefreshLive),512 Layer.provideMerge(ProviderInstallationRefreshLive),
507 Layer.provideMerge(ReplayMarkers.layer),513 Layer.provideMerge(ReplayMarkers.layer),
508 Layer.provideMerge(ProviderAuthServiceLive),514 Layer.provideMerge(ProviderAuthServiceLive),
509 // Core Services515 // Core Services
...@@ -532,7 +538,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -532,7 +538,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
532 // with explicit `providerInstances` entries on boot.538 // with explicit `providerInstances` entries on boot.
533 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),539 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
534).pipe(540).pipe(
535 Layer.provideMerge(AntigravityInstallation.layer),541 Layer.provideMerge(Layer.mergeAll(AntigravityInstallation.layer, CodexInstallation.layer)),
536 // Shared native/canonical NDJSON writers used by both the per-instance542 // Shared native/canonical NDJSON writers used by both the per-instance
537 // drivers (native stream, written from inside each `<X>Adapter`) and543 // drivers (native stream, written from inside each `<X>Adapter`) and
538 // `ProviderService` (canonical stream, written after event normalization).544 // `ProviderService` (canonical stream, written after event normalization).
apps/server/src/textGeneration/CodexTextGeneration.test.ts+28
...@@ -135,6 +135,7 @@ function withFakeCodexEnv<A, E, R>(...@@ -135,6 +135,7 @@ function withFakeCodexEnv<A, E, R>(
135 launchArgs?: string;135 launchArgs?: string;
136 environment?: NodeJS.ProcessEnv;136 environment?: NodeJS.ProcessEnv;
137 models?: ReadonlyArray<string>;137 models?: ReadonlyArray<string>;
138 managedRuntime?: boolean;
138 },139 },
139 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,140 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
140) {141) {
...@@ -154,6 +155,13 @@ function withFakeCodexEnv<A, E, R>(...@@ -154,6 +155,13 @@ function withFakeCodexEnv<A, E, R>(
154 capabilities: null,155 capabilities: null,
155 })),156 })),
156 ),157 ),
158 input.managedRuntime
159 ? Effect.succeed({
160 config,
161 environment: input.environment ?? process.env,
162 revision: "test",
163 })
164 : undefined,
157 );165 );
158 return yield* effectFn(textGeneration);166 return yield* effectFn(textGeneration);
159 }).pipe(Effect.scoped);167 }).pipe(Effect.scoped);
...@@ -236,6 +244,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {...@@ -236,6 +244,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
236 ),244 ),
237 );245 );
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
239 it.effect("passes exec-safe launch args into codex exec", () =>267 it.effect("passes exec-safe launch args into codex exec", () =>
240 withFakeCodexEnv(268 withFakeCodexEnv(
241 {269 {
apps/server/src/textGeneration/CodexTextGeneration.ts+22-6
...@@ -48,6 +48,11 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -48,6 +48,11 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
48 codexConfig: CodexSettings,48 codexConfig: CodexSettings,
49 environment?: NodeJS.ProcessEnv,49 environment?: NodeJS.ProcessEnv,
50 getModels: Effect.Effect<ReadonlyArray<ServerProviderModel>> = Effect.succeed([]),50 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 >,
51) {56) {
52 const fileSystem = yield* FileSystem.FileSystem;57 const fileSystem = yield* FileSystem.FileSystem;
53 const path = yield* Path.Path;58 const path = yield* Path.Path;
...@@ -180,6 +185,15 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -180,6 +185,15 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
180 const outputPath = yield* writeTempFile(operation, "codex-output", "");185 const outputPath = yield* writeTempFile(operation, "codex-output", "");
181186
182 const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () {187 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;
183 const models = yield* getModels;197 const models = yield* getModels;
184 const requestedModel = modelSelection.model;198 const requestedModel = modelSelection.model;
185 const model =199 const model =
...@@ -188,13 +202,13 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -188,13 +202,13 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
188 (candidate) => !candidate.isCustom && codexModelFamily(candidate.slug) === requestedModel,202 (candidate) => !candidate.isCustom && codexModelFamily(candidate.slug) === requestedModel,
189 )?.slug ??203 )?.slug ??
190 requestedModel;204 requestedModel;
191 const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment);205 const launchArgs = resolveCodexLaunchArgs(effectiveConfig.launchArgs, effectiveEnvironment);
192 const reasoningEffort =206 const reasoningEffort =
193 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??207 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
194 DEFAULT_TEXT_GENERATION_REASONING_EFFORT;208 DEFAULT_TEXT_GENERATION_REASONING_EFFORT;
195 const serviceTier = getCodexServiceTierOptionValue(modelSelection);209 const serviceTier = resolved ? undefined : getCodexServiceTierOptionValue(modelSelection);
196 const spawnCommand = yield* resolveSpawnCommand(210 const spawnCommand = yield* resolveSpawnCommand(
197 codexConfig.binaryPath || "codex",211 effectiveConfig.binaryPath || "codex",
198 [212 [
199 "exec",213 "exec",
200 ...codexExecLaunchArgs(launchArgs),214 ...codexExecLaunchArgs(launchArgs),
...@@ -214,12 +228,14 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -214,12 +228,14 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
214 ...imagePaths.flatMap((imagePath) => ["--image", imagePath]),228 ...imagePaths.flatMap((imagePath) => ["--image", imagePath]),
215 "-",229 "-",
216 ],230 ],
217 { env: resolvedEnvironment },231 { env: effectiveEnvironment },
218 );232 );
219 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {233 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
220 env: {234 env: {
221 ...resolvedEnvironment,235 ...effectiveEnvironment,
222 ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}),236 ...(effectiveConfig.homePath
237 ? { CODEX_HOME: expandHomePath(effectiveConfig.homePath) }
238 : {}),
223 },239 },
224 cwd,240 cwd,
225 shell: spawnCommand.shell,241 shell: spawnCommand.shell,
apps/server/src/usage/UsageService.test.ts+89
...@@ -121,6 +121,95 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens...@@ -121,6 +121,95 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens
121}121}
122122
123describe("UsageService", () => {123describe("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 }
124 it.live("omits Cursor account usage when no file login is saved", () =>213 it.live("omits Cursor account usage when no file login is saved", () =>
125 Effect.gen(function* () {214 Effect.gen(function* () {
126 const { settings, home } = yield* setup;215 const { settings, home } = yield* setup;
apps/server/src/usage/UsageService.ts+17-8
...@@ -18,8 +18,8 @@ import {...@@ -18,8 +18,8 @@ import {
18 ClaudeSettings,18 ClaudeSettings,
19 CodexSettings,19 CodexSettings,
20 type ProviderInstanceConfig,20 type ProviderInstanceConfig,
21 USAGE_CONTRACT_VERSION,
22 ProviderInstanceId,21 ProviderInstanceId,
22 USAGE_CONTRACT_VERSION,
23 type ServerSettings as ServerSettingsValue,23 type ServerSettings as ServerSettingsValue,
24 type UsageProviderKind,24 type UsageProviderKind,
25 type UsageSource,25 type UsageSource,
...@@ -271,10 +271,16 @@ export const make = Effect.gen(function* () {...@@ -271,10 +271,16 @@ export const make = Effect.gen(function* () {
271 for (const driver of ["claudeAgent", "codex", "grok"] as const) {271 for (const driver of ["claudeAgent", "codex", "grok"] as const) {
272 // Disabled accounts still have history. Explicit default slots replace272 // Disabled accounts still have history. Explicit default slots replace
273 // the legacy settings, just as they do in the provider registry.273 // the legacy settings, just as they do in the provider registry.
274 const instances: Array<Pick<ProviderInstanceConfig, "config" | "environment">> =274 const instances: Array<
275 Object.values(settings.providerInstances).filter((instance) => instance.driver === driver);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) }));
276 if (!Object.hasOwn(settings.providerInstances, driver)) {279 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 });
278 }284 }
279 for (const instance of instances) {285 for (const instance of instances) {
280 const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment);286 const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment);
...@@ -283,12 +289,15 @@ export const make = Effect.gen(function* () {...@@ -283,12 +289,15 @@ export const make = Effect.gen(function* () {
283 if (driver === "codex") {289 if (driver === "codex") {
284 const decoded = decodeCodexSettings(instance.config ?? {});290 const decoded = decodeCodexSettings(instance.config ?? {});
285 if (Option.isNone(decoded)) continue;291 if (Option.isNone(decoded)) continue;
286 const config = decoded.value;292 const codexConfig = decoded.value;
287 const environmentHome = environment.CODEX_HOME?.trim();293 const environmentHome = environment.CODEX_HOME?.trim();
288 const layout = yield* resolveCodexHomeLayout(294 const layout = yield* resolveCodexHomeLayout(
289 !config.homePath.trim() && !config.shadowHomePath.trim() && environmentHome295 codexConfig.setupMode !== "managed" &&
290 ? { ...config, homePath: environmentHome }296 !codexConfig.homePath.trim() &&
291 : config,297 !codexConfig.shadowHomePath.trim() &&
298 environmentHome
299 ? { ...codexConfig, homePath: environmentHome }
300 : codexConfig,
292 );301 );
293 home = layout.sharedHomePath;302 home = layout.sharedHomePath;
294 } else if (driver === "claudeAgent") {303 } else if (driver === "claudeAgent") {
apps/server/src/ws.ts+14
...@@ -17,6 +17,8 @@ import * as Ref from "effect/Ref";...@@ -17,6 +17,8 @@ import * as Ref from "effect/Ref";
17import * as Schedule from "effect/Schedule";17import * as Schedule from "effect/Schedule";
18import * as Schema from "effect/Schema";18import * as Schema from "effect/Schema";
19import * as Stream from "effect/Stream";19import * as Stream from "effect/Stream";
20import { subscribeChatGptHandoff } from "./provider/CodexChatGptHandoff.ts";
21import { subscribeCodexAuthCallback } from "./provider/CodexAuthCallback.ts";
20import {22import {
21 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,23 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
22 AuthAccessStreamError,24 AuthAccessStreamError,
...@@ -2508,6 +2510,18 @@ const makeWsRpcLayer = (...@@ -2508,6 +2510,18 @@ const makeWsRpcLayer = (
2508 providerAuth.complete(input, currentSessionId),2510 providerAuth.complete(input, currentSessionId),
2509 { "rpc.aggregate": "provider" },2511 { "rpc.aggregate": "provider" },
2510 ),2512 ),
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 ),
2511 [WS_METHODS.providerAuthCancel]: (input) =>2525 [WS_METHODS.providerAuthCancel]: (input) =>
2512 observeRpcEffect(2526 observeRpcEffect(
2513 WS_METHODS.providerAuthCancel,2527 WS_METHODS.providerAuthCancel,
apps/web/src/components/ChatView.tsx+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { isChatGptUsageLimitError } from "@t3tools/shared/usageLimits";
1import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment";2import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment";
2import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";3import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
3import type { UsageLimitSourceSnapshots } from "@t3tools/contracts";4import type { UsageLimitSourceSnapshots } from "@t3tools/contracts";
...@@ -9809,6 +9810,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -9809,6 +9810,7 @@ export default function ChatView(props: ChatViewProps) {
9809 />9810 />
9810 <ThreadErrorBanner9811 <ThreadErrorBanner
9811 error={visibleThreadError}9812 error={visibleThreadError}
9813 chatGptUsageLimit={isChatGptUsageLimitError(threadActivities, visibleThreadError)}
9812 onDismiss={() => {9814 onDismiss={() => {
9813 setThreadError(activeThread.id, null);9815 setThreadError(activeThread.id, null);
9814 dismissThreadErrorBannerForSession(threadErrorBannerKey);9816 dismissThreadErrorBannerForSession(threadErrorBannerKey);
apps/web/src/components/Icons.tsx+1-1
...@@ -548,7 +548,7 @@ export const OpenAI: Icon = ({ className, ...props }) => (...@@ -548,7 +548,7 @@ export const OpenAI: Icon = ({ className, ...props }) => (
548 {...props}548 {...props}
549 preserveAspectRatio="xMidYMid"549 preserveAspectRatio="xMidYMid"
550 viewBox="100 100 411 411"550 viewBox="100 100 411 411"
551 className={cn("fill-black dark:fill-white", className)}551 className={cn("fill-current", className)}
552 >552 >
553 <path553 <path
554 fillRule="evenodd"554 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";...@@ -2,6 +2,8 @@ import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts";
2import { limitsNotice } from "@t3tools/shared/usageLimits";2import { limitsNotice } from "@t3tools/shared/usageLimits";
3import { GaugeIcon } from "lucide-react";3import { GaugeIcon } from "lucide-react";
44
5import { ensureLocalApi } from "../../localApi";
6import { Button } from "../ui/button";
5import { getDriverOption } from "../settings/providerDriverMeta";7import { getDriverOption } from "../settings/providerDriverMeta";
6import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";8import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";
7import { LimitWindows, ResetCredits } from "../usage/UsageLimits";9import { LimitWindows, ResetCredits } from "../usage/UsageLimits";
...@@ -85,6 +87,7 @@ function UsageLimitsBannerBody({...@@ -85,6 +87,7 @@ function UsageLimitsBannerBody({
85 account.resetCreditInput ??87 account.resetCreditInput ??
86 (account.instanceId ? { instanceId: account.instanceId } : undefined);88 (account.instanceId ? { instanceId: account.instanceId } : undefined);
87 const notice = limitsNotice(account.limits);89 const notice = limitsNotice(account.limits);
90 const externalUsage = account.limits.externalUsage;
88 return (91 return (
89 <div key={account.id} className="flex min-w-0 flex-col gap-1">92 <div key={account.id} className="flex min-w-0 flex-col gap-1">
90 {report.accounts.length > 1 ? (93 {report.accounts.length > 1 ? (
...@@ -102,6 +105,16 @@ function UsageLimitsBannerBody({...@@ -102,6 +105,16 @@ function UsageLimitsBannerBody({
102 now={now}105 now={now}
103 />106 />
104 )}107 )}
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}
105 {resetCreditInput && account.limits.resetCredits ? (118 {resetCreditInput && account.limits.resetCredits ? (
106 <ResetCredits119 <ResetCredits
107 environmentId={environmentId}120 environmentId={environmentId}
apps/web/src/components/chat/ProviderModelPicker.tsx+4
...@@ -10,6 +10,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";...@@ -10,6 +10,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
10import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";10import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
11import { cn } from "~/lib/utils";11import { cn } from "~/lib/utils";
12import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent";12import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent";
13import { ChatGptSharingControl } from "./ChatGptSharingControl";
13import { ProviderInstanceIcon } from "./ProviderInstanceIcon";14import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
14import {15import {
15 ModelEsque,16 ModelEsque,
...@@ -308,6 +309,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -308,6 +309,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
308 : {})}309 : {})}
309 onInstanceModelChange={handleInstanceModelChange}310 onInstanceModelChange={handleInstanceModelChange}
310 />311 />
312 {props.selectedModels === undefined ? (
313 <ChatGptSharingControl provider={activeEntry?.snapshot ?? null} />
314 ) : null}
311 </PopoverPopup>315 </PopoverPopup>
312 </Popover>316 </Popover>
313 );317 );
apps/web/src/components/chat/ThreadErrorBanner.tsx+29-11
...@@ -3,6 +3,8 @@ import { Alert, AlertAction, AlertDescription } from "../ui/alert";...@@ -3,6 +3,8 @@ import { Alert, AlertAction, AlertDescription } from "../ui/alert";
3import { Button } from "../ui/button";3import { Button } from "../ui/button";
4import { CircleAlertIcon, XIcon } from "lucide-react";4import { CircleAlertIcon, XIcon } from "lucide-react";
5import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";5import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
6import { OpenAI } from "../Icons";
7import { ChatGptUsageButton } from "../settings/ChatGptUsageButton";
68
7export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null {9export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null {
8 return error === null ? null : `${threadKey}\u0000${error}`;10 return error === null ? null : `${threadKey}\u0000${error}`;
...@@ -36,28 +38,44 @@ export function isThreadErrorBannerDismissedForSession(bannerKey: string | null)...@@ -36,28 +38,44 @@ export function isThreadErrorBannerDismissedForSession(bannerKey: string | null)
36export const ThreadErrorBanner = memo(function ThreadErrorBanner({38export const ThreadErrorBanner = memo(function ThreadErrorBanner({
37 error,39 error,
38 onDismiss,40 onDismiss,
41 chatGptUsageLimit = false,
39}: {42}: {
40 error: string | null;43 error: string | null;
41 onDismiss?: () => void;44 onDismiss?: () => void;
45 chatGptUsageLimit?: boolean;
42}) {46}) {
43 if (!error) return null;47 if (!error) return null;
44 return (48 return (
45 <div className="pointer-events-auto mx-auto w-fit max-w-[min(48rem,calc(100%-2rem))] pt-3">49 <div className="pointer-events-auto mx-auto w-fit max-w-[min(48rem,calc(100%-2rem))] pt-3">
46 <Alert variant="error" surface="glass" controlAlignment="first-line">50 <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 )}
48 <AlertDescription>56 <AlertDescription>
49 <Tooltip>57 {chatGptUsageLimit ? (
50 <TooltipTrigger render={<div className="line-clamp-3" />}>{error}</TooltipTrigger>58 <div className="space-y-1">
51 <TooltipPopup side="top" className="whitespace-pre-wrap">59 <p className="font-medium">ChatGPT usage limit reached</p>
52 {error}60 <p>Review your usage settings in ChatGPT to continue.</p>
53 </TooltipPopup>61 </div>
54 </Tooltip>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 )}
55 </AlertDescription>70 </AlertDescription>
56 {onDismiss && (71 {(chatGptUsageLimit || onDismiss) && (
57 <AlertAction>72 <AlertAction>
58 <Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={onDismiss}>73 {chatGptUsageLimit ? <ChatGptUsageButton variant="default" size="sm" /> : null}
59 <XIcon className="text-destructive" />74 {onDismiss ? (
60 </Button>75 <Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={onDismiss}>
76 <XIcon className="text-destructive" />
77 </Button>
78 ) : null}
61 </AlertAction>79 </AlertAction>
62 )}80 )}
63 </Alert>81 </Alert>
apps/web/src/components/onboarding/WelcomeWizard.test.tsx+5
...@@ -74,6 +74,11 @@ vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} }));...@@ -74,6 +74,11 @@ vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} }));
74vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() }));74vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() }));
75vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false }));75vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false }));
76vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null }));76vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null }));
77vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null }));
78vi.mock("../settings/CodexSetupSection", () => ({
79 CodexSetupSection: () => null,
80 AddManagedCodexAccountDialog: () => null,
81}));
77vi.mock("../cloud/CloudEnvironmentConnectList", () => ({82vi.mock("../cloud/CloudEnvironmentConnectList", () => ({
78 CloudEnvironmentConnectRows: () => null,83 CloudEnvironmentConnectRows: () => null,
79}));84}));
apps/web/src/components/onboarding/WelcomeWizard.tsx+228-51
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4 AgentSessionProjectCandidate,4 AgentSessionProjectCandidate,
5 EnvironmentId,5 EnvironmentId,
6 ProjectId,6 ProjectId,
7 ProviderInstanceId,
7 ScopedProjectRef,8 ScopedProjectRef,
8 ServerConfig,9 ServerConfig,
9 ServerProvider,10 ServerProvider,
...@@ -13,7 +14,12 @@ import {...@@ -13,7 +14,12 @@ import {
13 isAtomCommandInterrupted,14 isAtomCommandInterrupted,
14 squashAtomCommandFailure,15 squashAtomCommandFailure,
15} from "@t3tools/client-runtime/state/runtime";16} 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";
17import * as Schema from "effect/Schema";23import * as Schema from "effect/Schema";
18import {24import {
19 ArrowRightIcon,25 ArrowRightIcon,
...@@ -60,6 +66,10 @@ import { useAtomCommand } from "../../state/use-atom-command";...@@ -60,6 +66,10 @@ import { useAtomCommand } from "../../state/use-atom-command";
60import { connectPairing } from "../../connection/onboarding";66import { connectPairing } from "../../connection/onboarding";
61import { getProviderSummary } from "../settings/providerStatus";67import { getProviderSummary } from "../settings/providerStatus";
62import { getDriverOption } from "../settings/providerDriverMeta";68import { 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";
63import { TerminalViewport } from "../ThreadTerminalDrawer";73import { TerminalViewport } from "../ThreadTerminalDrawer";
64import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";74import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
65import { ClaudeAI, OpenAI } from "../Icons";75import { ClaudeAI, OpenAI } from "../Icons";
...@@ -81,7 +91,7 @@ import { formatRelativeTime } from "../../timestampFormat";...@@ -81,7 +91,7 @@ import { formatRelativeTime } from "../../timestampFormat";
81 * First-run welcome wizard. Rendered over the workspace at `/welcome` on a91 * First-run welcome wizard. Rendered over the workspace at `/welcome` on a
82 * fresh install (no completed-onboarding flag, empty workspace). Flow per the92 * fresh install (no completed-onboarding flag, empty workspace). Flow per the
83 * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) →93 * 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.
85 * Every step past the connection gate is skippable; the whole wizard is95 * Every step past the connection gate is skippable; the whole wizard is
86 * re-runnable by clearing the flag.96 * re-runnable by clearing the flag.
87 */97 */
...@@ -96,17 +106,21 @@ const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations m...@@ -96,17 +106,21 @@ const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations m
96export function WelcomeWizard({106export function WelcomeWizard({
97 localAvailable,107 localAvailable,
98 onDone,108 onDone,
109 resumeEnvironmentId,
99}: {110}: {
100 /** Whether this client is authenticated to the server serving the app. */111 /** Whether this client is authenticated to the server serving the app. */
101 readonly localAvailable: boolean;112 readonly localAvailable: boolean;
113 readonly resumeEnvironmentId?: EnvironmentId | undefined;
102 readonly onDone: (projectRef?: ScopedProjectRef) => void | Promise<void>;114 readonly onDone: (projectRef?: ScopedProjectRef) => void | Promise<void>;
103}) {115}) {
104 const completeOnboarding = useCompleteOnboarding();116 const completeOnboarding = useCompleteOnboarding();
105 const [step, setStep] = useState<WizardStep>("connection");117 const [step, setStep] = useState<WizardStep>(resumeEnvironmentId ? "agents" : "connection");
106 const { environments } = useEnvironments();118 const { environments } = useEnvironments();
107 const [selection, setSelection] = useState<ReadonlySet<EnvironmentId> | null>(null);119 const [selection, setSelection] = useState<ReadonlySet<EnvironmentId> | null>(null);
108 const autoSelectedComputers = useRef(new Set<EnvironmentId>());120 const autoSelectedComputers = useRef(new Set<EnvironmentId>());
109 const [setupIds, setSetupIds] = useState<readonly EnvironmentId[]>([]);121 const [setupIds, setSetupIds] = useState<readonly EnvironmentId[]>(
122 resumeEnvironmentId ? [resumeEnvironmentId] : [],
123 );
110 const [isImporting, setIsImporting] = useState(false);124 const [isImporting, setIsImporting] = useState(false);
111 const finishingPromiseRef = useRef<Promise<boolean> | null>(null);125 const finishingPromiseRef = useRef<Promise<boolean> | null>(null);
112 const completionErrorToastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null);126 const completionErrorToastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null);
...@@ -197,6 +211,7 @@ export function WelcomeWizard({...@@ -197,6 +211,7 @@ export function WelcomeWizard({
197 return (211 return (
198 <Dialog open disablePointerDismissal onOpenChange={(_, event) => event.cancel()}>212 <Dialog open disablePointerDismissal onOpenChange={(_, event) => event.cancel()}>
199 <WizardPopup213 <WizardPopup
214 size="wide"
200 bottomStickOnMobile={false}215 bottomStickOnMobile={false}
201 showCloseButton={false}216 showCloseButton={false}
202 initialFocus={() => document.getElementById("onboarding-pairing-url") ?? true}217 initialFocus={() => document.getElementById("onboarding-pairing-url") ?? true}
...@@ -261,6 +276,7 @@ export function WelcomeWizard({...@@ -261,6 +276,7 @@ export function WelcomeWizard({
261 )}276 )}
262 </WizardPanel>277 </WizardPanel>
263 </WizardPopup>278 </WizardPopup>
279 <ChatGptWelcomeCoordinator />
264 </Dialog>280 </Dialog>
265 );281 );
266}282}
...@@ -613,7 +629,7 @@ function PairingForm({...@@ -613,7 +629,7 @@ function PairingForm({
613629
614// ── Step 3: agents ───────────────────────────────────────────630// ── Step 3: agents ───────────────────────────────────────────
615631
616const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const;632const PRIMARY_AGENT_DRIVERS = ["codex", "claudeAgent"] as const;
617type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number];633type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number];
618634
619/** Setup values stay fixed while provider probes refresh the surrounding cards. */635/** Setup values stay fixed while provider probes refresh the surrounding cards. */
...@@ -626,13 +642,7 @@ interface AgentTerminalSession {...@@ -626,13 +642,7 @@ interface AgentTerminalSession {
626 readonly keybindings: ServerConfig["keybindings"];642 readonly keybindings: ServerConfig["keybindings"];
627}643}
628644
629/**645/** Codex uses managed setup; existing CLI installs retain the terminal path. */
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 */
636function AgentsStep({646function AgentsStep({
637 environmentIds,647 environmentIds,
638 onContinue,648 onContinue,
...@@ -642,8 +652,11 @@ function AgentsStep({...@@ -642,8 +652,11 @@ function AgentsStep({
642}) {652}) {
643 const { environments } = useEnvironments();653 const { environments } = useEnvironments();
644 return (654 return (
645 <StepShell title="Your agents" description="Agents available on your selected computers.">655 <StepShell
646 <ScrollArea scrollFade className="mt-5 h-auto max-h-96">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)]">
647 <div className="space-y-5 pr-3">660 <div className="space-y-5 pr-3">
648 {environmentIds.map((environmentId) => (661 {environmentIds.map((environmentId) => (
649 <ConnectedAgentsStep662 <ConnectedAgentsStep
...@@ -680,6 +693,12 @@ function ConnectedAgentsStep({...@@ -680,6 +693,12 @@ function ConnectedAgentsStep({
680 });693 });
681 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));694 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
682 const [terminalSession, setTerminalSession] = useState<AgentTerminalSession | null>(null);695 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
684 // Re-probe on entry so freshly installed CLIs show up without a manual703 // Re-probe on entry so freshly installed CLIs show up without a manual
685 // refresh; harmless when nothing changed (single-flighted per environment).704 // refresh; harmless when nothing changed (single-flighted per environment).
...@@ -689,44 +708,114 @@ function ConnectedAgentsStep({...@@ -689,44 +708,114 @@ function ConnectedAgentsStep({
689708
690 const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]);709 const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]);
691710
692 const primaryAgents = PRIMARY_AGENT_DRIVERS.map((driver) => ({711 const primaryAgents = PRIMARY_AGENT_DRIVERS.flatMap((driver) => {
693 driver,712 const instances =
694 provider: byDriver.get(driver),713 driver === "codex" ? providers?.filter((provider) => provider.driver === driver) : undefined;
695 }));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 }
696 return (732 return (
697 <section>733 <section>
698 <h2 className="mb-2 text-sm font-medium">{machineLabel}</h2>734 <h2 className="mb-2 text-sm font-medium">{machineLabel}</h2>
699 <div className="space-y-1.5">735 <div className="space-y-1.5">
700 {primaryAgents.map(({ driver, provider }) => (736 {primaryAgents.map(({ driver, provider, instanceId }) =>
701 <AgentCard737 driver === "codex" && serverConfig !== null ? (
702 key={driver}738 <OnboardingCodexSetup
703 driver={driver}739 key={instanceId ?? driver}
704 provider={provider}740 environmentId={environmentId}
705 terminalOpen={terminalSession?.driver === driver}741 provider={provider}
706 terminalAvailable={serverConfig !== null}742 serverConfig={serverConfig}
707 onOpenTerminal={() => {743 createdAccount={instanceId === createdAccount?.instanceId ? createdAccount : null}
708 if (provider === undefined || serverConfig === null) return;744 onAutoStartConsumed={() =>
709 setTerminalSession({745 setCreatedAccount((account) => (account ? { ...account, autoStart: false } : null))
710 environmentId,746 }
711 driver,747 terminalOpen={terminalSession?.driver === driver}
712 providerInstanceId: provider.instanceId,748 onOpenTerminal={() => {
713 cwd: serverConfig.cwd,749 if (provider === undefined) return;
714 command: provider.installed750 setTerminalSession({
715 ? resolveOnboardingProviderLoginCommand(751 environmentId,
716 provider,752 driver,
717 serverConfig.settings,753 providerInstanceId: provider.instanceId,
718 serverConfig.environment.platform.os,754 cwd: serverConfig.cwd,
719 )755 command: provider.installed
720 : resolveOnboardingProviderInstallCommand(756 ? resolveOnboardingProviderLoginCommand(
721 driver,757 provider,
722 serverConfig.environment.platform.os,758 serverConfig.settings,
723 ),759 serverConfig.environment.platform.os,
724 keybindings: serverConfig.keybindings,760 )
725 });761 : resolveOnboardingProviderInstallCommand(
726 }}762 driver,
727 />763 serverConfig.environment.platform.os,
728 ))}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 )}
729 </div>799 </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}
730 {terminalSession !== null ? (819 {terminalSession !== null ? (
731 <AgentInstallTerminal820 <AgentInstallTerminal
732 key={`${terminalSession.environmentId}:${terminalSession.providerInstanceId}:${terminalSession.driver}`}821 key={`${terminalSession.environmentId}:${terminalSession.providerInstanceId}:${terminalSession.driver}`}
...@@ -741,6 +830,93 @@ function ConnectedAgentsStep({...@@ -741,6 +830,93 @@ function ConnectedAgentsStep({
741 );830 );
742}831}
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
744function AgentCard({920function AgentCard({
745 driver,921 driver,
746 provider,922 provider,
...@@ -756,20 +932,21 @@ function AgentCard({...@@ -756,20 +932,21 @@ function AgentCard({
756}) {932}) {
757 const meta = getDriverOption(ProviderDriverKind.make(driver));933 const meta = getDriverOption(ProviderDriverKind.make(driver));
758 const Icon = meta?.icon;934 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));
760 const summary = getProviderSummary(provider);937 const summary = getProviderSummary(provider);
761 const providerState = getOnboardingProviderState(provider);938 const providerState = getOnboardingProviderState(provider);
762939
763 return (940 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">
765 {Icon ? (942 {Icon ? (
766 <Icon className={cn("size-5 shrink-0", driver !== "claudeAgent" && "fill-foreground")} />943 <Icon className={cn("size-5 shrink-0", driver !== "claudeAgent" && "fill-foreground")} />
767 ) : null}944 ) : null}
768 <div className="min-w-0 flex-1">945 <div className="min-w-0 flex-1">
769 <span className="block text-sm font-medium text-foreground">{displayName}</span>946 <span className="block text-sm font-medium text-foreground">{displayName}</span>
770 <p className="mt-0.5 text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground">947 <p className="mt-0.5 text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground">
771 {summary.headline}948 {providerState === "ready" ? "Ready to code." : summary.headline}
772 {summary.detail ? ` · ${summary.detail}` : ""}949 {providerState !== "ready" && summary.detail ? ` · ${summary.detail}` : ""}
773 </p>950 </p>
774 </div>951 </div>
775 <div className="shrink-0">952 <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...@@ -14,6 +14,7 @@ import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hook
14import { cn } from "../../lib/utils";14import { cn } from "../../lib/utils";
15import { normalizeProviderAccentColor } from "../../providerInstances";15import { normalizeProviderAccentColor } from "../../providerInstances";
16import { Button } from "../ui/button";16import { Button } from "../ui/button";
17import { ChatGptConnectionButton } from "./ChatGptConnectionButton";
17import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";18import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
18import { Dialog } from "../ui/dialog";19import { Dialog } from "../ui/dialog";
19import { Badge } from "../ui/badge";20import { Badge } from "../ui/badge";
...@@ -30,6 +31,7 @@ import {...@@ -30,6 +31,7 @@ import {
30 type WizardNavigation,31 type WizardNavigation,
31} from "./AddProviderInstanceDialog.logic";32} from "./AddProviderInstanceDialog.logic";
32import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps";33import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps";
34import { AddManagedCodexAccountDialog } from "./CodexSetupSection";
3335
34const PROVIDER_ACCENT_SWATCHES = [36const PROVIDER_ACCENT_SWATCHES = [
35 "#2563eb",37 "#2563eb",
...@@ -126,6 +128,7 @@ export function AddProviderInstanceDialog({...@@ -126,6 +128,7 @@ export function AddProviderInstanceDialog({
126 const updateSettings = useUpdateEnvironmentSettings(environmentId);128 const updateSettings = useUpdateEnvironmentSettings(environmentId);
127129
128 const [wizardStep, setWizardStep] = useState(0);130 const [wizardStep, setWizardStep] = useState(0);
131 const [addingChatGptAccount, setAddingChatGptAccount] = useState(false);
129 const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND);132 const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND);
130 const [label, setLabel] = useState("");133 const [label, setLabel] = useState("");
131 const [accentColor, setAccentColor] = useState<string>("");134 const [accentColor, setAccentColor] = useState<string>("");
...@@ -185,7 +188,10 @@ export function AddProviderInstanceDialog({...@@ -185,7 +188,10 @@ export function AddProviderInstanceDialog({
185 setHasAttemptedSubmit(true);188 setHasAttemptedSubmit(true);
186 if (instanceIdError !== null) return;189 if (instanceIdError !== null) return;
187190
188 const config = configByDriver[driver] ?? {};191 const config =
192 driver === "codex"
193 ? { ...configByDriver[driver], setupMode: "existing" }
194 : (configByDriver[driver] ?? {});
189 const hasConfig = Object.keys(config).length > 0;195 const hasConfig = Object.keys(config).length > 0;
190 const normalizedAccentColor = normalizeProviderAccentColor(accentColor);196 const normalizedAccentColor = normalizeProviderAccentColor(accentColor);
191197
...@@ -222,17 +228,21 @@ export function AddProviderInstanceDialog({...@@ -222,17 +228,21 @@ export function AddProviderInstanceDialog({
222 }228 }
223 };229 };
224230
231 if (addingChatGptAccount) {
232 return (
233 <AddManagedCodexAccountDialog
234 environmentId={environmentId}
235 onClose={() => onOpenChange(false)}
236 />
237 );
238 }
239
225 return (240 return (
226 <Dialog open={open} onOpenChange={onOpenChange}>241 <Dialog open={open} onOpenChange={onOpenChange}>
227 <WizardPopup>242 <WizardPopup size="wide">
228 <WizardHeader243 <WizardHeader
229 title="Add provider instance"244 title="Add provider instance"
230 description={245 description={<>Add an account or configure a provider on {environmentLabel}.</>}
231 <>
232 Configure an additional provider instance on {environmentLabel} — for example, a
233 second Codex install pointed at a different workspace.
234 </>
235 }
236 >246 >
237 <AddProviderInstanceWizardSteps247 <AddProviderInstanceWizardSteps
238 currentStep={wizardStep}248 currentStep={wizardStep}
...@@ -400,7 +410,7 @@ export function AddProviderInstanceDialog({...@@ -400,7 +410,7 @@ export function AddProviderInstanceDialog({
400410
401 <WizardFooter>411 <WizardFooter>
402 <Button412 <Button
403 variant="outline"413 variant={wizardStep === 0 ? "ghost-muted" : "outline"}
404 onClick={() => {414 onClick={() => {
405 if (wizardStep === 0) {415 if (wizardStep === 0) {
406 onOpenChange(false);416 onOpenChange(false);
...@@ -411,7 +421,14 @@ export function AddProviderInstanceDialog({...@@ -411,7 +421,14 @@ export function AddProviderInstanceDialog({
411 >421 >
412 {wizardStep === 0 ? "Cancel" : "Back"}422 {wizardStep === 0 ? "Cancel" : "Back"}
413 </Button>423 </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 ? (
415 <Button onClick={() => navigateToStep(wizardStep + 1)}>Next</Button>432 <Button onClick={() => navigateToStep(wizardStep + 1)}>Next</Button>
416 ) : (433 ) : (
417 <Button onClick={handleSave}>Add instance</Button>434 <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({...@@ -16,12 +16,14 @@ export function FoldedSettingsSection({
16 title,16 title,
17 summary,17 summary,
18 control,18 control,
19 headerPlacement = "inside",
19 children,20 children,
20}: {21}: {
21 readonly id: string;22 readonly id: string;
22 readonly title: string;23 readonly title: string;
23 readonly summary?: string | null;24 readonly summary?: string | null;
24 readonly control?: ReactNode;25 readonly control?: ReactNode;
26 readonly headerPlacement?: "inside" | "outside";
25 readonly children: ReactNode;27 readonly children: ReactNode;
26}) {28}) {
27 const [open, setOpen] = useState(false);29 const [open, setOpen] = useState(false);
...@@ -34,6 +36,38 @@ export function FoldedSettingsSection({...@@ -34,6 +36,38 @@ export function FoldedSettingsSection({
34 if (!open) setOpen(true);36 if (!open) setOpen(true);
35 }37 }
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
37 return (71 return (
38 <section id={id} ref={targetRef} tabIndex={-1} className="outline-none">72 <section id={id} ref={targetRef} tabIndex={-1} className="outline-none">
39 <Collapsible open={open} onOpenChange={setOpen} render={<SettingsGroup divided={false} />}>73 <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...@@ -49,6 +49,8 @@ import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/Provider
49import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";49import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
50import { RedactedSensitiveText } from "./RedactedSensitiveText";50import { RedactedSensitiveText } from "./RedactedSensitiveText";
51import { SettingsRow, SettingsSection } from "./settingsLayout";51import { SettingsRow, SettingsSection } from "./settingsLayout";
52import { FoldedSettingsSection } from "./FoldedSettingsSection";
53import { readCodexSetupMode } from "./CodexSetupSection.logic";
52import {54import {
53 getProviderVersionAdvisoryPresentation,55 getProviderVersionAdvisoryPresentation,
54 PROVIDER_STATUS_STYLES,56 PROVIDER_STATUS_STYLES,
...@@ -387,6 +389,7 @@ interface ProviderInstanceCardProps {...@@ -387,6 +389,7 @@ interface ProviderInstanceCardProps {
387 */389 */
388 readonly headerAction?: ReactNode | undefined;390 readonly headerAction?: ReactNode | undefined;
389 readonly setup?: ReactNode;391 readonly setup?: ReactNode;
392 readonly runtime?: ReactNode;
390 readonly hiddenModels: ReadonlyArray<string>;393 readonly hiddenModels: ReadonlyArray<string>;
391 readonly favoriteModels: ReadonlyArray<string>;394 readonly favoriteModels: ReadonlyArray<string>;
392 readonly modelOrder: ReadonlyArray<string>;395 readonly modelOrder: ReadonlyArray<string>;
...@@ -430,6 +433,7 @@ export function ProviderInstanceCard({...@@ -430,6 +433,7 @@ export function ProviderInstanceCard({
430 onDelete,433 onDelete,
431 headerAction,434 headerAction,
432 setup,435 setup,
436 runtime,
433 hiddenModels,437 hiddenModels,
434 favoriteModels,438 favoriteModels,
435 modelOrder,439 modelOrder,
...@@ -654,11 +658,6 @@ export function ProviderInstanceCard({...@@ -654,11 +658,6 @@ export function ProviderInstanceCard({
654 <span className="min-w-0 flex-1">658 <span className="min-w-0 flex-1">
655 <span className="flex min-w-0 items-center gap-2">659 <span className="flex min-w-0 items-center gap-2">
656 <span className="truncate text-sm font-medium text-foreground">{displayName}</span>660 <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}
662 {versionLabel ? (661 {versionLabel ? (
663 <code className="max-w-24 shrink-0 truncate text-xs text-muted-foreground">662 <code className="max-w-24 shrink-0 truncate text-xs text-muted-foreground">
664 {versionLabel}663 {versionLabel}
...@@ -860,6 +859,26 @@ export function ProviderInstanceCard({...@@ -860,6 +859,26 @@ export function ProviderInstanceCard({
860 </div>859 </div>
861 );860 );
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
863 return (882 return (
864 <>883 <>
865 <SettingsSection title={displayName} icon={titleIconNode} headerAction={editorHeaderAction}>884 <SettingsSection title={displayName} icon={titleIconNode} headerAction={editorHeaderAction}>
...@@ -907,33 +926,31 @@ export function ProviderInstanceCard({...@@ -907,33 +926,31 @@ export function ProviderInstanceCard({
907926
908 {setup ? <SettingsSection title="Setup">{setup}</SettingsSection> : null}927 {setup ? <SettingsSection title="Setup">{setup}</SettingsSection> : null}
909928
910 <SettingsSection929 {instance.driver === "codex" && readCodexSetupMode(instance.config) === "managed" ? (
911 title="Runtime"930 <div
912 inert={readOnly}931 inert={readOnly}
913 aria-disabled={readOnly || undefined}932 aria-disabled={readOnly || undefined}
914 className={readOnly ? "opacity-50 select-none" : undefined}933 className={readOnly ? "opacity-50 select-none" : undefined}
915 >934 >
916 {driverOption ? (935 <FoldedSettingsSection
917 <ProviderSettingsForm936 key={instanceId}
918 definition={driverOption}937 id={`provider-instance-${instanceId}-runtime`}
919 value={instance.config}938 title="Runtime"
920 idPrefix={`provider-instance-${instanceId}`}939 headerPlacement="outside"
921 variant="settings"940 >
922 onChange={updateConfig}941 {runtime ?? runtimeFields}
923 />942 </FoldedSettingsSection>
924 ) : (943 </div>
925 <SettingsRow944 ) : (
926 title="Driver"945 <SettingsSection
927 description={946 title="Runtime"
928 <span>947 inert={readOnly}
929 This instance uses{" "}948 aria-disabled={readOnly || undefined}
930 <code className="text-foreground">{String(instance.driver)}</code>, which is not949 className={readOnly ? "opacity-50 select-none" : undefined}
931 available in this build. Its configuration is preserved.950 >
932 </span>951 {runtimeFields}
933 }952 </SettingsSection>
934 />953 )}
935 )}
936 </SettingsSection>
937954
938 <SettingsSection955 <SettingsSection
939 title="Environment"956 title="Environment"
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts+15-11
...@@ -20,18 +20,22 @@ export function isProviderSettingsEnvironmentAvailable(input: {...@@ -20,18 +20,22 @@ export function isProviderSettingsEnvironmentAvailable(input: {
20export function buildProviderEnvironmentOptions<T extends ProviderEnvironmentOptionLike>(20export function buildProviderEnvironmentOptions<T extends ProviderEnvironmentOptionLike>(
21 environments: ReadonlyArray<T>,21 environments: ReadonlyArray<T>,
22 primaryEnvironmentId: EnvironmentId | null,22 primaryEnvironmentId: EnvironmentId | null,
23 environmentIds?: readonly EnvironmentId[],
23): ReadonlyArray<T> {24): ReadonlyArray<T> {
24 return environments.toSorted((left, right) => {25 const allowed = environmentIds ? new Set(environmentIds) : null;
25 const leftIsPrimary = left.environmentId === primaryEnvironmentId;26 return environments
26 const rightIsPrimary = right.environmentId === primaryEnvironmentId;27 .filter((environment) => !allowed || allowed.has(environment.environmentId))
27 if (leftIsPrimary !== rightIsPrimary) {28 .toSorted((left, right) => {
28 return leftIsPrimary ? -1 : 1;29 const leftIsPrimary = left.environmentId === primaryEnvironmentId;
29 }30 const rightIsPrimary = right.environmentId === primaryEnvironmentId;
30 return (31 if (leftIsPrimary !== rightIsPrimary) {
31 left.label.localeCompare(right.label) ||32 return leftIsPrimary ? -1 : 1;
32 String(left.environmentId).localeCompare(String(right.environmentId))33 }
33 );34 return (
34 });35 left.label.localeCompare(right.label) ||
36 String(left.environmentId).localeCompare(String(right.environmentId))
37 );
38 });
35}39}
3640
37export function resolveSelectedProviderEnvironmentId(41export function resolveSelectedProviderEnvironmentId(
apps/web/src/components/settings/ProviderSettingsPanel.tsx+51-7
...@@ -84,6 +84,8 @@ import { ExpandableText } from "./ExpandableText";...@@ -84,6 +84,8 @@ import { ExpandableText } from "./ExpandableText";
84import { ProviderInstanceCard } from "./ProviderInstanceCard";84import { ProviderInstanceCard } from "./ProviderInstanceCard";
85import { UsageProviderSettings } from "./UsageProviderSettings";85import { UsageProviderSettings } from "./UsageProviderSettings";
86import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection";86import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection";
87import { CodexSetupSection, CodexManagedRuntimeFields } from "./CodexSetupSection";
88import { readCodexSetupMode } from "./CodexSetupSection.logic";
87import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";89import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
88import { searchableSetting } from "./settingsSearch";90import { searchableSetting } from "./settingsSearch";
89import {91import {
...@@ -265,6 +267,7 @@ interface ProviderSettingsTarget {...@@ -265,6 +267,7 @@ interface ProviderSettingsTarget {
265 readonly environmentId?: EnvironmentId;267 readonly environmentId?: EnvironmentId;
266 readonly instanceId?: ProviderInstanceId;268 readonly instanceId?: ProviderInstanceId;
267 readonly scoped?: boolean;269 readonly scoped?: boolean;
270 readonly environmentIds?: readonly EnvironmentId[];
268}271}
269272
270export function ProviderSettingsPanel(target: ProviderSettingsTarget) {273export function ProviderSettingsPanel(target: ProviderSettingsTarget) {
...@@ -283,8 +286,9 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {...@@ -283,8 +286,9 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
283 const primaryEnvironmentId = usePrimaryEnvironmentId();286 const primaryEnvironmentId = usePrimaryEnvironmentId();
284 const searchTargetId = useSettingsSearchTargetId();287 const searchTargetId = useSettingsSearchTargetId();
285 const options = useMemo(288 const options = useMemo(
286 () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId),289 () =>
287 [environments, primaryEnvironmentId],290 buildProviderEnvironmentOptions(environments, primaryEnvironmentId, target.environmentIds),
291 [environments, primaryEnvironmentId, target.environmentIds],
288 );292 );
289 // Raw user intent; the effective selection is re-derived every render so a293 // Raw user intent; the effective selection is re-derived every render so a
290 // device that drops out of the catalog falls back without erasing the pick —294 // device that drops out of the catalog falls back without erasing the pick —
...@@ -935,6 +939,17 @@ export function EnvironmentProviderSettings({...@@ -935,6 +939,17 @@ export function EnvironmentProviderSettings({
935 selected={mode === "list" && selectedRow?.instanceId === row.instanceId}939 selected={mode === "list" && selectedRow?.instanceId === row.instanceId}
936 onSelect={mode === "list" ? () => setSelectedInstanceId(row.instanceId) : undefined}940 onSelect={mode === "list" ? () => setSelectedInstanceId(row.instanceId) : undefined}
937 readOnly={readOnly}941 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 }
938 setup={953 setup={
939 mode === "editor" && row.driver === "antigravity" ? (954 mode === "editor" && row.driver === "antigravity" ? (
940 <ProviderSetupSection955 <ProviderSetupSection
...@@ -948,6 +963,30 @@ export function EnvironmentProviderSettings({...@@ -948,6 +963,30 @@ export function EnvironmentProviderSettings({
948 readOnly={readOnly}963 readOnly={readOnly}
949 onEnable={() => updateProviderInstance(row, { ...row.instance, enabled: true })}964 onEnable={() => updateProviderInstance(row, { ...row.instance, enabled: true })}
950 />965 />
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 />
951 ) : null990 ) : null
952 }991 }
953 onUpdate={(next) => {992 onUpdate={(next) => {
...@@ -1021,10 +1060,11 @@ export function EnvironmentProviderSettings({...@@ -1021,10 +1060,11 @@ export function EnvironmentProviderSettings({
10211060
1022 return (1061 return (
1023 <>1062 <>
1024 <SettingsSection {...searchableSetting("providers")} variant="plain">1063 <SettingsSection
1025 <div className="flex min-h-11 min-w-0 items-center gap-2 px-3 sm:px-4">1064 {...searchableSetting("providers")}
1026 {deviceTabs}1065 variant="plain"
1027 <div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">1066 headerAction={
1067 <div className="flex min-w-0 items-center gap-2">
1028 {readOnly ? (1068 {readOnly ? (
1029 <span className="min-w-0 truncate text-xs text-muted-foreground">1069 <span className="min-w-0 truncate text-xs text-muted-foreground">
1030 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />1070 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
...@@ -1073,7 +1113,11 @@ export function EnvironmentProviderSettings({...@@ -1073,7 +1113,11 @@ export function EnvironmentProviderSettings({
1073 </>1113 </>
1074 )}1114 )}
1075 </div>1115 </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}
1077 {readOnly ? (1121 {readOnly ? (
1078 <SettingsGroup divided={false} className="overflow-hidden">1122 <SettingsGroup divided={false} className="overflow-hidden">
1079 <SettingsRow1123 <SettingsRow
apps/web/src/components/settings/SettingsScopeContext.tsx+22-6
...@@ -10,6 +10,7 @@ import { getProjectFileQueryAtom, optimisticFileAtom } from "../files/projectFil...@@ -10,6 +10,7 @@ import { getProjectFileQueryAtom, optimisticFileAtom } from "../files/projectFil
10import { useSettingsProjectGroups } from "./useSettingsProjectGroups";10import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
11import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings";11import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings";
12import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";12import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
13import { selectSingleEnvironmentScope } from "./settingsScopeAxis";
1314
14/**15/**
15 * Each member's decoded t3.json, so file-backed settings show the file as a16 * 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>) {...@@ -53,10 +54,22 @@ function useMemberProjectFiles(scope: ReturnType<typeof resolveSettingsScope>) {
53 );54 );
54}55}
5556
56function useResolvedSettingsScope(search: SettingsScopeSearch) {57function useResolvedSettingsScope(rawSearch: SettingsScopeSearch, singleEnvironment: boolean) {
57 const groups = useSettingsProjectGroups();58 const groups = useSettingsProjectGroups();
58 const { environments: availableEnvironments } = useEnvironments();59 const { environments: availableEnvironments } = useEnvironments();
59 const primaryEnvironmentId = usePrimaryEnvironmentId();60 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 );
60 const scope = useMemo(73 const scope = useMemo(
61 () => resolveSettingsScope(search, groups, availableEnvironments),74 () => resolveSettingsScope(search, groups, availableEnvironments),
62 [availableEnvironments, groups, search],75 [availableEnvironments, groups, search],
...@@ -81,12 +94,13 @@ function useResolvedSettingsScope(search: SettingsScopeSearch) {...@@ -81,12 +94,13 @@ function useResolvedSettingsScope(search: SettingsScopeSearch) {
81 ) ??94 ) ??
82 targets[0] ??95 targets[0] ??
83 null;96 null;
84 return { scope, groups, ...selected, targets, target };97 return { scope, groups, search, ...selected, targets, target };
85 }, [availableEnvironments, groups, primaryEnvironmentId, projectFiles, scope]);98 }, [availableEnvironments, groups, primaryEnvironmentId, projectFiles, scope, search]);
86}99}
87100
88const SettingsScopeContext = createContext<101const SettingsScopeContext = createContext<
89 | (ReturnType<typeof useResolvedSettingsScope> & {102 | (ReturnType<typeof useResolvedSettingsScope> & {
103 singleEnvironment: boolean;
90 search: SettingsScopeSearch;104 search: SettingsScopeSearch;
91 selectScope: (next: SettingsScopeSearch) => void;105 selectScope: (next: SettingsScopeSearch) => void;
92 })106 })
...@@ -97,15 +111,17 @@ export function SettingsScopeProvider({...@@ -97,15 +111,17 @@ export function SettingsScopeProvider({
97 search,111 search,
98 onChange,112 onChange,
99 children,113 children,
114 singleEnvironment = false,
100}: {115}: {
116 singleEnvironment?: boolean;
101 search: SettingsScopeSearch;117 search: SettingsScopeSearch;
102 onChange: (next: SettingsScopeSearch) => void;118 onChange: (next: SettingsScopeSearch) => void;
103 children: ReactNode;119 children: ReactNode;
104}) {120}) {
105 const resolved = useResolvedSettingsScope(search);121 const resolved = useResolvedSettingsScope(search, singleEnvironment);
106 const value = useMemo(122 const value = useMemo(
107 () => ({ ...resolved, search, selectScope: onChange }),123 () => ({ ...resolved, singleEnvironment, selectScope: onChange }),
108 [onChange, resolved, search],124 [onChange, resolved, singleEnvironment],
109 );125 );
110 return <SettingsScopeContext value={value}>{children}</SettingsScopeContext>;126 return <SettingsScopeContext value={value}>{children}</SettingsScopeContext>;
111}127}
apps/web/src/components/settings/SettingsScopeSentence.tsx+24-10
...@@ -40,6 +40,7 @@ interface SettingsScopeMenuProps {...@@ -40,6 +40,7 @@ interface SettingsScopeMenuProps {
40 readonly value: SettingsScopeSearch;40 readonly value: SettingsScopeSearch;
41 readonly groups: readonly SidebarProjectSnapshot[];41 readonly groups: readonly SidebarProjectSnapshot[];
42 readonly environments: readonly EnvironmentPresentation[];42 readonly environments: readonly EnvironmentPresentation[];
43 readonly singleEnvironment: boolean;
43 readonly onChange: (next: SettingsScopeSearch) => void;44 readonly onChange: (next: SettingsScopeSearch) => void;
44}45}
4546
...@@ -56,6 +57,7 @@ export function SettingsScopeSentence() {...@@ -56,6 +57,7 @@ export function SettingsScopeSentence() {
56 if (scope === null || SETTINGS_DEVICE_ONLY_PATHS.has(pathname)) return null;57 if (scope === null || SETTINGS_DEVICE_ONLY_PATHS.has(pathname)) return null;
57 const props: SettingsScopeMenuProps = {58 const props: SettingsScopeMenuProps = {
58 value: scope.search,59 value: scope.search,
60 singleEnvironment: scope.singleEnvironment,
59 groups: scope.groups,61 groups: scope.groups,
60 environments,62 environments,
61 onChange: scope.selectScope,63 onChange: scope.selectScope,
...@@ -105,7 +107,13 @@ function ScopeMenu({...@@ -105,7 +107,13 @@ function ScopeMenu({
105 );107 );
106}108}
107109
108function EnvironmentScopeMenu({ value, groups, environments, onChange }: SettingsScopeMenuProps) {110function EnvironmentScopeMenu({
111 value,
112 groups,
113 environments,
114 onChange,
115 singleEnvironment,
116}: SettingsScopeMenuProps) {
109 const resolved = resolveSettingsScope(value, groups, environments);117 const resolved = resolveSettingsScope(value, groups, environments);
110 const environmentValue = environmentAxisValue(118 const environmentValue = environmentAxisValue(
111 value,119 value,
...@@ -131,7 +139,9 @@ function EnvironmentScopeMenu({ value, groups, environments, onChange }: Setting...@@ -131,7 +139,9 @@ function EnvironmentScopeMenu({ value, groups, environments, onChange }: Setting
131 ? settingsScopeEnvironmentLabel(selected, environments)139 ? settingsScopeEnvironmentLabel(selected, environments)
132 : environmentValue !== ALL_ENVIRONMENTS_VALUE140 : environmentValue !== ALL_ENVIRONMENTS_VALUE
133 ? "Unavailable environment"141 ? "Unavailable environment"
134 : "All environments"142 : singleEnvironment
143 ? "No environments"
144 : "All environments"
135 }145 }
136 >146 >
137 <MenuRadioGroup147 <MenuRadioGroup
...@@ -140,14 +150,18 @@ function EnvironmentScopeMenu({ value, groups, environments, onChange }: Setting...@@ -140,14 +150,18 @@ function EnvironmentScopeMenu({ value, groups, environments, onChange }: Setting
140 if (typeof next === "string") onChange(selectEnvironmentAxis(value, next));150 if (typeof next === "string") onChange(selectEnvironmentAxis(value, next));
141 }}151 }}
142 >152 >
143 <MenuRadioItem value={ALL_ENVIRONMENTS_VALUE}>153 {!singleEnvironment ? (
144 <span className="flex min-w-0 items-center gap-2">154 <>
145 <LayersIcon aria-hidden className="size-3.5" />155 <MenuRadioItem value={ALL_ENVIRONMENTS_VALUE}>
146 <span className="min-w-0 flex-1 truncate">All environments</span>156 <span className="flex min-w-0 items-center gap-2">
147 <MenuRadioItemIndicator />157 <LayersIcon aria-hidden className="size-3.5" />
148 </span>158 <span className="min-w-0 flex-1 truncate">All environments</span>
149 </MenuRadioItem>159 <MenuRadioItemIndicator />
150 <MenuSeparator />160 </span>
161 </MenuRadioItem>
162 <MenuSeparator />
163 </>
164 ) : null}
151 {environments.map((environment) => (165 {environments.map((environment) => (
152 <MenuRadioItem key={environment.environmentId} value={environment.environmentId}>166 <MenuRadioItem key={environment.environmentId} value={environment.environmentId}>
153 <span className="flex min-w-0 items-center gap-2">167 <span className="flex min-w-0 items-center gap-2">
apps/web/src/components/settings/settingsScopeAxis.ts+23-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { EnvironmentPresentation } from "../../state/environments";1import type { EnvironmentPresentation } from "../../state/environments";
2import type { SettingsScopeSearch } from "./settingsScope";2import type { ResolvedSettingsScope, SettingsScopeSearch } from "./settingsScope";
33
4type ScopeEnvironment = Pick<EnvironmentPresentation, "environmentId" | "label" | "displayUrl">;4type ScopeEnvironment = Pick<EnvironmentPresentation, "environmentId" | "label" | "displayUrl">;
55
...@@ -53,3 +53,25 @@ export function selectProjectAxis(search: SettingsScopeSearch, value: string): S...@@ -53,3 +53,25 @@ export function selectProjectAxis(search: SettingsScopeSearch, value: string): S
53 if (search.machine) next.machine = search.machine;53 if (search.machine) next.machine = search.machine;
54 return next;54 return next;
55}55}
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...@@ -8,10 +8,19 @@ import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter
8/** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */8/** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */
9export function WizardPopup({9export function WizardPopup({
10 children,10 children,
11 size = "default",
11 ...props12 ...props
12}: Omit<ComponentProps<typeof DialogPopup>, "className" | "style">) {13}: Omit<ComponentProps<typeof DialogPopup>, "className" | "style"> & {
14 readonly size?: "default" | "wide";
15}) {
13 return (16 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 >
15 <div className="flex min-h-0 flex-col">{children}</div>24 <div className="flex min-h-0 flex-col">{children}</div>
16 </DialogPopup>25 </DialogPopup>
17 );26 );
apps/web/src/components/usage/UsageLimitsPooled.tsx+37-2
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import {1import {
2 CHATGPT_USAGE_URL,
2 collectLimitAccounts,3 collectLimitAccounts,
4 collectExternalUsageLinks,
3 collectLimitNotices,5 collectLimitNotices,
4 collectLimitPools,6 collectLimitPools,
5 cursorUsageWindowDetails,7 cursorUsageWindowDetails,
...@@ -11,9 +13,10 @@ import {...@@ -11,9 +13,10 @@ import {
11 type LimitPoolWindow,13 type LimitPoolWindow,
12 remainingPercent,14 remainingPercent,
13} from "@t3tools/shared/usageLimits";15} from "@t3tools/shared/usageLimits";
14import { AlertTriangleIcon, TicketIcon } from "lucide-react";16import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react";
15import { Fragment, type ReactNode, useState } from "react";17import { Fragment, type ReactNode, useState } from "react";
1618
19import { ensureLocalApi } from "../../localApi";
17import { usePrimarySettings } from "../../hooks/useSettings";20import { usePrimarySettings } from "../../hooks/useSettings";
18import { cn } from "../../lib/utils";21import { cn } from "../../lib/utils";
19import { formatUpcomingTimestamp } from "../../timestampFormat";22import { formatUpcomingTimestamp } from "../../timestampFormat";
...@@ -21,6 +24,7 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";...@@ -21,6 +24,7 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
21import { getDriverOption } from "../settings/providerDriverMeta";24import { getDriverOption } from "../settings/providerDriverMeta";
22import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";25import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";
23import { Button } from "../ui/button";26import { Button } from "../ui/button";
27import { OpenAI } from "../Icons";
24import { Alert, AlertTitle } from "../ui/alert";28import { Alert, AlertTitle } from "../ui/alert";
25import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";29import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
26import {30import {
...@@ -573,6 +577,7 @@ export function UsageLimitsPooled({...@@ -573,6 +577,7 @@ export function UsageLimitsPooled({
573}) {577}) {
574 const pools = collectLimitPools(collectLimitAccounts(presentations), now);578 const pools = collectLimitPools(collectLimitAccounts(presentations), now);
575 const notices = collectLimitNotices(presentations);579 const notices = collectLimitNotices(presentations);
580 const externalLinks = collectExternalUsageLinks(presentations);
576 const cursorPromptAt =581 const cursorPromptAt =
577 Math.max(582 Math.max(
578 pools.findIndex((pool) => pool.driver === "codex"),583 pools.findIndex((pool) => pool.driver === "codex"),
...@@ -580,7 +585,7 @@ export function UsageLimitsPooled({...@@ -580,7 +585,7 @@ export function UsageLimitsPooled({
580 ) + 1;585 ) + 1;
581 return (586 return (
582 <div className="flex flex-col gap-8">587 <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 ? (
584 <p className="text-sm text-muted-foreground">589 <p className="text-sm text-muted-foreground">
585 No provider on the selected environments reports subscription limits.590 No provider on the selected environments reports subscription limits.
586 </p>591 </p>
...@@ -592,6 +597,36 @@ export function UsageLimitsPooled({...@@ -592,6 +597,36 @@ export function UsageLimitsPooled({
592 </Fragment>597 </Fragment>
593 ))}598 ))}
594 {cursorPromptAt === pools.length ? cursorPrompt : null}599 {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 ))}
595 <LimitNotices notices={notices} />630 <LimitNotices notices={notices} />
596 </div>631 </div>
597 );632 );
apps/web/src/components/usage/UsagePage.tsx+13
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { ChatGptUsageButton } from "../settings/ChatGptUsageButton";
2import { usesChatGptSharing } from "@t3tools/shared/usageLimits";
1import { RefreshIcon } from "~/components/ui/refresh-icon";3import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { useAtomValue } from "@effect/atom-react";4import { useAtomValue } from "@effect/atom-react";
3import {5import {
...@@ -516,6 +518,17 @@ export function UsagePage() {...@@ -516,6 +518,17 @@ export function UsagePage() {
516 </span>518 </span>
517 </div>519 </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
519 {summaryRows.map((row) => {532 {summaryRows.map((row) => {
520 if (row.kind === "enable") {533 if (row.kind === "enable") {
521 return (534 return (
apps/web/src/main.tsx+3
...@@ -4,6 +4,7 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"...@@ -4,6 +4,7 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"
44
5import "./index.css";5import "./index.css";
66
7import { prepareProviderAuthDelivery } from "./providerAuthDelivery";
7import { isElectron } from "./env";8import { isElectron } from "./env";
8import { hasCloudPublicConfig } from "./cloud/publicConfig";9import { hasCloudPublicConfig } from "./cloud/publicConfig";
9import { getRouter } from "./router";10import { getRouter } from "./router";
...@@ -14,6 +15,8 @@ import {...@@ -14,6 +15,8 @@ import {
14import { AppRoot } from "./AppRoot";15import { AppRoot } from "./AppRoot";
15import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard";16import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard";
1617
18prepareProviderAuthDelivery();
19
17// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.20// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
18const history = isElectron ? createHashHistory() : createBrowserHistory();21const history = isElectron ? createHashHistory() : createBrowserHistory();
1922
apps/web/src/onboarding/providerReadiness.logic.ts+2
...@@ -36,6 +36,8 @@ function quoteProviderBinary(...@@ -36,6 +36,8 @@ function quoteProviderBinary(
36export function getOnboardingProviderState(provider: ServerProvider | undefined) {36export function getOnboardingProviderState(provider: ServerProvider | undefined) {
37 if (provider === undefined) return "checking";37 if (provider === undefined) return "checking";
38 if (!provider.enabled || provider.status === "disabled") return "disabled";38 if (!provider.enabled || provider.status === "disabled") return "disabled";
39 if (!provider.installed && provider.status === "warning" && provider.auth.status === "unknown")
40 return "checking";
39 if (!provider.installed) return "install";41 if (!provider.installed) return "install";
40 if (provider.auth.status === "unauthenticated") return "signIn";42 if (provider.auth.status === "unauthenticated") return "signIn";
41 if (provider.status === "ready") return "ready";43 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...@@ -32,6 +32,8 @@ import { ThreadNotificationCoordinator } from "../components/ThreadNotificationC
32import { QueuedMessageSender } from "../components/QueuedMessageSender";32import { QueuedMessageSender } from "../components/QueuedMessageSender";
33import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";33import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";
34import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";34import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
35import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator";
36import { ProviderAuthCallbackCoordinator } from "../components/settings/ProviderAuthCallbackCoordinator";
35import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";37import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
36import { useCopyToClipboard } from "../hooks/useCopyToClipboard";38import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
37import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme";39import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme";
...@@ -176,6 +178,7 @@ function RootRouteView() {...@@ -176,6 +178,7 @@ function RootRouteView() {
176 <EnvironmentThemeSync />178 <EnvironmentThemeSync />
177 <GlassAppearanceSync />179 <GlassAppearanceSync />
178 <FontAppearanceSync />180 <FontAppearanceSync />
181 <ProviderAuthCallbackCoordinator />
179 <CustomSnoozeDialogHost />182 <CustomSnoozeDialogHost />
180 <CommandPalette>183 <CommandPalette>
181 <AppSidebarLayout>184 <AppSidebarLayout>
...@@ -216,6 +219,8 @@ function RootRouteView() {...@@ -216,6 +219,8 @@ function RootRouteView() {
216 <EnvironmentThemeSync />219 <EnvironmentThemeSync />
217 <GlassAppearanceSync />220 <GlassAppearanceSync />
218 <FontAppearanceSync />221 <FontAppearanceSync />
222 <ProviderAuthCallbackCoordinator />
223 <ChatGptWelcomeCoordinator />
219 <FirstRunGate224 <FirstRunGate
220 enabled={primaryEnvironmentAuthenticated}225 enabled={primaryEnvironmentAuthenticated}
221 hostedStatic={authGateState.status === "hosted-static"}226 hostedStatic={authGateState.status === "hosted-static"}
apps/web/src/routes/settings.providers.tsx+2-2
...@@ -6,8 +6,8 @@ import { useSettingsScope } from "../components/settings/SettingsScopeContext";...@@ -6,8 +6,8 @@ import { useSettingsScope } from "../components/settings/SettingsScopeContext";
66
7/**7/**
8 * Providers are machine state, so the page shows one environment at a time:8 * 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 crumb9 * the chosen one from the settings scope selector.
10 * narrows the candidates to the environments that project is registered on.10 * A project crumb narrows candidates to where that project is registered.
11 */11 */
12function SettingsProvidersRoute() {12function SettingsProvidersRoute() {
13 const target = Route.useSearch();13 const target = Route.useSearch();
apps/web/src/routes/settings.tsx+1
...@@ -153,6 +153,7 @@ function SettingsRouteLayout() {...@@ -153,6 +153,7 @@ function SettingsRouteLayout() {
153 return (153 return (
154 <SettingsScopeProvider154 <SettingsScopeProvider
155 search={rawSearch}155 search={rawSearch}
156 singleEnvironment={pathname === "/settings/providers"}
156 onChange={(next) => {157 onChange={(next) => {
157 // Send every axis so the retain middleware sees an explicit target158 // Send every axis so the retain middleware sees an explicit target
158 // even when the choice is "all", which is the absence of a key.159 // even when the choice is "all", which is the absence of a key.
apps/web/src/routes/welcome.tsx+10
...@@ -1,10 +1,15 @@...@@ -1,10 +1,15 @@
1import { createFileRoute, redirect, useLocation, useNavigate } from "@tanstack/react-router";1import { createFileRoute, redirect, useLocation, useNavigate } from "@tanstack/react-router";
2import { useState } from "react";2import { useState } from "react";
3import { EnvironmentId } from "@t3tools/contracts";
4import * as Schema from "effect/Schema";
5import * as Option from "effect/Option";
36
4import { NoProjectsHero } from "../components/NoProjectsHero";7import { NoProjectsHero } from "../components/NoProjectsHero";
5import { WelcomeWizard } from "../components/onboarding/WelcomeWizard";8import { WelcomeWizard } from "../components/onboarding/WelcomeWizard";
6import { useNewThreadHandler } from "../hooks/useHandleNewThread";9import { useNewThreadHandler } from "../hooks/useHandleNewThread";
710
11const decodeEnvironmentId = Schema.decodeOption(EnvironmentId);
12
8/** Onboarding overlays the workspace. Visiting /welcome reopens setup. */13/** Onboarding overlays the workspace. Visiting /welcome reopens setup. */
9export const Route = createFileRoute("/welcome")({14export const Route = createFileRoute("/welcome")({
10 beforeLoad: ({ context }) => {15 beforeLoad: ({ context }) => {
...@@ -19,6 +24,10 @@ export const Route = createFileRoute("/welcome")({...@@ -19,6 +24,10 @@ export const Route = createFileRoute("/welcome")({
19function WelcomeRouteView() {24function WelcomeRouteView() {
20 const { authGateState } = Route.useRouteContext();25 const { authGateState } = Route.useRouteContext();
21 const navigate = useNavigate();26 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;
22 // The root shell can remount this pending outlet after the location changes.31 // The root shell can remount this pending outlet after the location changes.
23 // Never reopen setup while the destination route is still loading.32 // Never reopen setup while the destination route is still loading.
24 const isWelcomeRoute = useLocation({ select: (location) => location.pathname === "/welcome" });33 const isWelcomeRoute = useLocation({ select: (location) => location.pathname === "/welcome" });
...@@ -35,6 +44,7 @@ function WelcomeRouteView() {...@@ -35,6 +44,7 @@ function WelcomeRouteView() {
35 {isWelcomeRoute && !dismissed ? (44 {isWelcomeRoute && !dismissed ? (
36 <WelcomeWizard45 <WelcomeWizard
37 localAvailable={localAvailable}46 localAvailable={localAvailable}
47 resumeEnvironmentId={resumeEnvironmentId}
38 onDone={async (projectRef) => {48 onDone={async (projectRef) => {
39 setDismissed(true);49 setDismissed(true);
40 if (projectRef !== undefined) {50 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...@@ -48,6 +48,13 @@ loopback listener may be on another machine. Forward only the callback for the o
48a successful callback HTTP request is not proof that provider authentication finished. The native48a successful callback HTTP request is not proof that provider authentication finished. The native
49process owns token exchange and storage.49process 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
51Antigravity sign-out closes admission to new processes and stops existing processes before clearing account58Antigravity sign-out closes admission to new processes and stops existing processes before clearing account
52metadata. Otherwise a helper or resumed session could retain the old account. Cached model lists59metadata. Otherwise a helper or resumed session could retain the old account. Cached model lists
53do not establish current access, and an authoritative empty catalog must clear the old list.60do 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...@@ -111,19 +111,20 @@ and enable the provider you want. Installation, login, and configuration belong
111to that environment's machine, even when you connect from a phone or another111to that environment's machine, even when you connect from a phone or another
112computer.112computer.
113113
114| Provider | Install and authenticate |114| Provider | Install and authenticate |
115| ----------- | -------------------------------------------------------------------------------------------- |115| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
116| Codex | Install [Codex CLI](https://developers.openai.com/codex/cli), then run `codex login`. |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`. |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`. |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`. |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`. |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. |121| Antigravity | Install and sign in with Google from T3 Code's provider settings. |
122122
123Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its123Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its
124**Binary path** in provider settings, especially when using a version manager.124**Binary path** in provider settings, especially when using a version manager.
125Cursor's executable is `cursor-agent`, although its login command is125Cursor'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
128T3 Code warns when a provider version has known compatibility problems with your129T3 Code warns when a provider version has known compatibility problems with your
129release. Check **Settings → Providers** on that environment for the recommended130release. Check **Settings → Providers** on that environment for the recommended
docs/user/providers-codex.md+30-3
...@@ -1,11 +1,38 @@...@@ -1,11 +1,38 @@
1# Codex1# Codex
22
3For one account, use the default Codex provider with your normal Codex login.3Use your ChatGPT plan or an existing Codex CLI login to code in T3 Code.
4[Provider setup](./install.md#providers) covers installation, Settings > Providers,4
5and custom binaries or environment variables.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
7## Use multiple accounts28## 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
9A shared Codex home with a shadow home lets work and personal accounts continue36A shared Codex home with a shadow home lets work and personal accounts continue
10the same threads. The accounts share Codex sessions and configuration while keeping37the same threads. The accounts share Codex sessions and configuration while keeping
11their own login and available models.38their own login and available models.
packages/client-runtime/src/rpc/client.ts+2
...@@ -41,6 +41,7 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string;...@@ -41,6 +41,7 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string;
41type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];41type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];
4242
43export type EnvironmentSubscriptionRpcTag =43export type EnvironmentSubscriptionRpcTag =
44 | typeof WS_METHODS.codexAuthCallbackSubscribe
44 | typeof WS_METHODS.providerAuthSubscribe45 | typeof WS_METHODS.providerAuthSubscribe
45 | typeof WS_METHODS.providerInstallSubscribe46 | typeof WS_METHODS.providerInstallSubscribe
46 | typeof ORCHESTRATION_WS_METHODS.subscribeShell47 | typeof ORCHESTRATION_WS_METHODS.subscribeShell
...@@ -62,6 +63,7 @@ export type EnvironmentSubscriptionRpcTag =...@@ -62,6 +63,7 @@ export type EnvironmentSubscriptionRpcTag =
62 | typeof WS_METHODS.terminalAttach;63 | typeof WS_METHODS.terminalAttach;
6364
64export type EnvironmentStreamCommandRpcTag =65export type EnvironmentStreamCommandRpcTag =
66 | typeof WS_METHODS.chatGptHandoffSubscribe
65 | typeof WS_METHODS.cloudInstallRelayClient67 | typeof WS_METHODS.cloudInstallRelayClient
66 | typeof WS_METHODS.serverUpdateServerWithProgress68 | typeof WS_METHODS.serverUpdateServerWithProgress
67 | typeof WS_METHODS.gitRunStackedAction;69 | 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...@@ -60,6 +60,7 @@ interface EnvironmentQueryAtomOptions<Input, A, E, R> extends EnvironmentAtomOpt
60}60}
6161
62interface EnvironmentSubscriptionAtomOptions<Input, A, E, R> {62interface EnvironmentSubscriptionAtomOptions<Input, A, E, R> {
63 readonly sensitiveInput?: boolean;
63 readonly label: string;64 readonly label: string;
64 readonly subscribe: (input: Input) => Stream.Stream<A, E, R>;65 readonly subscribe: (input: Input) => Stream.Stream<A, E, R>;
65 readonly idleTtlMs?: number;66 readonly idleTtlMs?: number;
...@@ -581,7 +582,11 @@ export function createEnvironmentSubscriptionAtomFamily<R, ER, Input, A, E>(...@@ -581,7 +582,11 @@ export function createEnvironmentSubscriptionAtomFamily<R, ER, Input, A, E>(
581 .atom(followStreamInEnvironment(target.environmentId, options.subscribe(target.input)))582 .atom(followStreamInEnvironment(target.environmentId, options.subscribe(target.input)))
582 .pipe(583 .pipe(
583 Atom.setIdleTTL(options.idleTtlMs ?? 5 * 60_000),584 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 ),
585 );590 );
586 });591 });
587 return (target: { readonly environmentId: EnvironmentIdType; readonly input: Input }) =>592 return (target: { readonly environmentId: EnvironmentIdType; readonly input: Input }) =>
packages/client-runtime/src/state/server.ts+22
...@@ -29,6 +29,7 @@ import {...@@ -29,6 +29,7 @@ import {
29 createEnvironmentQueryAtomFamily,29 createEnvironmentQueryAtomFamily,
30 createEnvironmentRpcQueryAtomFamily,30 createEnvironmentRpcQueryAtomFamily,
31 createEnvironmentRpcSubscriptionAtomFamily,31 createEnvironmentRpcSubscriptionAtomFamily,
32 createEnvironmentSubscriptionAtomFamily,
32 createRuntimeCommand,33 createRuntimeCommand,
33 scheduleAtomCommandEffect,34 scheduleAtomCommandEffect,
34} from "./runtime.ts";35} from "./runtime.ts";
...@@ -994,6 +995,27 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -994,6 +995,27 @@ export function createServerEnvironmentAtoms<R, E>(
994 label: "environment-data:provider:auth-complete",995 label: "environment-data:provider:auth-complete",
995 tag: WS_METHODS.providerAuthComplete,996 tag: WS_METHODS.providerAuthComplete,
996 }),997 }),
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 }),
997 cancelProviderAuth: createEnvironmentRpcCommand(runtime, {1019 cancelProviderAuth: createEnvironmentRpcCommand(runtime, {
998 label: "environment-data:provider:auth-cancel",1020 label: "environment-data:provider:auth-cancel",
999 tag: WS_METHODS.providerAuthCancel,1021 tag: WS_METHODS.providerAuthCancel,
packages/contracts/src/ipc.ts+3
...@@ -1212,6 +1212,9 @@ export interface DesktopBridge {...@@ -1212,6 +1212,9 @@ export interface DesktopBridge {
1212 items: readonly ContextMenuItem<T>[],1212 items: readonly ContextMenuItem<T>[],
1213 position?: { x: number; y: number },1213 position?: { x: number; y: number },
1214 ) => Promise<T | null>;1214 ) => 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>;
1215 openExternal: (url: string) => Promise<boolean>;1218 openExternal: (url: string) => Promise<boolean>;
1216 /**1219 /**
1217 * Open a System Settings pane by identifier. Optional: older desktop builds1220 * 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;...@@ -809,6 +809,7 @@ export type RuntimeWarningPayload = typeof RuntimeWarningPayload.Type;
809809
810const RuntimeErrorPayload = Schema.Struct({810const RuntimeErrorPayload = Schema.Struct({
811 message: TrimmedNonEmptyStringSchema,811 message: TrimmedNonEmptyStringSchema,
812 code: Schema.optional(TrimmedNonEmptyStringSchema),
812 class: Schema.optional(RuntimeErrorClass),813 class: Schema.optional(RuntimeErrorClass),
813 detail: Schema.optional(Schema.Unknown),814 detail: Schema.optional(Schema.Unknown),
814});815});
packages/contracts/src/providerSetup.ts+75
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
22
3import {3import {
4 EnvironmentId,
4 ForwardCompatibleArray,5 ForwardCompatibleArray,
5 ForwardCompatibleOptional,6 ForwardCompatibleOptional,
6 IsoDateTime,7 IsoDateTime,
...@@ -13,10 +14,27 @@ export const ProviderSetupInput = Schema.Struct({...@@ -13,10 +14,27 @@ export const ProviderSetupInput = Schema.Struct({
13});14});
14export type ProviderSetupInput = typeof ProviderSetupInput.Type;15export 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
16const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));33const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
1734
18export const ProviderAuthMethod = Schema.Struct({35export const ProviderAuthMethod = Schema.Struct({
19 id: SetupOperationId,36 id: SetupOperationId,
37 accountEmail: Schema.optional(TrimmedNonEmptyString),
20 name: TrimmedNonEmptyString,38 name: TrimmedNonEmptyString,
21 description: Schema.NullOr(Schema.String),39 description: Schema.NullOr(Schema.String),
22 type: Schema.Literals(["agent", "terminal", "credentials"]),40 type: Schema.Literals(["agent", "terminal", "credentials"]),
...@@ -86,6 +104,8 @@ export type ProviderAuthResponse = typeof ProviderAuthResponse.Type;...@@ -86,6 +104,8 @@ export type ProviderAuthResponse = typeof ProviderAuthResponse.Type;
86export const ProviderAuthStartInput = Schema.Struct({104export const ProviderAuthStartInput = Schema.Struct({
87 instanceId: ProviderInstanceId,105 instanceId: ProviderInstanceId,
88 methodId: Schema.optionalKey(SetupOperationId),106 methodId: Schema.optionalKey(SetupOperationId),
107 returnUrl: Schema.optionalKey(Schema.String),
108 callbackMode: Schema.optionalKey(Schema.Literals(["server", "client"])),
89});109});
90export type ProviderAuthStartInput = typeof ProviderAuthStartInput.Type;110export type ProviderAuthStartInput = typeof ProviderAuthStartInput.Type;
91111
...@@ -153,6 +173,8 @@ export const ProviderInstallState = Schema.Struct({...@@ -153,6 +173,8 @@ export const ProviderInstallState = Schema.Struct({
153 totalBytes: Schema.NullOr(ByteCount),173 totalBytes: Schema.NullOr(ByteCount),
154 version: Schema.NullOr(TrimmedNonEmptyString),174 version: Schema.NullOr(TrimmedNonEmptyString),
155 installedVersion: Schema.NullOr(TrimmedNonEmptyString),175 installedVersion: Schema.NullOr(TrimmedNonEmptyString),
176 executablePath: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)),
177 source: Schema.optionalKey(Schema.NullOr(Schema.Literals(["managed", "local"]))),
156 canRemove: Schema.Boolean,178 canRemove: Schema.Boolean,
157 message: Schema.NullOr(Schema.String),179 message: Schema.NullOr(Schema.String),
158});180});
...@@ -178,3 +200,56 @@ export class ProviderSetupError extends Schema.TaggedError<ProviderSetupError>()...@@ -178,3 +200,56 @@ export class ProviderSetupError extends Schema.TaggedError<ProviderSetupError>()
178 return this.detail;200 return this.detail;
179 }201 }
180}202}
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({...@@ -51,6 +51,13 @@ export const ServerProviderUsageLimits = Schema.Struct({
51 checkedAt: IsoDateTime,51 checkedAt: IsoDateTime,
52 windows: ForwardCompatibleArray(ServerProviderUsageWindow),52 windows: ForwardCompatibleArray(ServerProviderUsageWindow),
53 resetCredits: Schema.optional(ServerProviderResetCredits),53 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 ),
54 unavailable: Schema.optional(61 unavailable: Schema.optional(
55 Schema.Struct({62 Schema.Struct({
56 reason: Schema.Literals(["unsupported", "probeFailed"]),63 reason: Schema.Literals(["unsupported", "probeFailed"]),
packages/contracts/src/rpc.ts+40
...@@ -1,8 +1,17 @@...@@ -1,8 +1,17 @@
1import {
2 ChatGptReconnectProfileInput,
3 ChatGptReconnectProfile,
4 ChatGptImportProfileInput,
5 ChatGptHandoffInput,
6 ChatGptHandoffState,
7} from "./providerSetup.ts";
1import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
2import * as Rpc from "effect/unstable/rpc/Rpc";9import * as Rpc from "effect/unstable/rpc/Rpc";
3import * as RpcGroup from "effect/unstable/rpc/RpcGroup";10import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
4import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";11import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
5import {12import {
13 CodexAuthCallbackInput,
14 CodexAuthCallbackState,
6 ProviderAuthCancelInput,15 ProviderAuthCancelInput,
7 ProviderAuthCompleteInput,16 ProviderAuthCompleteInput,
8 ProviderAuthState,17 ProviderAuthState,
...@@ -301,6 +310,10 @@ export const WS_METHODS = {...@@ -301,6 +310,10 @@ export const WS_METHODS = {
301 providerAuthStart: "provider.auth.start",310 providerAuthStart: "provider.auth.start",
302 providerConsumeResetCredit: "provider.consumeResetCredit",311 providerConsumeResetCredit: "provider.consumeResetCredit",
303 providerAuthComplete: "provider.auth.complete",312 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",
304 providerAuthRespond: "provider.auth.respond",317 providerAuthRespond: "provider.auth.respond",
305 providerAuthCancel: "provider.auth.cancel",318 providerAuthCancel: "provider.auth.cancel",
306 providerAuthLogout: "provider.auth.logout",319 providerAuthLogout: "provider.auth.logout",
...@@ -519,6 +532,29 @@ const WsProviderAuthCompleteRpc = Rpc.make(WS_METHODS.providerAuthComplete, {...@@ -519,6 +532,29 @@ const WsProviderAuthCompleteRpc = Rpc.make(WS_METHODS.providerAuthComplete, {
519 error: ProviderSetupRpcError,532 error: ProviderSetupRpcError,
520});533});
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
522const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, {558const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, {
523 payload: ProviderAuthCancelInput,559 payload: ProviderAuthCancelInput,
524 success: ProviderAuthState,560 success: ProviderAuthState,
...@@ -1399,6 +1435,10 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1399,6 +1435,10 @@ export const WsRpcGroup = RpcGroup.make(
1399 WsProviderConsumeResetCreditRpc,1435 WsProviderConsumeResetCreditRpc,
1400 WsProviderAuthStartRpc,1436 WsProviderAuthStartRpc,
1401 WsProviderAuthCompleteRpc,1437 WsProviderAuthCompleteRpc,
1438 WsChatGptReconnectProfileRpc,
1439 WsChatGptImportProfileRpc,
1440 WsChatGptHandoffSubscribeRpc,
1441 WsCodexAuthCallbackSubscribeRpc,
1402 WsProviderAuthRespondRpc,1442 WsProviderAuthRespondRpc,
1403 WsProviderAuthCancelRpc,1443 WsProviderAuthCancelRpc,
1404 WsProviderAuthLogoutRpc,1444 WsProviderAuthLogoutRpc,
packages/contracts/src/server.ts+8
...@@ -63,6 +63,8 @@ export const ServerProviderAuth = Schema.Struct({...@@ -63,6 +63,8 @@ export const ServerProviderAuth = Schema.Struct({
63 type: Schema.optional(TrimmedNonEmptyString),63 type: Schema.optional(TrimmedNonEmptyString),
64 label: Schema.optional(TrimmedNonEmptyString),64 label: Schema.optional(TrimmedNonEmptyString),
65 email: Schema.optional(TrimmedNonEmptyString),65 email: Schema.optional(TrimmedNonEmptyString),
66 subscriptionSharing: Schema.optional(Schema.Boolean),
67 profileId: Schema.optional(TrimmedNonEmptyString),
66});68});
67export type ServerProviderAuth = typeof ServerProviderAuth.Type;69export type ServerProviderAuth = typeof ServerProviderAuth.Type;
6870
...@@ -226,6 +228,12 @@ export const ServerProvider = Schema.Struct({...@@ -226,6 +228,12 @@ export const ServerProvider = Schema.Struct({
226 canInstall: Schema.Boolean,228 canInstall: Schema.Boolean,
227 }),229 }),
228 ),230 ),
231 runtimePaths: Schema.optionalKey(
232 Schema.Struct({
233 homePath: TrimmedNonEmptyString,
234 shadowHomePath: Schema.NullOr(TrimmedNonEmptyString),
235 }),
236 ),
229 enabled: Schema.Boolean,237 enabled: Schema.Boolean,
230 installed: Schema.Boolean,238 installed: Schema.Boolean,
231 version: Schema.NullOr(TrimmedNonEmptyString),239 version: Schema.NullOr(TrimmedNonEmptyString),
packages/contracts/src/settings.ts+3
...@@ -576,6 +576,9 @@ function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fields>(...@@ -576,6 +576,9 @@ function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fields>(
576576
577export const CodexSettings = makeProviderSettingsSchema(577export const CodexSettings = makeProviderSettingsSchema(
578 {578 {
579 setupMode: Schema.optionalKey(Schema.Literals(["managed", "existing"])).pipe(
580 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
581 ),
579 enabled: Schema.Boolean.pipe(582 enabled: Schema.Boolean.pipe(
580 Schema.withDecodingDefault(Effect.succeed(true)),583 Schema.withDecodingDefault(Effect.succeed(true)),
581 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),584 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
packages/shared/package.json+12
...@@ -346,6 +346,18 @@...@@ -346,6 +346,18 @@
346 "./gitPatchPath": {346 "./gitPatchPath": {
347 "types": "./src/gitPatchPath.ts",347 "types": "./src/gitPatchPath.ts",
348 "import": "./src/gitPatchPath.ts"348 "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"
349 }361 }
350 },362 },
351 "scripts": {363 "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 {...@@ -3,6 +3,8 @@ import {
3 ProviderDriverKind,3 ProviderDriverKind,
4 ProviderInstanceId,4 ProviderInstanceId,
5 type ServerProvider,5 type ServerProvider,
6 EventId,
7 type OrchestrationThreadActivity,
6 UsageLimitSourceId,8 UsageLimitSourceId,
7} from "@t3tools/contracts";9} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";10import { describe, expect, it } from "vite-plus/test";
...@@ -14,6 +16,7 @@ import {...@@ -14,6 +16,7 @@ import {
14 sameUsageLimitCommandCoverage,16 sameUsageLimitCommandCoverage,
15 withUsageLimitsCommands,17 withUsageLimitsCommands,
16 collectLimitAccounts,18 collectLimitAccounts,
19 collectExternalUsageLinks,
17 collectLimitNotices,20 collectLimitNotices,
18 collectLimitPools,21 collectLimitPools,
19 displayLimitWindows,22 displayLimitWindows,
...@@ -23,6 +26,8 @@ import {...@@ -23,6 +26,8 @@ import {
23 paceOf,26 paceOf,
24 providersWithLimits,27 providersWithLimits,
25 remainingPercent,28 remainingPercent,
29 isChatGptUsageLimitError,
30 usesChatGptSharing,
26} from "./usageLimits.ts";31} from "./usageLimits.ts";
2732
28const now = Date.parse("2026-09-03T12:00:00.000Z");33const now = Date.parse("2026-09-03T12:00:00.000Z");
...@@ -1056,3 +1061,108 @@ describe("isUsageLimitsCommand", () => {...@@ -1056,3 +1061,108 @@ describe("isUsageLimitsCommand", () => {
1056 expect(isUsageLimitsCommand("/usage")).toBe(false);1061 expect(isUsageLimitsCommand("/usage")).toBe(false);
1057 });1062 });
1058});1063});
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 {...@@ -13,6 +13,7 @@ import {
13 type ServerProviderSlashCommand,13 type ServerProviderSlashCommand,
14 isProviderAvailable,14 isProviderAvailable,
15 type ServerProvider,15 type ServerProvider,
16 type OrchestrationThreadActivity,
16 type ServerProviderUsageLimits,17 type ServerProviderUsageLimits,
17 type ServerProviderUsageWindow,18 type ServerProviderUsageWindow,
18 type UsageLimitSourceSnapshots,19 type UsageLimitSourceSnapshots,
...@@ -24,6 +25,35 @@ const MINUTE = 60_000;...@@ -24,6 +25,35 @@ const MINUTE = 60_000;
24const HOUR = 60 * MINUTE;25const HOUR = 60 * MINUTE;
25const DAY = 24 * HOUR;26const 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
27export const CURSOR_USAGE_WINDOWS = [57export const CURSOR_USAGE_WINDOWS = [
28 {58 {
29 id: "totalPercentUsed",59 id: "totalPercentUsed",
...@@ -79,6 +109,33 @@ export type LimitPresentations = ReadonlyMap<...@@ -79,6 +109,33 @@ export type LimitPresentations = ReadonlyMap<
79 }109 }
80>;110>;
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
82function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null {139function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null {
83 const normalizedEmail = email?.trim().toLowerCase();140 const normalizedEmail = email?.trim().toLowerCase();
84 return normalizedEmail ? `${driver}:${normalizedEmail}` : null;141 return normalizedEmail ? `${driver}:${normalizedEmail}` : null;
pnpm-lock.yaml+21
...@@ -531,9 +531,15 @@ importers:...@@ -531,9 +531,15 @@ importers:
531 effect:531 effect:
532 specifier: 4.0.0-rc.115532 specifier: 4.0.0-rc.115
533 version: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)533 version: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)
534 jose:
535 specifier: 'catalog:'
536 version: 6.2.2
534 node-pty:537 node-pty:
535 specifier: ^1.2.0-beta.15538 specifier: ^1.2.0-beta.15
536 version: 1.2.0-beta.15(patch_hash=f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414)539 version: 1.2.0-beta.15(patch_hash=f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414)
540 proper-lockfile:
541 specifier: 4.1.2
542 version: 4.1.2
537 stream-chain:543 stream-chain:
538 specifier: ^4.2.5544 specifier: ^4.2.5
539 version: 4.2.5545 version: 4.2.5
...@@ -568,6 +574,9 @@ importers:...@@ -568,6 +574,9 @@ importers:
568 '@types/node':574 '@types/node':
569 specifier: 24.12.4575 specifier: 24.12.4
570 version: 24.12.4576 version: 24.12.4
577 '@types/proper-lockfile':
578 specifier: ^4.1.4
579 version: 4.1.4
571 '@types/yauzl':580 '@types/yauzl':
572 specifier: ^3.4.0581 specifier: ^3.4.0
573 version: 3.4.0582 version: 3.4.0
...@@ -5411,6 +5420,9 @@ packages:...@@ -5411,6 +5420,9 @@ packages:
5411 '@types/pngjs@6.0.5':5420 '@types/pngjs@6.0.5':
5412 resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}5421 resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
54135422
5423 '@types/proper-lockfile@4.1.4':
5424 resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==}
5425
5414 '@types/qs@6.15.1':5426 '@types/qs@6.15.1':
5415 resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}5427 resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
54165428
...@@ -5431,6 +5443,9 @@ packages:...@@ -5431,6 +5443,9 @@ packages:
5431 '@types/responselike@1.0.3':5443 '@types/responselike@1.0.3':
5432 resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}5444 resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
54335445
5446 '@types/retry@0.12.5':
5447 resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==}
5448
5434 '@types/send@1.2.1':5449 '@types/send@1.2.1':
5435 resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}5450 resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
54365451
...@@ -15799,6 +15814,10 @@ snapshots:...@@ -15799,6 +15814,10 @@ snapshots:
15799 dependencies:15814 dependencies:
15800 '@types/node': 24.12.415815 '@types/node': 24.12.4
1580115816
15817 '@types/proper-lockfile@4.1.4':
15818 dependencies:
15819 '@types/retry': 0.12.5
15820
15802 '@types/qs@6.15.1': {}15821 '@types/qs@6.15.1': {}
1580315822
15804 '@types/range-parser@1.2.7': {}15823 '@types/range-parser@1.2.7': {}
...@@ -15819,6 +15838,8 @@ snapshots:...@@ -15819,6 +15838,8 @@ snapshots:
15819 dependencies:15838 dependencies:
15820 '@types/node': 24.12.415839 '@types/node': 24.12.4
1582115840
15841 '@types/retry@0.12.5': {}
15842
15822 '@types/send@1.2.1':15843 '@types/send@1.2.1':
15823 dependencies:15844 dependencies:
15824 '@types/node': 24.12.415845 '@types/node': 24.12.4