Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest.
2import {
3 ProviderDriverKind,
4 type ProviderInstallState,
5 type ServerProviderCompatibilityAdvisory,
6} from "@t3tools/contracts";
7import { cliArchiveTarCommand, parseChecksums } from "@t3tools/shared/cliRelease";
8import { compareSemverVersions } from "@t3tools/shared/semver";
9import * as HostProcess from "@t3tools/shared/HostProcess";
10import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell";
11import * as Clock from "effect/Clock";
12import * as Cause from "effect/Cause";
13import * as Context from "effect/Context";
14import * as SecureRandom from "effect/crypto/SecureRandom";
15import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";
17import * as Fiber from "effect/Fiber";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Path from "effect/Path";
22import * as Schedule from "effect/Schedule";
23import * as Schema from "effect/Schema";
24import * as Scope from "effect/Scope";
25import * as Semaphore from "effect/Semaphore";
26import * as Stream from "effect/Stream";
27import * as SubscriptionRef from "effect/SubscriptionRef";
28import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
29import { ChildProcess, ChildProcessSpawner } from "effect/process";
30import * as NodeCrypto from "node:crypto";
31import * as ServerConfig from "../config.ts";
32import { selectManagedRelease } from "./managedRelease.ts";
33import * as ModelManifest from "./ModelManifest.ts";
34import { resolveProviderCompatibility } from "./providerCompatibility.ts";
35
36const DRIVER = ProviderDriverKind.make("codex");
37const STAGING_STALE_AFTER_MS = 24 * 60 * 60_000;
38const UPDATE_CHECK_INTERVAL_MS = 60 * 60_000;
39const UPDATE_CHECK_TIMEOUT = "20 seconds";
40const UPSTREAM_LATEST_URL = "https://registry.npmjs.org/@openai/codex/latest";
41const UPSTREAM_RELEASES_URL = "https://github.com/openai/codex/releases/download";
42// An upstream release has no recorded size; current packages are about 160 MB.
43const UPSTREAM_MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024;
44const Version = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/u));
45const ActiveRelease = Schema.Struct({ version: Version });
46const InstalledRelease = Schema.Struct({
47 version: Version,
48 target: Schema.String,
49 sha256: Schema.String,
50});
51const PackageManifest = Schema.Struct({
52 layoutVersion: Schema.Literal(1),
53 version: Version,
54 target: Schema.String,
55 entrypoint: Schema.String,
56});
57const decodeVersion = Schema.decodeUnknownEffect(Version);
58const decodeNpmLatest = Schema.decodeUnknownEffect(Schema.Struct({ version: Version }));
59const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease));
60const encodeActive = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease));
61export interface CodexReleaseAsset {
62 readonly version: string;
63 readonly target: string;
64 readonly url: string;
65 readonly sha256: string;
66 /** Null for an upstream release, whose package checksum is published without a size. */
67 readonly archiveBytes: number | null;
68}
69const RELEASES: Readonly<Record<string, CodexReleaseAsset>> = {
70 "darwin-arm64": {
71 version: "0.166.0",
72 target: "aarch64-apple-darwin",
73 url: "https://github.com/openai/codex/releases/download/rust-v0.166.0/codex-package-aarch64-apple-darwin.tar.gz",
74 sha256: "55a440cb579f970abe7c79ab181e54b0ea4c419be99f333bbd868eebac9c21f0",
75 archiveBytes: 165951869,
76 },
77 "darwin-x64": {
78 version: "0.166.0",
79 target: "x86_64-apple-darwin",
80 url: "https://github.com/openai/codex/releases/download/rust-v0.166.0/codex-package-x86_64-apple-darwin.tar.gz",
81 sha256: "1cb65690230be5982c654ba2655e3a3b594dce9c55b780e78bfdfeff829d7ad9",
82 archiveBytes: 180076524,
83 },
84 "linux-arm64": {
85 version: "0.166.0",
86 target: "aarch64-unknown-linux-musl",
87 url: "https://github.com/openai/codex/releases/download/rust-v0.166.0/codex-package-aarch64-unknown-linux-musl.tar.gz",
88 sha256: "7821b77dda2e6283fb1f63f3165177a271da8833e857cf673c6ca1c9db3bfcc6",
89 archiveBytes: 187370368,
90 },
91 "linux-x64": {
92 version: "0.166.0",
93 target: "x86_64-unknown-linux-musl",
94 url: "https://github.com/openai/codex/releases/download/rust-v0.166.0/codex-package-x86_64-unknown-linux-musl.tar.gz",
95 sha256: "1d44b28bc4ac9937d7d3532264baa68a507ce364956a0d4ebb856f331cdee254",
96 archiveBytes: 199694962,
97 },
98 "win32-arm64": {
99 version: "0.166.0",
100 target: "aarch64-pc-windows-msvc",
101 url: "https://github.com/openai/codex/releases/download/rust-v0.166.0/codex-package-aarch64-pc-windows-msvc.tar.gz",
102 sha256: "9383152a72f97bb5dd148493c8214e4fed38d805aa2c3297cb3739f7f130becf",
103 archiveBytes: 187024318,
104 },
105 "win32-x64": {
106 version: "0.166.0",
107 target: "x86_64-pc-windows-msvc",
108 url: "https://github.com/openai/codex/releases/download/rust-v0.166.0/codex-package-x86_64-pc-windows-msvc.tar.gz",
109 sha256: "d559bb88e74d471265857b93b7f19bc250dae527673b9191b33534478093f6bc",
110 archiveBytes: 202589789,
111 },
112};
113const resolveCodexReleaseAsset = (platform: NodeJS.Platform, arch: string) =>
114 RELEASES[`${platform}-${arch}`] ?? null;
115export class CodexInstallationError extends Schema.TaggedError<CodexInstallationError>()(
116 "CodexInstallationError",
117 {
118 operation: Schema.String,
119 detail: Schema.String,
120 cause: Schema.optional(Schema.Defect()),
121 },
122) {
123 override get message() {
124 return this.detail;
125 }
126}
127const isInstallationError = Schema.is(CodexInstallationError);
128const installationError = (operation: string, detail: string, cause?: unknown) =>
129 new CodexInstallationError({ operation, detail, ...(cause === undefined ? {} : { cause }) });
130const wrapFailure = (operation: string, detail: string) => (cause: unknown) =>
131 isInstallationError(cause) ? cause : installationError(operation, detail, cause);
132export interface CodexExecutable {
133 readonly executablePath: string;
134 readonly source: "managed" | "local";
135 readonly version: string;
136 readonly managedVersionDirectory: string | null;
137}
138interface CodexInstallationService {
139 readonly managedDirectory: string;
140 readonly resolve: () => Effect.Effect<CodexExecutable, CodexInstallationError>;
141 readonly acquire: () => Effect.Effect<CodexExecutable, CodexInstallationError, Scope.Scope>;
142 readonly compatibility: (
143 version: string,
144 ) => Effect.Effect<ServerProviderCompatibilityAdvisory | undefined>;
145 readonly start: Effect.Effect<ProviderInstallState, CodexInstallationError>;
146 readonly cancel: (
147 operationId: string,
148 ) => Effect.Effect<ProviderInstallState, CodexInstallationError>;
149 readonly state: Effect.Effect<ProviderInstallState>;
150 readonly changes: Stream.Stream<ProviderInstallState>;
151 /**
152 * Looks up OpenAI's newest Codex release, at most hourly, and offers it
153 * through `state.version` when it is newer than the managed install.
154 */
155 readonly checkForUpdate: Effect.Effect<void>;
156 readonly remove: (
157 protectedBinaryPaths?: ReadonlyArray<string>,
158 ) => Effect.Effect<void, CodexInstallationError>;
159}
160export class CodexInstallation extends Context.Service<
161 CodexInstallation,
162 CodexInstallationService
163>()("t3/provider/CodexInstallation") {
164 static readonly layer = Layer.effect(
165 CodexInstallation,
166 Effect.gen(function* () {
167 const config = yield* ServerConfig.ServerConfig;
168 return yield* makeCodexInstallation({ baseDir: config.baseDir });
169 }),
170 );
171}
172export interface CodexInstallationOptions {
173 readonly baseDir: string;
174 readonly releaseAsset?: CodexReleaseAsset | null;
175 readonly validate?: (
176 executable: CodexExecutable,
177 expectedVersion: string,
178 ) => Effect.Effect<void, CodexInstallationError, Scope.Scope>;
179}
180const isRunning = (state: ProviderInstallState) =>
181 ["downloading", "extracting", "verifying"].includes(state.phase);
182export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function* (
183 options: CodexInstallationOptions,
184) {
185 const manifestService = yield* ModelManifest.ModelManifest;
186 const fs = yield* FileSystem.FileSystem;
187 const path = yield* Path.Path;
188 const random = yield* SecureRandom.SecureRandom;
189 const http = yield* HttpClient.HttpClient;
190 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
191 const serviceScope = yield* Effect.scope;
192 const platform = yield* HostProcess.Platform;
193 const environment = yield* HostProcess.Environment;
194 const arch = yield* HostProcess.Architecture;
195 const asset =
196 options.releaseAsset === undefined
197 ? resolveCodexReleaseAsset(platform, arch)
198 : options.releaseAsset;
199 const managedDirectory = path.join(options.baseDir, "tools", "codex");
200 const activePath = path.join(managedDirectory, "active.json");
201 const executableName = platform === "win32" ? "codex.exe" : "codex";
202 const gate = yield* Semaphore.make(1);
203 const updateCheckGate = yield* Semaphore.make(1);
204 let leases = 0;
205 let running: { readonly operationId: string; readonly fiber: Fiber.Fiber<void> } | undefined;
206 let upstream: CodexReleaseAsset | null = null;
207 let upstreamCheckedAt: number | null = null;
208
209 // A local Codex is used as is, so only a managed install is ever updated.
210 const managedVersion = (current: ProviderInstallState) =>
211 current.source === "managed" ? current.installedVersion : null;
212 // What `start` installs next. Clients compare `version` with
213 // `installedVersion` to offer an update.
214 const offer = (installedVersion: string | null) => {
215 const release = selectManagedRelease(installedVersion, asset, upstream);
216 return {
217 version: release?.version ?? installedVersion,
218 totalBytes: release?.archiveBytes ?? null,
219 };
220 };
221
222 const state = yield* SubscriptionRef.make<ProviderInstallState>({
223 driver: DRIVER,
224 operationId: null,
225 phase: "idle",
226 downloadedBytes: 0,
227 ...offer(null),
228 installedVersion: null,
229 executablePath: null,
230 canRemove: false,
231 message: null,
232 });
233 const readRecord = Effect.fn("CodexInstallation.readRecord")(function* <A>(
234 file: string,
235 schema: Schema.Codec<A>,
236 ) {
237 const info = yield* fs.stat(file);
238 if (info.type !== "File" || Number(info.size) > 8192)
239 return yield* installationError(
240 "resolve",
241 "The managed Codex installation record is invalid. Reinstall Codex.",
242 );
243 return yield* Schema.decodeEffect(Schema.fromJsonString(schema))(
244 yield* fs.readFileString(file),
245 );
246 });
247 const fromDirectory = Effect.fn("CodexInstallation.fromDirectory")(function* (
248 directory: string,
249 version: string,
250 target: string,
251 ) {
252 const manifest = yield* readRecord(path.join(directory, "codex-package.json"), PackageManifest);
253 if (
254 manifest.version !== version ||
255 manifest.target !== target ||
256 manifest.entrypoint !== `bin/${executableName}`
257 )
258 return yield* installationError(
259 "verify",
260 "The downloaded Codex package does not match the expected release.",
261 );
262 for (const name of [
263 `bin/${executableName}`,
264 `bin/codex-code-mode-host${platform === "win32" ? ".exe" : ""}`,
265 `codex-path/rg${platform === "win32" ? ".exe" : ""}`,
266 ]) {
267 const info = yield* fs.stat(path.join(directory, name));
268 if (
269 info.type !== "File" ||
270 Number(info.size) === 0 ||
271 (platform !== "win32" && (info.mode & 0o111) === 0)
272 )
273 return yield* installationError(
274 "verify",
275 "The managed Codex package is incomplete. Reinstall Codex.",
276 );
277 }
278 return {
279 executablePath: path.join(directory, "bin", executableName),
280 source: "managed",
281 version,
282 managedVersionDirectory: directory,
283 } satisfies CodexExecutable;
284 });
285 const completedRelease = Effect.fn("CodexInstallation.completedRelease")(function* (
286 version: string,
287 ) {
288 yield* decodeVersion(version);
289 const directory = path.join(managedDirectory, version);
290 const record = yield* readRecord(
291 path.join(directory, ".install-complete.json"),
292 InstalledRelease,
293 );
294 if (record.version !== version)
295 return yield* installationError(
296 "resolve",
297 "The managed Codex installation record has the wrong version.",
298 );
299 return yield* fromDirectory(directory, version, record.target);
300 });
301 const compatibility = Effect.fn("CodexInstallation.compatibility")(function* (version: string) {
302 const manifest = yield* manifestService.current;
303 return (
304 resolveProviderCompatibility(manifest.compatibility, DRIVER, version) ??
305 resolveProviderCompatibility(
306 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
307 DRIVER,
308 version,
309 )
310 );
311 });
312 const isSupported = (version: string) =>
313 compatibility(version).pipe(Effect.map((advisory) => advisory?.status === "supported"));
314 // Forgets what the manifest withdrew after it was seen. Managed resolution
315 // refuses a withdrawn install, so the pin replaces it rather than a re-check.
316 const keptInstall = Effect.fn("CodexInstallation.keptInstall")(function* (
317 installedVersion: string | null,
318 ) {
319 if (upstream && !(yield* isSupported(upstream.version))) upstream = null;
320 return installedVersion !== null && (yield* isSupported(installedVersion))
321 ? installedVersion
322 : null;
323 });
324 // npm names the latest stable release; its GitHub release lists the package
325 // checksums, so an upstream update stays SHA-256 verified.
326 const lookupUpstream = Effect.gen(function* () {
327 if (!asset) return null;
328 const latest = yield* http
329 .execute(HttpClientRequest.get(UPSTREAM_LATEST_URL))
330 .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
331 const { version } = yield* latest.json.pipe(Effect.flatMap(decodeNpmLatest));
332 // Managed resolution refuses anything else, so offering it would break sessions.
333 if (!(yield* isSupported(version))) return null;
334 const releaseUrl = `${UPSTREAM_RELEASES_URL}/rust-v${version}`;
335 const checksums = yield* http
336 .execute(HttpClientRequest.get(`${releaseUrl}/codex-package_SHA256SUMS`))
337 .pipe(
338 Effect.flatMap(HttpClientResponse.filterStatusOk),
339 Effect.flatMap((response) => response.text),
340 );
341 const fileName = `codex-package-${asset.target}.tar.gz`;
342 const sha256 = parseChecksums(checksums).get(fileName);
343 if (!sha256) return null;
344 return {
345 version,
346 target: asset.target,
347 url: `${releaseUrl}/${fileName}`,
348 sha256,
349 archiveBytes: null,
350 } satisfies CodexReleaseAsset;
351 });
352 // A check already in flight covers concurrent subscribers, including when it fails.
353 const checkForUpdate = updateCheckGate
354 .withPermitsIfAvailable(1)(
355 Effect.gen(function* () {
356 const now = yield* Clock.currentTimeMillis;
357 if (upstreamCheckedAt === null || now - upstreamCheckedAt >= UPDATE_CHECK_INTERVAL_MS) {
358 // A failed lookup counts too, and keeps whatever release was offered before.
359 upstreamCheckedAt = now;
360 upstream = yield* lookupUpstream.pipe(
361 Effect.timeout(UPDATE_CHECK_TIMEOUT),
362 Effect.orElseSucceed(() => upstream),
363 );
364 }
365 // A running install already published the release it is installing.
366 yield* SubscriptionRef.updateEffect(state, (current) =>
367 isRunning(current)
368 ? Effect.succeed(current)
369 : keptInstall(managedVersion(current)).pipe(
370 Effect.map((kept) => ({ ...current, ...offer(kept) })),
371 ),
372 );
373 }),
374 )
375 .pipe(Effect.ignore);
376 const resolveManaged = Effect.fn("CodexInstallation.resolveManaged")(
377 function* () {
378 const active = yield* readRecord(activePath, ActiveRelease);
379 const executable = yield* completedRelease(active.version);
380 const advisory = yield* compatibility(executable.version);
381 if (advisory?.status !== "supported")
382 return yield* installationError(
383 "resolve",
384 advisory?.message ?? "Update the managed Codex installation to continue.",
385 );
386 return executable;
387 },
388 Effect.mapError(
389 wrapFailure("resolve", "Codex is not installed in T3 Code. Install it to continue."),
390 ),
391 );
392 const acquire = Effect.fn("CodexInstallation.acquire")(function* () {
393 return yield* Effect.acquireRelease(
394 gate.withPermit(
395 resolve().pipe(
396 Effect.tap(() =>
397 Effect.sync(() => {
398 leases += 1;
399 }),
400 ),
401 ),
402 ),
403 () =>
404 Effect.sync(() => {
405 leases -= 1;
406 }),
407 );
408 });
409 const runCommand = Effect.fn("CodexInstallation.runCommand")(function* (
410 command: string,
411 args: ReadonlyArray<string>,
412 ) {
413 const resolved = yield* resolveSpawnCommand(command, args).pipe(
414 Effect.provideService(HostProcess.Platform, platform),
415 );
416 const child = yield* spawner.spawn(
417 ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell }),
418 );
419 const [output, , exitCode] = yield* Effect.all(
420 [
421 child.stdout.pipe(Stream.decodeText(), Stream.mkString),
422 child.stderr.pipe(Stream.runDrain),
423 child.exitCode,
424 ],
425 { concurrency: "unbounded" },
426 );
427 if (exitCode !== 0)
428 return yield* installationError(
429 "verify",
430 "Could not unpack or start the downloaded Codex package.",
431 );
432 return output;
433 }, Effect.scoped);
434 // Native Codex needs its adjacent host; launchers resolve their own payloads.
435 const hasCodeModeHost = Effect.fn("CodexInstallation.hasCodeModeHost")(
436 function* (realExecutablePath: string) {
437 if (/^(?:volta-shim|mise|aqua-proxy)(?:\.exe)?$/iu.test(path.basename(realExecutablePath)))
438 return true;
439 if (platform === "win32") {
440 if (path.extname(realExecutablePath).toLowerCase() !== ".exe") return true;
441 for (const extension of [".bunx", ".shim"]) {
442 const marker = yield* fs
443 .stat(realExecutablePath.slice(0, -4) + extension)
444 .pipe(Effect.option);
445 if (
446 Option.isSome(marker) &&
447 marker.value.type === "File" &&
448 Number(marker.value.size) > 0
449 )
450 return true;
451 }
452 } else {
453 const header = yield* fs.open(realExecutablePath, { flag: "r" }).pipe(
454 Effect.flatMap((file) => file.readAlloc(2)),
455 Effect.orElseSucceed(() => Option.none()),
456 );
457 if (Option.isSome(header) && header.value[0] === 0x23 && header.value[1] === 0x21)
458 return true;
459 }
460 const info = yield* fs.stat(
461 path.join(
462 path.dirname(realExecutablePath),
463 `codex-code-mode-host${platform === "win32" ? ".exe" : ""}`,
464 ),
465 );
466 return (
467 info.type === "File" &&
468 Number(info.size) > 0 &&
469 (platform === "win32" || (info.mode & 0o111) !== 0)
470 );
471 },
472 Effect.scoped,
473 Effect.orElseSucceed(() => false),
474 );
475 const localCache = new Map<string, { fingerprint: string; executable: CodexExecutable | null }>();
476 const resolveLocal = Effect.fn("CodexInstallation.resolveLocal")(
477 function* () {
478 const executablePath = yield* resolveCommandPath("codex", { env: environment }).pipe(
479 Effect.provideService(HostProcess.Platform, platform),
480 Effect.provideService(FileSystem.FileSystem, fs),
481 Effect.provideService(Path.Path, path),
482 );
483 if (executablePath === null) return null;
484 // Keep launcher symlinks intact: version-manager shims dispatch by their invoked name.
485 const realExecutablePath = yield* fs.realPath(executablePath);
486 const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option);
487 if (
488 realExecutablePath.startsWith(
489 `${Option.getOrElse(realManaged, () => managedDirectory)}${path.sep}`,
490 )
491 )
492 return null;
493 if (!(yield* hasCodeModeHost(realExecutablePath))) return null;
494 const info = yield* fs.stat(executablePath);
495 const fingerprint = `${realExecutablePath}:${info.size}:${Option.getOrUndefined(info.mtime)?.getTime()}:${Option.getOrUndefined(info.ino)}`;
496 const cached = localCache.get(executablePath);
497 const executable =
498 cached?.fingerprint === fingerprint
499 ? cached.executable
500 : yield* Effect.gen(function* () {
501 const output = yield* runCommand(executablePath, ["--version"]);
502 const version = /^codex-cli (\d+\.\d+\.\d+)$/u.exec(output.trim())?.[1];
503 if (!version) return null;
504 yield* runCommand(executablePath, ["app-server", "--help"]);
505 return {
506 executablePath,
507 source: "local",
508 version,
509 managedVersionDirectory: null,
510 } satisfies CodexExecutable;
511 }).pipe(
512 Effect.timeout("5 seconds"),
513 Effect.orElseSucceed(() => null),
514 );
515 localCache.set(executablePath, { fingerprint, executable });
516 if (!executable) return null;
517 const advisory = yield* compatibility(executable.version);
518 return advisory?.status === "supported" ? executable : null;
519 },
520 Effect.orElseSucceed(() => null),
521 );
522 const resolve = Effect.fn("CodexInstallation.resolve")(function* () {
523 const local = yield* resolveLocal();
524 return local ?? (yield* resolveManaged());
525 });
526 const reuseLocal = Effect.fn("CodexInstallation.reuseLocal")(function* () {
527 const local = yield* resolveLocal();
528 if (!local) return false;
529 yield* SubscriptionRef.update(
530 state,
531 (current) =>
532 ({
533 ...current,
534 phase: "succeeded",
535 source: "local",
536 operationId: null,
537 installedVersion: local.version,
538 executablePath: local.executablePath,
539 downloadedBytes: 0,
540 totalBytes: null,
541 message: null,
542 }) satisfies ProviderInstallState,
543 );
544 return true;
545 });
546 const validate =
547 options.validate ??
548 Effect.fn("CodexInstallation.validate")(
549 function* (executable: CodexExecutable, version: string) {
550 const output = yield* runCommand(executable.executablePath, ["--version"]);
551 if (output.trim() !== `codex-cli ${version}`)
552 return yield* installationError(
553 "verify",
554 "The downloaded Codex executable has the wrong version.",
555 );
556 },
557 Effect.mapError(wrapFailure("verify", "The downloaded Codex runtime could not start.")),
558 );
559 const install = Effect.fn("CodexInstallation.install")(
560 function* (release: CodexReleaseAsset) {
561 const ensureEligible = Effect.fn("CodexInstallation.ensureEligible")(function* () {
562 if (release.version !== asset?.version && !(yield* isSupported(release.version))) {
563 return yield* installationError(
564 "verify",
565 "This Codex release was withdrawn. Check for updates and try again.",
566 );
567 }
568 });
569 yield* decodeVersion(release.version);
570 yield* fs.makeDirectory(managedDirectory, { recursive: true });
571 yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove: true }));
572 const destination = path.join(managedDirectory, release.version);
573 const activate = Effect.fn("CodexInstallation.activate")(
574 function* () {
575 const executable = yield* completedRelease(release.version);
576 const temporary = yield* fs.makeTempDirectoryScoped({
577 directory: managedDirectory,
578 prefix: ".active-",
579 });
580 const pointer = path.join(temporary, "active.json");
581 yield* fs.writeFileString(pointer, yield* encodeActive({ version: release.version }), {
582 mode: 0o600,
583 flag: "wx",
584 });
585 yield* ensureEligible();
586 yield* fs.rename(pointer, activePath);
587 yield* SubscriptionRef.update(
588 state,
589 (current) =>
590 ({
591 ...current,
592 ...offer(release.version),
593 phase: "succeeded",
594 source: "managed",
595 installedVersion: release.version,
596 executablePath: executable.executablePath,
597 message: null,
598 }) satisfies ProviderInstallState,
599 );
600 },
601 Effect.scoped,
602 Effect.uninterruptible,
603 );
604 if (yield* fs.exists(destination)) {
605 const existing = yield* completedRelease(release.version);
606 const record = yield* readRecord(
607 path.join(destination, ".install-complete.json"),
608 InstalledRelease,
609 );
610 if (record.sha256 !== release.sha256 || record.target !== release.target)
611 return yield* installationError(
612 "verify",
613 "The existing managed Codex release differs from the official package. Remove it and reinstall.",
614 );
615 yield* ensureEligible();
616 yield* validate(existing, release.version).pipe(
617 Effect.scoped,
618 Effect.timeout("90 seconds"),
619 );
620 yield* activate();
621 return;
622 }
623 const staging = yield* fs.makeTempDirectoryScoped({
624 directory: managedDirectory,
625 prefix: ".install-",
626 });
627 const archivePath = path.join(staging, "download.tar.gz");
628 const runtime = path.join(staging, "runtime");
629 yield* fs.makeDirectory(runtime);
630 const hash = NodeCrypto.createHash("sha256");
631 const maxArchiveBytes = release.archiveBytes ?? UPSTREAM_MAX_ARCHIVE_BYTES;
632 let downloadedBytes = 0;
633 let lastProgressAt = yield* Clock.currentTimeMillis;
634 const response = yield* http
635 .execute(HttpClientRequest.get(release.url))
636 .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
637 yield* response.stream.pipe(
638 Stream.tap((chunk) =>
639 Effect.gen(function* () {
640 downloadedBytes += chunk.byteLength;
641 if (downloadedBytes > maxArchiveBytes)
642 return yield* installationError(
643 "download",
644 "The Codex download exceeded the expected release size.",
645 );
646 hash.update(chunk);
647 const now = yield* Clock.currentTimeMillis;
648 if (now - lastProgressAt >= 250 || downloadedBytes === release.archiveBytes) {
649 lastProgressAt = now;
650 yield* SubscriptionRef.update(state, (current) => ({ ...current, downloadedBytes }));
651 }
652 }),
653 ),
654 Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })),
655 Effect.timeout("45 minutes"),
656 Effect.catchTags({
657 TimeoutError: (cause) =>
658 installationError(
659 "download",
660 "The Codex download timed out. Retry installation.",
661 cause,
662 ),
663 }),
664 );
665 if (
666 (release.archiveBytes !== null && downloadedBytes !== release.archiveBytes) ||
667 hash.digest("hex") !== release.sha256
668 )
669 return yield* installationError(
670 "download",
671 "The Codex download failed its size or SHA-256 check. Nothing was installed.",
672 );
673 yield* SubscriptionRef.update(
674 state,
675 (current) =>
676 ({
677 ...current,
678 phase: "extracting",
679 message: "Extracting Codex.",
680 }) satisfies ProviderInstallState,
681 );
682 // Bare `tar` on Windows can resolve to Git's MSYS tar, which reads `C:\...` as a remote host.
683 const tarCommand = cliArchiveTarCommand(platform, environment);
684 const entries = (yield* runCommand(tarCommand, ["-tzf", archivePath])).trim().split(/\r?\n/u);
685 const types = (yield* runCommand(tarCommand, ["-tvzf", archivePath])).trim().split(/\r?\n/u);
686 if (
687 entries.length > 10000 ||
688 entries.length !== types.length ||
689 types.some((line) => !["-", "d"].includes(line[0] ?? "")) ||
690 entries.some((entry) => {
691 const parts = entry.replace(/\/$/u, "").split("/");
692 return (
693 !entry ||
694 entry.includes("\\") ||
695 entry.startsWith("/") ||
696 parts.some((part) => part === ".." || part === "." || part === "" || part.includes(":"))
697 );
698 })
699 )
700 return yield* installationError("extract", "The Codex archive contains unsafe entries.");
701 yield* runCommand(tarCommand, ["-xzf", archivePath, "-C", runtime]);
702 yield* SubscriptionRef.update(
703 state,
704 (current) =>
705 ({
706 ...current,
707 phase: "verifying",
708 message: "Checking Codex.",
709 }) satisfies ProviderInstallState,
710 );
711 const executable = yield* fromDirectory(runtime, release.version, release.target);
712 yield* ensureEligible();
713 yield* validate(executable, release.version).pipe(
714 Effect.scoped,
715 Effect.timeout("90 seconds"),
716 );
717 yield* fs.writeFileString(
718 path.join(runtime, ".install-complete.json"),
719 yield* encodeRecord({
720 version: release.version,
721 target: release.target,
722 sha256: release.sha256,
723 }),
724 { flag: "wx", mode: 0o600 },
725 );
726 // Windows Defender or process rundown may retain codex.exe handles briefly after --version exits.
727 const renameRuntime = fs.rename(runtime, destination);
728 yield* platform === "win32"
729 ? renameRuntime.pipe(Effect.retry({ times: 10, schedule: Schedule.spaced("100 millis") }))
730 : renameRuntime;
731 yield* activate();
732 },
733 Effect.scoped,
734 Effect.catchTags({
735 HttpClientError: (cause) => {
736 const httpStatus =
737 cause.reason._tag === "StatusCodeError" ? cause.reason.response.status : undefined;
738 return new CodexInstallationError({
739 operation: "download",
740 detail:
741 httpStatus === undefined
742 ? "The Codex download was interrupted. Check your internet connection, then retry installation."
743 : `The Codex download server returned HTTP ${httpStatus}. Try again later.`,
744 cause,
745 });
746 },
747 }),
748 Effect.mapError(
749 wrapFailure(
750 "install",
751 "Could not install Codex. Check disk space and directory access, then try again.",
752 ),
753 ),
754 );
755 // Nothing newer is known, so "reinstall" re-checks the active runtime rather
756 // than replacing it with an older pinned release.
757 const reverify = Effect.fn("CodexInstallation.reverify")(
758 function* () {
759 const active = yield* readRecord(activePath, ActiveRelease);
760 const executable = yield* completedRelease(active.version);
761 yield* validate(executable, executable.version).pipe(
762 Effect.scoped,
763 Effect.timeout("90 seconds"),
764 );
765 const kept = yield* keptInstall(executable.version);
766 if (kept === null) {
767 yield* SubscriptionRef.update(state, (current) => ({ ...current, ...offer(kept) }));
768 return yield* installationError(
769 "verify",
770 "This Codex release was withdrawn. Check for updates and try again.",
771 );
772 }
773 yield* SubscriptionRef.update(
774 state,
775 (current) =>
776 ({
777 ...current,
778 ...offer(kept),
779 phase: "succeeded",
780 source: "managed",
781 installedVersion: executable.version,
782 executablePath: executable.executablePath,
783 message: null,
784 }) satisfies ProviderInstallState,
785 );
786 },
787 Effect.mapError(
788 wrapFailure(
789 "verify",
790 "The installed Codex runtime could not start. Remove it and reinstall.",
791 ),
792 ),
793 );
794 const start = gate
795 .withPermit(
796 Effect.gen(function* () {
797 const current = yield* SubscriptionRef.get(state);
798 if (isRunning(current)) return current;
799 if (yield* reuseLocal()) return yield* SubscriptionRef.get(state);
800 const installedVersion = yield* keptInstall(managedVersion(current));
801 const release = selectManagedRelease(installedVersion, asset, upstream);
802 if (!release && installedVersion === null) {
803 return yield* installationError(
804 "start",
805 `OpenAI does not publish a Codex runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`,
806 );
807 }
808 const operationId = yield* random.uuidV4();
809 const next: ProviderInstallState = {
810 ...current,
811 operationId,
812 phase: release ? "downloading" : "verifying",
813 downloadedBytes: 0,
814 ...offer(installedVersion),
815 message: release ? "Downloading Codex." : "Checking Codex.",
816 };
817 yield* SubscriptionRef.set(state, next);
818 const work = (release ? install(release) : reverify()).pipe(
819 Effect.onExit((exit) =>
820 Exit.isFailure(exit)
821 ? SubscriptionRef.update(state, (value) => {
822 if (value.operationId !== operationId || value.phase === "succeeded")
823 return value;
824 const error = Cause.findErrorOption(exit.cause);
825 const cancelled = Cause.hasInterruptsOnly(exit.cause);
826 return {
827 ...value,
828 phase: cancelled ? "cancelled" : "failed",
829 message: cancelled
830 ? "Installation cancelled. The previous runtime is unchanged."
831 : Option.isSome(error)
832 ? error.value.detail
833 : "Could not finish the Codex installation. Check disk space and directory access.",
834 } satisfies ProviderInstallState;
835 })
836 : Effect.void,
837 ),
838 Effect.ignoreCause,
839 Effect.ensuring(
840 Effect.sync(() => {
841 if (running?.operationId === operationId) running = undefined;
842 }),
843 ),
844 );
845 const fiber = yield* Effect.forkIn(Effect.interruptible(work), serviceScope);
846 running = { operationId, fiber };
847 return next;
848 }).pipe(Effect.uninterruptible),
849 )
850 .pipe(Effect.mapError(wrapFailure("start", "Could not start the Codex installation.")));
851
852 const cancel = Effect.fn("CodexInstallation.cancel")(function* (operationId: string) {
853 return yield* gate.withPermit(
854 Effect.gen(function* () {
855 const current = yield* SubscriptionRef.get(state);
856 if (current.operationId !== operationId) {
857 return yield* installationError(
858 "cancel",
859 "This installation is no longer current. Refresh its status before cancelling.",
860 );
861 }
862 if (running?.operationId === operationId && isRunning(current)) {
863 yield* Fiber.interrupt(running.fiber);
864 }
865 return yield* SubscriptionRef.get(state);
866 }),
867 );
868 });
869
870 const remove = Effect.fn("CodexInstallation.remove")(
871 function* (protectedBinaryPaths: ReadonlyArray<string> = []) {
872 yield* gate.withPermit(
873 Effect.gen(function* () {
874 if (isRunning(yield* SubscriptionRef.get(state)) || leases > 0) {
875 return yield* installationError(
876 "remove",
877 "Stop Codex sessions and sign-in flows before removing its managed runtime.",
878 );
879 }
880 const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option);
881 if (Option.isSome(realManaged)) {
882 for (const binary of protectedBinaryPaths) {
883 const resolved = yield* fs.realPath(binary).pipe(Effect.option);
884 const candidate = Option.getOrElse(resolved, () => path.resolve(binary));
885 if (candidate.startsWith(`${realManaged.value}${path.sep}`))
886 return yield* installationError(
887 "remove",
888 "A provider instance uses a custom path inside managed Codex. Clear that path before removing it.",
889 );
890 }
891 }
892 yield* fs.remove(managedDirectory, { recursive: true, force: true });
893 yield* SubscriptionRef.update(
894 state,
895 (current) =>
896 ({
897 ...current,
898 ...offer(null),
899 operationId: null,
900 phase: "idle",
901 downloadedBytes: 0,
902 installedVersion: null,
903 executablePath: null,
904 source: null,
905 canRemove: false,
906 message: null,
907 }) satisfies ProviderInstallState,
908 );
909 yield* reuseLocal();
910 }).pipe(Effect.uninterruptible),
911 );
912 },
913 Effect.mapError(
914 wrapFailure(
915 "remove",
916 "Could not remove the managed Codex runtime. Check for open processes and try again.",
917 ),
918 ),
919 );
920
921 yield* Effect.gen(function* () {
922 // Shared-home installs do not refresh parent mtimes; remove only staging directories far older than the 45-minute download cap.
923 let canRemove = false;
924 if (yield* fs.exists(managedDirectory)) {
925 const entries = yield* fs
926 .readDirectory(managedDirectory)
927 .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));
928 const now = yield* Clock.currentTimeMillis;
929 for (const entry of entries) {
930 if (!entry.startsWith(".install-") && !entry.startsWith(".active-")) continue;
931 const target = path.join(managedDirectory, entry);
932 const info = yield* fs.stat(target).pipe(Effect.option);
933 if (Option.isNone(info)) continue;
934 const modifiedAt = Option.match(info.value.mtime, {
935 onNone: () => now,
936 onSome: (date) => date.getTime(),
937 });
938 if (now - modifiedAt < STAGING_STALE_AFTER_MS) continue;
939 yield* fs.remove(target, { recursive: true, force: true }).pipe(Effect.ignore);
940 }
941 // Keep the empty root to avoid racing new staging directories; unreadable roots remain removable.
942 const remaining = yield* fs.readDirectory(managedDirectory).pipe(Effect.option);
943 canRemove = Option.isNone(remaining) || remaining.value.length > 0;
944 }
945 yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove }));
946 if (yield* reuseLocal()) return;
947 if (!(yield* fs.exists(activePath))) return;
948 const active = yield* readRecord(activePath, ActiveRelease);
949 const installed = yield* completedRelease(active.version);
950 const kept = yield* keptInstall(installed.version);
951 yield* SubscriptionRef.update(
952 state,
953 (current) =>
954 ({
955 ...current,
956 ...offer(kept),
957 installedVersion: installed.version,
958 executablePath: installed.executablePath,
959 source: "managed",
960 }) satisfies ProviderInstallState,
961 );
962 // A supported older runtime still resolves, so a raised pin needs its own update.
963 if (asset && compareSemverVersions(installed.version, asset.version) < 0)
964 yield* start.pipe(Effect.ignore);
965 }).pipe(
966 Effect.catch(() =>
967 SubscriptionRef.update(
968 state,
969 (current) =>
970 ({
971 ...current,
972 phase: "failed",
973 message: "The managed Codex runtime is incomplete. Remove it and reinstall.",
974 }) satisfies ProviderInstallState,
975 ),
976 ),
977 );
978
979 return CodexInstallation.of({
980 managedDirectory,
981 resolve,
982 acquire,
983 compatibility,
984 start,
985 cancel,
986 state: SubscriptionRef.get(state),
987 changes: SubscriptionRef.changes(state),
988 checkForUpdate,
989 remove,
990 });
991});