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
6 changes: 5 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import {
import {
materializeCodexShadowHome,
resolveCodexHomeLayout,
withCodexSqliteHome,
} from "../../provider/Drivers/CodexHomeLayout.ts";
import {
boundProviderEventForLogging,
Expand Down Expand Up @@ -1600,7 +1601,10 @@ export const createCodexAdapterV2 = (
return yield* makeCodexAdapterV2({
instanceId,
settings,
environment: yield* mergeProviderInstanceEnvironment(environment, hostEnvironment),
environment: withCodexSqliteHome(
yield* mergeProviderInstanceEnvironment(environment, hostEnvironment),
homeLayout,
),
clientFactory,
crypto,
fileSystem,
Expand Down
15 changes: 9 additions & 6 deletions apps/server/src/provider/CodexManagedRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as Schema from "effect/Schema";
import * as ServerConfig from "../config.ts";
import * as CodexInstallation from "./CodexInstallation.ts";
import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts";
import { materializeCodexShadowHome } from "./Drivers/CodexHomeLayout.ts";
import { materializeCodexShadowHome, withCodexSqliteHome } from "./Drivers/CodexHomeLayout.ts";

export interface CodexEffectiveRuntime {
readonly config: CodexSettings;
Expand Down Expand Up @@ -89,11 +89,14 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func
),
);
// Ambient CLI overrides cannot redirect a T3-owned token to a different provider.
const environment: NodeJS.ProcessEnv = {
...options.environment,
ACCESS_TOKEN: credentials.accessToken,
CODEX_HOME: homePath,
};
const environment = withCodexSqliteHome(
{
...options.environment,
ACCESS_TOKEN: credentials.accessToken,
CODEX_HOME: homePath,
},
homeLayout,
);
delete environment.T3CODE_CODEX_LAUNCH_ARGS;
delete environment.OPENAI_API_KEY;
delete environment.OPENAI_BASE_URL;
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
codexContinuationIdentity,
materializeCodexShadowHome,
resolveCodexHomeLayout,
withCodexSqliteHome,
} from "./CodexHomeLayout.ts";
import { makeManagedCodexProvider } from "./CodexManagedProvider.ts";
import { codexUsageReader } from "./codexUsage.ts";
Expand Down Expand Up @@ -125,8 +126,11 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
const modelCatalog = yield* ModelCatalog.ModelCatalog;
const currentCatalog = modelCatalog.current(DRIVER_KIND);
const processEnv = yield* mergeProviderInstanceEnvironment(environment);
const homeLayout = yield* resolveCodexHomeLayout(config);
const processEnv = withCodexSqliteHome(
yield* mergeProviderInstanceEnvironment(environment),
homeLayout,
);
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 @@ -12,6 +12,7 @@ import {
CodexShadowHomePathConflictError,
materializeCodexShadowHome,
resolveCodexHomeLayout,
withCodexSqliteHome,
} from "./CodexHomeLayout.ts";
import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings);
Expand Down Expand Up @@ -84,6 +85,32 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
);
});

describe("withCodexSqliteHome", () => {
it.effect("points a shadow home's SQLite state at the shared home", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const sharedHome = yield* makeTempDir("t3code-codex-shared-");
const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
const overlay = yield* resolveCodexHomeLayout(
decodeCodexSettings({
homePath: sharedHome,
shadowHomePath: path.join(shadowRoot, "shadow"),
}),
);
const direct = yield* resolveCodexHomeLayout(decodeCodexSettings({ homePath: sharedHome }));

expect(withCodexSqliteHome({ PATH: "/bin" }, overlay)).toEqual({
PATH: "/bin",
CODEX_SQLITE_HOME: sharedHome,
});
expect(withCodexSqliteHome({ PATH: "/bin" }, direct)).toEqual({ PATH: "/bin" });
expect(withCodexSqliteHome({ CODEX_SQLITE_HOME: "/custom" }, overlay)).toEqual({
CODEX_SQLITE_HOME: "/custom",
});
}),
);
});

