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(