diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index a16df458e2043..3713705d1baeb 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -63,6 +63,7 @@ import * as Orchestrator from "../Orchestrator.ts"; import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; import { makeReplayServerConfig, withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; import * as CodexAdapterV2Testkit from "./CodexAdapterV2.testkit.ts"; @@ -782,6 +783,120 @@ describe("CodexAdapterV2 process spawning", () => { }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); + it.effect.each(["sqlite", "config"] as const)( + "keeps paths in the construction cause, not the public %s error detail", + (entry) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-safe-detail-" }); + const sharedHome = path.join(root, "shared"); + const shadowHome = path.join(root, "shadow"); + yield* fs.makeDirectory(sharedHome); + yield* fs.makeDirectory(shadowHome); + const name = entry === "sqlite" ? "state_5.sqlite" : "config.toml"; + const target = path.join(sharedHome, name); + const link = path.join(shadowHome, name); + yield* fs.writeFileString(target, "shared"); + if (entry === "sqlite") yield* fs.symlink(target, link); + else yield* fs.writeFileString(link, "private config"); + const error = yield* CodexAdapterV2.createCodexAdapterV2({ + instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, + displayName: undefined, + environment: [], + enabled: true, + config: { ...DEFAULT_CODEX_SETTINGS, homePath: sharedHome, shadowHomePath: shadowHome }, + }).pipe( + Effect.provide( + Layer.mergeAll( + CodexAdapterV2.layerAppServerClientFactory, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-safe-detail-config-" }), + ), + ), + Effect.provideService( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + Effect.flip, + ); + if (error._tag !== "ProviderAdapterDriverCreateError") return yield* error; + assert.notInclude(error.detail, sharedHome); + assert.notInclude(error.detail, shadowHome); + if (entry === "sqlite") assert.include(error.detail, "Stop all Codex processes"); + else assert.equal(error.detail, "Failed to prepare Codex shadow home."); + assert.deepInclude(error.cause, { linkPath: link }); + if (entry === "sqlite") assert.equal(yield* fs.readLink(link), target); + else assert.equal(yield* fs.readFileString(link), "private config"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + { skip: !symlinksSupported }, + ); + + it.effect.each([undefined, "/explicit/sqlite"])( + "keeps shadow app-server SQLite together, honoring an explicit home: %s", + (sqliteHome) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-sqlite-launch-" }); + const sharedHome = path.join(root, "shared"); + const shadowHome = path.join(root, "shadow"); + const environments: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) environments.push(command.options.env ?? {}); + return Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + ); + }); + const adapter = yield* CodexAdapterV2.createCodexAdapterV2({ + instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, + displayName: undefined, + environment: + sqliteHome === undefined + ? [] + : [{ name: "CODEX_SQLITE_HOME", value: sqliteHome, sensitive: false }], + enabled: true, + config: { ...DEFAULT_CODEX_SETTINGS, homePath: sharedHome, shadowHomePath: shadowHome }, + }).pipe( + Effect.provide( + Layer.mergeAll( + CodexAdapterV2.layerAppServerClientFactory, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-sqlite-" }), + ), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ); + yield* adapter + .openSession({ + threadId: ThreadId.make("thread-sqlite"), + providerSessionId: ProviderSessionId.make("provider-session-sqlite"), + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }) + .pipe(Effect.scoped, Effect.exit); + assert.lengthOf(environments, 1); + assert.equal(environments[0]?.CODEX_HOME, shadowHome); + assert.equal(environments[0]?.CODEX_SQLITE_HOME, sqliteHome ?? sharedHome); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + Effect.provideService(HostProcess.Environment, {}), + ), + { skip: !symlinksSupported }, + ); + it.effect("expands ~ in the configured binary path before spawning", () => Effect.gen(function* () { const spawnedCommands: Array = []; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 19d1e7b7ffe29..d05c43d1eb256 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -105,8 +105,11 @@ import { toMcpElicitationResponse, } from "../../provider/CodexMcpElicitation.ts"; import { + CODEX_SQLITE_RECOVERY_GUIDANCE, materializeCodexShadowHome, + mergeCodexEnvironment, resolveCodexHomeLayout, + withCodexSqliteHome, } from "../../provider/Drivers/CodexHomeLayout.ts"; import { boundProviderEventForLogging, @@ -114,7 +117,6 @@ import { } from "../../provider/EventNdjsonLogger.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "../../provider/codexLaunchArgs.ts"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { @@ -1584,7 +1586,10 @@ export const createCodexAdapterV2 = ( new ProviderAdapterDriverCreateError({ driver: CODEX_DRIVER_KIND, instanceId, - detail: "Failed to materialize the Codex shadow home.", + detail: + cause._tag === "CodexShadowHomeSqliteRecoveryRequiredError" + ? CODEX_SQLITE_RECOVERY_GUIDANCE + : "Failed to prepare Codex shadow home.", cause, }), ), @@ -1600,7 +1605,11 @@ export const createCodexAdapterV2 = ( return yield* makeCodexAdapterV2({ instanceId, settings, - environment: yield* mergeProviderInstanceEnvironment(environment, hostEnvironment), + environment: withCodexSqliteHome( + homeLayout, + yield* mergeCodexEnvironment(environment, hostEnvironment), + yield* HostProcess.Platform, + ), clientFactory, crypto, fileSystem, diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 69d27f1377b6b..64439c91d77b7 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -44,8 +44,8 @@ it.effect("managed home defaults to the global Codex home and honors configured ProviderInstanceId.make("codex-work"), decodeSettings({ homePath: "/custom/shared", shadowHomePath: "/custom/shadow" }), ); - assert.equal(configured.sharedHomePath, "/custom/shared"); - assert.equal(configured.effectiveHomePath, "/custom/shadow"); + assert.equal(configured.sharedHomePath, path.resolve("/custom/shared")); + assert.equal(configured.effectiveHomePath, path.resolve("/custom/shadow")); }).pipe(Effect.provide(NodeServices.layer)), ); it.effect.each( @@ -135,10 +135,12 @@ it.effect.each( if (account === "primary") { assert.equal(effective.config.homePath, sharedHome); assert.equal(runtime.homeLayout.mode, "direct"); + assert.isUndefined(effective.environment.CODEX_SQLITE_HOME); } else { assert.include(effective.config.homePath, instanceId); - assert.include(effective.config.homePath, "userdata/providers/codex"); + assert.include(effective.config.homePath, path.join("userdata", "providers", "codex")); assert.equal(runtime.homeLayout.mode, "authOverlay"); + assert.equal(effective.environment.CODEX_SQLITE_HOME, sharedHome); assert.equal( yield* fs.readLink(path.join(effective.config.homePath, "sessions")), path.join(sharedHome, "sessions"), @@ -173,6 +175,43 @@ it.effect.each( assert.strictEqual(ambient.CODEX_HOME, "/user/.codex"); }).pipe(Effect.scoped); assert.strictEqual(leases, 0); + if (account === "additional") { + yield* fs.writeFileString(path.join(sharedHome, "state_5.sqlite"), ""); + yield* fs.symlink( + path.join(sharedHome, "state_5.sqlite"), + path.join(runtime.homePath, "state_5.sqlite"), + ); + const error = yield* runtime.resolve.pipe(Effect.scoped, Effect.flip); + assert.equal(error._tag, "ProviderSetupError"); + assert.include(error.detail, "Codex SQLite recovery is required"); + assert.include(error.detail, "Stop all Codex processes"); + assert.notInclude(error.detail, sharedHome); + assert.notInclude(error.detail, runtime.homePath); + assert.notInclude(error.message, sharedHome); + assert.notInclude(error.message, runtime.homePath); + assert.deepInclude(error.cause, { + _tag: "CodexShadowHomeSqliteRecoveryRequiredError", + sharedHomePath: sharedHome, + linkPath: path.join(runtime.homePath, "state_5.sqlite"), + }); + assert.strictEqual(leases, 0); + yield* fs.remove(path.join(runtime.homePath, "state_5.sqlite")); + yield* fs.remove(path.join(runtime.homePath, "config.toml")); + yield* fs.writeFileString(path.join(runtime.homePath, "config.toml"), "private config"); + const conflict = yield* runtime.resolve.pipe(Effect.scoped, Effect.flip); + assert.equal(conflict._tag, "ProviderSetupError"); + assert.equal(conflict.detail, "Could not prepare the managed Codex runtime."); + assert.notInclude(conflict.detail, sharedHome); + assert.notInclude(conflict.detail, runtime.homePath); + assert.notInclude(conflict.message, sharedHome); + assert.notInclude(conflict.message, runtime.homePath); + assert.deepInclude(conflict.cause, { + _tag: "CodexShadowHomeEntryConflictError", + entryName: "config.toml", + linkPath: path.join(runtime.homePath, "config.toml"), + }); + assert.strictEqual(leases, 0); + } yield* runtime.auth.controller.logout(Effect.void); assert.equal( yield* fs.readFileString(path.join(sharedHome, "auth.json")), diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts index 826d777f1bd70..695233ea3a95f 100644 --- a/apps/server/src/provider/CodexManagedRuntime.ts +++ b/apps/server/src/provider/CodexManagedRuntime.ts @@ -7,7 +7,12 @@ 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 { + CODEX_SQLITE_RECOVERY_GUIDANCE, + materializeCodexShadowHome, + withCodexSqliteHome, +} from "./Drivers/CodexHomeLayout.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; export interface CodexEffectiveRuntime { readonly config: CodexSettings; @@ -74,7 +79,11 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func new ProviderSetupError({ instanceId: options.instanceId, operation: "runtime", - detail: cause.message, + detail: + cause._tag === "CodexShadowHomeSqliteRecoveryRequiredError" + ? CODEX_SQLITE_RECOVERY_GUIDANCE + : "Could not prepare the managed Codex runtime.", + cause, }), ), ); @@ -89,11 +98,15 @@ 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( + homeLayout, + { + ...options.environment, + ACCESS_TOKEN: credentials.accessToken, + CODEX_HOME: homePath, + }, + yield* HostProcess.Platform, + ); delete environment.T3CODE_CODEX_LAUNCH_ARGS; delete environment.OPENAI_API_KEY; delete environment.OPENAI_BASE_URL; diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index ab2caf9aafb61..651d93f53550f 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -214,7 +214,7 @@ it.layer(layerTest)("CodexDriver", (it) => { expect(restored.auth.email).toBe("account@example.test"); expect(restored.runtimePaths?.homePath).toBe(sharedHome); expect(restored.runtimePaths?.shadowHomePath).toContain( - `providers/codex/${instanceId}/shadow`, + NodePath.join("providers", "codex", instanceId, "shadow"), ); // The fixture app-server cannot start, so the workspace scan fails // instead of passing the machine snapshot off as the workspace's skills. @@ -250,8 +250,47 @@ it.layer(layerTest)("CodexDriver", (it) => { expect(launch.environment.ACCESS_TOKEN).toBe("dummy-owned-access"); expect(launch.environment.OPENAI_API_KEY).toBeUndefined(); expect(launch.environment.CODEX_HOME).toBe(launch.settings.homePath); + expect(launch.environment.CODEX_SQLITE_HOME).toBe(sharedHome); const before = yield* instance.auth!.subscribe("test-owner").pipe(Stream.runHead); expect(Option.getOrThrow(before).phase).toBe("idle"); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(NodePath.join(sharedHome, "state_5.sqlite"), ""); + yield* fs.symlink( + NodePath.join(sharedHome, "state_5.sqlite"), + NodePath.join(launch.settings.homePath, "state_5.sqlite"), + ); + const blocked = yield* instance.snapshot.refresh; + expect(blocked.message).toContain("Codex SQLite recovery is required"); + expect(blocked.message).toContain("Stop all Codex processes"); + expect(blocked.message).not.toContain(sharedHome); + expect(blocked.message).not.toContain(launch.settings.homePath); + // An explicitly configured shadow is checked during construction, not only + // later runtime probes. The registry must receive the same recovery guidance. + const explicit = yield* CodexDriver.create({ + instanceId, + displayName: "Explicit shadow account", + enabled: true, + environment: [], + config: { + ...CodexDriver.defaultConfig(), + setupMode: "managed", + homePath: sharedHome, + shadowHomePath: launch.settings.homePath, + }, + }).pipe(Effect.scoped, Effect.flip); + expect(explicit.detail).toContain("Codex SQLite recovery is required"); + expect(explicit.detail).toContain("Stop all Codex processes"); + expect(explicit.detail).not.toContain(sharedHome); + expect(explicit.detail).not.toContain(launch.settings.homePath); + expect(explicit.cause).toMatchObject({ + cause: { + _tag: "CodexShadowHomeSqliteRecoveryRequiredError", + linkPath: NodePath.join(launch.settings.homePath, "state_5.sqlite"), + }, + }); + expect(yield* fs.readLink(NodePath.join(launch.settings.homePath, "state_5.sqlite"))).toBe( + NodePath.join(sharedHome, "state_5.sqlite"), + ); yield* instance.auth!.logout(Effect.void); const after = Option.getOrThrow(yield* Fiber.join(disconnected)); expect(after.auth.status).toBe("unauthenticated"); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 2388213eed301..f99521c78affd 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -31,7 +31,6 @@ import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; import { applyCodexModelCatalog } from "../codexModelCatalog.ts"; import type { ProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { enrichProviderSnapshotWithVersionAdvisory, makeCachedProviderMaintenanceResolution, @@ -45,9 +44,13 @@ import { type ProviderSnapshotSettings, } from "@t3tools/provider-core/server/snapshotSettings"; import { + CODEX_SQLITE_RECOVERY_GUIDANCE, + CodexShadowHomeSqliteRecoveryRequiredError, codexContinuationIdentity, materializeCodexShadowHome, + mergeCodexEnvironment, resolveCodexHomeLayout, + withCodexSqliteHome, } from "./CodexHomeLayout.ts"; import { makeManagedCodexProvider } from "./CodexManagedProvider.ts"; import { codexUsageReader } from "./codexUsage.ts"; @@ -58,6 +61,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); +const isSqliteRecoveryRequired = Schema.is(CodexShadowHomeSqliteRecoveryRequiredError); function isCodexStandaloneCommandPath(commandPath: string): boolean { return normalizeCommandPath(commandPath).includes("/packages/standalone/"); } @@ -125,8 +129,12 @@ export const CodexDriver: ProviderDriver { ); }); + describe("withCodexSqliteHome", () => { + it.effect( + "defaults overlays to shared SQLite without changing explicit or direct-home settings", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const direct = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome }), + ); + const overlay = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: path.join(sharedHome, "shadow"), + }), + ); + const environment = { PATH: "/bin" }; + expect(withCodexSqliteHome(overlay, environment, "linux")).toEqual({ + PATH: "/bin", + CODEX_SQLITE_HOME: sharedHome, + }); + expect( + withCodexSqliteHome(overlay, { CODEX_SQLITE_HOME: "/explicit/sqlite" }, "linux"), + ).toEqual({ + CODEX_SQLITE_HOME: "/explicit/sqlite", + }); + expect(withCodexSqliteHome(direct, environment, "linux")).toBe(environment); + expect(environment).toEqual({ PATH: "/bin" }); + }), + ); + }); + + it.effect.each(["linux", "win32"] as const)( + "matches explicit SQLite environment names using $0 semantics", + (platform) => + Effect.gen(function* () { + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowHome = yield* makeTempDir("t3code-codex-shadow-"); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + const environment = { codex_sqlite_home: "/explicit/sqlite" }; + const resolved = withCodexSqliteHome(layout, environment, platform); + expect(resolved.CODEX_SQLITE_HOME).toBe( + platform === "win32" ? "/explicit/sqlite" : sharedHome, + ); + expect(environment).toEqual({ codex_sqlite_home: "/explicit/sqlite" }); + }), + ); + + it.effect.each([ + { inherited: "CODEX_SQLITE_HOME", provider: "codex_sqlite_home" }, + { inherited: "codex_sqlite_home", provider: "CODEX_SQLITE_HOME" }, + ])( + "merges Windows SQLite sources before defaulting ($inherited, $provider)", + ({ inherited, provider }) => + Effect.gen(function* () { + const environment = yield* mergeCodexEnvironment( + [{ name: provider, value: "/provider/sqlite", sensitive: false }], + { [inherited]: "/inherited/sqlite" }, + ); + expect(environment.CODEX_SQLITE_HOME).toBe("/provider/sqlite"); + expect(Object.keys(environment)).toEqual(["CODEX_SQLITE_HOME"]); + }).pipe(Effect.provideService(HostProcess.Platform, "win32")), + ); + + it.effect("preflights a fresh terminal shadow without creating it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir("t3code-codex-preflight-"); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: path.join(root, "shared"), + shadowHomePath: path.join(root, "shadow"), + }), + ); + expect(yield* inspectCodexShadowSqlite(layout)).toBeUndefined(); + expect(yield* fs.readDirectory(root)).toEqual([]); + }), + ); + describe("materializeCodexShadowHome", () => { + for (const { databaseName, sidecarName } of [ + { databaseName: "STATE_5.sqlite", sidecarName: "state_5.sqlite-wal" }, + { databaseName: "state_5.SQLITE", sidecarName: "state_5.sqlite-SHM" }, + { databaseName: "state_5.SQLITE", sidecarName: undefined }, + ]) { + it.effect.skipIf(!symlinksSupported)( + `blocks SQLite links regardless of filename casing: ${databaseName} / ${sidecarName ?? "database link"}`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-case-shared-"); + const shadowHome = yield* makeTempDir("t3code-codex-case-shadow-"); + const databasePath = path.join(shadowHome, databaseName); + const linkPath = path.join(shadowHome, sidecarName ?? databaseName); + const target = path.join(sharedHome, sidecarName ?? "state_5.sqlite"); + yield* fs.writeFileString(target, "shared bytes"); + if (sidecarName) yield* fs.writeFileString(databasePath, "shadow database"); + yield* fs.symlink(target, linkPath); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); + expect(error).toBeInstanceOf(CodexShadowHomeSqliteRecoveryRequiredError); + expect(yield* fs.readLink(linkPath)).toBe(target); + expect(yield* fs.readFileString(target)).toBe("shared bytes"); + if (sidecarName) expect(yield* fs.readFileString(databasePath)).toBe("shadow database"); + }), + ); + } + + it.effect.each([false, true])( + "does not link SQLite families and preserves real files (shadow databases: %s)", + (hasShadowFiles) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowHome = yield* makeTempDir("t3code-codex-shadow-"); + const names = ["state_5.sqlite", "logs_2.sqlite", "future_42.sqlite"].flatMap((name) => [ + name, + `${name}-wal`, + `${name}-shm`, + `${name}-journal`, + ]); + for (const name of names) { + yield* writeTextFile(path.join(sharedHome, name), `shared:${name}`); + if (hasShadowFiles) yield* writeTextFile(path.join(shadowHome, name), `shadow:${name}`); + } + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + yield* materializeCodexShadowHome(layout); + yield* materializeCodexShadowHome(layout); + for (const name of names) { + expect(yield* fs.readFileString(path.join(sharedHome, name))).toBe(`shared:${name}`); + expect(yield* fs.exists(path.join(shadowHome, name))).toBe(hasShadowFiles); + if (hasShadowFiles) { + expect(yield* fs.readFileString(path.join(shadowHome, name))).toBe(`shadow:${name}`); + expect((yield* fs.stat(path.join(shadowHome, name))).type).toBe("File"); + } + } + }), + { skip: !symlinksSupported }, + ); + + it.effect.skipIf(!symlinksSupported)( + "blocks a legacy database link without touching its regular WAL or SHM files", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowHome = yield* makeTempDir("t3code-codex-shadow-"); + yield* writeTextFile(path.join(sharedHome, "state_5.sqlite"), "shared database"); + yield* fs.symlink( + path.join(sharedHome, "state_5.sqlite"), + path.join(shadowHome, "state_5.sqlite"), + ); + for (const suffix of ["wal", "shm"]) { + yield* writeTextFile( + path.join(sharedHome, `state_5.sqlite-${suffix}`), + `shared:${suffix}`, + ); + yield* writeTextFile( + path.join(shadowHome, `state_5.sqlite-${suffix}`), + `uncheckpointed:${suffix}`, + ); + } + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + for (const attempt of [1, 2]) { + const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); + expect(error, `attempt ${attempt}`).toBeInstanceOf( + CodexShadowHomeSqliteRecoveryRequiredError, + ); + expect(error.message).toContain("Stop all Codex processes"); + } + expect(yield* fs.readLink(path.join(shadowHome, "state_5.sqlite"))).toBe( + path.join(sharedHome, "state_5.sqlite"), + ); + expect(yield* fs.readFileString(path.join(sharedHome, "state_5.sqlite"))).toBe( + "shared database", + ); + for (const suffix of ["wal", "shm"]) { + expect( + yield* fs.readFileString(path.join(sharedHome, `state_5.sqlite-${suffix}`)), + ).toBe(`shared:${suffix}`); + expect( + yield* fs.readFileString(path.join(shadowHome, `state_5.sqlite-${suffix}`)), + ).toBe(`uncheckpointed:${suffix}`); + } + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "blocks a linked sidecar beside a real shadow database without changing either", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowHome = yield* makeTempDir("t3code-codex-shadow-"); + const database = path.join(shadowHome, "state_5.sqlite"); + const sidecar = path.join(shadowHome, "state_5.sqlite-wal"); + const target = path.join(sharedHome, "state_5.sqlite-wal"); + yield* fs.writeFileString(database, "private database"); + yield* fs.writeFileString(target, "shared WAL"); + yield* fs.symlink(target, sidecar); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + expect(yield* materializeCodexShadowHome(layout).pipe(Effect.flip)).toBeInstanceOf( + CodexShadowHomeSqliteRecoveryRequiredError, + ); + expect(yield* fs.readFileString(database)).toBe("private database"); + expect(yield* fs.readLink(sidecar)).toBe(target); + expect(yield* fs.readFileString(target)).toBe("shared WAL"); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "requires manual recovery for dangling sidecar links and preserves real sidecars", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowHome = yield* makeTempDir("t3code-codex-shadow-"); + const dangling = "logs_1.sqlite-shm"; + yield* fs.symlink(path.join(sharedHome, dangling), path.join(shadowHome, dangling)); + yield* writeTextFile(path.join(shadowHome, "state_5.sqlite-wal"), "preserved WAL"); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + for (const attempt of [1, 2]) { + expect( + yield* materializeCodexShadowHome(layout).pipe(Effect.flip), + `attempt ${attempt}`, + ).toBeInstanceOf(CodexShadowHomeSqliteRecoveryRequiredError); + expect(yield* fs.readLink(path.join(shadowHome, dangling))).toBe( + path.join(sharedHome, dangling), + ); + } + // Only an operator's explicit unlink clears the legacy entry. + yield* fs.remove(path.join(shadowHome, dangling)); + yield* materializeCodexShadowHome(layout); + expect(yield* fs.readDirectory(shadowHome)).not.toContain(dangling); + expect(yield* fs.readFileString(path.join(shadowHome, "state_5.sqlite-wal"))).toBe( + "preserved WAL", + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "retains WAL commits from direct and overlay launches while rematerializing", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowHome = yield* makeTempDir("t3code-codex-shadow-"); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + const open = (home: string) => + Effect.acquireRelease( + Effect.sync(() => new NodeSqlite.DatabaseSync(path.join(home, "state_5.sqlite"))), + (db) => Effect.sync(() => db.close()), + ); + yield* Effect.gen(function* () { + const direct = yield* open(sharedHome); + direct.exec( + "PRAGMA journal_mode=WAL; CREATE TABLE writes (value TEXT); INSERT INTO writes VALUES ('direct');", + ); + yield* materializeCodexShadowHome(layout); + const environment = withCodexSqliteHome(layout, { CODEX_HOME: shadowHome }, "linux"); + const overlay = yield* open(environment.CODEX_SQLITE_HOME ?? shadowHome); + overlay.exec("INSERT INTO writes VALUES ('overlay');"); + yield* materializeCodexShadowHome(layout); + expect(yield* fs.exists(path.join(sharedHome, "state_5.sqlite-wal"))).toBe(true); + expect(yield* fs.exists(path.join(shadowHome, "state_5.sqlite-wal"))).toBe(false); + expect(direct.prepare("SELECT value FROM writes ORDER BY rowid").all()).toEqual([ + { value: "direct" }, + { value: "overlay" }, + ]); + }).pipe(Effect.scoped); + const reopened = yield* open(sharedHome); + expect(reopened.prepare("SELECT value FROM writes ORDER BY rowid").all()).toEqual([ + { value: "direct" }, + { value: "overlay" }, + ]); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "requires the legacy writer to stop and checkpoint before a canonical launch", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowHome = yield* makeTempDir("t3code-codex-shadow-"); + const sharedDatabase = path.join(sharedHome, "state_5.sqlite"); + const shadowDatabase = path.join(shadowHome, "state_5.sqlite"); + yield* fs.writeFileString(sharedDatabase, ""); + yield* fs.symlink(sharedDatabase, shadowDatabase); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + yield* Effect.gen(function* () { + const legacy = yield* Effect.acquireRelease( + Effect.sync(() => new NodeSqlite.DatabaseSync(shadowDatabase)), + (db) => Effect.sync(() => db.close()), + ); + // A writer can have no sidecar yet; even that link must not be switched. + expect(yield* materializeCodexShadowHome(layout).pipe(Effect.flip)).toBeInstanceOf( + CodexShadowHomeSqliteRecoveryRequiredError, + ); + legacy.exec( + "PRAGMA journal_mode=WAL; CREATE TABLE writes (value TEXT); INSERT INTO writes VALUES ('legacy');", + ); + expect(yield* materializeCodexShadowHome(layout).pipe(Effect.flip)).toBeInstanceOf( + CodexShadowHomeSqliteRecoveryRequiredError, + ); + expect(yield* fs.readLink(shadowDatabase)).toBe(sharedDatabase); + expect(legacy.prepare("SELECT value FROM writes").all()).toEqual([{ value: "legacy" }]); + }).pipe(Effect.scoped); + // Simulate the operator's recovery only after SQLite closed and checkpointed. + yield* fs.remove(shadowDatabase); + yield* materializeCodexShadowHome(layout); + const canonical = yield* Effect.acquireRelease( + Effect.sync(() => new NodeSqlite.DatabaseSync(sharedDatabase)), + (db) => Effect.sync(() => db.close()), + ); + canonical.exec("INSERT INTO writes VALUES ('canonical');"); + expect(canonical.prepare("SELECT value FROM writes ORDER BY rowid").all()).toEqual([ + { value: "legacy" }, + { value: "canonical" }, + ]); + }), + ); + it.effect.skipIf(!symlinksSupported)( "keeps Finder metadata local across repeated shadow home setup", () => @@ -314,6 +682,7 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { "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-"); @@ -341,6 +710,11 @@ 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.`, ); + 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"), + ); }), ); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index b0bb7df4e2d46..8dfc7d6d58212 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -1,4 +1,9 @@ -import { ProviderDriverKind, type CodexSettings } from "@t3tools/contracts"; +import { + ProviderDriverKind, + type CodexSettings, + type ProviderInstanceEnvironment, +} from "@t3tools/contracts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -31,6 +36,59 @@ const KNOWN_SHARED_DIRECTORIES = [ const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); // Finder maintains metadata separately for each directory, including shadow homes. const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp", ".DS_Store"]); +const SQLITE_ENTRY_NAME = /\.sqlite(?:-(?:wal|shm|journal))?$/iu; + +// Normalize each environment source before merging: overwriting a property does +// not move its insertion position, so final key order cannot encode precedence. +export function normalizeCodexSqliteEnvironment( + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +) { + if (platform !== "win32") return environment; + const normalized = { ...environment }; + for (const key of Object.keys(environment)) { + if (key.toUpperCase() !== "CODEX_SQLITE_HOME") continue; + delete normalized[key]; + normalized.CODEX_SQLITE_HOME = environment[key]; + } + return normalized; +} + +export const mergeCodexEnvironment = Effect.fn("mergeCodexEnvironment")(function* ( + environment: ProviderInstanceEnvironment | undefined, + baseEnvironment?: NodeJS.ProcessEnv, +) { + const platform = yield* HostProcess.Platform; + const base = normalizeCodexSqliteEnvironment( + baseEnvironment ?? (yield* HostProcess.Environment), + platform, + ); + return yield* mergeProviderInstanceEnvironment( + platform === "win32" + ? environment?.map((variable) => + variable.name.toUpperCase() === "CODEX_SQLITE_HOME" + ? { ...variable, name: "CODEX_SQLITE_HOME" } + : variable, + ) + : environment, + base, + ); +}); + +// SQLite must open the database and its sidecars through the same directory. +// Individual links split the WAL across homes when a sidecar does not exist yet. +export function withCodexSqliteHome( + layout: CodexHomeLayout, + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): NodeJS.ProcessEnv { + if (layout.mode !== "authOverlay") return environment; + const normalized = normalizeCodexSqliteEnvironment(environment, platform); + return { + ...normalized, + CODEX_SQLITE_HOME: normalized.CODEX_SQLITE_HOME ?? layout.sharedHomePath, + }; +} // 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"]); @@ -111,6 +169,22 @@ export class CodexShadowHomeEntryConflictError extends Schema.TaggedError()( + "CodexShadowHomeSqliteRecoveryRequiredError", + { + ...CodexShadowHomeContext, + entryName: Schema.String, + linkPath: Schema.String, + }, +) { + override get message(): string { + return CODEX_SQLITE_RECOVERY_GUIDANCE; + } +} + export class CodexShadowHomePrivateEntrySymlinkError extends Schema.TaggedError()( "CodexShadowHomePrivateEntrySymlinkError", { @@ -128,6 +202,7 @@ export const CodexShadowHomeError = Schema.Union([ CodexShadowHomeFileSystemError, CodexShadowHomePathConflictError, CodexShadowHomeEntryConflictError, + CodexShadowHomeSqliteRecoveryRequiredError, CodexShadowHomePrivateEntrySymlinkError, ]); export type CodexShadowHomeError = typeof CodexShadowHomeError.Type; @@ -185,27 +260,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 { const path = yield* Path.Path; - const privatePath = path.join(input.effectiveHomePath, input.entryName); + const linkPath = path.join(input.effectiveHomePath, input.entryName); const state = yield* readLinkState({ ...input, - linkPath: privatePath, + linkPath, }); if (state._tag === "Symlink") { - yield* input.fileSystem.remove(privatePath).pipe( + yield* input.fileSystem.remove(linkPath).pipe( Effect.catchTags({ PlatformError: (cause) => new CodexShadowHomeFileSystemError({ sharedHomePath: input.sharedHomePath, effectiveHomePath: input.effectiveHomePath, operation: "remove", - path: privatePath, + path: linkPath, entryName: input.entryName, cause, }), @@ -318,6 +393,54 @@ const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsP }, ); +// Read-only preflight shared by materialization and provider-associated terminals. +export const inspectCodexShadowSqlite = Effect.fn("inspectCodexShadowSqlite")(function* ( + layout: CodexHomeLayout, +) { + const effectiveHomePath = layout.effectiveHomePath; + if (layout.mode !== "authOverlay" || !effectiveHomePath) return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const shadowEntryNames = yield* fileSystem.readDirectory(effectiveHomePath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed>([]) + : new CodexShadowHomeFileSystemError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + operation: "readDirectory", + path: effectiveHomePath, + cause, + }), + }), + ); + // Even an orphan sidecar link is not launch-ready: a terminal or explicit + // SQLite override can create a database beside it. Leave all legacy SQLite + // links for operator recovery instead of giving launch paths different rules. + for (const entryName of shadowEntryNames) { + if (!SQLITE_ENTRY_NAME.test(entryName)) continue; + // Preserve the directory lookup spelling: path.join would collapse '..' + // before the filesystem can resolve an earlier directory symlink. + const linkPath = `${effectiveHomePath}${effectiveHomePath.endsWith(path.sep) ? "" : path.sep}${entryName}`; + const state = yield* readLinkState({ + fileSystem, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + entryName, + linkPath, + }); + if (state._tag === "Symlink") { + return yield* new CodexShadowHomeSqliteRecoveryRequiredError({ + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + entryName, + linkPath, + }); + } + } +}); + export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome")(function* ( layout: CodexHomeLayout, ) { @@ -334,6 +457,8 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + yield* inspectCodexShadowSqlite(layout); + const makeDirectory = (directoryPath: string) => fileSystem.makeDirectory(directoryPath, { recursive: true }).pipe( Effect.catchTags({ @@ -373,7 +498,11 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" ); const entries = new Set(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.test(entryName) + ) { entries.add(entryName); } } @@ -383,7 +512,7 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" (entryName) => entryName === "auth.json" ? Effect.void - : removePrivateSymlink({ + : removeShadowSymlink({ fileSystem, sharedHomePath: layout.sharedHomePath, effectiveHomePath, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index e3cede11fdea0..0daa5db0388e0 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -2,6 +2,7 @@ import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3 import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/process"; @@ -17,15 +18,20 @@ import { probeCodexSkillsForCwd, } from "../CodexProvider.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { type ProviderDriverCreateInput, type ProviderInstance, } from "@t3tools/provider-core/server/driver"; -import { codexContinuationIdentity } from "./CodexHomeLayout.ts"; +import { + CODEX_SQLITE_RECOVERY_GUIDANCE, + CodexShadowHomeSqliteRecoveryRequiredError, + codexContinuationIdentity, + mergeCodexEnvironment, +} from "./CodexHomeLayout.ts"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { HttpClient } from "effect/http"; const DRIVER = ProviderDriverKind.make("codex"); +const isSqliteRecoveryRequired = Schema.is(CodexShadowHomeSqliteRecoveryRequiredError); export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(function* ( input: ProviderDriverCreateInput, @@ -40,7 +46,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu instanceId, enabled, config, - environment: yield* mergeProviderInstanceEnvironment(input.environment), + environment: yield* mergeCodexEnvironment(input.environment), }); const continuationIdentity = codexContinuationIdentity(runtime.homeLayout); const stamp = withInstanceIdentity({ @@ -163,7 +169,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu : {}), }), ), - Effect.catch(() => + Effect.catch((cause) => runtime.auth.read.pipe( Effect.orElseSucceed(() => Option.none()), Effect.map((current) => ({ @@ -188,7 +194,10 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu ...(Option.isSome(current) && current.value.scopes.includes("chatgpt.tokens.use.direct") ? { usageLimits } : {}), - message: "Could not check Codex right now. Retry, or reconnect in provider settings.", + message: + cause._tag === "ProviderSetupError" && cause.operation === "runtime" + ? cause.detail + : "Could not check Codex right now. Retry, or reconnect in provider settings.", })), ), ), @@ -236,7 +245,9 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu new ProviderDriverError({ driver: DRIVER, instanceId, - detail: "Failed to build Codex orchestration adapter.", + detail: isSqliteRecoveryRequired(cause.cause) + ? CODEX_SQLITE_RECOVERY_GUIDANCE + : "Failed to build Codex orchestration adapter.", cause, }), ), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 5287c5a3dbd9d..2ec14638cdcc3 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1910,6 +1910,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { serverSettings, path, rawProviderInstanceId: instanceId, + stateDir: serverConfig.stateDir, + cwd: process.cwd(), env: undefined, }); const persisted = yield* fileSystem.readFileString(serverConfig.settingsPath); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 65197ab61fa95..2fa90f4703fd6 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -13,6 +13,7 @@ import { TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as Data from "effect/Data"; import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; @@ -36,6 +37,8 @@ import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/process"; +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; import { expect, vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -51,6 +54,9 @@ vi.mock("effect/SynchronizedRef", async (importOriginal) => { return { ...original, make: vi.fn(original.make) }; }); +// Builtin namespace exports are read-only; synchronize mutable OS spies into them. +const mutableOS: typeof NodeOS = NodeModule.createRequire(import.meta.url)("node:os"); + const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ @@ -2292,6 +2298,1396 @@ it.layer( }), ); + it.effect.each([ + { + platform: "linux" as const, + inheritedKey: "CODEX_SQLITE_HOME", + request: undefined, + provider: undefined, + expected: "/inherited/sqlite", + }, + { + platform: "win32" as const, + inheritedKey: "codex_sqlite_home", + request: undefined, + provider: undefined, + expected: "/inherited/sqlite", + }, + { + platform: "win32" as const, + inheritedKey: "codex_sqlite_home", + request: "/request/sqlite", + provider: undefined, + expected: "/request/sqlite", + }, + { + platform: "win32" as const, + inheritedKey: "codex_sqlite_home", + request: "/request/sqlite", + provider: "/provider/sqlite", + expected: "/provider/sqlite", + }, + { + platform: "win32" as const, + inheritedKey: "CODEX_SQLITE_HOME", + requestKey: "codex_sqlite_home", + request: "/request/sqlite", + providerKey: "CODEX_SQLITE_HOME", + provider: "/provider/sqlite", + expected: "/provider/sqlite", + }, + { + platform: "win32" as const, + inheritedKey: "codex_sqlite_home", + requestKey: "CODEX_SQLITE_HOME", + request: "/request/sqlite", + providerKey: "codex_sqlite_home", + provider: "/provider/sqlite", + expected: "/provider/sqlite", + }, + ])( + "preserves SQLite terminal environment precedence: $expected ($platform)", + ({ platform, inheritedKey, requestKey, request, providerKey, provider, expected }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const providerInstanceId = ProviderInstanceId.make("codex_sqlite"); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { [inheritedKey]: "/inherited/sqlite", T3CODE_SECRET: "server-only" }, + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: "/test/state", + cwd, + env, + inheritedEnvironment, + }), + }); + yield* manager.open( + openInput({ + providerInstanceId, + env: + request === undefined ? undefined : { [requestKey ?? "CODEX_SQLITE_HOME"]: request }, + }), + ); + const environment = ptyAdapter.spawnInputs[0]?.env; + expect(environment?.[inheritedKey]).toBe(expected); + expect(environment?.CODEX_HOME).toBe(path.resolve("/configured/codex-shadow")); + expect(environment?.T3CODE_SECRET).toBeUndefined(); + }).pipe( + Effect.provideService(HostProcess.Platform, platform), + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [ProviderInstanceId.make("codex_sqlite")]: { + driver: ProviderDriverKind.make("codex"), + config: { + homePath: "/configured/codex", + shadowHomePath: "/configured/codex-shadow", + }, + environment: + provider === undefined + ? [] + : [ + { + name: providerKey ?? "CODEX_SQLITE_HOME", + value: provider, + sensitive: false, + }, + ], + }, + }, + }), + ), + ), + ); + + for (const { platform, variable } of [ + { platform: "linux", variable: "CODEX_HOME" }, + { platform: "win32", variable: "CODEX_HOME" }, + { platform: "linux", variable: "HOME" }, + ] as const) { + for (const source of ["inherited", "request", "provider"] as const) { + for (const entry of ["database", "dangling sidecar"] as const) { + it.effect.skipIf(!symlinksSupported)( + `blocks the actual terminal home from ${source} (${platform}, ${entry}, ${variable})`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-terminal-home-" }); + const sharedHome = path.join(root, "shared"); + const home = path.join(root, "legacy"); + const actualHome = variable === "HOME" ? path.join(home, ".codex") : home; + const value = variable === "HOME" ? home : actualHome; + const cwd = path.join(root, "worktree"); + for (const directory of [sharedHome, actualHome, cwd]) { + yield* fs.makeDirectory(directory, { recursive: true }); + } + const providerInstanceId = ProviderInstanceId.make("codex_actual_home"); + const generatedHome = path.join( + root, + "providers", + "codex", + providerInstanceId, + "shadow", + ); + const database = path.join(sharedHome, "state_5.sqlite"); + const linkPath = path.join( + actualHome, + entry === "database" ? "state_5.sqlite" : "STATE_5.SQLITE-WAL", + ); + const target = entry === "database" ? database : path.join(sharedHome, "missing-wal"); + yield* fs.writeFileString(database, "shared database"); + yield* fs.symlink(target, linkPath); + const sidecars = + entry === "database" ? ["wal", "shm", "journal"] : ["shm", "journal"]; + for (const sidecar of sidecars) { + yield* fs.writeFileString( + path.join(actualHome, `state_5.sqlite-${sidecar}`), + sidecar, + ); + } + const key = platform === "win32" ? "codex_home" : variable; + const serverSettings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "managed", homePath: "", shadowHomePath: "" }, + environment: + source === "provider" ? [{ name: key, value, sensitive: false }] : [], + }, + }, + }), + ), + ); + const { manager, ptyAdapter } = yield* createManager(5, { + env: source === "inherited" ? { [key]: value } : {}, + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: root, + env, + inheritedEnvironment, + cwd, + }), + }); + const input = openInput({ + providerInstanceId, + cwd, + env: source === "request" ? { [key]: value } : undefined, + }); + for (let attempt = 0; attempt < 2; attempt += 1) { + const operation = + source === "request" + ? manager.restart({ ...input, cols: 100, rows: 24 }).pipe(Effect.asVoid) + : source === "inherited" + ? manager.attachStream(input, () => Effect.void).pipe(Effect.asVoid) + : manager.open(input).pipe(Effect.asVoid); + const result = yield* operation.pipe( + Effect.provideService(HostProcess.HomeDirectory, path.join(root, "server-home")), + Effect.provideService( + HostProcess.AccountHomeDirectory, + Effect.succeed(path.join(root, "account")), + ), + Effect.result, + ); + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") return; + expect(result.failure).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + cause: { _tag: "CodexShadowHomeSqliteRecoveryRequiredError", linkPath }, + }); + expect(result.failure.message).toContain("Codex SQLite recovery is required"); + expect(result.failure.message).not.toContain(root); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + expect(yield* fs.readLink(linkPath)).toBe(target); + expect(yield* fs.readFileString(database)).toBe("shared database"); + for (const sidecar of sidecars) { + expect( + yield* fs.readFileString(path.join(actualHome, `state_5.sqlite-${sidecar}`)), + ).toBe(sidecar); + } + expect(yield* fs.exists(generatedHome)).toBe(false); + if (entry === "dangling sidecar") { + expect(yield* fs.exists(target)).toBe(false); + expect(yield* fs.exists(path.join(actualHome, "state_5.sqlite"))).toBe(false); + } + } + }).pipe(Effect.provideService(HostProcess.Platform, platform)), + ); + } + } + } + + for (const mode of [ + "configured", + "implicit", + "relative", + "directory-alias", + "alias-no-id", + "alias-zero-id", + "unsafe-generated", + "native-home", + "native-empty-home", + "native-unset-home", + "native-directory-alias", + "native-explicit-winner", + "native-relative-home", + "native-unsafe-generated", + "windows-home-override", + ] as const) { + const isAlias = + mode === "directory-alias" || + mode === "alias-no-id" || + mode === "alias-zero-id" || + mode === "native-directory-alias"; + const unsafeGenerated = mode === "unsafe-generated" || mode === "native-unsafe-generated"; + const nativeDefault = mode.startsWith("native-") && mode !== "native-explicit-winner"; + const hasExtraHome = + mode === "native-explicit-winner" || + mode === "native-relative-home" || + mode === "windows-home-override"; + it.effect.skipIf( + !symlinksSupported || (isAlias && HostProcess.Platform.defaultValue() === "win32"), + )(`preserves native terminal selection without bypassing generated recovery (${mode})`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-terminal-direct-shared-" }); + const home = path.join(root, "home"); + const shared = path.join(home, ".codex"); + yield* fs.makeDirectory(shared, { recursive: true }); + const database = path.join(root, "database.sqlite"); + yield* fs.writeFileString(database, "shared database"); + const sharedLink = path.join(shared, "state_5.sqlite"); + yield* fs.symlink(database, sharedLink); + yield* fs.writeFileString(path.join(shared, "state_5.sqlite-wal"), "shared WAL"); + const providerInstanceId = ProviderInstanceId.make( + mode === "configured" ? "codex" : "codex_shared", + ); + const generated = path.join(root, "providers", "codex", providerInstanceId, "shadow"); + const generatedLink = path.join(generated, "state_5.sqlite"); + if (unsafeGenerated) { + yield* fs.makeDirectory(generated, { recursive: true }); + yield* fs.symlink(database, generatedLink); + yield* fs.writeFileString(path.join(generated, "state_5.sqlite-wal"), "private WAL"); + } + let terminalHome = mode === "relative" ? "." : shared; + if (isAlias) { + terminalHome = path.join(root, "shared-alias"); + yield* fs.symlink(mode === "native-directory-alias" ? home : shared, terminalHome); + } + const extraHome = path.join(root, "extra-home"); + const extraLink = path.join(extraHome, ".codex", "state_5.sqlite"); + if (hasExtraHome) { + yield* fs.makeDirectory(path.dirname(extraLink), { recursive: true }); + yield* fs.symlink(database, extraLink); + yield* fs.writeFileString(`${extraLink}-wal`, "private WAL"); + } + const cwd = mode === "relative" ? shared : root; + const serverSettings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { + setupMode: "managed", + homePath: mode === "configured" ? shared : "", + shadowHomePath: "", + }, + }, + }, + }), + ), + ); + const resolverFs = + mode === "alias-no-id" || mode === "alias-zero-id" + ? { + ...fs, + stat: (candidate: string) => + fs.stat(candidate).pipe( + Effect.map((info) => ({ + ...info, + ino: mode === "alias-no-id" ? Option.none() : Option.some(0n), + })), + ), + } + : fs; + const { manager, ptyAdapter } = yield* createManager(5, { + env: {}, + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: root, + cwd, + env, + inheritedEnvironment, + }).pipe(Effect.provideService(FileSystem.FileSystem, resolverFs)), + }); + const environment: Record = + mode === "native-unset-home" + ? {} + : mode === "native-empty-home" + ? { HOME: "" } + : mode === "native-directory-alias" + ? { HOME: terminalHome } + : mode === "windows-home-override" + ? { HOME: extraHome, USERPROFILE: extraHome } + : mode === "native-relative-home" + ? { HOME: "extra-home" } + : nativeDefault + ? { HOME: home } + : mode === "native-explicit-winner" + ? { CODEX_HOME: terminalHome, HOME: extraHome } + : { CODEX_HOME: terminalHome }; + const input = openInput({ providerInstanceId, cwd, env: environment }); + const blocked = + unsafeGenerated || + mode === "alias-no-id" || + mode === "alias-zero-id" || + mode === "native-relative-home"; + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* manager + .open(input) + .pipe( + Effect.provideService(HostProcess.HomeDirectory, home), + Effect.provideService(HostProcess.AccountHomeDirectory, Effect.succeed(home)), + Effect.provideService( + HostProcess.Platform, + mode.startsWith("native-") + ? "linux" + : mode === "windows-home-override" + ? "win32" + : HostProcess.Platform.defaultValue(), + ), + Effect.result, + ); + expect(result._tag).toBe(blocked ? "Failure" : "Success"); + if (blocked && result._tag === "Failure") { + expect(result.failure).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + cause: { + _tag: "CodexShadowHomeSqliteRecoveryRequiredError", + linkPath: unsafeGenerated + ? generatedLink + : mode === "native-relative-home" + ? extraLink + : path.join(terminalHome, "state_5.sqlite"), + }, + }); + expect(result.failure.message).not.toContain(root); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + if (unsafeGenerated) { + expect(yield* fs.readLink(generatedLink)).toBe(database); + expect(yield* fs.readFileString(path.join(generated, "state_5.sqlite-wal"))).toBe( + "private WAL", + ); + } else { + expect(yield* fs.exists(path.join(root, "providers"))).toBe(false); + } + } else if (!blocked) { + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.spawnInputs[0]?.cwd).toBe(cwd); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe(environment.CODEX_HOME); + expect(ptyAdapter.spawnInputs[0]?.env.HOME).toBe(environment.HOME); + expect(ptyAdapter.spawnInputs[0]?.env.USERPROFILE).toBe(environment.USERPROFILE); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_SQLITE_HOME).toBeUndefined(); + expect(yield* fs.exists(path.join(root, "providers"))).toBe(false); + } + expect(yield* fs.readLink(sharedLink)).toBe(database); + expect(yield* fs.readFileString(database)).toBe("shared database"); + expect(yield* fs.readFileString(path.join(shared, "state_5.sqlite-wal"))).toBe( + "shared WAL", + ); + if (hasExtraHome) { + expect(yield* fs.readLink(extraLink)).toBe(database); + expect(yield* fs.readFileString(`${extraLink}-wal`)).toBe("private WAL"); + } + } + }), + ); + } + + for (const mode of [ + "cwd-symlink", + "home-symlink", + "absolute-home-symlink", + "shared-decoy", + "native-home-symlink", + "native-relative-home-symlink", + ] as const) { + for (const physicalUnsafe of [true, false]) { + it.effect.skipIf(!symlinksSupported || HostProcess.Platform.defaultValue() === "win32")( + `preserves physical terminal home selection (${mode}, unsafe=${physicalUnsafe})`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-terminal-physical-" }); + const work = path.join(root, "work"); + const child = path.join(root, "physical", "child"); + yield* fs.makeDirectory(work); + yield* fs.makeDirectory(child, { recursive: true }); + let cwd = work; + let home = "hop/../legacy"; + const nativeMode = + mode === "native-home-symlink" || mode === "native-relative-home-symlink"; + let lexicalHome = path.join(work, nativeMode ? ".codex" : "legacy"); + const entry = mode === "shared-decoy" || nativeMode ? ".codex" : "legacy"; + const fixtureHome = mode === "shared-decoy" ? root : path.join(root, "home"); + let physicalHome = path.join(root, "physical", entry); + if (mode === "native-relative-home-symlink") { + cwd = path.join(root, "alias"); + yield* fs.symlink(child, cwd); + home = "missing/../../native"; + lexicalHome = path.join(root, "native", ".codex"); + physicalHome = path.join(root, "physical", "native", ".codex"); + } else if (mode === "cwd-symlink" || mode === "shared-decoy") { + cwd = path.join(root, "alias"); + yield* fs.symlink(child, cwd); + home = `../${entry}`; + lexicalHome = path.join(root, entry); + } else { + yield* fs.symlink(child, path.join(work, "hop")); + if (mode === "absolute-home-symlink") home = `${work}/hop/../legacy`; + if (mode === "native-home-symlink") home = `${work}/hop/../`; + } + yield* fs.makeDirectory(lexicalHome, { recursive: true }); + yield* fs.makeDirectory(physicalHome, { recursive: true }); + const database = path.join(root, "shared.sqlite"); + yield* fs.writeFileString(database, "preserved database"); + const selectedHome = mode === "native-home-symlink" ? lexicalHome : physicalHome; + const decoyHome = mode === "native-home-symlink" ? physicalHome : lexicalHome; + const unsafeHome = physicalUnsafe ? selectedHome : decoyHome; + const linkPath = path.join(unsafeHome, "state_5.sqlite"); + yield* fs.symlink(database, linkPath); + for (const sidecar of ["wal", "shm", "journal"]) { + yield* fs.writeFileString( + path.join(unsafeHome, `state_5.sqlite-${sidecar}`), + sidecar, + ); + } + const providerInstanceId = ProviderInstanceId.make("codex_physical"); + const serverSettings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "managed", homePath: "", shadowHomePath: "" }, + }, + }, + }), + ), + ); + const { manager, ptyAdapter } = yield* createManager(5, { + env: {}, + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: root, + cwd, + env, + inheritedEnvironment, + }), + }); + const variable = nativeMode ? "HOME" : "CODEX_HOME"; + const input = openInput({ providerInstanceId, cwd, env: { [variable]: home } }); + const selectedLookup = nativeMode ? path.normalize(`${home}/.codex`) : home; + const lookupHome = path.isAbsolute(selectedLookup) + ? selectedLookup + : `${cwd}/${selectedLookup}`; + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* manager + .open(input) + .pipe(Effect.provideService(HostProcess.HomeDirectory, fixtureHome), Effect.result); + expect(result._tag).toBe(physicalUnsafe ? "Failure" : "Success"); + if (physicalUnsafe && result._tag === "Failure") { + expect(result.failure).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + cause: { + _tag: "CodexShadowHomeSqliteRecoveryRequiredError", + linkPath: `${lookupHome}/state_5.sqlite`, + }, + }); + expect(result.failure.message).not.toContain(root); + expect(yield* fs.readLink(`${lookupHome}/state_5.sqlite`)).toBe(database); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + } else if (!physicalUnsafe) { + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.spawnInputs[0]?.cwd).toBe(cwd); + expect(ptyAdapter.spawnInputs[0]?.env[variable]).toBe(home); + if (variable === "HOME") + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBeUndefined(); + } + expect(yield* fs.readLink(linkPath)).toBe(database); + expect(yield* fs.readFileString(database)).toBe("preserved database"); + for (const sidecar of ["wal", "shm", "journal"]) { + expect( + yield* fs.readFileString(path.join(unsafeHome, `state_5.sqlite-${sidecar}`)), + ).toBe(sidecar); + } + expect(yield* fs.exists(path.join(root, "providers"))).toBe(false); + } + }), + ); + } + } + + for (const source of ["inherited", "request", "provider"] as const) { + for (const home of ["~/legacy", "~//legacy"] as const) { + for (const selectedUnsafe of [true, false]) { + it.effect.skipIf(!symlinksSupported)( + `checks one-pass native HOME expansion for ${source} (${home}, ${selectedUnsafe ? "selected unsafe" : "unused unsafe"})`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-native-tilde-" }); + const selected = path.join(root, "~", "legacy", "legacy", ".codex"); + const unexpanded = path.join(root, "~", "legacy", ".codex"); + for (const directory of [selected, unexpanded]) { + yield* fs.makeDirectory(directory, { recursive: true }); + } + const unsafe = selectedUnsafe ? selected : unexpanded; + const database = path.join(root, "database.sqlite"); + const link = path.join(unsafe, "state_5.sqlite"); + yield* fs.writeFileString(database, "preserved database"); + yield* fs.symlink(database, link); + for (const sidecar of ["wal", "shm", "journal"]) { + yield* fs.writeFileString(path.join(unsafe, `state_5.sqlite-${sidecar}`), sidecar); + } + const providerInstanceId = ProviderInstanceId.make("codex_native_tilde"); + const environment = + source === "provider" ? [{ name: "HOME", value: home, sensitive: false }] : []; + const serverSettings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "managed", homePath: "", shadowHomePath: "" }, + environment, + }, + }, + }), + ), + ); + const inherited = source === "inherited" ? { HOME: home } : {}; + const { manager, ptyAdapter } = yield* createManager(5, { + env: inherited, + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: root, + cwd, + env, + inheritedEnvironment, + }).pipe( + Effect.provideService(HostProcess.HomeDirectory, path.join(root, "server")), + ), + }); + const input = openInput({ + providerInstanceId, + cwd: root, + env: source === "request" ? { HOME: home } : undefined, + }); + const original = JSON.stringify([input, inherited, environment]); + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* manager.open(input).pipe(Effect.result); + expect(result._tag).toBe(selectedUnsafe ? "Failure" : "Success"); + if (result._tag === "Failure") { + expect(result.failure).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + cause: { _tag: "CodexShadowHomeSqliteRecoveryRequiredError", linkPath: link }, + }); + expect(result.failure.message).not.toContain(root); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + } else { + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.spawnInputs[0]?.env.HOME).toBe(home); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBeUndefined(); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_SQLITE_HOME).toBeUndefined(); + } + expect(yield* fs.readLink(link)).toBe(database); + expect(yield* fs.readFileString(database)).toBe("preserved database"); + for (const sidecar of ["wal", "shm", "journal"]) { + expect( + yield* fs.readFileString(path.join(unsafe, `state_5.sqlite-${sidecar}`)), + ).toBe(sidecar); + } + expect(JSON.stringify([input, inherited, environment])).toBe(original); + expect(yield* fs.exists(path.join(root, "providers"))).toBe(false); + } + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), + ); + } + } + } + + for (const source of [ + "inherited", + "request", + "provider", + "empty", + "unset", + "tilde-inherited", + "tilde-request", + "tilde-provider", + ] as const) { + it.effect.skipIf(!symlinksSupported)( + `refuses restart before killing an existing terminal selected through ${source} HOME`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-terminal-native-home-" }); + const tilde = source.startsWith("tilde-"); + const homeSource = tilde ? source.slice(6) : source; + const home = tilde ? "~/legacy" : path.join(root, "terminal-home"); + const codexHome = tilde + ? path.join(root, "~", "legacy", "legacy", ".codex") + : path.join(home, ".codex"); + const accountFallback = homeSource === "empty" || homeSource === "unset"; + yield* fs.makeDirectory(codexHome, { recursive: true }); + const providerInstanceId = ProviderInstanceId.make("codex_native_home"); + const serverSettings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "managed", homePath: "", shadowHomePath: "" }, + environment: + homeSource === "provider" + ? [{ name: "HOME", value: home, sensitive: false }] + : [], + }, + }, + }), + ), + ); + const { manager, ptyAdapter } = yield* createManager(5, { + env: + homeSource === "unset" + ? {} + : { HOME: homeSource === "inherited" ? home : path.join(root, "server-home") }, + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: root, + cwd, + env, + inheritedEnvironment, + }).pipe( + Effect.provideService(HostProcess.HomeDirectory, path.join(root, "server-home")), + Effect.provideService( + HostProcess.AccountHomeDirectory, + Effect.succeed(accountFallback ? home : path.join(root, "account")), + ), + ), + }); + const input = openInput({ + providerInstanceId, + cwd: root, + env: + homeSource === "empty" + ? { HOME: "" } + : homeSource === "request" + ? { HOME: home } + : undefined, + }); + yield* manager.open(input); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.spawnInputs[0]?.env.HOME).toBe( + homeSource === "empty" ? "" : homeSource === "unset" ? undefined : home, + ); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBeUndefined(); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_SQLITE_HOME).toBeUndefined(); + const target = path.join(root, "missing-shm"); + const link = path.join(codexHome, "STATE_5.SQLITE-SHM"); + yield* fs.symlink(target, link); + yield* fs.writeFileString(path.join(codexHome, "state_5.sqlite-wal"), "private WAL"); + const unsubscribe = yield* manager.attachStream(input, () => Effect.void); + yield* Effect.sync(unsubscribe); + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* manager + .restart({ ...input, cols: 100, rows: 24 }) + .pipe(Effect.result); + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") return; + expect(result.failure).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + cause: { _tag: "CodexShadowHomeSqliteRecoveryRequiredError", linkPath: link }, + }); + expect(result.failure.message).not.toContain(root); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.processes[0]?.killed).toBe(false); + expect(yield* fs.readLink(link)).toBe(target); + expect(yield* fs.readFileString(path.join(codexHome, "state_5.sqlite-wal"))).toBe( + "private WAL", + ); + expect(yield* fs.exists(target)).toBe(false); + expect(yield* fs.exists(path.join(root, "providers"))).toBe(false); + } + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), + ); + } + + for (const source of ["inherited", "request", "provider"] as const) { + for (const form of ["relative", "tilde"] as const) { + it.effect.skipIf(!symlinksSupported)( + `checks the terminal cwd and expansion for ${source} ${form} homes`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-terminal-path-" }); + const cwd = path.join(root, "worktree"); + const home = path.join(root, "home"); + // Inherited values are not shell-expanded by terminal environment composition. + const actualHome = path.join( + form === "tilde" && source !== "inherited" ? home : cwd, + ...(form === "tilde" && source === "inherited" ? ["~", "legacy"] : ["legacy"]), + ); + yield* fs.makeDirectory(actualHome, { recursive: true }); + yield* fs.makeDirectory(home, { recursive: true }); + yield* fs.makeDirectory(cwd, { recursive: true }); + const linkPath = path.join(actualHome, "state_5.sqlite-shm"); + const target = path.join(root, "missing-shm"); + yield* fs.symlink(target, linkPath); + const value = form === "tilde" ? "~/legacy" : "legacy"; + const lookupHome = + form === "tilde" && source !== "inherited" ? actualHome : `${cwd}${path.sep}${value}`; + const providerInstanceId = ProviderInstanceId.make("codex_relative_home"); + const serverSettings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "managed", homePath: "", shadowHomePath: "" }, + environment: + source === "provider" + ? [{ name: "CODEX_HOME", value, sensitive: false }] + : [], + }, + }, + }), + ), + ); + const { manager, ptyAdapter } = yield* createManager(5, { + env: source === "inherited" ? { CODEX_HOME: value } : {}, + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: root, + env, + inheritedEnvironment, + cwd, + }), + }); + const error = yield* manager + .open( + openInput({ + providerInstanceId, + cwd, + env: source === "request" ? { CODEX_HOME: value } : undefined, + }), + ) + .pipe(Effect.provideService(HostProcess.HomeDirectory, home), Effect.flip); + expect(error).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + cause: { + _tag: "CodexShadowHomeSqliteRecoveryRequiredError", + linkPath: `${lookupHome}${path.sep}state_5.sqlite-shm`, + }, + }); + expect(error.message).not.toContain(root); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + expect(yield* fs.readLink(linkPath)).toBe(target); + expect(yield* fs.exists(target)).toBe(false); + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), + ); + } + } + + for (const { platform, winner, inheritedKey, requestKey, providerKey } of [ + { + platform: "linux" as const, + winner: "request", + inheritedKey: "CODEX_HOME", + requestKey: "CODEX_HOME", + providerKey: "CODEX_HOME", + }, + { + platform: "linux" as const, + winner: "provider", + inheritedKey: "CODEX_HOME", + requestKey: "CODEX_HOME", + providerKey: "CODEX_HOME", + }, + { + platform: "win32" as const, + winner: "request", + inheritedKey: "codex_home", + requestKey: "CODEX_HOME", + providerKey: "codex_home", + }, + { + platform: "win32" as const, + winner: "request", + inheritedKey: "CODEX_HOME", + requestKey: "codex_home", + providerKey: "CODEX_HOME", + }, + { + platform: "win32" as const, + winner: "provider", + inheritedKey: "CODEX_HOME", + requestKey: "CODEX_HOME", + providerKey: "codex_home", + }, + { + platform: "win32" as const, + winner: "provider", + inheritedKey: "codex_home", + requestKey: "codex_home", + providerKey: "CODEX_HOME", + }, + { + platform: "win32" as const, + winner: "home", + inheritedKey: "codex_home", + requestKey: "CoDeX_HoMe", + providerKey: "codex_home", + }, + { + platform: "win32" as const, + winner: "shadow", + inheritedKey: "codex_home", + requestKey: "CODEX_HOME", + providerKey: "codex_home", + }, + ]) { + it.effect.skipIf(!symlinksSupported)( + `inspects only the selected terminal home: ${winner} (${platform}, ${requestKey}, ${providerKey})`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-terminal-home-priority-" }); + const unsafeHome = path.join(root, "unsafe"); + const safeHome = path.join(root, "fresh"); + yield* fs.makeDirectory(unsafeHome); + const linkPath = path.join(unsafeHome, "state_5.sqlite-wal"); + const target = path.join(root, "missing-wal"); + yield* fs.symlink(target, linkPath); + const providerInstanceId = ProviderInstanceId.make("codex_home_priority"); + const serverSettings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { + setupMode: "managed", + homePath: winner === "home" ? safeHome : "", + shadowHomePath: winner === "shadow" ? safeHome : "", + }, + environment: [ + ...(winner === "request" + ? [] + : [ + { + name: providerKey, + value: winner === "provider" ? safeHome : unsafeHome, + sensitive: false, + }, + ]), + { name: "PROVIDER_SECRET", value: "provider-visible", sensitive: true }, + ], + }, + }, + }), + ), + ); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + [inheritedKey]: unsafeHome, + CODEX_SQLITE_HOME: "/chosen/sqlite", + T3CODE_SECRET: "server-only", + }, + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: root, + env, + inheritedEnvironment, + cwd, + }), + }); + const input = openInput({ + providerInstanceId, + cwd: root, + env: { [requestKey]: winner === "request" ? safeHome : unsafeHome }, + }); + const snapshot = yield* manager.open(input); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + const environment = ptyAdapter.spawnInputs[0]?.env ?? {}; + expect( + Object.entries(environment) + .filter(([key]) => key.toUpperCase() === "CODEX_HOME") + .map(([, value]) => value), + ).toEqual([safeHome]); + expect(environment.CODEX_SQLITE_HOME).toBe("/chosen/sqlite"); + expect(environment.PROVIDER_SECRET).toBe("provider-visible"); + expect(environment.T3CODE_SECRET).toBeUndefined(); + expect(snapshot).not.toHaveProperty("env"); + expect(yield* fs.exists(safeHome)).toBe(false); + expect(yield* fs.readLink(linkPath)).toBe(target); + expect(yield* fs.exists(target)).toBe(false); + + // Attaching must not relaunch. Restart still checks generated shadows + // even when the selected home is the legitimate direct shared home. + const recoveryHome = + winner === "home" + ? path.join(root, "providers", "codex", providerInstanceId, "shadow") + : safeHome; + yield* fs.makeDirectory(recoveryHome, { recursive: true }); + const selectedLink = path.join(recoveryHome, "state_5.sqlite-shm"); + yield* fs.symlink(target, selectedLink); + const unsubscribe = yield* manager.attachStream(input, () => Effect.void); + yield* Effect.sync(unsubscribe); + const error = yield* manager.restart({ ...input, cols: 100, rows: 24 }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + cause: { _tag: "CodexShadowHomeSqliteRecoveryRequiredError", linkPath: selectedLink }, + }); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.processes[0]?.killed).toBe(false); + expect(yield* fs.readLink(selectedLink)).toBe(target); + }).pipe(Effect.provideService(HostProcess.Platform, platform)), + ); + } + + for (const shadowMode of ["explicit", "managed"] as const) { + for (const orphan of [false, true]) { + it.effect.skipIf(!symlinksSupported)( + `blocks PTY spawn for a recovery-required ${shadowMode} shadow with ${orphan ? "orphan sidecar" : "database link"}`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-terminal-sqlite-" }); + const sharedHome = path.join(root, "shared"); + const providerInstanceId = ProviderInstanceId.make("codex_recovery"); + const shadowHome = + shadowMode === "managed" + ? path.join(root, "providers", "codex", providerInstanceId, "shadow") + : path.join(root, "shadow"); + yield* fs.makeDirectory(sharedHome); + yield* fs.makeDirectory(shadowHome, { recursive: true }); + const sharedDatabase = path.join(sharedHome, "state_5.sqlite"); + const shadowDatabase = path.join(shadowHome, "state_5.sqlite"); + const shadowWal = path.join(shadowHome, "state_5.sqlite-wal"); + const sharedWal = path.join(sharedHome, "state_5.sqlite-wal"); + yield* fs.writeFileString(sharedDatabase, ""); + if (orphan) { + yield* fs.writeFileString(sharedWal, "shared WAL"); + yield* fs.symlink(sharedWal, shadowWal); + } else { + yield* fs.symlink(sharedDatabase, shadowDatabase); + yield* fs.writeFileString(shadowWal, "uncheckpointed writes"); + } + const serverSettings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { + homePath: sharedHome, + setupMode: shadowMode === "managed" ? "managed" : "existing", + shadowHomePath: shadowMode === "managed" ? "" : shadowHome, + }, + }, + }, + }), + ), + ); + const { manager, ptyAdapter } = yield* createManager(5, { + env: orphan ? { CODEX_SQLITE_HOME: shadowHome } : {}, + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: root, + cwd, + env, + inheritedEnvironment, + }), + }); + const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip); + expect(error._tag).toBe("TerminalProviderEnvironmentError"); + expect(error.message).toContain("Codex SQLite recovery is required"); + expect(error.message).toContain("Stop all Codex processes"); + expect(error.message).not.toContain(sharedHome); + expect(error.message).not.toContain(shadowHome); + expect(error).toMatchObject({ + cause: { + _tag: "CodexShadowHomeSqliteRecoveryRequiredError", + linkPath: orphan ? shadowWal : shadowDatabase, + }, + }); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + if (orphan) { + expect(yield* fs.exists(shadowDatabase)).toBe(false); + expect(yield* fs.readLink(shadowWal)).toBe(sharedWal); + expect(yield* fs.readFileString(sharedWal)).toBe("shared WAL"); + } else { + expect(yield* fs.readLink(shadowDatabase)).toBe(sharedDatabase); + expect(yield* fs.readFileString(shadowWal)).toBe("uncheckpointed writes"); + } + }), + ); + } + } + + it.effect.each(["empty", "unset", "explicit", "home", "windows"] as const)( + "keeps %s home selection safe on account-home failure", + (selection) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-account-home-" }); + const serverHome = path.join(root, "server"); + const sharedHome = path.join(serverHome, ".codex"); + yield* fs.makeDirectory(sharedHome, { recursive: true }); + const entries = [ + "state_5.sqlite", + "state_5.sqlite-wal", + "state_5.sqlite-shm", + "state_5.sqlite-journal", + ]; + for (const entry of entries) yield* fs.writeFileString(path.join(sharedHome, entry), entry); + const providerInstanceId = ProviderInstanceId.make("codex_account_home"); + const generatedHome = path.join(root, "providers", "codex", providerInstanceId, "shadow"); + const serverSettings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "managed", homePath: "", shadowHomePath: "" }, + }, + }, + }), + ), + ); + const inherited = selection === "empty" ? { HOME: serverHome } : {}; + const env = + selection === "empty" + ? { HOME: "" } + : selection === "explicit" + ? { CODEX_HOME: path.join(root, "explicit") } + : selection === "home" + ? { HOME: path.join(root, "native") } + : {}; + const originalEnv = { ...env }; + const originalInherited = { ...inherited }; + const { manager, ptyAdapter } = yield* createManager(5, { + env: inherited, + shellResolver: () => "/bin/bash", + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: root, + cwd, + env, + inheritedEnvironment, + }).pipe(Effect.provideService(HostProcess.HomeDirectory, serverHome)), + }); + const cause = Object.assign(new Error(`private account diagnostic at ${root}`), { + code: "ERR_SYSTEM_ERROR", + }); + const userInfo = vi.spyOn(mutableOS, "userInfo").mockImplementation(() => { + throw cause; + }); + NodeModule.syncBuiltinESMExports(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + userInfo.mockRestore(); + NodeModule.syncBuiltinESMExports(); + }), + ); + const input = openInput({ providerInstanceId, cwd: root, env }); + const needsAccount = selection === "empty" || selection === "unset"; + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* manager.open(input).pipe(Effect.result); + if (needsAccount) { + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") return; + expect(result.failure).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + cause: { _tag: "AccountHomeDirectoryError", cause }, + }); + if (result.failure._tag !== "TerminalProviderEnvironmentError") return; + expect(result.failure.cause).toHaveProperty("cause", cause); + expect(result.failure.message).not.toContain(root); + expect(result.failure.message).not.toContain(cause.message); + expect(result.failure.detail).toBeUndefined(); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + } else { + expect(result._tag).toBe("Success"); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.spawnInputs[0]?.env.HOME).toBe(env.HOME); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe(env.CODEX_HOME); + } + expect(env).toEqual(originalEnv); + expect(inherited).toEqual(originalInherited); + expect(yield* fs.exists(generatedHome)).toBe(false); + for (const entry of entries) + expect(yield* fs.readFileString(path.join(sharedHome, entry))).toBe(entry); + } + expect(userInfo).toHaveBeenCalledTimes(needsAccount ? 2 : 0); + }).pipe( + Effect.provideService(HostProcess.Platform, selection === "windows" ? "win32" : "linux"), + ), + ); + + it.effect.skipIf(!symlinksSupported)( + "keeps generated inspection ahead of an account-home failure", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-account-home-first-" }); + const providerInstanceId = ProviderInstanceId.make("codex_account_home"); + const generatedHome = path.join(root, "providers", "codex", providerInstanceId, "shadow"); + yield* fs.makeDirectory(generatedHome, { recursive: true }); + const target = path.join(root, "missing-shm"); + const linkPath = path.join(generatedHome, "STATE_5.SQLITE-SHM"); + yield* fs.symlink(target, linkPath); + const walPath = path.join(generatedHome, "state_5.sqlite-wal"); + yield* fs.writeFileString(walPath, "private WAL"); + const serverSettings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "managed", homePath: "", shadowHomePath: "" }, + }, + }, + }), + ), + ); + const { manager, ptyAdapter } = yield* createManager(5, { + env: {}, + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: root, + cwd, + env, + inheritedEnvironment, + }), + }); + const userInfo = vi.spyOn(mutableOS, "userInfo").mockImplementation(() => { + throw new Error("account lookup must not run"); + }); + NodeModule.syncBuiltinESMExports(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + userInfo.mockRestore(); + NodeModule.syncBuiltinESMExports(); + }), + ); + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* manager + .open(openInput({ providerInstanceId, cwd: root })) + .pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "TerminalProviderEnvironmentError", + cause: { _tag: "CodexShadowHomeSqliteRecoveryRequiredError", linkPath }, + }, + }); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + expect(userInfo).not.toHaveBeenCalled(); + expect(yield* fs.readLink(linkPath)).toBe(target); + expect(yield* fs.readFileString(walPath)).toBe("private WAL"); + expect(yield* fs.exists(target)).toBe(false); + } + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), + ); + + it.effect( + "refuses restart on account-home failure without killing or re-resolving on attach", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-account-home-restart-" }); + const nativeHome = path.join(root, "native"); + const codexHome = path.join(nativeHome, ".codex"); + yield* fs.makeDirectory(codexHome, { recursive: true }); + const database = path.join(codexHome, "state_5.sqlite"); + yield* fs.writeFileString(database, "database bytes"); + const providerInstanceId = ProviderInstanceId.make("codex_account_home"); + const serverSettings = yield* ServerSettings.ServerSettingsService.pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "managed", homePath: "", shadowHomePath: "" }, + }, + }, + }), + ), + ); + const { manager, ptyAdapter } = yield* createManager(5, { + env: {}, + shellResolver: () => "/bin/bash", + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: root, + cwd, + env, + inheritedEnvironment, + }).pipe(Effect.provideService(HostProcess.HomeDirectory, path.join(root, "server"))), + }); + const cause = Object.assign(new Error(`private account diagnostic at ${root}`), { + code: "ERR_SYSTEM_ERROR", + }); + const userInfo = vi.spyOn(mutableOS, "userInfo").mockImplementation(() => { + throw cause; + }); + NodeModule.syncBuiltinESMExports(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + userInfo.mockRestore(); + NodeModule.syncBuiltinESMExports(); + }), + ); + const input = openInput({ providerInstanceId, cwd: root, env: { HOME: nativeHome } }); + yield* manager.open(input); + const unavailable = { ...input, env: { HOME: "" } }; + const unsubscribe = yield* manager.attachStream(unavailable, () => Effect.void); + yield* Effect.sync(unsubscribe); + expect(userInfo).not.toHaveBeenCalled(); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* manager + .restart({ ...unavailable, cols: 100, rows: 24 }) + .pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "TerminalProviderEnvironmentError", + cause: { _tag: "AccountHomeDirectoryError", cause }, + }, + }); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.processes[0]?.killed).toBe(false); + expect(yield* fs.readFileString(database)).toBe("database bytes"); + expect(unavailable.env).toEqual({ HOME: "" }); + } + expect(userInfo).toHaveBeenCalledTimes(2); + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), + ); + it.effect("fails closed when a provider instance is missing", () => Effect.gen(function* () { const providerInstanceId = ProviderInstanceId.make("deleted_instance"); @@ -2341,6 +3737,8 @@ it.layer( serverSettings, path, rawProviderInstanceId: providerInstanceId, + stateDir: "/test/state", + cwd: process.cwd(), env: undefined, }).pipe(Effect.flip); @@ -2384,10 +3782,17 @@ it.layer( serverSettings, path, rawProviderInstanceId: "configured_home", + stateDir: "/test/state", + cwd: process.cwd(), env: undefined, }); expect(environment[variable]).toBe(path.resolve(expectedHome)); + if (driver === "codex" && config.shadowHomePath !== undefined) { + expect(environment.CODEX_SQLITE_HOME).toBe(path.resolve(config.homePath)); + } else { + expect(environment.CODEX_SQLITE_HOME).toBeUndefined(); + } }).pipe( Effect.provide( ServerSettings.layerTest({ @@ -2411,6 +3816,8 @@ it.layer( serverSettings, path, rawProviderInstanceId: "codex", + stateDir: "/test/state", + cwd: process.cwd(), env: undefined, }); @@ -2437,6 +3844,8 @@ it.layer( serverSettings, path, rawProviderInstanceId: "claudeAgent", + stateDir: "/test/state", + cwd: process.cwd(), env: undefined, }); @@ -2463,6 +3872,8 @@ it.layer( serverSettings, path, rawProviderInstanceId: "codex", + stateDir: "/test/state", + cwd: process.cwd(), env: { CODEX_HOME: "/inherited/codex-home" }, }); @@ -2480,6 +3891,8 @@ it.layer( serverSettings, path, rawProviderInstanceId: "codex_unknown", + stateDir: "/test/state", + cwd: process.cwd(), env: undefined, }).pipe(Effect.flip); @@ -2516,11 +3929,19 @@ it.layer( const providerInstanceId = ProviderInstanceId.make("codex_restart"); const { manager, ptyAdapter, logsDir } = yield* createManager(2, { historyByteLimit: 8, - resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) => + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => TerminalManager.resolveProviderInstanceTerminalEnvironment({ serverSettings, path, rawProviderInstanceId, + stateDir: "/test/state", + cwd, + inheritedEnvironment, env, }), }); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 3bb561aef2537..0546bf15c2918 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -58,7 +58,15 @@ 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 { resolveManagedCodexHomeLayout } from "../provider/CodexManagedHome.ts"; +import { + CODEX_SQLITE_RECOVERY_GUIDANCE, + inspectCodexShadowSqlite, + mergeCodexEnvironment, + normalizeCodexSqliteEnvironment, + 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"; @@ -1286,11 +1294,11 @@ function createTerminalSpawnEnv( return stripAppImageRuntimeEnv(spawnEnv); } -function normalizedRuntimeEnv( - env: Record | undefined, -): Record | null { +function normalizedRuntimeEnv(env: NodeJS.ProcessEnv | undefined): Record | null { if (!env) return null; - const entries = Object.entries(env); + const entries = Object.entries(env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ); if (entries.length === 0) return null; return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); } @@ -1324,9 +1332,12 @@ interface TerminalManagerOptions { resolveProviderInstanceEnvironment?: ( providerInstanceId: string, env: Record | undefined, + inheritedEnvironment: NodeJS.ProcessEnv, + cwd: string, ) => Effect.Effect< Record, - TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError, + FileSystem.FileSystem >; } @@ -1336,7 +1347,10 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( readonly serverSettings: ServerSettings.ServerSettingsService["Service"]; readonly path: Path.Path; readonly rawProviderInstanceId: string; + readonly stateDir: string; + readonly cwd: string; readonly env: Record | undefined; + readonly inheritedEnvironment?: NodeJS.ProcessEnv; }) { const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId); const settings = yield* input.serverSettings.getSettings.pipe( @@ -1347,15 +1361,136 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId }); } - let resolved = yield* mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); + const platform = yield* HostProcess.Platform; + const inheritedSqlite = Object.fromEntries( + Object.entries(input.inheritedEnvironment ?? {}).filter(([key]) => + platform === "win32" + ? key.toUpperCase() === "CODEX_SQLITE_HOME" + : key === "CODEX_SQLITE_HOME", + ), + ); + const codexHomeKey = (key: string) => + platform === "win32" && key.toUpperCase() === "CODEX_HOME" ? "CODEX_HOME" : key; + let resolved = yield* instance.driver === "codex" + ? mergeCodexEnvironment( + instance.environment?.map((variable) => ({ + ...variable, + name: codexHomeKey(variable.name), + })), + { + ...normalizeCodexSqliteEnvironment(inheritedSqlite, platform), + ...normalizeCodexSqliteEnvironment( + Object.fromEntries( + Object.entries(input.env ?? {}).map(([key, value]) => [codexHomeKey(key), value]), + ), + platform, + ), + }, + ) + : mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); if (instance.driver === "codex") { const config = decodeCodexSettings(instance.config ?? {}); if (Option.isSome(config)) { const layout = yield* resolveCodexHomeLayout(config.value).pipe( Effect.provideService(Path.Path, input.path), ); + // Keep terminal CLI authentication unchanged, but inspect the managed + // account's actual shadow before allowing another shared SQLite writer. + const recoveryLayout = + config.value.setupMode === "managed" + ? yield* resolveManagedCodexHomeLayout( + input.stateDir, + providerInstanceId, + config.value, + ).pipe(Effect.provideService(Path.Path, input.path)) + : layout; + resolved = withCodexSqliteHome(layout, resolved, platform); if (layout.effectiveHomePath) resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; + + // Use the same sorted/expanded environment as PTY spawning. Inherited + // homes stay unexpanded, and relative homes belong to the terminal cwd. + const terminalEnvironment = createTerminalSpawnEnv( + input.inheritedEnvironment ?? {}, + normalizedRuntimeEnv(resolved), + platform, + yield* HostProcess.HomeDirectory, + ); + yield* Effect.gen(function* () { + yield* inspectCodexShadowSqlite(recoveryLayout); + const explicitHome = Object.entries(terminalEnvironment).find( + ([key]) => codexHomeKey(key) === "CODEX_HOME", + )?.[1]; + // POSIX Codex falls back to nonempty HOME, then the account database. + // Windows uses the Known Folder API, not HOME/USERPROFILE overrides. + const nativeHome = + !explicitHome && platform !== "win32" + ? terminalEnvironment.HOME || (yield* Effect.flatten(HostProcess.AccountHomeDirectory)) + : undefined; + // Native HOME's AbsolutePathBuf expands '~/' once against HOME itself, + // then normalizes lexically. Explicit CODEX_HOME is canonicalized first. + const terminalHome = + explicitHome || + (nativeHome + ? input.path.normalize(`${nativeHome}/.codex`.replace(/^~\/+/u, () => `${nativeHome}/`)) + : undefined); + // Keep the cwd and explicit CODEX_HOME spelling: POSIX traverses symlinks + // before '..'. Even fs.realpath's JS implementation normalizes lexically. + // Windows resolves drive-relative paths using Win32 semantics. + const terminalHomePath = terminalHome + ? platform === "win32" + ? input.path.resolve(input.cwd, terminalHome) + : input.path.isAbsolute(terminalHome) + ? terminalHome + : `${input.cwd}${input.cwd.endsWith(input.path.sep) ? "" : input.path.sep}${terminalHome}` + : undefined; + if ( + terminalHomePath === undefined || + (recoveryLayout.mode === "authOverlay" && + terminalHomePath === recoveryLayout.effectiveHomePath) + ) + return; + + if (layout.mode === "direct") { + // Shared homes may deliberately link a database. Directory identity + // also recognizes aliases without lexically collapsing symlink/.. paths. + if (terminalHomePath === layout.sharedHomePath) return; + const fileSystem = yield* FileSystem.FileSystem; + // Unverifiable identity keeps inspection; an unused shared home's + // metadata failure must not become another launch requirement. + const terminalInfo = yield* fileSystem.stat(terminalHomePath).pipe(Effect.option); + const sharedInfo = yield* fileSystem.stat(layout.sharedHomePath).pipe(Effect.option); + if ( + Option.isSome(terminalInfo) && + Option.isSome(sharedInfo) && + terminalInfo.value.type === "Directory" && + sharedInfo.value.type === "Directory" && + Option.isSome(terminalInfo.value.ino) && + Option.isSome(sharedInfo.value.ino) && + terminalInfo.value.ino.value > 0 && + terminalInfo.value.dev === sharedInfo.value.dev && + terminalInfo.value.ino.value === sharedInfo.value.ino.value + ) + return; + } + yield* inspectCodexShadowSqlite({ + ...layout, + mode: "authOverlay", + effectiveHomePath: terminalHomePath, + }); + }).pipe( + Effect.provideService(Path.Path, input.path), + Effect.mapError( + (cause) => + new TerminalProviderEnvironmentError({ + providerInstanceId, + cause, + ...(cause._tag === "CodexShadowHomeSqliteRecoveryRequiredError" + ? { detail: CODEX_SQLITE_RECOVERY_GUIDANCE } + : {}), + }), + ), + ); } } else if (instance.driver === "claudeAgent") { const config = decodeClaudeSettings(instance.config ?? {}); @@ -1373,7 +1508,8 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("TerminalManager.make")(function* () { - const { terminalLogsDir, providerStatusCacheDir, baseDir } = yield* ServerConfig.ServerConfig; + const { terminalLogsDir, providerStatusCacheDir, baseDir, stateDir } = + yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; const portDiscovery = yield* PortScanner.PortDiscovery; const nativeTelemetry = yield* NativeTelemetryClient.NativeTelemetryClient; @@ -1381,13 +1517,22 @@ export const make = Effect.fn("TerminalManager.make")(function* () { const path = yield* Path.Path; const resolveProviderInstanceEnvironment = Effect.fn( "terminal.resolveProviderInstanceEnvironment", - )((rawProviderInstanceId: string, env: Record | undefined) => - resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId, - env, - }), + )( + ( + rawProviderInstanceId: string, + env: Record | undefined, + inheritedEnvironment: NodeJS.ProcessEnv, + cwd: string, + ) => + resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir, + env, + inheritedEnvironment, + cwd, + }), ); return yield* makeWithOptions({ logsDir: terminalLogsDir, @@ -1426,7 +1571,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( - function* ( + function* ( input: Input, ): Effect.fn.Return< Input, @@ -1439,7 +1584,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), }); } - const env = yield* resolver(input.providerInstanceId, input.env); + const env = yield* resolver(input.providerInstanceId, input.env, baseEnv, input.cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); return { ...input, env }; }, ); diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index bdc87902a63b2..f5da1235312d8 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -56,6 +56,13 @@ Both instances must use the same **CODEX_HOME path**. T3 Code prepares the share state in the shadow directory; do not populate it by copying your whole Codex home. +If an older shadow home reports that **SQLite recovery is required**, stop all +Codex processes using either home and back up both directories. Checkpoint or +recover any shadow WAL/journal data before removing the old shadow SQLite +symlinks. Do not delete real databases or sidecars to force startup; T3 Code does +not merge their data automatically. Orphaned or dangling SQLite sidecar links +also require manual recovery; T3 Code does not remove them automatically. + The shadow account needs its own `auth.json` file. If Codex uses an OS credential store, configure file storage for this setup. See [OpenAI's credential storage guide](https://learn.chatgpt.com/docs/auth#credential-storage). diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 066253602a49e..259fdf6a42add 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -34,6 +34,18 @@ function decodes(schema: S, input: unknown): boolean { } describe("TerminalProviderEnvironmentError", () => { + it("round-trips an explicit safe recovery detail without exposing its cause", () => { + const detail = "Codex SQLite recovery is required. Stop all Codex processes before recovery."; + const error = new TerminalProviderEnvironmentError({ + providerInstanceId: ProviderInstanceId.make("codex_work"), + cause: { detail: "secret backend unavailable" }, + detail, + }); + const decoded = decodeTerminalError(encodeTerminalError(error)); + expect(decoded.message).toBe(detail); + expect(decoded.message).not.toContain("secret backend unavailable"); + }); + it("round-trips its required cause without exposing it in the message", () => { const cause = { operation: "read-secret", detail: "secret backend unavailable" }; const error = new TerminalProviderEnvironmentError({ diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 7e9edf3a99c23..05e2331a9473e 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -315,10 +315,14 @@ export class TerminalProviderEnvironmentError extends Schema.TaggedError + Effect.gen(function* () { + const expected = NodeOS.userInfo().homedir; + const ambientHome = vi.spyOn(mutableOS, "homedir").mockReturnValue("/unused/ambient/home"); + NodeModule.syncBuiltinESMExports(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + ambientHome.mockRestore(); + NodeModule.syncBuiltinESMExports(); + }), + ); + const actual = yield* Effect.flatten(HostProcess.AccountHomeDirectory); + expect(actual).toBe(expected); + expect(ambientHome).not.toHaveBeenCalled(); + }), +); + +it.effect("keeps account home lookup failures typed with their exact cause", () => + Effect.gen(function* () { + const cause = Object.assign(new Error("private account lookup diagnostic"), { + code: "ERR_SYSTEM_ERROR", + }); + const userInfo = vi.spyOn(mutableOS, "userInfo").mockImplementation(() => { + throw cause; + }); + const ambientHome = vi.spyOn(mutableOS, "homedir").mockReturnValue("/unused/ambient/home"); + NodeModule.syncBuiltinESMExports(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + ambientHome.mockRestore(); + userInfo.mockRestore(); + NodeModule.syncBuiltinESMExports(); + }), + ); + const result = yield* Effect.flatten(HostProcess.AccountHomeDirectory).pipe(Effect.result); + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") return; + expect(result.failure).toMatchObject({ _tag: "AccountHomeDirectoryError" }); + expect(result.failure.cause).toBe(cause); + expect(result.failure.message).not.toContain(cause.message); + expect(ambientHome).not.toHaveBeenCalled(); + expect(userInfo).toHaveBeenCalledTimes(1); + }), +); diff --git a/packages/shared/src/HostProcess.ts b/packages/shared/src/HostProcess.ts index 79f6dfef15d7a..681a514bd19fe 100644 --- a/packages/shared/src/HostProcess.ts +++ b/packages/shared/src/HostProcess.ts @@ -1,9 +1,19 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as NodeDns from "node:dns"; import * as NodeOS from "node:os"; import * as NodeSea from "node:sea"; +export class AccountHomeDirectoryError extends Schema.TaggedError()( + "AccountHomeDirectoryError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not resolve the OS account home directory."; + } +} + export const Platform = Context.Reference("@t3tools/shared/HostProcess/Platform", { defaultValue: () => process.platform, }); @@ -26,6 +36,19 @@ export const HomeDirectory = Context.Reference( }, ); +// Account metadata ignores HOME, unlike homedir(), for native POSIX tools' +// fallback when the child omits HOME or sets it to an empty string. A missing +// passwd entry is a typed failure, not permission to inspect the ambient HOME. +export const AccountHomeDirectory = Context.Reference< + Effect.Effect +>("@t3tools/shared/HostProcess/AccountHomeDirectory", { + defaultValue: () => + Effect.try({ + try: () => NodeOS.userInfo().homedir, + catch: (cause) => new AccountHomeDirectoryError({ cause }), + }), +}); + export const Environment = Context.Reference( "@t3tools/shared/HostProcess/Environment", {