Skip to content
Closed
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
12 changes: 6 additions & 6 deletions apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,8 @@
* - `adapter` — the Codex session/turn/approval runtime;
* - `textGeneration` — commit/PR/branch/title generation via `codex exec`.
*
* Each call to `create()` captures the `codexConfig` argument in closures
* owned by the returned instance. Two instances created with different
* `homePath`s (e.g. `codex_personal` + `codex_work`) therefore run with
* fully independent Codex app-server processes and `CODEX_HOME`
* environments — no shared mutable state.
* Each instance owns its app-server process and `CODEX_HOME`. Auth overlays
* share session and SQLite state while keeping account credentials private.
*
* Resource lifecycle: `create()` runs in a scope handed in by the registry.
* Closing that scope releases the adapter's child processes, the managed
Expand Down Expand Up @@ -112,8 +109,11 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
const serverSettings = yield* ServerSettingsService;
const eventLoggers = yield* ProviderEventLoggers;
const modelManifest = yield* ModelManifest.ModelManifest;
const processEnv = mergeProviderInstanceEnvironment(environment);
let processEnv = mergeProviderInstanceEnvironment(environment);
const homeLayout = yield* resolveCodexHomeLayout(config);
if (homeLayout.mode === "authOverlay") {
processEnv = { ...processEnv, CODEX_SQLITE_HOME: homeLayout.sharedHomePath };
}
const continuationIdentity = codexContinuationIdentity(homeLayout);
const stampIdentity = withInstanceIdentity({
instanceId,
Expand Down
122 changes: 122 additions & 0 deletions apps/server/src/provider/Drivers/CodexHomeLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,26 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');
yield* writeTextFile(path.join(sharedHome, "models_cache.json"), '{"models":["shared"]}\n');
yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n');
const sqliteEntryNames = ["state_5.sqlite", "state_5.sqlite-shm", "state_5.sqlite-wal"];
yield* Effect.forEach(
sqliteEntryNames,
(entryName) => writeTextFile(path.join(sharedHome, entryName), "sqlite"),
{ discard: true },
);
yield* fileSystem.makeDirectory(shadowHome, { recursive: true });
yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n');
yield* fileSystem.symlink(
path.join(sharedHome, "models_cache.json"),
path.join(shadowHome, "models_cache.json"),
);
yield* Effect.forEach(
sqliteEntryNames,
(entryName) =>
fileSystem.symlink(path.join(sharedHome, entryName), path.join(shadowHome, entryName)),
{ discard: true },
);
const shadowLocalSqlite = path.join(shadowHome, "shadow-local.sqlite");
yield* writeTextFile(shadowLocalSqlite, "shadow-local");

const layout = yield* resolveCodexHomeLayout(
decodeCodexSettings({
Expand All @@ -124,13 +138,19 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
.readLink(path.join(shadowHome, "auth.json"))
.pipe(Effect.result);
const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json"));
const sqliteLinksExist = yield* Effect.forEach(sqliteEntryNames, (entryName) =>
fileSystem.exists(path.join(shadowHome, entryName)),
);
const shadowLocalSqliteContents = yield* fileSystem.readFileString(shadowLocalSqlite);

expect(sessionsTarget).toBe(path.join(sharedHome, "sessions"));
expect(configTarget).toBe(path.join(sharedHome, "config.toml"));
expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks"));
expect(modelsCacheExists).toBe(false);
expect(authLinkResult._tag).toBe("Failure");
expect(authContents).toContain("shadow");
expect(sqliteLinksExist).toEqual([false, false, false]);
expect(shadowLocalSqliteContents).toBe("shadow-local");
}),
);

Expand Down Expand Up @@ -210,6 +230,94 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
}),
);

it.effect("keeps both homes' SQLite files intact across repeated materialization", () =>
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 layout = yield* resolveCodexHomeLayout(
decodeCodexSettings({
homePath: sharedHome,
shadowHomePath: shadowHome,
}),
);

yield* materializeCodexShadowHome(layout);

// Codex adds a runtime database to both homes after the first pass.
const sqliteSuffixes = ["", "-wal", "-shm"];
for (const suffix of sqliteSuffixes) {
yield* writeTextFile(path.join(sharedHome, `queue_1.sqlite${suffix}`), `shared${suffix}`);
yield* writeTextFile(path.join(shadowHome, `queue_1.sqlite${suffix}`), `shadow${suffix}`);
yield* writeTextFile(
path.join(sharedHome, `future_runtime.sqlite${suffix}`),
`future-shared${suffix}`,
);
}
yield* writeTextFile(path.join(shadowHome, "future_runtime.sqlite"), "future-shadow");

yield* materializeCodexShadowHome(layout);
yield* materializeCodexShadowHome(layout);

for (const suffix of sqliteSuffixes) {
const entryName = `queue_1.sqlite${suffix}`;
const shadowPath = path.join(shadowHome, entryName);
expect(yield* fileSystem.readFileString(path.join(sharedHome, entryName))).toBe(
`shared${suffix}`,
);
expect(yield* fileSystem.readFileString(shadowPath)).toBe(`shadow${suffix}`);
expect((yield* fileSystem.readLink(shadowPath).pipe(Effect.result))._tag).toBe("Failure");
}

expect(
yield* fileSystem.readFileString(path.join(shadowHome, "future_runtime.sqlite")),
).toBe("future-shadow");
expect(yield* fileSystem.exists(path.join(shadowHome, "future_runtime.sqlite-wal"))).toBe(
false,
);
expect(yield* fileSystem.exists(path.join(shadowHome, "future_runtime.sqlite-shm"))).toBe(
false,
);
}),
);