describe("materializeCodexShadowHome", () => {
it.effect.skipIf(!symlinksSupported)(
"keeps Finder metadata local across repeated shadow home setup",
Expand Down Expand Up @@ -207,6 +234,101 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
}),
);

it.effect.skipIf(!symlinksSupported)(
"leaves SQLite databases Codex created in the shadow home alone across repeated setup",
() =>
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');
for (const name of ["queue_1.sqlite", "state_5.sqlite", "state_5.sqlite-wal"]) {
yield* writeTextFile(path.join(sharedHome, name), `shared ${name}`);
}
yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite"), "shadow queue");
yield* writeTextFile(path.join(shadowHome, "state_5.sqlite-wal"), "shadow wal");

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

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

expect(yield* fileSystem.readFileString(path.join(shadowHome, "queue_1.sqlite"))).toBe(
"shadow queue",
);
expect(
yield* fileSystem.readFileString(path.join(shadowHome, "state_5.sqlite-wal")),
).toBe("shadow wal");
expect(yield* fileSystem.exists(path.join(shadowHome, "state_5.sqlite"))).toBe(false);
expect(yield* fileSystem.readLink(path.join(shadowHome, "config.toml"))).toBe(
path.join(sharedHome, "config.toml"),
);
}),
);

it.effect.skipIf(!symlinksSupported)("accepts SQLite files Codex creates between setups", () =>
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 }),
);
const names = ["queue_1.sqlite", "queue_1.sqlite-wal", "queue_1.sqlite-shm"];

yield* materializeCodexShadowHome(layout);
for (const name of names) {
yield* writeTextFile(path.join(sharedHome, name), `shared ${name}`);
yield* writeTextFile(path.join(shadowHome, name), `shadow ${name}`);
}
yield* materializeCodexShadowHome(layout);

for (const name of names) {
const shadowPath = path.join(shadowHome, name);
expect(yield* fileSystem.readFileString(shadowPath)).toBe(`shadow ${name}`);
const linkResult = yield* fileSystem.readLink(shadowPath).pipe(Effect.result);
expect(linkResult._tag).toBe("Failure");
}
}),
);

it.effect.skipIf(!symlinksSupported)(
"removes SQLite links an earlier setup left in the shadow home",
() =>
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* fileSystem.makeDirectory(shadowHome, { recursive: true });
for (const name of ["state_5.sqlite", "state_5.sqlite-wal"]) {
yield* writeTextFile(path.join(sharedHome, name), `shared ${name}`);
yield* fileSystem.symlink(path.join(sharedHome, name), path.join(shadowHome, name));
}

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

yield* materializeCodexShadowHome(layout);

expect(yield* fileSystem.exists(path.join(shadowHome, "state_5.sqlite"))).toBe(false);
expect(yield* fileSystem.exists(path.join(shadowHome, "state_5.sqlite-wal"))).toBe(false);
expect(yield* fileSystem.readFileString(path.join(sharedHome, "state_5.sqlite"))).toBe(
"shared state_5.sqlite",
);
}),
);

it.effect.skipIf(!symlinksSupported)(
"replaces Codex-created local MCP OAuth locks with the shared lock directory",
() =>
Expand Down
67 changes: 52 additions & 15 deletions apps/server/src/provider/Drivers/CodexHomeLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp", ".DS_Store"]
// Codex creates these runtime locks itself when it starts in a shadow home before the shared
// home has one. They guard shared state, so a local copy is replaced with the shared link.
const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set(["mcp-oauth-locks", ".sqlite-maintenance.lock"]);
// Shadow homes reach SQLite state through CODEX_SQLITE_HOME instead (see withCodexSqliteHome).
// A linked database splits from the -wal and -shm files SQLite writes beside the link, and a
// database Codex creates after setup would block the next setup as a real file.
const SQLITE_ENTRY_PATTERN = /\.sqlite(?:-wal|-shm|-journal)?$/;

function resolveHomePath(path: Path.Path, home: string, value: string | undefined): string {
const expanded =
Expand Down Expand Up @@ -66,6 +70,20 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi
};
});

