From be87944f5335b9de94ba016450c3847f75632722 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:13:48 +0000 Subject: [PATCH 1/6] test(server): cover Codex terminal SQLite preflight --- apps/server/src/terminal/Manager.test.ts | 480 +++++++++++++++++++++++ packages/contracts/src/terminal.test.ts | 18 + 2 files changed, 498 insertions(+) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index c864ba28e5c0b..7bffc4db9423e 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2,6 +2,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { DEFAULT_TERMINAL_ID, + type CodexSettings, + type ProviderInstanceEnvironment, type TerminalAttachStreamEvent, type TerminalEvent, type TerminalMetadataStreamEvent, @@ -36,6 +38,11 @@ import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/process"; +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as PortScanner from "../preview/PortScanner.ts"; +import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; import { expect, vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -328,6 +335,96 @@ const createManager = ( }), ); +const mutableOS: typeof NodeOS = NodeModule.createRequire(import.meta.url)("node:os"); + +const createCodexTerminal = Effect.fn("test.createCodexTerminal")(function* ( + root: string, + options: { + config?: Partial; + inherited?: Record; + environment?: ProviderInstanceEnvironment; + } = {}, +) { + const path = yield* Path.Path; + const home = path.join(root, "home"); + const providerInstanceId = ProviderInstanceId.make("codex_sqlite"); + const account = vi.spyOn(mutableOS, "userInfo").mockReturnValue({ + homedir: path.join(root, "account"), + username: "fixture", + uid: 1000, + gid: 1000, + shell: "/bin/bash", + }); + NodeModule.syncBuiltinESMExports(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + account.mockRestore(); + NodeModule.syncBuiltinESMExports(); + vi.unstubAllEnvs(); + }), + ); + for (const key of Object.keys(process.env)) { + if (["CODEX_HOME", "CODEX_SQLITE_HOME", "HOME", "USERPROFILE"].includes(key.toUpperCase())) + vi.stubEnv(key, undefined); + } + vi.stubEnv("HOME", home); + vi.stubEnv("SHELL", "/bin/bash"); + for (const [key, value] of Object.entries(options.inherited ?? {})) vi.stubEnv(key, value); + const ptyAdapter = new FakePtyAdapter(); + const config = yield* ServerConfig.ServerConfig.pipe( + Effect.provide(ServerConfig.layerTest(root, path.join(root, "t3"))), + ); + const manager = yield* TerminalManager.make().pipe( + Effect.provideService(HostProcess.HomeDirectory, home), + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.provideService(PtyAdapter.PtyAdapter, ptyAdapter), + Effect.provide( + Layer.mock(PortScanner.PortDiscovery)({ + registerTerminalProcesses: () => Effect.void, + unregisterTerminal: () => Effect.void, + }), + ), + Effect.provide(NativeTelemetryClient.layerTest({ processTable: Effect.succeed([]) })), + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "managed", homePath: "", shadowHomePath: "", ...options.config }, + environment: options.environment ?? [], + }, + }, + }), + ), + ); + return { + manager: { + open: (input: TerminalOpenInput) => + manager.open(input).pipe(Effect.provideService(HostProcess.HomeDirectory, home)), + restart: (input: TerminalRestartInput) => + manager.restart(input).pipe(Effect.provideService(HostProcess.HomeDirectory, home)), + attachStream: (...args: Parameters) => + manager.attachStream(...args).pipe(Effect.provideService(HostProcess.HomeDirectory, home)), + }, + ptyAdapter, + account, + home, + input: openInput({ providerInstanceId, cwd: root }), + generated: path.join(config.stateDir, "providers", "codex", providerInstanceId, "shadow"), + }; +}); + +const legacySqliteLink = Effect.fn("test.legacySqliteLink")(function* (home: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(home, { recursive: true }); + const link = path.join(home, "future.SQLITE-SHM"); + const target = path.join(home, "missing-shm"); + yield* fs.symlink(target, link); + yield* fs.writeFileString(path.join(home, "state.sqlite-wal"), "private WAL"); + return { link, target }; +}); + const layerWithHostPlatform = (platform: NodeJS.Platform) => Layer.succeed(HostProcess.Platform, platform); @@ -2478,6 +2575,389 @@ it.layer( }), ); + it.effect.each([ + { + winner: "default", + platform: "linux", + inheritedKey: "CODEX_SQLITE_HOME", + providerKey: "CODEX_SQLITE_HOME", + }, + { + winner: "inherited", + platform: "linux", + inheritedKey: "CODEX_SQLITE_HOME", + providerKey: "CODEX_SQLITE_HOME", + }, + { + winner: "request", + platform: "win32", + inheritedKey: "codex_sqlite_home", + providerKey: "CODEX_SQLITE_HOME", + }, + { + winner: "provider", + platform: "win32", + inheritedKey: "CODEX_SQLITE_HOME", + providerKey: "codex_sqlite_home", + }, + { + winner: "provider", + platform: "win32", + inheritedKey: "codex_sqlite_home", + providerKey: "CODEX_SQLITE_HOME", + }, + ] as const)( + "Codex SQLite terminal default and source precedence: $winner $platform $providerKey", + ({ winner, platform, inheritedKey, providerKey }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "terminal-sqlite-" }); + const shared = path.join(root, "shared"); + const shadow = path.join(root, "shadow"); + const { manager, ptyAdapter, input } = yield* createCodexTerminal(root, { + config: { homePath: shared, shadowHomePath: shadow }, + inherited: + winner === "default" + ? {} + : { [inheritedKey]: "/inherited/sqlite", T3CODE_SECRET: "server-only" }, + environment: + winner === "provider" + ? [{ name: providerKey, value: "/provider/sqlite", sensitive: false }] + : [], + }); + yield* manager.open({ + ...input, + env: { + ...(winner === "request" || winner === "provider" + ? { CODEX_SQLITE_HOME: "/request/sqlite" } + : {}), + ACCESS_TOKEN: "cli-auth", + }, + }); + const env = ptyAdapter.spawnInputs[0]?.env ?? {}; + expect( + Object.entries(env) + .filter(([key]) => key.toUpperCase() === "CODEX_SQLITE_HOME") + .map(([, value]) => value), + ).toEqual([winner === "default" ? shared : `/${winner}/sqlite`]); + expect(env.CODEX_HOME).toBe(shadow); + expect(env.ACCESS_TOKEN).toBe("cli-auth"); + expect(env.T3CODE_SECRET).toBeUndefined(); + expect(yield* fs.exists(shared)).toBe(false); + expect(yield* fs.exists(shadow)).toBe(false); + }).pipe(Effect.provideService(HostProcess.Platform, platform)), + ); + + it.effect.each(["configured", "generated"] as const)( + "Codex SQLite checks the $0 shadow before resolving the selected account home", + (mode) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "terminal-recovery-" }); + const shadow = path.join(root, "shadow"); + const { manager, ptyAdapter, input, generated, account } = yield* createCodexTerminal( + root, + { + config: mode === "configured" ? { shadowHomePath: shadow } : {}, + inherited: { HOME: "" }, + }, + ); + const unsafe = mode === "configured" ? shadow : generated; + const { link, target } = yield* legacySqliteLink(unsafe); + account.mockImplementation(() => { + throw new Error("account lookup must not run"); + }); + for (const attempt of [1, 2]) { + const result = yield* manager.open(input).pipe(Effect.result); + expect(result, `attempt ${attempt}`).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "TerminalProviderEnvironmentError", + cause: { _tag: "CodexShadowHomeSqliteRecoveryRequiredError", linkPath: link }, + }, + }); + if (result._tag === "Failure") { + expect(result.failure.message).toContain("Stop all Codex processes"); + expect(result.failure.message).not.toContain(root); + } + expect(ptyAdapter.spawnInputs).toHaveLength(0); + expect(account).not.toHaveBeenCalled(); + expect(yield* fs.readLink(link)).toBe(target); + expect(yield* fs.readFileString(path.join(unsafe, "state.sqlite-wal"))).toBe( + "private WAL", + ); + expect((yield* fs.readDirectory(unsafe)).sort()).toEqual([ + "future.SQLITE-SHM", + "state.sqlite-wal", + ]); + } + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), + { skip: !symlinksSupported }, + ); + + it.effect.each([ + { source: "inherited", platform: "linux" }, + { source: "request", platform: "linux" }, + { source: "provider", platform: "linux" }, + { source: "request", platform: "win32" }, + { source: "provider", platform: "win32" }, + ] as const)( + "Codex SQLite inspects the actual $source home ($platform) and preserves a running shell on unsafe restart", + ({ source, platform }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "terminal-selected-" }); + const selected = path.join(root, "selected"); + const decoy = path.join(root, "decoy"); + const expands = source === "request" && platform === "linux"; + const value = expands ? "~/selected" : selected; + const key = platform === "win32" ? "codex_home" : "CODEX_HOME"; + const { manager, ptyAdapter, input, home, generated } = yield* createCodexTerminal(root, { + inherited: { [key]: source === "inherited" ? value : decoy }, + environment: source === "provider" ? [{ name: key, value, sensitive: false }] : [], + }); + const actual = expands ? path.join(home, "selected") : selected; + yield* legacySqliteLink(decoy); + const request = { + ...input, + env: + source === "inherited" + ? undefined + : { CODEX_HOME: source === "request" ? value : decoy }, + }; + const before = JSON.stringify(request); + yield* manager.open(request); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect( + Object.entries(ptyAdapter.spawnInputs[0]?.env ?? {}) + .filter(([key]) => key.toUpperCase() === "CODEX_HOME") + .map(([, value]) => value), + ).toEqual([expands ? actual : value]); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_SQLITE_HOME).toBeUndefined(); + const { link, target } = yield* legacySqliteLink(actual); + const unsubscribe = yield* manager.attachStream(request, () => Effect.void); + yield* Effect.sync(unsubscribe); + const result = yield* manager + .restart({ ...request, cols: 100, rows: 24 }) + .pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "TerminalProviderEnvironmentError", cause: { linkPath: link } }, + }); + 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(actual, "state.sqlite-wal"))).toBe("private WAL"); + expect(yield* fs.exists(generated)).toBe(false); + expect(JSON.stringify(request)).toBe(before); + }).pipe(Effect.provideService(HostProcess.Platform, platform)), + { skip: !symlinksSupported }, + ); + + it.effect.each([ + { selection: "explicit", unsafeSelected: true }, + { selection: "explicit", unsafeSelected: false }, + { selection: "native", unsafeSelected: true }, + { selection: "native", unsafeSelected: false }, + { selection: "tilde", unsafeSelected: true }, + { selection: "tilde", unsafeSelected: false }, + ] as const)( + "Codex SQLite follows $selection home semantics (unsafe selected: $unsafeSelected)", + ({ selection, unsafeSelected }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "terminal-home-semantics-" }); + yield* fs.makeDirectory(path.join(root, "physical", "child"), { recursive: true }); + yield* fs.symlink(path.join(root, "physical", "child"), path.join(root, "hop")); + const explicit = selection === "explicit"; + const selected = + selection === "tilde" + ? path.join(root, "~", "legacy", "legacy", ".codex") + : explicit + ? path.join(root, "physical", "legacy") + : path.join(root, "legacy", ".codex"); + const decoy = + selection === "tilde" + ? path.join(root, "~", "legacy", ".codex") + : explicit + ? path.join(root, "legacy") + : path.join(root, "physical", "legacy", ".codex"); + const value = selection === "tilde" ? "~//legacy" : "hop/../legacy"; + const { manager, ptyAdapter, input, generated } = yield* createCodexTerminal(root, { + inherited: { [explicit ? "CODEX_HOME" : "HOME"]: value }, + }); + const { link, target } = yield* legacySqliteLink(unsafeSelected ? selected : decoy); + const result = yield* manager.open(input).pipe(Effect.result); + expect(result._tag).toBe(unsafeSelected ? "Failure" : "Success"); + if (result._tag === "Failure") { + expect(result.failure).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + cause: { _tag: "CodexShadowHomeSqliteRecoveryRequiredError" }, + }); + } + expect(ptyAdapter.spawnInputs).toHaveLength(unsafeSelected ? 0 : 1); + if (!unsafeSelected) + expect(ptyAdapter.spawnInputs[0]?.env[explicit ? "CODEX_HOME" : "HOME"]).toBe(value); + expect(yield* fs.readLink(link)).toBe(target); + expect( + yield* fs.readFileString( + path.join(unsafeSelected ? selected : decoy, "state.sqlite-wal"), + ), + ).toBe("private WAL"); + expect(yield* fs.exists(generated)).toBe(false); + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), + { skip: !symlinksSupported || HostProcess.Platform.defaultValue() === "win32" }, + ); + + it.effect.each(["known", "missing", "zero"] as const)( + "Codex SQLite allows direct shared-directory aliases only with $0 identity", + (identity) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "terminal-shared-alias-" }); + const filesystem = + identity === "known" + ? fs + : { + ...fs, + stat: (candidate: string) => + fs.stat(candidate).pipe( + Effect.map((info) => ({ + ...info, + ino: identity === "missing" ? Option.none() : Option.some(0n), + })), + ), + }; + const { manager, ptyAdapter, input, home, generated } = yield* createCodexTerminal( + root, + ).pipe(Effect.provideService(FileSystem.FileSystem, filesystem)); + const shared = path.join(home, ".codex"); + const { link, target } = yield* legacySqliteLink(shared); + const alias = path.join(root, "alias"); + yield* fs.symlink(shared, alias); + const request = { ...input, env: { CODEX_HOME: alias } }; + const result = yield* manager + .open(request) + .pipe(Effect.provideService(FileSystem.FileSystem, filesystem), Effect.result); + expect(result._tag).toBe(identity === "known" ? "Success" : "Failure"); + expect(ptyAdapter.spawnInputs).toHaveLength(identity === "known" ? 1 : 0); + if (identity === "known") { + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe(alias); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_SQLITE_HOME).toBeUndefined(); + const generatedLink = yield* legacySqliteLink(generated); + expect( + yield* manager.restart({ ...request, cols: 100, rows: 24 }).pipe(Effect.result), + ).toMatchObject({ + _tag: "Failure", + failure: { cause: { linkPath: generatedLink.link } }, + }); + expect(ptyAdapter.processes[0]?.killed).toBe(false); + } + expect(yield* fs.readLink(link)).toBe(target); + expect(yield* fs.readFileString(path.join(shared, "state.sqlite-wal"))).toBe("private WAL"); + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), + { skip: !symlinksSupported || HostProcess.Platform.defaultValue() === "win32" }, + ); + + it.effect.each(["", undefined])( + "Codex SQLite uses the POSIX account home when HOME is %s", + (childHome) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "terminal-account-home-" }); + const { manager, ptyAdapter, input, account } = yield* createCodexTerminal(root); + vi.stubEnv("HOME", childHome); + const { link, target } = yield* legacySqliteLink(path.join(root, "account", ".codex")); + expect(yield* manager.open(input).pipe(Effect.result)).toMatchObject({ + _tag: "Failure", + failure: { _tag: "TerminalProviderEnvironmentError", cause: { linkPath: link } }, + }); + expect(account).toHaveBeenCalled(); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + expect(yield* fs.readLink(link)).toBe(target); + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), + { skip: !symlinksSupported }, + ); + + it.effect.skipIf(!symlinksSupported)( + "Codex SQLite leaves Windows native home selection independent of HOME and passwd", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "terminal-windows-home-" }); + const decoy = path.join(root, "unused"); + const { manager, ptyAdapter, input, account, generated } = yield* createCodexTerminal( + root, + { + inherited: { HOME: decoy, USERPROFILE: decoy }, + }, + ); + const { link, target } = yield* legacySqliteLink(path.join(decoy, ".codex")); + account.mockImplementation(() => { + throw new Error("POSIX passwd must not be used"); + }); + yield* manager.open(input); + expect(account).not.toHaveBeenCalled(); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.spawnInputs[0]?.env).toMatchObject({ HOME: decoy, USERPROFILE: decoy }); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBeUndefined(); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_SQLITE_HOME).toBeUndefined(); + expect(yield* fs.readLink(link)).toBe(target); + expect(yield* fs.exists(generated)).toBe(false); + }).pipe(Effect.provideService(HostProcess.Platform, "win32")), + ); + + it.effect( + "Codex SQLite keeps missing-account failures typed and refuses restart before killing", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "terminal-missing-account-" }); + const { manager, ptyAdapter, input, account } = yield* createCodexTerminal(root); + const cause = Object.assign(new Error("private passwd diagnostic"), { + code: "ERR_SYSTEM_ERROR", + }); + account.mockImplementation(() => { + throw cause; + }); + yield* manager.open(input); + expect(account).not.toHaveBeenCalled(); + vi.stubEnv("HOME", ""); + const unsubscribe = yield* manager.attachStream(input, () => Effect.void); + yield* Effect.sync(unsubscribe); + expect(account).not.toHaveBeenCalled(); + const failed = yield* manager.restart(restartInput(input)).pipe(Effect.result); + expect(failed).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "TerminalProviderEnvironmentError", + cause: { _tag: "AccountHomeDirectoryError", cause }, + }, + }); + if (failed._tag === "Failure") { + expect(failed.failure.message).not.toContain(cause.message); + expect(failed.failure).toHaveProperty("cause.cause", cause); + } + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.processes[0]?.killed).toBe(false); + expect( + yield* manager.open({ ...input, terminalId: "new" }).pipe(Effect.result), + ).toMatchObject({ + _tag: "Failure", + failure: { _tag: "TerminalProviderEnvironmentError" }, + }); + yield* manager.open({ ...input, terminalId: "explicit", env: { CODEX_HOME: root } }); + expect(ptyAdapter.spawnInputs).toHaveLength(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/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 066253602a49e..08ac53c11ef23 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -34,6 +34,24 @@ function decodes(schema: S, input: unknown): boolean { } describe("TerminalProviderEnvironmentError", () => { + it("retains recovery guidance and the structured cause across the wire", () => { + const detail = "Codex SQLite recovery is required. Stop all Codex processes before recovery."; + const cause = { + _tag: "CodexShadowHomeSqliteRecoveryRequiredError", + linkPath: "/private/shadow/state.sqlite", + }; + const error = decodeTerminalError({ + _tag: "TerminalProviderEnvironmentError", + providerInstanceId: "codex_work", + cause, + detail, + }); + const decoded = decodeTerminalError(encodeTerminalError(error)); + expect(decoded.message).toBe(detail); + expect(decoded).toMatchObject({ cause }); + expect(decoded.message).not.toContain(cause.linkPath); + }); + 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({ From f18c36eed6be5806ed6694678cb02359af74b603 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:26:32 +0000 Subject: [PATCH 2/6] fix(server): preflight actual Codex terminal homes without changing auth --- apps/server/src/serverSettings.test.ts | 2 + apps/server/src/terminal/Manager.test.ts | 18 ++- apps/server/src/terminal/Manager.ts | 150 ++++++++++++++++++++--- docs/user/providers-codex.md | 7 ++ packages/contracts/src/terminal.ts | 6 +- packages/shared/src/HostProcess.ts | 21 ++++ 6 files changed, 185 insertions(+), 19 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 5287c5a3dbd9d..d68b407368d98 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1907,6 +1907,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); const environment = yield* resolveProviderInstanceTerminalEnvironment({ + stateDir: serverConfig.stateDir, + cwd: serverConfig.baseDir, serverSettings, path, rawProviderInstanceId: instanceId, diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 7bffc4db9423e..b4422d006d307 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2933,7 +2933,9 @@ it.layer( const unsubscribe = yield* manager.attachStream(input, () => Effect.void); yield* Effect.sync(unsubscribe); expect(account).not.toHaveBeenCalled(); - const failed = yield* manager.restart(restartInput(input)).pipe(Effect.result); + const failed = yield* manager + .restart({ ...input, cols: input.cols ?? 100, rows: input.rows ?? 24 }) + .pipe(Effect.result); expect(failed).toMatchObject({ _tag: "Failure", failure: { @@ -3004,6 +3006,8 @@ it.layer( }); const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + stateDir: "/isolated-test/state", + cwd: "/isolated-test/cwd", serverSettings, path, rawProviderInstanceId: providerInstanceId, @@ -3047,6 +3051,8 @@ it.layer( const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + stateDir: "/isolated-test/state", + cwd: "/isolated-test/cwd", serverSettings, path, rawProviderInstanceId: "configured_home", @@ -3074,6 +3080,8 @@ it.layer( const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + stateDir: "/isolated-test/state", + cwd: "/isolated-test/cwd", serverSettings, path, rawProviderInstanceId: "codex", @@ -3100,6 +3108,8 @@ it.layer( const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + stateDir: "/isolated-test/state", + cwd: "/isolated-test/cwd", serverSettings, path, rawProviderInstanceId: "claudeAgent", @@ -3126,6 +3136,8 @@ it.layer( const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + stateDir: "/isolated-test/state", + cwd: "/isolated-test/cwd", serverSettings, path, rawProviderInstanceId: "codex", @@ -3143,6 +3155,8 @@ it.layer( const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + stateDir: "/isolated-test/state", + cwd: "/isolated-test/cwd", serverSettings, path, rawProviderInstanceId: "codex_unknown", @@ -3184,6 +3198,8 @@ it.layer( historyByteLimit: 8, resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) => TerminalManager.resolveProviderInstanceTerminalEnvironment({ + stateDir: "/isolated-test/state", + cwd: "/isolated-test/cwd", serverSettings, path, rawProviderInstanceId, diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 08651591f1a8b..35ef3dae7f15e 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -58,7 +58,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 { + CODEX_SQLITE_RECOVERY_GUIDANCE, + inspectCodexShadowSqlite, + mergeCodexEnvironment, + 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"; @@ -1310,11 +1317,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))); } @@ -1348,9 +1355,12 @@ interface TerminalManagerOptions { resolveProviderInstanceEnvironment?: ( providerInstanceId: string, env: Record | undefined, + inheritedEnvironment: NodeJS.ProcessEnv, + cwd: string, ) => Effect.Effect< Record, - TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError, + FileSystem.FileSystem >; } @@ -1360,6 +1370,9 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( readonly serverSettings: ServerSettings.ServerSettingsService["Service"]; readonly path: Path.Path; readonly rawProviderInstanceId: string; + readonly stateDir: string; + readonly cwd: string; + readonly inheritedEnvironment?: NodeJS.ProcessEnv; readonly env: Record | undefined; }) { const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId); @@ -1371,15 +1384,106 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId }); } - let resolved = yield* mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); + const platform = yield* HostProcess.Platform; + const inheritedEnvironment = input.inheritedEnvironment ?? {}; + // Only SQLite defaults belong in runtime overrides. Inherited CODEX_HOME stays raw at spawn. + const inheritedSqlite = Object.fromEntries( + Object.entries(inheritedEnvironment).filter( + ([key]) => (platform === "win32" ? key.toUpperCase() : key) === "CODEX_SQLITE_HOME", + ), + ); + let resolved = yield* instance.driver === "codex" + ? mergeCodexEnvironment(instance.environment, inheritedSqlite, input.env ?? {}) + : 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), ); + const recoveryLayout = + config.value.setupMode === "managed" + ? yield* resolveManagedCodexHomeLayout( + input.stateDir, + providerInstanceId, + config.value, + ).pipe(Effect.provideService(Path.Path, input.path)) + : layout; + resolved = withCodexSqliteHome(layout, resolved); if (layout.effectiveHomePath) resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; + + const projected = createTerminalSpawnEnv( + inheritedEnvironment, + normalizedRuntimeEnv(resolved), + platform, + yield* HostProcess.HomeDirectory, + ); + yield* Effect.gen(function* () { + yield* inspectCodexShadowSqlite(recoveryLayout); + const explicitHome = Object.entries(projected).find( + ([key]) => (platform === "win32" ? key.toUpperCase() : key) === "CODEX_HOME", + )?.[1]; + // Windows Codex uses Known Folders; HOME/USERPROFILE cannot predict that selection. + if (!explicitHome && platform === "win32") return; + const nativeHome = explicitHome + ? undefined + : projected.HOME || (yield* Effect.flatten(HostProcess.AccountHomeDirectory)); + // Native HOME goes through AbsolutePathBuf's one-pass expansion and lexical normalization. + const selected = + explicitHome || + input.path.normalize((nativeHome + "/.codex").replace(/^~\/+/u, () => nativeHome + "/")); + // Explicit POSIX paths retain symlink/.. traversal, including a symlinked cwd. + const selectedPath = + platform === "win32" + ? input.path.resolve(input.cwd, selected) + : input.path.isAbsolute(selected) + ? selected + : input.cwd + "/" + selected; + if ( + recoveryLayout.mode === "authOverlay" && + selectedPath === recoveryLayout.effectiveHomePath + ) + return; + if (layout.mode === "direct") { + if (selectedPath === layout.sharedHomePath) return; + const fs = yield* FileSystem.FileSystem; + // An unavailable unused shared home cannot add a launch gate. Unknown identity still inspects. + const [selectedInfo, sharedInfo] = yield* Effect.all([ + fs.stat(selectedPath).pipe(Effect.option), + fs.stat(layout.sharedHomePath).pipe(Effect.option), + ]); + if ( + Option.isSome(selectedInfo) && + Option.isSome(sharedInfo) && + selectedInfo.value.type === "Directory" && + sharedInfo.value.type === "Directory" && + Option.isSome(selectedInfo.value.ino) && + Option.isSome(sharedInfo.value.ino) && + selectedInfo.value.ino.value > 0 && + selectedInfo.value.dev === sharedInfo.value.dev && + selectedInfo.value.ino.value === sharedInfo.value.ino.value + ) + return; + } + yield* inspectCodexShadowSqlite({ + ...layout, + mode: "authOverlay", + effectiveHomePath: selectedPath, + }); + }).pipe( + Effect.provideService(Path.Path, input.path), + Effect.mapError( + (cause) => + new TerminalProviderEnvironmentError({ + providerInstanceId, + cause, + ...(cause._tag === "CodexShadowHomeSqliteRecoveryRequiredError" + ? { detail: CODEX_SQLITE_RECOVERY_GUIDANCE } + : {}), + }), + ), + ); } } else if (instance.driver === "claudeAgent") { const config = decodeClaudeSettings(instance.config ?? {}); @@ -1397,7 +1501,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; @@ -1405,13 +1510,22 @@ export const make = Effect.fn("TerminalManager.make")(function* () { const path = yield* Path.Path; const resolveProviderInstanceEnvironment = Effect.fn( "terminal.resolveProviderInstanceEnvironment", - )((rawProviderInstanceId: string, env: Record | undefined) => - resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId, - env, - }), + )( + ( + rawProviderInstanceId: string, + env: Record | undefined, + inheritedEnvironment: NodeJS.ProcessEnv, + cwd: string, + ) => + resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + stateDir, + inheritedEnvironment, + cwd, + env, + }), ); return yield* makeWithOptions({ logsDir: terminalLogsDir, @@ -1450,7 +1564,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, @@ -1463,7 +1577,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), }); } - const env = yield* resolver(input.providerInstanceId, input.env); + const env = yield* resolver(input.providerInstanceId, input.env, baseEnv, input.cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); return { ...input, env }; }, ); diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index bdc87902a63b2..2df02ca91c615 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 **SQLite recovery is required**, stop every +Codex process using either home and back up both directories. Recover or +checkpoint any shadow WAL/journal data before removing the old shadow SQLite +symlinks, including dangling or orphaned sidecar links. Do not delete real +databases or sidecars to force startup. T3 Code does not repair or merge 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.ts b/packages/contracts/src/terminal.ts index e4824657a6e61..441c7821c7c57 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -317,10 +317,14 @@ export class TerminalProviderEnvironmentError extends Schema.TaggedError( }, ); +export class AccountHomeDirectoryError extends Schema.TaggedError()( + "AccountHomeDirectoryError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not resolve the OS account home directory."; + } +} + +// Unlike homedir(), account metadata must not fall back to the server's 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", { From b36f8f93d0a9fac55d81cfe05a3fd043cea44291 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:28:17 +0000 Subject: [PATCH 3/6] test(server): compose Codex terminal fixture layers together --- apps/server/src/terminal/Manager.test.ts | 30 ++++++++++++------------ 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index b4422d006d307..01e4010262643 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -379,22 +379,22 @@ const createCodexTerminal = Effect.fn("test.createCodexTerminal")(function* ( Effect.provideService(ServerConfig.ServerConfig, config), Effect.provideService(PtyAdapter.PtyAdapter, ptyAdapter), Effect.provide( - Layer.mock(PortScanner.PortDiscovery)({ - registerTerminalProcesses: () => Effect.void, - unregisterTerminal: () => Effect.void, - }), - ), - Effect.provide(NativeTelemetryClient.layerTest({ processTable: Effect.succeed([]) })), - Effect.provide( - ServerSettings.layerTest({ - providerInstances: { - [providerInstanceId]: { - driver: ProviderDriverKind.make("codex"), - config: { setupMode: "managed", homePath: "", shadowHomePath: "", ...options.config }, - environment: options.environment ?? [], + Layer.mergeAll( + Layer.mock(PortScanner.PortDiscovery)({ + registerTerminalProcesses: () => Effect.void, + unregisterTerminal: () => Effect.void, + }), + NativeTelemetryClient.layerTest({ processTable: Effect.succeed([]) }), + ServerSettings.layerTest({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "managed", homePath: "", shadowHomePath: "", ...options.config }, + environment: options.environment ?? [], + }, }, - }, - }), + }), + ), ), ); return { From f0a304388b3e590bf4cd8e510ae60f1a722aa9d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 21:02:00 +0000 Subject: [PATCH 4/6] docs: make Codex SQLite upgrade recovery discoverable --- docs/user/providers-codex.md | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 2df02ca91c615..b720dda623b59 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -31,6 +31,30 @@ Add another ChatGPT account in **Settings → Providers**, then select the accou from the thread's model picker. Compatible accounts can continue the same thread. Connecting accounts through T3 Code leaves your CLI login unchanged. +Upgrading older account shadow homes can report **SQLite recovery is required** +on any platform, including macOS and Linux. T3 Code refuses all legacy SQLite +symlinks, including dangling or orphaned sidecar links, until you recover the +data and manually remove those links. + +For accounts connected through T3 Code, generated shadow homes normally live at +`~/.t3/userdata/providers/codex//shadow`. On the environment's +machine, list the account directories with: + +```bash +ls -d ~/.t3/userdata/providers/codex/*/shadow +``` + +If the server uses `T3CODE_HOME` or `--base-dir`, replace `~/.t3` with that T3 home; +keep the `userdata` directory in the path. For a configured **Shadow home path**, +use the path in **Settings → Providers** instead. For a remote environment, these +paths are on the server's machine. + +Before removing links, stop every Codex process using either the shared or shadow +home and back up both directories. Recover or checkpoint any shadow WAL/journal +data, then remove only the old shadow SQLite symlinks. Do not delete real +databases or sidecars to force startup. T3 Code does not repair or merge them +automatically. + ### Multiple CLI logins A shared Codex home with a shadow home lets work and personal accounts continue @@ -56,13 +80,6 @@ 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 **SQLite recovery is required**, stop every -Codex process using either home and back up both directories. Recover or -checkpoint any shadow WAL/journal data before removing the old shadow SQLite -symlinks, including dangling or orphaned sidecar links. Do not delete real -databases or sidecars to force startup. T3 Code does not repair or merge 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). From 289584bf1501480c44c8ff200d6972649c680212 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 21:18:28 +0000 Subject: [PATCH 5/6] test(server): cover SQLite default for selected managed shadow --- apps/server/src/terminal/Manager.test.ts | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 01e4010262643..31ec15c408442 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2649,6 +2649,31 @@ it.layer( }).pipe(Effect.provideService(HostProcess.Platform, platform)), ); + it.effect.each(["default", "explicit"] as const)( + "Codex SQLite selected generated managed shadow uses $0 SQLite home", + (sqlite) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "terminal-generated-sqlite-" }); + const { manager, ptyAdapter, input, home, generated } = yield* createCodexTerminal(root); + const explicit = path.join(root, "explicit-sqlite"); + yield* fs.makeDirectory(generated, { recursive: true }); + yield* manager.open({ + ...input, + env: { + CODEX_HOME: generated, + ...(sqlite === "explicit" ? { CODEX_SQLITE_HOME: explicit } : {}), + }, + }); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe(generated); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_SQLITE_HOME).toBe( + sqlite === "explicit" ? explicit : path.join(home, ".codex"), + ); + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), + ); + it.effect.each(["configured", "generated"] as const)( "Codex SQLite checks the $0 shadow before resolving the selected account home", (mode) => From 627ab752b7225b026c250e66d0048800b1a7ec58 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 21:23:05 +0000 Subject: [PATCH 6/6] fix(server): share SQLite for terminal-selected managed Codex homes --- apps/server/src/terminal/Manager.ts | 52 +++++++++++++++++------------ docs/user/providers-codex.md | 6 ++-- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 35ef3dae7f15e..55ead96760778 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1364,6 +1364,27 @@ interface TerminalManagerOptions { >; } +const pathsReferToSameDirectory = Effect.fnUntraced(function* (left: string, right: string) { + if (left === right) return true; + const fs = yield* FileSystem.FileSystem; + // An unavailable unused home cannot add a launch gate. Unknown identity still inspects. + const [leftInfo, rightInfo] = yield* Effect.all([ + fs.stat(left).pipe(Effect.option), + fs.stat(right).pipe(Effect.option), + ]); + return ( + Option.isSome(leftInfo) && + Option.isSome(rightInfo) && + leftInfo.value.type === "Directory" && + rightInfo.value.type === "Directory" && + Option.isSome(leftInfo.value.ino) && + Option.isSome(rightInfo.value.ino) && + leftInfo.value.ino.value > 0 && + leftInfo.value.dev === rightInfo.value.dev && + leftInfo.value.ino.value === rightInfo.value.ino.value + ); +}); + export const resolveProviderInstanceTerminalEnvironment = Effect.fn( "terminal.resolveProviderInstanceTerminalEnvironment", )(function* (input: { @@ -1442,30 +1463,17 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( : input.cwd + "/" + selected; if ( recoveryLayout.mode === "authOverlay" && - selectedPath === recoveryLayout.effectiveHomePath - ) + recoveryLayout.effectiveHomePath && + (yield* pathsReferToSameDirectory(selectedPath, recoveryLayout.effectiveHomePath)) + ) { + resolved = withCodexSqliteHome(recoveryLayout, resolved); return; - if (layout.mode === "direct") { - if (selectedPath === layout.sharedHomePath) return; - const fs = yield* FileSystem.FileSystem; - // An unavailable unused shared home cannot add a launch gate. Unknown identity still inspects. - const [selectedInfo, sharedInfo] = yield* Effect.all([ - fs.stat(selectedPath).pipe(Effect.option), - fs.stat(layout.sharedHomePath).pipe(Effect.option), - ]); - if ( - Option.isSome(selectedInfo) && - Option.isSome(sharedInfo) && - selectedInfo.value.type === "Directory" && - sharedInfo.value.type === "Directory" && - Option.isSome(selectedInfo.value.ino) && - Option.isSome(sharedInfo.value.ino) && - selectedInfo.value.ino.value > 0 && - selectedInfo.value.dev === sharedInfo.value.dev && - selectedInfo.value.ino.value === sharedInfo.value.ino.value - ) - return; } + if ( + layout.mode === "direct" && + (yield* pathsReferToSameDirectory(selectedPath, layout.sharedHomePath)) + ) + return; yield* inspectCodexShadowSqlite({ ...layout, mode: "authOverlay", diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index b720dda623b59..3dcac1a366458 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -37,8 +37,10 @@ symlinks, including dangling or orphaned sidecar links, until you recover the data and manually remove those links. For accounts connected through T3 Code, generated shadow homes normally live at -`~/.t3/userdata/providers/codex//shadow`. On the environment's -machine, list the account directories with: +`~/.t3/userdata/providers/codex//shadow` on macOS and Linux, +or `%USERPROFILE%\.t3\userdata\providers\codex\\shadow` on +Windows. On the environment's macOS or Linux machine, list the account directories +with: ```bash ls -d ~/.t3/userdata/providers/codex/*/shadow