it.effect("clears stale SQLite links when the shadow home has no database of its own", () =>
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 staleWalLink = path.join(shadowHome, "state.sqlite-wal");

yield* writeTextFile(path.join(sharedHome, "state.sqlite"), "shared");
yield* fileSystem.makeDirectory(shadowHome, { recursive: true });
// An earlier launch linked sidecars only, and the shared WAL has since gone.
yield* fileSystem.symlink(path.join(sharedHome, "state.sqlite-wal"), staleWalLink);
yield* writeTextFile(path.join(shadowHome, "state.sqlite-shm"), "shadow-shm");

const layout = yield* resolveCodexHomeLayout(
decodeCodexSettings({
homePath: sharedHome,
shadowHomePath: shadowHome,
}),
);

yield* materializeCodexShadowHome(layout);

expect(yield* fileSystem.exists(path.join(shadowHome, "state.sqlite"))).toBe(false);
expect((yield* fileSystem.readLink(staleWalLink).pipe(Effect.result))._tag).toBe("Failure");
expect(yield* fileSystem.readFileString(path.join(shadowHome, "state.sqlite-shm"))).toBe(
"shadow-shm",
);
expect(yield* fileSystem.readFileString(path.join(sharedHome, "state.sqlite"))).toBe(
"shared",
);
}),
);

it.effect("rejects shadow homes that point at the shared home", () =>
Effect.gen(function* () {
const sharedHome = yield* makeTempDir("t3code-codex-shared-");
Expand All @@ -235,6 +343,7 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {

it.effect("rejects shared entries that already exist in the shadow home as real files", () =>
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-");
Expand Down Expand Up @@ -262,6 +371,19 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
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.`,
);
expect(yield* fileSystem.readFileString(path.join(sharedHome, "config.toml"))).toBe(
'model = "gpt-5-codex"\n',
);
expect(yield* fileSystem.readFileString(path.join(shadowHome, "config.toml"))).toBe(
'model = "local"\n',
);

yield* fileSystem.remove(path.join(shadowHome, "config.toml"));
yield* materializeCodexShadowHome(layout);

expect(yield* fileSystem.readLink(path.join(shadowHome, "config.toml"))).toBe(
path.join(sharedHome, "config.toml"),
);
}),
);

Expand Down
24 changes: 22 additions & 2 deletions apps/server/src/provider/Drivers/CodexHomeLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const KNOWN_SHARED_DIRECTORIES = [
const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]);
const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]);
const REPLACEABLE_SHARED_RUNTIME_DIRECTORIES = new Set(["mcp-oauth-locks"]);
const SQLITE_ENTRY_NAME_PATTERN = /\.sqlite(?:-(?:journal|shm|wal))?$/;

function resolveHomePath(path: Path.Path, value: string | undefined): string {
const expanded =
Expand Down Expand Up @@ -370,15 +371,34 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome"
}),
}),
);
const shadowEntryNames = yield* fileSystem.readDirectory(effectiveHomePath).pipe(
Effect.catchTags({
PlatformError: (cause) =>
new CodexShadowHomeFileSystemError({
sharedHomePath: layout.sharedHomePath,
effectiveHomePath,
operation: "readDirectory",
path: effectiveHomePath,
cause,
}),
}),
);
const entries = new Set<string>(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) &&
!SQLITE_ENTRY_NAME_PATTERN.test(entryName)
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
) {
Comment thread
cursor[bot] marked this conversation as resolved.
entries.add(entryName);
}
}

yield* Effect.forEach(
PRIVATE_ENTRY_NAMES,
[
...PRIVATE_ENTRY_NAMES,
...shadowEntryNames.filter((entryName) => SQLITE_ENTRY_NAME_PATTERN.test(entryName)),
],
(entryName) =>
entryName === "auth.json"
? Effect.void
Expand Down
Loading