@golba98/codexa 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +320 -0
  2. package/bin/codexa.js +445 -0
  3. package/package.json +45 -0
  4. package/scripts/audit-codexa-capabilities.mjs +466 -0
  5. package/scripts/smoke-terminal-bench.mjs +35 -0
  6. package/src/app.tsx +4561 -0
  7. package/src/appRenderStability.test.ts +131 -0
  8. package/src/commands/handler.test.ts +643 -0
  9. package/src/commands/handler.ts +875 -0
  10. package/src/config/launchArgs.test.ts +158 -0
  11. package/src/config/launchArgs.ts +186 -0
  12. package/src/config/layeredConfig.test.ts +143 -0
  13. package/src/config/layeredConfig.ts +836 -0
  14. package/src/config/persistence.test.ts +110 -0
  15. package/src/config/persistence.ts +311 -0
  16. package/src/config/runtimeConfig.test.ts +218 -0
  17. package/src/config/runtimeConfig.ts +554 -0
  18. package/src/config/settings.test.ts +155 -0
  19. package/src/config/settings.ts +401 -0
  20. package/src/config/toml-serialize.ts +98 -0
  21. package/src/config/trustStore.test.ts +29 -0
  22. package/src/config/trustStore.ts +68 -0
  23. package/src/core/attachments.test.ts +155 -0
  24. package/src/core/attachments.ts +71 -0
  25. package/src/core/auth/codexAuth.test.ts +68 -0
  26. package/src/core/auth/codexAuth.ts +359 -0
  27. package/src/core/cleanupFastFail.test.ts +76 -0
  28. package/src/core/cleanupFastFail.ts +67 -0
  29. package/src/core/clipboard.ts +24 -0
  30. package/src/core/codex.ts +124 -0
  31. package/src/core/codexExecArgs.test.ts +195 -0
  32. package/src/core/codexExecArgs.ts +152 -0
  33. package/src/core/codexLaunch.test.ts +205 -0
  34. package/src/core/codexLaunch.ts +162 -0
  35. package/src/core/codexPrompt.test.ts +252 -0
  36. package/src/core/codexPrompt.ts +428 -0
  37. package/src/core/executables/claudeExecutable.ts +63 -0
  38. package/src/core/executables/codexExecutable.test.ts +212 -0
  39. package/src/core/executables/codexExecutable.ts +159 -0
  40. package/src/core/executables/executableResolver.test.ts +129 -0
  41. package/src/core/executables/executableResolver.ts +138 -0
  42. package/src/core/executables/geminiExecutable.test.ts +116 -0
  43. package/src/core/executables/geminiExecutable.ts +78 -0
  44. package/src/core/executables/pathSanityScan.test.ts +47 -0
  45. package/src/core/githubDiagnostics.test.ts +92 -0
  46. package/src/core/githubDiagnostics.ts +222 -0
  47. package/src/core/hollowResponseFormat.test.ts +58 -0
  48. package/src/core/hollowResponseFormat.ts +39 -0
  49. package/src/core/inputDebug.ts +51 -0
  50. package/src/core/launchContext.test.ts +157 -0
  51. package/src/core/launchContext.ts +266 -0
  52. package/src/core/models/codexCapabilities.test.ts +45 -0
  53. package/src/core/models/codexCapabilities.ts +95 -0
  54. package/src/core/models/codexModelCapabilities.test.ts +246 -0
  55. package/src/core/models/codexModelCapabilities.ts +571 -0
  56. package/src/core/models/modelSpecs.test.ts +283 -0
  57. package/src/core/models/modelSpecs.ts +300 -0
  58. package/src/core/perf/profiler.ts +125 -0
  59. package/src/core/perf/renderDebug.test.ts +230 -0
  60. package/src/core/perf/renderDebug.ts +373 -0
  61. package/src/core/planStorage.test.ts +143 -0
  62. package/src/core/planStorage.ts +141 -0
  63. package/src/core/process/CommandRunner.test.ts +105 -0
  64. package/src/core/process/CommandRunner.ts +269 -0
  65. package/src/core/process/processValidation.ts +101 -0
  66. package/src/core/projectInstructions.test.ts +50 -0
  67. package/src/core/projectInstructions.ts +54 -0
  68. package/src/core/providerLauncher/launcher.test.ts +238 -0
  69. package/src/core/providerLauncher/launcher.ts +203 -0
  70. package/src/core/providerLauncher/registry.test.ts +324 -0
  71. package/src/core/providerLauncher/registry.ts +253 -0
  72. package/src/core/providerLauncher/types.ts +84 -0
  73. package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
  74. package/src/core/providerLauncher/workspaceConfig.ts +407 -0
  75. package/src/core/providerRuntime/anthropic.test.ts +1120 -0
  76. package/src/core/providerRuntime/anthropic.ts +576 -0
  77. package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
  78. package/src/core/providerRuntime/capabilityProfile.ts +288 -0
  79. package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
  80. package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
  81. package/src/core/providerRuntime/contextMetadata.ts +409 -0
  82. package/src/core/providerRuntime/gemini.test.ts +437 -0
  83. package/src/core/providerRuntime/gemini.ts +784 -0
  84. package/src/core/providerRuntime/lmstudio.test.ts +168 -0
  85. package/src/core/providerRuntime/lmstudio.ts +118 -0
  86. package/src/core/providerRuntime/local.test.ts +787 -0
  87. package/src/core/providerRuntime/local.ts +754 -0
  88. package/src/core/providerRuntime/models.ts +150 -0
  89. package/src/core/providerRuntime/reasoning.ts +17 -0
  90. package/src/core/providerRuntime/registry.test.ts +233 -0
  91. package/src/core/providerRuntime/registry.ts +203 -0
  92. package/src/core/providerRuntime/types.ts +103 -0
  93. package/src/core/providers/codexJsonStream.test.ts +148 -0
  94. package/src/core/providers/codexJsonStream.ts +305 -0
  95. package/src/core/providers/codexSubprocess.test.ts +68 -0
  96. package/src/core/providers/codexSubprocess.ts +372 -0
  97. package/src/core/providers/codexTranscript.test.ts +284 -0
  98. package/src/core/providers/codexTranscript.ts +695 -0
  99. package/src/core/providers/openaiNative.ts +13 -0
  100. package/src/core/providers/registry.ts +21 -0
  101. package/src/core/providers/types.ts +59 -0
  102. package/src/core/terminal/terminalCapabilities.test.ts +93 -0
  103. package/src/core/terminal/terminalCapabilities.ts +100 -0
  104. package/src/core/terminal/terminalControl.test.ts +75 -0
  105. package/src/core/terminal/terminalControl.ts +147 -0
  106. package/src/core/terminal/terminalSanitize.test.ts +22 -0
  107. package/src/core/terminal/terminalSanitize.ts +147 -0
  108. package/src/core/terminal/terminalSelection.test.ts +42 -0
  109. package/src/core/terminal/terminalSelection.ts +66 -0
  110. package/src/core/terminal/terminalTitle.test.ts +328 -0
  111. package/src/core/terminal/terminalTitle.ts +483 -0
  112. package/src/core/workspaceActivity.test.ts +163 -0
  113. package/src/core/workspaceActivity.ts +380 -0
  114. package/src/core/workspaceGuard.test.ts +151 -0
  115. package/src/core/workspaceGuard.ts +288 -0
  116. package/src/core/workspaceRoot.test.ts +23 -0
  117. package/src/core/workspaceRoot.ts +47 -0
  118. package/src/exec.test.ts +13 -0
  119. package/src/exec.ts +72 -0
  120. package/src/headless/execArgs.test.ts +147 -0
  121. package/src/headless/execArgs.ts +294 -0
  122. package/src/headless/execRunner.test.ts +434 -0
  123. package/src/headless/execRunner.ts +304 -0
  124. package/src/index.test.tsx +618 -0
  125. package/src/index.tsx +296 -0
  126. package/src/session/appSession.test.ts +897 -0
  127. package/src/session/appSession.ts +761 -0
  128. package/src/session/chatLifecycle.test.ts +64 -0
  129. package/src/session/chatLifecycle.ts +951 -0
  130. package/src/session/liveRenderScheduler.test.ts +201 -0
  131. package/src/session/liveRenderScheduler.ts +214 -0
  132. package/src/session/planFlow.test.ts +103 -0
  133. package/src/session/planFlow.ts +149 -0
  134. package/src/session/planTranscript.test.ts +65 -0
  135. package/src/session/planTranscript.ts +15 -0
  136. package/src/session/promptRunSchedule.test.ts +36 -0
  137. package/src/session/promptRunSchedule.ts +26 -0
  138. package/src/session/types.ts +228 -0
  139. package/src/test/runtimeTestUtils.ts +14 -0
  140. package/src/types/react-dom.d.ts +3 -0
  141. package/src/ui/ActionRequiredBlock.tsx +38 -0
  142. package/src/ui/ActivityBars.tsx +68 -0
  143. package/src/ui/ActivityIndicator.test.tsx +58 -0
  144. package/src/ui/ActivityIndicator.tsx +58 -0
  145. package/src/ui/AgentBlock.test.ts +6 -0
  146. package/src/ui/AgentBlock.tsx +130 -0
  147. package/src/ui/AnimatedStatusText.test.ts +16 -0
  148. package/src/ui/AnimatedStatusText.tsx +69 -0
  149. package/src/ui/AppShell.test.tsx +1739 -0
  150. package/src/ui/AppShell.tsx +698 -0
  151. package/src/ui/AttachmentImportPanel.test.tsx +204 -0
  152. package/src/ui/AttachmentImportPanel.tsx +98 -0
  153. package/src/ui/AuthPanel.tsx +113 -0
  154. package/src/ui/BackendPicker.tsx +28 -0
  155. package/src/ui/BottomComposer.test.ts +674 -0
  156. package/src/ui/BottomComposer.tsx +1028 -0
  157. package/src/ui/CodexLogo.tsx +55 -0
  158. package/src/ui/DashCard.tsx +82 -0
  159. package/src/ui/Markdown.test.ts +157 -0
  160. package/src/ui/Markdown.tsx +310 -0
  161. package/src/ui/ModePicker.tsx +27 -0
  162. package/src/ui/ModelPicker.tsx +31 -0
  163. package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
  164. package/src/ui/ModelPickerScreen.test.tsx +99 -0
  165. package/src/ui/ModelPickerScreen.tsx +416 -0
  166. package/src/ui/ModelPickerState.test.tsx +151 -0
  167. package/src/ui/ModelReasoningPicker.test.tsx +447 -0
  168. package/src/ui/ModelReasoningPicker.tsx +458 -0
  169. package/src/ui/Panel.tsx +51 -0
  170. package/src/ui/PermissionsPanel.tsx +78 -0
  171. package/src/ui/PlanActionPicker.tsx +119 -0
  172. package/src/ui/PlanReviewPanel.test.tsx +267 -0
  173. package/src/ui/PlanReviewPanel.tsx +212 -0
  174. package/src/ui/PromptCardBorder.test.tsx +161 -0
  175. package/src/ui/ProviderPicker.test.tsx +289 -0
  176. package/src/ui/ProviderPicker.tsx +321 -0
  177. package/src/ui/ProviderShortcut.test.tsx +143 -0
  178. package/src/ui/ReasoningPicker.tsx +46 -0
  179. package/src/ui/RunFooter.tsx +65 -0
  180. package/src/ui/SelectionPanel.tsx +67 -0
  181. package/src/ui/SettingsPanel.test.tsx +233 -0
  182. package/src/ui/SettingsPanel.tsx +156 -0
  183. package/src/ui/Spinner.tsx +25 -0
  184. package/src/ui/StaticIntroItem.tsx +54 -0
  185. package/src/ui/StaticTranscriptItem.tsx +56 -0
  186. package/src/ui/TextEntryPanel.tsx +139 -0
  187. package/src/ui/ThemePicker.tsx +31 -0
  188. package/src/ui/ThinkingBlock.tsx +100 -0
  189. package/src/ui/Timeline.test.ts +2067 -0
  190. package/src/ui/Timeline.tsx +1472 -0
  191. package/src/ui/TimelineNavigation.test.tsx +201 -0
  192. package/src/ui/TopHeader.test.tsx +239 -0
  193. package/src/ui/TopHeader.tsx +257 -0
  194. package/src/ui/TurnGroup.test.tsx +365 -0
  195. package/src/ui/TurnGroup.tsx +657 -0
  196. package/src/ui/busyStatusAnimation.test.ts +30 -0
  197. package/src/ui/busyStatusAnimation.ts +11 -0
  198. package/src/ui/commandNormalize.test.ts +142 -0
  199. package/src/ui/commandNormalize.ts +66 -0
  200. package/src/ui/diffRenderer.test.ts +102 -0
  201. package/src/ui/diffRenderer.ts +116 -0
  202. package/src/ui/focus.ts +61 -0
  203. package/src/ui/focusFlow.test.tsx +1098 -0
  204. package/src/ui/inputBuffer.test.ts +151 -0
  205. package/src/ui/inputBuffer.ts +203 -0
  206. package/src/ui/layout.test.ts +145 -0
  207. package/src/ui/layout.ts +287 -0
  208. package/src/ui/modeDisplay.test.ts +42 -0
  209. package/src/ui/modeDisplay.ts +52 -0
  210. package/src/ui/outputPipeline.ts +64 -0
  211. package/src/ui/progressEntries.ts +156 -0
  212. package/src/ui/runActivityView.test.ts +89 -0
  213. package/src/ui/runActivityView.ts +37 -0
  214. package/src/ui/runLifecycleView.test.tsx +237 -0
  215. package/src/ui/slashCommands.ts +41 -0
  216. package/src/ui/statusRenderIsolation.test.tsx +654 -0
  217. package/src/ui/terminalAnswerFormat.test.ts +19 -0
  218. package/src/ui/terminalAnswerFormat.ts +128 -0
  219. package/src/ui/textLayout.test.ts +18 -0
  220. package/src/ui/textLayout.ts +338 -0
  221. package/src/ui/theme.tsx +395 -0
  222. package/src/ui/themeFlow.test.ts +53 -0
  223. package/src/ui/themeFlow.ts +41 -0
  224. package/src/ui/timelineMeasure.ts +3088 -0
  225. package/src/ui/timelineMeasureCache.test.ts +986 -0
  226. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,212 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { afterEach } from "node:test";
