diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 2bbcea20faa91..a271c627d30d9 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -331,15 +331,93 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { ); it.effect.skipIf(!symlinksSupported)( - "rejects shared entries that already exist in the shadow home as real files", + "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'); + } + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\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 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); + + expect(result._tag).toBe("Success"); + for (const entryName of rewrittenEntries) { + 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* fileSystem.readLink(path.join(shadowHome, "config.toml"))).toBe( + path.join(sharedHome, "config.toml"), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "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({ @@ -354,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 f6d2ba9d44cf5..4bbd856ccdac4 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -29,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. @@ -80,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,