Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/cli/codex-shim-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
getCodexRoutingKind,
type CodexRoutingKind,
} from "../codex/inject";
import { diagnoseCodexShim, type CodexShimDiagnostic } from "../codex/shim";
import { findFirstCodexOnPath, type CodexPathCandidate } from "../codex/shim-path-resolution";
import { loadConfig, resolveEnvValue } from "../config";

const PROXY_ENV_KEYS = [
Expand All @@ -21,6 +23,82 @@ export interface CodexShimReadinessInputs {
configuredProxyResolved: boolean;
}

export type CodexConnectShimStatus = "ready" | "missing" | "unhealthy";

export interface CodexConnectShimReadiness {
status: CodexConnectShimStatus;
/** Secret-free, actionable text rendered by ocx connect. */
message: string;
}

export interface CodexConnectShimInspectionDeps {
diagnose?: () => Pick<CodexShimDiagnostic, "installed" | "healthy" | "summary">;
findOnPath?: () => CodexPathCandidate | null;
}

const CODEX_TOKEN_NOTE = "The connected Codex config uses OPENCODEX_API_AUTH_TOKEN; "
+ "without a working shim or when a PATH wrapper replaces it, Codex may fail with "
+ '"Missing environment variable".';
const CODEX_TOKEN_ACTION = `${CODEX_TOKEN_NOTE} Run 'ocx codex-shim install' to repair it.`;

export function codexConnectShimReadiness(inputs: {
diagnosis: Pick<CodexShimDiagnostic, "installed" | "healthy" | "summary">;
commandPath: string | null;
commandIsShim?: boolean;
}): CodexConnectShimReadiness {
if (inputs.diagnosis.installed && !inputs.diagnosis.healthy) {
return {
status: "unhealthy",
message: `installed but unhealthy: ${inputs.diagnosis.summary}. ${CODEX_TOKEN_ACTION}`,
};
}
if (inputs.diagnosis.healthy && inputs.commandPath && !inputs.commandIsShim) {
// The shim is installed and healthy, so reinstalling it cannot fix this: only PATH
// order (or the wrapper in front of it) can.
return {
status: "missing",
message: `not active; PATH resolves 'codex' to ${inputs.commandPath}, not an OpenCodex shim. `
+ `Shim state: ${inputs.diagnosis.summary}. Put the directory of the tracked shim ahead of that command on PATH, `
+ `or remove that wrapper; re-running 'ocx codex-shim install' will not change PATH order. ${CODEX_TOKEN_NOTE}`,
};
}
Comment thread
andrew05060414 marked this conversation as resolved.
if (inputs.diagnosis.installed && inputs.diagnosis.healthy) {
return { status: "ready", message: "installed and healthy" };
}
if (inputs.commandPath) {
return {
status: "missing",
message: `not active; PATH resolves 'codex' to ${inputs.commandPath}, not an OpenCodex shim. ${CODEX_TOKEN_ACTION}`,
};
}
return {
status: "missing",
message: `not installed and no 'codex' executable was found on PATH. ${CODEX_TOKEN_ACTION}`,
};
}

export function inspectCodexShimForConnect(
deps: CodexConnectShimInspectionDeps = {},
): CodexConnectShimReadiness {
let diagnosis: Pick<CodexShimDiagnostic, "installed" | "healthy" | "summary">;
try {
diagnosis = (deps.diagnose ?? diagnoseCodexShim)();
} catch {
diagnosis = { installed: true, healthy: false, summary: "diagnostic state could not be read" };
}
let command: CodexPathCandidate | null = null;
try {
command = (deps.findOnPath ?? findFirstCodexOnPath)();
} catch {
// The diagnosis above still gives the operator a repair path.
}
return codexConnectShimReadiness({
diagnosis,
commandPath: command?.path ?? null,
commandIsShim: command?.isShim ?? false,
});
}

function externalProviderLabel(provider: string | null): string {
return provider
? `external model_provider ${JSON.stringify(provider)}`
Expand Down
19 changes: 19 additions & 0 deletions src/cli/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ import {
terminalSafeText,
type RuntimeApiDeps,
} from "./runtime-api";
import {
inspectCodexShimForConnect,
type CodexConnectShimReadiness,
} from "./codex-shim-readiness";

export interface ClientCommandDeps extends RuntimeApiDeps {
lifecycleLockDeps?: ClientLifecycleLockDeps;
Expand Down Expand Up @@ -277,6 +281,19 @@ export type ConnectCompletionReport = {
readonly failure: string | null;
};

export function codexShimReadinessLine(
selectedClients: readonly OcxConnectedClientId[],
inspect: () => CodexConnectShimReadiness = inspectCodexShimForConnect,
): string | null {
if (!selectedClients.includes("codex")) return null;
return `Codex autostart shim: ${terminalSafeText(inspect().message)}`;
}

function printCodexShimReadiness(selectedClients: readonly OcxConnectedClientId[]): void {
const line = codexShimReadinessLine(selectedClients);
if (line) console.log(line);
}

/**
* What `ocx connect` says once the hub and the credential are settled, and whether the command
* still fails (#4207).
Expand Down Expand Up @@ -414,6 +431,7 @@ async function runHubConnect(argv: string[], deps: ClientCommandDeps): Promise<v
const readiness = inspectInstalledCatalogReadiness(installedCatalogFileState(), deps.catalogProbeDeps ?? {});
const report = connectCompletionReport(connection, clients, readiness);
for (const line of report.lines) console.log(line);
printCodexShimReadiness(clients);
if (report.failure) throw new Error(report.failure);
}

Expand Down Expand Up @@ -463,6 +481,7 @@ async function runLinkConnect(argv: string[], deps: ClientCommandDeps): Promise<
const readiness = inspectInstalledCatalogReadiness(installedCatalogFileState(), deps.catalogProbeDeps ?? {});
const report = connectCompletionReport(connection, clients, readiness);
for (const line of report.lines) console.log(line);
printCodexShimReadiness(clients);
if (report.failure) throw new Error(report.failure);
}

Expand Down
113 changes: 113 additions & 0 deletions src/codex/shim-path-resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
import { delimiter, posix, win32 } from "node:path";
import { isWslRuntime, wslAutomountRoot } from "./home";
import { SHIM_MARKER } from "./shim-templates";

/**
* A PATH entry that reaches Windows through WSL drive interop
* (`<automount-root>/<drive>/...`; root defaults to /mnt, configurable via
* /etc/wsl.conf [automount] root).
*/
export function isWindowsInteropDir(dir: string, automountRoot = "/mnt"): boolean {
const root = automountRoot.replace(/\/+$/, "");
const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^${escaped}/[a-z](/|$)`, "i").test(dir);
}

export type CodexPathScanDeps = {
pathValue?: string;
wsl?: boolean;
/** Treat PATH entries as POSIX paths (WSL context). Defaults to wsl || non-win32. */
posixPaths?: boolean;
automountRoot?: string;
exists?: (path: string) => boolean;
isShimFile?: (path: string) => boolean;
isDirectory?: (path: string) => boolean;
realpath?: (path: string) => string;
};

export type CodexPathCandidate = {
path: string;
isShim: boolean;
};

export function realIsDirectory(path: string): boolean {
try {
return lstatSync(path).isDirectory();
} catch {
return true; // unreadable -> treat as unusable
}
}

function isShimFile(path: string): boolean {
try {
return readFileSync(path, "utf8").includes(SHIM_MARKER);
} catch {
return false;
}
}

function commandNames(name: string): string[] {
if (process.platform !== "win32") return [name];
const exts = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD;.PS1").split(";").filter(Boolean);
return [name, ...exts.flatMap(ext => [`${name}${ext.toLowerCase()}`, `${name}${ext.toUpperCase()}`])];
}

/** The shell-local bin directory that fnm creates for one interactive shell. */
export function isFnmMultishellPath(path: string, posixPaths: boolean): boolean {
const normalized = (posixPaths
? posix.normalize(path)
: win32.normalize(path).replace(/\\/g, "/")).toLowerCase();
return /(?:^|\/)fnm_multishells?(?:\/|$)/.test(normalized);
}

/** Find the command the shell would actually use, including an OpenCodex shim. */
export function findFirstCodexOnPath(deps: CodexPathScanDeps = {}): CodexPathCandidate | null {
const exists = deps.exists ?? existsSync;
const shimFile = deps.isShimFile ?? isShimFile;
const isDir = deps.isDirectory ?? realIsDirectory;
const wsl = deps.wsl ?? (process.platform === "linux" && isWslRuntime());
const usePosix = deps.posixPaths ?? (wsl || process.platform !== "win32");
const joinPath = usePosix ? posix.join : win32.join;
const pathSep = usePosix ? ":" : delimiter;
const automountRoot = deps.automountRoot ?? (wsl ? wslAutomountRoot() : "/mnt");
const interopNames = ["codex", "codex.exe", "codex.cmd", "codex.ps1"];

for (const dir of (deps.pathValue ?? process.env.PATH ?? "").split(pathSep).filter(Boolean)) {
if (wsl && isWindowsInteropDir(dir, automountRoot)) continue;
const names = isWindowsInteropDir(dir, automountRoot) ? interopNames : commandNames("codex");
for (const name of names) {
const path = joinPath(dir, name);
if (!exists(path) || isDir(path)) continue;
return { path, isShim: shimFile(path) };
}
}
return null;
}

/**
* Resolve a Codex command discovered through fnm's temporary PATH entry.
*
* A temporary path is safe to use only when its physical target is outside the
* temporary tree. Returning null is deliberate: callers must not wrap the
* shell-local path or silently choose a lower-priority Codex installation.
*/
export function resolveStableFnmCodexPath(
path: string,
posixPaths: boolean,
realpath: ((path: string) => string) | undefined = realpathSync.native,
): string | null {
if (!isFnmMultishellPath(path, posixPaths)) return path;
try {
// fnm normally links the temporary directory to the durable installation,
// while npm may link the command file again into its package internals.
// Resolve only the directory so the command basename remains the stable
// installation entry that the installer is allowed to replace.
const pathTools = posixPaths ? posix : win32;
const resolvedParent = realpath(pathTools.dirname(path));
if (!resolvedParent || isFnmMultishellPath(resolvedParent, posixPaths) || !pathTools.isAbsolute(resolvedParent)) return null;
return pathTools.join(resolvedParent, pathTools.basename(path));
} catch {
return null;
}
}
78 changes: 39 additions & 39 deletions src/codex/shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
unlinkSync,
writeFileSync,
} from "node:fs";
import { basename, delimiter, dirname, extname, join, posix } from "node:path";
import { basename, delimiter, dirname, extname, join, posix, win32 } from "node:path";
import { durableBunRuntime } from "../lib/bun-runtime";
import type { BunRuntimeSource } from "../lib/bun-runtime";
import { serviceApiTokenFilePath } from "../lib/service-secrets";
Expand Down Expand Up @@ -53,12 +53,20 @@ import {
type UnixShimProbeResult,
} from "./shim-probe";
import { tryAcquireShimRestoreLock } from "./shim-restore-lock";
import {
isWindowsInteropDir,
realIsDirectory,
resolveStableFnmCodexPath,
type CodexPathScanDeps,
} from "./shim-path-resolution";

export { buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim } from "./shim-templates";
export { isVersionManagerOwnedCodexPath } from "./shim-fingerprint";
export { CODEX_SHIM_STATE_MAX_BYTES } from "./shim-state-file";
export { setCodexShimProbeHookForTests, setCodexShimProbeShellForTests, setCodexShimProbeObservationMsForTests } from "./shim-probe";
export type { CodexShimBackingForCommand } from "./shim-inspect";
export type { CodexPathScanDeps } from "./shim-path-resolution";
export { isWindowsInteropDir } from "./shim-path-resolution";
export { isLocalAbsoluteInspectionPath, inspectCodexShimBackingForCommand } from "./shim-inspect";

export const CODEX_SHIM_REPLACEMENT_STABLE_MS = 100;
Expand All @@ -75,6 +83,17 @@ interface InstallCodexShimInternalOptions {
beforeGuardedRefresh?: (wrapperPath: string, index: number) => void;
}

function discoveredCodexPath(path: string, posixPaths: boolean, realpath?: (path: string) => string): string | null {
const resolved = resolveStableFnmCodexPath(path, posixPaths, realpath);
if (resolved !== null) return resolved;
lastShimDiscoveryError = truncateRetainedUtf8(
`Found codex at ${path} inside fnm's temporary multishell path, but it did not resolve to a durable Node installation. `
+ "Refusing to install a shim; retry after fnm selects a stable installation.",
MAX_DIAGNOSTIC_VALUE_BYTES,
);
return null;
}

export type CodexShimAutoRestoreResult =
| { status: "not-installed" | "healthy" | "disabled" }
| { status: "ineligible" | "deferred"; message?: string }
Expand Down Expand Up @@ -115,45 +134,17 @@ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean {
}
}

/**
* A PATH entry that reaches Windows through WSL drive interop
* (`<automount-root>/<drive>/...`; root defaults to /mnt, configurable via
* /etc/wsl.conf [automount] root).
*/
export function isWindowsInteropDir(dir: string, automountRoot = "/mnt"): boolean {
const root = automountRoot.replace(/\/+$/, "");
const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^${escaped}/[a-z](/|$)`, "i").test(dir);
}

export type CodexPathScanDeps = {
pathValue?: string;
wsl?: boolean;
/** Treat PATH entries as POSIX paths (WSL context). Defaults to wsl || non-win32. */
posixPaths?: boolean;
automountRoot?: string;
exists?: (path: string) => boolean;
isShimFile?: (path: string) => boolean;
isDirectory?: (path: string) => boolean;
};

function realIsDirectory(path: string): boolean {
try {
return lstatSync(path).isDirectory();
} catch {
return true; // unreadable -> treat as unusable
}
}

export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
lastShimDiscoveryError = null;
const exists = deps.exists ?? existsSync;
const shimFile = deps.isShimFile ?? isShim;
const isDir = deps.isDirectory ?? realIsDirectory;
const wsl = deps.wsl ?? (process.platform === "linux" && isWslRuntime());
const usePosix = deps.posixPaths ?? (wsl || process.platform !== "win32");
const joinPath = usePosix ? posix.join : join;
const pathSep = usePosix ? ":" : delimiter;
// The injected path flavour governs join and separator alike; host-native `join` and
// `delimiter` made a Windows-flavoured scan split `C:\...` on a Linux host.
const joinPath = usePosix ? posix.join : win32.join;
const pathSep = usePosix ? ":" : win32.delimiter;
const automountRoot = deps.automountRoot ?? (wsl ? wslAutomountRoot() : "/mnt");
// Windows npm prefixes ship codex.exe/codex.cmd next to the extensionless sh launcher.
const interopNames = ["codex", "codex.exe", "codex.cmd", "codex.ps1"];
Expand All @@ -176,7 +167,11 @@ export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
for (const name of names) {
const path = joinPath(dir, name);
if (!exists(path) || shimFile(path)) continue;
if (!isDir(path)) return path;
if (!isDir(path)) {
const resolved = discoveredCodexPath(path, usePosix, deps.realpath);
if (resolved !== null) return resolved;
return null;
}
}
}

Expand All @@ -195,11 +190,13 @@ function findWindowsCodexTargets(): ShimFileState[] | null {
lastShimDiscoveryError = null;
for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
const exe = join(dir, "codex.exe");
if (existsSync(exe) && !isShim(exe)) {
const stableExe = existsSync(exe) && !isShim(exe) ? discoveredCodexPath(exe, false) : exe;
if (stableExe === null) return null;
if (existsSync(stableExe) && !isShim(stableExe)) {
try {
if (!lstatSync(exe).isDirectory()) {
if (!lstatSync(stableExe).isDirectory()) {
lastShimDiscoveryError = truncateRetainedUtf8(
`Found codex.exe at ${exe}. Refusing to rename a real .exe because exact codex.exe invocations would break; ` +
`Found codex.exe at ${stableExe}. Refusing to rename a real .exe because exact codex.exe invocations would break; ` +
"install a codex.cmd/codex.ps1 launcher or use `ocx service install` for autostart.",
MAX_DIAGNOSTIC_VALUE_BYTES,
);
Expand All @@ -216,9 +213,12 @@ function findWindowsCodexTargets(): ShimFileState[] | null {
const targets: ShimFileState[] = [];
for (const path of [cmd, ps1, gitBashLauncher]) {
if (!existsSync(path) || isShim(path)) continue;
const stablePath = discoveredCodexPath(path, false);
if (stablePath === null) return null;
if (!existsSync(stablePath) || isShim(stablePath)) continue;
try {
if (!lstatSync(path).isDirectory()) {
targets.push({ wrapperPath: path, originalPath: path, backupPath: backupPathFor(path) });
if (!lstatSync(stablePath).isDirectory()) {
targets.push({ wrapperPath: stablePath, originalPath: stablePath, backupPath: backupPathFor(stablePath) });
}
} catch { /* keep scanning */ }
}
Expand Down
Loading
Loading