From ffe1f97ce4c6e4d1a78916c5100d2052fd35c5f2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:06:59 +0000 Subject: [PATCH 1/5] test(server): cover canonical Codex SQLite storage and recovery --- .../Adapters/CodexAdapterV2.test.ts | 149 ++++++++++++++++++ .../src/provider/CodexManagedRuntime.test.ts | 29 +++- .../src/provider/Drivers/CodexDriver.test.ts | 82 +++++++++- .../provider/Drivers/CodexHomeLayout.test.ts | 134 ++++++++++++++++ 4 files changed, 390 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 51d072951b4e0..c60bf50a59038 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -63,6 +63,7 @@ import * as Orchestrator from "../Orchestrator.ts"; import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; import { makeReplayServerConfig, withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; import * as CodexAdapterV2Testkit from "./CodexAdapterV2.testkit.ts"; @@ -782,6 +783,154 @@ describe("CodexAdapterV2 process spawning", () => { }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); + it.effect.each(["sqlite", "config"] as const)( + "keeps paths in the construction cause, not the public %s error detail", + (entry) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-safe-detail-" }); + const sharedHome = path.join(root, "shared"); + const shadowHome = path.join(root, "shadow"); + yield* fs.makeDirectory(sharedHome); + yield* fs.makeDirectory(shadowHome); + const name = entry === "sqlite" ? "state_5.sqlite" : "config.toml"; + const target = path.join(sharedHome, name); + const link = path.join(shadowHome, name); + yield* fs.writeFileString(target, "shared"); + if (entry === "sqlite") yield* fs.symlink(target, link); + else yield* fs.writeFileString(link, "private config"); + const error = yield* CodexAdapterV2.createCodexAdapterV2({ + instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, + displayName: undefined, + environment: [], + enabled: true, + config: { ...DEFAULT_CODEX_SETTINGS, homePath: sharedHome, shadowHomePath: shadowHome }, + }).pipe( + Effect.provide( + Layer.mergeAll( + CodexAdapterV2.layerAppServerClientFactory, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-safe-detail-config-" }), + ), + ), + Effect.provideService( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + Effect.flip, + ); + if (error._tag !== "ProviderAdapterDriverCreateError") return yield* error; + assert.notInclude(error.detail, sharedHome); + assert.notInclude(error.detail, shadowHome); + if (entry === "sqlite") assert.include(error.detail, "Stop all Codex processes"); + else assert.equal(error.detail, "Failed to prepare Codex shadow home."); + assert.deepInclude(error.cause, { linkPath: link }); + if (entry === "sqlite") assert.equal(yield* fs.readLink(link), target); + else assert.equal(yield* fs.readFileString(link), "private config"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + { skip: !symlinksSupported }, + ); + + it.effect.each([ + { + platform: "linux", + key: "CODEX_SQLITE_HOME", + sqliteHome: undefined, + inheritedKey: "CODEX_SQLITE_HOME", + }, + { + platform: "linux", + key: "CODEX_SQLITE_HOME", + sqliteHome: "/explicit/sqlite", + inheritedKey: "CODEX_SQLITE_HOME", + }, + { + platform: "win32", + key: "codex_sqlite_home", + sqliteHome: "/explicit/sqlite", + inheritedKey: "CODEX_SQLITE_HOME", + }, + { + platform: "win32", + key: "CODEX_SQLITE_HOME", + sqliteHome: "/explicit/sqlite", + inheritedKey: "codex_sqlite_home", + }, + ] as const)( + "keeps app-server SQLite canonical with $platform $key precedence ($sqliteHome)", + ({ platform, key, sqliteHome, inheritedKey }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-sqlite-launch-" }); + const sharedHome = path.join(root, "shared"); + const shadowHome = path.join(root, "shadow"); + const environments: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) environments.push(command.options.env ?? {}); + return Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + ); + }); + const adapter = yield* CodexAdapterV2.createCodexAdapterV2({ + instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, + displayName: undefined, + environment: + sqliteHome === undefined ? [] : [{ name: key, value: sqliteHome, sensitive: false }], + enabled: true, + config: { ...DEFAULT_CODEX_SETTINGS, homePath: sharedHome, shadowHomePath: shadowHome }, + }).pipe( + Effect.provide( + Layer.mergeAll( + CodexAdapterV2.layerAppServerClientFactory, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-sqlite-" }), + ), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ); + yield* adapter + .openSession({ + threadId: ThreadId.make("thread-sqlite"), + providerSessionId: ProviderSessionId.make("provider-session-sqlite"), + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }) + .pipe(Effect.scoped, Effect.exit); + assert.lengthOf(environments, 1); + assert.equal(environments[0]?.CODEX_HOME, shadowHome); + assert.equal(environments[0]?.CODEX_SQLITE_HOME, sqliteHome ?? sharedHome); + assert.lengthOf( + Object.keys(environments[0] ?? {}).filter( + (key) => key.toUpperCase() === "CODEX_SQLITE_HOME", + ), + 1, + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + Effect.provideService( + HostProcess.Environment, + sqliteHome ? { [inheritedKey]: "/inherited/sqlite" } : {}, + ), + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(SpawnExecutableResolution, () => "codex"), + ), + { skip: !symlinksSupported }, + ); + it.effect("expands ~ in the configured binary path before spawning", () => Effect.gen(function* () { const spawnedCommands: Array = []; diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 69d27f1377b6b..a72911f7de597 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -44,8 +44,8 @@ it.effect("managed home defaults to the global Codex home and honors configured ProviderInstanceId.make("codex-work"), decodeSettings({ homePath: "/custom/shared", shadowHomePath: "/custom/shadow" }), ); - assert.equal(configured.sharedHomePath, "/custom/shared"); - assert.equal(configured.effectiveHomePath, "/custom/shadow"); + assert.equal(configured.sharedHomePath, path.resolve("/custom/shared")); + assert.equal(configured.effectiveHomePath, path.resolve("/custom/shadow")); }).pipe(Effect.provide(NodeServices.layer)), ); it.effect.each( @@ -135,10 +135,12 @@ it.effect.each( if (account === "primary") { assert.equal(effective.config.homePath, sharedHome); assert.equal(runtime.homeLayout.mode, "direct"); + assert.isUndefined(effective.environment.CODEX_SQLITE_HOME); } 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(effective.environment.CODEX_SQLITE_HOME, sharedHome); assert.equal( yield* fs.readLink(path.join(effective.config.homePath, "sessions")), path.join(sharedHome, "sessions"), @@ -173,6 +175,27 @@ it.effect.each( assert.strictEqual(ambient.CODEX_HOME, "/user/.codex"); }).pipe(Effect.scoped); assert.strictEqual(leases, 0); + if (account === "additional") { + yield* fs.writeFileString(path.join(sharedHome, "state_5.sqlite"), ""); + yield* fs.symlink( + path.join(sharedHome, "state_5.sqlite"), + path.join(runtime.homePath, "state_5.sqlite"), + ); + const error = yield* runtime.resolve.pipe(Effect.scoped, Effect.flip); + assert.equal(error._tag, "ProviderSetupError"); + assert.include(error.detail, "Codex SQLite recovery is required"); + assert.include(error.detail, "Stop all Codex processes"); + assert.notInclude(error.detail, sharedHome); + assert.notInclude(error.detail, runtime.homePath); + assert.notInclude(error.message, sharedHome); + assert.notInclude(error.message, runtime.homePath); + assert.deepInclude(error.cause, { + _tag: "CodexShadowHomeSqliteRecoveryRequiredError", + sharedHomePath: sharedHome, + linkPath: path.join(runtime.homePath, "state_5.sqlite"), + }); + assert.strictEqual(leases, 0); + } yield* runtime.auth.controller.logout(Effect.void); assert.equal( yield* fs.readFileString(path.join(sharedHome, "auth.json")), diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index e6c0e87b4df48..4d28b976979b7 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -1,3 +1,4 @@ +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as CodexInstallation from "../CodexInstallation.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; @@ -97,6 +98,46 @@ const noSpawn = ChildProcessSpawner.make(() => const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); it.layer(layerTest)("CodexDriver", (it) => { + it.effect.skipIf(!symlinksSupported)( + "refuses a native provider with a legacy SQLite shadow before probing or spawning", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "codex-native-recovery-" }); + const sharedHome = NodePath.join(root, "shared"); + const shadowHome = NodePath.join(root, "shadow"); + yield* fs.makeDirectory(shadowHome); + const linkPath = NodePath.join(shadowHome, "future.SQLITE-WAL"); + const target = NodePath.join(root, "missing-wal"); + yield* fs.symlink(target, linkPath); + const result = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("native-recovery"), + displayName: undefined, + enabled: true, + environment: [], + config: { + ...CodexDriver.defaultConfig(), + setupMode: "existing", + homePath: sharedHome, + shadowHomePath: shadowHome, + }, + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { cause: { _tag: "CodexShadowHomeSqliteRecoveryRequiredError", linkPath } }, + }); + if (result._tag === "Failure") { + expect(result.failure.detail).toContain("Stop all Codex processes"); + expect(result.failure.detail).not.toContain(root); + } + expect(yield* fs.readLink(linkPath)).toBe(target); + expect(yield* fs.exists(sharedHome)).toBe(false); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.scoped, + ), + ); + it.effect("reports a disabled managed account as disabled", () => Effect.gen(function* () { const instance = yield* CodexDriver.create({ @@ -229,7 +270,7 @@ it.layer(layerTest)("CodexDriver", (it) => { expect(restored.auth.email).toBe("account@example.test"); expect(restored.runtimePaths?.homePath).toBe(sharedHome); expect(restored.runtimePaths?.shadowHomePath).toContain( - `providers/codex/${instanceId}/shadow`, + NodePath.join("providers", "codex", instanceId, "shadow"), ); // The fixture app-server cannot start, so the workspace scan fails // instead of passing the machine snapshot off as the workspace's skills. @@ -265,8 +306,47 @@ it.layer(layerTest)("CodexDriver", (it) => { expect(launch.environment.ACCESS_TOKEN).toBe("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"); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(NodePath.join(sharedHome, "state_5.sqlite"), ""); + yield* fs.symlink( + NodePath.join(sharedHome, "state_5.sqlite"), + NodePath.join(launch.settings.homePath, "state_5.sqlite"), + ); + const blocked = yield* instance.snapshot.refresh; + expect(blocked.message).toContain("Codex SQLite recovery is required"); + expect(blocked.message).toContain("Stop all Codex processes"); + expect(blocked.message).not.toContain(sharedHome); + expect(blocked.message).not.toContain(launch.settings.homePath); + // An explicitly configured shadow is checked during construction, not only + // later runtime probes. The registry must receive the same recovery guidance. + const explicit = yield* CodexDriver.create({ + instanceId, + displayName: "Explicit shadow account", + enabled: true, + environment: [], + config: { + ...CodexDriver.defaultConfig(), + setupMode: "managed", + homePath: sharedHome, + shadowHomePath: launch.settings.homePath, + }, + }).pipe(Effect.scoped, Effect.flip); + expect(explicit.detail).toContain("Codex SQLite recovery is required"); + expect(explicit.detail).toContain("Stop all Codex processes"); + expect(explicit.detail).not.toContain(sharedHome); + expect(explicit.detail).not.toContain(launch.settings.homePath); + expect(explicit.cause).toMatchObject({ + cause: { + _tag: "CodexShadowHomeSqliteRecoveryRequiredError", + linkPath: NodePath.join(launch.settings.homePath, "state_5.sqlite"), + }, + }); + expect(yield* fs.readLink(NodePath.join(launch.settings.homePath, "state_5.sqlite"))).toBe( + NodePath.join(sharedHome, "state_5.sqlite"), + ); yield* instance.auth!.logout(Effect.void); const after = Option.getOrThrow(yield* Fiber.join(disconnected)); expect(after.auth.status).toBe("unauthenticated"); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 7434552471e3a..c19818e73fe55 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Real WAL preservation uses only disposable test homes. +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"; @@ -85,6 +87,138 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }); describe("materializeCodexShadowHome", () => { + it.effect.each([false, true])( + "leaves SQLite families unlinked and preserves real shadow files (existing: %s)", + (existing) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("codex-sqlite-shared-"); + const shadowHome = yield* makeTempDir("codex-sqlite-shadow-"); + const names = [ + "state_5.sqlite", + "future_42.SQLITE-WAL", + "logs.sqlite-shm", + "other.sqlite-journal", + ]; + for (const name of names) { + yield* fs.writeFileString(path.join(sharedHome, name), `shared:${name}`); + if (existing) yield* fs.writeFileString(path.join(shadowHome, name), `private:${name}`); + } + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + yield* materializeCodexShadowHome(layout); + yield* materializeCodexShadowHome(layout); + for (const name of names) { + expect(yield* fs.readFileString(path.join(sharedHome, name))).toBe(`shared:${name}`); + expect(yield* fs.exists(path.join(shadowHome, name))).toBe(existing); + if (existing) + expect(yield* fs.readFileString(path.join(shadowHome, name))).toBe(`private:${name}`); + } + }), + { skip: !symlinksSupported }, + ); + + it.effect.each([ + "state_5.sqlite", + "future.SQLITE-WAL", + "orphan.sqlite-SHM", + "future.sqlite-journal", + ])( + "refuses legacy %s links before preparation, even dangling, without changing files", + (name) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("codex-sqlite-shared-"); + const shadowHome = yield* makeTempDir("codex-sqlite-shadow-"); + const target = path.join(sharedHome, name); + const linkPath = path.join(shadowHome, name); + yield* fs.symlink(target, linkPath); + yield* fs.writeFileString(path.join(shadowHome, "private.sqlite-wal"), "uncheckpointed"); + yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "shared config"); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + for (const attempt of [1, 2]) { + const result = yield* materializeCodexShadowHome(layout).pipe(Effect.result); + expect(result, `attempt ${attempt}`).toMatchObject({ + _tag: "Failure", + failure: { _tag: "CodexShadowHomeSqliteRecoveryRequiredError", linkPath }, + }); + if (result._tag === "Failure") { + expect(result.failure.message).toContain("Stop all Codex processes"); + expect(result.failure.message).not.toContain(sharedHome); + } + expect(yield* fs.readLink(linkPath)).toBe(target); + expect(yield* fs.exists(target)).toBe(false); + expect(yield* fs.readFileString(path.join(shadowHome, "private.sqlite-wal"))).toBe( + "uncheckpointed", + ); + expect((yield* fs.readDirectory(shadowHome)).sort()).toEqual( + [name, "private.sqlite-wal"].sort(), + ); + expect(yield* fs.readDirectory(sharedHome)).toEqual(["config.toml"]); + } + }), + { skip: !symlinksSupported }, + ); + + it.effect.skipIf(!symlinksSupported)( + "preserves live WAL commits and shadow sidecars until manual recovery", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("codex-sqlite-shared-"); + const shadowHome = yield* makeTempDir("codex-sqlite-shadow-"); + const database = path.join(sharedHome, "state_5.sqlite"); + const link = path.join(shadowHome, "state_5.sqlite"); + yield* fs.writeFileString(database, ""); + yield* fs.symlink(database, link); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + yield* Effect.gen(function* () { + const writer = yield* Effect.acquireRelease( + Effect.sync(() => new NodeSqlite.DatabaseSync(database)), + (db) => Effect.sync(() => db.close()), + ); + expect(yield* materializeCodexShadowHome(layout).pipe(Effect.result)).toMatchObject({ + _tag: "Failure", + failure: { _tag: "CodexShadowHomeSqliteRecoveryRequiredError" }, + }); + writer.exec( + "PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0; CREATE TABLE writes (value TEXT); INSERT INTO writes VALUES ('legacy');", + ); + const wal = path.join(sharedHome, "state_5.sqlite-wal"); + const before = yield* fs.readFile(wal); + const shadowWal = path.join(shadowHome, "state_5.sqlite-wal"); + yield* fs.writeFile(shadowWal, before); + const databaseBefore = yield* fs.readFile(database); + expect(before.length).toBeGreaterThan(0); + expect(yield* materializeCodexShadowHome(layout).pipe(Effect.result)).toMatchObject({ + _tag: "Failure", + failure: { _tag: "CodexShadowHomeSqliteRecoveryRequiredError" }, + }); + expect(yield* fs.readFile(wal)).toEqual(before); + expect(yield* fs.readFile(shadowWal)).toEqual(before); + expect(yield* fs.readFile(database)).toEqual(databaseBefore); + expect(yield* fs.readLink(link)).toBe(database); + expect(writer.prepare("SELECT value FROM writes").all()).toEqual([{ value: "legacy" }]); + }).pipe(Effect.scoped); + // Only the operator unlinks the legacy database after the writer closes and checkpoints. + yield* fs.remove(link); + yield* materializeCodexShadowHome(layout); + const reopened = yield* Effect.acquireRelease( + Effect.sync(() => new NodeSqlite.DatabaseSync(database)), + (db) => Effect.sync(() => db.close()), + ); + expect(reopened.prepare("SELECT value FROM writes").all()).toEqual([{ value: "legacy" }]); + expect(yield* fs.exists(link)).toBe(false); + }), + ); it.effect.skipIf(!symlinksSupported)( "keeps Finder metadata local across repeated shadow home setup", () => From 24df5fb466426827376f581332eeb3cf9190a2d6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:20:11 +0000 Subject: [PATCH 2/5] fix(server): keep Codex overlay SQLite storage canonical --- .../Adapters/CodexAdapterV2.ts | 14 ++- .../src/provider/CodexManagedRuntime.ts | 33 ++++- .../src/provider/Drivers/CodexDriver.ts | 16 ++- .../src/provider/Drivers/CodexHomeLayout.ts | 115 +++++++++++++++++- .../provider/Drivers/CodexManagedProvider.ts | 22 +++- .../src/textGeneration/CodexTextGeneration.ts | 2 +- 6 files changed, 182 insertions(+), 20 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index d9316c7a69155..c67cbb8e0a438 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -105,7 +105,10 @@ import { toMcpElicitationResponse, } from "../../provider/CodexMcpElicitation.ts"; import { + CODEX_SQLITE_RECOVERY_GUIDANCE, materializeCodexShadowHome, + mergeCodexEnvironment, + withCodexSqliteHome, resolveCodexHomeLayout, } from "../../provider/Drivers/CodexHomeLayout.ts"; import { @@ -114,7 +117,6 @@ import { } from "../../provider/EventNdjsonLogger.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "../../provider/codexLaunchArgs.ts"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { @@ -1585,7 +1587,10 @@ export const createCodexAdapterV2 = ( new ProviderAdapterDriverCreateError({ driver: CODEX_DRIVER_KIND, instanceId, - detail: "Failed to materialize the Codex shadow home.", + detail: + cause._tag === "CodexShadowHomeSqliteRecoveryRequiredError" + ? CODEX_SQLITE_RECOVERY_GUIDANCE + : "Failed to prepare Codex shadow home.", cause, }), ), @@ -1601,7 +1606,10 @@ export const createCodexAdapterV2 = ( return yield* makeCodexAdapterV2({ instanceId, settings, - environment: yield* mergeProviderInstanceEnvironment(environment, hostEnvironment), + environment: withCodexSqliteHome( + homeLayout, + yield* mergeCodexEnvironment(environment, hostEnvironment), + ), clientFactory, crypto, fileSystem, diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts index 826d777f1bd70..1bfed8646d3e4 100644 --- a/apps/server/src/provider/CodexManagedRuntime.ts +++ b/apps/server/src/provider/CodexManagedRuntime.ts @@ -7,7 +7,12 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; -import { materializeCodexShadowHome } from "./Drivers/CodexHomeLayout.ts"; +import { + CODEX_SQLITE_RECOVERY_GUIDANCE, + inspectCodexShadowSqlite, + materializeCodexShadowHome, + withCodexSqliteHome, +} from "./Drivers/CodexHomeLayout.ts"; export interface CodexEffectiveRuntime { readonly config: CodexSettings; @@ -55,6 +60,22 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func ); const homePath = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath; const resolve = Effect.gen(function* () { + yield* inspectCodexShadowSqlite(homeLayout).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "runtime", + cause, + detail: + cause._tag === "CodexShadowHomeSqliteRecoveryRequiredError" + ? CODEX_SQLITE_RECOVERY_GUIDANCE + : "Could not prepare the managed Codex runtime.", + }), + ), + ); const executable = yield* installation.acquire().pipe( Effect.mapError( () => @@ -74,7 +95,11 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func new ProviderSetupError({ instanceId: options.instanceId, operation: "runtime", - detail: cause.message, + detail: + cause._tag === "CodexShadowHomeSqliteRecoveryRequiredError" + ? CODEX_SQLITE_RECOVERY_GUIDANCE + : "Could not prepare the managed Codex runtime.", + cause, }), ), ); @@ -89,11 +114,11 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func ), ); // Ambient CLI overrides cannot redirect a T3-owned token to a different provider. - const environment: NodeJS.ProcessEnv = { + const environment = withCodexSqliteHome(homeLayout, { ...options.environment, ACCESS_TOKEN: credentials.accessToken, CODEX_HOME: homePath, - }; + }); delete environment.T3CODE_CODEX_LAUNCH_ARGS; delete environment.OPENAI_API_KEY; delete environment.OPENAI_BASE_URL; diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 2388213eed301..5ccdcb2f3e1cc 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -31,7 +31,6 @@ import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; import { applyCodexModelCatalog } from "../codexModelCatalog.ts"; import type { ProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { enrichProviderSnapshotWithVersionAdvisory, makeCachedProviderMaintenanceResolution, @@ -46,7 +45,10 @@ import { } from "@t3tools/provider-core/server/snapshotSettings"; import { codexContinuationIdentity, + CODEX_SQLITE_RECOVERY_GUIDANCE, materializeCodexShadowHome, + mergeCodexEnvironment, + withCodexSqliteHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; import { makeManagedCodexProvider } from "./CodexManagedProvider.ts"; @@ -125,8 +127,8 @@ export const CodexDriver: ProviderDriver [ + ["CODEX_HOME", "CODEX_SQLITE_HOME"].includes(key.toUpperCase()) ? key.toUpperCase() : key, + value, + ]), + ); +} + +export const mergeCodexEnvironment = Effect.fn("mergeCodexEnvironment")(function* ( + environment: ProviderInstanceEnvironment | undefined, + ...sources: readonly NodeJS.ProcessEnv[] +) { + const platform = yield* HostProcess.Platform; + // Normalize each source before merging so property insertion order cannot reverse precedence. + const base: NodeJS.ProcessEnv = {}; + for (const source of sources.length ? sources : [yield* HostProcess.Environment]) { + Object.assign(base, normalizeCodexEnvironment(source, platform)); + } + return yield* mergeProviderInstanceEnvironment( + environment?.map((variable) => ({ + ...variable, + name: + platform === "win32" && + ["CODEX_HOME", "CODEX_SQLITE_HOME"].includes(variable.name.toUpperCase()) + ? variable.name.toUpperCase() + : variable.name, + })), + base, + ); +}); + +export function withCodexSqliteHome( + layout: CodexHomeLayout, + environment: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + // Codex's sqlite_home config still takes precedence over this environment default. + return layout.mode === "authOverlay" + ? { ...environment, CODEX_SQLITE_HOME: environment.CODEX_SQLITE_HOME ?? layout.sharedHomePath } + : environment; +} // 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"]); @@ -111,6 +160,18 @@ export class CodexShadowHomeEntryConflictError extends Schema.TaggedError()( + "CodexShadowHomeSqliteRecoveryRequiredError", + { ...CodexShadowHomeContext, entryName: Schema.String, linkPath: Schema.String }, +) { + override get message(): string { + return CODEX_SQLITE_RECOVERY_GUIDANCE; + } +} + export class CodexShadowHomePrivateEntrySymlinkError extends Schema.TaggedError()( "CodexShadowHomePrivateEntrySymlinkError", { @@ -129,6 +190,7 @@ export const CodexShadowHomeError = Schema.Union([ CodexShadowHomePathConflictError, CodexShadowHomeEntryConflictError, CodexShadowHomePrivateEntrySymlinkError, + CodexShadowHomeSqliteRecoveryRequiredError, ]); export type CodexShadowHomeError = typeof CodexShadowHomeError.Type; @@ -318,6 +380,49 @@ const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsP }, ); +export const inspectCodexShadowSqlite = Effect.fn("inspectCodexShadowSqlite")(function* ( + layout: CodexHomeLayout, +) { + const effectiveHomePath = layout.effectiveHomePath; + if (layout.mode !== "authOverlay" || !effectiveHomePath) return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + 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, + }), + }), + ); + for (const entryName of entries) { + if (!SQLITE_ENTRY_NAME.test(entryName)) continue; + // path.join would collapse symlink/.. before the filesystem traverses it. + const linkPath = + effectiveHomePath + (effectiveHomePath.endsWith(path.sep) ? "" : path.sep) + entryName; + const state = yield* readLinkState({ + fileSystem, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + entryName, + linkPath, + }); + if (state._tag === "Symlink") + return yield* new CodexShadowHomeSqliteRecoveryRequiredError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + entryName, + linkPath, + }); + } +}); + export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome")(function* ( layout: CodexHomeLayout, ) { @@ -334,6 +439,8 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + yield* inspectCodexShadowSqlite(layout); + const makeDirectory = (directoryPath: string) => fileSystem.makeDirectory(directoryPath, { recursive: true }).pipe( Effect.catchTags({ @@ -373,7 +480,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/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index ad41a94b48233..7564fc778e101 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -17,12 +17,15 @@ import { probeCodexSkillsForCwd, } from "../CodexProvider.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { type ProviderDriverCreateInput, type ProviderInstance, } from "@t3tools/provider-core/server/driver"; -import { codexContinuationIdentity } from "./CodexHomeLayout.ts"; +import { + CODEX_SQLITE_RECOVERY_GUIDANCE, + codexContinuationIdentity, + mergeCodexEnvironment, +} from "./CodexHomeLayout.ts"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { HttpClient } from "effect/http"; const DRIVER = ProviderDriverKind.make("codex"); @@ -40,7 +43,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu instanceId, enabled, config, - environment: yield* mergeProviderInstanceEnvironment(input.environment), + environment: yield* mergeCodexEnvironment(input.environment), }); const continuationIdentity = codexContinuationIdentity(runtime.homeLayout); const stamp = withInstanceIdentity({ @@ -163,7 +166,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu : {}), }), ), - Effect.catch(() => + Effect.catch((cause) => runtime.auth.read.pipe( Effect.orElseSucceed(() => Option.none()), Effect.map((current) => ({ @@ -188,7 +191,10 @@ 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: + cause._tag === "ProviderSetupError" && cause.operation === "runtime" + ? cause.detail + : "Could not check Codex right now. Retry, or reconnect in provider settings.", })), ), ), @@ -236,7 +242,10 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu new ProviderDriverError({ driver: DRIVER, instanceId, - detail: "Failed to build Codex orchestration adapter.", + detail: + cause.detail === CODEX_SQLITE_RECOVERY_GUIDANCE + ? CODEX_SQLITE_RECOVERY_GUIDANCE + : "Failed to build Codex orchestration adapter.", cause, }), ), @@ -255,6 +264,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu new TextGenerationError({ operation, detail: "detail" in cause ? cause.detail : "Codex text generation failed.", + cause, }), ), ); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 6ee03420b7abf..3244891963af8 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -245,7 +245,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const resolved = resolveRuntime ? yield* resolveRuntime.pipe( Effect.mapError( - (cause) => new TextGenerationError({ operation, detail: cause.detail }), + (cause) => new TextGenerationError({ operation, detail: cause.detail, cause }), ), ) : undefined; From f13b0c74f2243f77d47b22d395f3ad861db0ad84 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:26:35 +0000 Subject: [PATCH 3/5] refactor(server): classify Codex recovery by its structured cause --- apps/server/src/provider/Drivers/CodexDriver.ts | 9 +++++---- .../src/provider/Drivers/CodexManagedProvider.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 5ccdcb2f3e1cc..c930d76dddf04 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -46,6 +46,7 @@ import { import { codexContinuationIdentity, CODEX_SQLITE_RECOVERY_GUIDANCE, + CodexShadowHomeSqliteRecoveryRequiredError, materializeCodexShadowHome, mergeCodexEnvironment, withCodexSqliteHome, @@ -59,6 +60,7 @@ import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); +const isSqliteRecoveryRequired = Schema.is(CodexShadowHomeSqliteRecoveryRequiredError); const DRIVER_KIND = ProviderDriverKind.make("codex"); function isCodexStandaloneCommandPath(commandPath: string): boolean { return normalizeCommandPath(commandPath).includes("/packages/standalone/"); @@ -187,10 +189,9 @@ export const CodexDriver: ProviderDriver Date: Mon, 19 Oct 2026 20:59:14 +0000 Subject: [PATCH 4/5] test(server): cover blank Codex SQLite home overrides --- .../Adapters/CodexAdapterV2.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index c60bf50a59038..49d9336c8ece0 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -842,6 +842,18 @@ describe("CodexAdapterV2 process spawning", () => { sqliteHome: undefined, inheritedKey: "CODEX_SQLITE_HOME", }, + { + platform: "linux", + key: "CODEX_SQLITE_HOME", + sqliteHome: "", + inheritedKey: "CODEX_SQLITE_HOME", + }, + { + platform: "linux", + key: "CODEX_SQLITE_HOME", + sqliteHome: " \t ", + inheritedKey: "CODEX_SQLITE_HOME", + }, { platform: "linux", key: "CODEX_SQLITE_HOME", @@ -910,7 +922,10 @@ describe("CodexAdapterV2 process spawning", () => { .pipe(Effect.scoped, Effect.exit); assert.lengthOf(environments, 1); assert.equal(environments[0]?.CODEX_HOME, shadowHome); - assert.equal(environments[0]?.CODEX_SQLITE_HOME, sqliteHome ?? sharedHome); + assert.equal( + environments[0]?.CODEX_SQLITE_HOME, + sqliteHome?.trim() ? sqliteHome : sharedHome, + ); assert.lengthOf( Object.keys(environments[0] ?? {}).filter( (key) => key.toUpperCase() === "CODEX_SQLITE_HOME", From 7bb8b1a74a2a36d6d5ccac8589bd91c73256704e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 21:01:33 +0000 Subject: [PATCH 5/5] fix(server): default blank Codex SQLite overrides to shared home --- apps/server/src/provider/Drivers/CodexHomeLayout.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index c7bcf4d6c9bf8..474f6858604dc 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -77,7 +77,12 @@ export function withCodexSqliteHome( ): NodeJS.ProcessEnv { // Codex's sqlite_home config still takes precedence over this environment default. return layout.mode === "authOverlay" - ? { ...environment, CODEX_SQLITE_HOME: environment.CODEX_SQLITE_HOME ?? layout.sharedHomePath } + ? { + ...environment, + CODEX_SQLITE_HOME: environment.CODEX_SQLITE_HOME?.trim() + ? environment.CODEX_SQLITE_HOME + : layout.sharedHomePath, + } : environment; } // Codex creates these runtime locks itself when it starts in a shadow home before the shared