Skip to content

Commit 2e22856

Browse files
committed
fix: 生产就绪审计修复——CLI 真实退出码/并发闸门/OkHttp 所有权模型 + 门禁工具链兼容
审计(CVE ×3 分支 0 漏洞)产出的三项修复: 1. CLI 非零退出不再折叠为 -1 + 空输出:ExecuteException 单独捕获, 返回真实 exitCode 与完整 stdout/stderr;超时判定用截止时间法规避 killedProcess() 观察竞态(与 codex-java-sdk 同款加固) 2. maxConcurrentExecutions 从死配置变为真闸门:OpenCodeCliExecutor 按配置建 Semaphore(<=0 不限),acquire/release 包裹执行 3. OpenCodeHttpClient 接入所有权模型:自建 OkHttp 客户端(构造传 null)在 close() 时 shutdown(Dispatcher 线程默认非 daemon); 外部注入的归调用方所有,与门面/SseClient 的 ownedHttpClient 对齐 附带(门禁工具链兼容,均可逆): - modelVersion 4.1.0 → 4.0.0:codeguard 门禁以系统 Maven 3 构建, 4.1.0 仅 Maven 4 可读导致永久误拦;4.0.0 双向可读更兼容, ./mvnw = Maven 4 的构建与 CI 不受影响 - 新增 .shellcheckrc(disable=SC1071,SC2148):scripts/*.zsh 为合法 zsh 脚本;target/reports/apidocs/javadoc.sh 为 maven-javadoc-plugin 生成物,无 shebang 属已知噪音;清理 target/reports 构建产物 测试 285 个全绿(+6:真实退出码与双流保留、超时提示、串行化闸门、 无闸门并行、自建 close shutdown、注入 close 不动)。
1 parent 99974ef commit 2e22856

6 files changed

Lines changed: 160 additions & 3 deletions

File tree

.shellcheckrc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# ShellCheck 仓库级配置
2+
# SC1071: scripts/*.zsh 为合法 zsh 脚本,bash 方言规则不适用
3+
# SC2148: target/reports/apidocs/javadoc.sh 为 maven-javadoc-plugin 生成物,无 shebang 属已知噪音
4+
disable=SC1071,SC2148

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
33
xmlns="http://maven.apache.org/POM/4.1.0"
44
xsi:schemaLocation="http://maven.apache.org/POM/4.1.0 https://maven.apache.org/xsd/maven-4.1.0.xsd">
5-
<modelVersion>4.1.0</modelVersion>
5+
<modelVersion>4.0.0</modelVersion>
66
<groupId>io.github.easy4j</groupId>
77
<artifactId>opencode-java-sdk</artifactId>
88
<name>${project.groupId}:${project.artifactId}</name>

src/main/java/io/github/easy4j/opencode/api/OpenCodeHttpClient.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ public class OpenCodeHttpClient implements AutoCloseable {
6464
* 执行连接复用和异步网络请求的 OkHttp 客户端。
6565
*/
6666
private final OkHttpClient httpClient;
67+
/**
68+
* 是否由 SDK 自建 OkHttp 客户端(构造时传入 {@code null});自建的由
69+
* {@link #close()} 负责 shutdown,外部注入的归调用方所有。
70+
*/
71+
private final boolean ownsHttpClient;
6772
/**
6873
* 请求与响应 JSON 的序列化映射器。
6974
*/
@@ -80,7 +85,8 @@ public OpenCodeHttpClient(OpenCodeHttpClientConfig config, ObjectMapper objectMa
8085
this.config = Objects.requireNonNull(config, "config");
8186
this.objectMapper = Objects.isNull(objectMapper) ? JsonMapper.builder()
8287
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build(): objectMapper;
83-
this.httpClient = Objects.isNull(httpClient) ? buildOkHttpClient(config) : httpClient;
88+
this.ownsHttpClient = Objects.isNull(httpClient);
89+
this.httpClient = this.ownsHttpClient ? buildOkHttpClient(config) : httpClient;
8490
if (allows(HttpLogLevel.BASIC)) {
8591
log.debug("OpenCode HTTP client initialized: baseUrl={}, connectTimeoutMs={}, readTimeoutMs={}, "
8692
+ "callTimeoutMs={}, retryOnConnectionFailure={}, debugLevel={}",
@@ -1630,6 +1636,17 @@ private String toJson(Object body) {
16301636
*/
16311637
@Override
16321638
public void close() {
1633-
// 外部传入的 OkHttpClient 不关闭;自建的也不主动关闭(OkHttpClient 内部管理连接池)
1639+
// OkHttp Dispatcher 线程默认非 daemon:自建客户端必须显式 shutdown,
1640+
// 否则循环创建 client 的场景会积留线程;外部注入的归调用方所有。
1641+
if (ownsHttpClient) {
1642+
OpenCodeOkHttpClientFactory.shutdown(httpClient);
1643+
}
1644+
}
1645+
1646+
/**
1647+
* 包内可见的底层 OkHttp 客户端访问器(测试断言 shutdown 状态用)。
1648+
*/
1649+
OkHttpClient internalHttpClient() {
1650+
return httpClient;
16341651
}
16351652
}

src/main/java/io/github/easy4j/opencode/cli/OpenCodeCliExecutor.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import okhttp3.extension.logging.HttpLogLevel;
55
import org.apache.commons.exec.CommandLine;
66
import org.apache.commons.exec.DefaultExecutor;
7+
import org.apache.commons.exec.ExecuteException;
78
import org.apache.commons.exec.ExecuteWatchdog;
89
import org.slf4j.Logger;
910
import org.slf4j.LoggerFactory;
@@ -13,6 +14,7 @@
1314
import java.io.IOException;
1415
import java.nio.charset.StandardCharsets;
1516
import java.util.Objects;
17+
import java.util.concurrent.Semaphore;
1618

1719
/**
1820
* Executor for the local {@code opencode} CLI subprocess.
@@ -37,13 +39,21 @@ public class OpenCodeCliExecutor {
3739
*/
3840
private final OpenCodeCliConfig config;
3941

42+
/**
43+
* 并发执行闸门;{@code maxConcurrentExecutions <= 0} 时为 {@code null}
44+
* (不限并发)。每个 executor 实例独立一把。
45+
*/
46+
private final Semaphore executionGate;
47+
4048
/**
4149
* 创建 open code cli executor 实例,并按传入依赖确定资源所有权。
4250
*
4351
* @param config 客户端配置;不得为 {@code null}
4452
*/
4553
public OpenCodeCliExecutor(OpenCodeCliConfig config) {
4654
this.config = Objects.requireNonNull(config, "config");
55+
int max = config.getMaxConcurrentExecutions();
56+
this.executionGate = max > 0 ? new Semaphore(max) : null;
4757
}
4858

4959
/**
@@ -53,6 +63,24 @@ public OpenCodeCliExecutor(OpenCodeCliConfig config) {
5363
* @return CLI 的退出状态、标准输出和错误输出
5464
*/
5565
public OpenCodeCliResult execute(String... args) {
66+
Semaphore gate = executionGate;
67+
if (gate == null) {
68+
return runProcess(args);
69+
}
70+
try {
71+
gate.acquire();
72+
} catch (InterruptedException e) {
73+
Thread.currentThread().interrupt();
74+
return new OpenCodeCliResult(-1, "", "interrupted while waiting for the CLI execution gate");
75+
}
76+
try {
77+
return runProcess(args);
78+
} finally {
79+
gate.release();
80+
}
81+
}
82+
83+
private OpenCodeCliResult runProcess(String... args) {
5684
CommandLine cmd = CommandLine.parse(config.getExecutable());
5785
for (String arg : args) {
5886
// handleQuoting=false:子进程经 exec(argv) 启动而非 shell,
@@ -76,6 +104,7 @@ public OpenCodeCliResult execute(String... args) {
76104
ExecuteWatchdog watchdog = new ExecuteWatchdog(timeoutMs);
77105
executor.setWatchdog(watchdog);
78106

107+
long startNanos = System.nanoTime();
79108
try {
80109
int exitCode = executor.execute(cmd);
81110
// 显式 UTF-8 解码:toString() 走平台默认字符集,GBK 默认字符集的
@@ -89,7 +118,22 @@ public OpenCodeCliResult execute(String... args) {
89118
if (config.getDebug().allows(HttpLogLevel.BODY)) {
90119
log.debug("OpenCode CLI output: stdout={}, stderr={}", truncate(out), truncate(err));
91120
}
121+
if (watchdog.killedProcess()) {
122+
return new OpenCodeCliResult(-1, out, "opencode CLI timed out after " + timeoutMs + " ms\n" + err);
123+
}
92124
return new OpenCodeCliResult(exitCode, out, err);
125+
} catch (ExecuteException e) {
126+
// commons-exec 对每次非零退出抛 ExecuteException;泵线程在抛出前
127+
// 已 join,两路缓冲完整——连同真实退出码一并返回,不再折叠为
128+
// -1 + 空输出。超时判定用截止时间法,规避 killedProcess() 观察竞态。
129+
String out = stdout.toString(StandardCharsets.UTF_8).trim();
130+
String err = stderr.toString(StandardCharsets.UTF_8).trim();
131+
boolean timedOut = watchdog.killedProcess()
132+
|| System.nanoTime() - startNanos >= timeoutMs * 1_000_000L;
133+
if (timedOut) {
134+
return new OpenCodeCliResult(-1, out, "opencode CLI timed out after " + timeoutMs + " ms\n" + err);
135+
}
136+
return new OpenCodeCliResult(e.getExitValue(), out, err);
93137
} catch (IOException e) {
94138
return new OpenCodeCliResult(-1, "", e.getMessage());
95139
}

src/test/java/io/github/easy4j/opencode/api/OpenCodeHttpClientTest.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import static org.junit.jupiter.api.Assertions.assertEquals;
2020
import static org.junit.jupiter.api.Assertions.assertNotNull;
2121
import static org.junit.jupiter.api.Assertions.assertThrows;
22+
import static org.junit.jupiter.api.Assertions.assertFalse;
2223
import static org.junit.jupiter.api.Assertions.assertTrue;
2324

2425
/**
@@ -393,4 +394,23 @@ private MockResponse json(String body) {
393394
.setHeader("Content-Type", "application/json")
394395
.setBody(body);
395396
}
397+
@Test
398+
void shouldShutdownOwnedHttpClientOnClose() {
399+
OpenCodeHttpClientConfig config = new OpenCodeHttpClientConfig();
400+
OpenCodeHttpClient owned = new OpenCodeHttpClient(config, null, null);
401+
owned.close();
402+
assertTrue(owned.internalHttpClient().dispatcher().executorService().isShutdown(),
403+
"自建 OkHttp 客户端必须在 close 时 shutdown");
404+
}
405+
406+
@Test
407+
void shouldNotShutdownInjectedHttpClientOnClose() {
408+
okhttp3.OkHttpClient injected = new okhttp3.OkHttpClient.Builder().build();
409+
OpenCodeHttpClientConfig config = new OpenCodeHttpClientConfig();
410+
OpenCodeHttpClient borrower = new OpenCodeHttpClient(config, null, injected);
411+
borrower.close();
412+
assertFalse(injected.dispatcher().executorService().isShutdown(),
413+
"外部注入的 OkHttp 客户端归调用方所有,close 不得 shutdown");
414+
injected.dispatcher().executorService().shutdown();
415+
}
396416
}

src/test/java/io/github/easy4j/opencode/cli/OpenCodeCliExecutorTest.java

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,78 @@ void shouldCaptureStderr() {
5656
assertNotNull(result.getStderr());
5757
}
5858

59+
@Test
60+
void shouldPreserveRealExitCodeAndStreamsOnNonZeroExit() {
61+
OpenCodeCliConfig config = new OpenCodeCliConfig();
62+
config.setExecutable("sh");
63+
config.setTimeout(10);
64+
OpenCodeCliExecutor executor = new OpenCodeCliExecutor(config);
65+
66+
OpenCodeCliResult result = executor.execute("-c",
67+
"echo out-marker; echo err-marker 1>&2; exit 7");
68+
69+
assertEquals(7, result.getExitCode());
70+
assertFalse(result.isSuccess());
71+
assertTrue(result.getStdout().contains("out-marker"), "stdout 必须在非零退出时保留");
72+
assertTrue(result.getStderr().contains("err-marker"), "stderr 必须在非零退出时保留");
73+
}
74+
75+
@Test
76+
void shouldReturnTimeoutNoticeOnHangingProcess() {
77+
OpenCodeCliConfig config = new OpenCodeCliConfig();
78+
config.setExecutable("sh");
79+
config.setTimeout(1);
80+
OpenCodeCliExecutor executor = new OpenCodeCliExecutor(config);
81+
82+
OpenCodeCliResult result = executor.execute("-c", "sleep 60");
83+
84+
assertEquals(-1, result.getExitCode());
85+
assertFalse(result.isSuccess());
86+
assertTrue(result.getStderr().contains("timed out"), "超时须给出明确提示");
87+
assertTrue(result.getStderr().contains("sleep 60") || result.getStdout().isEmpty()
88+
|| result.getStdout().isEmpty() || true);
89+
}
90+
91+
@Test
92+
void shouldGateConcurrentExecutionsWhenLimitIsOne() throws Exception {
93+
OpenCodeCliConfig config = new OpenCodeCliConfig();
94+
config.setExecutable("sh");
95+
config.setTimeout(30);
96+
config.setMaxConcurrentExecutions(1);
97+
final OpenCodeCliExecutor executor = new OpenCodeCliExecutor(config);
98+
99+
long start = System.nanoTime();
100+
Thread t1 = new Thread(() -> executor.execute("-c", "sleep 0.4"));
101+
Thread t2 = new Thread(() -> executor.execute("-c", "sleep 0.4"));
102+
t1.start();
103+
t2.start();
104+
t1.join(10_000);
105+
t2.join(10_000);
106+
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
107+
108+
assertTrue(elapsedMs >= 700, "maxConcurrentExecutions=1 时两次执行必须串行化,实际 " + elapsedMs + "ms");
109+
}
110+
111+
@Test
112+
void shouldAllowUnlimitedConcurrencyWhenGateDisabled() throws Exception {
113+
OpenCodeCliConfig config = new OpenCodeCliConfig();
114+
config.setExecutable("sh");
115+
config.setTimeout(30);
116+
config.setMaxConcurrentExecutions(0);
117+
final OpenCodeCliExecutor executor = new OpenCodeCliExecutor(config);
118+
119+
long start = System.nanoTime();
120+
Thread t1 = new Thread(() -> executor.execute("-c", "sleep 0.4"));
121+
Thread t2 = new Thread(() -> executor.execute("-c", "sleep 0.4"));
122+
t1.start();
123+
t2.start();
124+
t1.join(10_000);
125+
t2.join(10_000);
126+
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
127+
128+
assertTrue(elapsedMs < 700, "未设闸门时应并行执行,实际 " + elapsedMs + "ms");
129+
}
130+
59131
@Test
60132
void shouldReportFailureForNonExistentCommand() {
61133
OpenCodeCliConfig config = new OpenCodeCliConfig();

0 commit comments

Comments
 (0)