diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 04dad8d9c784..9716a832c409 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -7,11 +7,8 @@ * - `adapter` — the Codex session/turn/approval runtime; * - `textGeneration` — commit/PR/branch/title generation via `codex exec`. * - * Each call to `create()` captures the `codexConfig` argument in closures - * owned by the returned instance. Two instances created with different - * `homePath`s (e.g. `codex_personal` + `codex_work`) therefore run with - * fully independent Codex app-server processes and `CODEX_HOME` - * environments — no shared mutable state. + * Each instance owns its app-server process and `CODEX_HOME`. Auth overlays + * share session and SQLite state while keeping account credentials private. * * Resource lifecycle: `create()` runs in a scope handed in by the registry. * Closing that scope releases the adapter's child processes, the managed @@ -112,8 +109,11 @@ export const CodexDriver: ProviderDriver = { const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const modelManifest = yield* ModelManifest.ModelManifest; - const processEnv = mergeProviderInstanceEnvironment(environment); + let processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); + if (homeLayout.mode === "authOverlay") { + processEnv = { ...processEnv, CODEX_SQLITE_HOME: homeLayout.sharedHomePath }; + } 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 03c717abb14d..674dffc5335b 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -96,12 +96,26 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); yield* writeTextFile(path.join(sharedHome, "models_cache.json"), '{"models":["shared"]}\n'); yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n'); + const sqliteEntryNames = ["state_5.sqlite", "state_5.sqlite-shm", "state_5.sqlite-wal"]; + yield* Effect.forEach( + sqliteEntryNames, + (entryName) => writeTextFile(path.join(sharedHome, entryName), "sqlite"), + { discard: true }, + ); yield* fileSystem.makeDirectory(shadowHome, { recursive: true }); yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); yield* fileSystem.symlink( path.join(sharedHome, "models_cache.json"), path.join(shadowHome, "models_cache.json"), ); + yield* Effect.forEach( + sqliteEntryNames, + (entryName) => + fileSystem.symlink(path.join(sharedHome, entryName), path.join(shadowHome, entryName)), + { discard: true }, + ); + const shadowLocalSqlite = path.join(shadowHome, "shadow-local.sqlite"); + yield* writeTextFile(shadowLocalSqlite, "shadow-local"); const layout = yield* resolveCodexHomeLayout( decodeCodexSettings({ @@ -124,6 +138,10 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { .readLink(path.join(shadowHome, "auth.json")) .pipe(Effect.result); const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json")); + const sqliteLinksExist = yield* Effect.forEach(sqliteEntryNames, (entryName) => + fileSystem.exists(path.join(shadowHome, entryName)), + ); + const shadowLocalSqliteContents = yield* fileSystem.readFileString(shadowLocalSqlite); expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); expect(configTarget).toBe(path.join(sharedHome, "config.toml")); @@ -131,6 +149,8 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(modelsCacheExists).toBe(false); expect(authLinkResult._tag).toBe("Failure"); expect(authContents).toContain("shadow"); + expect(sqliteLinksExist).toEqual([false, false, false]); + expect(shadowLocalSqliteContents).toBe("shadow-local"); }), ); @@ -210,6 +230,94 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); + it.effect("keeps both homes' SQLite files intact across repeated materialization", () => + 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, + }), + ); + + yield* materializeCodexShadowHome(layout); + + // Codex adds a runtime database to both homes after the first pass. + const sqliteSuffixes = ["", "-wal", "-shm"]; + for (const suffix of sqliteSuffixes) { + yield* writeTextFile(path.join(sharedHome, `queue_1.sqlite${suffix}`), `shared${suffix}`); + yield* writeTextFile(path.join(shadowHome, `queue_1.sqlite${suffix}`), `shadow${suffix}`); + yield* writeTextFile( + path.join(sharedHome, `future_runtime.sqlite${suffix}`), + `future-shared${suffix}`, + ); + } + yield* writeTextFile(path.join(shadowHome, "future_runtime.sqlite"), "future-shadow"); + + yield* materializeCodexShadowHome(layout); + yield* materializeCodexShadowHome(layout); + + for (const suffix of sqliteSuffixes) { + const entryName = `queue_1.sqlite${suffix}`; + const shadowPath = path.join(shadowHome, entryName); + expect(yield* fileSystem.readFileString(path.join(sharedHome, entryName))).toBe( + `shared${suffix}`, + ); + expect(yield* fileSystem.readFileString(shadowPath)).toBe(`shadow${suffix}`); + expect((yield* fileSystem.readLink(shadowPath).pipe(Effect.result))._tag).toBe("Failure"); + } + + expect( + yield* fileSystem.readFileString(path.join(shadowHome, "future_runtime.sqlite")), + ).toBe("future-shadow"); + expect(yield* fileSystem.exists(path.join(shadowHome, "future_runtime.sqlite-wal"))).toBe( + false, + ); + expect(yield* fileSystem.exists(path.join(shadowHome, "future_runtime.sqlite-shm"))).toBe( + false, + ); + }), + ); + + it.effect("clears stale SQLite links when the shadow home has no database of its own", () => + 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 staleWalLink = path.join(shadowHome, "state.sqlite-wal"); + + yield* writeTextFile(path.join(sharedHome, "state.sqlite"), "shared"); + yield* fileSystem.makeDirectory(shadowHome, { recursive: true }); + // An earlier launch linked sidecars only, and the shared WAL has since gone. + yield* fileSystem.symlink(path.join(sharedHome, "state.sqlite-wal"), staleWalLink); + yield* writeTextFile(path.join(shadowHome, "state.sqlite-shm"), "shadow-shm"); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + expect(yield* fileSystem.exists(path.join(shadowHome, "state.sqlite"))).toBe(false); + expect((yield* fileSystem.readLink(staleWalLink).pipe(Effect.result))._tag).toBe("Failure"); + expect(yield* fileSystem.readFileString(path.join(shadowHome, "state.sqlite-shm"))).toBe( + "shadow-shm", + ); + expect(yield* fileSystem.readFileString(path.join(sharedHome, "state.sqlite"))).toBe( + "shared", + ); + }), + ); + it.effect("rejects shadow homes that point at the shared home", () => Effect.gen(function* () { const sharedHome = yield* makeTempDir("t3code-codex-shared-"); @@ -235,6 +343,7 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { it.effect("rejects shared entries that already exist in the shadow home as real files", () => 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-"); @@ -262,6 +371,19 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(error.message).toBe( `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`, ); + expect(yield* fileSystem.readFileString(path.join(sharedHome, "config.toml"))).toBe( + 'model = "gpt-5-codex"\n', + ); + expect(yield* fileSystem.readFileString(path.join(shadowHome, "config.toml"))).toBe( + 'model = "local"\n', + ); + + yield* fileSystem.remove(path.join(shadowHome, "config.toml")); + yield* materializeCodexShadowHome(layout); + + expect(yield* fileSystem.readLink(path.join(shadowHome, "config.toml"))).toBe( + path.join(sharedHome, "config.toml"), + ); }), ); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 92e923c0b669..1b3f42798409 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -32,6 +32,7 @@ const KNOWN_SHARED_DIRECTORIES = [ const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); const REPLACEABLE_SHARED_RUNTIME_DIRECTORIES = new Set(["mcp-oauth-locks"]); +const SQLITE_ENTRY_NAME_PATTERN = /\.sqlite(?:-(?:journal|shm|wal))?$/; function resolveHomePath(path: Path.Path, value: string | undefined): string { const expanded = @@ -370,15 +371,34 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" }), }), ); + const shadowEntryNames = yield* fileSystem.readDirectory(effectiveHomePath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + operation: "readDirectory", + path: effectiveHomePath, + cause, + }), + }), + ); 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_PATTERN.test(entryName) + ) { entries.add(entryName); } } yield* Effect.forEach( - PRIVATE_ENTRY_NAMES, + [ + ...PRIVATE_ENTRY_NAMES, + ...shadowEntryNames.filter((entryName) => SQLITE_ENTRY_NAME_PATTERN.test(entryName)), + ], (entryName) => entryName === "auth.json" ? Effect.void