| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; |
| 2 | import * as CodexInstallation from "../CodexInstallation.ts"; |
| 3 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; |
| 4 | import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; |
| 5 | // @effect-diagnostics nodeBuiltinImport:off |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import * as NodeOS from "node:os"; |
| 8 | import * as NodePath from "node:path"; |
| 9 | import { expect, it } from "@effect/vitest"; |
| 10 | import { EnvironmentId, ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; |
| 11 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Deferred from "effect/Deferred"; |
| 14 | import * as Fiber from "effect/Fiber"; |
| 15 | import * as FileSystem from "effect/FileSystem"; |
| 16 | import * as Layer from "effect/Layer"; |
| 17 | import * as Option from "effect/Option"; |
| 18 | import * as PlatformError from "effect/PlatformError"; |
| 19 | import * as Schema from "effect/Schema"; |
| 20 | import * as Sink from "effect/Sink"; |
| 21 | import * as Stream from "effect/Stream"; |
| 22 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 23 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 24 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 25 | |
| 26 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 27 | import * as ServerConfig from "../../config.ts"; |
| 28 | import * as ServerSettings from "../../serverSettings.ts"; |
| 29 | import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; |
| 30 | import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; |
| 31 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 32 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 33 | import * as ModelManifest from "../ModelManifest.ts"; |
| 34 | import { |
| 35 | createProviderVersionAdvisory, |
| 36 | resolveLatestProviderVersion, |
| 37 | } from "@t3tools/provider-core/server/maintenanceResolver"; |
| 38 | import { CodexDriver } from "./CodexDriver.ts"; |
| 39 | import * as CodexAdapterV2 from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 40 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 41 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 42 | import * as ProviderCredentialStore from "../ProviderCredentialStore.ts"; |
| 43 | import * as ProviderHostLive from "../ProviderHostLive.ts"; |
| 44 | |
| 45 | const layerDeps = ServerConfig.layerTest(process.cwd(), { |
| 46 | prefix: "t3-codex-driver-maintenance-", |
| 47 | }).pipe( |
| 48 | Layer.provideMerge(NodeServices.layer), |
| 49 | Layer.provideMerge(IdAllocator.layer), |
| 50 | Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), |
| 51 | Layer.provideMerge( |
| 52 | Layer.mock(CodexAdapterV2.CodexAppServerClientFactory)({ |
| 53 | open: () => Effect.die("Maintenance resolution must not open a Codex session"), |
| 54 | }), |
| 55 | ), |
| 56 | Layer.provideMerge( |
| 57 | Layer.mock(CodexInstallation.CodexInstallation)({ |
| 58 | managedDirectory: "unused-managed-installation", |
| 59 | }), |
| 60 | ), |
| 61 | Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), |
| 62 | Layer.provideMerge( |
| 63 | Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { |
| 64 | getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")), |
| 65 | }), |
| 66 | ), |
| 67 | Layer.provideMerge(ServerSettings.layerTest()), |
| 68 | Layer.provideMerge(ModelManifest.layerTest), |
| 69 | Layer.provideMerge(ResetCreditCoordinator.layerTest), |
| 70 | Layer.provideMerge( |
| 71 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({ |
| 72 | shouldRunScopeWork: () => Effect.succeed(false), |
| 73 | }), |
| 74 | ), |
| 75 | Layer.provideMerge( |
| 76 | Layer.succeed( |
| 77 | ProviderEventLoggers.ProviderEventLoggers, |
| 78 | ProviderEventLoggers.NoOpProviderEventLoggers, |
| 79 | ), |
| 80 | ), |
| 81 | Layer.provideMerge(ProviderLatestVersions.layer), |
| 82 | Layer.provideMerge( |
| 83 | Layer.succeed( |
| 84 | HttpClient.HttpClient, |
| 85 | HttpClient.make(() => Effect.die("Disabled Codex must not make an HTTP request")), |
| 86 | ), |
| 87 | ), |
| 88 | ); |
| 89 | const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); |
| 90 | |
| 91 | // The `#!/bin/sh` stub below cannot be resolved as an executable on Windows. |
| 92 | const windowsHost = HostProcess.Platform.defaultValue() === "win32"; |
| 93 | |
| 94 | const noSpawn = ChildProcessSpawner.make(() => |
| 95 | Effect.die("Disabled Codex must not spawn a process"), |
| 96 | ); |
| 97 | const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 98 | |
| 99 | it.layer(layerTest)("CodexDriver", (it) => { |
| 100 | it.effect("disconnect refreshes a restored managed account while its auth flow is idle", () => |
| 101 | Effect.gen(function* () { |
| 102 | const instanceId = ProviderInstanceId.make("restored-managed-account"); |
| 103 | const credentials = new Map<string, Uint8Array>(); |
| 104 | const secrets = ServerSecretStore.ServerSecretStore.of({ |
| 105 | get: (key) => Effect.sync(() => Option.fromUndefinedOr(credentials.get(key))), |
| 106 | set: (key, value) => |
| 107 | Effect.sync(() => { |
| 108 | credentials.set(key, value); |
| 109 | }), |
| 110 | remove: (key) => |
| 111 | Effect.sync(() => { |
| 112 | credentials.delete(key); |
| 113 | }), |
| 114 | create: () => Effect.die("unused"), |
| 115 | getOrCreateRandom: () => Effect.die("unused"), |
| 116 | }); |
| 117 | yield* Effect.gen(function* () { |
| 118 | const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId); |
| 119 | const json = yield* encodeCredentials({ |
| 120 | clientId: "oaiapp_test", |
| 121 | accessToken: "dummy-owned-access", |
| 122 | refreshToken: "dummy-refresh", |
| 123 | expiresAt: Number.MAX_SAFE_INTEGER, |
| 124 | earliestRefreshAt: null, |
| 125 | scopes: ["chatgpt.tokens.use.direct"], |
| 126 | subject: "test-user", |
| 127 | email: "account@example.test", |
| 128 | }); |
| 129 | yield* store.set(new TextEncoder().encode(json)); |
| 130 | const executable = { |
| 131 | executablePath: "/user/bin/codex", |
| 132 | managedVersionDirectory: null, |
| 133 | source: "local" as const, |
| 134 | version: "0.156.1", |
| 135 | }; |
| 136 | const installation = yield* CodexInstallation.CodexInstallation; |
| 137 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 138 | const sharedHome = NodePath.join(serverConfig.stateDir, "shared-codex-home"); |
| 139 | const launches: Array< |
| 140 | Parameters<CodexAdapterV2.CodexAppServerClientFactoryShape["open"]>[0] |
| 141 | > = []; |
| 142 | // Sign-out interrupts an account check that is still resolving the runtime. |
| 143 | // Interrupt the two startup checks that way (one is the sign-in listener's); |
| 144 | // the disconnect below must still refresh. |
| 145 | let interruptedChecks = 0; |
| 146 | const startupChecksInterrupted = yield* Deferred.make<void>(); |
| 147 | const acquire = () => |
| 148 | Effect.suspend(() => { |
| 149 | if (interruptedChecks === 2) return Effect.succeed(executable); |
| 150 | interruptedChecks += 1; |
| 151 | return ( |
| 152 | interruptedChecks === 2 |
| 153 | ? Deferred.succeed(startupChecksInterrupted, undefined) |
| 154 | : Effect.void |
| 155 | ).pipe(Effect.andThen(Effect.interrupt)); |
| 156 | }); |
| 157 | const instance = yield* CodexDriver.create({ |
| 158 | instanceId, |
| 159 | displayName: "Restored account", |
| 160 | enabled: true, |
| 161 | environment: [{ name: "OPENAI_API_KEY", value: "ambient-key", sensitive: true }], |
| 162 | config: { ...CodexDriver.defaultConfig(), setupMode: "managed", homePath: sharedHome }, |
| 163 | }).pipe( |
| 164 | Effect.provideService( |
| 165 | CodexAdapterV2.CodexAppServerClientFactory, |
| 166 | CodexAdapterV2.CodexAppServerClientFactory.of({ |
| 167 | open: (launch) => |
| 168 | Effect.sync(() => launches.push(launch)).pipe( |
| 169 | Effect.andThen(Effect.die("The fixture stops after recording the launch")), |
| 170 | ), |
| 171 | }), |
| 172 | ), |
| 173 | Effect.provideService( |
| 174 | CodexInstallation.CodexInstallation, |
| 175 | CodexInstallation.CodexInstallation.of({ |
| 176 | ...installation, |
| 177 | managedDirectory: "unused-managed-installation", |
| 178 | resolve: () => Effect.succeed(executable), |
| 179 | acquire, |
| 180 | }), |
| 181 | ), |
| 182 | Effect.provideService( |
| 183 | ChildProcessSpawner.ChildProcessSpawner, |
| 184 | ChildProcessSpawner.make(() => |
| 185 | Effect.fail( |
| 186 | PlatformError.badArgument({ |
| 187 | module: "ChildProcessSpawner", |
| 188 | method: "spawn", |
| 189 | description: "The fixture app-server is unavailable", |
| 190 | }), |
| 191 | ), |
| 192 | ), |
| 193 | ), |
| 194 | ); |
| 195 | const observedAccount = yield* Deferred.make<void>(); |
| 196 | const disconnected = yield* instance.snapshot.streamChanges.pipe( |
| 197 | Stream.tap((provider) => |
| 198 | provider.auth.status === "authenticated" |
| 199 | ? Deferred.succeed(observedAccount, undefined).pipe(Effect.asVoid) |
| 200 | : Effect.void, |
| 201 | ), |
| 202 | Stream.filter((provider) => provider.auth.status === "unauthenticated"), |
| 203 | Stream.runHead, |
| 204 | Effect.forkScoped, |
| 205 | ); |
| 206 | yield* Deferred.await(startupChecksInterrupted); |
| 207 | const restored = yield* instance.snapshot.refresh; |
| 208 | expect(restored.auth.email).toBe("account@example.test"); |
| 209 | expect(restored.runtimePaths?.homePath).toBe(sharedHome); |
| 210 | expect(restored.runtimePaths?.shadowHomePath).toContain( |
| 211 | `providers/codex/${instanceId}/shadow`, |
| 212 | ); |
| 213 | yield* Deferred.await(observedAccount); |
| 214 | // Sessions launch the T3-installed Codex with the account's token, not ambient credentials. |
| 215 | const threadId = ThreadId.make("managed-account-thread"); |
| 216 | yield* instance.orchestrationAdapter |
| 217 | .openSession({ |
| 218 | threadId, |
| 219 | providerSessionId: ProviderSessionId.make("managed-account-session"), |
| 220 | modelSelection: { instanceId, model: "gpt-5.4" }, |
| 221 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ |
| 222 | runtimeMode: "full-access", |
| 223 | interactionMode: "default", |
| 224 | cwd: serverConfig.stateDir, |
| 225 | }), |
| 226 | }) |
| 227 | .pipe(Effect.scoped, Effect.exit); |
| 228 | expect(launches).toHaveLength(1); |
| 229 | const launch = launches[0]!; |
| 230 | expect(launch.settings.binaryPath).toBe(executable.executablePath); |
| 231 | expect(launch.settings.launchArgs).toContain("openai_token_sharing"); |
| 232 | expect(launch.environment.ACCESS_TOKEN).toBe("dummy-owned-access"); |
| 233 | expect(launch.environment.OPENAI_API_KEY).toBeUndefined(); |
| 234 | expect(launch.environment.CODEX_HOME).toBe(launch.settings.homePath); |
| 235 | const before = yield* instance.auth!.subscribe("test-owner").pipe(Stream.runHead); |
| 236 | expect(Option.getOrThrow(before).phase).toBe("idle"); |
| 237 | yield* instance.auth!.logout(Effect.void); |
| 238 | const after = Option.getOrThrow(yield* Fiber.join(disconnected)); |
| 239 | expect(after.auth.status).toBe("unauthenticated"); |
| 240 | expect(after.auth.email).toBeUndefined(); |
| 241 | expect(after.installed).toBe(true); |
| 242 | expect(after.models).toEqual([]); |
| 243 | expect(Option.isNone(yield* store.get)).toBe(true); |
| 244 | }).pipe( |
| 245 | Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), |
| 246 | Effect.provideService( |
| 247 | HttpClient.HttpClient, |
| 248 | HttpClient.make((request) => |
| 249 | Effect.sync(() => { |
| 250 | expect([ |
| 251 | "https://auth.openai.com/.well-known/openid-configuration", |
| 252 | "https://auth.openai.com/revoke", |
| 253 | "https://api.openai.com/v1/models", |
| 254 | ]).toContain(request.url); |
| 255 | if (request.url.endsWith("/models")) |
| 256 | return HttpClientResponse.fromWeb(request, Response.json({ models: [] })); |
| 257 | return HttpClientResponse.fromWeb( |
| 258 | request, |
| 259 | request.url.endsWith("/revoke") |
| 260 | ? new Response(null, { status: 200 }) |
| 261 | : Response.json({ |
| 262 | issuer: "https://auth.openai.com", |
| 263 | authorization_endpoint: "https://auth.openai.com/api/accounts/authorize", |
| 264 | token_endpoint: "https://auth.openai.com/api/accounts/oauth/token", |
| 265 | jwks_uri: "https://auth.openai.com/jwks", |
| 266 | revocation_endpoint: "https://auth.openai.com/revoke", |
| 267 | }), |
| 268 | ); |
| 269 | }), |
| 270 | ), |
| 271 | ), |
| 272 | ); |
| 273 | }).pipe(Effect.scoped), |
| 274 | ); |
| 275 | |
| 276 | it.effect.skipIf(windowsHost)( |
| 277 | "runs the standalone updater against the shared home, not the shadow home", |
| 278 | () => |
| 279 | Effect.gen(function* () { |
| 280 | const fs = yield* FileSystem.FileSystem; |
| 281 | const tempDir = yield* fs |
| 282 | .makeTempDirectoryScoped({ prefix: "t3-codex-driver-" }) |
| 283 | .pipe(Effect.flatMap((directory) => fs.realPath(directory))); |
| 284 | const sharedHome = NodePath.join(tempDir, "codex-home"); |
| 285 | const shadowHome = NodePath.join(tempDir, "codex-shadow"); |
| 286 | const binaryPath = NodePath.join(sharedHome, "packages", "standalone", "bin", "codex"); |
| 287 | yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); |
| 288 | yield* fs.writeFileString(binaryPath, "#!/bin/sh\n"); |
| 289 | yield* fs.chmod(binaryPath, 0o755); |
| 290 | |
| 291 | const instance = yield* CodexDriver.create({ |
| 292 | instanceId: ProviderInstanceId.make("codex-shadow"), |
| 293 | displayName: "Codex test", |
| 294 | enabled: false, |
| 295 | environment: [], |
| 296 | config: { |
| 297 | ...CodexDriver.defaultConfig(), |
| 298 | binaryPath, |
| 299 | homePath: sharedHome, |
| 300 | shadowHomePath: shadowHome, |
| 301 | }, |
| 302 | }); |
| 303 | |
| 304 | const capabilities = yield* instance.snapshot.resolveMaintenance(); |
| 305 | expect(capabilities.update).toMatchObject({ |
| 306 | executable: binaryPath, |
| 307 | args: ["update"], |
| 308 | lockKey: "codex-native", |
| 309 | env: { CODEX_HOME: sharedHome }, |
| 310 | }); |
| 311 | }).pipe( |
| 312 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), |
| 313 | Effect.scoped, |
| 314 | ), |
| 315 | ); |
| 316 | |
| 317 | it.effect("stays manual-only when the configured executable does not exist", () => |
| 318 | Effect.gen(function* () { |
| 319 | const instance = yield* CodexDriver.create({ |
| 320 | instanceId: ProviderInstanceId.make("codex-missing"), |
| 321 | displayName: "Codex test", |
| 322 | enabled: false, |
| 323 | environment: [], |
| 324 | config: { |
| 325 | ...CodexDriver.defaultConfig(), |
| 326 | binaryPath: NodePath.join(NodeOS.tmpdir(), "t3-codex-missing", "codex"), |
| 327 | }, |
| 328 | }); |
| 329 | expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); |
| 330 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), |
| 331 | ); |
| 332 | |
| 333 | for (const fixture of [ |
| 334 | { |
| 335 | name: "leaves mise npm-backend installations manual-only", |
| 336 | installSegments: ["mise", "installs", "npm-openai-codex", "0.110.0"], |
| 337 | npmOwned: false, |
| 338 | }, |
| 339 | { |
| 340 | name: "leaves mise tool aliases backed by npm manual-only", |
| 341 | installSegments: ["mise", "installs", "codex", "0.110.0"], |
| 342 | npmOwned: false, |
| 343 | }, |
| 344 | { |
| 345 | name: "keeps npm updates for globals in a mise Node installation", |
| 346 | installSegments: ["mise", "installs", "node", "24.0.0"], |
| 347 | npmOwned: true, |
| 348 | }, |
| 349 | { |
| 350 | name: "keeps npm updates for ordinary global installations", |
| 351 | installSegments: ["npm-global"], |
| 352 | npmOwned: true, |
| 353 | }, |
| 354 | ] as const) { |
| 355 | it.effect.skipIf(windowsHost)(fixture.name, () => |
| 356 | Effect.gen(function* () { |
| 357 | const fs = yield* FileSystem.FileSystem; |
| 358 | const tempDir = yield* fs |
| 359 | .makeTempDirectoryScoped({ prefix: "t3-codex-installer-" }) |
| 360 | .pipe(Effect.flatMap((directory) => fs.realPath(directory))); |
| 361 | const installPath = NodePath.join(tempDir, ...fixture.installSegments); |
| 362 | const realBinaryPath = NodePath.join( |
| 363 | installPath, |
| 364 | "lib", |
| 365 | "node_modules", |
| 366 | "@openai", |
| 367 | "codex", |
| 368 | "bin", |
| 369 | "codex.js", |
| 370 | ); |
| 371 | const binaryPath = NodePath.join(tempDir, "bin", "codex"); |
| 372 | yield* fs.makeDirectory(NodePath.dirname(realBinaryPath), { recursive: true }); |
| 373 | yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); |
| 374 | yield* fs.writeFileString(realBinaryPath, "#!/bin/sh\n"); |
| 375 | yield* fs.chmod(realBinaryPath, 0o755); |
| 376 | yield* fs.symlink(realBinaryPath, binaryPath); |
| 377 | |
| 378 | const instance = yield* CodexDriver.create({ |
| 379 | instanceId: ProviderInstanceId.make("codex-installer"), |
| 380 | displayName: "Codex installer test", |
| 381 | enabled: false, |
| 382 | environment: [], |
| 383 | config: { |
| 384 | ...CodexDriver.defaultConfig(), |
| 385 | binaryPath, |
| 386 | homePath: NodePath.join(tempDir, "codex-home"), |
| 387 | }, |
| 388 | }); |
| 389 | |
| 390 | const update = (yield* instance.snapshot.resolveMaintenance()).update; |
| 391 | if (fixture.npmOwned) { |
| 392 | expect(update).toMatchObject({ |
| 393 | executable: "npm", |
| 394 | args: [ |
| 395 | "install", |
| 396 | "-g", |
| 397 | "--prefix", |
| 398 | installPath, |
| 399 | "--allow-scripts=@openai/codex", |
| 400 | "@openai/codex@latest", |
| 401 | ], |
| 402 | }); |
| 403 | } else { |
| 404 | expect(update).toBeNull(); |
| 405 | } |
| 406 | }).pipe( |
| 407 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), |
| 408 | Effect.scoped, |
| 409 | ), |
| 410 | ); |
| 411 | } |
| 412 | |
| 413 | for (const layout of ["direct", "wrapper"] as const) { |
| 414 | it.effect.skipIf(windowsHost)(`leaves a mise ${layout} installation manual-only`, () => |
| 415 | Effect.gen(function* () { |
| 416 | const fs = yield* FileSystem.FileSystem; |
| 417 | const tempDir = yield* fs |
| 418 | .makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` }) |
| 419 | .pipe(Effect.flatMap((directory) => fs.realPath(directory))); |
| 420 | const binaryPath = |
| 421 | layout === "direct" |
| 422 | ? NodePath.join(tempDir, "mise", "installs", "codex", "0.110.0", "codex") |
| 423 | : NodePath.join(tempDir, "omarchy", "bin", "codex"); |
| 424 | yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); |
| 425 | yield* fs.writeFileString( |
| 426 | binaryPath, |
| 427 | layout === "direct" |
| 428 | ? "#!/bin/sh\n" |
| 429 | : '#!/bin/sh\nmise use -g --quiet "codex" || exit 1\nexec mise x "codex" -- "codex" "$@"\n', |
| 430 | ); |
| 431 | yield* fs.chmod(binaryPath, 0o755); |
| 432 | |
| 433 | const instance = yield* CodexDriver.create({ |
| 434 | instanceId: ProviderInstanceId.make(`codex-mise-${layout}`), |
| 435 | displayName: "Codex mise test", |
| 436 | enabled: false, |
| 437 | environment: [], |
| 438 | config: { |
| 439 | ...CodexDriver.defaultConfig(), |
| 440 | binaryPath, |
| 441 | homePath: NodePath.join(tempDir, "codex-home"), |
| 442 | }, |
| 443 | }); |
| 444 | |
| 445 | expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); |
| 446 | }).pipe( |
| 447 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), |
| 448 | Effect.scoped, |
| 449 | ), |
| 450 | ); |
| 451 | } |
| 452 | |
| 453 | it.effect.each([ |
| 454 | { |
| 455 | name: "conventional shim", |
| 456 | dataRoot: "mise", |
| 457 | commandName: "codex", |
| 458 | version: "0.153.4", |
| 459 | nodeFirst: false, |
| 460 | }, |
| 461 | { |
| 462 | name: "custom data directory", |
| 463 | dataRoot: "custom-tool-data", |
| 464 | commandName: "codex", |
| 465 | version: "0.153.4", |
| 466 | nodeFirst: false, |
| 467 | }, |
| 468 | { |
| 469 | name: "renamed configured command", |
| 470 | dataRoot: "mise", |
| 471 | commandName: "custom-codex", |
| 472 | version: "0.153.4", |
| 473 | nodeFirst: false, |
| 474 | }, |
| 475 | { |
| 476 | name: "outdated provider", |
| 477 | dataRoot: "mise", |
| 478 | commandName: "codex", |
| 479 | version: "0.153.3", |
| 480 | nodeFirst: false, |
| 481 | }, |
| 482 | { |
| 483 | name: "npm before shim", |
| 484 | dataRoot: "mise", |
| 485 | commandName: "codex", |
| 486 | version: "0.153.4", |
| 487 | nodeFirst: true, |
| 488 | }, |
| 489 | ])( |
| 490 | "does not mistake Homebrew mise for Codex's installer: $name", |
| 491 | (fixture) => |
| 492 | Effect.gen(function* () { |
| 493 | const fs = yield* FileSystem.FileSystem; |
| 494 | const tempDir = yield* fs |
| 495 | .makeTempDirectoryScoped({ prefix: "t3-codex-mise-shim-" }) |
| 496 | .pipe(Effect.flatMap((directory) => fs.realPath(directory))); |
| 497 | const brewPrefix = NodePath.join(tempDir, "homebrew"); |
| 498 | const brewPath = NodePath.join(brewPrefix, "bin", "brew"); |
| 499 | const misePath = NodePath.join(brewPrefix, "Cellar", "mise", "2026.9.1", "bin", "mise"); |
| 500 | const shimDir = NodePath.join(tempDir, fixture.dataRoot, "shims"); |
| 501 | const npmPrefix = NodePath.join(tempDir, "mise", "installs", "node", "24.13.0"); |
| 502 | const npmBin = NodePath.join(npmPrefix, "bin"); |
| 503 | const npmEntry = NodePath.join( |
| 504 | npmPrefix, |
| 505 | "lib", |
| 506 | "node_modules", |
| 507 | "@openai", |
| 508 | "codex", |
| 509 | "bin", |
| 510 | "codex.js", |
| 511 | ); |
| 512 | for (const file of [brewPath, misePath, npmEntry]) { |
| 513 | yield* fs.makeDirectory(NodePath.dirname(file), { recursive: true }); |
| 514 | yield* fs.writeFileString(file, "#!/bin/sh\n"); |
| 515 | yield* fs.chmod(file, 0o755); |
| 516 | } |
| 517 | yield* fs.makeDirectory(shimDir, { recursive: true }); |
| 518 | yield* fs.makeDirectory(npmBin, { recursive: true }); |
| 519 | yield* fs.symlink(misePath, NodePath.join(shimDir, fixture.commandName)); |
| 520 | yield* fs.symlink(npmEntry, NodePath.join(npmBin, fixture.commandName)); |
| 521 | const lookupPath = [ |
| 522 | ...(fixture.nodeFirst ? [npmBin, shimDir] : [shimDir, npmBin]), |
| 523 | NodePath.dirname(brewPath), |
| 524 | ].join(NodePath.delimiter); |
| 525 | const probes: Array<ReadonlyArray<string>> = []; |
| 526 | const metadataSpawner = ChildProcessSpawner.make((command) => { |
| 527 | if (!ChildProcess.isStandardCommand(command) || command.command !== brewPath) { |
| 528 | return Effect.die("Provider resolution must not execute a provider or updater"); |
| 529 | } |
| 530 | probes.push(command.args); |
| 531 | const stdout = |
| 532 | command.args[0] === "--prefix" |
| 533 | ? brewPrefix |
| 534 | : JSON.stringify({ formulae: [{ versions: { stable: "2026.9.1" } }] }); |
| 535 | return Effect.succeed( |
| 536 | ChildProcessSpawner.makeHandle({ |
| 537 | pid: ChildProcessSpawner.ProcessId(1), |
| 538 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), |
| 539 | isRunning: Effect.succeed(false), |
| 540 | kill: () => Effect.void, |
| 541 | unref: Effect.succeed(Effect.void), |
| 542 | stdin: Sink.drain, |
| 543 | stdout: Stream.encodeText(Stream.make(stdout)), |
| 544 | stderr: Stream.empty, |
| 545 | all: Stream.empty, |
| 546 | getInputFd: () => Sink.drain, |
| 547 | getOutputFd: () => Stream.empty, |
| 548 | }), |
| 549 | ); |
| 550 | }); |
| 551 | const instance = yield* CodexDriver.create({ |
| 552 | instanceId: ProviderInstanceId.make("codex-mise-shim"), |
| 553 | displayName: "Codex shim test", |
| 554 | enabled: false, |
| 555 | environment: [{ name: "PATH", value: lookupPath, sensitive: false }], |
| 556 | config: { |
| 557 | ...CodexDriver.defaultConfig(), |
| 558 | binaryPath: fixture.commandName, |
| 559 | homePath: NodePath.join(tempDir, "codex-home"), |
| 560 | }, |
| 561 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, metadataSpawner)); |
| 562 | const capabilities = yield* instance.snapshot.resolveMaintenance(); |
| 563 | const latestVersion = yield* resolveLatestProviderVersion(capabilities).pipe( |
| 564 | Effect.provideServiceEffect( |
| 565 | ProviderLatestVersions.ProviderLatestVersions, |
| 566 | ProviderLatestVersions.make([["@openai/codex", "0.153.4"]]), |
| 567 | ), |
| 568 | ); |
| 569 | expect(probes).toEqual([]); |
| 570 | expect(latestVersion).toBe("0.153.4"); |
| 571 | expect( |
| 572 | createProviderVersionAdvisory({ |
| 573 | driver: CodexDriver.driverKind, |
| 574 | currentVersion: fixture.version, |
| 575 | latestVersion, |
| 576 | maintenanceCapabilities: capabilities, |
| 577 | }), |
| 578 | ).toMatchObject({ |
| 579 | status: fixture.version === "0.153.4" ? "current" : "behind_latest", |
| 580 | currentVersion: fixture.version, |
| 581 | latestVersion: "0.153.4", |
| 582 | canUpdate: fixture.nodeFirst, |
| 583 | }); |
| 584 | if (fixture.nodeFirst) { |
| 585 | expect(capabilities.update).toMatchObject({ |
| 586 | executable: "npm", |
| 587 | args: expect.arrayContaining([ |
| 588 | "--prefix", |
| 589 | yield* fs.realPath(npmPrefix), |
| 590 | "@openai/codex@latest", |
| 591 | ]), |
| 592 | }); |
| 593 | } else { |
| 594 | expect(capabilities.update).toBeNull(); |
| 595 | } |
| 596 | }).pipe(Effect.scoped), |
| 597 | { skip: windowsHost }, |
| 598 | ); |
| 599 | }); |