From 5c3145876e1c54de79b02da2b6ceebd3ca7e28cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:06:05 +0000 Subject: [PATCH 1/4] test(server): Codex rewriting a linked shadow-home file keeps the provider usable Failing repro for #26811. AI-assisted; verified with the tests and tools listed above. --- .../provider/Drivers/CodexHomeLayout.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 2bbcea20faa91..7dd3e8a836719 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -330,6 +330,40 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "keeps the provider usable after Codex atomically rewrites a linked runtime file", + () => + 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 rewrittenEntries = ["cloud-config-bundle-cache.json", ".codex-global-state.json"]; + for (const entryName of rewrittenEntries) { + yield* writeTextFile(path.join(sharedHome, entryName), '{"owner":"shared"}\n'); + } + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + yield* materializeCodexShadowHome(layout); + + // Codex saves these with write-temp-then-rename, replacing the link with a regular file. + for (const entryName of rewrittenEntries) { + const temporaryPath = path.join(shadowHome, `.${entryName}.tmp`); + yield* fileSystem.writeFileString(temporaryPath, '{"owner":"shadow"}\n'); + yield* fileSystem.rename(temporaryPath, path.join(shadowHome, entryName)); + } + + const result = yield* materializeCodexShadowHome(layout).pipe(Effect.result); + + expect(result._tag).toBe("Success"); + }), + ); + it.effect.skipIf(!symlinksSupported)( "rejects shared entries that already exist in the shadow home as real files", () => From 5e6e90260a604dcb569c9b1275fa84b82bf2b853 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:12:10 +0000 Subject: [PATCH 2/4] fix(server): Codex rewriting a linked shadow-home file no longer disables the provider Codex saves files such as cloud-config-bundle-cache.json and .codex-global-state.json by write-temp-then-rename, which turns the shadow home's link into a regular file. The next shadow-home setup rejected that file with CodexShadowHomeEntryConflictError and the instance was disabled. When the shadow entry and the shared entry are both plain files, setup now does what a working link would have done: the newer copy becomes the shared one, the displaced copy is kept in the shadow home's .t3-shadow-conflicts folder, and the link is restored. SQLite files, lock files and directories still raise the conflict error. Fixes pingdotgg/t3code#26811 AI-assisted; verified with the tests and tools listed above. --- .../provider/Drivers/CodexHomeLayout.test.ts | 37 ++++++-- .../src/provider/Drivers/CodexHomeLayout.ts | 84 ++++++++++++++++++- 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 7dd3e8a836719..c0209a5385937 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -343,6 +343,7 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { for (const entryName of rewrittenEntries) { yield* writeTextFile(path.join(sharedHome, entryName), '{"owner":"shared"}\n'); } + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); const layout = yield* resolveCodexHomeLayout( decodeCodexSettings({ homePath: sharedHome, @@ -358,9 +359,33 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { yield* fileSystem.rename(temporaryPath, path.join(shadowHome, entryName)); } + // Shared config.toml was saved after the shadow's rewrite, so the shared copy wins. + const staleShadowConfig = path.join(shadowHome, ".config.toml.tmp"); + yield* fileSystem.writeFileString(staleShadowConfig, 'model = "shadow"\n'); + yield* fileSystem.rename(staleShadowConfig, path.join(shadowHome, "config.toml")); + yield* fileSystem.utimes(path.join(shadowHome, "config.toml"), 0, 0); + for (const entryName of rewrittenEntries) { + yield* fileSystem.utimes(path.join(sharedHome, entryName), 0, 0); + } + const result = yield* materializeCodexShadowHome(layout).pipe(Effect.result); expect(result._tag).toBe("Success"); + for (const entryName of [...rewrittenEntries, "config.toml"]) { + expect(yield* fileSystem.readLink(path.join(shadowHome, entryName))).toBe( + path.join(sharedHome, entryName), + ); + } + const readShared = (entryName: string) => + fileSystem.readFileString(path.join(sharedHome, entryName)); + const readBackup = (entryName: string) => + fileSystem.readFileString(path.join(shadowHome, ".t3-shadow-conflicts", entryName)); + for (const entryName of rewrittenEntries) { + expect(yield* readShared(entryName)).toBe('{"owner":"shadow"}\n'); + expect(yield* readBackup(entryName)).toBe('{"owner":"shared"}\n'); + } + expect(yield* readShared("config.toml")).toBe('model = "gpt-5-codex"\n'); + expect(yield* readBackup("config.toml")).toBe('model = "shadow"\n'); }), ); @@ -372,8 +397,8 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { 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'); - yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n'); + yield* writeTextFile(path.join(sharedHome, "state_5.sqlite"), "shared"); + yield* writeTextFile(path.join(shadowHome, "state_5.sqlite"), "local"); const layout = yield* resolveCodexHomeLayout( decodeCodexSettings({ @@ -388,12 +413,12 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(error).toMatchObject({ sharedHomePath: sharedHome, effectiveHomePath: shadowHome, - entryName: "config.toml", - linkPath: path.join(shadowHome, "config.toml"), - targetPath: path.join(sharedHome, "config.toml"), + entryName: "state_5.sqlite", + linkPath: path.join(shadowHome, "state_5.sqlite"), + targetPath: path.join(sharedHome, "state_5.sqlite"), }); 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.`, + `Cannot create Codex shadow home entry 'state_5.sqlite' because '${path.join(shadowHome, "state_5.sqlite")}' already exists and is not a symlink.`, ); }), ); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index f6d2ba9d44cf5..bab9a7563499c 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -1,6 +1,7 @@ import { ProviderDriverKind, type CodexSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as PlatformError from "effect/PlatformError"; @@ -39,6 +40,10 @@ const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set([ "thread-writer-locks", ".sqlite-maintenance.lock", ]); +// Codex saves files like config caches by write-temp-then-rename, which turns a shared link into a +// regular file. Databases and locks are never replaced that way, and copying them is unsafe. +const NON_RECONCILABLE_FILE_PATTERN = /\.(?:sqlite(?:-wal|-shm|-journal)?|lock)$/; +const SHADOW_CONFLICT_BACKUP_DIRECTORY = ".t3-shadow-conflicts"; function resolveHomePath(path: Path.Path, home: string, value: string | undefined): string { const expanded = @@ -80,7 +85,17 @@ export class CodexShadowHomeFileSystemError extends Schema.TaggedError { + if (NON_RECONCILABLE_FILE_PATTERN.test(input.entryName)) return false; + const path = yield* Path.Path; + const { fileSystem, entryName } = input; + const sharedPath = path.join(input.sharedHomePath, entryName); + const shadowPath = path.join(input.effectiveHomePath, entryName); + const backupDirectory = path.join(input.effectiveHomePath, SHADOW_CONFLICT_BACKUP_DIRECTORY); + const backupPath = path.join(backupDirectory, entryName); + const fail = + (operation: CodexShadowHomeFileSystemError["operation"], failedPath: string) => + (cause: PlatformError.PlatformError) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation, + path: failedPath, + entryName, + cause, + }); + + const [sharedInfo, shadowInfo] = yield* Effect.all([ + fileSystem.stat(sharedPath).pipe(Effect.mapError(fail("stat", sharedPath))), + fileSystem.stat(shadowPath).pipe(Effect.mapError(fail("stat", shadowPath))), + ]); + if (sharedInfo.type !== "File" || shadowInfo.type !== "File") return false; + + const shadowIsNewer = + Option.isSome(shadowInfo.mtime) && + Option.isSome(sharedInfo.mtime) && + shadowInfo.mtime.value.getTime() > sharedInfo.mtime.value.getTime(); + const displacedPath = shadowIsNewer ? sharedPath : shadowPath; + + yield* fileSystem + .makeDirectory(backupDirectory, { recursive: true }) + .pipe(Effect.mapError(fail("makeDirectory", backupDirectory))); + yield* fileSystem + .copyFile(displacedPath, backupPath) + .pipe(Effect.mapError(fail("copyFile", displacedPath))); + if (shadowIsNewer) { + // Write through, not rename, so a shared entry that is itself a symlink stays one. + const contents = yield* fileSystem + .readFile(shadowPath) + .pipe(Effect.mapError(fail("readFile", shadowPath))); + yield* fileSystem + .writeFile(sharedPath, contents) + .pipe(Effect.mapError(fail("writeFile", sharedPath))); + } + yield* fileSystem.remove(shadowPath).pipe(Effect.mapError(fail("remove", shadowPath))); + yield* Effect.logWarning( + "Codex replaced a linked shadow home file; kept the newer copy shared and backed up the other.", + { entryName, sharedPath, backupPath }, + ); + return true; + }, +); + const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { readonly fileSystem: FileSystem.FileSystem; readonly sharedHomePath: string; @@ -250,6 +329,9 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu if (state._tag === "NotSymlink") { if (!REPLACEABLE_SHARED_RUNTIME_ENTRIES.has(input.entryName)) { + if (yield* reconcileReplacedSharedFile(input)) { + return yield* createLink; + } return yield* new CodexShadowHomeEntryConflictError({ sharedHomePath: input.sharedHomePath, effectiveHomePath: input.effectiveHomePath, From 97442e8f303567084d2ad1b3b139abdc3bb3d430 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:23:00 +0000 Subject: [PATCH 3/4] fix(server): shadow homes keep the Codex files Codex replaces by rename The previous change copied whichever real file in a shadow home was newer into the shared Codex home. A real config.toml in a shadow home is the user's own (Codex writes config.toml through the link), so that could overwrite the main config. Drop that reconciliation and keep the conflict error for config.toml. Instead, cloud-config-bundle-cache.json and cloud-requirements-cache.json (signed per ChatGPT identity) and .codex-global-state.json with its .bak (saved by write-temp-then-rename) are private to each shadow home, like models_cache.json. An existing link to the shared copy is removed, and a real file Codex left there is kept. Fixes pingdotgg/t3code#26811 AI-assisted; verified with the tests and tools listed above. --- .../provider/Drivers/CodexHomeLayout.test.ts | 41 +++----- .../src/provider/Drivers/CodexHomeLayout.ts | 97 +++---------------- 2 files changed, 24 insertions(+), 114 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index c0209a5385937..fc404a9b451ca 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -343,7 +343,6 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { for (const entryName of rewrittenEntries) { yield* writeTextFile(path.join(sharedHome, entryName), '{"owner":"shared"}\n'); } - yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); const layout = yield* resolveCodexHomeLayout( decodeCodexSettings({ homePath: sharedHome, @@ -359,33 +358,17 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { yield* fileSystem.rename(temporaryPath, path.join(shadowHome, entryName)); } - // Shared config.toml was saved after the shadow's rewrite, so the shared copy wins. - const staleShadowConfig = path.join(shadowHome, ".config.toml.tmp"); - yield* fileSystem.writeFileString(staleShadowConfig, 'model = "shadow"\n'); - yield* fileSystem.rename(staleShadowConfig, path.join(shadowHome, "config.toml")); - yield* fileSystem.utimes(path.join(shadowHome, "config.toml"), 0, 0); - for (const entryName of rewrittenEntries) { - yield* fileSystem.utimes(path.join(sharedHome, entryName), 0, 0); - } - const result = yield* materializeCodexShadowHome(layout).pipe(Effect.result); expect(result._tag).toBe("Success"); - for (const entryName of [...rewrittenEntries, "config.toml"]) { - expect(yield* fileSystem.readLink(path.join(shadowHome, entryName))).toBe( - path.join(sharedHome, entryName), - ); - } - const readShared = (entryName: string) => - fileSystem.readFileString(path.join(sharedHome, entryName)); - const readBackup = (entryName: string) => - fileSystem.readFileString(path.join(shadowHome, ".t3-shadow-conflicts", entryName)); for (const entryName of rewrittenEntries) { - expect(yield* readShared(entryName)).toBe('{"owner":"shadow"}\n'); - expect(yield* readBackup(entryName)).toBe('{"owner":"shared"}\n'); + expect(yield* fileSystem.readFileString(path.join(shadowHome, entryName))).toBe( + '{"owner":"shadow"}\n', + ); + expect(yield* fileSystem.readFileString(path.join(sharedHome, entryName))).toBe( + '{"owner":"shared"}\n', + ); } - expect(yield* readShared("config.toml")).toBe('model = "gpt-5-codex"\n'); - expect(yield* readBackup("config.toml")).toBe('model = "shadow"\n'); }), ); @@ -397,8 +380,8 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { 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, "state_5.sqlite"), "shared"); - yield* writeTextFile(path.join(shadowHome, "state_5.sqlite"), "local"); + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); + yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n'); const layout = yield* resolveCodexHomeLayout( decodeCodexSettings({ @@ -413,12 +396,12 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(error).toMatchObject({ sharedHomePath: sharedHome, effectiveHomePath: shadowHome, - entryName: "state_5.sqlite", - linkPath: path.join(shadowHome, "state_5.sqlite"), - targetPath: path.join(sharedHome, "state_5.sqlite"), + entryName: "config.toml", + linkPath: path.join(shadowHome, "config.toml"), + targetPath: path.join(sharedHome, "config.toml"), }); expect(error.message).toBe( - `Cannot create Codex shadow home entry 'state_5.sqlite' because '${path.join(shadowHome, "state_5.sqlite")}' already exists and is not a symlink.`, + `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`, ); }), ); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index bab9a7563499c..ec1ef5d7deff9 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -1,7 +1,6 @@ import { ProviderDriverKind, type CodexSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as PlatformError from "effect/PlatformError"; @@ -30,8 +29,17 @@ const KNOWN_SHARED_DIRECTORIES = [ "thread-writer-locks", ] as const; -// auth.lock guards the shadow home's private auth.json. -const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "auth.lock", "models_cache.json"]); +// auth.lock guards the shadow home's private auth.json. The cloud caches are signed per ChatGPT +// identity, and Codex replaces them and its global state by rename, which would break a link. +const PRIVATE_ENTRY_NAMES = new Set([ + "auth.json", + "auth.lock", + "models_cache.json", + "cloud-config-bundle-cache.json", + "cloud-requirements-cache.json", + ".codex-global-state.json", + ".codex-global-state.json.bak", +]); // Finder maintains metadata separately for each directory, including shadow homes. const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp", ".DS_Store"]); // Shadow homes may create local runtime locks first; replace them with shared links because they guard shared state. @@ -40,10 +48,6 @@ const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set([ "thread-writer-locks", ".sqlite-maintenance.lock", ]); -// Codex saves files like config caches by write-temp-then-rename, which turns a shared link into a -// regular file. Databases and locks are never replaced that way, and copying them is unsafe. -const NON_RECONCILABLE_FILE_PATTERN = /\.(?:sqlite(?:-wal|-shm|-journal)?|lock)$/; -const SHADOW_CONFLICT_BACKUP_DIRECTORY = ".t3-shadow-conflicts"; function resolveHomePath(path: Path.Path, home: string, value: string | undefined): string { const expanded = @@ -85,17 +89,7 @@ export class CodexShadowHomeFileSystemError extends Schema.TaggedError { - if (NON_RECONCILABLE_FILE_PATTERN.test(input.entryName)) return false; - const path = yield* Path.Path; - const { fileSystem, entryName } = input; - const sharedPath = path.join(input.sharedHomePath, entryName); - const shadowPath = path.join(input.effectiveHomePath, entryName); - const backupDirectory = path.join(input.effectiveHomePath, SHADOW_CONFLICT_BACKUP_DIRECTORY); - const backupPath = path.join(backupDirectory, entryName); - const fail = - (operation: CodexShadowHomeFileSystemError["operation"], failedPath: string) => - (cause: PlatformError.PlatformError) => - new CodexShadowHomeFileSystemError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - operation, - path: failedPath, - entryName, - cause, - }); - - const [sharedInfo, shadowInfo] = yield* Effect.all([ - fileSystem.stat(sharedPath).pipe(Effect.mapError(fail("stat", sharedPath))), - fileSystem.stat(shadowPath).pipe(Effect.mapError(fail("stat", shadowPath))), - ]); - if (sharedInfo.type !== "File" || shadowInfo.type !== "File") return false; - - const shadowIsNewer = - Option.isSome(shadowInfo.mtime) && - Option.isSome(sharedInfo.mtime) && - shadowInfo.mtime.value.getTime() > sharedInfo.mtime.value.getTime(); - const displacedPath = shadowIsNewer ? sharedPath : shadowPath; - - yield* fileSystem - .makeDirectory(backupDirectory, { recursive: true }) - .pipe(Effect.mapError(fail("makeDirectory", backupDirectory))); - yield* fileSystem - .copyFile(displacedPath, backupPath) - .pipe(Effect.mapError(fail("copyFile", displacedPath))); - if (shadowIsNewer) { - // Write through, not rename, so a shared entry that is itself a symlink stays one. - const contents = yield* fileSystem - .readFile(shadowPath) - .pipe(Effect.mapError(fail("readFile", shadowPath))); - yield* fileSystem - .writeFile(sharedPath, contents) - .pipe(Effect.mapError(fail("writeFile", sharedPath))); - } - yield* fileSystem.remove(shadowPath).pipe(Effect.mapError(fail("remove", shadowPath))); - yield* Effect.logWarning( - "Codex replaced a linked shadow home file; kept the newer copy shared and backed up the other.", - { entryName, sharedPath, backupPath }, - ); - return true; - }, -); - const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { readonly fileSystem: FileSystem.FileSystem; readonly sharedHomePath: string; @@ -329,9 +259,6 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu if (state._tag === "NotSymlink") { if (!REPLACEABLE_SHARED_RUNTIME_ENTRIES.has(input.entryName)) { - if (yield* reconcileReplacedSharedFile(input)) { - return yield* createLink; - } return yield* new CodexShadowHomeEntryConflictError({ sharedHomePath: input.sharedHomePath, effectiveHomePath: input.effectiveHomePath, From f9b769843a7fd522d7a66cfffde62959cb863b50 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:34:23 +0000 Subject: [PATCH 4/4] fix(server): a real config.toml in a Codex shadow home no longer disables the provider When the shadow home's config.toml is a regular file, setup no longer fails. If it matches the shared config byte for byte (Codex re-saved it by rename), the link is restored. If it differs, it may be that account's own config, so it is kept and the shared config.toml is not touched. Other shared entries that are regular files still raise the conflict error. Fixes pingdotgg/t3code#26811 AI-assisted; verified with the tests and tools listed above. --- .../provider/Drivers/CodexHomeLayout.test.ts | 46 +++++++++++++++++-- .../src/provider/Drivers/CodexHomeLayout.ts | 34 +++++++++++++- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index fc404a9b451ca..a271c627d30d9 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -343,6 +343,7 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { for (const entryName of rewrittenEntries) { yield* writeTextFile(path.join(sharedHome, entryName), '{"owner":"shared"}\n'); } + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); const layout = yield* resolveCodexHomeLayout( decodeCodexSettings({ homePath: sharedHome, @@ -357,6 +358,9 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { yield* fileSystem.writeFileString(temporaryPath, '{"owner":"shadow"}\n'); yield* fileSystem.rename(temporaryPath, path.join(shadowHome, entryName)); } + const temporaryConfig = path.join(shadowHome, ".config.toml.tmp"); + yield* fileSystem.writeFileString(temporaryConfig, 'model = "gpt-5-codex"\n'); + yield* fileSystem.rename(temporaryConfig, path.join(shadowHome, "config.toml")); const result = yield* materializeCodexShadowHome(layout).pipe(Effect.result); @@ -369,19 +373,51 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { '{"owner":"shared"}\n', ); } + expect(yield* fileSystem.readLink(path.join(shadowHome, "config.toml"))).toBe( + path.join(sharedHome, "config.toml"), + ); }), ); it.effect.skipIf(!symlinksSupported)( - "rejects shared entries that already exist in the shadow home as real files", + "keeps a shadow home's own config.toml without touching the shared one", () => 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'); yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n'); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + expect(yield* fileSystem.readFileString(path.join(shadowHome, "config.toml"))).toBe( + 'model = "local"\n', + ); + expect(yield* fileSystem.readFileString(path.join(sharedHome, "config.toml"))).toBe( + 'model = "gpt-5-codex"\n', + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "rejects shared entries that already exist in the shadow home as real files", + () => + Effect.gen(function* () { + 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, "history.jsonl"), "shared\n"); + yield* writeTextFile(path.join(shadowHome, "history.jsonl"), "local\n"); const layout = yield* resolveCodexHomeLayout( decodeCodexSettings({ @@ -396,12 +432,12 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(error).toMatchObject({ sharedHomePath: sharedHome, effectiveHomePath: shadowHome, - entryName: "config.toml", - linkPath: path.join(shadowHome, "config.toml"), - targetPath: path.join(sharedHome, "config.toml"), + entryName: "history.jsonl", + linkPath: path.join(shadowHome, "history.jsonl"), + targetPath: path.join(sharedHome, "history.jsonl"), }); 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.`, + `Cannot create Codex shadow home entry 'history.jsonl' because '${path.join(shadowHome, "history.jsonl")}' already exists and is not a symlink.`, ); }), ); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index ec1ef5d7deff9..4bbd856ccdac4 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -89,7 +89,14 @@ export class CodexShadowHomeFileSystemError extends Schema.TaggedError + input.fileSystem.readFileString(configPath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "readFile", + path: configPath, + entryName: input.entryName, + cause, + }), + }), + ); + // A config that differs may be this account's own, so it is kept and the shared one is not + // touched. An identical copy (Codex re-saving by rename) is linked again. + if ((yield* readConfig(link)) !== (yield* readConfig(target))) { + return yield* Effect.logWarning( + "Codex shadow home has its own config.toml; it is used instead of the shared one.", + { path: link }, + ); + } + } else if (!REPLACEABLE_SHARED_RUNTIME_ENTRIES.has(input.entryName)) { return yield* new CodexShadowHomeEntryConflictError({ sharedHomePath: input.sharedHomePath, effectiveHomePath: input.effectiveHomePath,