1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import * as CodexInstallation from "../CodexInstallation.ts";
3import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
4import * as ServerEnvironment from "../../environment/ServerEnvironment.ts";
5// @effect-diagnostics nodeBuiltinImport:off
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as NodeOS from "node:os";
8import * as NodePath from "node:path";
9import { expect, it } from "@effect/vitest";
10import { EnvironmentId, ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12import * as Effect from "effect/Effect";
13import * as Deferred from "effect/Deferred";
14import * as Fiber from "effect/Fiber";
15import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as PlatformError from "effect/PlatformError";
19import * as Schema from "effect/Schema";
20import * as Sink from "effect/Sink";
21import * as Stream from "effect/Stream";
22import { HttpClient, HttpClientResponse } from "effect/http";
23import * as ChildProcess from "effect/process/ChildProcess";
24import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
25
26import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
27import * as ServerConfig from "../../config.ts";
28import * as ServerSettings from "../../serverSettings.ts";
29import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";
30import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
31import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
32import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
33import * as ModelManifest from "../ModelManifest.ts";
34import {
35 createProviderVersionAdvisory,
36 resolveLatestProviderVersion,
37} from "@t3tools/provider-core/server/maintenanceResolver";
38import { CodexDriver } from "./CodexDriver.ts";
39import * as CodexAdapterV2 from "../../orchestration-v2/Adapters/CodexAdapterV2.ts";
40import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
41import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
42import * as ProviderCredentialStore from "../ProviderCredentialStore.ts";
43import * as ProviderHostLive from "../ProviderHostLive.ts";
44
45const 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);
89const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps));
90
91// The `#!/bin/sh` stub below cannot be resolved as an executable on Windows.
92const windowsHost = HostProcess.Platform.defaultValue() === "win32";
93
94const noSpawn = ChildProcessSpawner.make(() =>
95 Effect.die("Disabled Codex must not spawn a process"),
96);
97const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
98
99it.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});