/**
* Codex keeps its SQLite state (session index, goals, memories) under
* CODEX_SQLITE_HOME, falling back to CODEX_HOME. Every account of an
* auth-overlay instance opens those databases in the shared home directly, so
* they stay whole and shared. A value set on the instance wins.
*/
export function withCodexSqliteHome(
environment: NodeJS.ProcessEnv,
layout: CodexHomeLayout,
): NodeJS.ProcessEnv {
if (layout.mode !== "authOverlay" || environment.CODEX_SQLITE_HOME?.trim()) return environment;
return { ...environment, CODEX_SQLITE_HOME: layout.sharedHomePath };
Comment thread
Theophilus2003 marked this conversation as resolved.
Resolved
}

const CodexShadowHomeContext = {
sharedHomePath: Schema.String,
effectiveHomePath: Schema.String,
Expand Down Expand Up @@ -185,27 +203,27 @@ const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* (inpu
);
});

const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(function* (input: {
const removeShadowSymlink = Effect.fn("CodexHomeLayout.removeShadowSymlink")(function* (input: {
readonly fileSystem: FileSystem.FileSystem;
readonly sharedHomePath: string;
readonly effectiveHomePath: string;
readonly entryName: string;
}): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> {
const path = yield* Path.Path;
const privatePath = path.join(input.effectiveHomePath, input.entryName);
const shadowPath = path.join(input.effectiveHomePath, input.entryName);
const state = yield* readLinkState({
...input,
linkPath: privatePath,
linkPath: shadowPath,
});
if (state._tag === "Symlink") {
yield* input.fileSystem.remove(privatePath).pipe(
yield* input.fileSystem.remove(shadowPath).pipe(
Effect.catchTags({
PlatformError: (cause) =>
new CodexShadowHomeFileSystemError({
sharedHomePath: input.sharedHomePath,
effectiveHomePath: input.effectiveHomePath,
operation: "remove",
path: privatePath,
path: shadowPath,
entryName: input.entryName,
cause,
}),
Expand Down Expand Up @@ -373,22 +391,41 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome"
);
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_PATTERN.test(entryName)
) {
entries.add(entryName);
}
}

const shadowEntryNames = yield* fileSystem.readDirectory(effectiveHomePath).pipe(
Effect.catchTags({
PlatformError: (cause) =>
new CodexShadowHomeFileSystemError({
sharedHomePath: layout.sharedHomePath,
effectiveHomePath,
operation: "readDirectory",
path: effectiveHomePath,
cause,
}),
}),
);
// Real SQLite files in the shadow home are left alone: they may hold an account's data.
const unlinkedEntryNames = [
...[...PRIVATE_ENTRY_NAMES].filter((entryName) => entryName !== "auth.json"),
...shadowEntryNames.filter((entryName) => SQLITE_ENTRY_PATTERN.test(entryName)),
];
yield* Effect.forEach(
PRIVATE_ENTRY_NAMES,
unlinkedEntryNames,
(entryName) =>
entryName === "auth.json"
? Effect.void
: removePrivateSymlink({
fileSystem,
sharedHomePath: layout.sharedHomePath,
effectiveHomePath,
entryName,
}),
removeShadowSymlink({
fileSystem,
sharedHomePath: layout.sharedHomePath,
effectiveHomePath,
entryName,
}),
{ discard: true },
);

Expand Down
10 changes: 8 additions & 2 deletions apps/server/src/terminal/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ import * as SynchronizedRef from "effect/SynchronizedRef";

import * as ServerConfig from "../config.ts";
import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
import {
resolveCodexHomeLayout,
withCodexSqliteHome,
} from "../provider/Drivers/CodexHomeLayout.ts";
import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts";
import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts";
import * as ServerSettings from "../serverSettings.ts";
Expand Down Expand Up @@ -1355,7 +1358,10 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn(
Effect.provideService(Path.Path, input.path),
);
if (layout.effectiveHomePath)
resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath };
resolved = withCodexSqliteHome(
{ ...resolved, CODEX_HOME: layout.effectiveHomePath },
layout,
);
}
} else if (instance.driver === "claudeAgent") {
const config = decodeClaudeSettings(instance.config ?? {});
Expand Down
Loading