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,101 @@ 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)("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", + () => + 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 ?? {});