diff --git a/src/codex/home.ts b/src/codex/home.ts index 71a2ad0a0a9..49eb0a0be0a 100644 --- a/src/codex/home.ts +++ b/src/codex/home.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join, posix, resolve, win32 } from "node:path"; -import { expandUserPath } from "../config"; +import { expandUserPath } from "../config/paths"; import { redactUserPath } from "../lib/redact"; export type CodexHomeDeps = { diff --git a/structure/codex-home.md b/structure/codex-home.md index ae7c6afca32..bf3683b265b 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -41,6 +41,7 @@ user off a Windows home they were running against, and a stat failure other than local home rather than switching to a different one. Codex runtime discovery (src/codex/runtime.ts) also reads this home on Linux: after an explicit runtime, PATH, and the ordinary install locations, it enumerates the direct children of /bin/wsl//codex newest first, probes each through the isolated --version seam, and re-enumerates on every resolve so a Desktop update that replaces the hash directory is picked up (issue 5635). An explicitly set path that is unreadable or not a directory is an error, not a fallback: silently using a different home than the operator named would write provider state where nobody is looking for it. A fresh install can have that directory but no `config.toml` yet; applying the integration then creates an empty `config.toml` there (never overwriting an existing file) and continues, while a missing home directory is refused with instructions to start Codex once or set `CODEX_HOME` (issue 5422). +`src/codex/home.ts` imports path expansion directly from `src/config/paths.ts`, so a fresh WSL process can resolve its Codex home without re-entering the config facade before the resolver initializes. The managed files are: ```text diff --git a/tests/cli/cli-help.test.ts b/tests/cli/cli-help.test.ts index 9e2ef5f31ea..728fc8cc52d 100644 --- a/tests/cli/cli-help.test.ts +++ b/tests/cli/cli-help.test.ts @@ -6,12 +6,14 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Database } from "bun:sqlite"; import { EXPORT_CLIENT_IDS } from "../../src/clients/config-export"; +import { isSystemd, unitPath } from "../../src/service/systemd"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); const cliPath = join(repoRoot, "src", "cli", "index.ts"); const binPath = join(repoRoot, "bin", "ocx.mjs"); +const dockerHost = process.platform === "linux" && existsSync("/.dockerenv"); // Every case below spawns the real CLI. A hung child without a spawnSync timeout can // pin the whole shard for the full 15-minute CI budget (observed on Linux test 3/4 @@ -197,7 +199,9 @@ describe("CLI subcommand help", () => { expect(result.stdout).toContain("Default provider: openai"); expect(result.stdout).toContain("Codex autostart: disabled"); expect(result.stdout).toContain("Service:"); - expect(result.stdout).toContain(join(opencodexHome, "service.log")); + if (dockerHost) expect(result.stdout).toContain("Service: unsupported in Docker"); + else if (process.platform === "linux" && !isSystemd()) expect(result.stdout).toContain("Service: unsupported: systemd not found"); + else expect(result.stdout).toContain(join(opencodexHome, "service.log")); expect(result.stdout).toContain("Codex autostart shim"); // #2411: status must name the routing kind it already computes. The // proxy is down in this fixture, so the unused-proxy warning must stay @@ -411,9 +415,13 @@ describe("CLI subcommand help", () => { expect(result.stdout).toContain("Usage: ocx start [--port ] [--socks5 [host:port] | --socks5-off]"); }); - test("invalid service and codex-shim usage include remove alias", () => { + test("invalid service and codex-shim usage fail with platform guidance", () => { const cases = [ - { args: ["service", "nope"], expected: "Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove|claim]" }, + { args: ["service", "nope"], expected: dockerHost + ? "Docker detected. Run 'ocx start' directly instead of using the service manager." + : process.platform === "linux" && !isSystemd() && !existsSync(unitPath()) + ? "systemd not found. Run 'ocx start' under your process supervisor." + : "Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove|claim]" }, { args: ["codex-shim", "nope"], expected: "Usage: ocx codex-shim " }, ]; diff --git a/tests/codex-integration/codex-home-wsl.test.ts b/tests/codex-integration/codex-home-wsl.test.ts index e56c60d1695..f2de8acb6b7 100644 --- a/tests/codex-integration/codex-home-wsl.test.ts +++ b/tests/codex-integration/codex-home-wsl.test.ts @@ -1,10 +1,41 @@ import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { defaultCodexHome, wslAutomountRoot, listWslWindowsCodexHomes } from "../../src/codex/home"; import { isWindowsInteropDir } from "../../src/codex/shim"; import { currentServiceHomes, serviceCodexHomeMatchesInstall } from "../../src/service"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; describe("wsl.conf automount root", () => { + test("loads and expands the home resolver first in a fresh WSL-like process", async () => { + const home = mkdtempSync(join(tmpdir(), "ocx-wsl-import-")); + try { + const child = Bun.spawn([process.execPath, "--eval", ` + const { wslAutomountRoot, resolveCodexHomeDir } = await import("./src/codex/home.ts"); + console.log(wslAutomountRoot({ wslConf: null })); + console.log(resolveCodexHomeDir()); + `], { + cwd: repoPath(), + env: { ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: "~/.codex", WSL_DISTRO_NAME: "Ubuntu" }, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout.trim().split(/\r?\n/)).toEqual(["/mnt", join(home, ".codex")]); + } finally { + removeTreeWithRetry(home); + } + }, 15_000); + test("defaults to /mnt when wsl.conf is absent or silent", () => { expect(wslAutomountRoot({ wslConf: null })).toBe("/mnt"); expect(wslAutomountRoot({ wslConf: "[boot]\nsystemd=true\n" })).toBe("/mnt"); diff --git a/tests/service/service-probe-docker.test.ts b/tests/service/service-probe-docker.test.ts index e5bbbbf4b8b..de7b040475d 100644 --- a/tests/service/service-probe-docker.test.ts +++ b/tests/service/service-probe-docker.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { mkdtempSync} from "node:fs"; +import { existsSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,6 +8,8 @@ import { type ProbeRunner, } from "../../src/service-manager-probe"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { serviceLogPath, serviceStatusSummary } from "../../src/service"; +import { isSystemd } from "../../src/service/systemd"; test("Linux reports systemd absent when systemctl cannot be spawned", () => { const home = mkdtempSync(join(tmpdir(), "ocx-probe-docker-")); @@ -27,3 +29,10 @@ test("Linux reports systemd absent when systemctl cannot be spawned", () => { removeTreeWithRetry(home); } }); + +test("status summary reports service availability or the service log path", () => { + const summary = serviceStatusSummary(); + if (process.platform === "linux" && existsSync("/.dockerenv")) expect(summary).toBe("unsupported in Docker"); + else if (process.platform === "linux" && !isSystemd()) expect(summary).toBe("unsupported: systemd not found"); + else expect(summary).toContain(serviceLogPath()); +}); diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index 5daaa02ec9c..acbe7a7aeae 100644 --- a/tests/service/service.test.ts +++ b/tests/service/service.test.ts @@ -7,7 +7,7 @@ import { pathToFileURL } from "node:url"; import * as serviceModule from "../../src/service"; import { saveConfig } from "../../src/config"; import { windowsEnvIndirectBatchValue } from "../../src/lib/win-paths"; -import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml as buildWindowsTaskXmlProduction, buildWindowsTaskXmlDocument, deriveWindowsServiceDiagnostic, deriveWindowsServiceDiagnosticForCurrentUser, expectedLaunchdCommand, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, reportServiceServing, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, stableLauncherEntry, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy as windowsTaskRegistrationHealthyProduction } from "../../src/service"; +import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml as buildWindowsTaskXmlProduction, buildWindowsTaskXmlDocument, deriveWindowsServiceDiagnostic, deriveWindowsServiceDiagnosticForCurrentUser, expectedLaunchdCommand, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, reportServiceServing, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, stableLauncherEntry, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy as windowsTaskRegistrationHealthyProduction } from "../../src/service"; import type { ServiceDiagnostic } from "../../src/service"; import { definitionCarriesCredential, resolvedProxyEnv, writeServiceApiTokenFile, writeServiceDefinitionFile } from "../../src/service"; import { buildWinswXml } from "../../src/lib/winsw"; @@ -2719,12 +2719,6 @@ describe("service diagnostics", () => { expect(parseServiceInstallState({ ...valid, version: 1, backend: undefined })?.version).toBe(1); }); - test("status summary exposes the service log path", () => { - const summary = serviceStatusSummary(); - - expectTextToContainPath(summary, serviceLogPath()); - }); - test("flags stale baked service paths recorded at install time", () => { const oldOpenCodexHome = process.env.OPENCODEX_HOME; const stateDir = join(TEST_DIR, "baked-paths-home");