diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 24a63d5c7ad..99c3e767166 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -463,6 +463,12 @@ supersedes it rather than replacing it. A state file with no ownership record means the CLI installation owns the runtime, which is what every installation made before this feature is in. Nothing changes for you until an app takes over. +Home paths inside a state record are compared with the current home by the physical directory they +resolve to, not just their spelling. A junction or symlink recorded under an older install still +names the same home and keeps working after the move; an alias that no longer resolves is only +treated as a different home when its recorded spelling also differs from the current one, so a +stale mount still produces the foreign-owner refusal instead of silently claiming the runtime. + While something other than this CLI owns the runtime, the subcommands that would **activate** your registration refuse instead: diff --git a/src/integrations/native/ownership-preflight.ts b/src/integrations/native/ownership-preflight.ts index 7ca9ca9771f..065870127d2 100644 --- a/src/integrations/native/ownership-preflight.ts +++ b/src/integrations/native/ownership-preflight.ts @@ -18,9 +18,11 @@ import { import { currentServiceHomes, inspectServiceStateEvidence, - serviceHomeMatches, + compareServicePathToInstall, type ServiceStateEvidence, + type ServicePathComparison, } from "../../service"; +import type { CodexHomeDeps } from "../../codex/home"; import { createWindowsTaskListingCache, inspectServiceManagerInstallation, @@ -70,15 +72,25 @@ export interface OwnershipInspection { readonly reason: string; } -function claimNamesDifferentHome( +function claimComparesToCurrentHomes( claim: ServiceManagerClaim, current: { codexHome: string; opencodexHome: string }, -): boolean { + deps: CodexHomeDeps, +): ServicePathComparison { // A definition that OMITS a home is not a definition that disagrees about it: // an install run without CODEX_HOME set writes no such key at all. - if (claim.homes.codexHome !== null && !serviceHomeMatches(claim.homes.codexHome, current.codexHome)) return true; - if (claim.homes.opencodexHome !== null && !serviceHomeMatches(claim.homes.opencodexHome, current.opencodexHome)) return true; - return false; + let indeterminate = false; + if (claim.homes.codexHome !== null) { + const verdict = compareServicePathToInstall(claim.homes.codexHome, current.codexHome, deps); + if (verdict === "different") return "different"; + indeterminate ||= verdict === "unknown"; + } + if (claim.homes.opencodexHome !== null) { + const verdict = compareServicePathToInstall(claim.homes.opencodexHome, current.opencodexHome, deps); + if (verdict === "different") return "different"; + indeterminate ||= verdict === "unknown"; + } + return indeterminate ? "unknown" : "same"; } /** @@ -109,6 +121,8 @@ export interface OwnershipDeps extends ProbeDeps { */ readonly statePaths?: readonly string[]; readonly currentHomes?: { codexHome: string; opencodexHome: string }; + /** Test seam for resolving recorded home aliases to their physical directory. */ + readonly realpathSync?: (path: string) => string; } export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): OwnershipInspection { @@ -130,22 +144,35 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership // Mirrors that disagree with each other are not a majority vote. for (const one of valid) { for (const other of valid) { - if (!serviceHomeMatches(one.state.codexHome, other.state.codexHome) - || !serviceHomeMatches(one.state.opencodexHome, other.state.opencodexHome)) { + if (compareServicePathToInstall(one.state.codexHome, other.state.codexHome, deps) !== "same" + || compareServicePathToInstall(one.state.opencodexHome, other.state.opencodexHome, deps) !== "same") { return { ownership: "unknown", reason: "two service state files disagree about which homes are installed" }; } } } - const foreign = valid.find(e => - !serviceHomeMatches(e.state.codexHome, current.codexHome) - || !serviceHomeMatches(e.state.opencodexHome, current.opencodexHome)); + const evidenceComparesDifferent = (e: Extract): boolean => + compareServicePathToInstall(e.state.codexHome, current.codexHome, deps) === "different" + || compareServicePathToInstall(e.state.opencodexHome, current.opencodexHome, deps) === "different"; + const evidenceComparesIndeterminate = (e: Extract): boolean => + compareServicePathToInstall(e.state.codexHome, current.codexHome, deps) === "unknown" + || compareServicePathToInstall(e.state.opencodexHome, current.opencodexHome, deps) === "unknown"; + const foreign = valid.find(evidenceComparesDifferent); if (foreign) { return { ownership: "foreign", reason: `a service is installed for CODEX_HOME=${foreign.state.codexHome} / OPENCODEX_HOME=${foreign.state.opencodexHome}`, }; } + // A resolution that could not run (EACCES, EPERM, a vanished directory, transient I/O) + // proves neither same nor different — report it as unknown, never as foreign. + const indeterminateEvidence = valid.find(evidenceComparesIndeterminate); + if (indeterminateEvidence) { + return { + ownership: "unknown", + reason: `a recorded home in ${indeterminateEvidence.path} could not be resolved for comparison`, + }; + } // The manager assets live under the effective OPENCODEX_HOME. Production // callers do not inject ProbeDeps.configDir, so derive it from the same @@ -162,7 +189,7 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership return { ownership: "unknown", reason: "more than one service manager holds a registration for this proxy" }; } if (manager.kind === "present") { - const disagreeing = manager.claims.find(claim => claimNamesDifferentHome(claim, current)); + const disagreeing = manager.claims.find(claim => claimComparesToCurrentHomes(claim, current, deps) === "different"); if (disagreeing) { /* * The state file says this home and the definition says another. An @@ -175,6 +202,13 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership reason: `${disagreeing.backend} is installed from ${disagreeing.definitionPath}, which names different homes than the recorded service state`, }; } + const indeterminateClaim = manager.claims.find(claim => claimComparesToCurrentHomes(claim, current, deps) === "unknown"); + if (indeterminateClaim) { + return { + ownership: "unknown", + reason: `the homes recorded in ${indeterminateClaim.definitionPath} could not be resolved for comparison`, + }; + } // A manager backend that disagrees with the recorded state (e.g. state says // native/WinSW but a scheduler task is found) is an interrupted backend // switch: it does not prove which manager owns the installation. v1 state diff --git a/src/service.ts b/src/service.ts index 8be7805d0ad..3706b1d3fe9 100644 --- a/src/service.ts +++ b/src/service.ts @@ -6,8 +6,8 @@ * restore it via the command. */ -export type { ServiceBackend, ServiceInstallState, ServiceStateEvidence, ServiceStateResolution, ServiceOwner, ServiceOwnership, ServiceOwnershipSubject, ServiceOwnershipResolution, ServiceStateSwapDeps, RecordServiceOwnerRequest, RecordServiceOwnerDeps, ReleaseServiceOwnerDeps, RemoveServiceStateDeps } from "./service/state"; -export { SERVICE_MANAGED_ENV, SERVICE_OWNERSHIP_PROTOCOL_VERSION, SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, stableLauncherEntry, serviceLogPath, serviceStatePaths, serviceStatePathsForOpenCodexHome, parseServiceInstallState, parseServiceOwnership, inspectServiceStateEvidence, resolveServiceState, currentServiceHomes, serviceHomeMatches, serviceCodexHomeMatchesInstall, readServiceBackend, serviceReinstallArgs, serviceInstallArgs, ServiceStateConflictError, ServiceOwnershipSubjectMismatchError, ServiceOwnershipSubjectUnknownError, ServiceTakeoverCompatibilityChangedError, swapServiceInstallState, removeServiceInstallStateRecords, serviceOwnership, resolveServiceOwnership, sameServiceOwnershipSubject, desktopOwnsService, ownershipGrantedTo, recordServiceOwner, releaseServiceOwner } from "./service/state"; +export type { ServiceBackend, ServiceInstallState, ServiceStateEvidence, ServiceStateResolution, ServiceOwner, ServiceOwnership, ServiceOwnershipSubject, ServiceOwnershipResolution, ServicePathComparison, ServiceStateSwapDeps, RecordServiceOwnerRequest, RecordServiceOwnerDeps, ReleaseServiceOwnerDeps, RemoveServiceStateDeps } from "./service/state"; +export { SERVICE_MANAGED_ENV, SERVICE_OWNERSHIP_PROTOCOL_VERSION, SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, stableLauncherEntry, serviceLogPath, serviceStatePaths, serviceStatePathsForOpenCodexHome, parseServiceInstallState, parseServiceOwnership, inspectServiceStateEvidence, resolveServiceState, currentServiceHomes, serviceHomeMatches, serviceCodexHomeMatchesInstall, servicePathMatchesInstall, compareServicePathToInstall, readServiceBackend, serviceReinstallArgs, serviceInstallArgs, ServiceStateConflictError, ServiceOwnershipSubjectMismatchError, ServiceOwnershipSubjectUnknownError, ServiceTakeoverCompatibilityChangedError, swapServiceInstallState, removeServiceInstallStateRecords, serviceOwnership, resolveServiceOwnership, sameServiceOwnershipSubject, desktopOwnsService, ownershipGrantedTo, recordServiceOwner, releaseServiceOwner } from "./service/state"; export type { OwnershipMutationLeaseOptions, OwnershipMutationLease } from "./service/ownership-mutation-lease.mjs"; export { acquireOwnershipMutationLease, withOwnershipMutationLease } from "./service/ownership-mutation-lease.mjs"; export type { ManagingCliRole, ManagingCliObservation, RegisteredManagingCliInvocation, ServiceTakeoverCompatibilityInput, ServiceTakeoverCompatibility } from "./service/ownership-compatibility"; diff --git a/src/service/guards.ts b/src/service/guards.ts index 436d053953f..93ad78e174f 100644 --- a/src/service/guards.ts +++ b/src/service/guards.ts @@ -10,7 +10,7 @@ import { recordOwnedConfigPath } from "../lib/config-ownership"; import { isTestHomeGuardArmed } from "../lib/test-home-guard"; import { diagnoseService } from "./diagnostics"; import type { ServiceDiagnostic } from "./diagnostics"; -import { currentCodexHome, currentOpenCodexHome, normalizePathForCompare, resolveServiceState, serviceCodexHomeMatchesInstall } from "./state"; +import { currentCodexHome, currentOpenCodexHome, resolveServiceState, serviceCodexHomeMatchesInstall, servicePathMatchesInstall } from "./state"; import { resolveCodexSqliteHome } from "../codex/paths"; import type { CodexHomeDeps } from "../codex/home"; import { isLoopbackHostname } from "../codex/loopback-target"; @@ -58,9 +58,8 @@ export function assertServiceEnvironmentMatchesInstall(deps: CodexHomeDeps = {}) `Rerun with CODEX_HOME=${state.codexHome} so native Codex restore updates the recorded home.`, ); } - const expectedOpenCodexHome = normalizePathForCompare(state.opencodexHome); - const actualOpenCodexHome = normalizePathForCompare(currentOpenCodexHome()); - if (expectedOpenCodexHome !== actualOpenCodexHome) { + const actualOpenCodexHome = currentOpenCodexHome(); + if (!servicePathMatchesInstall(state.opencodexHome, actualOpenCodexHome, deps)) { throw new ServiceOwnershipError( `Service was installed with OPENCODEX_HOME=${state.opencodexHome}, but current OPENCODEX_HOME=${currentOpenCodexHome()}. ` + "Run the service command from the same OpenCodex home so service state and secrets match.", @@ -68,7 +67,7 @@ export function assertServiceEnvironmentMatchesInstall(deps: CodexHomeDeps = {}) } if (state.codexSqliteHome !== undefined) { const actualCodexSqliteHome = resolveCodexSqliteHome({ codexHome: actualCodexHome }); - if (normalizePathForCompare(state.codexSqliteHome) !== normalizePathForCompare(actualCodexSqliteHome)) { + if (!servicePathMatchesInstall(state.codexSqliteHome, actualCodexSqliteHome, deps)) { throw new ServiceOwnershipError( `Service was installed with Codex SQLite home=${state.codexSqliteHome}, but the current Codex SQLite home=${actualCodexSqliteHome}. ` + "Run the service command with the same sqlite_home configuration and CODEX_SQLITE_HOME so native Codex history restore updates the correct database.", diff --git a/src/service/state.ts b/src/service/state.ts index f3844feacea..d4f390773e4 100644 --- a/src/service/state.ts +++ b/src/service/state.ts @@ -1,4 +1,4 @@ -import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { accessSync, constants as fsConstants, existsSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path"; import { expandUserPath, getConfigDir } from "../config"; @@ -857,8 +857,35 @@ export function serviceHomeMatches(a: string, b: string): boolean { return normalizePathForCompare(a) === normalizePathForCompare(b); } +export type ServicePathComparison = "same" | "different" | "unknown"; + +/** + * Tri-state physical-home compare. A realpath failure (EACCES, EPERM, a + * vanished directory, transient I/O) is "unknown", not "different": callers + * deciding whether a home is foreign must not turn an unreadable resolution + * into a definitive mismatch. Lifecycle guards may still fail closed on + * "unknown". + */ +export function compareServicePathToInstall(recorded: string, current: string, deps: CodexHomeDeps = {}): ServicePathComparison { + if (serviceHomeMatches(recorded, current)) return "same"; + const realpath = deps.realpathSync ?? realpathSync; + try { + return serviceHomeMatches(realpath(recorded), realpath(current)) ? "same" : "different"; + } catch { + return "unknown"; + } +} + +/** Lexical compare first; when spellings differ, compare the directories both resolve to so a + * junction or symlink spelling recorded by an older install still names the same home. + * Fails closed on an indeterminate resolution — ownership classification needs the + * tri-state {@link compareServicePathToInstall} instead. */ +export function servicePathMatchesInstall(recorded: string, current: string, deps: CodexHomeDeps = {}): boolean { + return compareServicePathToInstall(recorded, current, deps) === "same"; +} + export function serviceCodexHomeMatchesInstall(recordedHome: string, deps: CodexHomeDeps = {}): boolean { - return serviceHomeMatches(recordedHome, currentCodexHome(deps)); + return servicePathMatchesInstall(recordedHome, currentCodexHome(deps), deps); } /** Single accessor for backend-sensitive service code — v1/legacy state maps to scheduler. */ diff --git a/structure/codex-home.md b/structure/codex-home.md index 808e0bb6b8a..b206ce8a7ed 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -95,7 +95,12 @@ to the single discoverable Windows Desktop home; recording Linux `~/.codex` inst later repair or uninstall look foreign even though the service and runtime were started from the same environment. A record written before that discovery still names Linux `~/.codex`; service commands refuse it and name the recorded home to rerun with, because stop and repair would otherwise -restore a different home. An explicit `CODEX_HOME` remains authoritative; nothing migrates implicitly. +restore a different home. A recorded spelling that still resolves to the same physical directory — +a junction or symlink alias — counts as the same home for the ownership check, the recorded SQLite +home, and the unattended ownership preflight. Access or transient I/O errors are unknown rather +than foreign in preflight; distinct spellings with missing/non-directory paths remain mismatches. +Lifecycle guards still fail closed on unknown and can throw `ServiceOwnershipError`. +An explicit `CODEX_HOME` remains authoritative; nothing migrates implicitly. > Decision record: [ADR-0006](decisions/ADR-0006-codex-home.md) diff --git a/structure/runtime.md b/structure/runtime.md index 84c3c0d7095..db85478cc4b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -140,7 +140,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | | `src/codex/shim.ts` | Codex autostart shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and transactionally restores complete, stable external launcher replacements without a watcher or PATH rediscovery. | -| `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks): always-on proxy with crash restart. Facade over the `src/service/` leaves — `src/service/launchd.ts`, `src/service/systemd.ts`, `src/service/windows-ops.ts`, `src/service/windows-scheduler.ts`, `src/service/windows-taskxml.ts`, `src/service/state.ts`, `src/service/guards.ts`, `src/service/health.ts`, `src/service/repair.ts`, `src/service/orchestration.ts`, `src/service/diagnostics.ts`, `src/service/cli.ts`. Elevated Task Scheduler repair stages bounded payloads; the unelevated launcher pins every namespace ancestor and payload with non-reparse handles that deny write/delete sharing on the payload and delete sharing on each ancestor until UAC processing exits. | +| `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks): always-on proxy with crash restart. Facade over the `src/service/` leaves — `src/service/launchd.ts`, `src/service/systemd.ts`, `src/service/windows-ops.ts`, `src/service/windows-scheduler.ts`, `src/service/windows-taskxml.ts`, `src/service/state.ts`, `src/service/guards.ts`, `src/service/health.ts`, `src/service/repair.ts`, `src/service/orchestration.ts`, `src/service/diagnostics.ts`, `src/service/cli.ts`. Codex-home ownership accepts either the recorded path or the same existing physical directory so path aliases remain compatible across upgrades. Elevated Task Scheduler repair stages bounded payloads; the unelevated launcher pins every namespace ancestor and payload with non-reparse handles that deny write/delete sharing on the payload and delete sharing on each ancestor until UAC processing exits. | `src/cli/provider.ts` accepts the Google-only `--google-tool-schema-policy` creation flag and rejects an unknown value or non-Google effective adapter before persistence. The persisted field and default diff --git a/tests/codex-integration/codex-home-wsl.test.ts b/tests/codex-integration/codex-home-wsl.test.ts index 75733559782..119aaad7b4e 100644 --- a/tests/codex-integration/codex-home-wsl.test.ts +++ b/tests/codex-integration/codex-home-wsl.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { defaultCodexHome, wslAutomountRoot, listWslWindowsCodexHomes } from "../../src/codex/home"; @@ -212,4 +212,56 @@ describe("wsl.conf automount root", () => { env: { ...deps.env, CODEX_HOME: windowsCodexHome }, })).toBe(false); }); + + test("service ownership accepts an older lexical spelling of the current physical home", () => { + const lexicalHome = "/home/example/.codex"; + const physicalHome = "/srv/codex-home"; + const deps = { + env: {}, + homedir: () => "/home/example", + statSync: (() => ({ isDirectory: () => true })) as never, + realpathSync: (path: string) => path === lexicalHome ? physicalHome : path, + }; + + expect(serviceCodexHomeMatchesInstall(lexicalHome, deps)).toBe(true); + expect(serviceCodexHomeMatchesInstall("/srv/other-home", deps)).toBe(false); + }); + + // The injected realpath seam above isolates the policy; this exercises the production + // resolver itself — a real junction (Windows) or directory symlink spells the same + // physical home two ways. + test("service ownership accepts a real junction spelling of the physical home", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-junction-home-")); + try { + const physical = join(root, "real-codex"); + const alias = join(root, "alias-codex"); + mkdirSync(physical, { recursive: true }); + symlinkSync(physical, alias, "junction"); + + const deps = { env: { CODEX_HOME: physical }, homedir: () => root }; + expect(serviceCodexHomeMatchesInstall(alias, deps)).toBe(true); + expect(serviceCodexHomeMatchesInstall(join(root, "other-codex"), deps)).toBe(false); + } finally { + removeTreeWithRetry(root); + } + }); + + // The home directory itself can sit under a junctioned ancestor — then the recorded + // spelling resolves through an intermediate link, not a link at the final component. + test("service ownership resolves a home through a junctioned parent directory", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-junction-parent-")); + try { + const parentReal = join(root, "parent-real"); + const physical = join(parentReal, ".codex"); + mkdirSync(physical, { recursive: true }); + const parentAlias = join(root, "parent-alias"); + symlinkSync(parentReal, parentAlias, "junction"); + + const deps = { env: { CODEX_HOME: physical }, homedir: () => root }; + expect(serviceCodexHomeMatchesInstall(join(parentAlias, ".codex"), deps)).toBe(true); + expect(serviceCodexHomeMatchesInstall(join(parentAlias, "other"), deps)).toBe(false); + } finally { + removeTreeWithRetry(root); + } + }); }); diff --git a/tests/codex-integration/codex-service-manager-probe-hardening.test.ts b/tests/codex-integration/codex-service-manager-probe-hardening.test.ts index f468a8ff510..a7ece534269 100644 --- a/tests/codex-integration/codex-service-manager-probe-hardening.test.ts +++ b/tests/codex-integration/codex-service-manager-probe-hardening.test.ts @@ -552,6 +552,9 @@ describe("Windows ownership probe hardening regressions", () => { winswStatus: () => "nonexistent", statePaths: [statePath], currentHomes: { codexHome: currentCodexHome, opencodexHome: configDir }, + // Keep the foreign-vs-current comparison lexical: the fixture paths are + // intentionally not on disk, and a real ENOENT is "unknown", not "different". + realpathSync: (path: string) => path, }); expect(result.ownership).toBe("unknown"); @@ -687,6 +690,7 @@ describe("Windows ownership probe hardening regressions", () => { winswStatus: () => "started", statePaths: [statePath], currentHomes: { codexHome, opencodexHome: configDir }, + realpathSync: (path: string) => path, }); expect(result.ownership).toBe("unknown"); diff --git a/tests/codex-integration/codex-service-manager-probe.test.ts b/tests/codex-integration/codex-service-manager-probe.test.ts index 7d0dff0cbae..9fd445d5839 100644 --- a/tests/codex-integration/codex-service-manager-probe.test.ts +++ b/tests/codex-integration/codex-service-manager-probe.test.ts @@ -819,7 +819,7 @@ describe("ownership refuses what it cannot prove", () => { * homedir(), which no test sandbox moves. Left alone, these fixtures would * read the developer's real installation and call their own machine foreign. */ - function own(extra: { run: ProbeRunner }) { + function own(extra: { run: ProbeRunner; realpathSync?: (path: string) => string }) { const codexHome = join(home, ".codex"); const opencodexHome = join(home, ".opencodex"); return { @@ -861,7 +861,43 @@ describe("ownership refuses what it cannot prove", () => { const { opencodexHome } = useHomes(); writeState(opencodexHome, "/elsewhere/.codex", "/elsewhere/.opencodex"); const { run } = recorder(() => ({ status: 113 })); - expect(inspectNativeCodexOwnership(own({ run })).ownership).toBe("foreign"); + // Identity resolution keeps this comparison lexical so it proves "different", not "unknown". + const realpathSync = (path: string) => path; + expect(inspectNativeCodexOwnership(own({ run, realpathSync })).ownership).toBe("foreign"); + }); + + /* + * A realpath failure (EACCES, EPERM, a directory that vanished mid-compare, + * transient I/O) is not evidence the home is different. Collapsing it to + * "different" would make the unattended preflight report a definitive + * foreign install — and wrongly block stop, repair, uninstall, and native + * writes with incorrect recovery guidance — on nothing but an I/O hiccup. + */ + test("an unresolvable recorded home is unknown, not foreign", () => { + const { codexHome, opencodexHome } = useHomes(); + const recordedHome = join(home, "recorded-alias"); + writeState(opencodexHome, recordedHome, opencodexHome); + const { run } = recorder(() => ({ status: 113 })); + const realpathSync = (path: string) => { + if (path === recordedHome) throw Object.assign(new Error("access denied"), { code: "EACCES" }); + return path; + }; + + const result = inspectNativeCodexOwnership(own({ run, realpathSync })); + expect(result.ownership).toBe("unknown"); + expect(result.reason).toContain("could not be resolved"); + expect(result.reason).not.toContain("foreign"); + }); + + // An older install may have recorded a junction or symlink spelling of the + // home this process now knows canonically — same directory, different name. + test("state spelling the current home through an alias is owned", () => { + const { codexHome, opencodexHome } = useHomes(); + const aliasHome = join(home, "codex-alias"); + writeState(opencodexHome, aliasHome, opencodexHome); + const { run } = recorder(() => ({ status: 113 })); + const realpathSync = (path: string) => path === aliasHome ? codexHome : path; + expect(inspectNativeCodexOwnership(own({ run, realpathSync })).ownership).toBe("owned"); }); /* @@ -876,7 +912,10 @@ describe("ownership refuses what it cannot prove", () => { writePlist("/elsewhere/.codex", "/elsewhere/.opencodex"); const { run } = recorder(() => ({ status: 113 })); - const result = inspectNativeCodexOwnership(own({ run })); + // Identity resolution keeps the claim comparison lexical: the fixture + // intends a genuinely different home, not an unresolvable one. + const realpathSync = (path: string) => path; + const result = inspectNativeCodexOwnership(own({ run, realpathSync })); expect(result.ownership).toBe("unknown"); expect(result.reason).toContain("different homes"); }); diff --git a/tests/service/service-sqlite-home.test.ts b/tests/service/service-sqlite-home.test.ts index 17af32d6673..faceb389596 100644 --- a/tests/service/service-sqlite-home.test.ts +++ b/tests/service/service-sqlite-home.test.ts @@ -75,6 +75,28 @@ describe("service install state Codex SQLite home binding", () => { expect(() => assertServiceEnvironmentMatchesInstall()).toThrow("Codex SQLite home"); }); + test("accepts a recorded SQLite home that names the same physical directory through an alias", () => { + const codexHome = join(TEST_DIR, "codex-home"); + const sqliteHome = join(TEST_DIR, "sqlite-home"); + const sqliteAlias = join(TEST_DIR, "sqlite-alias"); + process.env.CODEX_HOME = codexHome; + process.env.CODEX_SQLITE_HOME = sqliteHome; + writeInstallState({ + version: 2, + codexHome, + codexSqliteHome: sqliteAlias, + opencodexHome: TEST_DIR, + backend: "scheduler", + }); + + const realpathSync = (path: string) => path === sqliteAlias ? sqliteHome : path; + expect(() => assertServiceEnvironmentMatchesInstall({ realpathSync })).not.toThrow(); + + const otherHome = join(TEST_DIR, "other-sqlite-home"); + const divergentRealpath = (path: string) => path === sqliteAlias ? otherHome : path; + expect(() => assertServiceEnvironmentMatchesInstall({ realpathSync: divergentRealpath })).toThrow("Codex SQLite home"); + }); + test("parses codexSqliteHome and rejects an empty value", () => { const valid = { version: 2, diff --git a/tests/service/service-wsl-home-ownership.test.ts b/tests/service/service-wsl-home-ownership.test.ts index 1d5ec1a2342..50c989ce242 100644 --- a/tests/service/service-wsl-home-ownership.test.ts +++ b/tests/service/service-wsl-home-ownership.test.ts @@ -61,6 +61,7 @@ describe("WSL service ownership after Windows home discovery", () => { expect(inspectNativeCodexOwnership({ statePaths: [statePath], currentHomes: { codexHome: windowsHome, opencodexHome: root }, + realpathSync: deps.realpathSync, }).ownership).toBe("foreign"); });