From ef65701f03d349e9e6cedbd4cca8a34e5c9698d5 Mon Sep 17 00:00:00 2001 From: web3theo Date: Sun, 18 Oct 2026 22:11:48 +0000 Subject: [PATCH 1/3] fix(server): Codex shadow homes keep SQLite state in the shared home Codex creates new SQLite databases, and their -wal and -shm files, after a shadow home is set up. The next setup found them as real files where it wanted links and failed with "Cannot create Codex shadow home entry ... already exists and is not a symlink", which disabled the provider (#5817). A linked database could also split from the sidecars SQLite writes beside the link. Auth-overlay instances now point CODEX_SQLITE_HOME at the shared home, so every account opens the shared databases directly. Setup no longer links SQLite files into shadow homes, and removes SQLite links an earlier setup left there while leaving real files alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ch8wyPrnXdHFdixz7SXJNX --- .../Adapters/CodexAdapterV2.ts | 6 +- .../src/provider/CodexManagedRuntime.ts | 15 +-- .../src/provider/Drivers/CodexDriver.ts | 6 +- .../provider/Drivers/CodexHomeLayout.test.ts | 94 +++++++++++++++++++ .../src/provider/Drivers/CodexHomeLayout.ts | 67 ++++++++++--- apps/server/src/terminal/Manager.ts | 10 +- 6 files changed, 173 insertions(+), 25 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 19d1e7b7ffe29..5c4c3e7497562 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -107,6 +107,7 @@ import { import { materializeCodexShadowHome, resolveCodexHomeLayout, + withCodexSqliteHome, } from "../../provider/Drivers/CodexHomeLayout.ts"; import { boundProviderEventForLogging, @@ -1600,7 +1601,10 @@ export const createCodexAdapterV2 = ( return yield* makeCodexAdapterV2({ instanceId, settings, - environment: yield* mergeProviderInstanceEnvironment(environment, hostEnvironment), + environment: withCodexSqliteHome( + yield* mergeProviderInstanceEnvironment(environment, hostEnvironment), + homeLayout, + ), clientFactory, crypto, fileSystem, diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts index 826d777f1bd70..54692c3c52651 100644 --- a/apps/server/src/provider/CodexManagedRuntime.ts +++ b/apps/server/src/provider/CodexManagedRuntime.ts @@ -7,7 +7,7 @@ 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 { materializeCodexShadowHome, withCodexSqliteHome } from "./Drivers/CodexHomeLayout.ts"; export interface CodexEffectiveRuntime { readonly config: CodexSettings; @@ -89,11 +89,14 @@ 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, - ACCESS_TOKEN: credentials.accessToken, - CODEX_HOME: homePath, - }; + const environment = withCodexSqliteHome( + { + ...options.environment, + ACCESS_TOKEN: credentials.accessToken, + CODEX_HOME: homePath, + }, + homeLayout, + ); 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..86bcf2baf39f7 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -48,6 +48,7 @@ import { codexContinuationIdentity, materializeCodexShadowHome, resolveCodexHomeLayout, + withCodexSqliteHome, } from "./CodexHomeLayout.ts"; import { makeManagedCodexProvider } from "./CodexManagedProvider.ts"; import { codexUsageReader } from "./codexUsage.ts"; @@ -125,8 +126,11 @@ export const CodexDriver: ProviderDriver { ); }); + describe("withCodexSqliteHome", () => { + it.effect("points a shadow home's SQLite state at the shared home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const overlay = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: path.join(shadowRoot, "shadow"), + }), + ); + const direct = yield* resolveCodexHomeLayout(decodeCodexSettings({ homePath: sharedHome })); + + expect(withCodexSqliteHome({ PATH: "/bin" }, overlay)).toEqual({ + PATH: "/bin", + CODEX_SQLITE_HOME: sharedHome, + }); + expect(withCodexSqliteHome({ PATH: "/bin" }, direct)).toEqual({ PATH: "/bin" }); + expect(withCodexSqliteHome({ CODEX_SQLITE_HOME: "/custom" }, overlay)).toEqual({ + CODEX_SQLITE_HOME: "/custom", + }); + }), + ); + }); + describe("materializeCodexShadowHome", () => { it.effect.skipIf(!symlinksSupported)( "keeps Finder metadata local across repeated shadow home setup", @@ -207,6 +234,73 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "leaves SQLite databases Codex created in the shadow home alone across repeated setup", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); + for (const name of ["queue_1.sqlite", "state_5.sqlite", "state_5.sqlite-wal"]) { + yield* writeTextFile(path.join(sharedHome, name), `shared ${name}`); + } + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite"), "shadow queue"); + yield* writeTextFile(path.join(shadowHome, "state_5.sqlite-wal"), "shadow wal"); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + + yield* materializeCodexShadowHome(layout); + yield* materializeCodexShadowHome(layout); + + expect(yield* fileSystem.readFileString(path.join(shadowHome, "queue_1.sqlite"))).toBe( + "shadow queue", + ); + expect( + yield* fileSystem.readFileString(path.join(shadowHome, "state_5.sqlite-wal")), + ).toBe("shadow wal"); + expect(yield* fileSystem.exists(path.join(shadowHome, "state_5.sqlite"))).toBe(false); + expect(yield* fileSystem.readLink(path.join(shadowHome, "config.toml"))).toBe( + path.join(sharedHome, "config.toml"), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "removes SQLite links an earlier setup left in the shadow home", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + + yield* fileSystem.makeDirectory(shadowHome, { recursive: true }); + for (const name of ["state_5.sqlite", "state_5.sqlite-wal"]) { + yield* writeTextFile(path.join(sharedHome, name), `shared ${name}`); + yield* fileSystem.symlink(path.join(sharedHome, name), path.join(shadowHome, name)); + } + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + + yield* materializeCodexShadowHome(layout); + + expect(yield* fileSystem.exists(path.join(shadowHome, "state_5.sqlite"))).toBe(false); + expect(yield* fileSystem.exists(path.join(shadowHome, "state_5.sqlite-wal"))).toBe(false); + expect(yield* fileSystem.readFileString(path.join(sharedHome, "state_5.sqlite"))).toBe( + "shared state_5.sqlite", + ); + }), + ); + it.effect.skipIf(!symlinksSupported)( "replaces Codex-created local MCP OAuth locks with the shared lock directory", () => diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index b0bb7df4e2d46..b6f77ef78f8e8 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -34,6 +34,10 @@ const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp", ".DS_Store"] // 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"]); +// Shadow homes reach SQLite state through CODEX_SQLITE_HOME instead (see withCodexSqliteHome). +// A linked database splits from the -wal and -shm files SQLite writes beside the link, and a +// database Codex creates after setup would block the next setup as a real file. +const SQLITE_ENTRY_PATTERN = /\.sqlite(?:-wal|-shm|-journal)?$/; function resolveHomePath(path: Path.Path, home: string, value: string | undefined): string { const expanded = @@ -66,6 +70,20 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi }; }); +/** + * Codex keeps its SQLite state (session index, goals, memories) under + * CODEX_SQLITE_HOME, falling back to CODEX_HOME. Every account of an + * auth-overlay instance opens those databases in the shared home directly, so + * they stay whole and shared. A value set on the instance wins. + */ +export function withCodexSqliteHome( + environment: NodeJS.ProcessEnv, + layout: CodexHomeLayout, +): NodeJS.ProcessEnv { + if (layout.mode !== "authOverlay" || environment.CODEX_SQLITE_HOME?.trim()) return environment; + return { ...environment, CODEX_SQLITE_HOME: layout.sharedHomePath }; +} + const CodexShadowHomeContext = { sharedHomePath: Schema.String, effectiveHomePath: Schema.String, @@ -185,27 +203,27 @@ const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* (inpu ); }); -const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(function* (input: { +const removeShadowSymlink = Effect.fn("CodexHomeLayout.removeShadowSymlink")(function* (input: { readonly fileSystem: FileSystem.FileSystem; readonly sharedHomePath: string; readonly effectiveHomePath: string; readonly entryName: string; }): Effect.fn.Return { const path = yield* Path.Path; - const privatePath = path.join(input.effectiveHomePath, input.entryName); + const shadowPath = path.join(input.effectiveHomePath, input.entryName); const state = yield* readLinkState({ ...input, - linkPath: privatePath, + linkPath: shadowPath, }); if (state._tag === "Symlink") { - yield* input.fileSystem.remove(privatePath).pipe( + yield* input.fileSystem.remove(shadowPath).pipe( Effect.catchTags({ PlatformError: (cause) => new CodexShadowHomeFileSystemError({ sharedHomePath: input.sharedHomePath, effectiveHomePath: input.effectiveHomePath, operation: "remove", - path: privatePath, + path: shadowPath, entryName: input.entryName, cause, }), @@ -373,22 +391,41 @@ 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_PATTERN.test(entryName) + ) { entries.add(entryName); } } + const shadowEntryNames = yield* fileSystem.readDirectory(effectiveHomePath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + operation: "readDirectory", + path: effectiveHomePath, + cause, + }), + }), + ); + // Real SQLite files in the shadow home are left alone: they may hold an account's data. + const unlinkedEntryNames = [ + ...[...PRIVATE_ENTRY_NAMES].filter((entryName) => entryName !== "auth.json"), + ...shadowEntryNames.filter((entryName) => SQLITE_ENTRY_PATTERN.test(entryName)), + ]; yield* Effect.forEach( - PRIVATE_ENTRY_NAMES, + unlinkedEntryNames, (entryName) => - entryName === "auth.json" - ? Effect.void - : removePrivateSymlink({ - fileSystem, - sharedHomePath: layout.sharedHomePath, - effectiveHomePath, - entryName, - }), + removeShadowSymlink({ + fileSystem, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + entryName, + }), { discard: true }, ); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 3bb561aef2537..2a0026291f1e1 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -58,7 +58,10 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ServerConfig from "../config.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { + resolveCodexHomeLayout, + withCodexSqliteHome, +} from "../provider/Drivers/CodexHomeLayout.ts"; import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -1355,7 +1358,10 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( Effect.provideService(Path.Path, input.path), ); if (layout.effectiveHomePath) - resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; + resolved = withCodexSqliteHome( + { ...resolved, CODEX_HOME: layout.effectiveHomePath }, + layout, + ); } } else if (instance.driver === "claudeAgent") { const config = decodeClaudeSettings(instance.config ?? {}); From 03900197b51752dafd95dfec4c92754a387e4d33 Mon Sep 17 00:00:00 2001 From: web3theo Date: Sun, 18 Oct 2026 22:31:29 +0000 Subject: [PATCH 2/3] test(server): cover SQLite files Codex creates between shadow home setups Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ch8wyPrnXdHFdixz7SXJNX --- .../provider/Drivers/CodexHomeLayout.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index bc932fdd9408a..38cf79e5b17a1 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -271,6 +271,36 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "accepts SQLite files Codex creates between setups", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + const names = ["queue_1.sqlite", "queue_1.sqlite-wal", "queue_1.sqlite-shm"]; + + yield* materializeCodexShadowHome(layout); + for (const name of names) { + yield* writeTextFile(path.join(sharedHome, name), `shared ${name}`); + yield* writeTextFile(path.join(shadowHome, name), `shadow ${name}`); + } + yield* materializeCodexShadowHome(layout); + + for (const name of names) { + const shadowPath = path.join(shadowHome, name); + expect(yield* fileSystem.readFileString(shadowPath)).toBe(`shadow ${name}`); + const linkResult = yield* fileSystem.readLink(shadowPath).pipe(Effect.result); + expect(linkResult._tag).toBe("Failure"); + } + }), + ); + it.effect.skipIf(!symlinksSupported)( "removes SQLite links an earlier setup left in the shadow home", () => From 5c4259e379fcd1adb83e6700cbef332048ba6343 Mon Sep 17 00:00:00 2001 From: web3theo Date: Sun, 18 Oct 2026 22:31:48 +0000 Subject: [PATCH 3/3] style(server): format the shadow home setup test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ch8wyPrnXdHFdixz7SXJNX --- .../provider/Drivers/CodexHomeLayout.test.ts | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 38cf79e5b17a1..6be4a570e82be 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -271,34 +271,32 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); - it.effect.skipIf(!symlinksSupported)( - "accepts SQLite files Codex creates between setups", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sharedHome = yield* makeTempDir("t3code-codex-shared-"); - const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); - const shadowHome = path.join(shadowRoot, "shadow"); - const layout = yield* resolveCodexHomeLayout( - decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), - ); - const names = ["queue_1.sqlite", "queue_1.sqlite-wal", "queue_1.sqlite-shm"]; + it.effect.skipIf(!symlinksSupported)("accepts SQLite files Codex creates between setups", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + const names = ["queue_1.sqlite", "queue_1.sqlite-wal", "queue_1.sqlite-shm"]; - yield* materializeCodexShadowHome(layout); - for (const name of names) { - yield* writeTextFile(path.join(sharedHome, name), `shared ${name}`); - yield* writeTextFile(path.join(shadowHome, name), `shadow ${name}`); - } - yield* materializeCodexShadowHome(layout); + yield* materializeCodexShadowHome(layout); + for (const name of names) { + yield* writeTextFile(path.join(sharedHome, name), `shared ${name}`); + yield* writeTextFile(path.join(shadowHome, name), `shadow ${name}`); + } + yield* materializeCodexShadowHome(layout); - for (const name of names) { - const shadowPath = path.join(shadowHome, name); - expect(yield* fileSystem.readFileString(shadowPath)).toBe(`shadow ${name}`); - const linkResult = yield* fileSystem.readLink(shadowPath).pipe(Effect.result); - expect(linkResult._tag).toBe("Failure"); - } - }), + for (const name of names) { + const shadowPath = path.join(shadowHome, name); + expect(yield* fileSystem.readFileString(shadowPath)).toBe(`shadow ${name}`); + const linkResult = yield* fileSystem.readLink(shadowPath).pipe(Effect.result); + expect(linkResult._tag).toBe("Failure"); + } + }), ); it.effect.skipIf(!symlinksSupported)(