Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 83 additions & 5 deletions apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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.`,
);
}),
);
Expand Down
47 changes: 43 additions & 4 deletions apps/server/src/provider/Drivers/CodexHomeLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -80,7 +89,14 @@ export class CodexShadowHomeFileSystemError extends Schema.TaggedError<CodexShad
"CodexShadowHomeFileSystemError",
{
...CodexShadowHomeContext,
operation: Schema.Literals(["readLink", "makeDirectory", "readDirectory", "remove", "symlink"]),
operation: Schema.Literals([
"readLink",
"makeDirectory",
"readDirectory",
"readFile",
"remove",
"symlink",
]),
path: Schema.String,
targetPath: Schema.optional(Schema.String),
entryName: Schema.optional(Schema.String),
Expand Down Expand Up @@ -249,7 +265,30 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu
);

if (state._tag === "NotSymlink") {
if (!REPLACEABLE_SHARED_RUNTIME_ENTRIES.has(input.entryName)) {
if (input.entryName === "config.toml") {
const readConfig = (configPath: string) =>
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,
Expand Down
Loading