From d1812ddfe4cf77a83e609d87a44b205b4786fd95 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:02:48 +0200 Subject: [PATCH 1/7] fix(server): keep Codex SQLite state in one home --- .../Adapters/CodexAdapterV2.test.ts | 63 +++ .../Adapters/CodexAdapterV2.ts | 11 +- .../src/provider/CodexManagedRuntime.test.ts | 20 +- .../src/provider/CodexManagedRuntime.ts | 17 +- .../src/provider/Drivers/CodexDriver.test.ts | 31 +- .../src/provider/Drivers/CodexDriver.ts | 11 +- .../provider/Drivers/CodexHomeLayout.test.ts | 374 ++++++++++++++++++ .../src/provider/Drivers/CodexHomeLayout.ts | 140 ++++++- .../provider/Drivers/CodexManagedProvider.ts | 14 +- apps/server/src/serverSettings.test.ts | 1 + apps/server/src/terminal/Manager.test.ts | 197 +++++++++ apps/server/src/terminal/Manager.ts | 82 +++- docs/user/providers-codex.md | 7 + packages/contracts/src/terminal.test.ts | 12 + packages/contracts/src/terminal.ts | 6 +- 15 files changed, 943 insertions(+), 43 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 8f6d0edb7eb8a..ad7c14d1bd286 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"; @@ -784,6 +785,68 @@ describe("CodexAdapterV2 process spawning", () => { }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); + 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 7f55417ea2ac0..f5be0f5491f15 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -106,7 +106,9 @@ import { } from "../../provider/CodexMcpElicitation.ts"; import { materializeCodexShadowHome, + mergeCodexEnvironment, resolveCodexHomeLayout, + withCodexSqliteHome, } from "../../provider/Drivers/CodexHomeLayout.ts"; import { boundProviderEventForLogging, @@ -114,7 +116,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 { @@ -1616,7 +1617,7 @@ export const createCodexAdapterV2 = ( new ProviderAdapterDriverCreateError({ driver: CODEX_DRIVER_KIND, instanceId, - detail: "Failed to materialize the Codex shadow home.", + detail: cause.message, cause, }), ), @@ -1632,7 +1633,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..b1a88bfe83234 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,18 @@ 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.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 fd8f3d7a9cd93..9665cd6a11ef2 100644 --- a/apps/server/src/provider/CodexManagedRuntime.ts +++ b/apps/server/src/provider/CodexManagedRuntime.ts @@ -7,7 +7,8 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; -import { materializeCodexShadowHome } from "./Drivers/CodexHomeLayout.ts"; +import { materializeCodexShadowHome, withCodexSqliteHome } from "./Drivers/CodexHomeLayout.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; export interface CodexEffectiveRuntime { readonly config: CodexSettings; @@ -90,11 +91,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 7ac25a2e6bc13..d02f0eb826aa9 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -207,7 +207,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"), ); yield* Deferred.await(observedAccount); // Sessions launch the T3-installed Codex with the account's token, not ambient credentials. @@ -231,8 +231,37 @@ 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"); + // 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(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 09d5413f39d59..8103e111ae847 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -54,7 +54,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, @@ -70,7 +69,9 @@ import { import { codexContinuationIdentity, materializeCodexShadowHome, + mergeCodexEnvironment, resolveCodexHomeLayout, + withCodexSqliteHome, } from "./CodexHomeLayout.ts"; import { makeManagedCodexProvider } from "./CodexManagedProvider.ts"; import { codexUsageReader } from "./codexUsage.ts"; @@ -155,8 +156,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)( "materializes a shadow home with shared state links and private auth", () => @@ -275,6 +643,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-"); @@ -302,6 +671,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 9d4410da98438..74dba1b465f19 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"; @@ -30,6 +35,59 @@ const KNOWN_SHARED_DIRECTORIES = [ const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); +const 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"]); @@ -110,6 +168,19 @@ export class CodexShadowHomeEntryConflictError extends Schema.TaggedError()( + "CodexShadowHomeSqliteRecoveryRequiredError", + { + ...CodexShadowHomeContext, + entryName: Schema.String, + linkPath: Schema.String, + }, +) { + override get message(): string { + return `Codex SQLite recovery is required before switching '${this.linkPath}' to the shared home '${this.sharedHomePath}'. Stop all Codex processes using either home, back up and checkpoint or recover any shadow WAL/journal data, then remove only the old shadow SQLite symlinks. Do not delete real SQLite files or sidecars.`; + } +} + export class CodexShadowHomePrivateEntrySymlinkError extends Schema.TaggedError()( "CodexShadowHomePrivateEntrySymlinkError", { @@ -127,6 +198,7 @@ export const CodexShadowHomeError = Schema.Union([ CodexShadowHomeFileSystemError, CodexShadowHomePathConflictError, CodexShadowHomeEntryConflictError, + CodexShadowHomeSqliteRecoveryRequiredError, CodexShadowHomePrivateEntrySymlinkError, ]); export type CodexShadowHomeError = typeof CodexShadowHomeError.Type; @@ -184,27 +256,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, }), @@ -317,6 +389,52 @@ 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; + const linkPath = path.join(effectiveHomePath, 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, ) { @@ -333,6 +451,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({ @@ -372,7 +492,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); } } @@ -382,7 +506,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 374f177fd9cae..620eeb89de68f 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -17,12 +17,11 @@ 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 { codexContinuationIdentity, mergeCodexEnvironment } from "./CodexHomeLayout.ts"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { HttpClient } from "effect/http"; const DRIVER = ProviderDriverKind.make("codex"); @@ -40,7 +39,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 +162,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 +187,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.", })), ), ), @@ -237,7 +239,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu new ProviderDriverError({ driver: DRIVER, instanceId, - detail: "Failed to build Codex orchestration adapter.", + detail: cause.message, cause, }), ), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index da696c2bc9eb7..a970789913e29 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1898,6 +1898,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { serverSettings, path, rawProviderInstanceId: instanceId, + stateDir: serverConfig.stateDir, 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 d8639c8015bc5..fc23d780d8255 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"; @@ -2223,6 +2224,190 @@ 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) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: "/test/state", + 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 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, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: root, + 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(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("fails closed when a provider instance is missing", () => Effect.gen(function* () { const providerInstanceId = ProviderInstanceId.make("deleted_instance"); @@ -2272,6 +2457,7 @@ it.layer( serverSettings, path, rawProviderInstanceId: providerInstanceId, + stateDir: "/test/state", env: undefined, }).pipe(Effect.flip); @@ -2315,10 +2501,16 @@ it.layer( serverSettings, path, rawProviderInstanceId: "configured_home", + stateDir: "/test/state", 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({ @@ -2342,6 +2534,7 @@ it.layer( serverSettings, path, rawProviderInstanceId: "codex", + stateDir: "/test/state", env: undefined, }); @@ -2368,6 +2561,7 @@ it.layer( serverSettings, path, rawProviderInstanceId: "claudeAgent", + stateDir: "/test/state", env: undefined, }); @@ -2394,6 +2588,7 @@ it.layer( serverSettings, path, rawProviderInstanceId: "codex", + stateDir: "/test/state", env: { CODEX_HOME: "/inherited/codex-home" }, }); @@ -2411,6 +2606,7 @@ it.layer( serverSettings, path, rawProviderInstanceId: "codex_unknown", + stateDir: "/test/state", env: undefined, }).pipe(Effect.flip); @@ -2452,6 +2648,7 @@ it.layer( serverSettings, path, rawProviderInstanceId, + stateDir: "/test/state", env, }), }); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 4f79d07ff40f9..ab0d45b7e89d8 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -65,7 +65,14 @@ 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 { + 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"; @@ -1396,9 +1403,11 @@ interface TerminalManagerOptions { resolveProviderInstanceEnvironment?: ( providerInstanceId: string, env: Record | undefined, + inheritedEnvironment: NodeJS.ProcessEnv, ) => Effect.Effect< Record, - TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError, + FileSystem.FileSystem >; } @@ -1408,7 +1417,9 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( readonly serverSettings: ServerSettings.ServerSettingsService["Service"]; readonly path: Path.Path; readonly rawProviderInstanceId: string; + readonly stateDir: string; readonly env: Record | undefined; + readonly inheritedEnvironment?: NodeJS.ProcessEnv; }) { const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId); const settings = yield* input.serverSettings.getSettings.pipe( @@ -1419,13 +1430,50 @@ 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", + ), + ); + let resolved = yield* instance.driver === "codex" + ? mergeCodexEnvironment(instance.environment, { + ...normalizeCodexSqliteEnvironment(inheritedSqlite, platform), + ...normalizeCodexSqliteEnvironment(input.env ?? {}, 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; + yield* inspectCodexShadowSqlite(recoveryLayout).pipe( + Effect.provideService(Path.Path, input.path), + Effect.mapError( + (cause) => + new TerminalProviderEnvironmentError({ + providerInstanceId, + cause, + ...(cause._tag === "CodexShadowHomeSqliteRecoveryRequiredError" + ? { detail: cause.message } + : {}), + }), + ), + ); + resolved = withCodexSqliteHome(layout, resolved, platform); if (layout.effectiveHomePath) resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; } @@ -1445,7 +1493,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; @@ -1453,13 +1502,20 @@ 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, + ) => + resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir, + env, + inheritedEnvironment, + }), ); return yield* makeWithOptions({ logsDir: terminalLogsDir, @@ -1512,7 +1568,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).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 e8772e4d1ae3d..34dccbbce9c8a 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -322,10 +322,14 @@ export class TerminalProviderEnvironmentError extends Schema.TaggedError Date: Thu, 15 Oct 2026 14:50:37 +0200 Subject: [PATCH 2/7] fix(server): keep Codex recovery details path-free --- .../Adapters/CodexAdapterV2.test.ts | 52 +++++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 6 ++- .../src/provider/CodexManagedRuntime.test.ts | 25 +++++++++ .../src/provider/CodexManagedRuntime.ts | 12 ++++- .../src/provider/Drivers/CodexDriver.test.ts | 10 ++++ .../src/provider/Drivers/CodexDriver.ts | 12 ++++- .../src/provider/Drivers/CodexHomeLayout.ts | 5 +- .../provider/Drivers/CodexManagedProvider.ts | 13 ++++- apps/server/src/terminal/Manager.test.ts | 8 +++ apps/server/src/terminal/Manager.ts | 3 +- 10 files changed, 137 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index ad7c14d1bd286..d87b3f6f6ecd4 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -785,6 +785,58 @@ 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) => diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index f5be0f5491f15..f4b3551a20a14 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -105,6 +105,7 @@ import { toMcpElicitationResponse, } from "../../provider/CodexMcpElicitation.ts"; import { + CODEX_SQLITE_RECOVERY_GUIDANCE, materializeCodexShadowHome, mergeCodexEnvironment, resolveCodexHomeLayout, @@ -1617,7 +1618,10 @@ export const createCodexAdapterV2 = ( new ProviderAdapterDriverCreateError({ driver: CODEX_DRIVER_KIND, instanceId, - detail: cause.message, + detail: + cause._tag === "CodexShadowHomeSqliteRecoveryRequiredError" + ? CODEX_SQLITE_RECOVERY_GUIDANCE + : "Failed to prepare Codex shadow home.", cause, }), ), diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index b1a88bfe83234..64439c91d77b7 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -185,6 +185,31 @@ it.effect.each( 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); diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts index 9665cd6a11ef2..c3fd4eac1816f 100644 --- a/apps/server/src/provider/CodexManagedRuntime.ts +++ b/apps/server/src/provider/CodexManagedRuntime.ts @@ -7,7 +7,11 @@ 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, withCodexSqliteHome } 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 { @@ -76,7 +80,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, }), ), ); diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index d02f0eb826aa9..5a586cdb9bd4a 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -243,6 +243,8 @@ it.layer(layerTest)("CodexDriver", (it) => { 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({ @@ -259,6 +261,14 @@ it.layer(layerTest)("CodexDriver", (it) => { }).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"), ); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 8103e111ae847..9c76dc64cf80e 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -67,6 +67,8 @@ import { type ProviderSnapshotSettings, } from "@t3tools/provider-core/server/snapshotSettings"; import { + CODEX_SQLITE_RECOVERY_GUIDANCE, + CodexShadowHomeSqliteRecoveryRequiredError, codexContinuationIdentity, materializeCodexShadowHome, mergeCodexEnvironment, @@ -82,6 +84,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); +const isSqliteRecoveryRequired = Schema.is(CodexShadowHomeSqliteRecoveryRequiredError); // The standalone installer lays out `/packages/standalone/…`; // CODEX_HOME is not always `~/.codex`. function isCodexStandaloneCommandPath(commandPath: string): boolean { @@ -176,7 +179,10 @@ export const CodexDriver: ProviderDriver()( "CodexShadowHomeSqliteRecoveryRequiredError", { @@ -177,7 +180,7 @@ export class CodexShadowHomeSqliteRecoveryRequiredError extends Schema.TaggedErr }, ) { override get message(): string { - return `Codex SQLite recovery is required before switching '${this.linkPath}' to the shared home '${this.sharedHomePath}'. Stop all Codex processes using either home, back up and checkpoint or recover any shadow WAL/journal data, then remove only the old shadow SQLite symlinks. Do not delete real SQLite files or sidecars.`; + return CODEX_SQLITE_RECOVERY_GUIDANCE; } } diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index 620eeb89de68f..708ed639e467f 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"; @@ -21,10 +22,16 @@ import { type ProviderDriverCreateInput, type ProviderInstance, } from "@t3tools/provider-core/server/driver"; -import { codexContinuationIdentity, mergeCodexEnvironment } 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, @@ -239,7 +246,9 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu new ProviderDriverError({ driver: DRIVER, instanceId, - detail: cause.message, + detail: isSqliteRecoveryRequired(cause.cause) + ? CODEX_SQLITE_RECOVERY_GUIDANCE + : "Failed to build Codex orchestration adapter.", cause, }), ), diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index fc23d780d8255..a70fe48843477 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2394,6 +2394,14 @@ it.layer( 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); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index ab0d45b7e89d8..aee954c507e1c 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -67,6 +67,7 @@ import * as ServerConfig from "../config.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { resolveManagedCodexHomeLayout } from "../provider/CodexManagedHome.ts"; import { + CODEX_SQLITE_RECOVERY_GUIDANCE, inspectCodexShadowSqlite, mergeCodexEnvironment, normalizeCodexSqliteEnvironment, @@ -1468,7 +1469,7 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( providerInstanceId, cause, ...(cause._tag === "CodexShadowHomeSqliteRecoveryRequiredError" - ? { detail: cause.message } + ? { detail: CODEX_SQLITE_RECOVERY_GUIDANCE } : {}), }), ), From b557e6513c1e75da302b9103d109841ed82302a2 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:33:56 +0200 Subject: [PATCH 3/7] fix(server): inspect the selected Codex terminal home --- .../src/provider/Drivers/CodexHomeLayout.ts | 4 +- apps/server/src/serverSettings.test.ts | 1 + apps/server/src/terminal/Manager.test.ts | 493 +++++++++++++++++- apps/server/src/terminal/Manager.ts | 79 ++- 4 files changed, 560 insertions(+), 17 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index bc34f2e96504a..f25ad282b6080 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -419,7 +419,9 @@ export const inspectCodexShadowSqlite = Effect.fn("inspectCodexShadowSqlite")(fu // links for operator recovery instead of giving launch paths different rules. for (const entryName of shadowEntryNames) { if (!SQLITE_ENTRY_NAME.test(entryName)) continue; - const linkPath = path.join(effectiveHomePath, entryName); + // 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, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index a970789913e29..0da1f11bee4b3 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1899,6 +1899,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { 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 a70fe48843477..c1631f63c70ee 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2280,12 +2280,18 @@ it.layer( const providerInstanceId = ProviderInstanceId.make("codex_sqlite"); const { manager, ptyAdapter } = yield* createManager(5, { env: { [inheritedKey]: "/inherited/sqlite", T3CODE_SECRET: "server-only" }, - resolveProviderInstanceEnvironment: (rawProviderInstanceId, env, inheritedEnvironment) => + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => TerminalManager.resolveProviderInstanceTerminalEnvironment({ serverSettings, path, rawProviderInstanceId, stateDir: "/test/state", + cwd, env, inheritedEnvironment, }), @@ -2329,6 +2335,474 @@ it.layer( ), ); + for (const platform of ["linux", "win32"] 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})`, + () => + 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 actualHome = path.join(root, "legacy"); + const cwd = path.join(root, "worktree"); + for (const directory of [sharedHome, actualHome, cwd]) { + yield* fs.makeDirectory(directory); + } + 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" : "CODEX_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: key, value: actualHome, sensitive: false }] + : [], + }, + }, + }), + ), + ); + const { manager, ptyAdapter } = yield* createManager(5, { + env: source === "inherited" ? { [key]: actualHome } : {}, + resolveProviderInstanceEnvironment: ( + rawProviderInstanceId, + env, + inheritedEnvironment, + cwd, + ) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir: root, + env, + inheritedEnvironment, + cwd, + }), + }); + const input = openInput({ + providerInstanceId, + cwd, + env: source === "request" ? { [key]: actualHome } : undefined, + }); + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* Effect.result( + 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), + ); + 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 ["cwd-symlink", "home-symlink", "absolute-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"; + let lexicalHome = path.join(work, "legacy"); + const physicalHome = path.join(root, "physical", "legacy"); + if (mode === "cwd-symlink") { + cwd = path.join(root, "alias"); + yield* fs.symlink(child, cwd); + home = "../legacy"; + lexicalHome = path.join(root, "legacy"); + } else { + yield* fs.symlink(child, path.join(work, "hop")); + if (mode === "absolute-home-symlink") home = `${work}/hop/../legacy`; + } + yield* fs.makeDirectory(lexicalHome); + yield* fs.makeDirectory(physicalHome); + const database = path.join(root, "shared.sqlite"); + yield* fs.writeFileString(database, "preserved database"); + const unsafeHome = physicalUnsafe ? physicalHome : lexicalHome; + 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 input = openInput({ providerInstanceId, cwd, env: { CODEX_HOME: home } }); + const lookupHome = path.isAbsolute(home) ? home : `${cwd}/${home}`; + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* manager.open(input).pipe(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.CODEX_HOME).toBe(home); + } + 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 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 to the running shell must not launch again. A new launch + // must still refuse links introduced since the original inspection. + yield* fs.makeDirectory(safeHome); + const selectedLink = path.join(safeHome, "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)( @@ -2380,12 +2854,14 @@ it.layer( rawProviderInstanceId, env, inheritedEnvironment, + cwd, ) => TerminalManager.resolveProviderInstanceTerminalEnvironment({ serverSettings, path, rawProviderInstanceId, stateDir: root, + cwd, env, inheritedEnvironment, }), @@ -2466,6 +2942,7 @@ it.layer( path, rawProviderInstanceId: providerInstanceId, stateDir: "/test/state", + cwd: process.cwd(), env: undefined, }).pipe(Effect.flip); @@ -2510,6 +2987,7 @@ it.layer( path, rawProviderInstanceId: "configured_home", stateDir: "/test/state", + cwd: process.cwd(), env: undefined, }); @@ -2543,6 +3021,7 @@ it.layer( path, rawProviderInstanceId: "codex", stateDir: "/test/state", + cwd: process.cwd(), env: undefined, }); @@ -2570,6 +3049,7 @@ it.layer( path, rawProviderInstanceId: "claudeAgent", stateDir: "/test/state", + cwd: process.cwd(), env: undefined, }); @@ -2597,6 +3077,7 @@ it.layer( path, rawProviderInstanceId: "codex", stateDir: "/test/state", + cwd: process.cwd(), env: { CODEX_HOME: "/inherited/codex-home" }, }); @@ -2615,6 +3096,7 @@ it.layer( path, rawProviderInstanceId: "codex_unknown", stateDir: "/test/state", + cwd: process.cwd(), env: undefined, }).pipe(Effect.flip); @@ -2651,12 +3133,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 aee954c507e1c..29e47939e4e6d 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1361,11 +1361,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))); } @@ -1405,6 +1405,7 @@ interface TerminalManagerOptions { providerInstanceId: string, env: Record | undefined, inheritedEnvironment: NodeJS.ProcessEnv, + cwd: string, ) => Effect.Effect< Record, TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError, @@ -1419,6 +1420,7 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( readonly path: Path.Path; readonly rawProviderInstanceId: string; readonly stateDir: string; + readonly cwd: string; readonly env: Record | undefined; readonly inheritedEnvironment?: NodeJS.ProcessEnv; }) { @@ -1439,11 +1441,24 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( : 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, { - ...normalizeCodexSqliteEnvironment(inheritedSqlite, platform), - ...normalizeCodexSqliteEnvironment(input.env ?? {}, platform), - }) + ? 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 ?? {}); @@ -1461,7 +1476,44 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( config.value, ).pipe(Effect.provideService(Path.Path, input.path)) : layout; - yield* inspectCodexShadowSqlite(recoveryLayout).pipe( + 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, + ); + const terminalHome = Object.entries(terminalEnvironment).find( + ([key]) => codexHomeKey(key) === "CODEX_HOME", + )?.[1]; + // POSIX resolves symlinks before subsequent '..' components. Keep the + // lookup spelling intact; 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; + const inspectionLayouts = [recoveryLayout]; + if ( + terminalHomePath !== undefined && + (recoveryLayout.mode !== "authOverlay" || + terminalHomePath !== recoveryLayout.effectiveHomePath) + ) { + inspectionLayouts.push({ + ...layout, + mode: "authOverlay", + effectiveHomePath: terminalHomePath, + }); + } + yield* Effect.forEach(inspectionLayouts, inspectCodexShadowSqlite).pipe( Effect.provideService(Path.Path, input.path), Effect.mapError( (cause) => @@ -1474,9 +1526,6 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( }), ), ); - resolved = withCodexSqliteHome(layout, resolved, platform); - if (layout.effectiveHomePath) - resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; } } else if (instance.driver === "claudeAgent") { const config = decodeClaudeSettings(instance.config ?? {}); @@ -1508,6 +1557,7 @@ export const make = Effect.fn("TerminalManager.make")(function* () { rawProviderInstanceId: string, env: Record | undefined, inheritedEnvironment: NodeJS.ProcessEnv, + cwd: string, ) => resolveProviderInstanceTerminalEnvironment({ serverSettings, @@ -1516,6 +1566,7 @@ export const make = Effect.fn("TerminalManager.make")(function* () { stateDir, env, inheritedEnvironment, + cwd, }), ); return yield* makeWithOptions({ @@ -1556,7 +1607,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, @@ -1569,7 +1620,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), }); } - const env = yield* resolver(input.providerInstanceId, input.env, baseEnv).pipe( + const env = yield* resolver(input.providerInstanceId, input.env, baseEnv, input.cwd).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), ); return { ...input, env }; From 24b02f7a0bb711e9d57be92a48b8c8727ab6130f Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:30:33 +0200 Subject: [PATCH 4/7] fix(server): keep direct shared Codex homes native --- apps/server/src/terminal/Manager.test.ts | 168 +++++++++++++++++++++-- apps/server/src/terminal/Manager.ts | 42 ++++-- 2 files changed, 191 insertions(+), 19 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index c1631f63c70ee..a46669d0c43e7 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2451,7 +2451,147 @@ it.layer( } } - for (const mode of ["cwd-symlink", "home-symlink", "absolute-home-symlink"] as const) { + for (const mode of [ + "configured", + "implicit", + "relative", + "directory-alias", + "alias-no-id", + "alias-zero-id", + "unsafe-generated", + ] as const) { + const isAlias = + mode === "directory-alias" || mode === "alias-no-id" || mode === "alias-zero-id"; + it.effect.skipIf( + !symlinksSupported || (isAlias && HostProcess.Platform.defaultValue() === "win32"), + )(`allows the known direct shared home 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 (mode === "unsafe-generated") { + 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(shared, terminalHome); + } + 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(0), + })), + ), + } + : 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 input = openInput({ providerInstanceId, cwd, env: { CODEX_HOME: terminalHome } }); + const blocked = + mode === "unsafe-generated" || mode === "alias-no-id" || mode === "alias-zero-id"; + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* manager + .open(input) + .pipe(Effect.provideService(HostProcess.HomeDirectory, home), Effect.result); + expect(result._tag).toBe(blocked ? "Failure" : "Success"); + if (blocked && result._tag === "Failure") { + expect(result.failure).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + cause: { + _tag: "CodexShadowHomeSqliteRecoveryRequiredError", + linkPath: + mode === "unsafe-generated" + ? generatedLink + : path.join(terminalHome, "state_5.sqlite"), + }, + }); + expect(result.failure.message).not.toContain(root); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + if (mode === "unsafe-generated") { + 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(terminalHome); + 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", + ); + } + }), + ); + } + + for (const mode of [ + "cwd-symlink", + "home-symlink", + "absolute-home-symlink", + "shared-decoy", + ] as const) { for (const physicalUnsafe of [true, false]) { it.effect.skipIf(!symlinksSupported || HostProcess.Platform.defaultValue() === "win32")( `preserves physical terminal home selection (${mode}, unsafe=${physicalUnsafe})`, @@ -2467,12 +2607,14 @@ it.layer( let cwd = work; let home = "hop/../legacy"; let lexicalHome = path.join(work, "legacy"); - const physicalHome = path.join(root, "physical", "legacy"); - if (mode === "cwd-symlink") { + const entry = mode === "shared-decoy" ? ".codex" : "legacy"; + const fixtureHome = mode === "shared-decoy" ? root : path.join(root, "home"); + const physicalHome = path.join(root, "physical", entry); + if (mode === "cwd-symlink" || mode === "shared-decoy") { cwd = path.join(root, "alias"); yield* fs.symlink(child, cwd); - home = "../legacy"; - lexicalHome = path.join(root, "legacy"); + 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`; @@ -2524,7 +2666,9 @@ it.layer( const input = openInput({ providerInstanceId, cwd, env: { CODEX_HOME: home } }); const lookupHome = path.isAbsolute(home) ? home : `${cwd}/${home}`; for (let attempt = 0; attempt < 2; attempt += 1) { - const result = yield* manager.open(input).pipe(Effect.result); + 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({ @@ -2784,10 +2928,14 @@ it.layer( expect(yield* fs.readLink(linkPath)).toBe(target); expect(yield* fs.exists(target)).toBe(false); - // Attaching to the running shell must not launch again. A new launch - // must still refuse links introduced since the original inspection. - yield* fs.makeDirectory(safeHome); - const selectedLink = path.join(safeHome, "state_5.sqlite-shm"); + // 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); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 29e47939e4e6d..dcaf6ca6f6f01 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1501,19 +1501,43 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( ? terminalHome : `${input.cwd}${input.cwd.endsWith(input.path.sep) ? "" : input.path.sep}${terminalHome}` : undefined; - const inspectionLayouts = [recoveryLayout]; - if ( - terminalHomePath !== undefined && - (recoveryLayout.mode !== "authOverlay" || - terminalHomePath !== recoveryLayout.effectiveHomePath) - ) { - inspectionLayouts.push({ + yield* Effect.gen(function* () { + yield* inspectCodexShadowSqlite(recoveryLayout); + 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, }); - } - yield* Effect.forEach(inspectionLayouts, inspectCodexShadowSqlite).pipe( + }).pipe( Effect.provideService(Path.Path, input.path), Effect.mapError( (cause) => From 2d5265494d93fcca807046d41e6c541d80ba0f5c Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:52:05 +0200 Subject: [PATCH 5/7] fix(server): inspect native Codex terminal homes --- apps/server/src/terminal/Manager.test.ts | 261 ++++++++++++++++++++--- apps/server/src/terminal/Manager.ts | 37 ++-- packages/shared/src/HostProcess.ts | 9 + 3 files changed, 259 insertions(+), 48 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index e5ee90beb127d..efddd887e4fb0 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2303,21 +2303,27 @@ it.layer( ), ); - for (const platform of ["linux", "win32"] as const) { + 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})`, + `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 actualHome = path.join(root, "legacy"); + 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); + yield* fs.makeDirectory(directory, { recursive: true }); } const providerInstanceId = ProviderInstanceId.make("codex_actual_home"); const generatedHome = path.join( @@ -2343,7 +2349,7 @@ it.layer( sidecar, ); } - const key = platform === "win32" ? "codex_home" : "CODEX_HOME"; + const key = platform === "win32" ? "codex_home" : variable; const serverSettings = yield* ServerSettings.ServerSettingsService.pipe( Effect.provide( ServerSettings.layerTest({ @@ -2352,16 +2358,14 @@ it.layer( driver: ProviderDriverKind.make("codex"), config: { setupMode: "managed", homePath: "", shadowHomePath: "" }, environment: - source === "provider" - ? [{ name: key, value: actualHome, sensitive: false }] - : [], + source === "provider" ? [{ name: key, value, sensitive: false }] : [], }, }, }), ), ); const { manager, ptyAdapter } = yield* createManager(5, { - env: source === "inherited" ? { [key]: actualHome } : {}, + env: source === "inherited" ? { [key]: value } : {}, resolveProviderInstanceEnvironment: ( rawProviderInstanceId, env, @@ -2381,15 +2385,22 @@ it.layer( const input = openInput({ providerInstanceId, cwd, - env: source === "request" ? { [key]: actualHome } : undefined, + env: source === "request" ? { [key]: value } : undefined, }); for (let attempt = 0; attempt < 2; attempt += 1) { - const result = yield* Effect.result( + 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), + : manager.open(input).pipe(Effect.asVoid); + const result = yield* operation.pipe( + Effect.provideService(HostProcess.HomeDirectory, path.join(root, "server-home")), + Effect.provideService( + HostProcess.AccountHomeDirectory, + path.join(root, "account"), + ), + Effect.result, ); expect(result._tag).toBe("Failure"); if (result._tag !== "Failure") return; @@ -2427,12 +2438,29 @@ it.layer( "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 === "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"), - )(`allows the known direct shared home without bypassing generated recovery (${mode})`, () => + )(`preserves native terminal selection without bypassing generated recovery (${mode})`, () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -2450,7 +2478,7 @@ it.layer( ); const generated = path.join(root, "providers", "codex", providerInstanceId, "shadow"); const generatedLink = path.join(generated, "state_5.sqlite"); - if (mode === "unsafe-generated") { + 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"); @@ -2458,7 +2486,14 @@ it.layer( let terminalHome = mode === "relative" ? "." : shared; if (isAlias) { terminalHome = path.join(root, "shared-alias"); - yield* fs.symlink(shared, terminalHome); + 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( @@ -2485,7 +2520,7 @@ it.layer( fs.stat(candidate).pipe( Effect.map((info) => ({ ...info, - ino: mode === "alias-no-id" ? Option.none() : Option.some(0), + ino: mode === "alias-no-id" ? Option.none() : Option.some(0n), })), ), } @@ -2508,28 +2543,60 @@ it.layer( inheritedEnvironment, }).pipe(Effect.provideService(FileSystem.FileSystem, resolverFs)), }); - const input = openInput({ providerInstanceId, cwd, env: { CODEX_HOME: terminalHome } }); + 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 = - mode === "unsafe-generated" || mode === "alias-no-id" || mode === "alias-zero-id"; + 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.result); + .pipe( + Effect.provideService(HostProcess.HomeDirectory, home), + Effect.provideService(HostProcess.AccountHomeDirectory, 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: - mode === "unsafe-generated" - ? generatedLink + 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 (mode === "unsafe-generated") { + if (unsafeGenerated) { expect(yield* fs.readLink(generatedLink)).toBe(database); expect(yield* fs.readFileString(path.join(generated, "state_5.sqlite-wal"))).toBe( "private WAL", @@ -2540,7 +2607,9 @@ it.layer( } else if (!blocked) { expect(ptyAdapter.spawnInputs).toHaveLength(1); expect(ptyAdapter.spawnInputs[0]?.cwd).toBe(cwd); - expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe(terminalHome); + 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); } @@ -2549,6 +2618,10 @@ it.layer( 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"); + } } }), ); @@ -2559,6 +2632,8 @@ it.layer( "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")( @@ -2574,11 +2649,19 @@ it.layer( yield* fs.makeDirectory(child, { recursive: true }); let cwd = work; let home = "hop/../legacy"; - let lexicalHome = path.join(work, "legacy"); - const entry = mode === "shared-decoy" ? ".codex" : "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"); - const physicalHome = path.join(root, "physical", entry); - if (mode === "cwd-symlink" || mode === "shared-decoy") { + 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}`; @@ -2586,12 +2669,15 @@ it.layer( } 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); - yield* fs.makeDirectory(physicalHome); + 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 unsafeHome = physicalUnsafe ? physicalHome : lexicalHome; + 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"]) { @@ -2631,8 +2717,12 @@ it.layer( inheritedEnvironment, }), }); - const input = openInput({ providerInstanceId, cwd, env: { CODEX_HOME: home } }); - const lookupHome = path.isAbsolute(home) ? home : `${cwd}/${home}`; + 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) @@ -2652,7 +2742,9 @@ it.layer( } else if (!physicalUnsafe) { expect(ptyAdapter.spawnInputs).toHaveLength(1); expect(ptyAdapter.spawnInputs[0]?.cwd).toBe(cwd); - expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe(home); + 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"); @@ -2668,6 +2760,105 @@ it.layer( } } + for (const source of ["inherited", "request", "provider", "empty", "unset"] 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 home = path.join(root, "terminal-home"); + const codexHome = path.join(home, ".codex"); + const accountFallback = source === "empty" || source === "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: + source === "provider" + ? [{ name: "HOME", value: home, sensitive: false }] + : [], + }, + }, + }), + ), + ); + const { manager, ptyAdapter } = yield* createManager(5, { + env: + source === "unset" + ? {} + : { HOME: source === "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, + accountFallback ? home : path.join(root, "account"), + ), + ), + }); + const input = openInput({ + providerInstanceId, + cwd: root, + env: + source === "empty" ? { HOME: "" } : source === "request" ? { HOME: home } : undefined, + }); + yield* manager.open(input); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.spawnInputs[0]?.env.HOME).toBe( + source === "empty" ? "" : source === "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)( diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index baf4b93151ee1..06a7319b62a55 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1411,21 +1411,32 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( platform, yield* HostProcess.HomeDirectory, ); - const terminalHome = Object.entries(terminalEnvironment).find( - ([key]) => codexHomeKey(key) === "CODEX_HOME", - )?.[1]; - // POSIX resolves symlinks before subsequent '..' components. Keep the - // lookup spelling intact; 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; 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* HostProcess.AccountHomeDirectory) + : undefined; + // Native HOME goes through Codex's lexical AbsolutePathBuf normalization; + // explicit CODEX_HOME is canonicalized first and must keep filesystem semantics. + const terminalHome = + explicitHome || + (nativeHome ? input.path.normalize(`${nativeHome}${input.path.sep}.codex`) : 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" && diff --git a/packages/shared/src/HostProcess.ts b/packages/shared/src/HostProcess.ts index 79f6dfef15d7a..51ca66ac352b9 100644 --- a/packages/shared/src/HostProcess.ts +++ b/packages/shared/src/HostProcess.ts @@ -26,6 +26,15 @@ 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. +export const AccountHomeDirectory = Context.Reference( + "@t3tools/shared/HostProcess/AccountHomeDirectory", + { + defaultValue: () => NodeOS.userInfo().homedir, + }, +); + export const Environment = Context.Reference( "@t3tools/shared/HostProcess/Environment", { From 986c1630420e781f3067d689ec00c49e3382ac82 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:23:28 +0200 Subject: [PATCH 6/7] fix(server): match native Codex home expansion --- apps/server/src/terminal/Manager.test.ts | 133 +++++++++++++++++++++-- apps/server/src/terminal/Manager.ts | 8 +- 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index efddd887e4fb0..53c9cfcba580a 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2760,7 +2760,114 @@ it.layer( } } - for (const source of ["inherited", "request", "provider", "empty", "unset"] as const) { + 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`, () => @@ -2768,9 +2875,13 @@ it.layer( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-terminal-native-home-" }); - const home = path.join(root, "terminal-home"); - const codexHome = path.join(home, ".codex"); - const accountFallback = source === "empty" || source === "unset"; + 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( @@ -2781,7 +2892,7 @@ it.layer( driver: ProviderDriverKind.make("codex"), config: { setupMode: "managed", homePath: "", shadowHomePath: "" }, environment: - source === "provider" + homeSource === "provider" ? [{ name: "HOME", value: home, sensitive: false }] : [], }, @@ -2791,9 +2902,9 @@ it.layer( ); const { manager, ptyAdapter } = yield* createManager(5, { env: - source === "unset" + homeSource === "unset" ? {} - : { HOME: source === "inherited" ? home : path.join(root, "server-home") }, + : { HOME: homeSource === "inherited" ? home : path.join(root, "server-home") }, resolveProviderInstanceEnvironment: ( rawProviderInstanceId, env, @@ -2820,12 +2931,16 @@ it.layer( providerInstanceId, cwd: root, env: - source === "empty" ? { HOME: "" } : source === "request" ? { HOME: home } : undefined, + homeSource === "empty" + ? { HOME: "" } + : homeSource === "request" + ? { HOME: home } + : undefined, }); yield* manager.open(input); expect(ptyAdapter.spawnInputs).toHaveLength(1); expect(ptyAdapter.spawnInputs[0]?.env.HOME).toBe( - source === "empty" ? "" : source === "unset" ? undefined : home, + homeSource === "empty" ? "" : homeSource === "unset" ? undefined : home, ); expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBeUndefined(); expect(ptyAdapter.spawnInputs[0]?.env.CODEX_SQLITE_HOME).toBeUndefined(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 06a7319b62a55..21ca79fd69ee9 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1422,11 +1422,13 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( !explicitHome && platform !== "win32" ? terminalEnvironment.HOME || (yield* HostProcess.AccountHomeDirectory) : undefined; - // Native HOME goes through Codex's lexical AbsolutePathBuf normalization; - // explicit CODEX_HOME is canonicalized first and must keep filesystem semantics. + // 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}${input.path.sep}.codex`) : undefined); + (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. From 212daed7e5d8455488bf4c1a7f3b7c265b203bcc Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:17:18 +0200 Subject: [PATCH 7/7] fix(server): contain Codex account-home lookup errors --- apps/server/src/terminal/Manager.test.ts | 281 ++++++++++++++++++++++- apps/server/src/terminal/Manager.ts | 2 +- packages/shared/src/HostProcess.test.ts | 55 +++++ packages/shared/src/HostProcess.ts | 28 ++- 4 files changed, 354 insertions(+), 12 deletions(-) create mode 100644 packages/shared/src/HostProcess.test.ts diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 53c9cfcba580a..f2ca0c56045a6 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -35,7 +35,9 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/process"; -import { expect } from "vite-plus/test"; +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"; import * as ServerConfig from "../config.ts"; @@ -45,6 +47,9 @@ import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; +// 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")<{ @@ -2398,7 +2403,7 @@ it.layer( Effect.provideService(HostProcess.HomeDirectory, path.join(root, "server-home")), Effect.provideService( HostProcess.AccountHomeDirectory, - path.join(root, "account"), + Effect.succeed(path.join(root, "account")), ), Effect.result, ); @@ -2570,7 +2575,7 @@ it.layer( .open(input) .pipe( Effect.provideService(HostProcess.HomeDirectory, home), - Effect.provideService(HostProcess.AccountHomeDirectory, home), + Effect.provideService(HostProcess.AccountHomeDirectory, Effect.succeed(home)), Effect.provideService( HostProcess.Platform, mode.startsWith("native-") @@ -2923,7 +2928,7 @@ it.layer( Effect.provideService(HostProcess.HomeDirectory, path.join(root, "server-home")), Effect.provideService( HostProcess.AccountHomeDirectory, - accountFallback ? home : path.join(root, "account"), + Effect.succeed(accountFallback ? home : path.join(root, "account")), ), ), }); @@ -3314,6 +3319,274 @@ it.layer( } } + 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"); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 21ca79fd69ee9..f31ba927c6f62 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1420,7 +1420,7 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( // Windows uses the Known Folder API, not HOME/USERPROFILE overrides. const nativeHome = !explicitHome && platform !== "win32" - ? terminalEnvironment.HOME || (yield* HostProcess.AccountHomeDirectory) + ? 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. diff --git a/packages/shared/src/HostProcess.test.ts b/packages/shared/src/HostProcess.test.ts new file mode 100644 index 0000000000000..0562ba2899465 --- /dev/null +++ b/packages/shared/src/HostProcess.test.ts @@ -0,0 +1,55 @@ +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; +import { expect, vi } from "vite-plus/test"; + +import * as HostProcess from "./HostProcess.ts"; + +// Builtin namespace exports are read-only; synchronize mutable OS spies into them. +const mutableOS: typeof NodeOS = NodeModule.createRequire(import.meta.url)("node:os"); + +it.effect("resolves the account home without consulting the ambient home", () => + 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 51ca66ac352b9..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, }); @@ -27,13 +37,17 @@ 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. -export const AccountHomeDirectory = Context.Reference( - "@t3tools/shared/HostProcess/AccountHomeDirectory", - { - defaultValue: () => NodeOS.userInfo().homedir, - }, -); +// 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",