| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as PlatformError from "effect/PlatformError"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | |
| 9 | import { CodexSettings } from "@t3tools/contracts"; |
| 10 | import { |
| 11 | CodexShadowHomeEntryConflictError, |
| 12 | CodexShadowHomePathConflictError, |
| 13 | materializeCodexShadowHome, |
| 14 | resolveCodexHomeLayout, |
| 15 | } from "./CodexHomeLayout.ts"; |
| 16 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 17 | const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings); |
| 18 | |
| 19 | const decodeCodexSettings = (input: { |
| 20 | readonly enabled?: boolean; |
| 21 | readonly homePath?: string; |
| 22 | readonly shadowHomePath?: string; |
| 23 | readonly customModels?: readonly string[]; |
| 24 | readonly binaryPath?: string; |
| 25 | }): CodexSettings => decodeCodexSettingsValue(input); |
| 26 | |
| 27 | const makeTempDir = Effect.fn("CodexHomeLayout.test.makeTempDir")(function* (prefix: string) { |
| 28 | const fileSystem = yield* FileSystem.FileSystem; |
| 29 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); |
| 30 | }); |
| 31 | |
| 32 | const writeTextFile = Effect.fn("CodexHomeLayout.test.writeTextFile")(function* ( |
| 33 | filePath: string, |
| 34 | contents: string, |
| 35 | ) { |
| 36 | const fileSystem = yield* FileSystem.FileSystem; |
| 37 | const path = yield* Path.Path; |
| 38 | yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); |
| 39 | yield* fileSystem.writeFileString(filePath, contents); |
| 40 | }); |
| 41 | |
| 42 | it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { |
| 43 | describe("resolveCodexHomeLayout", () => { |
| 44 | it.effect("uses direct CODEX_HOME when no shadow home is configured", () => |
| 45 | Effect.gen(function* () { |
| 46 | const homePath = yield* makeTempDir("t3code-codex-home-"); |
| 47 | |
| 48 | const layout = yield* resolveCodexHomeLayout( |
| 49 | decodeCodexSettings({ |
| 50 | homePath, |
| 51 | }), |
| 52 | ); |
| 53 | |
| 54 | expect(layout).toMatchObject({ |
| 55 | mode: "direct", |
| 56 | sharedHomePath: homePath, |
| 57 | effectiveHomePath: homePath, |
| 58 | continuationKey: `codex:home:${homePath}`, |
| 59 | }); |
| 60 | }), |
| 61 | ); |
| 62 | |
| 63 | it.effect("uses the shared home for continuation and the shadow home for runtime", () => |
| 64 | Effect.gen(function* () { |
| 65 | const path = yield* Path.Path; |
| 66 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); |
| 67 | const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); |
| 68 | const shadowHome = path.join(shadowRoot, "shadow"); |
| 69 | |
| 70 | const layout = yield* resolveCodexHomeLayout( |
| 71 | decodeCodexSettings({ |
| 72 | homePath: sharedHome, |
| 73 | shadowHomePath: shadowHome, |
| 74 | }), |
| 75 | ); |
| 76 | |
| 77 | expect(layout).toMatchObject({ |
| 78 | mode: "authOverlay", |
| 79 | sharedHomePath: sharedHome, |
| 80 | effectiveHomePath: shadowHome, |
| 81 | continuationKey: `codex:home:${sharedHome}`, |
| 82 | }); |
| 83 | }), |
| 84 | ); |
| 85 | }); |
| 86 | |
| 87 | describe("materializeCodexShadowHome", () => { |
| 88 | it.effect.skipIf(!symlinksSupported)( |
| 89 | "materializes a shadow home with shared state links and private auth", |
| 90 | () => |
| 91 | Effect.gen(function* () { |
| 92 | const fileSystem = yield* FileSystem.FileSystem; |
| 93 | const path = yield* Path.Path; |
| 94 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); |
| 95 | const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); |
| 96 | const shadowHome = path.join(shadowRoot, "shadow"); |
| 97 | |
| 98 | yield* fileSystem.makeDirectory(path.join(sharedHome, "sessions")); |
| 99 | yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); |
| 100 | yield* writeTextFile( |
| 101 | path.join(sharedHome, "models_cache.json"), |
| 102 | '{"models":["shared"]}\n', |
| 103 | ); |
| 104 | yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n'); |
| 105 | yield* fileSystem.makeDirectory(shadowHome, { recursive: true }); |
| 106 | yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); |
| 107 | yield* fileSystem.symlink( |
| 108 | path.join(sharedHome, "models_cache.json"), |
| 109 | path.join(shadowHome, "models_cache.json"), |
| 110 | ); |
| 111 | |
| 112 | const layout = yield* resolveCodexHomeLayout( |
| 113 | decodeCodexSettings({ |
| 114 | homePath: sharedHome, |
| 115 | shadowHomePath: shadowHome, |
| 116 | }), |
| 117 | ); |
| 118 | |
| 119 | yield* materializeCodexShadowHome(layout); |
| 120 | |
| 121 | const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions")); |
| 122 | const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); |
| 123 | const mcpOauthLocksTarget = yield* fileSystem.readLink( |
| 124 | path.join(shadowHome, "mcp-oauth-locks"), |
| 125 | ); |
| 126 | const modelsCacheExists = yield* fileSystem.exists( |
| 127 | path.join(shadowHome, "models_cache.json"), |
| 128 | ); |
| 129 | const authLinkResult = yield* fileSystem |
| 130 | .readLink(path.join(shadowHome, "auth.json")) |
| 131 | .pipe(Effect.result); |
| 132 | const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json")); |
| 133 | |
| 134 | expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); |
| 135 | expect(configTarget).toBe(path.join(sharedHome, "config.toml")); |
| 136 | expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks")); |
| 137 | expect(modelsCacheExists).toBe(false); |
| 138 | expect(authLinkResult._tag).toBe("Failure"); |
| 139 | expect(authContents).toContain("shadow"); |
| 140 | }), |
| 141 | ); |
| 142 | |
| 143 | it.effect.skipIf(!symlinksSupported)( |
| 144 | "replaces a Codex-created local sqlite maintenance lock with the shared lock", |
| 145 | () => |
| 146 | Effect.gen(function* () { |
| 147 | const fileSystem = yield* FileSystem.FileSystem; |
| 148 | const path = yield* Path.Path; |
| 149 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); |
| 150 | const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); |
| 151 | const shadowHome = path.join(shadowRoot, "shadow"); |
| 152 | const sharedLock = path.join(sharedHome, ".sqlite-maintenance.lock"); |
| 153 | const shadowLock = path.join(shadowHome, ".sqlite-maintenance.lock"); |
| 154 | |
| 155 | yield* writeTextFile(sharedLock, ""); |
| 156 | yield* writeTextFile(shadowLock, ""); |
| 157 | |
| 158 | const layout = yield* resolveCodexHomeLayout( |
| 159 | decodeCodexSettings({ |
| 160 | homePath: sharedHome, |
| 161 | shadowHomePath: shadowHome, |
| 162 | }), |
| 163 | ); |
| 164 | |
| 165 | yield* materializeCodexShadowHome(layout); |
| 166 | |
| 167 | expect(yield* fileSystem.readLink(shadowLock)).toBe(sharedLock); |
| 168 | }), |
| 169 | ); |
| 170 | |
| 171 | it.effect.skipIf(!symlinksSupported)( |
| 172 | "replaces Codex-created local MCP OAuth locks with the shared lock directory", |
| 173 | () => |
| 174 | Effect.gen(function* () { |
| 175 | const fileSystem = yield* FileSystem.FileSystem; |
| 176 | const path = yield* Path.Path; |
| 177 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); |
| 178 | const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); |
| 179 | const shadowHome = path.join(shadowRoot, "shadow"); |
| 180 | const sharedLocks = path.join(sharedHome, "mcp-oauth-locks"); |
| 181 | const shadowLocks = path.join(shadowHome, "mcp-oauth-locks"); |
| 182 | |
| 183 | yield* writeTextFile(path.join(sharedLocks, "file-store.lock"), ""); |
| 184 | yield* writeTextFile(path.join(shadowLocks, "file-store.lock"), ""); |
| 185 | |
| 186 | const layout = yield* resolveCodexHomeLayout( |
| 187 | decodeCodexSettings({ |
| 188 | homePath: sharedHome, |
| 189 | shadowHomePath: shadowHome, |
| 190 | }), |
| 191 | ); |
| 192 | |
| 193 | yield* materializeCodexShadowHome(layout); |
| 194 | |
| 195 | const locksTarget = yield* fileSystem.readLink(shadowLocks); |
| 196 | const sharedLockExists = yield* fileSystem.exists( |
| 197 | path.join(sharedLocks, "file-store.lock"), |
| 198 | ); |
| 199 | |
| 200 | expect(locksTarget).toBe(sharedLocks); |
| 201 | expect(sharedLockExists).toBe(true); |
| 202 | }), |
| 203 | ); |
| 204 | |
| 205 | it.effect.skipIf(!symlinksSupported)( |
| 206 | "accepts Codex-created shadow-local runtime directories", |
| 207 | () => |
| 208 | Effect.gen(function* () { |
| 209 | const fileSystem = yield* FileSystem.FileSystem; |
| 210 | const path = yield* Path.Path; |
| 211 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); |
| 212 | const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); |
| 213 | const shadowHome = path.join(shadowRoot, "shadow"); |
| 214 | |
| 215 | yield* fileSystem.makeDirectory(path.join(sharedHome, "log")); |
| 216 | yield* fileSystem.makeDirectory(path.join(sharedHome, "memories")); |
| 217 | yield* fileSystem.makeDirectory(path.join(sharedHome, "tmp")); |
| 218 | yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); |
| 219 | yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); |
| 220 | yield* fileSystem.makeDirectory(path.join(shadowHome, "log"), { recursive: true }); |
| 221 | yield* fileSystem.makeDirectory(path.join(shadowHome, "memories"), { recursive: true }); |
| 222 | yield* fileSystem.makeDirectory(path.join(shadowHome, "tmp"), { recursive: true }); |
| 223 | |
| 224 | const layout = yield* resolveCodexHomeLayout( |
| 225 | decodeCodexSettings({ |
| 226 | homePath: sharedHome, |
| 227 | shadowHomePath: shadowHome, |
| 228 | }), |
| 229 | ); |
| 230 | |
| 231 | yield* materializeCodexShadowHome(layout); |
| 232 | |
| 233 | const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); |
| 234 | const logLinkResult = yield* fileSystem |
| 235 | .readLink(path.join(shadowHome, "log")) |
| 236 | .pipe(Effect.result); |
| 237 | const memoriesLinkResult = yield* fileSystem |
| 238 | .readLink(path.join(shadowHome, "memories")) |
| 239 | .pipe(Effect.result); |
| 240 | const tmpLinkResult = yield* fileSystem |
| 241 | .readLink(path.join(shadowHome, "tmp")) |
| 242 | .pipe(Effect.result); |
| 243 | |
| 244 | expect(configTarget).toBe(path.join(sharedHome, "config.toml")); |
| 245 | expect(logLinkResult._tag).toBe("Failure"); |
| 246 | expect(memoriesLinkResult._tag).toBe("Failure"); |
| 247 | expect(tmpLinkResult._tag).toBe("Failure"); |
| 248 | }), |
| 249 | ); |
| 250 | |
| 251 | it.effect("rejects shadow homes that point at the shared home", () => |
| 252 | Effect.gen(function* () { |
| 253 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); |
| 254 | const layout = yield* resolveCodexHomeLayout( |
| 255 | decodeCodexSettings({ |
| 256 | homePath: sharedHome, |
| 257 | shadowHomePath: sharedHome, |
| 258 | }), |
| 259 | ); |
| 260 | |
| 261 | const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); |
| 262 | |
| 263 | expect(error).toBeInstanceOf(CodexShadowHomePathConflictError); |
| 264 | expect(error).toMatchObject({ |
| 265 | sharedHomePath: sharedHome, |
| 266 | effectiveHomePath: sharedHome, |
| 267 | }); |
| 268 | expect(error.message).toBe( |
| 269 | `Codex shadow home path '${sharedHome}' must be different from the shared home path '${sharedHome}'.`, |
| 270 | ); |
| 271 | }), |
| 272 | ); |
| 273 | |
| 274 | it.effect.skipIf(!symlinksSupported)( |
| 275 | "rejects shared entries that already exist in the shadow home as real files", |
| 276 | () => |
| 277 | Effect.gen(function* () { |
| 278 | const path = yield* Path.Path; |
| 279 | const sharedHome = yield* makeTempDir("t3code-codex-shared-"); |
| 280 | const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); |
| 281 | const shadowHome = path.join(shadowRoot, "shadow"); |
| 282 | yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); |
| 283 | yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n'); |
| 284 | |
| 285 | const layout = yield* resolveCodexHomeLayout( |
| 286 | decodeCodexSettings({ |
| 287 | homePath: sharedHome, |
| 288 | shadowHomePath: shadowHome, |
| 289 | }), |
| 290 | ); |
| 291 | |
| 292 | const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); |
| 293 | |
| 294 | expect(error).toBeInstanceOf(CodexShadowHomeEntryConflictError); |
| 295 | expect(error).toMatchObject({ |
| 296 | sharedHomePath: sharedHome, |
| 297 | effectiveHomePath: shadowHome, |
| 298 | entryName: "config.toml", |
| 299 | linkPath: path.join(shadowHome, "config.toml"), |
| 300 | targetPath: path.join(sharedHome, "config.toml"), |
| 301 | }); |
| 302 | expect(error.message).toBe( |
| 303 | `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`, |
| 304 | ); |
| 305 | }), |
| 306 | ); |
| 307 | |
| 308 | it.effect("preserves filesystem operation, paths, and cause", () => |
| 309 | Effect.gen(function* () { |
| 310 | const path = yield* Path.Path; |
| 311 | const sharedRoot = yield* makeTempDir("t3code-codex-shared-root-"); |
| 312 | const sharedHome = path.join(sharedRoot, "shared-home"); |
| 313 | const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); |
| 314 | const shadowHome = path.join(shadowRoot, "shadow"); |
| 315 | yield* writeTextFile(sharedHome, "not a directory\n"); |
| 316 | |
| 317 | const layout = yield* resolveCodexHomeLayout( |
| 318 | decodeCodexSettings({ |
| 319 | homePath: sharedHome, |
| 320 | shadowHomePath: shadowHome, |
| 321 | }), |
| 322 | ); |
| 323 | |
| 324 | const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); |
| 325 | |
| 326 | expect(error._tag).toBe("CodexShadowHomeFileSystemError"); |
| 327 | if (error._tag !== "CodexShadowHomeFileSystemError") { |
| 328 | return expect.fail("Expected CodexShadowHomeFileSystemError"); |
| 329 | } |
| 330 | expect(error).toMatchObject({ |
| 331 | operation: "makeDirectory", |
| 332 | sharedHomePath: sharedHome, |
| 333 | effectiveHomePath: shadowHome, |
| 334 | }); |
| 335 | expect(error.path.startsWith(sharedHome)).toBe(true); |
| 336 | expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); |
| 337 | expect(error.message).toBe( |
| 338 | `Codex shadow home filesystem operation 'makeDirectory' failed for '${error.path}'.`, |
| 339 | ); |
| 340 | }), |
| 341 | ); |
| 342 | }); |
| 343 | }); |