From 0c49c83b6a6b861e55647cf7c7308702b1d58bba Mon Sep 17 00:00:00 2001
From: uShifty <51722240+uShifty@users.noreply.github.com>
Date: Sat, 24 Oct 2026 06:28:10 -0600
Subject: [PATCH 1/5] fix(server): stop SQLite conflicts from breaking Codex
providers on Windows
---
.../Adapters/CodexAdapterV2.ts | 6 +-
.../src/provider/CodexChatGptAuth.test.ts | 59 +++++-
.../src/provider/CodexManagedRuntime.test.ts | 200 +++++++++++++++++-
.../src/provider/CodexManagedRuntime.ts | 4 +-
.../server/src/provider/CodexProvider.test.ts | 107 +++++++++-
.../src/provider/Drivers/CodexDriver.test.ts | 38 +++-
.../src/provider/Drivers/CodexDriver.ts | 6 +-
.../provider/Drivers/CodexHomeLayout.test.ts | 52 +++++
.../src/provider/Drivers/CodexHomeLayout.ts | 17 +-
packages/shared/src/testing/providerEnv.ts | 2 +
10 files changed, 469 insertions(+), 22 deletions(-)
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
index 73d1e1f649879..cc331d9d43ac6 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
@@ -108,6 +108,7 @@ import {
} from "../../provider/CodexMcpElicitation.ts";
import {
materializeCodexShadowHome,
+ codexHomeEnvironment,
resolveCodexHomeLayout,
} from "../../provider/Drivers/CodexHomeLayout.ts";
import {
@@ -1659,7 +1660,10 @@ export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapter
return yield* makeCodexAdapterV2({
instanceId,
settings,
- environment: yield* mergeProviderInstanceEnvironment(environment, hostEnvironment),
+ environment: codexHomeEnvironment(
+ homeLayout,
+ yield* mergeProviderInstanceEnvironment(environment, hostEnvironment),
+ ),
clientFactory,
crypto,
fileSystem,
diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts
index 3e521103d56cf..69c99f7ed2ece 100644
--- a/apps/server/src/provider/CodexChatGptAuth.test.ts
+++ b/apps/server/src/provider/CodexChatGptAuth.test.ts
@@ -10,11 +10,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
+import * as Clock from "effect/Clock";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import * as Fiber from "effect/Fiber";
import * as Deferred from "effect/Deferred";
+import * as TestClock from "effect/testing/TestClock";
import { subscribeChatGptHandoff } from "./CodexChatGptHandoff.ts";
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
@@ -67,6 +69,7 @@ const makeHarnessFor = Effect.fnUntraced(function* (
let codeError: string | undefined;
const revocations: URLSearchParams[] = [];
let transient = false;
+ let transientStatus: number | "connection-reset" = 503;
let invalidNonce = false;
let identityFailure: "issuer" | "audience" | "signature" | undefined;
let mismatchedState = false;
@@ -126,7 +129,11 @@ const makeHarnessFor = Effect.fnUntraced(function* (
if (body.get("grant_type") === "refresh_token") {
refreshes++;
if (transient) {
- response.statusCode = 503;
+ if (transientStatus === "connection-reset") {
+ request.socket.destroy();
+ return;
+ }
+ response.statusCode = transientStatus;
response.end(JSON.stringify({ error: "temporarily_unavailable" }));
return;
}
@@ -272,13 +279,20 @@ const makeHarnessFor = Effect.fnUntraced(function* (
callbackResponses.push({ body: await response.text(), headers: response.headers });
});
});
- const seedExpired = Effect.gen(function* () {
+ const seedExpiry = Effect.fn("CodexChatGptAuth.test.seedExpiry")(function* (remainingMs: number) {
const stored = yield* auth.read;
const record = Option.getOrThrow(stored);
const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe(
Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),
);
- yield* store.set(new TextEncoder().encode(JSON.stringify({ ...record, expiresAt: 0 })));
+ yield* store.set(
+ new TextEncoder().encode(
+ JSON.stringify({
+ ...record,
+ expiresAt: (yield* Clock.currentTimeMillis) + remainingMs,
+ }),
+ ),
+ );
});
return {
auth,
@@ -348,7 +362,8 @@ const makeHarnessFor = Effect.fnUntraced(function* (
changeAccount: signInWithMethod("chatgpt-change-account"),
reconnectProfile: (clientId: string) => signInWithMethod(`chatgpt-profile:${clientId}`),
phase,
- seedExpired,
+ seedExpired: seedExpiry(0),
+ seedExpiring: seedExpiry(30_000),
exchanges,
authorizationRequests,
callbackResponses,
@@ -375,8 +390,9 @@ const makeHarnessFor = Effect.fnUntraced(function* (
setRevoked: () => {
revoked = true;
},
- setTransient: (value: boolean) => {
+ setTransient: (value: boolean, status: number | "connection-reset" = 503) => {
transient = value;
+ transientStatus = status;
},
setInvalidNonce: () => {
invalidNonce = true;
@@ -706,7 +722,7 @@ it.effect(
yield* h.phase("succeeded");
assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "user-test");
assert.strictEqual(h.exchanges[0]?.get("client_id"), "oaiapp_test");
- yield* h.seedExpired;
+ yield* h.seedExpiring;
const records = yield* Effect.all([h.auth.access, h.auth.access], {
concurrency: "unbounded",
});
@@ -714,6 +730,9 @@ it.effect(
assert.strictEqual(records[0].accessToken, "access-1");
assert.strictEqual(records[1].refreshToken, "refresh-1");
assert.isNull(h.exchanges[1]?.get("scope"));
+ yield* h.seedExpired;
+ assert.strictEqual((yield* h.auth.access).refreshToken, "refresh-2");
+ assert.strictEqual(h.exchanges[2]?.get("refresh_token"), "refresh-1");
yield* h.auth.controller.logout(Effect.void);
assert.isTrue(Option.isNone(yield* h.auth.read));
}),
@@ -760,17 +779,17 @@ it.effect("clears revoked refresh credentials and does not replay them", () =>
),
);
-it.effect(
- "preserves credentials through temporary renewal failure and retries the latest refresh",
- () =>
+it.effect.each([429, 500, 502, 503, 504, "connection-reset"] as const)(
+ "preserves credentials through temporary renewal failure and retries the latest refresh: %s",
+ (status) =>
provision(
Effect.gen(function* () {
const h = yield* makeHarness;
yield* h.signIn;
yield* h.phase("succeeded");
yield* h.seedExpired;
- h.setTransient(true);
- yield* h.auth.access.pipe(Effect.result);
+ h.setTransient(true, status);
+ assert.equal((yield* h.auth.access.pipe(Effect.result))._tag, "Failure");
assert.strictEqual(Option.getOrThrow(yield* h.auth.read).refreshToken, "initial-refresh");
h.setTransient(false);
const renewed = yield* h.auth.access;
@@ -1767,6 +1786,24 @@ const provideBroker = (effect: Effect.Effect) =>
),
);
+it.effect("an OAuth timeout retains the saved refresh token for a later successful renewal", () =>
+ provideBroker(
+ Effect.gen(function* () {
+ const h = yield* makeRenewalBroker;
+ h.pause();
+ const renewal = yield* h.auth.access.pipe(Effect.flip, Effect.forkChild);
+ yield* Deferred.await(h.oauthEntered);
+ yield* TestClock.adjust("20 seconds");
+ assert.equal((yield* Fiber.join(renewal)).operation, "refresh");
+ assert.equal(Option.getOrThrow(yield* h.auth.read).refreshToken, "single-use-refresh");
+ yield* Deferred.succeed(h.oauthRelease, undefined);
+ assert.equal((yield* h.auth.access).accessToken, "renewed-access");
+ assert.equal(Option.getOrThrow(yield* h.auth.read).refreshToken, "rotated-refresh");
+ assert.deepEqual(h.refreshTokens, ["single-use-refresh", "single-use-refresh"]);
+ }),
+ ),
+);
+
// regression: #16132 a renewal must outlive the Codex helper that asked for it, because the
// refresh token it spends is single-use.
it.effect("server-owned renewal persists rotated credentials after the helper disconnects", () =>
diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts
index 8f8408bdaee1f..57b0efd8c9db1 100644
--- a/apps/server/src/provider/CodexManagedRuntime.test.ts
+++ b/apps/server/src/provider/CodexManagedRuntime.test.ts
@@ -1,5 +1,6 @@
// @effect-diagnostics nodeBuiltinImport:off - checks the host's default Codex directory without writing to it.
import * as NodeURL from "node:url";
+import * as NodeHttp from "node:http";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
@@ -9,6 +10,7 @@ import { assert, it } from "@effect/vitest";
import { CodexSettings, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
+import * as Deferred from "effect/Deferred";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Layer from "effect/Layer";
@@ -19,7 +21,9 @@ import * as ServerConfig from "../config.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as CodexInstallation from "./CodexInstallation.ts";
-import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts";
+import { makeCodexManagedRuntime, type CodexEffectiveRuntime } from "./CodexManagedRuntime.ts";
+import { checkCodexProviderStatus, withCodexAppServerClient } from "./CodexProvider.ts";
+import type * as CodexSchema from "effect-codex-app-server/schema";
import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
import { codexAppServerArgs } from "./codexLaunchArgs.ts";
@@ -28,6 +32,166 @@ const decodeSettings = Schema.decodeSync(CodexSettings);
const decodeCommand = Schema.decodeUnknownSync(
Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.Array(Schema.String)])),
);
+// An explicit binary enables native protocol proof without making routine tests
+// depend on a globally installed CLI or a real ChatGPT account.
+const nativeCodexBinary = process.env.T3CODE_TEST_CODEX_BINARY;
+const exerciseNativeRenewal = Effect.fn("CodexManagedRuntime.test.nativeRenewal")(function* <
+ A,
+ E,
+ R,
+>(binaryPath: string, effective: CodexEffectiveRuntime, renew: Effect.Effect) {
+ const expiredRequest = Promise.withResolvers();
+ const requests: string[] = [];
+ const server = yield* Effect.acquireRelease(
+ Effect.callback((resume) => {
+ const server = NodeHttp.createServer((request, response) => {
+ request.on("error", () => response.destroy());
+ request.resume();
+ if (request.url !== "/v1/responses") {
+ response.writeHead(404).end();
+ return;
+ }
+ const credential =
+ request.headers.authorization === "Bearer dummy-owned-access"
+ ? "initial"
+ : request.headers.authorization === "Bearer renewed-access"
+ ? "renewed"
+ : "unexpected";
+ requests.push(credential);
+ if (credential === "initial") {
+ expiredRequest.resolve(response);
+ return;
+ }
+ if (credential !== "renewed") {
+ response.writeHead(403).end();
+ return;
+ }
+ const message = {
+ id: "msg_fixture",
+ type: "message",
+ role: "assistant",
+ status: "completed",
+ content: [{ type: "output_text", text: "renewal survived", annotations: [] }],
+ };
+ const events = [
+ {
+ type: "response.created",
+ response: { id: "resp_fixture", status: "in_progress", output: [] },
+ },
+ { type: "response.output_item.done", output_index: 0, item: message },
+ {
+ type: "response.completed",
+ response: {
+ id: "resp_fixture",
+ status: "completed",
+ output: [message],
+ usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
+ },
+ },
+ ];
+ response.writeHead(200, { "content-type": "text/event-stream" });
+ response.end(
+ events
+ .map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
+ .join(""),
+ );
+ });
+ server.once("error", (error) => resume(Effect.fail(error)));
+ server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server)));
+ }),
+ (server) =>
+ Effect.promise(
+ () =>
+ new Promise((resolve) => {
+ server.closeAllConnections();
+ server.close(() => resolve());
+ }),
+ ),
+ );
+ const address = server.address();
+ assert.isNotNull(address);
+ if (!address || typeof address === "string") return yield* Effect.die("Missing fixture port");
+ const baseUrl = `http://127.0.0.1:${address.port}/v1`;
+ const args = [
+ `model_providers.openai_token_sharing.base_url=${JSON.stringify(baseUrl)}`,
+ `model_providers.openai_token_sharing.model_catalog_url=${JSON.stringify(`${baseUrl}/models`)}`,
+ "features.apps=false",
+ "features.plugins=false",
+ "check_for_update_on_startup=false",
+ ]
+ .map((value) => `-c ${JSON.stringify(value)}`)
+ .join(" ");
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const children: ChildProcessSpawner.ChildProcessHandle[] = [];
+ yield* Effect.gen(function* () {
+ const { client } = yield* withCodexAppServerClient({
+ binaryPath,
+ homePath: effective.config.homePath,
+ launchArgs: `${effective.config.launchArgs} ${args}`,
+ cwd: effective.config.homePath,
+ environment: effective.environment,
+ });
+ const completed = yield* Deferred.make();
+ yield* client.handleServerNotification("turn/completed", (payload) =>
+ Deferred.succeed(completed, payload),
+ );
+ const thread = yield* client.request("thread/start", {
+ cwd: effective.config.homePath,
+ model: "gpt-5.4-mini",
+ approvalPolicy: "never",
+ sandbox: "danger-full-access",
+ });
+ const turn = yield* client.request("turn/start", {
+ threadId: thread.thread.id,
+ input: [{ type: "text", text: "Reply with a short greeting." }],
+ });
+ const response = yield* Effect.promise(() => expiredRequest.promise);
+ yield* renew;
+ response.writeHead(401, { "content-type": "application/json" }).end(
+ JSON.stringify({
+ error: {
+ type: "invalid_request_error",
+ code: "token_expired",
+ message: "Provided authentication token is expired.",
+ },
+ }),
+ );
+ const finished = yield* Deferred.await(completed);
+ assert.equal(finished.threadId, thread.thread.id);
+ assert.equal(finished.turn.id, turn.turn.id);
+ assert.equal(finished.turn.status, "completed");
+ assert.deepEqual(requests, ["initial", "renewed"]);
+ assert.lengthOf(children, 1);
+ assert.isTrue(yield* children[0]!.isRunning);
+ }).pipe(
+ Effect.scoped,
+ Effect.provideService(
+ ChildProcessSpawner.ChildProcessSpawner,
+ ChildProcessSpawner.make((command) =>
+ spawner.spawn(command).pipe(
+ Effect.tap((child) =>
+ Effect.sync(() => {
+ children.push(child);
+ }),
+ ),
+ ),
+ ),
+ ),
+ );
+ assert.isFalse(yield* children[0]!.isRunning);
+ const provider = yield* checkCodexProviderStatus(
+ {
+ ...effective.config,
+ binaryPath,
+ launchArgs: `${effective.config.launchArgs} ${args}`,
+ },
+ undefined,
+ effective.environment,
+ { status: "authenticated", type: "chatgpt", label: "ChatGPT" },
+ );
+ assert.equal(provider.status, "ready");
+ assert.equal(provider.auth.status, "authenticated");
+});
it.live.each(
(["managed", "local"] as const).flatMap((source) =>
(["primary", "additional"] as const).map((account) => ({ source, account })),
@@ -94,8 +258,11 @@ it.live.each(
email: null,
});
yield* store.set(new TextEncoder().encode(json));
+ const sqliteOverride =
+ source === "local" ? path.join(sharedHome, "sqlite-override") : undefined;
const ambient = {
CODEX_HOME: "/user/.codex",
+ ...(sqliteOverride ? { CODEX_SQLITE_HOME: sqliteOverride } : {}),
ACCESS_TOKEN: "ambient-bearer",
T3CODE_HOME: path.join(sharedHome, "wrong-server-home"),
OPENAI_API_KEY: "dummy-global-key",
@@ -116,12 +283,16 @@ it.live.each(
assert.strictEqual(effective.config.binaryPath, executable.executablePath);
assert.notStrictEqual(effective.config.homePath, ambient.CODEX_HOME);
assert.equal(runtime.homeLayout.sharedHomePath, sharedHome);
+ assert.equal(
+ effective.environment.CODEX_SQLITE_HOME,
+ sqliteOverride ?? (account === "additional" ? sharedHome : undefined),
+ );
if (account === "primary") {
assert.equal(effective.config.homePath, sharedHome);
assert.equal(runtime.homeLayout.mode, "direct");
} else {
assert.include(effective.config.homePath, instanceId);
- assert.include(effective.config.homePath, "userdata/providers/codex");
+ assert.include(effective.config.homePath, path.join("userdata", "providers", "codex"));
assert.equal(runtime.homeLayout.mode, "authOverlay");
assert.equal(
yield* fs.readLink(path.join(effective.config.homePath, "sessions")),
@@ -146,6 +317,31 @@ it.live.each(
assert.include(provider, 'model_catalog_url="https://api.openai.com/v1/models"');
assert.notInclude(provider, "env_key");
assert.include(provider, "refresh_interval_ms=0");
+ if (nativeCodexBinary && source === "managed" && account === "additional") {
+ yield* exerciseNativeRenewal(
+ nativeCodexBinary,
+ effective,
+ Effect.gen(function* () {
+ const saved = Option.getOrThrow(yield* runtime.auth.read);
+ const expired = yield* encodeJson({ ...saved, expiresAt: 0 });
+ yield* store.set(new TextEncoder().encode(expired));
+ assert.equal((yield* runtime.auth.access).accessToken, "renewed-access");
+ assert.equal(
+ Option.getOrThrow(yield* runtime.auth.read).refreshToken,
+ "renewed-refresh",
+ );
+ assert.equal(renewals, 1);
+ }),
+ );
+ assert.isTrue(yield* fs.exists(path.join(sharedHome, "state_5.sqlite")));
+ assert.deepEqual(
+ (yield* fs.readDirectory(runtime.homePath)).filter((name) =>
+ name.includes(".sqlite"),
+ ),
+ [],
+ );
+ assert.equal((yield* runtime.resolve).revision, effective.revision);
+ }
// regression: #16132 Codex fetches each token through this helper, so a running
// session picks up renewals instead of keeping the token it launched with.
if (source === "managed" && account === "primary") {
diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts
index 3704c919c2791..24643a74c37c3 100644
--- a/apps/server/src/provider/CodexManagedRuntime.ts
+++ b/apps/server/src/provider/CodexManagedRuntime.ts
@@ -11,7 +11,7 @@ import { resolveSelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeR
import * as CodexInstallation from "./CodexInstallation.ts";
import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts";
import { COMMAND_TIMEOUT_MS } from "./CodexManagedTokenProtocol.ts";
-import { materializeCodexShadowHome } from "./Drivers/CodexHomeLayout.ts";
+import { codexHomeEnvironment, materializeCodexShadowHome } from "./Drivers/CodexHomeLayout.ts";
export interface CodexEffectiveRuntime {
readonly config: CodexSettings;
@@ -115,7 +115,7 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func
);
// Ambient CLI overrides cannot redirect a T3-owned token to a different provider.
const environment: NodeJS.ProcessEnv = {
- ...options.environment,
+ ...codexHomeEnvironment(homeLayout, options.environment),
CODEX_HOME: homePath,
};
delete environment.ACCESS_TOKEN;
diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts
index 30f63b21f318e..968ccfc03ee20 100644
--- a/apps/server/src/provider/CodexProvider.test.ts
+++ b/apps/server/src/provider/CodexProvider.test.ts
@@ -1,6 +1,111 @@
import { assert, it } from "@effect/vitest";
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { CodexSettings } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Deferred from "effect/Deferred";
+import * as Fiber from "effect/Fiber";
+import * as FileSystem from "effect/FileSystem";
+import * as Queue from "effect/Queue";
+import * as Schema from "effect/Schema";
+import * as Sink from "effect/Sink";
+import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
+import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
-import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts";
+import {
+ applyPreferredCodexDefaultModel,
+ checkCodexProviderStatus,
+ mapCodexModelCapabilities,
+} from "./CodexProvider.ts";
+
+const decodeCodexSettings = Schema.decodeSync(CodexSettings);
+const decodeRequest = Schema.decodeUnknownSync(
+ Schema.fromJsonString(
+ Schema.Struct({
+ id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Number])),
+ method: Schema.String,
+ }),
+ ),
+);
+
+it.effect("keeps account authentication when optional model discovery never answers", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const homePath = yield* fs.makeTempDirectoryScoped();
+ const output = yield* Queue.unbounded();
+ const modelRequested = yield* Deferred.make();
+ const stopped = yield* Deferred.make();
+ const spawner = ChildProcessSpawner.make(() =>
+ Effect.gen(function* () {
+ yield* Effect.addFinalizer(() => Deferred.succeed(stopped, undefined));
+ return ChildProcessSpawner.makeHandle({
+ pid: ChildProcessSpawner.ProcessId(1),
+ exitCode: Deferred.await(stopped).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
+ isRunning: Deferred.isDone(stopped).pipe(Effect.map((done) => !done)),
+ kill: () => Deferred.succeed(stopped, undefined).pipe(Effect.asVoid),
+ unref: Effect.succeed(Effect.void),
+ stdin: Sink.forEach((bytes: Uint8Array) =>
+ Effect.forEach(
+ new TextDecoder().decode(bytes).split("\n").filter(Boolean),
+ (line) =>
+ Effect.gen(function* () {
+ const { id, method } = decodeRequest(line);
+ if (method === "model/list") {
+ yield* Deferred.succeed(modelRequested, undefined);
+ return;
+ }
+ if (id === undefined || !["initialize", "account/read"].includes(method)) return;
+ const result =
+ method === "initialize"
+ ? {
+ userAgent: "codex/0.164.0",
+ codexHome: homePath,
+ platformFamily: "unix",
+ platformOs: "linux",
+ }
+ : {
+ account: {
+ type: "chatgpt",
+ email: "account@example.test",
+ planType: "pro",
+ },
+ requiresOpenaiAuth: false,
+ };
+ yield* Queue.offer(
+ output,
+ new TextEncoder().encode(`${JSON.stringify({ id, result })}\n`),
+ );
+ }),
+ { discard: true },
+ ),
+ ),
+ stdout: Stream.fromQueue(output),
+ stderr: Stream.empty,
+ all: Stream.empty,
+ getInputFd: () => Sink.drain,
+ getOutputFd: () => Stream.empty,
+ });
+ }),
+ );
+ const probe = yield* checkCodexProviderStatus(
+ decodeCodexSettings({ homePath, customModels: [{ slug: "custom-model" }] }),
+ ).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Effect.forkChild,
+ );
+ yield* Deferred.await(modelRequested);
+ yield* TestClock.adjust("20 seconds");
+ const provider = yield* Fiber.join(probe);
+ assert.equal(provider.status, "warning");
+ assert.equal(provider.auth.status, "authenticated");
+ assert.equal(provider.message, "Codex did not return its model catalog in time.");
+ assert.deepEqual(
+ provider.models.map((model) => model.slug),
+ ["custom-model"],
+ );
+ assert.isTrue(yield* Deferred.isDone(stopped));
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+);
it("maps current Codex model capability fields", () => {
const capabilities = mapCodexModelCapabilities({
diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts
index db8f42faea7f0..1b222bfc52c84 100644
--- a/apps/server/src/provider/Drivers/CodexDriver.test.ts
+++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts
@@ -19,6 +19,7 @@ import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
import { HttpClient, HttpClientResponse } from "effect/http";
import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
@@ -103,7 +104,7 @@ const noSpawn = ChildProcessSpawner.make(() =>
const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
it.layer(layerTest)("CodexDriver", (it) => {
- const checkManagedAccount = (replacementTiming: "unchanged" | "during-probe") =>
+ const checkManagedAccount = (replacementTiming: "unchanged" | "during-probe" | "probe-timeout") =>
Effect.gen(function* () {
const instanceId = ProviderInstanceId.make("restored-managed-account");
const credentials = new Map();
@@ -156,6 +157,18 @@ it.layer(layerTest)("CodexDriver", (it) => {
const installation = yield* CodexInstallation.CodexInstallation;
const serverConfig = yield* ServerConfig.ServerConfig;
const sharedHome = NodePath.join(serverConfig.stateDir, "shared-codex-home");
+ const fs = yield* FileSystem.FileSystem;
+ const shadowHome = NodePath.join(
+ serverConfig.stateDir,
+ "providers",
+ "codex",
+ instanceId,
+ "shadow",
+ );
+ yield* fs.makeDirectory(sharedHome, { recursive: true });
+ yield* fs.makeDirectory(shadowHome, { recursive: true });
+ yield* fs.writeFileString(NodePath.join(sharedHome, "logs_2.sqlite-shm"), "shared shm");
+ yield* fs.writeFileString(NodePath.join(shadowHome, "logs_2.sqlite-shm"), "local shm");
const launches: Array<
Parameters[0]
> = [];
@@ -209,6 +222,10 @@ it.layer(layerTest)("CodexDriver", (it) => {
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.gen(function* () {
+ if (replacementTiming === "probe-timeout") {
+ yield* Deferred.succeed(probeEntered, undefined);
+ return yield* Effect.never;
+ }
if (replacementTiming === "during-probe") {
yield* Deferred.succeed(probeEntered, undefined);
yield* Deferred.await(resumeProbe);
@@ -235,6 +252,10 @@ it.layer(layerTest)("CodexDriver", (it) => {
);
yield* Deferred.await(startupChecksInterrupted);
const refresh = yield* instance.snapshot.refresh.pipe(Effect.forkChild);
+ if (replacementTiming === "probe-timeout") {
+ yield* Deferred.await(probeEntered);
+ yield* TestClock.adjust("20 seconds");
+ }
if (replacementTiming === "during-probe") {
yield* Deferred.await(probeEntered);
yield* replaceAccount;
@@ -250,10 +271,20 @@ it.layer(layerTest)("CodexDriver", (it) => {
}
expect(catalogTokens).toEqual(["Bearer dummy-owned-access"]);
expect(restored.auth.email).toBe("account@example.test");
+ expect(restored.auth.status).toBe("authenticated");
+ expect(Option.isSome(yield* store.get)).toBe(true);
expect(restored.runtimePaths?.homePath).toBe(sharedHome);
expect(restored.runtimePaths?.shadowHomePath).toContain(
- `providers/codex/${instanceId}/shadow`,
+ NodePath.join("providers", "codex", instanceId, "shadow"),
+ );
+ expect(yield* fs.readFileString(NodePath.join(shadowHome, "logs_2.sqlite-shm"))).toBe(
+ "local shm",
);
+ if (replacementTiming === "probe-timeout") {
+ expect(restored.status).toBe("error");
+ expect(restored.message).toContain("Timed out while checking Codex");
+ return;
+ }
// The fixture app-server cannot start, so the workspace scan fails
// instead of passing the machine snapshot off as the workspace's skills.
const scan = yield* instance.snapshotForCwd!(serverConfig.stateDir).pipe(Effect.exit);
@@ -290,6 +321,7 @@ it.layer(layerTest)("CodexDriver", (it) => {
expect(launch.settings.launchArgs).not.toContain("dummy-owned-access");
expect(launch.environment.OPENAI_API_KEY).toBeUndefined();
expect(launch.environment.CODEX_HOME).toBe(launch.settings.homePath);
+ expect(launch.environment.CODEX_SQLITE_HOME).toBe(sharedHome);
const before = yield* instance.auth!.subscribe("test-owner").pipe(Stream.runHead);
expect(Option.getOrThrow(before).phase).toBe("idle");
yield* instance.auth!.logout(Effect.void);
@@ -337,7 +369,7 @@ it.layer(layerTest)("CodexDriver", (it) => {
}).pipe(Effect.scoped);
// regression: a sign-in that replaces the account mid-probe must not label the old
// account's catalog with the new identity.
- it.effect.each(["unchanged", "during-probe"] as const)(
+ it.effect.each(["unchanged", "during-probe", "probe-timeout"] as const)(
"managed catalog keeps its account label and token together: %s",
checkManagedAccount,
);
diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts
index 5462b13867b71..45346f3cf140c 100644
--- a/apps/server/src/provider/Drivers/CodexDriver.ts
+++ b/apps/server/src/provider/Drivers/CodexDriver.ts
@@ -47,6 +47,7 @@ import {
} from "@t3tools/provider-core/server/snapshotSettings";
import {
codexContinuationIdentity,
+ codexHomeEnvironment,
materializeCodexShadowHome,
resolveCodexHomeLayout,
} from "./CodexHomeLayout.ts";
@@ -139,8 +140,11 @@ export const CodexDriver: ProviderDriver<
const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
const modelCatalog = yield* ModelCatalog.ModelCatalog;
const currentCatalog = modelCatalog.current(DRIVER_KIND);
- const processEnv = yield* mergeProviderInstanceEnvironment(environment);
const homeLayout = yield* resolveCodexHomeLayout(config);
+ const processEnv = codexHomeEnvironment(
+ homeLayout,
+ yield* mergeProviderInstanceEnvironment(environment),
+ );
const continuationIdentity = codexContinuationIdentity(homeLayout);
const stampIdentity = withInstanceIdentity({
instanceId,
diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
index 093777e67db5d..293387ad61110 100644
--- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
+++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
@@ -65,6 +65,58 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
});
describe("materializeCodexShadowHome", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "preserves SQLite files when shared sidecars appear after the shadow home starts",
+ () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const sharedHome = yield* makeTempDir("t3code-codex-shared-");
+ const shadowHome = yield* makeTempDir("t3code-codex-shadow-");
+ const layout = yield* resolveCodexHomeLayout(
+ decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }),
+ );
+ yield* writeTextFile(path.join(sharedHome, "logs_2.sqlite"), "shared logs");
+ yield* writeTextFile(path.join(sharedHome, "state_5.sqlite"), "shared state");
+ // An existing overlay may already link the database. Never remove its
+ // live WAL or replace files while another Codex process has them open.
+ yield* fileSystem.symlink(
+ path.join(sharedHome, "state_5.sqlite"),
+ path.join(shadowHome, "state_5.sqlite"),
+ );
+ yield* materializeCodexShadowHome(layout);
+
+ const localFiles = ["logs_2.sqlite-shm", "logs_2.sqlite-wal", "state_5.sqlite-journal"];
+ for (const name of localFiles) {
+ yield* writeTextFile(path.join(shadowHome, name), `local ${name}`);
+ yield* writeTextFile(path.join(sharedHome, name), `shared ${name}`);
+ }
+ yield* materializeCodexShadowHome(layout);
+ yield* writeTextFile(path.join(shadowHome, "goals_1.sqlite"), "local goals");
+ yield* writeTextFile(path.join(sharedHome, "goals_1.sqlite"), "shared goals");
+ yield* materializeCodexShadowHome(layout);
+
+ for (const name of localFiles) {
+ expect(yield* fileSystem.readFileString(path.join(shadowHome, name))).toBe(
+ `local ${name}`,
+ );
+ expect(yield* fileSystem.readFileString(path.join(sharedHome, name))).toBe(
+ `shared ${name}`,
+ );
+ }
+ expect(yield* fileSystem.exists(path.join(shadowHome, "logs_2.sqlite"))).toBe(false);
+ expect(yield* fileSystem.readFileString(path.join(shadowHome, "goals_1.sqlite"))).toBe(
+ "local goals",
+ );
+ expect(yield* fileSystem.readLink(path.join(shadowHome, "state_5.sqlite"))).toBe(
+ path.join(sharedHome, "state_5.sqlite"),
+ );
+ expect(yield* fileSystem.readLink(path.join(shadowHome, "sessions"))).toBe(
+ path.join(sharedHome, "sessions"),
+ );
+ }),
+ );
+
it.effect.skipIf(!symlinksSupported)(
"keeps Finder metadata local across repeated shadow home setup",
() =>
diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts
index b0bb7df4e2d46..0e35096aedec4 100644
--- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts
+++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts
@@ -31,6 +31,9 @@ const KNOWN_SHARED_DIRECTORIES = [
const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]);
// Finder maintains metadata separately for each directory, including shadow homes.
const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp", ".DS_Store"]);
+// SQLite can create WAL/SHM beside the link on Windows. Route databases through
+// CODEX_SQLITE_HOME instead; never replace existing databases or live sidecars.
+const SQLITE_ENTRY_NAME = /\.sqlite(?:-(?:shm|wal|journal))?$/u;
// Codex creates these runtime locks itself when it starts in a shadow home before the shared
// home has one. They guard shared state, so a local copy is replaced with the shared link.
const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set(["mcp-oauth-locks", ".sqlite-maintenance.lock"]);
@@ -66,6 +69,14 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi
};
});
+export const codexHomeEnvironment = (layout: CodexHomeLayout, environment: NodeJS.ProcessEnv) =>
+ layout.mode === "authOverlay"
+ ? {
+ ...environment,
+ CODEX_SQLITE_HOME: environment.CODEX_SQLITE_HOME?.trim() || layout.sharedHomePath,
+ }
+ : environment;
+
const CodexShadowHomeContext = {
sharedHomePath: Schema.String,
effectiveHomePath: Schema.String,
@@ -373,7 +384,11 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome"
);
const entries = new Set(KNOWN_SHARED_DIRECTORIES);
for (const entryName of sharedEntryNames) {
- if (!PRIVATE_ENTRY_NAMES.has(entryName) && !SHADOW_LOCAL_ENTRY_NAMES.has(entryName)) {
+ if (
+ !PRIVATE_ENTRY_NAMES.has(entryName) &&
+ !SHADOW_LOCAL_ENTRY_NAMES.has(entryName) &&
+ !SQLITE_ENTRY_NAME.test(entryName)
+ ) {
entries.add(entryName);
}
}
diff --git a/packages/shared/src/testing/providerEnv.ts b/packages/shared/src/testing/providerEnv.ts
index 21bf59e52b500..985305530ea10 100644
--- a/packages/shared/src/testing/providerEnv.ts
+++ b/packages/shared/src/testing/providerEnv.ts
@@ -10,6 +10,8 @@ const KEPT_ENV = new Set([
"T3CODE_TRANSFER_BUDGET_RESULT_PATH",
// Opt-in: points the HtmlRender tests at a headless browser, which they skip without.
"T3CODE_TEST_HEADLESS_SHELL",
+ // Opt-in native managed-auth integration uses synthetic credentials and an isolated home.
+ "T3CODE_TEST_CODEX_BINARY",
]);
if (process.env.T3CODE_TEST_KEEP_PROVIDER_ENV !== "1") {
From fb07cb311b31bb6dc4d5223a682d1e90d843dc8e Mon Sep 17 00:00:00 2001
From: uShifty <51722240+uShifty@users.noreply.github.com>
Date: Sat, 24 Oct 2026 06:50:43 -0600
Subject: [PATCH 2/5] test(server): verify Codex provider probe calls kill
during cleanup
---
apps/server/src/provider/CodexProvider.test.ts | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts
index 968ccfc03ee20..d4fe67498db71 100644
--- a/apps/server/src/provider/CodexProvider.test.ts
+++ b/apps/server/src/provider/CodexProvider.test.ts
@@ -35,14 +35,18 @@ it.effect("keeps account authentication when optional model discovery never answ
const output = yield* Queue.unbounded();
const modelRequested = yield* Deferred.make();
const stopped = yield* Deferred.make();
+ const killed = yield* Deferred.make();
const spawner = ChildProcessSpawner.make(() =>
Effect.gen(function* () {
- yield* Effect.addFinalizer(() => Deferred.succeed(stopped, undefined));
- return ChildProcessSpawner.makeHandle({
+ const handle = ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Deferred.await(stopped).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
isRunning: Deferred.isDone(stopped).pipe(Effect.map((done) => !done)),
- kill: () => Deferred.succeed(stopped, undefined).pipe(Effect.asVoid),
+ kill: () =>
+ Deferred.succeed(killed, undefined).pipe(
+ Effect.andThen(Deferred.succeed(stopped, undefined)),
+ Effect.asVoid,
+ ),
unref: Effect.succeed(Effect.void),
stdin: Sink.forEach((bytes: Uint8Array) =>
Effect.forEach(
@@ -85,6 +89,8 @@ it.effect("keeps account authentication when optional model discovery never answ
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
});
+ yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.orDie));
+ return handle;
}),
);
const probe = yield* checkCodexProviderStatus(
@@ -103,7 +109,7 @@ it.effect("keeps account authentication when optional model discovery never answ
provider.models.map((model) => model.slug),
["custom-model"],
);
- assert.isTrue(yield* Deferred.isDone(stopped));
+ assert.isTrue(yield* Deferred.isDone(killed));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
From 489522f372f2cd8b1503c3cb5f7cce008ca01cd7 Mon Sep 17 00:00:00 2001
From: uShifty <51722240+uShifty@users.noreply.github.com>
Date: Tue, 27 Oct 2026 18:41:27 -0600
Subject: [PATCH 3/5] fix(server): keep existing Codex WAL data accessible
---
.../Adapters/CodexAdapterV2.ts | 12 ++--
.../src/provider/CodexManagedRuntime.ts | 5 +-
.../src/provider/Drivers/CodexDriver.test.ts | 2 +-
.../src/provider/Drivers/CodexDriver.ts | 8 +--
.../provider/Drivers/CodexHomeLayout.test.ts | 64 +++++++++++++++++++
.../src/provider/Drivers/CodexHomeLayout.ts | 42 +++++++++---
6 files changed, 112 insertions(+), 21 deletions(-)
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
index c9c387896b18d..f6915678257f2 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
@@ -1568,7 +1568,12 @@ export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapter
const serverConfig = yield* ServerConfig;
const homeLayout = yield* resolveCodexHomeLayout(config);
- yield* materializeCodexShadowHome(homeLayout).pipe(
+ const environmentOverrides = yield* mergeProviderInstanceEnvironment(
+ environment,
+ hostEnvironment,
+ );
+ const processEnv = yield* materializeCodexShadowHome(homeLayout).pipe(
+ Effect.andThen(() => codexHomeEnvironment(homeLayout, environmentOverrides)),
Effect.mapError(
(cause) =>
new ProviderAdapterDriverCreateError({
@@ -1590,10 +1595,7 @@ export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapter
return yield* makeCodexAdapterV2({
instanceId,
settings,
- environment: codexHomeEnvironment(
- homeLayout,
- yield* mergeProviderInstanceEnvironment(environment, hostEnvironment),
- ),
+ environment: processEnv,
clientFactory,
crypto,
fileSystem,
diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts
index 24643a74c37c3..d8777f20e3897 100644
--- a/apps/server/src/provider/CodexManagedRuntime.ts
+++ b/apps/server/src/provider/CodexManagedRuntime.ts
@@ -91,7 +91,8 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func
environmentId,
profileKey,
]);
- yield* materializeCodexShadowHome(homeLayout).pipe(
+ const homeEnvironment = yield* materializeCodexShadowHome(homeLayout).pipe(
+ Effect.andThen(() => codexHomeEnvironment(homeLayout, options.environment)),
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
Effect.mapError(
@@ -115,7 +116,7 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func
);
// Ambient CLI overrides cannot redirect a T3-owned token to a different provider.
const environment: NodeJS.ProcessEnv = {
- ...codexHomeEnvironment(homeLayout, options.environment),
+ ...homeEnvironment,
CODEX_HOME: homePath,
};
delete environment.ACCESS_TOKEN;
diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts
index 010cc2d2aa09b..7febe8cbaaa9c 100644
--- a/apps/server/src/provider/Drivers/CodexDriver.test.ts
+++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts
@@ -317,7 +317,7 @@ it.layer(layerTest)("CodexDriver", (it) => {
expect(launch.settings.launchArgs).not.toContain("dummy-owned-access");
expect(launch.environment.OPENAI_API_KEY).toBeUndefined();
expect(launch.environment.CODEX_HOME).toBe(launch.settings.homePath);
- expect(launch.environment.CODEX_SQLITE_HOME).toBe(sharedHome);
+ expect(launch.environment.CODEX_SQLITE_HOME).toBe(shadowHome);
const before = yield* instance.auth!.subscribe("test-owner").pipe(Stream.runHead);
expect(Option.getOrThrow(before).phase).toBe("idle");
yield* instance.auth!.logout(Effect.void);
diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts
index 5ba9236eec6f3..0564c751abe5d 100644
--- a/apps/server/src/provider/Drivers/CodexDriver.ts
+++ b/apps/server/src/provider/Drivers/CodexDriver.ts
@@ -136,10 +136,7 @@ export const CodexDriver: ProviderDriver<
const modelCatalog = yield* ModelCatalog.ModelCatalog;
const currentCatalog = modelCatalog.current(DRIVER_KIND);
const homeLayout = yield* resolveCodexHomeLayout(config);
- const processEnv = codexHomeEnvironment(
- homeLayout,
- yield* mergeProviderInstanceEnvironment(environment),
- );
+ const environmentOverrides = yield* mergeProviderInstanceEnvironment(environment);
const continuationIdentity = codexContinuationIdentity(homeLayout);
const stampIdentity = withInstanceIdentity({
instanceId,
@@ -148,7 +145,8 @@ export const CodexDriver: ProviderDriver<
accentColor,
continuationGroupKey: continuationIdentity.continuationKey,
});
- yield* materializeCodexShadowHome(homeLayout).pipe(
+ const processEnv = yield* materializeCodexShadowHome(homeLayout).pipe(
+ Effect.andThen(() => codexHomeEnvironment(homeLayout, environmentOverrides)),
Effect.mapError(
(cause) =>
new ProviderDriverError({
diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
index 293387ad61110..66921cca7890e 100644
--- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
+++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
@@ -1,3 +1,6 @@
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodeSqlite from "node:sqlite";
+
import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
@@ -6,9 +9,11 @@ import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { CodexSettings } from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
import {
CodexShadowHomeEntryConflictError,
CodexShadowHomePathConflictError,
+ codexHomeEnvironment,
materializeCodexShadowHome,
resolveCodexHomeLayout,
} from "./CodexHomeLayout.ts";
@@ -64,6 +69,65 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
);
});
+ describe("codexHomeEnvironment", () => {
+ it.effect.each(["live", "interrupted"] as const)(
+ "reads committed shadow WAL data with a %s connection",
+ (connection) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const sharedHome = yield* makeTempDir("t3code-codex-shared-");
+ const shadowHome = yield* makeTempDir("t3code-codex-shadow-");
+ const sharedDatabasePath = path.join(sharedHome, "state_5.sqlite");
+ const shadowDatabasePath = path.join(shadowHome, "state_5.sqlite");
+ {
+ using seed = new NodeSqlite.DatabaseSync(sharedDatabasePath);
+ seed.exec("PRAGMA journal_mode = WAL; CREATE TABLE entries (value TEXT);");
+ }
+ // Windows places sidecars beside database links; Unix resolves the target first.
+ if ((yield* HostProcess.isWindows) && symlinksSupported) {
+ yield* fileSystem.symlink(sharedDatabasePath, shadowDatabasePath);
+ } else {
+ yield* fileSystem.copyFile(sharedDatabasePath, shadowDatabasePath);
+ }
+ using writer = new NodeSqlite.DatabaseSync(shadowDatabasePath);
+ writer.exec("PRAGMA wal_autocheckpoint = 0; INSERT INTO entries VALUES ('committed');");
+ const database = yield* fileSystem.readFile(shadowDatabasePath);
+ const walPath = `${shadowDatabasePath}-wal`;
+ const wal = yield* fileSystem.readFile(walPath);
+ expect(wal.byteLength).toBeGreaterThan(0);
+ if (connection === "interrupted") {
+ // Restore the uncheckpointed disk image only after SQLite closes our fixture.
+ writer.close();
+ yield* fileSystem.writeFile(shadowDatabasePath, database);
+ yield* fileSystem.writeFile(walPath, wal);
+ }
+ {
+ using sharedReader = new NodeSqlite.DatabaseSync(sharedDatabasePath);
+ expect(sharedReader.prepare("SELECT value FROM entries").all()).toEqual([]);
+ }
+
+ const layout = yield* resolveCodexHomeLayout(
+ decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }),
+ );
+ const environment = yield* codexHomeEnvironment(layout, {});
+ expect(yield* fileSystem.readFile(walPath)).toEqual(wal);
+ using reader = new NodeSqlite.DatabaseSync(
+ path.join(environment.CODEX_SQLITE_HOME!, "state_5.sqlite"),
+ );
+ expect(reader.prepare("SELECT value FROM entries").all()).toEqual([
+ { value: "committed" },
+ ]);
+ if (connection === "live") {
+ writer.exec("INSERT INTO entries VALUES ('still writable');");
+ expect(reader.prepare("SELECT count(*) AS count FROM entries").get()).toEqual({
+ count: 2,
+ });
+ }
+ }),
+ );
+ });
+
describe("materializeCodexShadowHome", () => {
it.effect.skipIf(!symlinksSupported)(
"preserves SQLite files when shared sidecars appear after the shadow home starts",
diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts
index 7e8a3db49a0ff..b8d439f398321 100644
--- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts
+++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts
@@ -68,14 +68,6 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi
};
});
-export const codexHomeEnvironment = (layout: CodexHomeLayout, environment: NodeJS.ProcessEnv) =>
- layout.mode === "authOverlay"
- ? {
- ...environment,
- CODEX_SQLITE_HOME: environment.CODEX_SQLITE_HOME?.trim() || layout.sharedHomePath,
- }
- : environment;
-
const CodexShadowHomeContext = {
sharedHomePath: Schema.String,
effectiveHomePath: Schema.String,
@@ -142,6 +134,40 @@ export const CodexShadowHomeError = Schema.Union([
]);
export type CodexShadowHomeError = typeof CodexShadowHomeError.Type;
+export const codexHomeEnvironment = Effect.fn("codexHomeEnvironment")(function* (
+ layout: CodexHomeLayout,
+ environment: NodeJS.ProcessEnv,
+) {
+ const effectiveHomePath = layout.effectiveHomePath;
+ if (layout.mode !== "authOverlay" || !effectiveHomePath) return environment;
+ const override = environment.CODEX_SQLITE_HOME?.trim();
+ if (override) return { ...environment, CODEX_SQLITE_HOME: override };
+
+ const fileSystem = yield* FileSystem.FileSystem;
+ const entries = yield* fileSystem.readDirectory(effectiveHomePath).pipe(
+ Effect.catchTags({
+ PlatformError: (cause) =>
+ cause.reason._tag === "NotFound"
+ ? Effect.succeed([])
+ : new CodexShadowHomeFileSystemError({
+ sharedHomePath: layout.sharedHomePath,
+ effectiveHomePath,
+ operation: "readDirectory",
+ path: effectiveHomePath,
+ cause,
+ }),
+ }),
+ );
+ // Keep existing databases on their original path so SQLite can recover WAL in place,
+ // including when another Codex process still owns a connection there.
+ return {
+ ...environment,
+ CODEX_SQLITE_HOME: entries.some((entry) => SQLITE_ENTRY_NAME.test(entry))
+ ? effectiveHomePath
+ : layout.sharedHomePath,
+ };
+});
+
type LinkState =
| {
readonly _tag: "Missing";
From 8dcd7544a0ba8cf47f4480ed763a8a4943751b8d Mon Sep 17 00:00:00 2001
From: uShifty <51722240+uShifty@users.noreply.github.com>
Date: Tue, 27 Oct 2026 19:07:40 -0600
Subject: [PATCH 4/5] test(server): explain native SQLite import in WAL
recovery tests
---
apps/server/src/provider/Drivers/CodexHomeLayout.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
index 66921cca7890e..dda66f1570000 100644
--- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
+++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
@@ -1,4 +1,4 @@
-// @effect-diagnostics nodeBuiltinImport:off
+// @effect-diagnostics nodeBuiltinImport:off - Real SQLite connections verify WAL recovery and concurrent access.
import * as NodeSqlite from "node:sqlite";
import * as NodeServices from "@effect/platform-node/NodeServices";
From 669b313c5bfd9d564bfa260483dd5956e617d851 Mon Sep 17 00:00:00 2001
From: uShifty <51722240+uShifty@users.noreply.github.com>
Date: Sat, 31 Oct 2026 13:24:05 -0600
Subject: [PATCH 5/5] fix(server): prevent split SQLite journals in existing
Codex shadow homes
---
.../Adapters/CodexAdapterV2.ts | 28 ++--
.../src/provider/CodexManagedRuntime.ts | 1 +
.../src/provider/Drivers/CodexDriver.test.ts | 98 +++++++++----
.../provider/Drivers/CodexHomeLayout.test.ts | 137 +++++++++++++++++-
.../src/provider/Drivers/CodexHomeLayout.ts | 65 ++++++++-
.../provider/Drivers/CodexManagedProvider.ts | 25 +++-
6 files changed, 294 insertions(+), 60 deletions(-)
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
index be5c20e92bf95..0ffe403af51d9 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
@@ -1564,18 +1564,22 @@ export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapter
environment,
hostEnvironment,
);
- const processEnv = yield* materializeCodexShadowHome(homeLayout).pipe(
- Effect.andThen(() => codexHomeEnvironment(homeLayout, environmentOverrides)),
- Effect.mapError(
- (cause) =>
- new ProviderAdapterDriverCreateError({
- driver: CODEX_DRIVER_KIND,
- instanceId,
- detail: "Failed to materialize the Codex shadow home.",
- cause,
- }),
- ),
- );
+ // Managed runtimes prepare their own home and report setup failures through provider status.
+ const processEnv =
+ hooks.resolveRuntime === undefined
+ ? yield* materializeCodexShadowHome(homeLayout).pipe(
+ Effect.andThen(() => codexHomeEnvironment(homeLayout, environmentOverrides)),
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapterDriverCreateError({
+ driver: CODEX_DRIVER_KIND,
+ instanceId,
+ detail: "Failed to materialize the Codex shadow home.",
+ cause,
+ }),
+ ),
+ )
+ : environmentOverrides;
const settings = {
...config,
diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts
index d8777f20e3897..9b49996e60534 100644
--- a/apps/server/src/provider/CodexManagedRuntime.ts
+++ b/apps/server/src/provider/CodexManagedRuntime.ts
@@ -101,6 +101,7 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func
instanceId: options.instanceId,
operation: "runtime",
detail: cause.message,
+ cause,
}),
),
);
diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts
index ef62c9cf6274f..0c4046ad9bce7 100644
--- a/apps/server/src/provider/Drivers/CodexDriver.test.ts
+++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts
@@ -8,6 +8,7 @@ import * as NodePath from "node:path";
import { expect, it } from "@effect/vitest";
import { EnvironmentId, ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Deferred from "effect/Deferred";
@@ -104,9 +105,14 @@ const noSpawn = ChildProcessSpawner.make(() =>
const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
it.layer(layerTest)("CodexDriver", (it) => {
- const checkManagedAccount = (replacementTiming: "unchanged" | "during-probe" | "probe-timeout") =>
+ const checkManagedAccount = (
+ replacementTiming: "unchanged" | "during-probe" | "probe-timeout" | "sqlite-conflict",
+ explicitShadow = false,
+ ) =>
Effect.gen(function* () {
- const instanceId = ProviderInstanceId.make("restored-managed-account");
+ const instanceId = ProviderInstanceId.make(
+ `restored-managed-account-${replacementTiming}-${explicitShadow ? "custom" : "default"}`,
+ );
const credentials = new Map();
const catalogTokens: Array = [];
const probeEntered = yield* Deferred.make();
@@ -156,19 +162,22 @@ it.layer(layerTest)("CodexDriver", (it) => {
};
const installation = yield* CodexInstallation.CodexInstallation;
const serverConfig = yield* ServerConfig.ServerConfig;
- const sharedHome = NodePath.join(serverConfig.stateDir, "shared-codex-home");
+ const sharedHome = NodePath.join(serverConfig.stateDir, `shared-codex-home-${instanceId}`);
const fs = yield* FileSystem.FileSystem;
- const shadowHome = NodePath.join(
- serverConfig.stateDir,
- "providers",
- "codex",
- instanceId,
- "shadow",
- );
+ const shadowHome = explicitShadow
+ ? NodePath.join(serverConfig.stateDir, `custom-shadow-${instanceId}`)
+ : NodePath.join(serverConfig.stateDir, "providers", "codex", instanceId, "shadow");
yield* fs.makeDirectory(sharedHome, { recursive: true });
yield* fs.makeDirectory(shadowHome, { recursive: true });
yield* fs.writeFileString(NodePath.join(sharedHome, "logs_2.sqlite-shm"), "shared shm");
yield* fs.writeFileString(NodePath.join(shadowHome, "logs_2.sqlite-shm"), "local shm");
+ if (replacementTiming === "sqlite-conflict") {
+ yield* fs.writeFileString(NodePath.join(sharedHome, "state_5.sqlite"), "shared state");
+ yield* fs.symlink(
+ NodePath.join(sharedHome, "state_5.sqlite"),
+ NodePath.join(shadowHome, "state_5.sqlite"),
+ );
+ }
const launches: Array<
Parameters[0]
> = [];
@@ -198,7 +207,12 @@ it.layer(layerTest)("CodexDriver", (it) => {
displayName: "Restored account",
enabled: true,
environment: [{ name: "OPENAI_API_KEY", value: "ambient-key", sensitive: true }],
- config: { ...CodexDriver.defaultConfig(), setupMode: "managed", homePath: sharedHome },
+ config: {
+ ...CodexDriver.defaultConfig(),
+ setupMode: "managed",
+ homePath: sharedHome,
+ ...(explicitShadow ? { shadowHomePath: shadowHome } : {}),
+ },
}).pipe(
Effect.provideService(
CodexAdapterV2.CodexAppServerClientFactory,
@@ -239,6 +253,16 @@ it.layer(layerTest)("CodexDriver", (it) => {
),
),
);
+ const openSession = instance.orchestrationAdapter.openSession({
+ threadId: ThreadId.make("managed-account-thread"),
+ providerSessionId: ProviderSessionId.make("managed-account-session"),
+ modelSelection: { instanceId, model: "gpt-5.4" },
+ runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ cwd: serverConfig.stateDir,
+ }),
+ });
const observedAccount = yield* Deferred.make();
const disconnected = yield* instance.snapshot.streamChanges.pipe(
Stream.tap((provider) =>
@@ -262,6 +286,18 @@ it.layer(layerTest)("CodexDriver", (it) => {
yield* Deferred.succeed(resumeProbe, undefined);
}
const restored = yield* Fiber.join(refresh);
+ if (replacementTiming === "sqlite-conflict") {
+ expect(Exit.isFailure(yield* openSession.pipe(Effect.scoped, Effect.exit))).toBe(true);
+ expect(launches).toHaveLength(0);
+ expect(restored.status).toBe("error");
+ expect(restored.message).toContain("Stop all Codex processes");
+ expect(restored.message).toContain(shadowHome);
+ expect(restored.message).toContain(sharedHome);
+ expect(restored.auth.status).toBe("authenticated");
+ expect(Option.isSome(yield* store.get)).toBe(true);
+ expect(catalogTokens).toEqual([]);
+ return;
+ }
if (replacementTiming === "during-probe") {
expect(catalogTokens).toEqual([]);
expect(restored.models).toEqual([]);
@@ -274,9 +310,7 @@ it.layer(layerTest)("CodexDriver", (it) => {
expect(restored.auth.status).toBe("authenticated");
expect(Option.isSome(yield* store.get)).toBe(true);
expect(restored.runtimePaths?.homePath).toBe(sharedHome);
- expect(restored.runtimePaths?.shadowHomePath).toContain(
- NodePath.join("providers", "codex", instanceId, "shadow"),
- );
+ expect(restored.runtimePaths?.shadowHomePath).toBe(shadowHome);
expect(yield* fs.readFileString(NodePath.join(shadowHome, "logs_2.sqlite-shm"))).toBe(
"local shm",
);
@@ -295,19 +329,7 @@ it.layer(layerTest)("CodexDriver", (it) => {
expect(Exit.isFailure(runtimeScan)).toBe(true);
yield* Deferred.await(observedAccount);
- const threadId = ThreadId.make("managed-account-thread");
- yield* instance.orchestrationAdapter
- .openSession({
- threadId,
- providerSessionId: ProviderSessionId.make("managed-account-session"),
- modelSelection: { instanceId, model: "gpt-5.4" },
- runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
- runtimeMode: "full-access",
- interactionMode: "default",
- cwd: serverConfig.stateDir,
- }),
- })
- .pipe(Effect.scoped, Effect.exit);
+ yield* openSession.pipe(Effect.scoped, Effect.exit);
expect(launches).toHaveLength(1);
const launch = launches[0]!;
expect(launch.settings.binaryPath).toBe(executable.executablePath);
@@ -363,11 +385,27 @@ it.layer(layerTest)("CodexDriver", (it) => {
}).pipe(Effect.scoped);
// regression: a sign-in that replaces the account mid-probe must not label the old
// account's catalog with the new identity.
- it.effect.each(["unchanged", "during-probe", "probe-timeout"] as const)(
- "managed catalog keeps its account label and token together: %s",
- checkManagedAccount,
+ it.effect.each([
+ { replacementTiming: "unchanged", explicitShadow: false },
+ { replacementTiming: "unchanged", explicitShadow: true },
+ { replacementTiming: "during-probe", explicitShadow: false },
+ { replacementTiming: "probe-timeout", explicitShadow: false },
+ ] as const)(
+ "managed catalog keeps its account label and token together: $replacementTiming, custom shadow: $explicitShadow",
+ ({ replacementTiming, explicitShadow }) =>
+ checkManagedAccount(replacementTiming, explicitShadow),
);
+ for (const explicitShadow of [false, true]) {
+ it.effect.skipIf(!symlinksSupported)(
+ `reports SQLite recovery instructions for a ${explicitShadow ? "custom" : "default"} shadow home without losing the managed account`,
+ () =>
+ checkManagedAccount("sqlite-conflict", explicitShadow).pipe(
+ Effect.provideService(HostProcess.Platform, "win32"),
+ ),
+ );
+ }
+
it.effect.skipIf(windowsHost)(
"runs the standalone updater against the shared home, not the shadow home",
() =>
diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
index e324cb3391b94..6744eaefd33ed 100644
--- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
+++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
@@ -71,7 +71,7 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
describe("codexHomeEnvironment", () => {
it.effect.each(["live", "interrupted"] as const)(
- "reads committed shadow WAL data with a %s connection",
+ "reads committed private shadow WAL data with a %s connection",
(connection) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
@@ -84,12 +84,7 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
using seed = new NodeSqlite.DatabaseSync(sharedDatabasePath);
seed.exec("PRAGMA journal_mode = WAL; CREATE TABLE entries (value TEXT);");
}
- // Windows places sidecars beside database links; Unix resolves the target first.
- if ((yield* HostProcess.isWindows) && symlinksSupported) {
- yield* fileSystem.symlink(sharedDatabasePath, shadowDatabasePath);
- } else {
- yield* fileSystem.copyFile(sharedDatabasePath, shadowDatabasePath);
- }
+ yield* fileSystem.copyFile(sharedDatabasePath, shadowDatabasePath);
using writer = new NodeSqlite.DatabaseSync(shadowDatabasePath);
writer.exec("PRAGMA wal_autocheckpoint = 0; INSERT INTO entries VALUES ('committed');");
const database = yield* fileSystem.readFile(shadowDatabasePath);
@@ -126,6 +121,134 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
}
}),
);
+
+ for (const platform of ["win32", "linux", "darwin"] as const) {
+ it.effect.skipIf(!symlinksSupported)(
+ `preserves SQLite home compatibility on ${platform}`,
+ () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const sharedHome = yield* makeTempDir("t3code-codex-shared-");
+ const shadowHome = yield* makeTempDir("t3code-codex-shadow-");
+ const layout = yield* resolveCodexHomeLayout(
+ decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }),
+ );
+ expect((yield* codexHomeEnvironment(layout, {})).CODEX_SQLITE_HOME).toBe(sharedHome);
+ for (const name of ["state_5.sqlite", "state_5.sqlite-wal", "state_5.sqlite-shm"]) {
+ yield* writeTextFile(path.join(sharedHome, name), `shared ${name}`);
+ const target =
+ name === "state_5.sqlite"
+ ? path.relative(shadowHome, path.join(sharedHome, name))
+ : path.join(sharedHome, name);
+ yield* fileSystem.symlink(target, path.join(shadowHome, name));
+ }
+ yield* materializeCodexShadowHome(layout);
+ const legacyHome = platform === "win32" ? sharedHome : shadowHome;
+ expect((yield* codexHomeEnvironment(layout, {})).CODEX_SQLITE_HOME).toBe(legacyHome);
+ expect(
+ (yield* codexHomeEnvironment(layout, { CODEX_SQLITE_HOME: " " })).CODEX_SQLITE_HOME,
+ ).toBe(legacyHome);
+
+ yield* writeTextFile(path.join(shadowHome, "goals_1.sqlite"), "private goals");
+ if (platform === "win32") {
+ const error = yield* codexHomeEnvironment(layout, {}).pipe(Effect.flip);
+ expect(error).toMatchObject({
+ _tag: "CodexShadowHomeSqliteConflictError",
+ sharedHomePath: sharedHome,
+ effectiveHomePath: shadowHome,
+ });
+ } else {
+ expect((yield* codexHomeEnvironment(layout, {})).CODEX_SQLITE_HOME).toBe(shadowHome);
+ }
+ for (const override of [sharedHome, shadowHome, "relative-sqlite-home"]) {
+ expect(
+ (yield* codexHomeEnvironment(layout, { CODEX_SQLITE_HOME: ` ${override} ` }))
+ .CODEX_SQLITE_HOME,
+ ).toBe(override);
+ }
+ expect(yield* fileSystem.readFileString(path.join(shadowHome, "goals_1.sqlite"))).toBe(
+ "private goals",
+ );
+ expect(yield* fileSystem.readLink(path.join(shadowHome, "state_5.sqlite"))).toBe(
+ path.relative(shadowHome, path.join(sharedHome, "state_5.sqlite")),
+ );
+ yield* fileSystem.remove(path.join(shadowHome, "goals_1.sqlite"));
+ yield* fileSystem.remove(path.join(shadowHome, "state_5.sqlite"));
+ yield* fileSystem.symlink(
+ path.join(sharedHome, "unrelated.sqlite"),
+ path.join(shadowHome, "state_5.sqlite"),
+ );
+ if (platform === "win32") {
+ expect(yield* codexHomeEnvironment(layout, {}).pipe(Effect.flip)).toMatchObject({
+ _tag: "CodexShadowHomeSqliteConflictError",
+ entryName: "state_5.sqlite",
+ });
+ } else {
+ expect((yield* codexHomeEnvironment(layout, {})).CODEX_SQLITE_HOME).toBe(shadowHome);
+ }
+ }).pipe(Effect.provideService(HostProcess.Platform, platform)),
+ );
+ }
+
+ it.effect.skipIf(!symlinksSupported || HostProcess.Platform.defaultValue() !== "win32")(
+ "refuses an existing linked database with split committed WAL views without changing files",
+ () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const sharedHome = yield* makeTempDir("t3code-codex-shared-");
+ const shadowHome = yield* makeTempDir("t3code-codex-shadow-");
+ const sharedDatabasePath = path.join(sharedHome, "state_5.sqlite");
+ const shadowDatabasePath = path.join(shadowHome, "state_5.sqlite");
+ {
+ using seed = new NodeSqlite.DatabaseSync(sharedDatabasePath);
+ seed.exec("PRAGMA journal_mode = WAL; CREATE TABLE entries (value TEXT);");
+ }
+ yield* fileSystem.symlink(sharedDatabasePath, shadowDatabasePath);
+ const shadowWriter = yield* Effect.acquireRelease(
+ Effect.sync(() => new NodeSqlite.DatabaseSync(shadowDatabasePath)),
+ (database) => Effect.sync(() => database.close()),
+ );
+ shadowWriter.exec(
+ "PRAGMA wal_autocheckpoint = 0; INSERT INTO entries VALUES ('shadow-committed');",
+ );
+ const sharedWriter = yield* Effect.acquireRelease(
+ Effect.sync(() => new NodeSqlite.DatabaseSync(sharedDatabasePath)),
+ (database) => Effect.sync(() => database.close()),
+ );
+ sharedWriter.exec(
+ "PRAGMA wal_autocheckpoint = 0; INSERT INTO entries VALUES ('shared-committed');",
+ );
+ expect(shadowWriter.prepare("SELECT value FROM entries").all()).toEqual([
+ { value: "shadow-committed" },
+ ]);
+ expect(sharedWriter.prepare("SELECT value FROM entries").all()).toEqual([
+ { value: "shared-committed" },
+ ]);
+ const files = [
+ sharedDatabasePath,
+ `${sharedDatabasePath}-wal`,
+ `${sharedDatabasePath}-shm`,
+ `${shadowDatabasePath}-wal`,
+ `${shadowDatabasePath}-shm`,
+ ];
+ const before = yield* Effect.forEach(files, (file) => fileSystem.readFile(file));
+ const layout = yield* resolveCodexHomeLayout(
+ decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }),
+ );
+ yield* materializeCodexShadowHome(layout);
+ const error = yield* codexHomeEnvironment(layout, {}).pipe(Effect.flip);
+ expect(error).toMatchObject({
+ _tag: "CodexShadowHomeSqliteConflictError",
+ sharedHomePath: sharedHome,
+ effectiveHomePath: shadowHome,
+ });
+ expect(error.message).toContain("Stop all Codex processes");
+ expect(yield* Effect.forEach(files, (file) => fileSystem.readFile(file))).toEqual(before);
+ expect(yield* fileSystem.readLink(shadowDatabasePath)).toBe(sharedDatabasePath);
+ }),
+ );
});
describe("materializeCodexShadowHome", () => {
diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts
index 33f7569152bf4..a6cc1ebf862eb 100644
--- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts
+++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts
@@ -132,11 +132,24 @@ export class CodexShadowHomePrivateEntrySymlinkError extends Schema.TaggedError<
}
}
+export class CodexShadowHomeSqliteConflictError extends Schema.TaggedError()(
+ "CodexShadowHomeSqliteConflictError",
+ {
+ ...CodexShadowHomeContext,
+ entryName: Schema.String,
+ },
+) {
+ override get message(): string {
+ return `Codex shadow home '${this.effectiveHomePath}' contains linked SQLite entry '${this.entryName}' that cannot safely share database journals with '${this.sharedHomePath}'. Stop all Codex processes using these directories, back up both directories including SQLite sidecars, and recover the databases into one directory before retrying. No SQLite files were changed.`;
+ }
+}
+
export const CodexShadowHomeError = Schema.Union([
CodexShadowHomeFileSystemError,
CodexShadowHomePathConflictError,
CodexShadowHomeEntryConflictError,
CodexShadowHomePrivateEntrySymlinkError,
+ CodexShadowHomeSqliteConflictError,
]);
export type CodexShadowHomeError = typeof CodexShadowHomeError.Type;
@@ -164,13 +177,55 @@ export const codexHomeEnvironment = Effect.fn("codexHomeEnvironment")(function*
}),
}),
);
- // Keep existing databases on their original path so SQLite can recover WAL in place,
- // including when another Codex process still owns a connection there.
+ const sqliteEntries = entries.filter((entry) => SQLITE_ENTRY_NAME.test(entry));
+ // Unix SQLite resolves database symlinks before choosing journals; retain existing overlays.
+ if (!(yield* HostProcess.isWindows)) {
+ return {
+ ...environment,
+ CODEX_SQLITE_HOME: sqliteEntries.length > 0 ? effectiveHomePath : layout.sharedHomePath,
+ };
+ }
+
+ const path = yield* Path.Path;
+ let hasLocalSqlite = false;
+ let linkedEntry: string | undefined;
+ for (const entryName of sqliteEntries) {
+ const linkPath = path.join(effectiveHomePath, entryName);
+ const state = yield* readLinkState({
+ fileSystem,
+ sharedHomePath: layout.sharedHomePath,
+ effectiveHomePath,
+ entryName,
+ linkPath,
+ });
+ if (state._tag === "NotSymlink") {
+ hasLocalSqlite = true;
+ } else if (state._tag === "Symlink") {
+ linkedEntry ??= entryName;
+ if (
+ path.resolve(effectiveHomePath, state.target).toLowerCase() !==
+ path.join(layout.sharedHomePath, entryName).toLowerCase()
+ ) {
+ return yield* new CodexShadowHomeSqliteConflictError({
+ sharedHomePath: layout.sharedHomePath,
+ effectiveHomePath,
+ entryName,
+ });
+ }
+ }
+ }
+ // Windows cannot preserve private journals and safely open linked databases in one SQLite home.
+ if (linkedEntry && hasLocalSqlite) {
+ return yield* new CodexShadowHomeSqliteConflictError({
+ sharedHomePath: layout.sharedHomePath,
+ effectiveHomePath,
+ entryName: linkedEntry,
+ });
+ }
+ // Private databases retain their WAL path; clean legacy links use the shared path directly.
return {
...environment,
- CODEX_SQLITE_HOME: entries.some((entry) => SQLITE_ENTRY_NAME.test(entry))
- ? effectiveHomePath
- : layout.sharedHomePath,
+ CODEX_SQLITE_HOME: hasLocalSqlite ? effectiveHomePath : layout.sharedHomePath,
};
});
diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts
index f9728f9a24973..99ae777bfbdd2 100644
--- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts
+++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts
@@ -7,6 +7,7 @@ import {
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as Option from "effect/Option";
import { ChildProcessSpawner } from "effect/process";
@@ -27,10 +28,15 @@ import {
type ProviderDriverCreateInput,
type ProviderInstance,
} from "@t3tools/provider-core/server/driver";
-import { codexContinuationIdentity } from "./CodexHomeLayout.ts";
+import {
+ CodexShadowHomeSqliteConflictError,
+ codexContinuationIdentity,
+} from "./CodexHomeLayout.ts";
import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
import { HttpClient } from "effect/http";
const DRIVER = ProviderDriverKind.make("codex");
+const isProviderSetupError = Schema.is(ProviderSetupError);
+const isCodexShadowHomeSqliteConflictError = Schema.is(CodexShadowHomeSqliteConflictError);
export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(function* (
input: ProviderDriverCreateInput,
@@ -179,11 +185,16 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu
: {}),
}),
),
- Effect.catch(() =>
- runtime.auth.read.pipe(
+ Effect.catch((cause) => {
+ const sqliteConflict =
+ isProviderSetupError(cause) && isCodexShadowHomeSqliteConflictError(cause.cause)
+ ? cause.cause
+ : undefined;
+ return runtime.auth.read.pipe(
Effect.orElseSucceed(() => Option.none()),
Effect.map((current) => ({
...base,
+ ...(sqliteConflict ? { status: "error" as const } : {}),
installed: true,
version: executable.value.version,
models: [],
@@ -204,10 +215,12 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu
...(Option.isSome(current) && current.value.scopes.includes("chatgpt.tokens.use.direct")
? { usageLimits }
: {}),
- message: "Could not check Codex right now. Retry, or reconnect in provider settings.",
+ message:
+ sqliteConflict?.message ??
+ "Could not check Codex right now. Retry, or reconnect in provider settings.",
})),
- ),
- ),
+ );
+ }),
Effect.scoped,
);
}).pipe(