Skip to content

Commit 222a535

Browse files
committed
fix(exec): 同步 3.0.x 生产就绪加固——非零退出保留真实 exitCode 与输出;参数原样传递修复字面引号注入 argv
1 parent f013c85 commit 222a535

4 files changed

Lines changed: 66 additions & 2 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,13 @@ There is no configuration file of its own. Key fields:
228228

229229
### 7.1 `CodexAppServerConfig` (app-server WebSocket route)
230230

231+
> **Upgrade notes (3.0.x.x.20260630+)**: CLI-route arguments are now passed
232+
> to the child process raw — multi-word prompts no longer arrive at `codex`
233+
> wrapped in embedded literal quotes. Non-zero CLI exits now preserve the real
234+
> exit code and both captured streams instead of collapsing to `exitCode=-1`
235+
> with empty output. A bearer token over a plaintext `ws://` connection logs a
236+
> warning; prefer `wss://`.
237+
231238
Plain POJO (Spring `@ConfigurationProperties`-bindable). Field names mirror the
232239
commonly used `CodexEndpoint` binding:
233240

@@ -238,6 +245,8 @@ commonly used `CodexEndpoint` binding:
238245
| `connectTimeoutMillis` | int | `5000` | TCP/TLS + WebSocket handshake timeout |
239246
| `readTimeoutMillis` | int | `120000` | Upper bound for a whole turn (connect → `turn/completed`) |
240247
| `maxSessionMappings` | int | `1000` | Bound of the `sessionKey → threadId` LRU; evicted sessions start fresh threads |
248+
| `maxFrameChars` | int | `1048576` | Frame accumulation hard cap; oversized server frames fail the turn (`<= 0` = unbounded) |
249+
| `maxContentChars` | int | `1048576` | Per-turn agent-message content cap; excess is truncated with a warning (`<= 0` = unbounded) |
241250

242251
## 8. Core Usage / API
243252

README.zh-CN.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,11 @@ public class CodexDemo {
222222

223223
### 7.1 `CodexAppServerConfig`(app-server WebSocket 路线)
224224

225+
> **升级注意(3.0.x.x.20260630+)**:CLI 路线的参数改为原样传给子进程——
226+
> 含空格的多词 prompt 不再被塞进字面双引号后发给 `codex`。CLI 非零退出现在
227+
> 保留真实退出码与两路输出,不再折叠为 `exitCode=-1` 加空输出。通过明文
228+
> `ws://` 携带 Bearer token 会打告警日志,生产环境请优先 `wss://`
229+
225230
纯 POJO(可绑定 Spring `@ConfigurationProperties`)。字段名与常用的
226231
`CodexEndpoint` 绑定保持一致:
227232

@@ -232,6 +237,8 @@ public class CodexDemo {
232237
| `connectTimeoutMillis` | int | `5000` | TCP/TLS + WebSocket 握手超时 |
233238
| `readTimeoutMillis` | int | `120000` | 单个 turn 全程上限(建连 → `turn/completed`|
234239
| `maxSessionMappings` | int | `1000` | `sessionKey → threadId` LRU 上限;被淘汰的会话退化为新建线程 |
240+
| `maxFrameChars` | int | `1048576` | 帧累积硬上限;超限的服务器帧使 turn 失败(`<= 0` = 不限) |
241+
| `maxContentChars` | int | `1048576` | 单 turn agent 消息内容上限;超出部分截断并告警(`<= 0` = 不限) |
235242

236243
## 8. 核心用法 / API
237244

src/main/java/io/github/easy4j/codex/cli/CodexCliExecutor.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import io.github.easy4j.codex.CodexClientConfig;
1919
import org.apache.commons.exec.CommandLine;
2020
import org.apache.commons.exec.DefaultExecutor;
21+
import org.apache.commons.exec.ExecuteException;
2122
import org.apache.commons.exec.ExecuteWatchdog;
2223
import org.slf4j.Logger;
2324
import org.slf4j.LoggerFactory;
@@ -79,6 +80,10 @@ public CodexCliExecutor(CodexClientConfig config) {
7980
* <li>Process timeout &mdash; {@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 &mdash; 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.) &mdash;
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
}

src/test/java/io/github/easy4j/codex/cli/CodexCliExecutorTest.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,21 @@ void shouldCaptureExitCodeFromFailingProcess() {
6464

6565
CodexCliResult result = executor.execute("-c", "exit 7");
6666

67-
assertEquals(-1, result.getExitCode());
6867
assertFalse(result.isSuccess());
6968
}
7069

70+
@Test
71+
void shouldPreserveRealExitCodeAndStreamsOnNonZeroExit() {
72+
CodexCliExecutor executor = new CodexCliExecutor(configFor("/bin/sh"));
73+
74+
CodexCliResult result = executor.execute("-c", "echo out-marker; echo err-marker 1>&2; exit 7");
75+
76+
assertEquals(7, result.getExitCode());
77+
assertFalse(result.isSuccess());
78+
assertTrue(result.getStdout().contains("out-marker"), "stdout must survive a non-zero exit");
79+
assertTrue(result.getStderr().contains("err-marker"), "stderr must survive a non-zero exit");
80+
}
81+
7182
@Test
7283
void shouldReturnIoExceptionMessageWhenExecutableMissing() {
7384
CodexCliExecutor executor = new CodexCliExecutor(configFor("/nonexistent/path/to/codex"));
@@ -80,6 +91,16 @@ void shouldReturnIoExceptionMessageWhenExecutableMissing() {
8091
assertFalse(result.getStderr().isEmpty());
8192
}
8293

94+
@Test
95+
void shouldPassArgumentsRawWithoutEmbeddedQuotes() {
96+
CodexCliExecutor executor = new CodexCliExecutor(configFor("/bin/echo"));
97+
98+
CodexCliResult result = executor.execute("Write a failing test", "-c", "key=some value");
99+
100+
assertEquals("Write a failing test -c key=some value", result.getStdout(),
101+
"multi-word arguments must arrive without embedded literal quotes");
102+
}
103+
83104
@Test
84105
void shouldIgnoreNullArguments() {
85106
CodexCliExecutor executor = new CodexCliExecutor(configFor("/bin/echo"));

0 commit comments

Comments
 (0)