Skip to content

Commit bbfcfbb

Browse files
committed
fix(cli): 同步执行器四项审计加固与新命令补齐——JDK 8 线经 decodeUtf8 helper 走 toString("UTF-8")(UTF-8 全 JVM 保证,catch 分支不可达)
1 parent 6e61bf0 commit bbfcfbb

5 files changed

Lines changed: 344 additions & 18 deletions

File tree

‎src/main/java/io/github/easy4j/hermes/cli/HermesCli.java‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,96 @@ public HermesCliResult cronAdd(String schedule, String prompt) {
634634
* @since 1.0.0
635635
*/
636636
public HermesCliResult update() { return executor.execute("update"); }
637+
638+
/**
639+
* <p>执行 Hermes CLI 的 {@code update} 命令并附带更新选项。</p>
640+
*
641+
* <p>文档化选项包括 {@code --check}、{@code --plan}、{@code --branch}、
642+
* {@code --backup}、{@code --force}、{@code --force-venv}、
643+
* {@code --no-gateway-restart}、{@code --keep-stash}、{@code --switch-branch}。</p>
644+
*
645+
* @param args 更新选项
646+
* @return 包含退出码、标准输出和标准错误的命令结果
647+
* @since 1.0.0
648+
*/
649+
public HermesCliResult update(String... args) { return executor.execute(prefix("update", args)); }
650+
651+
/**
652+
* <p>执行 Hermes CLI 的 {@code acp} 子命令(编辑器 ACP 模式)。</p>
653+
*
654+
* @param args 附加参数
655+
* @return 包含退出码、标准输出和标准错误的命令结果
656+
* @since 1.0.0
657+
*/
658+
public HermesCliResult acp(String... args) { return executor.execute(prefix("acp", args)); }
659+
660+
/**
661+
* <p>执行 Hermes CLI 的 {@code serve} 子命令(远程 Desktop 后端服务)。</p>
662+
*
663+
* @param args 附加参数
664+
* @return 包含退出码、标准输出和标准错误的命令结果
665+
* @since 1.0.0
666+
*/
667+
public HermesCliResult serve(String... args) { return executor.execute(prefix("serve", args)); }
668+
669+
/**
670+
* <p>执行 Hermes CLI 的 {@code desktop} 子命令。</p>
671+
*
672+
* @return 包含退出码、标准输出和标准错误的命令结果
673+
* @since 1.0.0
674+
*/
675+
public HermesCliResult desktop() { return executor.execute("desktop"); }
676+
677+
/**
678+
* <p>执行 Hermes CLI 的 {@code skills browse} 子命令(浏览技能目录)。</p>
679+
*
680+
* @param args 附加参数
681+
* @return 包含退出码、标准输出和标准错误的命令结果
682+
* @since 1.0.0
683+
*/
684+
public HermesCliResult skillsBrowse(String... args) { return executor.execute(prefix("skills", prefixArgs("browse", args))); }
685+
686+
/**
687+
* <p>执行 Hermes CLI 的 {@code skills opt-in <skill>} 子命令。</p>
688+
*
689+
* @param skill 要启用的技能名
690+
* @return 包含退出码、标准输出和标准错误的命令结果
691+
* @since 1.0.0
692+
*/
693+
public HermesCliResult skillsOptIn(String skill) { return executor.execute("skills", "opt-in", skill); }
694+
695+
/**
696+
* <p>执行 Hermes CLI 的 {@code egress setup} 子命令(出口代理初始化)。</p>
697+
*
698+
* @param args 附加参数
699+
* @return 包含退出码、标准输出和标准错误的命令结果
700+
* @since 1.0.0
701+
*/
702+
public HermesCliResult egressSetup(String... args) { return executor.execute(prefix("egress", prefixArgs("setup", args))); }
703+
704+
/**
705+
* <p>执行 Hermes CLI 的 {@code egress start} 子命令(启动出口代理)。</p>
706+
*
707+
* @return 包含退出码、标准输出和标准错误的命令结果
708+
* @since 1.0.0
709+
*/
710+
public HermesCliResult egressStart() { return executor.execute("egress", "start"); }
711+
712+
/**
713+
* <p>执行 Hermes CLI 的 {@code import} 子命令(导入备份)。</p>
714+
*
715+
* @param args 附加参数
716+
* @return 包含退出码、标准输出和标准错误的命令结果
717+
* @since 1.0.0
718+
*/
719+
public HermesCliResult importData(String... args) { return executor.execute(prefix("import", args)); }
720+
721+
private static String[] prefixArgs(String head, String... tail) {
722+
String[] all = new String[tail.length + 1];
723+
all[0] = head;
724+
System.arraycopy(tail, 0, all, 1, tail.length);
725+
return all;
726+
}
637727
/**
638728
* <p>执行 Hermes CLI 的 {@code uninstall} 命令。</p>
639729
*

‎src/main/java/io/github/easy4j/hermes/cli/HermesCliExecutor.java‎

Lines changed: 112 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,42 @@
44
import lombok.extern.slf4j.Slf4j;
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;
9+
import org.apache.commons.exec.PumpStreamHandler;
810

11+
import java.io.ByteArrayInputStream;
912
import java.io.ByteArrayOutputStream;
1013
import java.io.File;
1114
import java.io.IOException;
15+
import java.nio.charset.StandardCharsets;
1216
import java.util.Objects;
1317

1418
/**
1519
* <p>Hermes CLI 子进程执行器。</p>
1620
*
1721
* <p>负责命令构造、工作目录、超时等待以及标准输出和错误输出收集;调用方负责限制并发子进程数。</p>
1822
*
23+
* <p>加固约定:</p>
24+
* <ul>
25+
* <li>参数经 {@code addArgument(arg, false)} 原样进入 argv——子进程经 exec 启动而非 shell,
26+
* 默认引号策略会把含空格参数的字面双引号烤进 argv,损坏多词 prompt 与路径;</li>
27+
* <li>子进程始终收到(可能为空的)立即关闭的 stdin——读取型命令读到 EOF 即结束,
28+
* 已关闭管道也不会与输入泵产生写后关闭竞态;</li>
29+
* <li>输出按 UTF-8 显式解码——{@code toString()} 走平台默认字符集,C locale 或
30+
* Windows GBK 环境会损坏中文输出;</li>
31+
* <li>非零退出经 {@link ExecuteException} 单独捕获,保留真实退出码与两路输出;
32+
* 超时判定用截止时间法,规避 {@code watchdog.killedProcess()} 的观察竞态。</li>
33+
* </ul>
34+
*
1935
* @author <a href="https://github.com/loong10k">Loong Wan</a>
2036
* @since 1.0.0
2137
*/
2238
@Slf4j
2339
public class HermesCliExecutor {
2440

41+
private static final String TIMEOUT_PREFIX = "hermes CLI timed out after ";
42+
2543
/**
2644
* 当前客户端使用的配置快照。
2745
*/
@@ -47,16 +65,80 @@ public HermesCliExecutor(HermesCliConfig config) {
4765
* @since 1.0.0
4866
*/
4967
public HermesCliResult execute(String... args) {
68+
return runProcess(null, args);
69+
}
70+
71+
/**
72+
* <p>执行 Hermes CLI 命令并向子进程 stdin 管道写入 {@code stdin} 内容。</p>
73+
*
74+
* <p>供从标准输入读取载荷的命令形态使用;{@code stdin} 为 {@code null} 或空时
75+
* 行为与 {@link #execute(String...)} 一致(子进程收到立即关闭的空管道)。失败语义
76+
* 与变参重载相同。</p>
77+
*
78+
* @param stdin 写入子进程标准输入的可选文本
79+
* @param args 传递给 Hermes CLI 的参数
80+
* @return 包含退出码、标准输出和标准错误的命令结果
81+
* @since 1.0.0
82+
*/
83+
public HermesCliResult executeWithStdin(String stdin, String... args) {
84+
return runProcess(stdin, args);
85+
}
86+
87+
/**
88+
* <p>探测 Hermes CLI 是否可执行。</p>
89+
*
90+
* @return CLI 版本命令成功退出时返回 {@code true}
91+
* @since 1.0.0
92+
*/
93+
public boolean probe() {
94+
try {
95+
HermesCliConfig probeConfig = copyForProbe(config);
96+
HermesCliResult result = new HermesCliExecutor(probeConfig).execute("--version");
97+
return result.isSuccess();
98+
} catch (Exception e) {
99+
return false;
100+
}
101+
}
102+
103+
/**
104+
* <p>以 UTF-8 显式解码子进程输出。</p>
105+
*
106+
* <p>{@code toString(Charset)} 是 Java 10+ API,JDK 8 线经由
107+
* {@code toString("UTF-8")};UTF-8 在所有 JVM 上保证存在,
108+
* catch 分支不可达,仅为满足受检异常。</p>
109+
*
110+
* @param buffer 待解码的输出缓冲
111+
* @return UTF-8 解码后的文本
112+
* @since 1.0.0
113+
*/
114+
private static String decodeUtf8(ByteArrayOutputStream buffer) {
115+
try {
116+
return buffer.toString("UTF-8");
117+
} catch (java.io.UnsupportedEncodingException e) {
118+
return new String(buffer.toByteArray(), StandardCharsets.UTF_8);
119+
}
120+
}
121+
122+
private HermesCliResult runProcess(String stdin, String... args) {
50123
// 按参数边界构造命令,避免手工拼接引入空格转义错误和命令注入风险。
51124
CommandLine cmd = new CommandLine(config.getExecutable());
52125
for (String arg : args) {
53-
cmd.addArgument(arg);
126+
if (arg == null) {
127+
continue;
128+
}
129+
// handleQuoting=false:子进程经 exec(argv) 启动而非 shell,默认引号策略
130+
// 会把含空格参数的字面双引号烤进 argv,损坏多词 prompt 与路径。
131+
cmd.addArgument(arg, false);
54132
}
55133

56134
DefaultExecutor executor = new DefaultExecutor();
57135
ByteArrayOutputStream stdout = new ByteArrayOutputStream();
58136
ByteArrayOutputStream stderr = new ByteArrayOutputStream();
59-
executor.setStreamHandler(new org.apache.commons.exec.PumpStreamHandler(stdout, stderr));
137+
// 始终向子进程提供(可能为空的)立即关闭的 stdin:消费型命令读到 EOF 即结束,
138+
// 已关闭管道不会与输入泵产生写后关闭竞态。
139+
byte[] stdinBytes = stdin == null ? new byte[0] : stdin.getBytes(StandardCharsets.UTF_8);
140+
executor.setStreamHandler(new PumpStreamHandler(stdout, stderr,
141+
new ByteArrayInputStream(stdinBytes)));
60142

61143
File workingDirectory = resolveWorkingDirectory();
62144
if (workingDirectory != null) {
@@ -68,38 +150,50 @@ public HermesCliResult execute(String... args) {
68150
ExecuteWatchdog watchdog = new ExecuteWatchdog(timeoutMs);
69151
executor.setWatchdog(watchdog);
70152

153+
long startNanos = System.nanoTime();
71154
try {
72155
int exitCode = executor.execute(cmd);
73-
String out = stdout.toString().trim();
74-
String err = stderr.toString().trim();
156+
String out = decodeUtf8(stdout).trim();
157+
String err = decodeUtf8(stderr).trim();
75158
if (config.getDebug().allows(HttpLogLevel.BASIC)) {
76159
log.debug("Hermes CLI executed: exitCode={}, stdoutLength={}, stderrLength={}",
77160
exitCode, out.length(), err.length());
78161
}
79162
if (config.getDebug().allows(HttpLogLevel.BODY)) {
80163
log.debug("Hermes CLI output: stdout={}, stderr={}", truncate(out), truncate(err));
81164
}
165+
if (watchdog.killedProcess()) {
166+
return timeoutResult(stdout, stderr, timeoutMs);
167+
}
82168
return new HermesCliResult(exitCode, out, err);
169+
} catch (ExecuteException e) {
170+
// commons-exec 对每次非零退出(以及 watchdog 击杀)都会抛出 ExecuteException;
171+
// 抛出前输出泵已 join,两路缓冲是完整的——原样保留真实退出码与输出,
172+
// 超时判定用截止时间法规避 killedProcess() 的观察竞态。
173+
String out = decodeUtf8(stdout).trim();
174+
String err = decodeUtf8(stderr).trim();
175+
boolean timedOut = watchdog.killedProcess()
176+
|| System.nanoTime() - startNanos >= timeoutMs * 1_000_000L;
177+
if (timedOut) {
178+
log.warn("Hermes CLI timed out after {} ms", timeoutMs);
179+
return timeoutResult(stdout, stderr, timeoutMs);
180+
}
181+
if (config.getDebug().allows(HttpLogLevel.BASIC)) {
182+
log.debug("Hermes CLI failed: exitCode={}, stdoutLength={}, stderrLength={}",
183+
e.getExitValue(), out.length(), err.length());
184+
}
185+
return new HermesCliResult(e.getExitValue(), out, err);
83186
} catch (IOException e) {
84187
log.warn("CLI execution failed", e);
85188
return new HermesCliResult(-1, "", e.getMessage());
86189
}
87190
}
88191

89-
/**
90-
* <p>探测 Hermes CLI 是否可执行。</p>
91-
*
92-
* @return CLI 版本命令成功退出时返回 {@code true}
93-
* @since 1.0.0
94-
*/
95-
public boolean probe() {
96-
try {
97-
HermesCliConfig probeConfig = copyForProbe(config);
98-
HermesCliResult result = new HermesCliExecutor(probeConfig).execute("--version");
99-
return result.isSuccess();
100-
} catch (Exception e) {
101-
return false;
102-
}
192+
private HermesCliResult timeoutResult(ByteArrayOutputStream stdout, ByteArrayOutputStream stderr,
193+
long timeoutMs) {
194+
String out = decodeUtf8(stdout).trim();
195+
String err = TIMEOUT_PREFIX + timeoutMs + " ms\n" + decodeUtf8(stderr).trim();
196+
return new HermesCliResult(-1, out, err);
103197
}
104198

105199
private static HermesCliConfig copyForProbe(HermesCliConfig source) {

‎src/main/java/io/github/easy4j/hermes/cli/HermesCliResult.java‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,16 @@ public class HermesCliResult {
3434
public boolean isSuccess() {
3535
return exitCode == 0;
3636
}
37+
38+
/**
39+
* <p>判断本次调用是否因超时被 watchdog 终止。</p>
40+
*
41+
* @return 退出码为 {@code -1} 且标准错误携带超时说明时返回 {@code true}
42+
* @since 1.0.0
43+
*/
44+
public boolean isTimeout() {
45+
return exitCode == -1 && stderr != null && stderr.startsWith(TIMEOUT_PREFIX);
46+
}
47+
48+
private static final String TIMEOUT_PREFIX = "hermes CLI timed out after ";
3749
}

0 commit comments

Comments
 (0)