4
+ import type { ChildProcess } from "node:child_process";
5
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+ import { runCommand, type CommandResult } from "../process/CommandRunner.js";
9
+ import {
10
+ resolveCodexExecutable,
11
+ resetCodexExecutableCacheForTests,
12
+ spawnCodexProcess,
13
+ } from "./codexExecutable.js";
14
+
15
+ function commandResult(overrides: Partial<CommandResult> = {}): CommandResult {
16
+ return {
17
+ status: "completed",
18
+ exitCode: 0,
19
+ signal: null,
20
+ stdout: "",
21
+ stderr: "",
22
+ startedAt: 0,
23
+ endedAt: 0,
24
+ durationMs: 0,
25
+ userMessage: "Command completed.",
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ function mockRunCommand(result: CommandResult, onCall?: (spec: Parameters<typeof runCommand>[0]) => void): typeof runCommand {
31
+ return ((spec) => {
32
+ onCall?.(spec);
33
+ return {
34
+ child: null as unknown as ChildProcess,
35
+ result: Promise.resolve(result),
36
+ cancel: () => undefined,
37
+ };
38
+ }) as typeof runCommand;
39
+ }
40
+
41
+ async function withEnv<T>(env: Partial<NodeJS.ProcessEnv>, callback: () => Promise<T>): Promise<T> {
42
+ const originalCodexExe = process.env.CODEX_EXECUTABLE;
43
+ try {
44
+ if ("CODEX_EXECUTABLE" in env) process.env.CODEX_EXECUTABLE = env.CODEX_EXECUTABLE;
45
+ else delete process.env.CODEX_EXECUTABLE;
46
+ resetCodexExecutableCacheForTests();
47
+ return await callback();
48
+ } finally {
49
+ if (originalCodexExe === undefined) delete process.env.CODEX_EXECUTABLE;
50
+ else process.env.CODEX_EXECUTABLE = originalCodexExe;
51
+ resetCodexExecutableCacheForTests();
52
+ }
53
+ }
54
+
55
+ afterEach(() => {
56
+ resetCodexExecutableCacheForTests();
57
+ });
58
+
59
+ test("Codex resolver: configuredPath wins and bypasses env and PATH", async () => {
60
+ await withEnv({ CODEX_EXECUTABLE: "env-codex.cmd" }, async () => {
61
+ let whereCalled = false;
62
+ const resolved = await resolveCodexExecutable({
63
+ configuredPath: process.execPath,
64
+ runCommandImpl: mockRunCommand(commandResult(), () => { whereCalled = true; }),
65
+ });
66
+
67
+ assert.equal(resolved, process.execPath);
68
+ assert.equal(whereCalled, false, "where.exe should not be called when configuredPath is set");
69
+ });
70
+ });
71
+
72
+ test("Codex resolver: CODEX_EXECUTABLE env var used when no configuredPath", async () => {
73
+ await withEnv({ CODEX_EXECUTABLE: "env-codex.cmd" }, async () => {
74
+ let whereCalled = false;
75
+ const resolved = await resolveCodexExecutable({
76
+ runCommandImpl: mockRunCommand(commandResult(), () => { whereCalled = true; }),
77
+ });
78
+
79
+ assert.equal(resolved, "env-codex.cmd");
80
+ assert.equal(whereCalled, false, "where.exe should not be called when CODEX_EXECUTABLE is set");
81
+ });
82
+ });
83
+
84
+ test("Codex resolver: rejects unsafe CODEX_EXECUTABLE values", async () => {
85
+ await withEnv({ CODEX_EXECUTABLE: "codex.cmd & calc" }, async () => {
86
+ await assert.rejects(
87
+ () => resolveCodexExecutable({
88
+ runCommandImpl: mockRunCommand(commandResult()),
89
+ }),
90
+ /shell metacharacters|single executable name/i,
91
+ );
92
+ });
93
+ });
94
+
95
+ test("Codex resolver: accepts environment executable paths with spaces", async () => {
96
+ const tempRoot = join(tmpdir(), `codexa codex resolver ${Date.now()}`);
97
+ const codexPath = join(tempRoot, "codex cli.exe");
98
+ mkdirSync(tempRoot, { recursive: true });
99
+ writeFileSync(codexPath, "");
100
+ try {
101
+ await withEnv({ CODEX_EXECUTABLE: `"${codexPath}"` }, async () => {
102
+ const resolved = await resolveCodexExecutable({
103
+ runCommandImpl: mockRunCommand(commandResult()),
104
+ });
105
+ assert.equal(resolved, codexPath);
106
+ });
107
+ } finally {
108
+ rmSync(tempRoot, { recursive: true, force: true });
109
+ }
110
+ });
111
+
112
+ test("Codex resolver: Windows where.exe PATH lookup used when no env or config", async () => {
113
+ if (process.platform !== "win32") return;
114
+
115
+ const resolvedPath = join(tmpdir(), "codex.cmd");
116
+ writeFileSync(resolvedPath, "");
117
+ await withEnv({}, async () => {
118
+ const calls: Array<Parameters<typeof runCommand>[0]> = [];
119
+ const resolved = await resolveCodexExecutable({
120
+ runCommandImpl: mockRunCommand(
121
+ commandResult({ stdout: `${resolvedPath}\n` }),
122
+ (spec) => calls.push(spec),
123
+ ),
124
+ });
125
+
126
+ assert.equal(resolved, resolvedPath);
127
+ const whereCall = calls.find((c) => c.executable === "where.exe");
128
+ assert.ok(whereCall, "where.exe should have been called");
129
+ });
130
+ });
131
+
132
+ test("Codex resolver: bare fallback returned when nothing found (non-Windows)", async () => {
133
+ if (process.platform === "win32") return;
134
+
135
+ await withEnv({}, async () => {
136
+ const resolved = await resolveCodexExecutable({
137
+ runCommandImpl: mockRunCommand(commandResult({ exitCode: 1, status: "failed" })),
138
+ });
139
+
140
+ assert.equal(resolved, "codex", "Should fall back to bare codex name");
141
+ });
142
+ });
143
+
144
+ test("Codex resolver: Windows bare fallback when where.exe fails", async () => {
145
+ if (process.platform !== "win32") return;
146
+
147
+ await withEnv({}, async () => {
148
+ const resolved = await resolveCodexExecutable({
149
+ runCommandImpl: mockRunCommand(commandResult({ exitCode: 1, status: "failed" })),
150
+ });
151
+
152
+ assert.ok(["codex.cmd", "codex.exe", "codex"].includes(resolved), `Unexpected fallback: ${resolved}`);
153
+ });
154
+ });
155
+
156
+ test("Codex resolver: cache is populated after first resolution", async () => {
157
+ await withEnv({ CODEX_EXECUTABLE: "env-codex.cmd" }, async () => {
158
+ const first = await resolveCodexExecutable();
159
+ const second = await resolveCodexExecutable();
160
+
161
+ assert.equal(first, second);
162
+ assert.equal(first, "env-codex.cmd");
163
+ });
164
+ });
165
+
166
+ test("Codex resolver: configuredPath bypasses cache", async () => {
167
+ await withEnv({ CODEX_EXECUTABLE: "env-codex.cmd" }, async () => {
168
+ const cached = await resolveCodexExecutable();
169
+ assert.equal(cached, "env-codex.cmd");
170
+
171
+ const withOverride = await resolveCodexExecutable({
172
+ configuredPath: process.execPath,
173
+ });
174
+ assert.equal(withOverride, process.execPath);
175
+
176
+ const afterOverride = await resolveCodexExecutable();
177
+ assert.equal(afterOverride, "env-codex.cmd", "Cache should not be polluted by configuredPath call");
178
+ });
179
+ });
180
+
181
+ test("Codex resolver: resetCodexExecutableCacheForTests clears state", async () => {
182
+ await withEnv({ CODEX_EXECUTABLE: "first.cmd" }, async () => {
183
+ const first = await resolveCodexExecutable();
184
+ assert.equal(first, "first.cmd");
185
+ });
186
+
187
+ resetCodexExecutableCacheForTests();
188
+
189
+ await withEnv({ CODEX_EXECUTABLE: "second.cmd" }, async () => {
190
+ const second = await resolveCodexExecutable();
191
+ assert.equal(second, "second.cmd");
192
+ });
193
+ });
194
+
195
+ test("spawnCodexProcess wraps .cmd in cmd.exe on Windows", () => {
196
+ if (process.platform !== "win32") return;
197
+
198
+ const codexPath = "C:\\Users\\Example\\AppData\\Roaming\\npm\\codex.cmd";
199
+ const proc = spawnCodexProcess(codexPath, ["exec", "--help"], { stdio: ["ignore", "pipe", "pipe"] });
200
+ proc.kill();
201
+
202
+ assert.ok(proc, "Process should have been spawned");
203
+ });
204
+
205
+ test("spawnCodexProcess uses executable directly on non-Windows", () => {
206
+ if (process.platform === "win32") return;
207
+
208
+ const proc = spawnCodexProcess("echo", ["hello"], { stdio: ["ignore", "pipe", "pipe"] });
209
+ proc.kill();
210
+
211
+ assert.ok(proc, "Process should have been spawned");
212
+ });
@@ -0,0 +1,159 @@
1
+ import { spawn } from "child_process";
2
+ import { join } from "path";
3
+ import { runCommand } from "../process/CommandRunner.js";
4
+ import { buildSpawnSpec, resolveExecutable } from "./executableResolver.js";
5
+
6
+ type CommandRunner = typeof runCommand;
7
+
8
+ let cachedExecutable: string | null = null;
9
+ let resolveInFlight: Promise<string> | null = null;
10
+
11
+ interface SpawnOptions {
12
+ stdio: ["ignore" | "pipe", "pipe", "pipe"];
13
+ }
14
+
15
+ export interface CapturedProcessOutput {
16
+ exitCode: number | null;
17
+ stdout: string;
18
+ stderr: string;
19
+ }
20
+
21
+ export function resetCodexExecutableCacheForTests(): void {
22
+ cachedExecutable = null;
23
+ }
24
+
25
+ export async function resolveCodexExecutable(options?: {
26
+ runCommandImpl?: CommandRunner;
27
+ cwd?: string;
28
+ configuredPath?: string | null;
29
+ }): Promise<string> {
30
+ if (!options?.configuredPath && !options?.runCommandImpl && cachedExecutable !== null) {
31
+ return cachedExecutable;
32
+ }
33
+
34
+ if (!options?.configuredPath && !options?.runCommandImpl) {
35
+ if (resolveInFlight) return resolveInFlight;
36
+
37
+ resolveInFlight = (async () => {
38
+ const result = await doResolveCodexExecutable(options);
39
+ cachedExecutable = result;
40
+ return result;
41
+ })();
42
+
43
+ try {
44
+ return await resolveInFlight;
45
+ } finally {
46
+ resolveInFlight = null;
47
+ }
48
+ }
49
+
50
+ return doResolveCodexExecutable(options);
51
+ }
52
+
53
+ async function doResolveCodexExecutable(options?: {
54
+ runCommandImpl?: CommandRunner;
55
+ cwd?: string;
56
+ configuredPath?: string | null;
57
+ }): Promise<string> {
58
+ const knownFilePaths: string[] = [];
59
+ if (process.platform === "win32" && process.env.LOCALAPPDATA) {
60
+ knownFilePaths.push(join(process.env.LOCALAPPDATA, "Microsoft", "WindowsApps", "codex.exe"));
61
+ }
62
+
63
+ return resolveExecutable({
64
+ runCommandImpl: options?.runCommandImpl,
65
+ cwd: options?.cwd,
66
+ configuredPath: options?.configuredPath,
67
+ envOverrides: ["CODEX_EXECUTABLE"],
68
+ commandNames: process.platform === "win32"
69
+ ? ["codex.cmd", "codex.exe", "codex"]
70
+ : ["codex"],
71
+ knownFilePaths,
72
+ label: "codex",
73
+ });
74
+ }
75
+
76
+ export function formatCodexLaunchError(err: NodeJS.ErrnoException): string {
77
+ const detail = err.message ? `\n\nDetails: ${err.message}` : "";
78
+
79
+ if (err.code === "ENOENT") {
80
+ return [
81
+ "Codex executable was not found in PATH.",
82
+ "Set CODEX_EXECUTABLE to your working command/path, then restart Codexa.",
83
+ "Alternative: install CLI with `npm install -g @openai/codex`.",
84
+ ].join("\n") + detail;
85
+ }
86
+
87
+ if (err.code === "EACCES" || err.code === "EPERM") {
88
+ return [
89
+ "Codex appears installed but this process cannot launch it (permission blocked).",
90
+ "Set CODEX_EXECUTABLE to a working CLI command/path and restart Codexa.",
91
+ "Windows note: Codex docs recommend WSL for the best CLI experience.",
92
+ ].join("\n") + detail;
93
+ }
94
+
95
+ return err.message;
96
+ }
97
+
98
+ export function spawnCodexProcess(
99
+ executable: string,
100
+ args: string[],
101
+ options: SpawnOptions,
102
+ ): ReturnType<typeof spawn> {
103
+ const spec = buildSpawnSpec(executable, args);
104
+ return spawn(spec.executable, spec.args, { ...options, shell: false });
105
+ }
106
+
107
+ export function captureCodexProcessOutput(
108
+ executable: string,
109
+ args: string[],
110
+ timeoutMs: number,
111
+ ): Promise<CapturedProcessOutput> {
112
+ return new Promise<CapturedProcessOutput>((resolve, reject) => {
113
+ let proc: ReturnType<typeof spawn>;
114
+ try {
115
+ proc = spawnCodexProcess(executable, args, { stdio: ["ignore", "pipe", "pipe"] });
116
+ } catch (error) {
117
+ reject(error);
118
+ return;
119
+ }
120
+
121
+ let stdout = "";
122
+ let stderr = "";
123
+ let settled = false;
124
+
125
+ const finish = (callback: () => void) => {
126
+ if (settled) return;
127
+ settled = true;
128
+ clearTimeout(timer);
129
+ callback();
130
+ };
131
+
132
+ const timer = setTimeout(() => {
133
+ proc.kill();
134
+ const error = new Error(`Timed out waiting for Codex command: ${args.join(" ")}`) as NodeJS.ErrnoException;
135
+ error.code = "ETIME";
136
+ finish(() => reject(error));
137
+ }, timeoutMs);
138
+
139
+ proc.stdout?.on("data", (chunk: Buffer) => {
140
+ stdout += chunk.toString();
141
+ });
142
+
143
+ proc.stderr?.on("data", (chunk: Buffer) => {
144
+ stderr += chunk.toString();
145
+ });
146
+
147
+ proc.on("error", (error) => {
148
+ finish(() => reject(error));
149
+ });
150
+
151
+ proc.on("close", (exitCode) => {
152
+ finish(() => resolve({
153
+ exitCode,
154
+ stdout,
155
+ stderr,
156
+ }));
157
+ });
158
+ });
159
+ }
@@ -0,0 +1,129 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { ChildProcess } from "node:child_process";
4
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { runCommand, type CommandResult } from "../process/CommandRunner.js";
8
+ import { resolveExecutable, buildSpawnSpec } from "./executableResolver.js";
9
+
10
+ function commandResult(overrides: Partial<CommandResult>): CommandResult {
11
+ return {
12
+ status: "completed",
13
+ exitCode: 0,
14
+ signal: null,
15
+ stdout: "",
16
+ stderr: "",
17
+ startedAt: 0,
18
+ endedAt: 0,
19
+ durationMs: 0,
20
+ userMessage: "Command completed.",
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ function mockRunCommand(result: CommandResult, onCall?: (spec: Parameters<typeof runCommand>[0]) => void): typeof runCommand {
26
+ return ((spec) => {
27
+ onCall?.(spec);
28
+ return {
29
+ child: null as unknown as ChildProcess,
30
+ result: Promise.resolve(result),
31
+ cancel: () => undefined,
32
+ };
33
+ }) as typeof runCommand;
34
+ }
35
+
36
+ test("resolver: uses configuredPath", async () => {
37
+ const resolved = await resolveExecutable({
38
+ commandNames: ["test"],
39
+ label: "test",
40
+ configuredPath: process.execPath,
41
+ });
42
+ assert.equal(resolved, process.execPath);
43
+ });
44
+
45
+ test("resolver: uses environment override", async () => {
46
+ const original = process.env.TEST_EXECUTABLE;
47
+ process.env.TEST_EXECUTABLE = "custom-test";
48
+ try {
49
+ const resolved = await resolveExecutable({
50
+ commandNames: ["test"],
51
+ label: "test",
52
+ envOverrides: ["TEST_EXECUTABLE"],
53
+ });
54
+ assert.equal(resolved, "custom-test");
55
+ } finally {
56
+ if (original === undefined) delete process.env.TEST_EXECUTABLE;
57
+ else process.env.TEST_EXECUTABLE = original;
58
+ }
59
+ });
60
+
61
+ test("resolver: rejects environment override with shell metacharacters", async () => {
62
+ const original = process.env.TEST_EXECUTABLE;
63
+ process.env.TEST_EXECUTABLE = "custom-test & calc";
64
+ try {
65
+ await assert.rejects(
66
+ () => resolveExecutable({
67
+ commandNames: ["test"],
68
+ label: "test",
69
+ envOverrides: ["TEST_EXECUTABLE"],
70
+ }),
71
+ /shell metacharacters|single executable name/i,
72
+ );
73
+ } finally {
74
+ if (original === undefined) delete process.env.TEST_EXECUTABLE;
75
+ else process.env.TEST_EXECUTABLE = original;
76
+ }
77
+ });
78
+
79
+ test("resolver: rejects configured executable values that include arguments", async () => {
80
+ await assert.rejects(
81
+ () => resolveExecutable({
82
+ commandNames: ["test"],
83
+ label: "test",
84
+ configuredPath: "test --version",
85
+ }),
86
+ /single executable name/i,
87
+ );
88
+ });
89
+
90
+ test("resolver: accepts quoted executable paths with spaces", async () => {
91
+ const tempRoot = join(tmpdir(), `codexa resolver ${Date.now()}`);
92
+ const executablePath = join(tempRoot, "tool with spaces.exe");
93
+ mkdirSync(tempRoot, { recursive: true });
94
+ writeFileSync(executablePath, "");
95
+ try {
96
+ const resolved = await resolveExecutable({
97
+ commandNames: ["test"],
98
+ label: "test",
99
+ configuredPath: `"${executablePath}"`,
100
+ });
101
+ assert.equal(resolved, executablePath);
102
+ } finally {
103
+ rmSync(tempRoot, { recursive: true, force: true });
104
+ }
105
+ });
106
+
107
+ test("resolver: falls back to bare name if not found", async () => {
108
+ const mockImpl = mockRunCommand(commandResult({ status: "failed", exitCode: 1 }));
109
+ const resolved = await resolveExecutable({
110
+ runCommandImpl: mockImpl,
111
+ commandNames: ["mytest"],
112
+ label: "test",
113
+ });
114
+ assert.equal(resolved, "mytest");
115
+ });
116
+
117
+ test("buildSpawnSpec: wraps .cmd files in cmd.exe on Windows", async () => {
118
+ if (process.platform !== "win32") return;
119
+ const spec = buildSpawnSpec("test.cmd", ["arg1"]);
120
+ assert.equal(spec.executable, "cmd.exe");
121
+ assert.deepEqual(spec.args, ["/d", "/s", "/c", "call", "test.cmd", "arg1"]);
122
+ });
123
+
124
+ test("buildSpawnSpec: does not wrap .exe files", async () => {
125
+ if (process.platform !== "win32") return;
126
+ const spec = buildSpawnSpec("test.exe", ["arg1"]);
127
+ assert.equal(spec.executable, "test.exe");
128
+ assert.deepEqual(spec.args, ["arg1"]);
129
+ });
@@ -0,0 +1,138 @@
1
+ import { existsSync } from "fs";
2
+ import { join } from "path";
3
+ import { runCommand } from "../process/CommandRunner.js";
4
+ import {
5
+ normalizeExecutableValue,
6
+ validateWindowsBatchExecutableForCmd,
7
+ } from "../process/processValidation.js";
8
+
9
+ type CommandRunner = typeof runCommand;
10
+
11
+ export interface ExecutableResolverOptions {
12
+ runCommandImpl?: CommandRunner;
13
+ cwd?: string;
14
+ configuredPath?: string | null;
15
+ envOverrides?: string[];
16
+ commandNames: string[];
17
+ knownPathDirectories?: string[];
18
+ knownFilePaths?: string[];
19
+ label: string;
20
+ allowBareFallback?: boolean;
21
+ requireResolvedFile?: boolean;
22
+ }
23
+
24
+ function validateConfiguredExecutable(value: string, label: string, cwd: string): string {
25
+ return normalizeExecutableValue(value, {
26
+ label,
27
+ cwd,
28
+ requireExistingPath: /[\\/]/.test(value) || /^[\s"']*[A-Za-z]:/.test(value),
29
+ allowBareExecutable: true,
30
+ });
31
+ }
32
+
33
+ async function resolveWithWhere(
34
+ runCommandImpl: CommandRunner,
35
+ cwd: string,
36
+ query: string,
37
+ requireResolvedFile: boolean,
38
+ ): Promise<string | null> {
39
+ const whereRunner = runCommandImpl({
40
+ executable: "where.exe",
41
+ args: [query],
42
+ cwd,
43
+ timeoutMs: 5000,
44
+ });
45
+ const whereResult = await whereRunner.result;
46
+ if (whereResult.status !== "completed" || whereResult.exitCode !== 0) return null;
47
+ const lines = whereResult.stdout
48
+ .trim()
49
+ .split(/[\r\n]+/)
50
+ .map((l) => l.trim())
51
+ .filter(Boolean);
52
+ for (const line of lines) {
53
+ if (!requireResolvedFile || existsSync(line)) return line;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Resolves an executable's location.
60
+ *
61
+ * Priority:
62
+ * 1. Configured path override
63
+ * 2. Environment variable overrides (in order)
64
+ * 3. Windows PATH lookup by explicit command names (using where.exe)
65
+ * 4. Windows known-path fallbacks (e.g. %APPDATA%\npm)
66
+ * 5. Explicit known file fallbacks
67
+ * 6. Bare name fallback, unless disabled
68
+ */
69
+ export async function resolveExecutable(options: ExecutableResolverOptions): Promise<string> {
70
+ const runCommandImpl = options.runCommandImpl ?? runCommand;
71
+ const cwd = options.cwd ?? process.cwd();
72
+
73
+ // 1. Configured path override
74
+ if (options.configuredPath?.trim()) {
75
+ return validateConfiguredExecutable(options.configuredPath, `${options.label}CommandPath`, cwd);
76
+ }
77
+
78
+ // 2. Environment variable overrides
79
+ if (options.envOverrides) {
80
+ for (const envVar of options.envOverrides) {
81
+ const envOverride = process.env[envVar]?.trim();
82
+ if (envOverride) {
83
+ return validateConfiguredExecutable(envOverride, envVar, cwd);
84
+ }
85
+ }
86
+ }
87
+
88
+ // 3. Windows PATH lookup by explicit command names (where.exe returns null on non-Windows gracefully)
89
+ for (const candidate of options.commandNames) {
90
+ const resolved = await resolveWithWhere(runCommandImpl, cwd, candidate, options.requireResolvedFile === true);
91
+ if (resolved) return resolved;
92
+ }
93
+
94
+ // 4. Windows known-path fallbacks (existsSync returns false for non-existent paths on any platform)
95
+ if (options.knownPathDirectories) {
96
+ const knownCandidates: string[] = [];
97
+ for (const dir of options.knownPathDirectories) {
98
+ for (const candidate of options.commandNames) {
99
+ knownCandidates.push(join(dir, candidate));
100
+ }
101
+ }
102
+
103
+ for (const candidate of knownCandidates) {
104
+ if (existsSync(candidate)) return candidate;
105
+ }
106
+ }
107
+
108
+ // 5. Explicit known file fallbacks
109
+ for (const candidate of options.knownFilePaths ?? []) {
110
+ if (existsSync(candidate)) return candidate;
111
+ }
112
+
113
+ if (options.allowBareFallback === false) {
114
+ throw new Error(`${options.label} executable was not found.`);
115
+ }
116
+
117
+ // 6. Bare name fallback — prefer the name without an extension (works on Unix).
118
+ const bareName = options.commandNames.find((c) => !c.includes(".")) ?? options.commandNames[0];
119
+ return bareName!;
120
+ }
121
+
122
+ /**
123
+ * Builds the spawn spec for a resolved executable.
124
+ * .cmd and .bat files must be invoked via `cmd.exe /d /s /c` on Windows.
125
+ */
126
+ export function buildSpawnSpec(
127
+ executable: string,
128
+ args: string[],
129
+ ): { executable: string; args: string[] } {
130
+ if (process.platform === "win32") {
131
+ const lower = executable.toLowerCase();
132
+ if (lower.endsWith(".cmd") || lower.endsWith(".bat")) {
133
+ validateWindowsBatchExecutableForCmd(executable, "Windows batch executable");
134
+ return { executable: "cmd.exe", args: ["/d", "/s", "/c", "call", executable, ...args] };
135
+ }
136
+ }
137
+ return { executable, args };
138
+ }