1818import io .github .easy4j .codex .CodexClientConfig ;
1919import org .apache .commons .exec .CommandLine ;
2020import org .apache .commons .exec .DefaultExecutor ;
21+ import org .apache .commons .exec .ExecuteException ;
2122import org .apache .commons .exec .ExecuteWatchdog ;
2223import org .slf4j .Logger ;
2324import org .slf4j .LoggerFactory ;
@@ -79,6 +80,10 @@ public CodexCliExecutor(CodexClientConfig config) {
7980 * <li>Process timeout — {@link CodexCliResult#isTimeout()} returns
8081 * {@code true}; exit code is {@code -1}; stderr contains the timeout
8182 * notice.</li>
83+ * <li>Non-zero process exit — the real exit code is preserved in
84+ * {@link CodexCliResult#getExitCode()}, and both captured streams are
85+ * returned as-is ({@link CodexCliResult#isSuccess()} is simply
86+ * {@code exitCode == 0}).</li>
8287 * <li>IOException (missing executable, permission denied, etc.) —
8388 * the {@link IOException#getMessage()} is captured in
8489 * {@link CodexCliResult#getStderr()} and the exit code is {@code -1}.</li>
@@ -113,7 +118,11 @@ private CodexCliResult runProcess(String stdin, String... args) {
113118 CommandLine cmd = CommandLine .parse (config .getLocalExecutable ());
114119 for (String arg : args ) {
115120 if (arg != null ) {
116- cmd .addArgument (arg );
121+ // handleQuoting=false: the child is spawned via exec(argv), not
122+ // a shell — commons-exec's default quoting would embed literal
123+ // double quotes inside arguments containing spaces (prompts,
124+ // config overrides, paths), corrupting them on arrival.
125+ cmd .addArgument (arg , false );
117126 }
118127 }
119128
@@ -131,6 +140,7 @@ private CodexCliResult runProcess(String stdin, String... args) {
131140 ExecuteWatchdog watchdog = new ExecuteWatchdog (timeoutMs );
132141 executor .setWatchdog (watchdog );
133142
143+ long startNanos = System .nanoTime ();
134144 try {
135145 int exitCode = executor .execute (cmd );
136146 String out = stdout .toString ().trim ();
@@ -140,6 +150,23 @@ private CodexCliResult runProcess(String stdin, String... args) {
140150 return new CodexCliResult (-1 , out , "codex CLI timed out after " + timeoutMs + " ms\n " + err );
141151 }
142152 return new CodexCliResult (exitCode , out , err );
153+ } catch (ExecuteException e ) {
154+ // commons-exec throws ExecuteException for EVERY non-zero exit
155+ // (and for watchdog kills). The stream pumps are joined before it
156+ // is thrown, so both buffers are complete — surface them together
157+ // with the real exit code instead of discarding the output. The
158+ // deadline check makes the timeout verdict race-free even when
159+ // {@code watchdog.killedProcess()} has not observed the kill yet.
160+ String out = stdout .toString ().trim ();
161+ String err = stderr .toString ().trim ();
162+ boolean timedOut = watchdog .killedProcess ()
163+ || System .nanoTime () - startNanos >= timeoutMs * 1_000_000L ;
164+ if (timedOut ) {
165+ return new CodexCliResult (-1 , out , "codex CLI timed out after " + timeoutMs + " ms\n " + err );
166+ }
167+ log .debug ("codex CLI failed: exitCode={}, stdout.len={}, stderr.len={}" ,
168+ e .getExitValue (), out .length (), err .length ());
169+ return new CodexCliResult (e .getExitValue (), out , err );
143170 } catch (IOException e ) {
144171 return new CodexCliResult (-1 , "" , e .getMessage ());
145172 }
0 commit comments