1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as PlatformError from "effect/PlatformError";
7import * as Schema from "effect/Schema";
8
9import { CodexSettings } from "@t3tools/contracts";
10import {
11 CodexShadowHomeEntryConflictError,
12 CodexShadowHomePathConflictError,
13 materializeCodexShadowHome,
14 resolveCodexHomeLayout,
15} from "./CodexHomeLayout.ts";
16import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
17const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings);
18
19const 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
27const makeTempDir = Effect.fn("CodexHomeLayout.test.makeTempDir")(function* (prefix: string) {
28 const fileSystem = yield* FileSystem.FileSystem;
29 return yield* fileSystem.makeTempDirectoryScoped({ prefix });
30});
31
32const 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
42it.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});