| author | |
| committer | |
| log | 27bdf1aa14e6b946b72fb7013062084ca70796a6 |
| tree | 19fee6ac7d288ff44dfcaad8e6b60cd9da93b611 |
| parent | d2c9281b8112dc3b2991642c4bdb985e4b08b9bb |
| signature | Signed by PGP key B5690EEEBB952194 |
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. | ||
| 2 | import * as NodeHttp from "node:http"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | import { codexAuthDeliveryUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; | ||
| 5 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 6 | import { receiveCodexAuthCallback, cancelCodexAuthCallback } from "./CodexAuthCallback.ts"; | ||
| 7 | |||
| 8 | async 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 | } | ||
| 16 | function 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 | } | ||
| 28 | function 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 | |||
| 39 | describe("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 @@ | ||
| 1 | export { | ||
| 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. | ||
| 2 | import * as NodeHttp from "node:http"; | ||
| 3 | import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; | ||
| 4 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 5 | import { HostProcessArguments } from "@t3tools/shared/hostProcess"; | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | 6 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as Cause from "effect/Cause"; | 7 | import * as Cause from "effect/Cause"; |
| 3 | import * as Effect from "effect/Effect"; | 8 | import * 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 | })); |
| 24 | 29 | ||
| 30 | import * as Option from "effect/Option"; | ||
| 25 | import * as Exit from "effect/Exit"; | 31 | import * as Exit from "effect/Exit"; |
| 26 | import * as FileSystem from "effect/FileSystem"; | 32 | import * as FileSystem from "effect/FileSystem"; |
| 27 | import * as ElectronApp from "../electron/ElectronApp.ts"; | 33 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 34 | import * as ElectronShell from "../electron/ElectronShell.ts"; | ||
| 28 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | 35 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 29 | import * as DesktopClerk from "./DesktopClerk.ts"; | 36 | import * as DesktopClerk from "./DesktopClerk.ts"; |
| 30 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 37 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 31 | 38 | ||
| 32 | const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { | 39 | const 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", () => { |
| 162 | 178 | ||
| 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 | |||
| 218 | it.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 | |||
| 264 | for (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"; |
| 7 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 8 | import * as Scope from "effect/Scope"; | 8 | import * as Scope from "effect/Scope"; |
| 9 | 9 | ||
| 10 | import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/codexAuthHandoff"; | ||
| 11 | import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCallback.ts"; | ||
| 12 | import * as ElectronShell from "../electron/ElectronShell.ts"; | ||
| 13 | import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; | ||
| 14 | import { HostProcessArguments } from "@t3tools/shared/hostProcess"; | ||
| 10 | import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; | 15 | import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; |
| 11 | import * as ElectronApp from "../electron/ElectronApp.ts"; | 16 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 12 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; | 17 | import * 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) { |
| 87 | export const make = Effect.gen(function* () { | 92 | export 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; | ||
| 90 | 96 | ||
| 91 | // Electron scopes the single-instance lock to the userData directory and | 97 | // Electron scopes the single-instance lock to the userData directory and |
| 92 | // creates that directory when the lock is acquired. The SDK bridge takes | 98 | // 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 | } |
| 138 | 144 | ||
| 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 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | ||
| 3 | import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; | ||
| 3 | import * as DesktopIpc from "./DesktopIpc.ts"; | 4 | import * as DesktopIpc from "./DesktopIpc.ts"; |
| 4 | import { installNotificationBadge } from "./methods/notificationBadge.ts"; | 5 | import { installNotificationBadge } from "./methods/notificationBadge.ts"; |
| 5 | import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; | 6 | import { 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"; |
| 116 | export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission"; | 116 | export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission"; |
| 117 | 117 | ||
| 118 | export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input"; | 118 | export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input"; |
| 119 | |||
| 120 | export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback"; | ||
| 121 | export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback"; |
apps/desktop/src/ipc/methods/providerAuth.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import { | ||
| 5 | receiveCodexAuthCallback, | ||
| 6 | cancelCodexAuthCallback, | ||
| 7 | CodexAuthCallbackError, | ||
| 8 | } from "../../app/CodexAuthCallback.ts"; | ||
| 9 | import * as ElectronShell from "../../electron/ElectronShell.ts"; | ||
| 10 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | ||
| 11 | import * as DesktopIpc from "../DesktopIpc.ts"; | ||
| 12 | import * as IpcChannels from "../channels.ts"; | ||
| 13 | |||
| 14 | const Request = Schema.String.check(Schema.isMaxLength(16_384)); | ||
| 15 | |||
| 16 | export 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 | |||
| 40 | export 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 | ); |
| 206 | 206 | ||
| 207 | const desktopClerkLayer = DesktopClerk.layer.pipe( | 207 | const 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 @@ | ||
| 1 | import type { ServerProvider } from "@t3tools/contracts"; | ||
| 2 | import { CHATGPT_USAGE_URL, usesChatGptSharing } from "@t3tools/shared/usageLimits"; | ||
| 3 | import { Alert, Linking, Pressable, View } from "react-native"; | ||
| 4 | import { AppText as Text } from "../../components/AppText"; | ||
| 5 | import { ProviderIcon } from "../../components/ProviderIcon"; | ||
| 6 | |||
| 7 | export 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 @@ | ||
| 1 | import type { EnvironmentId, OrchestrationThreadShell } from "@t3tools/contracts"; | ||
| 2 | import { CHATGPT_USAGE_URL, isChatGptUsageLimitError } from "@t3tools/shared/usageLimits"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import { Linking, Pressable, View } from "react-native"; | ||
| 5 | import { AppText as Text } from "../../components/AppText"; | ||
| 6 | import { ProviderIcon } from "../../components/ProviderIcon"; | ||
| 7 | import { useThreadDetail } from "../../state/use-thread-detail"; | ||
| 8 | |||
| 9 | export 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 @@ |
| 1 | import { ChatGptUsageLimitNotice } from "./ChatGptUsageLimitNotice"; | ||
| 1 | import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; | 2 | import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; |
| 2 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 3 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 3 | import { useAtomValue } from "@effect/atom-react"; | 4 | import { 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"; |
| 63 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 63 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 64 | import { serverEnvironment } from "../../state/server"; | 64 | import { ChatGptSharingStatus } from "./ChatGptSharingStatus"; |
| 65 | import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server"; | ||
| 65 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 66 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 66 | import { useAtomCommand } from "../../state/use-atom-command"; | 67 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 67 | import { useNewTaskFlow } from "./new-task-flow-provider"; | 68 | import { 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_SUPPORTED | 694 | Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED |
| 688 | ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET | 695 | ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET |
| ... | @@ -690,6 +697,7 @@ function ThreadSettingsOptionsItem(props: { | ... | @@ -690,6 +697,7 @@ function ThreadSettingsOptionsItem(props: { |
| 690 | 697 | ||
| 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.View | 702 | <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 | <ThreadSettingsSessionProvider | 1380 | <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 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { CHATGPT_USAGE_URL, collectExternalUsageLinks } from "@t3tools/shared/usageLimits"; | ||
| 4 | import { Linking, Pressable, View } from "react-native"; | ||
| 5 | import { AppText as Text } from "../../components/AppText"; | ||
| 6 | import { ProviderIcon } from "../../components/ProviderIcon"; | ||
| 7 | import { environmentPresentations } from "../../state/presentation"; | ||
| 8 | |||
| 9 | export 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" |
| 3 | import { EnvironmentId } from "@t3tools/contracts"; | 3 | import { EnvironmentId } from "@t3tools/contracts"; |
| 4 | import { | 4 | import { |
| 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"; |
| 16 | import { Fragment, type ReactNode, useId, useState } from "react"; | 17 | import { Fragment, type ReactNode, useId, useState } from "react"; |
| 17 | import { Pressable, ScrollView, View } from "react-native"; | 18 | import { Linking, Pressable, ScrollView, View } from "react-native"; |
| 18 | import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; | 19 | import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; |
| 19 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 20 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 20 | 21 | ||
| ... | @@ -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 === 0 | 236 | {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 | <View | 292 | <View |
| 271 | accessible | 293 | 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"; |
| 19 | import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; | 19 | import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; |
| 20 | import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; | 20 | import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; |
| 21 | import { Alert, Pressable, View } from "react-native"; | 21 | import { Alert, Linking, Pressable, View } from "react-native"; |
| 22 | 22 | ||
| 23 | import { AppText as Text } from "../../components/AppText"; | 23 | import { AppText as Text } from "../../components/AppText"; |
| 24 | import { ProviderIcon } from "../../components/ProviderIcon"; | 24 | import { 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 | <View | 146 | <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 @@ |
| 1 | import { ChatGptUsageSummary } from "./ChatGptUsageSummary"; | ||
| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; | 2 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; | 3 | import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; |
| 3 | import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; | 4 | import { 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 => |
| 138 | export class ServerSecretStore extends Context.Service< | 138 | export 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 | ); |
| 304 | 306 | ||
| 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 | }); |
| 3949 | 3950 | ||
| ... | @@ -3960,6 +3961,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -3960,6 +3961,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 3960 | 3961 | ||
| 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 | }); |
| 3964 | 3966 | ||
| 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. | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Fiber from "effect/Fiber"; | ||
| 7 | import * as Stream from "effect/Stream"; | ||
| 8 | import * as NodeHttp from "node:http"; | ||
| 9 | import { subscribeCodexAuthCallback } from "./CodexAuthCallback.ts"; | ||
| 10 | |||
| 11 | async 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 | } | ||
| 34 | function 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 | |||
| 45 | it.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 | |||
| 76 | it.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 | |||
| 96 | it.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 @@ | ||
| 1 | import { ProviderSetupError, type CodexAuthCallbackInput } from "@t3tools/contracts"; | ||
| 2 | import { receiveCodexAuthCallback } from "@t3tools/shared/codexAuthCallback"; | ||
| 3 | import { codexAuthorizationRequest } from "@t3tools/shared/codexAuthHandoff"; | ||
| 4 | import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; | ||
| 5 | import { isLoopbackHost } from "@t3tools/shared/preview"; | ||
| 6 | import * as Deferred from "effect/Deferred"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Fiber from "effect/Fiber"; | ||
| 9 | import * as Stream from "effect/Stream"; | ||
| 10 | |||
| 11 | /** A connected local environment receives the callback; only the remote environment owns tokens. */ | ||
| 12 | export 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 @@ | ||
| 1 | import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; | ||
| 2 | |||
| 3 | export const codexAuthReturnUrl = providerAuthReturnUrl; | ||
| 4 | |||
| 5 | const escapeHtml = (value: string) => | ||
| 6 | value.replace( | ||
| 7 | /[&<>"']/gu, | ||
| 8 | (character) => | ||
| 9 | ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]!, | ||
| 10 | ); | ||
| 11 | |||
| 12 | export 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} | ||
| 31 | main{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} | ||
| 34 | h1{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} | ||
| 35 | a{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. | ||
| 2 | import * as NodeHttp from "node:http"; | ||
| 3 | import * as NodeCrypto from "node:crypto"; | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { assert, it } from "@effect/vitest"; | ||
| 6 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Stream from "effect/Stream"; | ||
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import * as Deferred from "effect/Deferred"; | ||
| 13 | import * as TestClock from "effect/testing/TestClock"; | ||
| 14 | import { subscribeChatGptHandoff } from "./CodexChatGptHandoff.ts"; | ||
| 15 | import { FetchHttpClient } from "effect/unstable/http"; | ||
| 16 | import { exportJWK, generateKeyPair, SignJWT } from "jose"; | ||
| 17 | import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; | ||
| 18 | import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; | ||
| 19 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; | ||
| 20 | import { layerTest as settingsLayerTest } from "../serverSettings.ts"; | ||
| 21 | import { AnalyticsService } from "../telemetry/AnalyticsService.ts"; | ||
| 22 | import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; | ||
| 23 | |||
| 24 | const 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 | }; | ||
| 31 | const environmentIds = new WeakMap<Map<string, Uint8Array>, EnvironmentId>(); | ||
| 32 | const instanceId = ProviderInstanceId.make("managed-codex-test"); | ||
| 33 | const 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 | }); | ||
| 388 | const makeHarness = makeHarnessFor(instanceId); | ||
| 389 | const 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 | ); | ||
| 394 | it.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 | ); | ||
| 429 | for (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 | } | ||
| 459 | it.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 | ); | ||
| 486 | it.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 | |||
| 519 | it.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 | ); | ||
| 542 | it.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 | |||
| 563 | it.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 | ); | ||
| 591 | it.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 | ); | ||
| 691 | it.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 | ); | ||
| 720 | it.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 | ); | ||
| 734 | it.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 | ); | ||
| 752 | it.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 | ); | ||
| 775 | it.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 | ); | ||
| 786 | it.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 | ); | ||
| 800 | it.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 | |||
| 816 | it.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 | |||
| 836 | it.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 | ); | ||
| 896 | it.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 | |||
| 928 | it.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 | |||
| 951 | it.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 | ); | ||
| 964 | for (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 | } | ||
| 981 | for (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 | |||
| 1007 | it.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 | |||
| 1026 | it.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 | |||
| 1059 | it.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 | |||
| 1096 | it.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 | |||
| 1129 | it.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 | |||
| 1160 | for (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 | |||
| 1181 | it.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 | |||
| 1218 | it.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 | |||
| 1256 | it.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 | |||
| 1307 | it.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 | |||
| 1325 | it.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 | |||
| 1371 | it.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 | |||
| 1398 | it.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 | |||
| 1430 | for (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 | |||
| 1467 | it.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 | |||
| 1495 | it.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 | |||
| 1512 | it.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 | |||
| 1544 | it.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 | |||
| 1565 | it.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 | |||
| 1585 | it.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 | |||
| 1605 | it.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 | |||
| 1661 | it.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 | |||
| 1681 | it.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 | |||
| 1767 | it.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 | |||
| 1795 | it.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 | |||
| 1825 | it.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 | |||
| 1853 | it.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 | |||
| 1913 | it.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 | |||
| 1925 | it.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 | |||
| 1969 | it.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 | |||
| 2001 | it.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 | |||
| 2019 | it.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 | |||
| 2050 | it.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. | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | import * as NodeHttp from "node:http"; | ||
| 4 | import { createRemoteJWKSet, jwtVerify } from "jose"; | ||
| 5 | import { | ||
| 6 | ProviderSetupError, | ||
| 7 | type ChatGptReconnectProfile, | ||
| 8 | type ChatGptTransferredProfile, | ||
| 9 | type ProviderInstanceId, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff"; | ||
| 12 | import * as Clock from "effect/Clock"; | ||
| 13 | import * as Cause from "effect/Cause"; | ||
| 14 | import { AnalyticsService } from "../telemetry/AnalyticsService.ts"; | ||
| 15 | import * as Exit from "effect/Exit"; | ||
| 16 | import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | import * as Option from "effect/Option"; | ||
| 19 | import * as Schema from "effect/Schema"; | ||
| 20 | import * as Semaphore from "effect/Semaphore"; | ||
| 21 | import * as ProviderAuthFlow from "./ProviderAuthFlow.ts"; | ||
| 22 | import type { ProviderAuthFlowContext } from "./ProviderAuthFlow.ts"; | ||
| 23 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 24 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; | ||
| 25 | import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; | ||
| 26 | import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; | ||
| 27 | import { ServerSettingsService } from "../serverSettings.ts"; | ||
| 28 | import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; | ||
| 29 | |||
| 30 | const isSetupError = Schema.is(ProviderSetupError); | ||
| 31 | const RESOURCE = "https://api.openai.com/v1"; | ||
| 32 | const REQUIRED_SCOPE = "chatgpt.tokens.use.direct"; | ||
| 33 | const DISCOVERY = "https://auth.openai.com/.well-known/openid-configuration"; | ||
| 34 | const 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 | }); | ||
| 43 | const 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 | }); | ||
| 55 | export type CodexChatGptCredentials = typeof Record.Type; | ||
| 56 | const Sessions = Schema.Struct({ | ||
| 57 | activeClientId: Schema.NullOr(Schema.String), | ||
| 58 | sessions: Schema.Array(Record), | ||
| 59 | }); | ||
| 60 | const sessionLocks = new WeakMap< | ||
| 61 | typeof ServerSecretStore.Service, | ||
| 62 | Map<string, Semaphore.Semaphore> | ||
| 63 | >(); | ||
| 64 | const 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 | }); | ||
| 76 | const RegistrationProfiles = Schema.Struct({ | ||
| 77 | profiles: Schema.Array(Registration), | ||
| 78 | lastClientId: Schema.NullOr(Schema.String), | ||
| 79 | }); | ||
| 80 | const decodeRegistration = Schema.decodeUnknownEffect( | ||
| 81 | Schema.fromJsonString(Schema.Union([RegistrationProfiles, Registration])), | ||
| 82 | ); | ||
| 83 | const encodeRegistration = Schema.encodeEffect(Schema.fromJsonString(RegistrationProfiles)); | ||
| 84 | const profileMethodId = (clientId: string) => `chatgpt-profile:${clientId}`; | ||
| 85 | const 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 | }); | ||
| 92 | const OAuthError = Schema.Struct({ error: Schema.String }); | ||
| 93 | const decodeSessions = Schema.decodeUnknownEffect( | ||
| 94 | Schema.fromJsonString(Schema.Union([Sessions, Record])), | ||
| 95 | ); | ||
| 96 | const encodeSessions = Schema.encodeEffect(Schema.fromJsonString(Sessions)); | ||
| 97 | const decodeTokens = Schema.decodeUnknownSync(TokenResponse); | ||
| 98 | const decodeDiscoveryEffect = Schema.decodeUnknownEffect(Discovery); | ||
| 99 | const decodeOAuthError = Schema.decodeUnknownSync(OAuthError); | ||
| 100 | |||
| 101 | export 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 @@ | ||
| 1 | import type { ChatGptHandoffInput, ChatGptHandoffState } from "@t3tools/contracts"; | ||
| 2 | import { ProviderSetupError } from "@t3tools/contracts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Stream from "effect/Stream"; | ||
| 6 | import { FetchHttpClient } from "effect/unstable/http"; | ||
| 7 | import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; | ||
| 8 | import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; | ||
| 9 | import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; | ||
| 10 | |||
| 11 | // The primary owns OAuth for this stream; the destination owns the refresh session. | ||
| 12 | export 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. | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 5 | import { chatGptModels } from "./CodexChatGptModels.ts"; | ||
| 6 | |||
| 7 | it.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 | |||
| 65 | it.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 @@ | ||
| 1 | import type { ServerProviderModel } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 5 | |||
| 6 | export class ChatGptCatalogError extends Schema.TaggedError<ChatGptCatalogError>()( | ||
| 7 | "ChatGptCatalogError", | ||
| 8 | { status: Schema.Int }, | ||
| 9 | ) {} | ||
| 10 | |||
| 11 | const 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. */ | ||
| 22 | export 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. | ||
| 2 | import * as NodeChildProcess from "node:child_process"; | ||
| 3 | import * as NodeModule from "node:module"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | import * as NodeUtil from "node:util"; | ||
| 6 | import { assert, it } from "@effect/vitest"; | ||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 8 | import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 9 | import * as Deferred from "effect/Deferred"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import * as FileSystem from "effect/FileSystem"; | ||
| 13 | import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; | ||
| 14 | |||
| 15 | const execFile = NodeUtil.promisify(NodeChildProcess.execFile); | ||
| 16 | const lockModule = NodeModule.createRequire(import.meta.url).resolve("proper-lockfile"); | ||
| 17 | const instanceId = ProviderInstanceId.make("cross-process-test"); | ||
| 18 | const 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 | |||
| 37 | it.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 | |||
| 54 | it.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. | ||
| 2 | import * as NodePath from "node:path"; | ||
| 3 | import { lock } from "proper-lockfile"; | ||
| 4 | import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; | ||
| 5 | import * as Deferred from "effect/Deferred"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | |||
| 8 | /** Keep rotating tokens and their active profile atomic across servers sharing a secret store. */ | ||
| 9 | export 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 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { BUNDLED_MODEL_MANIFEST, ModelManifest, type ModelManifestData } from "./ModelManifest.ts"; | ||
| 3 | import { expect, it } from "@effect/vitest"; | ||
| 4 | import { | ||
| 5 | HostProcessArchitecture, | ||
| 6 | HostProcessEnvironment, | ||
| 7 | HostProcessPlatform, | ||
| 8 | } from "@t3tools/shared/hostProcess"; | ||
| 9 | import * as Deferred from "effect/Deferred"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as FileSystem from "effect/FileSystem"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as Exit from "effect/Exit"; | ||
| 14 | import * as Scope from "effect/Scope"; | ||
| 15 | import * as Stream from "effect/Stream"; | ||
| 16 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 17 | import * as NodeCrypto from "node:crypto"; | ||
| 18 | import { | ||
| 19 | makeCodexInstallation, | ||
| 20 | type CodexInstallation, | ||
| 21 | type CodexInstallationOptions, | ||
| 22 | resolveCodexReleaseAsset, | ||
| 23 | } from "./CodexInstallation.ts"; | ||
| 24 | |||
| 25 | const archive = Buffer.from( | ||
| 26 | "H4sIAAAAAAAC/+3W0W6DIBQGYB/FcD0sKNSkD7J7aom6tmAQtzXL3n3QZM3qdWVt+n8XoCckJp78wLY3q8bu9Ge2HBbUUp7nYD4H8s9zrNdSsCxnWQLT6JULn8ye09K9h/u2/c0/jSM9xqGzo0+bf3Gdf15WQiD/Kdy61/CA+z8dlO9Wrv2387+c5Z9VZY38p7BY0+Gh8t/sVauLt9Ga9Pnnop7ln1e4/6fxRQ7qZCf/qt3YW0M2OX/JyfvljbCCy3XBSaiGH9VqH4tKuaZbC6qG4aDpTrmP3sQV2nh3Gmxvzqsud0vyjaABAAAAAAAAAAAAAAAk8gOq19rvACgAAA==", | ||
| 27 | "base64", | ||
| 28 | ); | ||
| 29 | const 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 | }; | ||
| 36 | const 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 | }); | ||
| 93 | const 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 | |||
| 100 | for (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 | } | ||
| 130 | for (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 | } | ||
| 157 | it.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 | |||
| 169 | it.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 | |||
| 184 | it.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 | |||
| 216 | it.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 | |||
| 227 | it.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 | |||
| 244 | it.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 | |||
| 264 | it.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 | |||
| 292 | it.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 | |||
| 306 | it.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 | |||
| 325 | it.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 | |||
| 341 | it.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 | |||
| 359 | it("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. | ||
| 2 | import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts"; | ||
| 3 | import { | ||
| 4 | HostProcessArchitecture, | ||
| 5 | HostProcessEnvironment, | ||
| 6 | HostProcessPlatform, | ||
| 7 | } from "@t3tools/shared/hostProcess"; | ||
| 8 | import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 9 | import * as Clock from "effect/Clock"; | ||
| 10 | import * as Cause from "effect/Cause"; | ||
| 11 | import * as Context from "effect/Context"; | ||
| 12 | import * as Crypto from "effect/Crypto"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Exit from "effect/Exit"; | ||
| 15 | import * as Fiber from "effect/Fiber"; | ||
| 16 | import * as FileSystem from "effect/FileSystem"; | ||
| 17 | import * as Layer from "effect/Layer"; | ||
| 18 | import * as Option from "effect/Option"; | ||
| 19 | import * as Path from "effect/Path"; | ||
| 20 | import * as Schema from "effect/Schema"; | ||
| 21 | import * as Scope from "effect/Scope"; | ||
| 22 | import * as Semaphore from "effect/Semaphore"; | ||
| 23 | import * as Stream from "effect/Stream"; | ||
| 24 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 25 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 26 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 27 | import * as NodeCrypto from "node:crypto"; | ||
| 28 | import { ServerConfig } from "../config.ts"; | ||
| 29 | import { BUNDLED_MODEL_MANIFEST, ModelManifest } from "./ModelManifest.ts"; | ||
| 30 | import { resolveProviderCompatibility } from "./providerCompatibility.ts"; | ||
| 31 | |||
| 32 | const DRIVER = ProviderDriverKind.make("codex"); | ||
| 33 | const Version = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/u)); | ||
| 34 | const ActiveRelease = Schema.Struct({ version: Version }); | ||
| 35 | const InstalledRelease = Schema.Struct({ | ||
| 36 | version: Version, | ||
| 37 | target: Schema.String, | ||
| 38 | sha256: Schema.String, | ||
| 39 | }); | ||
| 40 | const PackageManifest = Schema.Struct({ | ||
| 41 | layoutVersion: Schema.Literal(1), | ||
| 42 | version: Version, | ||
| 43 | target: Schema.String, | ||
| 44 | entrypoint: Schema.String, | ||
| 45 | }); | ||
| 46 | const decodeVersion = Schema.decodeUnknownEffect(Version); | ||
| 47 | const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease)); | ||
| 48 | const encodeActive = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease)); | ||
| 49 | export 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. | ||
| 57 | const 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 | }; | ||
| 101 | export const resolveCodexReleaseAsset = (platform: NodeJS.Platform, arch: string) => | ||
| 102 | RELEASES[`${platform}-${arch}`] ?? null; | ||
| 103 | export 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 | } | ||
| 115 | const isInstallationError = Schema.is(CodexInstallationError); | ||
| 116 | const installationError = (operation: string, detail: string, cause?: unknown) => | ||
| 117 | new CodexInstallationError({ operation, detail, ...(cause === undefined ? {} : { cause }) }); | ||
| 118 | const wrapFailure = (operation: string, detail: string) => (cause: unknown) => | ||
| 119 | isInstallationError(cause) ? cause : installationError(operation, detail, cause); | ||
| 120 | export interface CodexExecutable { | ||
| 121 | readonly executablePath: string; | ||
| 122 | readonly source: "managed" | "local"; | ||
| 123 | readonly version: string; | ||
| 124 | readonly managedVersionDirectory: string | null; | ||
| 125 | } | ||
| 126 | interface 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 | } | ||
| 140 | export 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 | } | ||
| 152 | export 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 | } | ||
| 160 | const isRunning = (state: ProviderInstallState) => | ||
| 161 | ["downloading", "extracting", "verifying"].includes(state.phase); | ||
| 162 | export 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 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { classifyCodexManagedError } from "./CodexManagedErrors.ts"; | ||
| 3 | it("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 | |||
| 35 | it("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 | |||
| 52 | it("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 @@ | ||
| 1 | const 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; | ||
| 40 | const 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 | }; | ||
| 58 | export 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 @@ | ||
| 1 | import type { CodexSettings, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Path from "effect/Path"; | ||
| 4 | import { resolveCodexHomeLayout } from "./Drivers/CodexHomeLayout.ts"; | ||
| 5 | |||
| 6 | export 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. | ||
| 2 | import * as NodeOS from "node:os"; | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 4 | import { assert, it } from "@effect/vitest"; | ||
| 5 | import { CodexSettings, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 6 | import * as Clock from "effect/Clock"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as FileSystem from "effect/FileSystem"; | ||
| 9 | import * as Path from "effect/Path"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 13 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 14 | import { ServerConfig } from "../config.ts"; | ||
| 15 | import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; | ||
| 16 | import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; | ||
| 17 | import { CodexInstallation } from "./CodexInstallation.ts"; | ||
| 18 | import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts"; | ||
| 19 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; | ||
| 20 | import { codexAppServerArgs } from "./Layers/codexLaunchArgs.ts"; | ||
| 21 | import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts"; | ||
| 22 | |||
| 23 | const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 24 | const decodeSettings = Schema.decodeSync(CodexSettings); | ||
| 25 | it.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 | ); | ||
| 51 | for (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 @@ | ||
| 1 | import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts"; | ||
| 2 | import { CodexSettings, ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Path from "effect/Path"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import { ServerConfig } from "../config.ts"; | ||
| 8 | import { CodexInstallation } from "./CodexInstallation.ts"; | ||
| 9 | import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; | ||
| 10 | import { materializeCodexShadowHome } from "./Drivers/CodexHomeLayout.ts"; | ||
| 11 | |||
| 12 | export interface CodexEffectiveRuntime { | ||
| 13 | readonly config: CodexSettings; | ||
| 14 | readonly environment: NodeJS.ProcessEnv; | ||
| 15 | readonly revision: string; | ||
| 16 | } | ||
| 17 | const decodeSettings = Schema.decodeSync(CodexSettings); | ||
| 18 | // Managed sign-in stores tokens in T3's credential store and never writes native auth.json. | ||
| 19 | const 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 | |||
| 33 | export 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 @@ |
| 1 | import { CodexInstallation } from "../CodexInstallation.ts"; | ||
| 2 | import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; | ||
| 3 | import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts"; | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 4 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as NodeOS from "node:os"; | 6 | import * as NodeOS from "node:os"; |
| 4 | import * as NodePath from "node:path"; | 7 | import * as NodePath from "node:path"; |
| 5 | import { expect, it } from "@effect/vitest"; | 8 | import { expect, it } from "@effect/vitest"; |
| 6 | import { ProviderInstanceId } from "@t3tools/contracts"; | 9 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; |
| 7 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 10 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 8 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Deferred from "effect/Deferred"; | ||
| 13 | import * as Fiber from "effect/Fiber"; | ||
| 9 | import * as FileSystem from "effect/FileSystem"; | 14 | import * as FileSystem from "effect/FileSystem"; |
| 10 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import * as PlatformError from "effect/PlatformError"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 11 | import * as Sink from "effect/Sink"; | 19 | import * as Sink from "effect/Sink"; |
| 12 | import * as Stream from "effect/Stream"; | 20 | import * as Stream from "effect/Stream"; |
| 13 | import { HttpClient } from "effect/unstable/http"; | 21 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 14 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | 22 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; |
| 15 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 23 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 16 | 24 | ||
| ... | @@ -26,11 +34,21 @@ import { | ... | @@ -26,11 +34,21 @@ import { |
| 26 | resolveLatestProviderVersion, | 34 | resolveLatestProviderVersion, |
| 27 | } from "../providerMaintenance.ts"; | 35 | } from "../providerMaintenance.ts"; |
| 28 | import { CodexDriver } from "./CodexDriver.ts"; | 36 | import { CodexDriver } from "./CodexDriver.ts"; |
| 37 | import * as ProviderCredentialStore from "../ProviderCredentialStore.ts"; | ||
| 29 | 38 | ||
| 30 | const testLayer = ServerConfig.layerTest(process.cwd(), { | 39 | const 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"; |
| 54 | const noSpawn = ChildProcessSpawner.make(() => | 72 | const 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 | ); |
| 75 | const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 57 | 76 | ||
| 58 | it.layer(testLayer)("CodexDriver", (it) => { | 77 | it.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"; |
| 71 | import { makeManagedCodexProvider } from "./CodexManagedProvider.ts"; | ||
| 72 | import { CodexInstallation } from "../CodexInstallation.ts"; | ||
| 73 | import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; | ||
| 74 | import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts"; | ||
| 71 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); | 75 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); |
| 72 | 76 | ||
| 73 | const DRIVER_KIND = ProviderDriverKind.make("codex"); | 77 | const DRIVER_KIND = ProviderDriverKind.make("codex"); |
| ... | @@ -111,7 +115,10 @@ export type CodexDriverEnv = | ... | @@ -111,7 +115,10 @@ export type CodexDriverEnv = |
| 111 | | Path.Path | 115 | | Path.Path |
| 112 | | ProviderEventLoggers | 116 | | ProviderEventLoggers |
| 113 | | ServerConfig | 117 | | ServerConfig |
| 114 | | ServerSettingsService; | 118 | | ServerSettingsService |
| 119 | | ServerSecretStore | ||
| 120 | | ServerEnvironmentIdentity | ||
| 121 | | CodexInstallation; | ||
| 115 | 122 | ||
| 116 | export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { | 123 | export 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 @@ | ||
| 1 | import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Stream from "effect/Stream"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 6 | import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; | ||
| 7 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 8 | import { chatGptModels } from "../CodexChatGptModels.ts"; | ||
| 9 | import { makeCodexManagedRuntime } from "../CodexManagedRuntime.ts"; | ||
| 10 | import { ProviderDriverError } from "../Errors.ts"; | ||
| 11 | import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; | ||
| 12 | import { | ||
| 13 | checkCodexProviderStatus, | ||
| 14 | makePendingCodexProvider, | ||
| 15 | probeCodexSkillsForCwd, | ||
| 16 | } from "../Layers/CodexProvider.ts"; | ||
| 17 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | ||
| 18 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | ||
| 19 | import { type ProviderDriverCreateInput, type ProviderInstance } from "../ProviderDriver.ts"; | ||
| 20 | import { codexContinuationIdentity } from "./CodexHomeLayout.ts"; | ||
| 21 | import { withInstanceIdentity } from "./instanceIdentity.ts"; | ||
| 22 | import { HttpClient } from "effect/unstable/http"; | ||
| 23 | const DRIVER = ProviderDriverKind.make("codex"); | ||
| 24 | |||
| 25 | export 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 | |||
| 3088 | it.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 | |||
| 3145 | it.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"; |
| 73 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 72 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| ... | @@ -85,6 +84,7 @@ const isCodexSessionRuntimeThreadIdMissingError = Schema.is( | ... | @@ -85,6 +84,7 @@ const isCodexSessionRuntimeThreadIdMissingError = Schema.is( |
| 85 | ); | 84 | ); |
| 86 | const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); | 85 | const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); |
| 87 | 86 | ||
| 87 | import { classifyCodexManagedError } from "../CodexManagedErrors.ts"; | ||
| 88 | const PROVIDER = ProviderDriverKind.make("codex"); | 88 | const PROVIDER = ProviderDriverKind.make("codex"); |
| 89 | 89 | ||
| 90 | export interface CodexAdapterLiveOptions { | 90 | export interface CodexAdapterLiveOptions { |
| ... | @@ -99,6 +99,12 @@ export interface CodexAdapterLiveOptions { | ... | @@ -99,6 +99,12 @@ export interface CodexAdapterLiveOptions { |
| 99 | CodexSessionRuntimeError, | 99 | CodexSessionRuntimeError, |
| 100 | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope | 100 | 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 | } |
| 114 | 122 | ||
| ... | @@ -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 | } |
| 2272 | 2280 | ||
| 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 === boundInstanceId | 2302 | !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 of | 2346 | // 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 | } |
| 2395 | 2418 | ||
| 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.error | 2433 | ? 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 | } |
| 2420 | 2459 | ||
| 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 | ); |
| 2532 | 2588 | ||
| 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 === boundInstanceId | 2612 | 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 === boundInstanceId | 2616 | !options?.resolveRuntime && input.modelSelection?.instanceId === boundInstanceId |
| 2540 | ? getCodexServiceTierOptionValue(input.modelSelection) | 2617 | ? getCodexServiceTierOptionValue(input.modelSelection) |
| 2541 | : undefined; | 2618 | : undefined; |
| 2542 | return yield* session.runtime | 2619 | 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* ( |
| 343 | export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { | 343 | export 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); |
| 421 | 422 | ||
| ... | @@ -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 "no | 443 | // 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 | ); |
| 465 | 468 | ||
| 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.Scope | 577 | 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 | } |
| 655 | 661 | ||
| 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 | }); |
| 145 | 145 | ||
| 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 @@ |
| 1 | import { CodexInstallation } from "../CodexInstallation.ts"; | ||
| 2 | import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; | ||
| 3 | import { 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 @@ |
| 25 | import { describe, expect, it } from "@effect/vitest"; | 28 | import { describe, expect, it } from "@effect/vitest"; |
| 26 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 29 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 27 | import { | 30 | import { |
| 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 outputs | 609 | // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs |
| 595 | // surfaced; that merged layer then provides `ServerConfig.layerTest`'s | 610 | // 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 @@ |
| 1 | import { CodexInstallation } from "../CodexInstallation.ts"; | ||
| 2 | import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; | ||
| 3 | import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts"; | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, it, assert } from "@effect/vitest"; | 5 | import { describe, it, assert } from "@effect/vitest"; |
| 3 | import * as DateTime from "effect/DateTime"; | 6 | import * 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"; |
| 16 | import * as TestClock from "effect/testing/TestClock"; | 19 | import * as TestClock from "effect/testing/TestClock"; |
| 17 | import * as CodexErrors from "effect-codex-app-server/errors"; | 20 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| 18 | import { | 21 | import { |
| 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 | ); |
| 371 | 375 | ||
| 372 | it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), TestHttpClientLive))( | 376 | const 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 | |||
| 385 | it.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 | |||
| 5277 | const chatGptAnalytics = makeRecordingAnalytics(); | ||
| 5278 | const chatGptAdapter = makeFakeCodexAdapter(); | ||
| 5279 | const 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 | }); | ||
| 5288 | chatGptTelemetry.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* ( |
| 543 | 544 | ||
| 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 toggle | 1816 | // 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 | ); |
| 443 | 443 | ||
| 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 | } | ||
| 406 | 410 | ||
| 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 | |||
| 522 | it.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"; |
| 25 | 25 | ||
| 26 | export interface ProviderAuthFlowContext { | 26 | export 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: { |
| 139 | 147 | ||
| 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 @@ |
| 1 | import type { | 1 | import 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"; |
| 13 | export interface ProviderAuthController { | 15 | export 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 | } |
| 50 | 65 | ||
| 51 | export interface ProviderAuthServiceShape { | 66 | export 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"; |
| 13 | import * as Stream from "effect/Stream"; | 13 | import * as Stream from "effect/Stream"; |
| 14 | 14 | ||
| 15 | import { layerTest as settingsLayerTest } from "../serverSettings.ts"; | 15 | import { layerTest as settingsLayerTest } from "../serverSettings.ts"; |
| 16 | import { CodexInstallation } from "./CodexInstallation.ts"; | ||
| 16 | import { AntigravityInstallation } from "./AntigravityInstallation.ts"; | 17 | import { AntigravityInstallation } from "./AntigravityInstallation.ts"; |
| 17 | import type { ProviderInstance } from "./ProviderDriver.ts"; | 18 | import type { ProviderInstance } from "./ProviderDriver.ts"; |
| 18 | import { makeProviderInstallation } from "./providerInstallation.ts"; | 19 | import { 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 | }; |
| 35 | 36 | ||
| 36 | function instance(kind = driver): ProviderInstance { | 37 | function 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 | ); |
| 141 | 161 | ||
| 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 11 | import * as Schema from "effect/Schema"; | 12 | import * as Schema from "effect/Schema"; |
| 12 | import * as Stream from "effect/Stream"; | 13 | import * as Stream from "effect/Stream"; |
| 13 | 14 | ||
| 15 | import { CodexInstallation, type CodexInstallationError } from "./CodexInstallation.ts"; | ||
| 14 | import { ServerSettingsService } from "../serverSettings.ts"; | 16 | import { ServerSettingsService } from "../serverSettings.ts"; |
| 15 | import { | 17 | import { |
| 16 | AntigravityInstallation, | 18 | AntigravityInstallation, |
| ... | @@ -23,11 +25,13 @@ import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment. | ... | @@ -23,11 +25,13 @@ import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment. |
| 23 | 25 | ||
| 24 | const ANTIGRAVITY = ProviderDriverKind.make("antigravity"); | 26 | const ANTIGRAVITY = ProviderDriverKind.make("antigravity"); |
| 25 | const hasBinaryPath = Schema.is(Schema.Struct({ binaryPath: Schema.String })); | 27 | const hasBinaryPath = Schema.is(Schema.Struct({ binaryPath: Schema.String })); |
| 28 | const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); | ||
| 26 | const decodeAntigravitySettings = Schema.decodeUnknownEffect(AntigravitySettings); | 29 | const decodeAntigravitySettings = Schema.decodeUnknownEffect(AntigravitySettings); |
| 27 | 30 | ||
| 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. */ |
| 29 | export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(function* () { | 32 | export 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 | }); |
| 86 | 102 | ||
| 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 }); | ||
| 89 | 107 | ||
| 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 | }); |
| 94 | 112 | ||
| 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* installation | 117 | 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 |
| 103 | 121 | ||
| 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 | ); |
| 108 | 128 | ||
| 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"; |
| 143 | import { CodexInstallation } from "./provider/CodexInstallation.ts"; | ||
| 143 | import type { ProviderInstance } from "./provider/ProviderDriver.ts"; | 144 | import type { ProviderInstance } from "./provider/ProviderDriver.ts"; |
| 144 | import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; | 145 | import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; |
| 145 | import { ProviderAdapterRequestError } from "./provider/Errors.ts"; | 146 | import { 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 |
| 55 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; | 55 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; |
| 56 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; | 56 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; |
| 57 | import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; | 57 | import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; |
| 58 | import { CodexInstallation } from "./provider/CodexInstallation.ts"; | ||
| 58 | import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; | 59 | import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; |
| 59 | import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; | 60 | import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; |
| 60 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; | 61 | import { 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 | ); |
| 477 | 478 | ||
| 478 | const AntigravityInstallationRefreshLive = Layer.effectDiscard( | 479 | const 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 | ); |
| 504 | 510 | ||
| 505 | const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | 511 | const 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 Services | 515 | // 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-instance | 542 | // Shared native/canonical NDJSON writers used by both the per-instance |
| 537 | // drivers (native stream, written from inside each `<X>Adapter`) and | 543 | // 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 | ); |
| 238 | 246 | ||
| 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", ""); |
| 181 | 186 | ||
| 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 | } |
| 122 | 122 | ||
| 123 | describe("UsageService", () => { | 123 | describe("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 replace | 272 | // 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() && environmentHome | 295 | 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"; |
| 17 | import * as Schedule from "effect/Schedule"; | 17 | import * as Schedule from "effect/Schedule"; |
| 18 | import * as Schema from "effect/Schema"; | 18 | import * as Schema from "effect/Schema"; |
| 19 | import * as Stream from "effect/Stream"; | 19 | import * as Stream from "effect/Stream"; |
| 20 | import { subscribeChatGptHandoff } from "./provider/CodexChatGptHandoff.ts"; | ||
| 21 | import { subscribeCodexAuthCallback } from "./provider/CodexAuthCallback.ts"; | ||
| 20 | import { | 22 | import { |
| 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 @@ |
| 1 | import { isChatGptUsageLimitError } from "@t3tools/shared/usageLimits"; | ||
| 1 | import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; | 2 | import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; |
| 2 | import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; | 3 | import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; |
| 3 | import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; | 4 | import 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 | <ThreadErrorBanner | 9811 | <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 | <path | 553 | <path |
| 554 | fillRule="evenodd" | 554 | fillRule="evenodd" |
apps/web/src/components/chat/ChatGptSharingControl.tsx created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | import { usesChatGptSharing } from "@t3tools/shared/usageLimits"; | ||
| 2 | import type { ServerProvider } from "@t3tools/contracts"; | ||
| 3 | import { OpenAI } from "../Icons"; | ||
| 4 | import { ChatGptUsageButton } from "../settings/ChatGptUsageButton"; | ||
| 5 | |||
| 6 | export 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"; |
| 2 | import { limitsNotice } from "@t3tools/shared/usageLimits"; | 2 | import { limitsNotice } from "@t3tools/shared/usageLimits"; |
| 3 | import { GaugeIcon } from "lucide-react"; | 3 | import { GaugeIcon } from "lucide-react"; |
| 4 | 4 | ||
| 5 | import { ensureLocalApi } from "../../localApi"; | ||
| 6 | import { Button } from "../ui/button"; | ||
| 5 | import { getDriverOption } from "../settings/providerDriverMeta"; | 7 | import { getDriverOption } from "../settings/providerDriverMeta"; |
| 6 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; | 8 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; |
| 7 | import { LimitWindows, ResetCredits } from "../usage/UsageLimits"; | 9 | import { 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 | <ResetCredits | 119 | <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"; |
| 10 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 10 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 11 | import { cn } from "~/lib/utils"; | 11 | import { cn } from "~/lib/utils"; |
| 12 | import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent"; | 12 | import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent"; |
| 13 | import { ChatGptSharingControl } from "./ChatGptSharingControl"; | ||
| 13 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; | 14 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; |
| 14 | import { | 15 | import { |
| 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"; |
| 3 | import { Button } from "../ui/button"; | 3 | import { Button } from "../ui/button"; |
| 4 | import { CircleAlertIcon, XIcon } from "lucide-react"; | 4 | import { CircleAlertIcon, XIcon } from "lucide-react"; |
| 5 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 5 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 6 | import { OpenAI } from "../Icons"; | ||
| 7 | import { ChatGptUsageButton } from "../settings/ChatGptUsageButton"; | ||
| 6 | 8 | ||
| 7 | export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null { | 9 | export 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) |
| 36 | export const ThreadErrorBanner = memo(function ThreadErrorBanner({ | 38 | export 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: {} })); |
| 74 | vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() })); | 74 | vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() })); |
| 75 | vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false })); | 75 | vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false })); |
| 76 | vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null })); | 76 | vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null })); |
| 77 | vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null })); | ||
| 78 | vi.mock("../settings/CodexSetupSection", () => ({ | ||
| 79 | CodexSetupSection: () => null, | ||
| 80 | AddManagedCodexAccountDialog: () => null, | ||
| 81 | })); | ||
| 77 | vi.mock("../cloud/CloudEnvironmentConnectList", () => ({ | 82 | vi.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"; |
| 16 | import { CommandId, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; | 17 | import { |
| 18 | CommandId, | ||
| 19 | defaultInstanceIdForDriver, | ||
| 20 | ProviderDriverKind, | ||
| 21 | ThreadId, | ||
| 22 | } from "@t3tools/contracts"; | ||
| 17 | import * as Schema from "effect/Schema"; | 23 | import * as Schema from "effect/Schema"; |
| 18 | import { | 24 | import { |
| 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"; |
| 60 | import { connectPairing } from "../../connection/onboarding"; | 66 | import { connectPairing } from "../../connection/onboarding"; |
| 61 | import { getProviderSummary } from "../settings/providerStatus"; | 67 | import { getProviderSummary } from "../settings/providerStatus"; |
| 62 | import { getDriverOption } from "../settings/providerDriverMeta"; | 68 | import { getDriverOption } from "../settings/providerDriverMeta"; |
| 69 | import { ChatGptWelcomeCoordinator } from "../settings/ChatGptWelcomeCoordinator"; | ||
| 70 | import { AddManagedCodexAccountDialog, CodexSetupSection } from "../settings/CodexSetupSection"; | ||
| 71 | import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; | ||
| 72 | import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic"; | ||
| 63 | import { TerminalViewport } from "../ThreadTerminalDrawer"; | 73 | import { TerminalViewport } from "../ThreadTerminalDrawer"; |
| 64 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; | 74 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; |
| 65 | import { ClaudeAI, OpenAI } from "../Icons"; | 75 | import { 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 a | 91 | * First-run welcome wizard. Rendered over the workspace at `/welcome` on a |
| 82 | * fresh install (no completed-onboarding flag, empty workspace). Flow per the | 92 | * 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 is | 95 | * 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 |
| 96 | export function WelcomeWizard({ | 106 | export 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 | <WizardPopup | 213 | <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({ |
| 613 | 629 | ||
| 614 | // ── Step 3: agents ─────────────────────────────────────────── | 630 | // ── Step 3: agents ─────────────────────────────────────────── |
| 615 | 631 | ||
| 616 | const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const; | 632 | const PRIMARY_AGENT_DRIVERS = ["codex", "claudeAgent"] as const; |
| 617 | type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number]; | 633 | type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number]; |
| 618 | 634 | ||
| 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 | } |
| 628 | 644 | ||
| 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 | */ | ||
| 636 | function AgentsStep({ | 646 | function 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 | <ConnectedAgentsStep | 662 | <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); | ||
| 683 | 702 | ||
| 684 | // Re-probe on entry so freshly installed CLIs show up without a manual | 703 | // 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({ |
| 689 | 708 | ||
| 690 | const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]); | 709 | const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]); |
| 691 | 710 | ||
| 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 | <AgentCard | 737 | 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.installed | 750 | 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 | <AgentInstallTerminal | 820 | <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 | } |
| 743 | 832 | ||
| 833 | function 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 | |||
| 744 | function AgentCard({ | 920 | function 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); |
| 762 | 939 | ||
| 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 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | ProviderDriverKind, | ||
| 4 | ProviderInstanceId, | ||
| 5 | type EnvironmentId, | ||
| 6 | type ServerProvider, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { useEffect, useEffectEvent, useState, type ReactNode } from "react"; | ||
| 9 | import { usesChatGptSharing } from "@t3tools/shared/usageLimits"; | ||
| 10 | |||
| 11 | import { useEnvironmentSettings } from "../../hooks/useSettings"; | ||
| 12 | import { randomUUID } from "../../lib/utils"; | ||
| 13 | import { serverEnvironment } from "../../state/server"; | ||
| 14 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 15 | import { Button } from "../ui/button"; | ||
| 16 | import { Dialog } from "../ui/dialog"; | ||
| 17 | import { Input } from "../ui/input"; | ||
| 18 | import { WizardFooter, WizardHeader, WizardPanel, WizardPopup } from "../ui/wizard"; | ||
| 19 | import { SettingsRow } from "./settingsLayout"; | ||
| 20 | |||
| 21 | export 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 |
| 14 | import { cn } from "../../lib/utils"; | 14 | import { cn } from "../../lib/utils"; |
| 15 | import { normalizeProviderAccentColor } from "../../providerInstances"; | 15 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 16 | import { Button } from "../ui/button"; | 16 | import { Button } from "../ui/button"; |
| 17 | import { ChatGptConnectionButton } from "./ChatGptConnectionButton"; | ||
| 17 | import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; | 18 | import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; |
| 18 | import { Dialog } from "../ui/dialog"; | 19 | import { Dialog } from "../ui/dialog"; |
| 19 | import { Badge } from "../ui/badge"; | 20 | import { 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"; |
| 32 | import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; | 33 | import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; |
| 34 | import { AddManagedCodexAccountDialog } from "./CodexSetupSection"; | ||
| 33 | 35 | ||
| 34 | const PROVIDER_ACCENT_SWATCHES = [ | 36 | const 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); |
| 127 | 129 | ||
| 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; |
| 187 | 190 | ||
| 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); |
| 191 | 197 | ||
| ... | @@ -222,17 +228,21 @@ export function AddProviderInstanceDialog({ | ... | @@ -222,17 +228,21 @@ export function AddProviderInstanceDialog({ |
| 222 | } | 228 | } |
| 223 | }; | 229 | }; |
| 224 | 230 | ||
| 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 | <WizardHeader | 243 | <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 | <AddProviderInstanceWizardSteps | 247 | <AddProviderInstanceWizardSteps |
| 238 | currentStep={wizardStep} | 248 | currentStep={wizardStep} |
| ... | @@ -400,7 +410,7 @@ export function AddProviderInstanceDialog({ | ... | @@ -400,7 +410,7 @@ export function AddProviderInstanceDialog({ |
| 400 | 410 | ||
| 401 | <WizardFooter> | 411 | <WizardFooter> |
| 402 | <Button | 412 | <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 @@ | ||
| 1 | import { useState } from "react"; | ||
| 2 | import type { ProviderAuthMethod } from "@t3tools/contracts"; | ||
| 3 | import { RadioGroup, Radio } from "../ui/radio-group"; | ||
| 4 | import { | ||
| 5 | Dialog, | ||
| 6 | DialogPopup, | ||
| 7 | DialogHeader, | ||
| 8 | DialogTitle, | ||
| 9 | DialogDescription, | ||
| 10 | DialogFooter, | ||
| 11 | } from "../ui/dialog"; | ||
| 12 | import { ChatGptConnectionButton } from "./ChatGptConnectionButton"; | ||
| 13 | |||
| 14 | export 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 @@ | ||
| 1 | import type { ComponentProps } from "react"; | ||
| 2 | import { OpenAI } from "../Icons"; | ||
| 3 | import { Button } from "../ui/button"; | ||
| 4 | |||
| 5 | export 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 @@ | ||
| 1 | import type { ComponentProps } from "react"; | ||
| 2 | import { ExternalLinkIcon } from "lucide-react"; | ||
| 3 | import { CHATGPT_USAGE_URL } from "@t3tools/shared/usageLimits"; | ||
| 4 | import { ensureLocalApi } from "../../localApi"; | ||
| 5 | import { Button } from "../ui/button"; | ||
| 6 | |||
| 7 | export 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 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { usesChatGptSharing } from "@t3tools/shared/usageLimits"; | ||
| 3 | import { useState } from "react"; | ||
| 4 | import { environmentPresentations } from "../../state/presentation"; | ||
| 5 | import { OpenAI } from "../Icons"; | ||
| 6 | import { Button } from "../ui/button"; | ||
| 7 | import { | ||
| 8 | Dialog, | ||
| 9 | DialogPopup, | ||
| 10 | DialogHeader, | ||
| 11 | DialogTitle, | ||
| 12 | DialogDescription, | ||
| 13 | DialogFooter, | ||
| 14 | } from "../ui/dialog"; | ||
| 15 | import { ChatGptUsageButton } from "./ChatGptUsageButton"; | ||
| 16 | |||
| 17 | const STORAGE_KEY = "t3:chatgpt-sharing-welcome:v1"; | ||
| 18 | function 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. */ | ||
| 30 | export 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 @@ | ||
| 1 | export 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 @@ | ||
| 1 | import { | ||
| 2 | isAtomCommandInterrupted, | ||
| 3 | squashAtomCommandFailure, | ||
| 4 | type AtomCommandResult, | ||
| 5 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 6 | import type { | ||
| 7 | ChatGptHandoffInput, | ||
| 8 | ChatGptTransferredProfile, | ||
| 9 | EnvironmentId, | ||
| 10 | ProviderInstanceId, | ||
| 11 | ServerProvider, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import { codexAuthHandoffUrl } from "@t3tools/shared/codexAuthHandoff"; | ||
| 14 | import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; | ||
| 15 | import { isLoopbackHost } from "@t3tools/shared/preview"; | ||
| 16 | import { CheckIcon, ChevronRightIcon, ExternalLinkIcon } from "lucide-react"; | ||
| 17 | import { Children, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; | ||
| 18 | |||
| 19 | import { ensureLocalApi } from "../../localApi"; | ||
| 20 | import { | ||
| 21 | useEnvironmentHttpBaseUrl, | ||
| 22 | usePrimaryEnvironmentId, | ||
| 23 | useEnvironment, | ||
| 24 | } from "../../state/environments"; | ||
| 25 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 26 | import { serverEnvironment } from "../../state/server"; | ||
| 27 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 28 | import { Button } from "../ui/button"; | ||
| 29 | import { ChatGptConnectionButton } from "./ChatGptConnectionButton"; | ||
| 30 | import { ChatGptUsageButton } from "./ChatGptUsageButton"; | ||
| 31 | import { ChatGptAccountPicker } from "./ChatGptAccountPicker"; | ||
| 32 | import { Input } from "../ui/input"; | ||
| 33 | import { OpenAI } from "../Icons"; | ||
| 34 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; | ||
| 35 | import { SettingsRow } from "./settingsLayout"; | ||
| 36 | import { AddCodexAccountDialog } from "./AddCodexAccountDialog"; | ||
| 37 | import { getOnboardingProviderState } from "../../onboarding/providerReadiness.logic"; | ||
| 38 | import { getProviderSummary } from "./providerStatus"; | ||
| 39 | |||
| 40 | const noop = () => undefined; | ||
| 41 | |||
| 42 | interface 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. */ | ||
| 58 | export 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. */ | ||
| 159 | export 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 | |||
| 194 | function 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. */ | ||
| 1011 | export 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 | |||
| 1082 | function 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. */ | ||
| 1110 | function 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 | } |
| 36 | 38 | ||
| 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 @@ | ||
| 1 | import { useEffect, useRef, useState } from "react"; | ||
| 2 | import { pendingProviderAuthDelivery, clearProviderAuthDelivery } from "../../providerAuthDelivery"; | ||
| 3 | import { serverEnvironment } from "../../state/server"; | ||
| 4 | import { useEnvironments } from "../../state/environments"; | ||
| 5 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 6 | import { toastManager } from "../ui/toast"; | ||
| 7 | |||
| 8 | /** Hosted web receives only the one-time code; the selected environment verifies and stores tokens. */ | ||
| 9 | export 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 |
| 49 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; | 49 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; |
| 50 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; | 50 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 51 | import { SettingsRow, SettingsSection } from "./settingsLayout"; | 51 | import { SettingsRow, SettingsSection } from "./settingsLayout"; |
| 52 | import { FoldedSettingsSection } from "./FoldedSettingsSection"; | ||
| 53 | import { readCodexSetupMode } from "./CodexSetupSection.logic"; | ||
| 52 | import { | 54 | import { |
| 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 | ); |
| 862 | 861 | ||
| 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({ |
| 907 | 926 | ||
| 908 | {setup ? <SettingsSection title="Setup">{setup}</SettingsSection> : null} | 927 | {setup ? <SettingsSection title="Setup">{setup}</SettingsSection> : null} |
| 909 | 928 | ||
| 910 | <SettingsSection | 929 | {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 | <ProviderSettingsForm | 936 | 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 | <SettingsRow | 944 | ) : ( |
| 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 not | 949 | 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> | ||
| 937 | 954 | ||
| 938 | <SettingsSection | 955 | <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: { |
| 20 | export function buildProviderEnvironmentOptions<T extends ProviderEnvironmentOptionLike>( | 20 | export 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 | } |
| 36 | 40 | ||
| 37 | export function resolveSelectedProviderEnvironmentId( | 41 | export 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"; |
| 84 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; | 84 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; |
| 85 | import { UsageProviderSettings } from "./UsageProviderSettings"; | 85 | import { UsageProviderSettings } from "./UsageProviderSettings"; |
| 86 | import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; | 86 | import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; |
| 87 | import { CodexSetupSection, CodexManagedRuntimeFields } from "./CodexSetupSection"; | ||
| 88 | import { readCodexSetupMode } from "./CodexSetupSection.logic"; | ||
| 87 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; | 89 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; |
| 88 | import { searchableSetting } from "./settingsSearch"; | 90 | import { searchableSetting } from "./settingsSearch"; |
| 89 | import { | 91 | import { |
| ... | @@ -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 | } |
| 269 | 272 | ||
| 270 | export function ProviderSettingsPanel(target: ProviderSettingsTarget) { | 273 | export 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 a | 293 | // 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 | <ProviderSetupSection | 955 | <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 | ) : null | 990 | ) : null |
| 952 | } | 991 | } |
| 953 | onUpdate={(next) => { | 992 | onUpdate={(next) => { |
| ... | @@ -1021,10 +1060,11 @@ export function EnvironmentProviderSettings({ | ... | @@ -1021,10 +1060,11 @@ export function EnvironmentProviderSettings({ |
| 1021 | 1060 | ||
| 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 | <SettingsRow | 1123 | <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 |
| 10 | import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; | 10 | import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; |
| 11 | import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings"; | 11 | import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings"; |
| 12 | import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; | 12 | import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; |
| 13 | import { selectSingleEnvironmentScope } from "./settingsScopeAxis"; | ||
| 13 | 14 | ||
| 14 | /** | 15 | /** |
| 15 | * Each member's decoded t3.json, so file-backed settings show the file as a | 16 | * 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 | } |
| 55 | 56 | ||
| 56 | function useResolvedSettingsScope(search: SettingsScopeSearch) { | 57 | function 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 | } |
| 87 | 100 | ||
| 88 | const SettingsScopeContext = createContext< | 101 | const 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 | } |
| 45 | 46 | ||
| ... | @@ -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 | } |
| 107 | 109 | ||
| 108 | function EnvironmentScopeMenu({ value, groups, environments, onChange }: SettingsScopeMenuProps) { | 110 | function 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_VALUE | 140 | : 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 | <MenuRadioGroup | 147 | <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 @@ |
| 1 | import type { EnvironmentPresentation } from "../../state/environments"; | 1 | import type { EnvironmentPresentation } from "../../state/environments"; |
| 2 | import type { SettingsScopeSearch } from "./settingsScope"; | 2 | import type { ResolvedSettingsScope, SettingsScopeSearch } from "./settingsScope"; |
| 3 | 3 | ||
| 4 | type ScopeEnvironment = Pick<EnvironmentPresentation, "environmentId" | "label" | "displayUrl">; | 4 | type ScopeEnvironment = Pick<EnvironmentPresentation, "environmentId" | "label" | "displayUrl">; |
| 5 | 5 | ||
| ... | @@ -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. */ | ||
| 58 | export 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. */ |
| 9 | export function WizardPopup({ | 9 | export function WizardPopup({ |
| 10 | children, | 10 | children, |
| 11 | size = "default", | ||
| 11 | ...props | 12 | ...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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 14 | import { AlertTriangleIcon, TicketIcon } from "lucide-react"; | 16 | import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react"; |
| 15 | import { Fragment, type ReactNode, useState } from "react"; | 17 | import { Fragment, type ReactNode, useState } from "react"; |
| 16 | 18 | ||
| 19 | import { ensureLocalApi } from "../../localApi"; | ||
| 17 | import { usePrimarySettings } from "../../hooks/useSettings"; | 20 | import { usePrimarySettings } from "../../hooks/useSettings"; |
| 18 | import { cn } from "../../lib/utils"; | 21 | import { cn } from "../../lib/utils"; |
| 19 | import { formatUpcomingTimestamp } from "../../timestampFormat"; | 22 | import { formatUpcomingTimestamp } from "../../timestampFormat"; |
| ... | @@ -21,6 +24,7 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | ... | @@ -21,6 +24,7 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 21 | import { getDriverOption } from "../settings/providerDriverMeta"; | 24 | import { getDriverOption } from "../settings/providerDriverMeta"; |
| 22 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; | 25 | import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; |
| 23 | import { Button } from "../ui/button"; | 26 | import { Button } from "../ui/button"; |
| 27 | import { OpenAI } from "../Icons"; | ||
| 24 | import { Alert, AlertTitle } from "../ui/alert"; | 28 | import { Alert, AlertTitle } from "../ui/alert"; |
| 25 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 29 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 26 | import { | 30 | import { |
| ... | @@ -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 @@ |
| 1 | import { ChatGptUsageButton } from "../settings/ChatGptUsageButton"; | ||
| 2 | import { usesChatGptSharing } from "@t3tools/shared/usageLimits"; | ||
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | 3 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | 4 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { | 5 | import { |
| ... | @@ -516,6 +518,17 @@ export function UsagePage() { | ... | @@ -516,6 +518,17 @@ export function UsagePage() { |
| 516 | </span> | 518 | </span> |
| 517 | </div> | 519 | </div> |
| 518 | 520 | ||
| 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" |
| 4 | 4 | ||
| 5 | import "./index.css"; | 5 | import "./index.css"; |
| 6 | 6 | ||
| 7 | import { prepareProviderAuthDelivery } from "./providerAuthDelivery"; | ||
| 7 | import { isElectron } from "./env"; | 8 | import { isElectron } from "./env"; |
| 8 | import { hasCloudPublicConfig } from "./cloud/publicConfig"; | 9 | import { hasCloudPublicConfig } from "./cloud/publicConfig"; |
| 9 | import { getRouter } from "./router"; | 10 | import { getRouter } from "./router"; |
| ... | @@ -14,6 +15,8 @@ import { | ... | @@ -14,6 +15,8 @@ import { |
| 14 | import { AppRoot } from "./AppRoot"; | 15 | import { AppRoot } from "./AppRoot"; |
| 15 | import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; | 16 | import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; |
| 16 | 17 | ||
| 18 | prepareProviderAuthDelivery(); | ||
| 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. |
| 18 | const history = isElectron ? createHashHistory() : createBrowserHistory(); | 21 | const history = isElectron ? createHashHistory() : createBrowserHistory(); |
| 19 | 22 |
apps/web/src/onboarding/providerReadiness.logic.ts+2| ... | @@ -36,6 +36,8 @@ function quoteProviderBinary( | ... | @@ -36,6 +36,8 @@ function quoteProviderBinary( |
| 36 | export function getOnboardingProviderState(provider: ServerProvider | undefined) { | 36 | export 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 @@ | ||
| 1 | import { readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; | ||
| 2 | |||
| 3 | let pending: ReturnType<typeof readCodexAuthDelivery>; | ||
| 4 | |||
| 5 | /** Remove the code before the router, tracing, or welcome screen sees the incoming address. */ | ||
| 6 | export 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 | |||
| 16 | export const pendingProviderAuthDelivery = () => pending; | ||
| 17 | export 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 |
| 32 | import { QueuedMessageSender } from "../components/QueuedMessageSender"; | 32 | import { QueuedMessageSender } from "../components/QueuedMessageSender"; |
| 33 | import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; | 33 | import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; |
| 34 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; | 34 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; |
| 35 | import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator"; | ||
| 36 | import { ProviderAuthCallbackCoordinator } from "../components/settings/ProviderAuthCallbackCoordinator"; | ||
| 35 | import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; | 37 | import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; |
| 36 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; | 38 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| 37 | import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme"; | 39 | import { 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 | <FirstRunGate | 224 | <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"; |
| 6 | 6 | ||
| 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 crumb | 9 | * 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 | */ |
| 12 | function SettingsProvidersRoute() { | 12 | function 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 | <SettingsScopeProvider | 154 | <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 target | 158 | // 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 @@ |
| 1 | import { createFileRoute, redirect, useLocation, useNavigate } from "@tanstack/react-router"; | 1 | import { createFileRoute, redirect, useLocation, useNavigate } from "@tanstack/react-router"; |
| 2 | import { useState } from "react"; | 2 | import { useState } from "react"; |
| 3 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 3 | 6 | ||
| 4 | import { NoProjectsHero } from "../components/NoProjectsHero"; | 7 | import { NoProjectsHero } from "../components/NoProjectsHero"; |
| 5 | import { WelcomeWizard } from "../components/onboarding/WelcomeWizard"; | 8 | import { WelcomeWizard } from "../components/onboarding/WelcomeWizard"; |
| 6 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | 9 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| 7 | 10 | ||
| 11 | const decodeEnvironmentId = Schema.decodeOption(EnvironmentId); | ||
| 12 | |||
| 8 | /** Onboarding overlays the workspace. Visiting /welcome reopens setup. */ | 13 | /** Onboarding overlays the workspace. Visiting /welcome reopens setup. */ |
| 9 | export const Route = createFileRoute("/welcome")({ | 14 | export 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")({ |
| 19 | function WelcomeRouteView() { | 24 | function 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 | <WelcomeWizard | 45 | <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 |
| 48 | a successful callback HTTP request is not proof that provider authentication finished. The native | 48 | a successful callback HTTP request is not proof that provider authentication finished. The native |
| 49 | process owns token exchange and storage. | 49 | process owns token exchange and storage. |
| 50 | 50 | ||
| 51 | Managed 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 | ||
| 53 | credential store and the destination's environment ID. It exchanges and verifies the code before | ||
| 54 | transferring the issued client registration and tokens. Only the destination persists and refreshes | ||
| 55 | that session; retaining a primary refresh session would race refresh-token rotation. Without a local | ||
| 56 | primary, the client uses the remote callback completion flow. | ||
| 57 | |||
| 51 | Antigravity sign-out closes admission to new processes and stops existing processes before clearing account | 58 | Antigravity sign-out closes admission to new processes and stops existing processes before clearing account |
| 52 | metadata. Otherwise a helper or resumed session could retain the old account. Cached model lists | 59 | metadata. Otherwise a helper or resumed session could retain the old account. Cached model lists |
| 53 | do not establish current access, and an authoritative empty catalog must clear the old list. | 60 | do 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 |
| 111 | to that environment's machine, even when you connect from a phone or another | 111 | to that environment's machine, even when you connect from a phone or another |
| 112 | computer. | 112 | computer. |
| 113 | 113 | ||
| 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. | |
| 122 | 122 | ||
| 123 | Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its | 123 | Provider 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. |
| 125 | Cursor's executable is `cursor-agent`, although its login command is | 125 | Cursor'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 |
| 127 | managed runtimes without a `PATH` entry. | ||
| 127 | 128 | ||
| 128 | T3 Code warns when a provider version has known compatibility problems with your | 129 | T3 Code warns when a provider version has known compatibility problems with your |
| 129 | release. Check **Settings → Providers** on that environment for the recommended | 130 | release. 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 | # Codex | 1 | # Codex |
| 2 | 2 | ||
| 3 | For one account, use the default Codex provider with your normal Codex login. | 3 | Use 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 | |
| 5 | and custom binaries or environment variables. | 5 | ## Connect with ChatGPT |
| 6 | |||
| 7 | Connect during onboarding or in **Settings → Providers**. For a remote machine, | ||
| 8 | select that environment first. T3 Code handles Codex installation; sign in on | ||
| 9 | OpenAI and allow sharing of your ChatGPT plan. | ||
| 10 | |||
| 11 | Manage shared usage and credits in ChatGPT through **Manage usage** in T3 Code. | ||
| 12 | If a request uses a feature that ChatGPT sharing does not support, use another | ||
| 13 | provider for that request. | ||
| 14 | |||
| 15 | When reconnecting, choose the same account in T3 Code and on OpenAI's sign-in | ||
| 16 | page. Disconnecting stops running threads but keeps their history and lets you | ||
| 17 | reconnect later. | ||
| 18 | |||
| 19 | If remote sign-in cannot return automatically, paste the full URL from the final | ||
| 20 | localhost page into the sign-in panel, even if that page could not load. | ||
| 21 | |||
| 22 | ## Use an existing Codex login | ||
| 23 | |||
| 24 | T3 Code can use your installed Codex and its existing login. Run `codex login` | ||
| 25 | on the environment's machine to sign in. [Provider setup](./install.md#providers) | ||
| 26 | covers installation and custom configuration. | ||
| 6 | 27 | ||
| 7 | ## Use multiple accounts | 28 | ## Use multiple accounts |
| 8 | 29 | ||
| 30 | Add another ChatGPT account in **Settings → Providers**, then select the account | ||
| 31 | from the thread's model picker. Compatible accounts can continue the same thread. | ||
| 32 | Connecting accounts through T3 Code leaves your CLI login unchanged. | ||
| 33 | |||
| 34 | ### Multiple CLI logins | ||
| 35 | |||
| 9 | A shared Codex home with a shadow home lets work and personal accounts continue | 36 | A shared Codex home with a shadow home lets work and personal accounts continue |
| 10 | the same threads. The accounts share Codex sessions and configuration while keeping | 37 | the same threads. The accounts share Codex sessions and configuration while keeping |
| 11 | their own login and available models. | 38 | their 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; |
| 41 | type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag]; | 41 | type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag]; |
| 42 | 42 | ||
| 43 | export type EnvironmentSubscriptionRpcTag = | 43 | export type EnvironmentSubscriptionRpcTag = |
| 44 | | typeof WS_METHODS.codexAuthCallbackSubscribe | ||
| 44 | | typeof WS_METHODS.providerAuthSubscribe | 45 | | typeof WS_METHODS.providerAuthSubscribe |
| 45 | | typeof WS_METHODS.providerInstallSubscribe | 46 | | typeof WS_METHODS.providerInstallSubscribe |
| 46 | | typeof ORCHESTRATION_WS_METHODS.subscribeShell | 47 | | 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; |
| 63 | 64 | ||
| 64 | export type EnvironmentStreamCommandRpcTag = | 65 | export type EnvironmentStreamCommandRpcTag = |
| 66 | | typeof WS_METHODS.chatGptHandoffSubscribe | ||
| 65 | | typeof WS_METHODS.cloudInstallRelayClient | 67 | | typeof WS_METHODS.cloudInstallRelayClient |
| 66 | | typeof WS_METHODS.serverUpdateServerWithProgress | 68 | | 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 | } |
| 61 | 61 | ||
| 62 | interface EnvironmentSubscriptionAtomOptions<Input, A, E, R> { | 62 | interface 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 builds | 1220 | * 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; |
| 809 | 809 | ||
| 810 | const RuntimeErrorPayload = Schema.Struct({ | 810 | const 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 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | }); |
| 14 | export type ProviderSetupInput = typeof ProviderSetupInput.Type; | 15 | export type ProviderSetupInput = typeof ProviderSetupInput.Type; |
| 15 | 16 | ||
| 17 | export 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 | }); | ||
| 24 | export type CodexAuthCallbackInput = typeof CodexAuthCallbackInput.Type; | ||
| 25 | export 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 | |||
| 16 | const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); | 33 | const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); |
| 17 | 34 | ||
| 18 | export const ProviderAuthMethod = Schema.Struct({ | 35 | export 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; |
| 86 | export const ProviderAuthStartInput = Schema.Struct({ | 104 | export 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 | }); |
| 90 | export type ProviderAuthStartInput = typeof ProviderAuthStartInput.Type; | 110 | export type ProviderAuthStartInput = typeof ProviderAuthStartInput.Type; |
| 91 | 111 | ||
| ... | @@ -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. | ||
| 205 | export 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 | }); | ||
| 218 | export type ChatGptReconnectProfile = typeof ChatGptReconnectProfile.Type; | ||
| 219 | export 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 | }); | ||
| 234 | export type ChatGptTransferredProfile = typeof ChatGptTransferredProfile.Type; | ||
| 235 | export const ChatGptReconnectProfileInput = Schema.Struct({ | ||
| 236 | instanceId: ProviderInstanceId, | ||
| 237 | methodId: Schema.String, | ||
| 238 | }); | ||
| 239 | export const ChatGptImportProfileInput = Schema.Struct({ | ||
| 240 | instanceId: ProviderInstanceId, | ||
| 241 | profile: ChatGptTransferredProfile, | ||
| 242 | }); | ||
| 243 | export 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 | }); | ||
| 250 | export type ChatGptHandoffInput = typeof ChatGptHandoffInput.Type; | ||
| 251 | export const ChatGptHandoffState = Schema.Union([ | ||
| 252 | Schema.Struct({ phase: Schema.Literal("auth"), state: ProviderAuthState }), | ||
| 253 | Schema.Struct({ phase: Schema.Literal("finished"), profile: ChatGptTransferredProfile }), | ||
| 254 | ]); | ||
| 255 | export 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 @@ |
| 1 | import { | ||
| 2 | ChatGptReconnectProfileInput, | ||
| 3 | ChatGptReconnectProfile, | ||
| 4 | ChatGptImportProfileInput, | ||
| 5 | ChatGptHandoffInput, | ||
| 6 | ChatGptHandoffState, | ||
| 7 | } from "./providerSetup.ts"; | ||
| 1 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 2 | import * as Rpc from "effect/unstable/rpc/Rpc"; | 9 | import * as Rpc from "effect/unstable/rpc/Rpc"; |
| 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; | 10 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 4 | import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 11 | import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 5 | import { | 12 | import { |
| 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 | }); |
| 521 | 534 | ||
| 535 | const WsChatGptReconnectProfileRpc = Rpc.make(WS_METHODS.chatGptReconnectProfile, { | ||
| 536 | payload: ChatGptReconnectProfileInput, | ||
| 537 | success: Schema.NullOr(ChatGptReconnectProfile), | ||
| 538 | error: ProviderSetupRpcError, | ||
| 539 | }); | ||
| 540 | const WsChatGptImportProfileRpc = Rpc.make(WS_METHODS.chatGptImportProfile, { | ||
| 541 | payload: ChatGptImportProfileInput, | ||
| 542 | success: ProviderAuthState, | ||
| 543 | error: ProviderSetupRpcError, | ||
| 544 | }); | ||
| 545 | const WsChatGptHandoffSubscribeRpc = Rpc.make(WS_METHODS.chatGptHandoffSubscribe, { | ||
| 546 | payload: ChatGptHandoffInput, | ||
| 547 | success: ChatGptHandoffState, | ||
| 548 | error: ProviderSetupRpcError, | ||
| 549 | stream: true, | ||
| 550 | }); | ||
| 551 | const WsCodexAuthCallbackSubscribeRpc = Rpc.make(WS_METHODS.codexAuthCallbackSubscribe, { | ||
| 552 | payload: CodexAuthCallbackInput, | ||
| 553 | success: CodexAuthCallbackState, | ||
| 554 | error: ProviderSetupRpcError, | ||
| 555 | stream: true, | ||
| 556 | }); | ||
| 557 | |||
| 522 | const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, { | 558 | const 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 | }); |
| 67 | export type ServerProviderAuth = typeof ServerProviderAuth.Type; | 69 | export type ServerProviderAuth = typeof ServerProviderAuth.Type; |
| 68 | 70 | ||
| ... | @@ -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>( |
| 576 | 576 | ||
| 577 | export const CodexSettings = makeProviderSettingsSchema( | 577 | export 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. | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | import * as NodeHttp from "node:http"; | ||
| 4 | import { codexAuthorizationRequest, codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff"; | ||
| 5 | |||
| 6 | export class CodexAuthCallbackError extends Schema.TaggedError<CodexAuthCallbackError>()( | ||
| 7 | "CodexAuthCallbackError", | ||
| 8 | { detail: Schema.String }, | ||
| 9 | ) { | ||
| 10 | override get message() { | ||
| 11 | return this.detail; | ||
| 12 | } | ||
| 13 | } | ||
| 14 | |||
| 15 | const listeners = new Map<string, AbortController>(); | ||
| 16 | |||
| 17 | export 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. */ | ||
| 23 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 3 | import { | ||
| 4 | codexAuthorizationRequest, | ||
| 5 | codexAuthDeliveryUrl, | ||
| 6 | codexAuthHandoffUrl, | ||
| 7 | readCodexAuthDelivery, | ||
| 8 | readCodexAuthHandoff, | ||
| 9 | } from "./codexAuthHandoff.ts"; | ||
| 10 | |||
| 11 | const 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 | }; | ||
| 23 | const 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 | }; | ||
| 30 | const callbackUrl = `http://127.0.0.1:54213/auth/callback?state=${"a".repeat(43)}&code=one-time-code&client_id=oaiapp_test`; | ||
| 31 | |||
| 32 | describe("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 @@ | ||
| 1 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | import { providerAuthReturnUrl } from "./providerAuthReturnUrl.ts"; | ||
| 4 | |||
| 5 | export 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 | }); | ||
| 12 | export type CodexAuthHandoff = typeof CodexAuthHandoff.Type; | ||
| 13 | const 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 | |||
| 21 | const encodeHandoff = Schema.encodeSync(Schema.fromJsonString(CodexAuthHandoff)); | ||
| 22 | const decodeHandoff = Schema.decodeUnknownSync(Schema.fromJsonString(CodexAuthHandoff)); | ||
| 23 | const encodeDelivery = Schema.encodeSync(Schema.fromJsonString(Delivery)); | ||
| 24 | const decodeDelivery = Schema.decodeUnknownSync(Schema.fromJsonString(Delivery)); | ||
| 25 | |||
| 26 | /** The helper only opens OpenAI's authorize endpoint and receives a loopback callback. */ | ||
| 27 | export 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. */ | ||
| 60 | export 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 | |||
| 87 | export 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 | |||
| 93 | export 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. */ | ||
| 117 | export 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 | |||
| 134 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { providerAuthReturnUrl } from "./providerAuthReturnUrl.ts"; | ||
| 3 | |||
| 4 | describe("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 @@ | ||
| 1 | import { isLoopbackHost } from "./preview.ts"; | ||
| 2 | |||
| 3 | /** Only return to a local client or the hosted T3 client, never an arbitrary OAuth-supplied URL. */ | ||
| 4 | export 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"; |
| 8 | import { describe, expect, it } from "vite-plus/test"; | 10 | import { 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"; |
| 27 | 32 | ||
| 28 | const now = Date.parse("2026-09-03T12:00:00.000Z"); | 33 | const 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 | |||
| 1065 | describe("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 | |||
| 1128 | describe("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; |
| 24 | const HOUR = 60 * MINUTE; | 25 | const HOUR = 60 * MINUTE; |
| 25 | const DAY = 24 * HOUR; | 26 | const DAY = 24 * HOUR; |
| 26 | 27 | ||
| 28 | export const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage"; | ||
| 29 | const CHATGPT_USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded"; | ||
| 30 | |||
| 31 | export 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. */ | ||
| 36 | export 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 | |||
| 27 | export const CURSOR_USAGE_WINDOWS = [ | 57 | export 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 | >; |
| 81 | 111 | ||
| 112 | /** One destination per service, even when several accounts or environments use it. */ | ||
| 113 | export 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 | |||
| 82 | function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null { | 139 | function 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.115 | 532 | 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.15 | 538 | 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.5 | 544 | specifier: ^4.2.5 |
| 539 | version: 4.2.5 | 545 | version: 4.2.5 |
| ... | @@ -568,6 +574,9 @@ importers: | ... | @@ -568,6 +574,9 @@ importers: |
| 568 | '@types/node': | 574 | '@types/node': |
| 569 | specifier: 24.12.4 | 575 | specifier: 24.12.4 |
| 570 | version: 24.12.4 | 576 | 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.0 | 581 | specifier: ^3.4.0 |
| 573 | version: 3.4.0 | 582 | 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==} |
| 5413 | 5422 | ||
| 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==} |
| 5416 | 5428 | ||
| ... | @@ -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==} |
| 5433 | 5445 | ||
| 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==} |
| 5436 | 5451 | ||
| ... | @@ -15799,6 +15814,10 @@ snapshots: | ... | @@ -15799,6 +15814,10 @@ snapshots: |
| 15799 | dependencies: | 15814 | dependencies: |
| 15800 | '@types/node': 24.12.4 | 15815 | '@types/node': 24.12.4 |
| 15801 | 15816 | ||
| 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': {} |
| 15803 | 15822 | ||
| 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.4 | 15839 | '@types/node': 24.12.4 |
| 15821 | 15840 | ||
| 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.4 | 15845 | '@types/node': 24.12.4 |