diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 2bbcea20faa91..f61d46d3e8e9f 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -165,6 +165,85 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "keeps auth-like entries private to each home without touching shared copies", + () => + 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 authLikeNames = [ + "auth-fixture-a.json", + "auth_fixture_b.json", + "auth.json.bak", + "AUTH.Backup", + "auth.lock", + ]; + + for (const name of authLikeNames) { + yield* writeTextFile(path.join(sharedHome, name), `{"shared":"${name}"}\n`); + } + yield* writeTextFile(path.join(sharedHome, "author.json"), '{"author":true}\n'); + yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); + // Older versions linked these into shadow homes; those links are removed, not rejected. + for (const name of ["auth.lock", "auth.json.bak"]) { + yield* fileSystem.symlink(path.join(sharedHome, name), path.join(shadowHome, name)); + } + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + + yield* materializeCodexShadowHome(layout); + + for (const name of authLikeNames) { + const shadowEntry = yield* fileSystem + .stat(path.join(shadowHome, name)) + .pipe(Effect.result); + expect({ name, tag: shadowEntry._tag }).toEqual({ name, tag: "Failure" }); + expect(yield* fileSystem.readFileString(path.join(sharedHome, name))).toBe( + `{"shared":"${name}"}\n`, + ); + } + expect(yield* fileSystem.readLink(path.join(shadowHome, "author.json"))).toBe( + path.join(sharedHome, "author.json"), + ); + expect(yield* fileSystem.readFileString(path.join(shadowHome, "auth.json"))).toBe( + '{"shadow":true}\n', + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "removes auth-like shadow symlinks that point outside the shared home, even dangling ones", + () => + 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 orphanLink = path.join(shadowHome, "auth-orphan.json"); + + yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); + yield* fileSystem.symlink( + path.join(shadowRoot, "elsewhere", "auth-orphan.json"), + orphanLink, + ); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + + yield* materializeCodexShadowHome(layout); + + const orphanLinkResult = yield* fileSystem.readLink(orphanLink).pipe(Effect.result); + expect(orphanLinkResult._tag).toBe("Failure"); + }), + ); + it.effect.skipIf(!symlinksSupported)( "replaces a Codex-created local sqlite maintenance lock with the shared lock", () => diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index f6d2ba9d44cf5..1c125be18da7f 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -31,6 +31,9 @@ const KNOWN_SHARED_DIRECTORIES = [ // auth.lock guards the shadow home's private auth.json. const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "auth.lock", "models_cache.json"]); +// Credential backups and alternate auth files (auth.json.bak, auth-work.json) stay per-home, +// but unrelated names such as author.json remain shared. +const AUTH_LIKE_ENTRY_NAME = /^auth(?:$|[._-])/i; // 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. @@ -329,21 +332,54 @@ const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsP readonly effectiveHomePath: string; }): Effect.fn.Return { const path = yield* Path.Path; - const entryName = "auth.json"; - const authPath = path.join(input.effectiveHomePath, entryName); - const state = yield* readLinkState({ - ...input, - entryName, - linkPath: authPath, - }); - if (state._tag === "Symlink") { - return yield* new CodexShadowHomePrivateEntrySymlinkError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - entryName, - path: authPath, - }); - } + const shadowEntryNames = yield* input.fileSystem.readDirectory(input.effectiveHomePath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "readDirectory", + path: input.effectiveHomePath, + cause, + }), + }), + ); + yield* Effect.forEach( + shadowEntryNames.filter((entryName) => AUTH_LIKE_ENTRY_NAME.test(entryName)), + Effect.fnUntraced(function* (entryName) { + const authPath = path.join(input.effectiveHomePath, entryName); + const state = yield* readLinkState({ + ...input, + entryName, + linkPath: authPath, + }); + if (state._tag !== "Symlink") return; + // Older versions linked auth-like backups into shadow homes. A link holds no data, + // so drop it; only a linked auth.json means the shadow home has no credentials of its own. + if (entryName !== "auth.json") { + return yield* input.fileSystem.remove(authPath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "remove", + path: authPath, + entryName, + cause, + }), + }), + ); + } + return yield* new CodexShadowHomePrivateEntrySymlinkError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + entryName, + path: authPath, + }); + }), + { discard: true }, + ); }, ); @@ -402,7 +438,11 @@ 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) && + !AUTH_LIKE_ENTRY_NAME.test(entryName) + ) { entries.add(entryName); } }