1/**
2 * CodexDriver — first concrete `ProviderDriver` in the new per-instance model.
3 *
4 * A driver is a plain value (not a Context.Service) whose `create()` returns
5 * one `ProviderInstance` bundling:
6 * - `snapshot` — the live `ManagedServerProvider` for this instance;
7 * - `adapter` — the Codex session/turn/approval runtime;
8 * - `textGeneration` — commit/PR/branch/title generation via `codex exec`.
9 *
10 * Each call to `create()` captures the `codexConfig` argument in closures
11 * owned by the returned instance. Two instances created with different
12 * `homePath`s (e.g. `codex_personal` + `codex_work`) therefore run with
13 * fully independent Codex app-server processes and `CODEX_HOME`
14 * environments — no shared mutable state.
15 *
16 * Resource lifecycle: `create()` runs in a scope handed in by the registry.
17 * Closing that scope releases the adapter's child processes, the managed
18 * snapshot's refresh fibre, and the text-generation binaries' transient
19 * scratch files. The registry uses this to tear down an instance when its
20 * `providerInstances` entry disappears or its config changes.
21 *
22 * @module provider/Drivers/CodexDriver
23 */
24import { CodexSettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";
25import * as Crypto from "effect/Crypto";
26import * as Effect from "effect/Effect";
27import * as FileSystem from "effect/FileSystem";
28import * as Path from "effect/Path";
29import * as Schema from "effect/Schema";
30import * as Result from "effect/Result";
31import { HttpClient } from "effect/http";
32import { ChildProcessSpawner } from "effect/process";
33
34import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
35import * as ManagedProviderHome from "../ManagedProviderHome.ts";
36import * as SharedAccountClient from "../SharedAccountClient.ts";
37import { authenticateSharedCodex } from "../SharedCodexAuth.ts";
38import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
39import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
40import * as ServerConfig from "../../config.ts";
41import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
42import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
43import {
44 createCodexAdapterV2,
45 type CodexAdapterV2DriverEnv,
46} from "../../orchestration-v2/Adapters/CodexAdapterV2.ts";
47import { ProviderDriverError } from "../Errors.ts";
48import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";
49import {
50 checkCodexProviderStatus,
51 makePendingCodexProvider,
52 probeCodexSkillsForCwd,
53 withCodexAppServerClient,
54} from "../CodexProvider.ts";
55import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts";
56import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
57import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
58import { applyCodexModelCatalog } from "../codexModelCatalog.ts";
59import type { ProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver";
60import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
61import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
62import {
63 enrichProviderSnapshotWithVersionAdvisory,
64 makeCachedProviderMaintenanceResolution,
65 makePackageManagedProviderMaintenanceResolver,
66 normalizeCommandPath,
67 resolveProviderMaintenanceCapabilitiesEffect,
68} from "@t3tools/provider-core/server/maintenanceResolver";
69import {
70 haveProviderSnapshotSettingsChanged,
71 makeProviderSnapshotSettingsSource,
72 type ProviderSnapshotSettings,
73} from "@t3tools/provider-core/server/snapshotSettings";
74import {
75 codexContinuationIdentity,
76 materializeCodexShadowHome,
77 resolveCodexHomeLayout,
78} from "./CodexHomeLayout.ts";
79import { makeManagedCodexProvider } from "./CodexManagedProvider.ts";
80import { codexUsageReader } from "./codexUsage.ts";
81import * as CodexInstallation from "../CodexInstallation.ts";
82import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
83import * as ServerEnvironment from "../../environment/ServerEnvironment.ts";
84import * as HostProcess from "@t3tools/shared/HostProcess";
85const decodeCodexSettings = Schema.decodeSync(CodexSettings);
86
87const DRIVER_KIND = ProviderDriverKind.make("codex");
88// The standalone installer lays out `<CODEX_HOME>/packages/standalone/…`;
89// CODEX_HOME is not always `~/.codex`.
90function isCodexStandaloneCommandPath(commandPath: string): boolean {
91 return normalizeCommandPath(commandPath).includes("/packages/standalone/");
92}
93
94/**
95 * `codex update` replaces the standalone tree under `CODEX_HOME`. That tree
96 * lives in the shared home even when an auth-overlay shadow home is in use
97 * (the overlay only carries auth and a few local entries), so the updater
98 * runs against `sharedHomePath` rather than the instance's effective home.
99 */
100function makeCodexMaintenanceResolver(sharedHomePath: string) {
101 return makePackageManagedProviderMaintenanceResolver({
102 provider: DRIVER_KIND,
103 npmPackageName: "@openai/codex",
104 nativeUpdate: {
105 args: ["update"],
106 isCommandPath: isCodexStandaloneCommandPath,
107 env: { CODEX_HOME: sharedHomePath },
108 },
109 });
110}
111
112/**
113 * Services the driver needs to materialize an instance. Surfaced as the
114 * driver's `R` so the registry layer aggregates these across every
115 * registered driver and the runtime satisfies them once.
116 */
117export type CodexDriverEnv =
118 | ManagedProviderHome.ManagedProviderHome
119 | SharedAccountClient.SharedAccountClient
120 | CodexAdapterV2DriverEnv
121 | ProviderHost.ProviderHost
122 | ChildProcessSpawner.ChildProcessSpawner
123 | ResetCreditCoordinator.ResetCreditCoordinator
124 | Crypto.Crypto
125 | FileSystem.FileSystem
126 | HttpClient.HttpClient
127 | ProviderLatestVersions.ProviderLatestVersions
128 | ModelCatalog.ModelCatalog
129 | Path.Path
130 | ProviderEventLoggers.ProviderEventLoggers
131 | ServerConfig.ServerConfig
132 | ServerSecretStore.ServerSecretStore
133 | ServerEnvironment.ServerEnvironmentIdentity
134 | CodexInstallation.CodexInstallation;
135
136export const CodexDriver: ProviderDriver<
137 CodexSettings,
138 CodexDriverEnv,
139 Path.Path | ServerConfig.ServerConfig
140> = {
141 driverKind: DRIVER_KIND,
142 metadata: {
143 displayName: "Codex",
144 supportsMultipleInstances: true,
145 },
146 configSchema: CodexSettings,
147 defaultConfig: (): CodexSettings => decodeCodexSettings({}),
148 usage: codexUsageReader,
149 create: ({ instanceId, displayName, accentColor, environment, enabled, config: configured }) =>
150 Effect.gen(function* () {
151 const sharedId = configured.sharedAccountId;
152 const managedHomes = sharedId ? yield* ManagedProviderHome.ManagedProviderHome : undefined;
153 const sharedAccounts = sharedId ? yield* SharedAccountClient.SharedAccountClient : undefined;
154 const managedPaths =
155 sharedId && managedHomes
156 ? yield* managedHomes.paths("codex", sharedId).pipe(
157 Effect.mapError(
158 (cause) =>
159 new ProviderDriverError({
160 driver: DRIVER_KIND,
161 instanceId,
162 detail: cause.message,
163 cause,
164 }),
165 ),
166 )
167 : undefined;
168 const config = managedPaths
169 ? {
170 ...configured,
171 setupMode: "existing" as const,
172 homePath: managedPaths.shared,
173 shadowHomePath: managedPaths.account,
174 }
175 : configured;
176 if (config.setupMode === "managed")
177 return yield* makeManagedCodexProvider({
178 instanceId,
179 displayName,
180 accentColor,
181 environment,
182 enabled,
183 config,
184 });
185 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
186 const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator;
187 const fileSystem = yield* FileSystem.FileSystem;
188 const pathService = yield* Path.Path;
189 const httpClient = yield* HttpClient.HttpClient;
190 const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
191 const modelCatalog = yield* ModelCatalog.ModelCatalog;
192 const currentCatalog = modelCatalog.current(DRIVER_KIND);
193 const processEnv = yield* mergeProviderInstanceEnvironment(environment);
194 const homeLayout = yield* resolveCodexHomeLayout(config);
195 const continuationIdentity = codexContinuationIdentity(homeLayout);
196 const stampIdentity = withInstanceIdentity({
197 instanceId,
198 driverKind: DRIVER_KIND,
199 displayName,
200 accentColor,
201 continuationGroupKey: continuationIdentity.continuationKey,
202 });
203 yield* materializeCodexShadowHome(homeLayout).pipe(
204 Effect.mapError(
205 (cause) =>
206 new ProviderDriverError({
207 driver: DRIVER_KIND,
208 instanceId,
209 detail: cause.message,
210 cause,
211 }),
212 ),
213 );
214 const effectiveConfig = {
215 ...config,
216 enabled,
217 binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory),
218 homePath: homeLayout.effectiveHomePath ?? "",
219 } satisfies CodexSettings;
220 const resolveSharedRuntime =
221 sharedId && managedHomes
222 ? managedHomes.prepare("codex", sharedId).pipe(
223 Effect.map((credentials) => ({
224 config: effectiveConfig,
225 environment: processEnv,
226 revision: credentials.accessToken,
227 })),
228 Effect.mapError(
229 (cause) =>
230 new ProviderSetupError({
231 instanceId,
232 operation: "shared-account",
233 detail: cause.message,
234 }),
235 ),
236 )
237 : undefined;
238 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
239 resolveProviderMaintenanceCapabilitiesEffect(
240 makeCodexMaintenanceResolver(homeLayout.sharedHomePath),
241 {
242 binaryPath: effectiveConfig.binaryPath,
243 env: processEnv,
244 },
245 ).pipe(
246 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
247 Effect.provideService(FileSystem.FileSystem, fileSystem),
248 Effect.provideService(Path.Path, pathService),
249 ),
250 );
251
252 const orchestrationAdapter = yield* createCodexAdapterV2(
253 {
254 instanceId,
255 displayName,
256 accentColor,
257 environment,
258 enabled,
259 config,
260 },
261 {
262 onUsageLimits: (update) => snapshot.applyUsageLimits(update),
263 ...(resolveSharedRuntime ? { resolveRuntime: resolveSharedRuntime } : {}),
264 ...(sharedId && sharedAccounts
265 ? {
266 authenticate: (client) =>
267 authenticateSharedCodex(client, (force) =>
268 sharedAccounts.access(sharedId, force),
269 ),
270 }
271 : {}),
272 },
273 ).pipe(
274 Effect.mapError(
275 (cause) =>
276 new ProviderDriverError({
277 driver: DRIVER_KIND,
278 instanceId,
279 detail: "Failed to build Codex orchestration adapter.",
280 cause,
281 }),
282 ),
283 );
284
285 // Build a managed snapshot whose settings never change — mutations come
286 // in as instance rebuilds from the registry rather than in-place
287 // updates. Pre-provide `ChildProcessSpawner` so the check fits
288 // `makeManagedServerProvider.checkProvider`'s `R = never`.
289 // Kick the TTL-gated manifest refresh in the background and classify
290 // with the in-memory manifest, so a slow or hung fetch never delays the
291 // provider check. A refresh that lands mid-probe applies on the next one.
292 const checkProvider = Effect.gen(function* () {
293 const readiness = yield* (resolveSharedRuntime ?? Effect.void).pipe(Effect.result);
294 yield* modelCatalog.refreshInBackground;
295 return yield* Effect.zipWith(
296 checkCodexProviderStatus(effectiveConfig, undefined, processEnv),
297 currentCatalog,
298 (draft, catalog) =>
299 stampIdentity({
300 ...applyCodexModelCatalog(draft, catalog),
301 ...(Result.isFailure(readiness)
302 ? {
303 status: "error" as const,
304 auth: { status: "unknown" as const },
305 message: readiness.failure.message,
306 }
307 : {}),
308 }),
309 { concurrent: true },
310 );
311 }).pipe(
312 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
313 Effect.provideService(FileSystem.FileSystem, fileSystem),
314 Effect.provideService(Path.Path, pathService),
315 );
316 const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig);
317 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CodexSettings>>({
318 resolveMaintenance,
319 getSettings: snapshotSettings.getSettings,
320 streamSettings: snapshotSettings.streamSettings,
321 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
322 initialSnapshot: (settings) =>
323 Effect.zipWith(
324 makePendingCodexProvider(settings.provider),
325 currentCatalog,
326 (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)),
327 ),
328 checkProvider,
329 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
330 resolveMaintenance().pipe(
331 Effect.flatMap((maintenanceCapabilities) =>
332 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
333 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
334 }),
335 ),
336 Effect.provideService(HttpClient.HttpClient, httpClient),
337 Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions),
338 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
339 ),
340 }).pipe(
341 Effect.mapError(
342 (cause) =>
343 new ProviderDriverError({
344 driver: DRIVER_KIND,
345 instanceId,
346 detail: `Failed to build Codex snapshot: ${cause.message ?? String(cause)}`,
347 cause,
348 }),
349 ),
350 );
351 const textGeneration = yield* makeCodexTextGeneration(
352 effectiveConfig,
353 processEnv,
354 snapshot.getSnapshot.pipe(Effect.map((value) => value.models)),
355 resolveSharedRuntime,
356 );
357 const snapshotForCwd = (cwd: string) =>
358 !effectiveConfig.enabled
359 ? snapshot.getSnapshot
360 : Effect.all([
361 snapshot.getSnapshot,
362 probeCodexSkillsForCwd({
363 binaryPath: effectiveConfig.binaryPath,
364 homePath: effectiveConfig.homePath,
365 launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv),
366 cwd,
367 environment: processEnv,
368 }).pipe(
369 Effect.scoped,
370 Effect.timeout("20 seconds"),
371 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
372 ),
373 ]).pipe(
374 Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })),
375 Effect.mapError(
376 (cause) =>
377 new ProviderDriverError({
378 driver: DRIVER_KIND,
379 instanceId,
380 detail: `Failed to probe Codex skills for '${cwd}'`,
381 cause,
382 }),
383 ),
384 );
385
386 // Redemption spends something on the user's account. It serialises on
387 // the account (instances sharing a Codex home share the credit), keeps
388 // one idempotency key until Codex reports an outcome, and is bounded so
389 // a hung app-server cannot hold the account lock.
390 // Keyed on the directory holding auth.json: an auth-overlay instance has
391 // its own account under `effectiveHomePath`, while plain instances share
392 // the common home. The continuation key would conflate the two.
393 const accountKey = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath;
394 const consumeResetCredit: NonNullable<ProviderInstance["consumeResetCredit"]> = () =>
395 resetCreditCoordinator
396 .redeem(accountKey, (idempotencyKey) =>
397 Effect.gen(function* () {
398 if (resolveSharedRuntime) yield* resolveSharedRuntime;
399 const { client } = yield* withCodexAppServerClient({
400 binaryPath: effectiveConfig.binaryPath,
401 homePath: effectiveConfig.homePath,
402 launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv),
403 // Account-level request; any directory serves, same as the status probe.
404 cwd: process.cwd(),
405 environment: processEnv,
406 });
407 if (sharedId && sharedAccounts)
408 yield* authenticateSharedCodex(client, (force) =>
409 sharedAccounts.access(sharedId, force),
410 );
411 const response = yield* client.request("account/rateLimitResetCredit/consume", {
412 idempotencyKey,
413 });
414 return response.outcome;
415 }).pipe(Effect.scoped, Effect.timeout("20 seconds")),
416 )
417 .pipe(
418 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
419 Effect.mapError(
420 (cause) =>
421 new ProviderDriverError({
422 driver: DRIVER_KIND,
423 instanceId,
424 detail: "Codex could not redeem the reset credit.",
425 cause,
426 }),
427 ),
428 // The windows just changed; re-probe so the snapshot says so. A
429 // failed probe republishes the pre-redemption limits rather than
430 // marking them failed, so "confirmed" means `checkedAt` moved
431 // past what was published before the redemption started. Only a
432 // reset claims the limits changed, so only a reset reports an
433 // unconfirmed refresh.
434 Effect.tap((outcome) =>
435 Effect.gen(function* () {
436 const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt;
437 const refreshed = yield* snapshot.refresh;
438 const after = refreshed.usageLimits?.checkedAt;
439 if (
440 outcome === "reset" &&
441 (after === undefined ||
442 after === before ||
443 refreshed.usageLimits?.unavailable?.reason === "probeFailed")
444 ) {
445 return yield* new ProviderDriverError({
446 driver: DRIVER_KIND,
447 instanceId,
448 detail:
449 "The reset was applied, but Codex could not confirm the new limits. Refresh to check.",
450 });
451 }
452 }),
453 ),
454 );
455
456 return {
457 instanceId,
458 driverKind: DRIVER_KIND,
459 continuationIdentity,
460 displayName,
461 accentColor,
462 enabled,
463 snapshot,
464 snapshotForCwd,
465 consumeResetCredit,
466 orchestrationAdapter,
467 textGeneration,
468 } satisfies ProviderInstance;
469 }),
470};