From efe31edc20e76b82c2864682929170a38d595600 Mon Sep 17 00:00:00 2001 From: zendext Date: Fri, 21 Aug 2026 17:17:54 +0800 Subject: [PATCH] fix(codex): honor non-default Codex home --- README.md | 2 +- docs/general/user-guide.md | 31 ++++++++++--- internal/app/install/darwin_service.go | 16 +++++-- internal/app/install/darwin_service_test.go | 35 +++++++++++++++ internal/app/install/entry.go | 9 ++++ internal/app/install/entry_test.go | 37 ++++++++++++++++ internal/app/install/linux_service.go | 11 +++-- internal/app/install/packaged_install.go | 9 ++++ internal/app/install/packaged_install_test.go | 7 +++ internal/app/install/runtime_paths.go | 6 ++- internal/app/install/runtime_paths_test.go | 20 +++++++++ internal/app/install/service.go | 3 ++ internal/app/install/service_entry_test.go | 44 +++++++++++++++++++ internal/app/install/state.go | 1 + internal/codexstate/sqlite_threads.go | 6 +-- internal/codexstate/sqlite_threads_test.go | 17 +++++++ .../codexstate/turn_patch_storage_test.go | 16 +++++++ internal/codexstate/turn_patch_types.go | 8 ++-- internal/config/codex_model_provider_env.go | 38 +++++++++++----- 19 files changed, 281 insertions(+), 35 deletions(-) create mode 100644 internal/app/install/runtime_paths_test.go diff --git a/README.md b/README.md index b5cf13e3c..35f4f4342 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ daemon 不会在后台自动弹升级提示;升级只通过这条手动入口 - **Codex API Profile**:独立设置 base URL、API key、模型、推理强度等 - **Claude Profile**:独立设置认证方式、base URL、模型、推理强度等 -这些 profile 保存在 codex-remote 自己的 `config.json` 里,不会改写 `~/.codex` 或 `~/.claude` 的原有配置,可以随时切换、并行使用。 +这些 profile 保存在 codex-remote 自己的 `config.json` 里,不会改写 `$CODEX_HOME`(默认 `~/.codex`)或 `~/.claude` 的原有配置,可以随时切换、并行使用。 在飞书里切换: diff --git a/docs/general/user-guide.md b/docs/general/user-guide.md index 309d54a03..51d631e5a 100644 --- a/docs/general/user-guide.md +++ b/docs/general/user-guide.md @@ -1,8 +1,8 @@ # 使用说明书 > Type: `general` -> Updated: `2026-08-05` -> Summary: 更新安装方式与首次配置流程,并补充模型后端说明:默认使用机器现有 Codex / Claude Code 配置,可通过独立 Profile 并行使用多套模型参数。 +> Updated: `2026-08-21` +> Summary: 补充非默认 `CODEX_HOME` 的安装与自动启动合同,并同步 Codex 配置目录说明。 ## 1. 这是什么 @@ -132,7 +132,24 @@ - VS Code Remote SSH 使用,就装在那台 SSH 目标机器上 - 如果两边都会各自运行 Codex,两边都可以各装一套,但它们是两套独立环境 -### 5.2 原生安装器(Windows / macOS) +### 5.2 使用非默认 CODEX_HOME + +如果本机 Codex 通过 `CODEX_HOME` 使用非默认配置与会话目录,请在运行安装命令前导出该变量,并确保目录已经存在: + +```bash +export CODEX_HOME="/path/to/codex-home" +``` + +安装时显式设置的 `CODEX_HOME` 会同时用于: + +- headless Codex 的配置、认证和会话 +- `/list`、`/use`、`/useall` 使用的本地会话索引 +- turn patch 使用的 sessions 与状态目录 +- Linux systemd 和 macOS launchd 自动启动 + +未设置时继续使用 Codex 默认目录 `~/.codex`。如果要切换到另一个 Codex Home,请先确保新目录有效,再带着新的 `CODEX_HOME` 重新运行安装命令,使安装状态和自动启动定义一起更新。 + +### 5.3 原生安装器(Windows / macOS) 如果你更习惯桌面安装器,可以直接从 [GitHub Releases](https://github.com/kxn/codex-remote-feishu/releases) 下载对应平台的 native installer。 @@ -156,7 +173,7 @@ codex-remote-feishu__darwin_universal_installer.dmg 运行 DMG 里的 **Install Codex Remote.app**。首次安装可以选择安装目录;完成后在结果页打开 WebSetup。已经安装过时再次运行,会按 repair / 升级处理。 -### 5.3 一条命令安装 +### 5.4 一条命令安装 macOS / Linux: @@ -198,7 +215,7 @@ curl -fsSL https://raw.githubusercontent.com/kxn/codex-remote-feishu/master/inst & ([scriptblock]::Create((irm https://raw.githubusercontent.com/kxn/codex-remote-feishu/master/install-release.ps1))) -Version ``` -### 5.4 手动安装 release 包 +### 5.5 手动安装 release 包 macOS / Linux: @@ -423,7 +440,7 @@ Windows PowerShell: 两个 headless 后端默认都直接使用机器上已经配好的环境: -- `codex`:使用本机 `~/.codex` 已有的登录状态、模型和配置 +- `codex`:使用 `$CODEX_HOME` 已有的登录状态、模型和配置;未设置时默认使用 `~/.codex` - `claude`:使用本机 `~/.claude` 已有的 Claude Code 登录状态和配置 默认不需要额外配置任何模型参数,开箱就用机器现有环境。 @@ -433,7 +450,7 @@ Windows PowerShell: - **Codex API Profile**:独立设置 base URL、API key、模型、review model、推理强度等 - **Claude Profile**:独立设置认证方式、base URL、auth token、模型、small model、推理强度等 -这些 profile 保存在 codex-remote 自己的 `config.json` 里,不会写回 `~/.codex` 或 `~/.claude` 的原有配置。Claude 自定义 profile 通过临时 `--settings` overlay 注入,不改动用户自己的 Claude 配置。 +这些 profile 保存在 codex-remote 自己的 `config.json` 里,不会写回 `$CODEX_HOME`(默认 `~/.codex`)或 `~/.claude` 的原有配置。Claude 自定义 profile 通过临时 `--settings` overlay 注入,不改动用户自己的 Claude 配置。 在飞书里查看或切换: diff --git a/internal/app/install/darwin_service.go b/internal/app/install/darwin_service.go index 2be3bbbc4..1c06e8bab 100644 --- a/internal/app/install/darwin_service.go +++ b/internal/app/install/darwin_service.go @@ -117,8 +117,16 @@ func renderLaunchdUserPlist(state InstallState) (string, error) { ` ` + xmlEscape(dataHome) + ``, ` XDG_STATE_HOME`, ` ` + xmlEscape(stateHome) + ``, + } + if codexHome := normalizeServicePathValue(state.CodexHome); codexHome != "" { + lines = append(lines, + ` CODEX_HOME`, + ` `+xmlEscape(codexHome)+``, + ) + } + lines = append(lines, ` PATH`, - ` ` + xmlEscape(systemdUserServicePATH()) + ``, + ` `+xmlEscape(systemdUserServicePATH())+``, ` `, ` RunAtLoad`, ` `, @@ -128,13 +136,13 @@ func renderLaunchdUserPlist(state InstallState) (string, error) { ` `, ` `, ` StandardOutPath`, - ` ` + xmlEscape(logPath) + ``, + ` `+xmlEscape(logPath)+``, ` StandardErrorPath`, - ` ` + xmlEscape(logPath) + ``, + ` `+xmlEscape(logPath)+``, ``, ``, ``, - } + ) return strings.Join(lines, "\n"), nil } diff --git a/internal/app/install/darwin_service_test.go b/internal/app/install/darwin_service_test.go index 6f571b847..0ba369234 100644 --- a/internal/app/install/darwin_service_test.go +++ b/internal/app/install/darwin_service_test.go @@ -162,6 +162,41 @@ func TestRenderLaunchdUserPlistContainsKeyElements(t *testing.T) { } } +func TestBootstrapPreservesCodexHomeInLaunchdUserPlist(t *testing.T) { + defer withDarwinGOOS(t)() + baseDir := t.TempDir() + stubServiceUserHome(t, baseDir) + codexHome := filepath.Join(baseDir, "custom-codex-home") + if err := os.MkdirAll(codexHome, 0o755); err != nil { + t.Fatalf("MkdirAll CODEX_HOME: %v", err) + } + t.Setenv("CODEX_HOME", codexHome) + t.Setenv("PATH", "/usr/bin:/bin") + + service := NewService() + state, err := service.Bootstrap(Options{ + BaseDir: baseDir, + BinaryPath: seedBinary(t, filepath.Join(baseDir, "source-bin", "codex-remote"), "binary"), + ServiceManager: ServiceManagerLaunchdUser, + CurrentVersion: "dev", + RelayServerURL: "ws://127.0.0.1:9500/ws/agent", + CodexHome: codexHome, + }) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + t.Setenv("CODEX_HOME", "") + + plist, err := renderLaunchdUserPlist(state) + if err != nil { + t.Fatalf("renderLaunchdUserPlist: %v", err) + } + want := "CODEX_HOME\n " + xmlEscape(codexHome) + "" + if !strings.Contains(plist, want) { + t.Fatalf("launchd plist missing preserved CODEX_HOME %q:\n%s", want, plist) + } +} + func TestRenderLaunchdUserPlistEscapesXMLSpecialChars(t *testing.T) { defer withDarwinGOOS(t)() baseDir := t.TempDir() diff --git a/internal/app/install/entry.go b/internal/app/install/entry.go index 0f88cbc05..7b62f68da 100644 --- a/internal/app/install/entry.go +++ b/internal/app/install/entry.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/kxn/codex-remote-feishu/internal/config" "github.com/kxn/codex-remote-feishu/internal/execlaunch" "github.com/kxn/codex-remote-feishu/internal/pathcompare" "github.com/kxn/codex-remote-feishu/internal/xutil" @@ -85,6 +86,10 @@ func RunMain(args []string, stdin io.Reader, stdout, stderr io.Writer, version s resolvedBaseDir := selection.BaseDir resolvedInstallBinDir := resolveTargetInstallBinDir(selection, *installBinDir) preInteractiveInstallBinDir := resolvedInstallBinDir + codexHome, err := config.ResolveExplicitCodexHomeDir(os.Environ()) + if err != nil { + return err + } service := NewService() opts := Options{ @@ -102,6 +107,7 @@ func RunMain(args []string, stdin io.Reader, stdout, stderr io.Writer, version s RelaydBinary: *legacyRelaydBinary, RelayServerURL: *relayURL, CodexRealBinary: *codexBinary, + CodexHome: codexHome, VSCodeSettingsPath: *settingsPath, BundleEntrypoint: *bundleEntrypoint, FeishuGatewayID: *feishuGatewayID, @@ -211,6 +217,9 @@ func preserveInstallOptionsFromExistingState(flagSet *flag.FlagSet, statePath st if !flagWasProvided(flagSet, "bundle-entrypoint") && strings.TrimSpace(existing.BundleEntrypoint) != "" { opts.BundleEntrypoint = existing.BundleEntrypoint } + if strings.TrimSpace(opts.CodexHome) == "" { + opts.CodexHome = existing.CodexHome + } } func defaultBinaryPath(goos string) string { diff --git a/internal/app/install/entry_test.go b/internal/app/install/entry_test.go index 32c93c12c..2df08f905 100644 --- a/internal/app/install/entry_test.go +++ b/internal/app/install/entry_test.go @@ -74,12 +74,45 @@ func TestRunMainBootstrapOnlyPreservesExistingRelayURLWhenFlagOmitted(t *testing } } +func TestRunMainBootstrapOnlyPersistsExplicitCodexHome(t *testing.T) { + t.Setenv(repoRootEnvVar, t.TempDir()) + baseDir := t.TempDir() + codexHome := filepath.Join(baseDir, "custom-codex-home") + if err := os.MkdirAll(codexHome, 0o755); err != nil { + t.Fatalf("MkdirAll CODEX_HOME: %v", err) + } + t.Setenv("CODEX_HOME", codexHome) + binaryPath := seedBinary(t, filepath.Join(baseDir, "bin", "codex-remote"), "binary") + + originalValidator := sourceBinaryValidator + sourceBinaryValidator = func(string) error { return nil } + defer func() { sourceBinaryValidator = originalValidator }() + + if err := RunMain([]string{ + "-bootstrap-only", + "-base-dir", baseDir, + "-binary", binaryPath, + }, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "vtest"); err != nil { + t.Fatalf("RunMain bootstrap-only: %v", err) + } + + state, err := LoadState(defaultInstallStatePath(baseDir)) + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if state.CodexHome != codexHome { + t.Fatalf("CodexHome = %q, want %q", state.CodexHome, codexHome) + } +} + func TestRunMainBootstrapOnlyPreservesExistingInstallMetadataWhenFlagsOmitted(t *testing.T) { t.Setenv(repoRootEnvVar, t.TempDir()) + t.Setenv("CODEX_HOME", "") baseDir := t.TempDir() installBinDir := filepath.Join(baseDir, "installed-bin") statePath := defaultInstallStatePathForInstance(baseDir, defaultInstanceID) existingBinary := seedBinary(t, filepath.Join(installBinDir, xutil.ExecutableName("linux")), "old-binary") + existingCodexHome := filepath.Join(baseDir, "existing-codex-home") if err := WriteState(statePath, InstallState{ InstanceID: defaultInstanceID, BaseDir: baseDir, @@ -93,6 +126,7 @@ func TestRunMainBootstrapOnlyPreservesExistingInstallMetadataWhenFlagsOmitted(t CurrentSlot: "v1.4.0-beta.1", VSCodeSettingsPath: filepath.Join(baseDir, "vscode", "settings.json"), BundleEntrypoint: filepath.Join(baseDir, "bundle", "codex"), + CodexHome: existingCodexHome, }); err != nil { t.Fatalf("WriteState: %v", err) } @@ -142,6 +176,9 @@ func TestRunMainBootstrapOnlyPreservesExistingInstallMetadataWhenFlagsOmitted(t if updated.BundleEntrypoint != filepath.Join(baseDir, "bundle", "codex") { t.Fatalf("BundleEntrypoint = %q, want preserved value", updated.BundleEntrypoint) } + if updated.CodexHome != existingCodexHome { + t.Fatalf("CodexHome = %q, want preserved %q", updated.CodexHome, existingCodexHome) + } } func TestRunMainDefaultsBinaryToCurrentExecutable(t *testing.T) { diff --git a/internal/app/install/linux_service.go b/internal/app/install/linux_service.go index c76326a03..e20d777c6 100644 --- a/internal/app/install/linux_service.go +++ b/internal/app/install/linux_service.go @@ -134,15 +134,20 @@ func renderSystemdUserUnit(state InstallState) (string, error) { "Environment=XDG_CONFIG_HOME=" + systemdEscapeValue(configHome), "Environment=XDG_DATA_HOME=" + systemdEscapeValue(dataHome), "Environment=XDG_STATE_HOME=" + systemdEscapeValue(stateHome), + } + if codexHome := normalizeServicePathValue(state.CodexHome); codexHome != "" { + lines = append(lines, "Environment=CODEX_HOME="+systemdEscapeValue(codexHome)) + } + lines = append(lines, "Restart=on-failure", "RestartSec=2s", - "StandardOutput=append:" + serviceLogPath, - "StandardError=append:" + serviceLogPath, + "StandardOutput=append:"+serviceLogPath, + "StandardError=append:"+serviceLogPath, "", "[Install]", "WantedBy=default.target", "", - } + ) return strings.Join(lines, "\n"), nil } diff --git a/internal/app/install/packaged_install.go b/internal/app/install/packaged_install.go index d620b8959..0facc3e35 100644 --- a/internal/app/install/packaged_install.go +++ b/internal/app/install/packaged_install.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/kxn/codex-remote-feishu/internal/config" "github.com/kxn/codex-remote-feishu/internal/xutil" ) @@ -59,6 +60,7 @@ type packagedInstallOptions struct { CurrentTrack ReleaseTrack VersionsRoot string CurrentSlot string + CodexHome string OutputFormat string ResultFilePath string GOOS string @@ -129,6 +131,10 @@ func RunPackagedInstall(args []string, _ io.Reader, stdout, _ io.Writer, version if err != nil { return err } + codexHome, err := config.ResolveExplicitCodexHomeDir(os.Environ()) + if err != nil { + return err + } opts := packagedInstallOptions{ Selection: selection, StatePath: resolvedStatePath, @@ -139,6 +145,7 @@ func RunPackagedInstall(args []string, _ io.Reader, stdout, _ io.Writer, version CurrentTrack: ParseReleaseTrack(*currentTrack), VersionsRoot: strings.TrimSpace(*versionsRoot), CurrentSlot: requestedSlot, + CodexHome: codexHome, OutputFormat: outputFormat, ResultFilePath: strings.TrimSpace(*resultFile), GOOS: defaults.GOOS, @@ -207,6 +214,7 @@ func runPackagedFirstInstall(ctx context.Context, opts packagedInstallOptions) ( CurrentTrack: opts.CurrentTrack, VersionsRoot: versionsRoot, CurrentSlot: targetSlot, + CodexHome: opts.CodexHome, BootstrapOnly: true, }) result := packagedInstallResultForState(packagedInstallModeFirstInstall, state) @@ -270,6 +278,7 @@ func runPackagedRepair(ctx context.Context, flagSet *flag.FlagSet, opts packaged } state.CurrentBinaryPath = liveBinaryPath state.VersionsRoot = xutil.FirstNonEmpty(strings.TrimSpace(opts.VersionsRoot), strings.TrimSpace(state.VersionsRoot), defaultVersionsRootForStatePath(state.StatePath)) + state.CodexHome = choosePreservedValue(opts.CodexHome, state.CodexHome) // Migrate version-scoped legacy live binary to canonical instance bin dir. if canonicalDir, needsMigration := canonicalInstallBinDirForMigration(opts.GOOS, state); needsMigration { diff --git a/internal/app/install/packaged_install_test.go b/internal/app/install/packaged_install_test.go index 722bc9406..aa0a9149e 100644 --- a/internal/app/install/packaged_install_test.go +++ b/internal/app/install/packaged_install_test.go @@ -214,6 +214,9 @@ func TestRunPackagedInstallRepairOverwritesLiveBinaryAndClearsUpgradeState(t *te liveBinary := seedBinary(t, filepath.Join(baseDir, "installed-bin", xutil.ExecutableName(runtime.GOOS)), "old-binary") sourceBinary := seedBinary(t, filepath.Join(baseDir, "pkg", xutil.ExecutableName(runtime.GOOS)), "new-binary") versionsRoot := filepath.Join(baseDir, "releases") + existingCodexHome := t.TempDir() + requestedCodexHome := t.TempDir() + t.Setenv("CODEX_HOME", requestedCodexHome) if err := WriteState(statePath, InstallState{ InstanceID: defaultInstanceID, BaseDir: baseDir, @@ -224,6 +227,7 @@ func TestRunPackagedInstallRepairOverwritesLiveBinaryAndClearsUpgradeState(t *te CurrentTrack: ReleaseTrackProduction, CurrentVersion: "v1.0.0", CurrentBinaryPath: liveBinary, + CodexHome: existingCodexHome, VersionsRoot: versionsRoot, CurrentSlot: "v1.0.0", PendingUpgrade: &PendingUpgrade{ @@ -310,6 +314,9 @@ func TestRunPackagedInstallRepairOverwritesLiveBinaryAndClearsUpgradeState(t *te if updated.CurrentVersion != "v1.2.0-beta.1" { t.Fatalf("CurrentVersion = %q, want new version", updated.CurrentVersion) } + if updated.CodexHome != requestedCodexHome { + t.Fatalf("CodexHome = %q, want %q", updated.CodexHome, requestedCodexHome) + } raw, err := os.ReadFile(liveBinary) if err != nil { diff --git a/internal/app/install/runtime_paths.go b/internal/app/install/runtime_paths.go index a56632bd2..455e148b4 100644 --- a/internal/app/install/runtime_paths.go +++ b/internal/app/install/runtime_paths.go @@ -33,10 +33,14 @@ func RuntimeEnvForState(state InstallState) []string { if configPath == "" { configPath = filepath.Join(layout.ConfigDir, "config.json") } - return []string{ + env := []string{ "CODEX_REMOTE_CONFIG=" + configPath, "XDG_CONFIG_HOME=" + layout.ConfigHome, "XDG_DATA_HOME=" + layout.DataHome, "XDG_STATE_HOME=" + layout.StateHome, } + if codexHome := strings.TrimSpace(state.CodexHome); codexHome != "" { + env = append(env, "CODEX_HOME="+codexHome) + } + return env } diff --git a/internal/app/install/runtime_paths_test.go b/internal/app/install/runtime_paths_test.go new file mode 100644 index 000000000..28dc3dacd --- /dev/null +++ b/internal/app/install/runtime_paths_test.go @@ -0,0 +1,20 @@ +package install + +import ( + "path/filepath" + "slices" + "testing" +) + +func TestRuntimeEnvForStateIncludesCodexHome(t *testing.T) { + baseDir := t.TempDir() + codexHome := filepath.Join(baseDir, "codex-home") + env := RuntimeEnvForState(InstallState{ + BaseDir: baseDir, + CodexHome: codexHome, + }) + + if !slices.Contains(env, "CODEX_HOME="+codexHome) { + t.Fatalf("RuntimeEnvForState() = %#v, want CODEX_HOME=%s", env, codexHome) + } +} diff --git a/internal/app/install/service.go b/internal/app/install/service.go index d5cc3f11c..a3666c66a 100644 --- a/internal/app/install/service.go +++ b/internal/app/install/service.go @@ -29,6 +29,7 @@ type Options struct { RelaydBinary string RelayServerURL string CodexRealBinary string + CodexHome string IntegrationMode WrapperIntegrationMode Integrations []WrapperIntegrationMode VSCodeSettingsPath string @@ -51,6 +52,7 @@ type InstallState struct { CurrentTrack ReleaseTrack `json:"currentTrack,omitempty"` CurrentVersion string `json:"currentVersion,omitempty"` CurrentBinaryPath string `json:"currentBinaryPath,omitempty"` + CodexHome string `json:"codexHome,omitempty"` VersionsRoot string `json:"versionsRoot,omitempty"` CurrentSlot string `json:"currentSlot,omitempty"` RollbackCandidate *RollbackCandidate `json:"rollbackCandidate,omitempty"` @@ -158,6 +160,7 @@ func (s *Service) Bootstrap(opts Options) (InstallState, error) { StatePath: statePath, ServiceManager: opts.ServiceManager, CurrentBinaryPath: installedBinary, + CodexHome: strings.TrimSpace(opts.CodexHome), Integrations: integrations, VSCodeSettingsPath: opts.VSCodeSettingsPath, BundleEntrypoint: opts.BundleEntrypoint, diff --git a/internal/app/install/service_entry_test.go b/internal/app/install/service_entry_test.go index fa85c99c8..3bfd742c9 100644 --- a/internal/app/install/service_entry_test.go +++ b/internal/app/install/service_entry_test.go @@ -168,6 +168,50 @@ func TestRenderSystemdUserUnitEscapesPathsWithoutQuotedAssignments(t *testing.T) } } +func TestBootstrapPreservesCodexHomeInSystemdUserUnit(t *testing.T) { + originalGOOS := serviceRuntimeGOOS + originalShellLookup := systemdShellEnvLookup + serviceRuntimeGOOS = "linux" + defer func() { + serviceRuntimeGOOS = originalGOOS + systemdShellEnvLookup = originalShellLookup + }() + + baseDir := t.TempDir() + stubServiceUserHome(t, baseDir) + codexHome := filepath.Join(baseDir, "custom-codex-home") + if err := os.MkdirAll(codexHome, 0o755); err != nil { + t.Fatalf("MkdirAll CODEX_HOME: %v", err) + } + t.Setenv("CODEX_HOME", codexHome) + t.Setenv("PATH", "/usr/bin") + systemdShellEnvLookup = func(env []string, key string) (string, error) { + return "/usr/bin", nil + } + + service := NewService() + state, err := service.Bootstrap(Options{ + BaseDir: baseDir, + BinaryPath: seedBinary(t, filepath.Join(baseDir, "source-bin", "codex-remote"), "binary"), + ServiceManager: ServiceManagerSystemdUser, + CurrentVersion: "dev", + RelayServerURL: "ws://127.0.0.1:9500/ws/agent", + CodexHome: codexHome, + }) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + t.Setenv("CODEX_HOME", "") + + unit, err := renderSystemdUserUnit(state) + if err != nil { + t.Fatalf("renderSystemdUserUnit: %v", err) + } + if want := "Environment=CODEX_HOME=" + systemdEscapeValue(codexHome); !strings.Contains(unit, want) { + t.Fatalf("systemd unit missing preserved CODEX_HOME %q:\n%s", want, unit) + } +} + func TestRenderSystemdUserUnitFallsBackToDefaultPATHWhenEnvironmentEmpty(t *testing.T) { originalGOOS := serviceRuntimeGOOS originalShellLookup := systemdShellEnvLookup diff --git a/internal/app/install/state.go b/internal/app/install/state.go index e864699ba..ddc0590b0 100644 --- a/internal/app/install/state.go +++ b/internal/app/install/state.go @@ -165,6 +165,7 @@ func canonicalizeInstallStatePaths(state *InstallState) { state.StatePath = canonicalInstallStatePath(state.StatePath) state.ServiceUnitPath = canonicalInstallStatePath(state.ServiceUnitPath) state.CurrentBinaryPath = canonicalInstallStatePath(state.CurrentBinaryPath) + state.CodexHome = canonicalInstallStatePath(state.CodexHome) state.VersionsRoot = canonicalInstallStatePath(state.VersionsRoot) state.VSCodeSettingsPath = canonicalInstallStatePath(state.VSCodeSettingsPath) state.BundleEntrypoint = canonicalInstallStatePath(state.BundleEntrypoint) diff --git a/internal/codexstate/sqlite_threads.go b/internal/codexstate/sqlite_threads.go index 43c7fe9d5..b1b2e262a 100644 --- a/internal/codexstate/sqlite_threads.go +++ b/internal/codexstate/sqlite_threads.go @@ -13,11 +13,11 @@ import ( _ "modernc.org/sqlite" + "github.com/kxn/codex-remote-feishu/internal/config" "github.com/kxn/codex-remote-feishu/internal/core/state" ) const ( - defaultCodexStateDir = ".codex" defaultCodexSQLiteFilename = "state_5.sqlite" internalProbeThreadPrefix = "_tmp-codex-thread-latency-" internalProbeAppPrefix = "_tmp-codex-appserver-" @@ -53,11 +53,11 @@ func NewDefaultSQLiteThreadCatalog(opts SQLiteThreadCatalogOptions) (*SQLiteThre } func DefaultSQLiteStatePath() (string, error) { - home, err := os.UserHomeDir() + codexHome, err := config.ResolveCodexHomeDir(os.Environ()) if err != nil { return "", err } - return filepath.Join(home, defaultCodexStateDir, defaultCodexSQLiteFilename), nil + return filepath.Join(codexHome, defaultCodexSQLiteFilename), nil } func NewSQLiteThreadCatalog(path string, opts SQLiteThreadCatalogOptions) *SQLiteThreadCatalog { diff --git a/internal/codexstate/sqlite_threads_test.go b/internal/codexstate/sqlite_threads_test.go index 060c37a8c..98425374f 100644 --- a/internal/codexstate/sqlite_threads_test.go +++ b/internal/codexstate/sqlite_threads_test.go @@ -118,6 +118,7 @@ func TestNewDefaultSQLiteThreadCatalogReturnsNilWhenStateFileMissing(t *testing. home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) // Windows: os.UserHomeDir() reads USERPROFILE + t.Setenv("CODEX_HOME", "") catalog, err := NewDefaultSQLiteThreadCatalog(SQLiteThreadCatalogOptions{Logf: func(string, ...any) {}}) if err != nil { @@ -128,6 +129,22 @@ func TestNewDefaultSQLiteThreadCatalogReturnsNilWhenStateFileMissing(t *testing. } } +func TestDefaultSQLiteStatePathUsesCodexHome(t *testing.T) { + home := t.TempDir() + codexHome := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("CODEX_HOME", codexHome) + + path, err := DefaultSQLiteStatePath() + if err != nil { + t.Fatalf("DefaultSQLiteStatePath: %v", err) + } + if want := filepath.Join(codexHome, defaultCodexSQLiteFilename); path != want { + t.Fatalf("DefaultSQLiteStatePath = %q, want %q", path, want) + } +} + func createThreadCatalogTestDB(t *testing.T) string { t.Helper() dbPath := filepath.Join(t.TempDir(), "state_5.sqlite") diff --git a/internal/codexstate/turn_patch_storage_test.go b/internal/codexstate/turn_patch_storage_test.go index 547597c2f..618676e4b 100644 --- a/internal/codexstate/turn_patch_storage_test.go +++ b/internal/codexstate/turn_patch_storage_test.go @@ -209,6 +209,22 @@ func TestTurnPatchStorageResolveThreadTargetFallsBackFromSQLiteDrift(t *testing. } } +func TestNewDefaultTurnPatchStorageUsesCodexHome(t *testing.T) { + codexHome := t.TempDir() + t.Setenv("CODEX_HOME", codexHome) + + storage, err := NewDefaultTurnPatchStorage(TurnPatchStorageOptions{Logf: func(string, ...any) {}}) + if err != nil { + t.Fatalf("NewDefaultTurnPatchStorage: %v", err) + } + if want := filepath.Join(codexHome, defaultCodexSessionsDirName); storage.sessionsRoot != want { + t.Fatalf("sessionsRoot = %q, want %q", storage.sessionsRoot, want) + } + if want := filepath.Join(codexHome, defaultTurnPatchStateDirName, defaultTurnPatchStateSubdir); storage.patchStateDir != want { + t.Fatalf("patchStateDir = %q, want %q", storage.patchStateDir, want) + } +} + func newTurnPatchStorageFixture(t *testing.T, lines []map[string]any) (*TurnPatchStorage, string, []byte) { t.Helper() baseDir := t.TempDir() diff --git a/internal/codexstate/turn_patch_types.go b/internal/codexstate/turn_patch_types.go index acd641579..4343007de 100644 --- a/internal/codexstate/turn_patch_types.go +++ b/internal/codexstate/turn_patch_types.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/kxn/codex-remote-feishu/internal/config" ) const ( @@ -175,11 +177,7 @@ func NewTurnPatchStorage(opts TurnPatchStorageOptions) (*TurnPatchStorage, error } func defaultCodexHomeDir() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, defaultCodexStateDir), nil + return config.ResolveCodexHomeDir(os.Environ()) } type noopTurnPatchMetadataReconciler struct{} diff --git a/internal/config/codex_model_provider_env.go b/internal/config/codex_model_provider_env.go index dd2bb1c1a..6c58e6bb4 100644 --- a/internal/config/codex_model_provider_env.go +++ b/internal/config/codex_model_provider_env.go @@ -153,17 +153,18 @@ func supplementCodexModelProviderEnv(env, args []string) ([]string, error) { } func resolveCodexConfigPath(env []string) (string, error) { - envMap := envSliceToMap(env) - if codexHome := strings.TrimSpace(envMap["CODEX_HOME"]); codexHome != "" { - info, err := os.Stat(codexHome) - if err != nil { - return "", fmt.Errorf("resolve CODEX_HOME %q: %w", codexHome, err) - } - if !info.IsDir() { - return "", fmt.Errorf("CODEX_HOME %q is not a directory", codexHome) - } - return filepath.Join(codexHome, codexConfigFileName), nil + codexHome, err := ResolveCodexHomeDir(env) + if err != nil { + return "", err + } + return filepath.Join(codexHome, codexConfigFileName), nil +} + +func ResolveCodexHomeDir(env []string) (string, error) { + if codexHome, err := ResolveExplicitCodexHomeDir(env); codexHome != "" || err != nil { + return codexHome, err } + envMap := envSliceToMap(env) home := strings.TrimSpace(envMap["HOME"]) if home == "" { home = currentUserHomeDir() @@ -171,7 +172,22 @@ func resolveCodexConfigPath(env []string) (string, error) { if home == "" { return "", fmt.Errorf("resolve codex home: home directory is unavailable") } - return filepath.Join(home, ".codex", codexConfigFileName), nil + return filepath.Join(home, ".codex"), nil +} + +func ResolveExplicitCodexHomeDir(env []string) (string, error) { + codexHome := strings.TrimSpace(envSliceToMap(env)["CODEX_HOME"]) + if codexHome == "" { + return "", nil + } + info, err := os.Stat(codexHome) + if err != nil { + return "", fmt.Errorf("resolve CODEX_HOME %q: %w", codexHome, err) + } + if !info.IsDir() { + return "", fmt.Errorf("CODEX_HOME %q is not a directory", codexHome) + } + return filepath.Clean(codexHome), nil } func currentUserHomeDir() string {