1// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest.
2import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell";
5import * as Clock from "effect/Clock";
6import * as Cause from "effect/Cause";
7import * as Context from "effect/Context";
8import * as Crypto from "effect/Crypto";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as Fiber from "effect/Fiber";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Path from "effect/Path";
16import * as Schema from "effect/Schema";
17import * as Scope from "effect/Scope";
18import * as Semaphore from "effect/Semaphore";
19import * as Stream from "effect/Stream";
20import * as SubscriptionRef from "effect/SubscriptionRef";
21import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
22import { ChildProcess, ChildProcessSpawner } from "effect/process";
23import * as NodeCrypto from "node:crypto";
24import * as ServerConfig from "../config.ts";
25import * as ModelManifest from "./ModelManifest.ts";
26import { resolveProviderCompatibility } from "./providerCompatibility.ts";
27
28const DRIVER = ProviderDriverKind.make("codex");
29const Version = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/u));
30const ActiveRelease = Schema.Struct({ version: Version });
31const InstalledRelease = Schema.Struct({
32 version: Version,
33 target: Schema.String,
34 sha256: Schema.String,
35});
36const PackageManifest = Schema.Struct({
37 layoutVersion: Schema.Literal(1),
38 version: Version,
39 target: Schema.String,
40 entrypoint: Schema.String,
41});
42const decodeVersion = Schema.decodeUnknownEffect(Version);
43const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease));
44const encodeActive = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease));
45export interface CodexReleaseAsset {
46 readonly version: string;
47 readonly target: string;
48 readonly url: string;
49 readonly sha256: string;
50 readonly archiveBytes: number;
51}
52// Official complete packages retain the code-mode, search, and resource companions.
53const RELEASES: Readonly<Record<string, CodexReleaseAsset>> = {
54 "darwin-arm64": {
55 version: "0.156.1",
56 target: "aarch64-apple-darwin",
57 url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-apple-darwin.tar.gz",
58 sha256: "fea42f9625091f011e38f059da974d52e57ba31831648bb1c7f0b1a385fde547",
59 archiveBytes: 127394863,
60 },
61 "darwin-x64": {
62 version: "0.156.1",
63 target: "x86_64-apple-darwin",
64 url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-apple-darwin.tar.gz",
65 sha256: "618dbcd55419fa041871f777a14b107ceb3fe2d339ef81e21e6ab5374420dc71",
66 archiveBytes: 138670455,
67 },
68 "linux-arm64": {
69 version: "0.156.1",
70 target: "aarch64-unknown-linux-musl",
71 url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-unknown-linux-musl.tar.gz",
72 sha256: "fdd47ed6aade0360796fd3f6f95a45096f327c15e19e8c7339f9dc5633041786",
73 archiveBytes: 136933361,
74 },
75 "linux-x64": {
76 version: "0.156.1",
77 target: "x86_64-unknown-linux-musl",
78 url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-unknown-linux-musl.tar.gz",
79 sha256: "8b711520beddf385467b8da4d2c93736637c6ba1e46811cf0d8606b7c490b6f6",
80 archiveBytes: 145976992,
81 },
82 "win32-arm64": {
83 version: "0.156.1",
84 target: "aarch64-pc-windows-msvc",
85 url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-pc-windows-msvc.tar.gz",
86 sha256: "85994caecdc7609c49fd585c1cdb5677fa9d0acbf789650cff23a13afc9505db",
87 archiveBytes: 142037952,
88 },
89 "win32-x64": {
90 version: "0.156.1",
91 target: "x86_64-pc-windows-msvc",
92 url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-pc-windows-msvc.tar.gz",
93 sha256: "a2e017db9807e6a2269a26fea0e1d9546469cef4d472a33016bc9f3ad7d3b733",
94 archiveBytes: 153839991,
95 },
96};
97export const resolveCodexReleaseAsset = (platform: NodeJS.Platform, arch: string) =>
98 RELEASES[`${platform}-${arch}`] ?? null;
99export class CodexInstallationError extends Schema.TaggedError<CodexInstallationError>()(
100 "CodexInstallationError",
101 {
102 operation: Schema.String,
103 detail: Schema.String,
104 cause: Schema.optional(Schema.Defect()),
105 },
106) {
107 override get message() {
108 return this.detail;
109 }
110}
111const isInstallationError = Schema.is(CodexInstallationError);
112const installationError = (operation: string, detail: string, cause?: unknown) =>
113 new CodexInstallationError({ operation, detail, ...(cause === undefined ? {} : { cause }) });
114const wrapFailure = (operation: string, detail: string) => (cause: unknown) =>
115 isInstallationError(cause) ? cause : installationError(operation, detail, cause);
116export interface CodexExecutable {
117 readonly executablePath: string;
118 readonly source: "managed" | "local";
119 readonly version: string;
120 readonly managedVersionDirectory: string | null;
121}
122interface CodexInstallationService {
123 readonly managedDirectory: string;
124 readonly resolve: () => Effect.Effect<CodexExecutable, CodexInstallationError>;
125 readonly acquire: () => Effect.Effect<CodexExecutable, CodexInstallationError, Scope.Scope>;
126 readonly start: Effect.Effect<ProviderInstallState, CodexInstallationError>;
127 readonly cancel: (
128 operationId: string,
129 ) => Effect.Effect<ProviderInstallState, CodexInstallationError>;
130 readonly state: Effect.Effect<ProviderInstallState>;
131 readonly changes: Stream.Stream<ProviderInstallState>;
132 readonly remove: (
133 protectedBinaryPaths?: ReadonlyArray<string>,
134 ) => Effect.Effect<void, CodexInstallationError>;
135}
136export class CodexInstallation extends Context.Service<
137 CodexInstallation,
138 CodexInstallationService
139>()("t3/provider/CodexInstallation") {
140 static readonly layer = Layer.effect(
141 CodexInstallation,
142 Effect.gen(function* () {
143 const config = yield* ServerConfig.ServerConfig;
144 return yield* makeCodexInstallation({ baseDir: config.baseDir });
145 }),
146 );
147}
148export interface CodexInstallationOptions {
149 readonly baseDir: string;
150 readonly releaseAsset?: CodexReleaseAsset | null;
151 readonly validate?: (
152 executable: CodexExecutable,
153 expectedVersion: string,
154 ) => Effect.Effect<void, CodexInstallationError, Scope.Scope>;
155}
156const isRunning = (state: ProviderInstallState) =>
157 ["downloading", "extracting", "verifying"].includes(state.phase);
158export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function* (
159 options: CodexInstallationOptions,
160) {
161 const manifestService = yield* ModelManifest.ModelManifest;
162 const fs = yield* FileSystem.FileSystem;
163 const path = yield* Path.Path;
164 const crypto = yield* Crypto.Crypto;
165 const http = yield* HttpClient.HttpClient;
166 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
167 const serviceScope = yield* Effect.scope;
168 const platform = yield* HostProcess.Platform;
169 const environment = yield* HostProcess.Environment;
170 const arch = yield* HostProcess.Architecture;
171 const asset =
172 options.releaseAsset === undefined
173 ? resolveCodexReleaseAsset(platform, arch)
174 : options.releaseAsset;
175 const managedDirectory = path.join(options.baseDir, "tools", "codex");
176 const activePath = path.join(managedDirectory, "active.json");
177 const executableName = platform === "win32" ? "codex.exe" : "codex";
178 const gate = yield* Semaphore.make(1);
179 let leases = 0;
180 let running: { readonly operationId: string; readonly fiber: Fiber.Fiber<void> } | undefined;
181 const state = yield* SubscriptionRef.make<ProviderInstallState>({
182 driver: DRIVER,
183 operationId: null,
184 phase: "idle",
185 downloadedBytes: 0,
186 totalBytes: asset?.archiveBytes ?? null,
187 version: asset?.version ?? null,
188 installedVersion: null,
189 executablePath: null,
190 canRemove: false,
191 message: null,
192 });
193 const readRecord = Effect.fn("CodexInstallation.readRecord")(function* <A>(
194 file: string,
195 schema: Schema.Codec<A>,
196 ) {
197 const info = yield* fs.stat(file);
198 if (info.type !== "File" || Number(info.size) > 8192)
199 return yield* installationError(
200 "resolve",
201 "The managed Codex installation record is invalid. Reinstall Codex.",
202 );
203 return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(
204 yield* fs.readFileString(file),
205 );
206 });
207 const fromDirectory = Effect.fn("CodexInstallation.fromDirectory")(function* (
208 directory: string,
209 version: string,
210 target: string,
211 ) {
212 const manifest = yield* readRecord(path.join(directory, "codex-package.json"), PackageManifest);
213 if (
214 manifest.version !== version ||
215 manifest.target !== target ||
216 manifest.entrypoint !== `bin/${executableName}`
217 )
218 return yield* installationError(
219 "verify",
220 "The downloaded Codex package does not match the expected release.",
221 );
222 for (const name of [
223 `bin/${executableName}`,
224 `bin/codex-code-mode-host${platform === "win32" ? ".exe" : ""}`,
225 `codex-path/rg${platform === "win32" ? ".exe" : ""}`,
226 ]) {
227 const info = yield* fs.stat(path.join(directory, name));
228 if (
229 info.type !== "File" ||
230 Number(info.size) === 0 ||
231 (platform !== "win32" && (info.mode & 0o111) === 0)
232 )
233 return yield* installationError(
234 "verify",
235 "The managed Codex package is incomplete. Reinstall Codex.",
236 );
237 }
238 return {
239 executablePath: path.join(directory, "bin", executableName),
240 source: "managed",
241 version,
242 managedVersionDirectory: directory,
243 } satisfies CodexExecutable;
244 });
245 const completedRelease = Effect.fn("CodexInstallation.completedRelease")(function* (
246 version: string,
247 ) {
248 yield* decodeVersion(version);
249 const directory = path.join(managedDirectory, version);
250 const record = yield* readRecord(
251 path.join(directory, ".install-complete.json"),
252 InstalledRelease,
253 );
254 if (record.version !== version)
255 return yield* installationError(
256 "resolve",
257 "The managed Codex installation record has the wrong version.",
258 );
259 return yield* fromDirectory(directory, version, record.target);
260 });
261 const compatibility = Effect.fn("CodexInstallation.compatibility")(function* (version: string) {
262 const manifest = yield* manifestService.current;
263 return (
264 resolveProviderCompatibility(manifest.compatibility, DRIVER, version) ??
265 resolveProviderCompatibility(
266 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
267 DRIVER,
268 version,
269 )
270 );
271 });
272 const resolveManaged = Effect.fn("CodexInstallation.resolveManaged")(
273 function* () {
274 const active = yield* readRecord(activePath, ActiveRelease);
275 const executable = yield* completedRelease(active.version);
276 const advisory = yield* compatibility(executable.version);
277 if (advisory?.status !== "supported")
278 return yield* installationError(
279 "resolve",
280 advisory?.message ?? "Update the managed Codex installation to continue.",
281 );
282 return executable;
283 },
284 Effect.mapError(
285 wrapFailure("resolve", "Codex is not installed in T3 Code. Install it to continue."),
286 ),
287 );
288 const acquire = Effect.fn("CodexInstallation.acquire")(function* () {
289 return yield* Effect.acquireRelease(
290 gate.withPermit(
291 resolve().pipe(
292 Effect.tap(() =>
293 Effect.sync(() => {
294 leases += 1;
295 }),
296 ),
297 ),
298 ),
299 () =>
300 Effect.sync(() => {
301 leases -= 1;
302 }),
303 );
304 });
305 const runCommand = Effect.fn("CodexInstallation.runCommand")(function* (
306 command: string,
307 args: ReadonlyArray<string>,
308 ) {
309 const resolved = yield* resolveSpawnCommand(command, args).pipe(
310 Effect.provideService(HostProcess.Platform, platform),
311 );
312 const child = yield* spawner.spawn(
313 ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell }),
314 );
315 const [output, , exitCode] = yield* Effect.all(
316 [
317 child.stdout.pipe(Stream.decodeText(), Stream.mkString),
318 child.stderr.pipe(Stream.runDrain),
319 child.exitCode,
320 ],
321 { concurrency: "unbounded" },
322 );
323 if (exitCode !== 0)
324 return yield* installationError(
325 "verify",
326 "Could not unpack or start the downloaded Codex package.",
327 );
328 return output;
329 }, Effect.scoped);
330 const localCache = new Map<string, { fingerprint: string; executable: CodexExecutable | null }>();
331 const resolveLocal = Effect.fn("CodexInstallation.resolveLocal")(
332 function* () {
333 const executablePath = yield* resolveCommandPath("codex", { env: environment }).pipe(
334 Effect.provideService(HostProcess.Platform, platform),
335 Effect.provideService(FileSystem.FileSystem, fs),
336 Effect.provideService(Path.Path, path),
337 );
338 if (executablePath === null) return null;
339 // Keep launcher symlinks intact: version-manager shims dispatch by their invoked name.
340 const realExecutablePath = yield* fs.realPath(executablePath);
341 // A PATH entry pointing into T3's download remains a managed installation.
342 const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option);
343 if (
344 realExecutablePath.startsWith(
345 `${Option.getOrElse(realManaged, () => managedDirectory)}${path.sep}`,
346 )
347 )
348 return null;
349 const info = yield* fs.stat(executablePath);
350 const fingerprint = `${realExecutablePath}:${info.size}:${Option.getOrUndefined(info.mtime)?.getTime()}:${Option.getOrUndefined(info.ino)}`;
351 const cached = localCache.get(executablePath);
352 const executable =
353 cached?.fingerprint === fingerprint
354 ? cached.executable
355 : yield* Effect.gen(function* () {
356 const output = yield* runCommand(executablePath, ["--version"]);
357 const version = /^codex-cli (\d+\.\d+\.\d+)$/u.exec(output.trim())?.[1];
358 if (!version) return null;
359 yield* runCommand(executablePath, ["app-server", "--help"]);
360 return {
361 executablePath,
362 source: "local",
363 version,
364 managedVersionDirectory: null,
365 } satisfies CodexExecutable;
366 }).pipe(
367 Effect.timeout("5 seconds"),
368 Effect.orElseSucceed(() => null),
369 );
370 localCache.set(executablePath, { fingerprint, executable });
371 if (!executable) return null;
372 // Cache executable probes, but reclassify against the current manifest on every resolve.
373 const advisory = yield* compatibility(executable.version);
374 return advisory?.status === "supported" ? executable : null;
375 },
376 Effect.orElseSucceed(() => null),
377 );
378 const resolve = Effect.fn("CodexInstallation.resolve")(function* () {
379 const local = yield* resolveLocal();
380 return local ?? (yield* resolveManaged());
381 });
382 const reuseLocal = Effect.fn("CodexInstallation.reuseLocal")(function* () {
383 const local = yield* resolveLocal();
384 if (!local) return false;
385 yield* SubscriptionRef.update(
386 state,
387 (current) =>
388 ({
389 ...current,
390 phase: "succeeded",
391 source: "local",
392 operationId: null,
393 installedVersion: local.version,
394 executablePath: local.executablePath,
395 downloadedBytes: 0,
396 totalBytes: null,
397 message: null,
398 }) satisfies ProviderInstallState,
399 );
400 return true;
401 });
402 const validate =
403 options.validate ??
404 Effect.fn("CodexInstallation.validate")(
405 function* (executable: CodexExecutable, version: string) {
406 const output = yield* runCommand(executable.executablePath, ["--version"]);
407 if (output.trim() !== `codex-cli ${version}`)
408 return yield* installationError(
409 "verify",
410 "The downloaded Codex executable has the wrong version.",
411 );
412 },
413 Effect.mapError(wrapFailure("verify", "The downloaded Codex runtime could not start.")),
414 );
415 const install = Effect.fn("CodexInstallation.install")(
416 function* (release: CodexReleaseAsset) {
417 yield* decodeVersion(release.version);
418 yield* fs.makeDirectory(managedDirectory, { recursive: true });
419 yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove: true }));
420 const destination = path.join(managedDirectory, release.version);
421 const activate = Effect.fn("CodexInstallation.activate")(
422 function* () {
423 const executable = yield* completedRelease(release.version);
424 const temporary = yield* fs.makeTempDirectoryScoped({
425 directory: managedDirectory,
426 prefix: ".active-",
427 });
428 const pointer = path.join(temporary, "active.json");
429 yield* fs.writeFileString(pointer, yield* encodeActive({ version: release.version }), {
430 mode: 0o600,
431 flag: "wx",
432 });
433 yield* fs.rename(pointer, activePath);
434 yield* SubscriptionRef.update(
435 state,
436 (current) =>
437 ({
438 ...current,
439 phase: "succeeded",
440 source: "managed",
441 installedVersion: release.version,
442 executablePath: executable.executablePath,
443 message: null,
444 }) satisfies ProviderInstallState,
445 );
446 },
447 Effect.scoped,
448 Effect.uninterruptible,
449 );
450 if (yield* fs.exists(destination)) {
451 const existing = yield* completedRelease(release.version);
452 const record = yield* readRecord(
453 path.join(destination, ".install-complete.json"),
454 InstalledRelease,
455 );
456 if (record.sha256 !== release.sha256 || record.target !== release.target)
457 return yield* installationError(
458 "verify",
459 "The existing managed Codex release differs from the official package. Remove it and reinstall.",
460 );
461 yield* validate(existing, release.version).pipe(
462 Effect.scoped,
463 Effect.timeout("90 seconds"),
464 );
465 yield* activate();
466 return;
467 }
468 const staging = yield* fs.makeTempDirectoryScoped({
469 directory: managedDirectory,
470 prefix: ".install-",
471 });
472 const archivePath = path.join(staging, "download.tar.gz");
473 const runtime = path.join(staging, "runtime");
474 yield* fs.makeDirectory(runtime);
475 const hash = NodeCrypto.createHash("sha256");
476 let downloadedBytes = 0;
477 let lastProgressAt = yield* Clock.currentTimeMillis;
478 const response = yield* http
479 .execute(HttpClientRequest.get(release.url))
480 .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
481 yield* response.stream.pipe(
482 Stream.tap((chunk) =>
483 Effect.gen(function* () {
484 downloadedBytes += chunk.byteLength;
485 if (downloadedBytes > release.archiveBytes)
486 return yield* installationError(
487 "download",
488 "The Codex download exceeded the expected release size.",
489 );
490 hash.update(chunk);
491 const now = yield* Clock.currentTimeMillis;
492 if (now - lastProgressAt >= 250 || downloadedBytes === release.archiveBytes) {
493 lastProgressAt = now;
494 yield* SubscriptionRef.update(state, (current) => ({ ...current, downloadedBytes }));
495 }
496 }),
497 ),
498 Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })),
499 Effect.timeout("45 minutes"),
500 );
501 if (downloadedBytes !== release.archiveBytes || hash.digest("hex") !== release.sha256)
502 return yield* installationError(
503 "download",
504 "The Codex download failed its size or SHA-256 check. Nothing was installed.",
505 );
506 yield* SubscriptionRef.update(
507 state,
508 (current) =>
509 ({
510 ...current,
511 phase: "extracting",
512 message: "Extracting Codex.",
513 }) satisfies ProviderInstallState,
514 );
515 const entries = (yield* runCommand("tar", ["-tzf", archivePath])).trim().split("\n");
516 const types = (yield* runCommand("tar", ["-tvzf", archivePath])).trim().split("\n");
517 if (
518 entries.length > 10000 ||
519 entries.length !== types.length ||
520 types.some((line) => !["-", "d"].includes(line[0] ?? "")) ||
521 entries.some((entry) => {
522 const parts = entry.replace(/\/$/u, "").split("/");
523 return (
524 !entry ||
525 entry.includes("\\") ||
526 entry.startsWith("/") ||
527 parts.some((part) => part === ".." || part === "." || part === "" || part.includes(":"))
528 );
529 })
530 )
531 return yield* installationError("extract", "The Codex archive contains unsafe entries.");
532 yield* runCommand("tar", ["-xzf", archivePath, "-C", runtime]);
533 yield* SubscriptionRef.update(
534 state,
535 (current) =>
536 ({
537 ...current,
538 phase: "verifying",
539 message: "Checking Codex.",
540 }) satisfies ProviderInstallState,
541 );
542 const executable = yield* fromDirectory(runtime, release.version, release.target);
543 yield* validate(executable, release.version).pipe(
544 Effect.scoped,
545 Effect.timeout("90 seconds"),
546 );
547 yield* fs.writeFileString(
548 path.join(runtime, ".install-complete.json"),
549 yield* encodeRecord({
550 version: release.version,
551 target: release.target,
552 sha256: release.sha256,
553 }),
554 { flag: "wx", mode: 0o600 },
555 );
556 yield* fs.rename(runtime, destination);
557 yield* activate();
558 },
559 Effect.scoped,
560 Effect.mapError(
561 wrapFailure(
562 "install",
563 "Could not install Codex. Check disk space and directory access, then try again.",
564 ),
565 ),
566 );
567 const start = gate
568 .withPermit(
569 Effect.gen(function* () {
570 const current = yield* SubscriptionRef.get(state);
571 if (isRunning(current)) return current;
572 if (yield* reuseLocal()) return yield* SubscriptionRef.get(state);
573 if (!asset) {
574 return yield* installationError(
575 "start",
576 `OpenAI does not publish a Codex runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`,
577 );
578 }
579 const operationId = yield* crypto.randomUUIDv4();
580 const next: ProviderInstallState = {
581 driver: DRIVER,
582 operationId,
583 phase: "downloading",
584 downloadedBytes: 0,
585 totalBytes: asset.archiveBytes,
586 version: asset.version,
587 installedVersion: current.installedVersion,
588 canRemove: current.canRemove,
589 message: "Downloading Codex.",
590 };
591 yield* SubscriptionRef.set(state, next);
592 const work = install(asset).pipe(
593 Effect.onExit((exit) =>
594 Exit.isFailure(exit)
595 ? SubscriptionRef.update(state, (value) => {
596 if (value.operationId !== operationId || value.phase === "succeeded")
597 return value;
598 const error = Cause.findErrorOption(exit.cause);
599 const cancelled = Cause.hasInterruptsOnly(exit.cause);
600 return {
601 ...value,
602 phase: cancelled ? "cancelled" : "failed",
603 message: cancelled
604 ? "Installation cancelled. The previous runtime is unchanged."
605 : Option.isSome(error)
606 ? error.value.detail
607 : "Could not finish the Codex installation. Check disk space and directory access.",
608 } satisfies ProviderInstallState;
609 })
610 : Effect.void,
611 ),
612 Effect.ignoreCause,
613 Effect.ensuring(
614 Effect.sync(() => {
615 if (running?.operationId === operationId) running = undefined;
616 }),
617 ),
618 );
619 const fiber = yield* Effect.forkIn(Effect.interruptible(work), serviceScope);
620 running = { operationId, fiber };
621 return next;
622 }).pipe(Effect.uninterruptible),
623 )
624 .pipe(Effect.mapError(wrapFailure("start", "Could not start the Codex installation.")));
625
626 const cancel = Effect.fn("CodexInstallation.cancel")(function* (operationId: string) {
627 return yield* gate.withPermit(
628 Effect.gen(function* () {
629 const current = yield* SubscriptionRef.get(state);
630 if (current.operationId !== operationId) {
631 return yield* installationError(
632 "cancel",
633 "This installation is no longer current. Refresh its status before cancelling.",
634 );
635 }
636 if (running?.operationId === operationId && isRunning(current)) {
637 yield* Fiber.interrupt(running.fiber);
638 }
639 return yield* SubscriptionRef.get(state);
640 }),
641 );
642 });
643
644 const remove = Effect.fn("CodexInstallation.remove")(
645 function* (protectedBinaryPaths: ReadonlyArray<string> = []) {
646 yield* gate.withPermit(
647 Effect.gen(function* () {
648 if (isRunning(yield* SubscriptionRef.get(state)) || leases > 0) {
649 return yield* installationError(
650 "remove",
651 "Stop Codex sessions and sign-in flows before removing its managed runtime.",
652 );
653 }
654 const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option);
655 if (Option.isSome(realManaged)) {
656 for (const binary of protectedBinaryPaths) {
657 const resolved = yield* fs.realPath(binary).pipe(Effect.option);
658 const candidate = Option.getOrElse(resolved, () => path.resolve(binary));
659 if (candidate.startsWith(`${realManaged.value}${path.sep}`))
660 return yield* installationError(
661 "remove",
662 "A provider instance uses a custom path inside managed Codex. Clear that path before removing it.",
663 );
664 }
665 }
666 yield* fs.remove(managedDirectory, { recursive: true, force: true });
667 yield* SubscriptionRef.update(
668 state,
669 (current) =>
670 ({
671 ...current,
672 operationId: null,
673 phase: "idle",
674 downloadedBytes: 0,
675 installedVersion: null,
676 executablePath: null,
677 source: null,
678 canRemove: false,
679 message: null,
680 }) satisfies ProviderInstallState,
681 );
682 yield* reuseLocal();
683 }).pipe(Effect.uninterruptible),
684 );
685 },
686 Effect.mapError(
687 wrapFailure(
688 "remove",
689 "Could not remove the managed Codex runtime. Check for open processes and try again.",
690 ),
691 ),
692 );
693
694 yield* Effect.gen(function* () {
695 const canRemove = yield* fs.exists(managedDirectory);
696 yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove }));
697 if (yield* reuseLocal()) return;
698 if (!(yield* fs.exists(activePath))) return;
699 const active = yield* readRecord(activePath, ActiveRelease);
700 const installed = yield* completedRelease(active.version);
701 yield* SubscriptionRef.update(
702 state,
703 (current) =>
704 ({
705 ...current,
706 installedVersion: installed.version,
707 executablePath: installed.executablePath,
708 source: "managed",
709 }) satisfies ProviderInstallState,
710 );
711 }).pipe(
712 Effect.catch(() =>
713 SubscriptionRef.update(
714 state,
715 (current) =>
716 ({
717 ...current,
718 phase: "failed",
719 message: "The managed Codex runtime is incomplete. Remove it and reinstall.",
720 }) satisfies ProviderInstallState,
721 ),
722 ),
723 );
724
725 return CodexInstallation.of({
726 managedDirectory,
727 resolve,
728 acquire,
729 start,
730 cancel,
731 state: SubscriptionRef.get(state),
732 changes: SubscriptionRef.changes(state),
733 remove,
734 });
735});