+
+ Interview Edition · Java 17 · 2026-07-31
+ 从“能调用 SSH 的 AI”升级为可治理、可恢复的 Agent 任务系统
+ 项目真正有面试价值的部分,不是又封装了一次大模型 API,而是把不确定的模型决策放进确定性的工程边界:持久化状态机、严格幂等、人工审批、执行权 CAS、超时取消、安全策略、重启恢复、监控与评测。
+
+ Java 17Spring Boot 3.4.3
+ Spring AI 1.1.5MyBatis-Plus
+ MySQLJSch
+ SSECompletableFuture
+ MicrometerPrometheus
+
+
+
99自动化测试0 失败、0 错误;2 个真实计费模型测试按设计跳过。
+
30/30安全评测场景10 ALLOW、10 ASK、10 DENY,固定样例危险误放行 0。
+
3 层严格幂等HTTP 响应回放、业务唯一键、Step 执行权 CAS。
+
+ 30 秒面试开场:LowenSSH 是一个 Java 17 的 SSH 运维 Agent。模型通过 Function Calling 产生工具意图,但没有执行权。系统先把任务和 Step 持久化,再经过规则链;高风险动作通过 SSE 通知前端审批,独立 HTTP 幂等提交决定,工作线程用 CompletableFuture 等待。批准后还要用数据库 CAS 抢到一次性执行权,执行完成做只读验证。服务崩溃时,执行中的副作用不会自动重放,而是进入 NEEDS_REVIEW。
+
+
+
+ 01. 项目到底是 JDK 多少?
+ 结论:项目的编译目标是 Java 17。当前机器运行 Maven 时使用 JDK 21,但 Maven 按 pom.xml 的 <java.version>17</java.version> 编译,所以项目语言级别和发布字节码仍是 Java 17。
+
+
pom.xml · 7–24,38–97<parent>
+ <artifactId>spring-boot-starter-parent</artifactId>
+ <version>3.4.3</version>
+</parent>
+
+<properties>
+ <java.version>17</java.version>
+ <spring-ai.version>1.1.5</spring-ai.version>
+</properties>
+
+spring-boot-starter-web
+spring-boot-starter-actuator
+micrometer-registry-prometheus
+spring-ai-starter-model-openai
+jsch 0.2.21
+mybatis-plus 3.5.9
+mysql-connector-j
+
+
+ - 先看 parent:Spring Boot 3.4.3 统一依赖版本和插件默认值。
+ - 再看 properties:
java.version=17 是回答 JDK 版本的直接证据。
+ - 再看依赖:Web 提供 REST/SSE;Spring AI 负责模型和 Tool Schema;JSch 负责 SSH/SFTP;MyBatis-Plus/MySQL 负责持久化;Actuator/Micrometer 负责指标。
+ - 测试专用:H2 验证事务与唯一键,Apache MINA SSHD 搭真实协议的内嵌 SSH Server。
+
+
面试回答不要说“本机 java -version 是 21,所以项目是 JDK 21”。项目版本以构建文件的 release/source 目标为准。
+
+
+
+
+
+ 02. 升级前后发生了什么
+
+ | 维度 | 升级前 | 升级后 | 面试价值 |
+
+ | 执行流程 | 模型 → 工具 → 回灌 | Plan → Risk → Approve → Execute → Verify → Summary | 能讲状态机和异常路径 |
+ | ASK | Web 入口自动批准 | 持久化审批 + SSE 事件 + HTTP 决定 + Future 唤醒 | 能讲异步桥接与事务边界 |
+ | 旧接口 | run/stream 对 ASK 自动放行 | 标记弃用并失败关闭;ASK 统一迁移到任务审批 API | 兼容旧响应格式,但不保留安全旁路 |
+ | 幂等 | 没有完整任务级约束 | 请求哈希、响应回放、唯一键、乐观锁、执行权 CAS | 能回答重复点击与网络重试 |
+ | SSH 阻塞 | 等 Channel 自然关闭 | 30 秒命令超时、1MB 输出上限、主动关闭 Channel | 能处理 tail -f / ping / top |
+ | 取消 | 断开流近似结束 | 独立取消 API,信号传播到工作线程、模型 Future、SSH Channel | 区分 UI 断开和后台停止 |
+ | 安全 | 简单正则门禁、Host Key 不校验 | 规则链、known_hosts、私钥认证、SFTP、防默认密钥 | 纵深防御,不夸大正则能力 |
+ | 恢复 | 进程结束后调用栈丢失 | 数据库恢复;EXECUTING 进入 NEEDS_REVIEW | 承认分布式副作用不确定窗口 |
+ | 评估 | 看最终回答 | Token、耗时、工具、安全决定、成功率、固定评测集 | 从“感觉有效”走向可测量 |
+
+
+
+ PLAN→RISK CHECK→APPROVE→
+ EXECUTE→VERIFY→SUMMARY
+
+
+
+
+ 03. 像人类调试一样阅读项目
+ 不要按包名从 A 到 Z 看,也不要逐行翻译。正确方法是选一个真实场景,把断点放在调用链上,跟着数据走。推荐场景:“重启 nginx”会命中 ASK,用户批准后执行并验证。
+
+
1HTTP 入口
TaskController.create:观察 Idempotency-Key 和请求体。
+
2事务创建
TaskCommandService.create:跳入请求哈希、幂等记录、任务、task_created 事件。
+
3异步线程
TaskWorkflowOrchestrator.start → schedule → run:HTTP 已返回 202,Loop 在专用线程继续。
+
4模型决策
AgentService.runInternal:看 Prompt、Function Schema、ChatResponse.toolCalls。
+
5风险检查
screen → CommandGuard → CommandPolicyEngine:返回 ASK、风险等级、规则列表。
+
6审批暂停
PersistentConfirmationHandler → ApprovalCoordinator:写库后进入 future.get。
+
7另一条 HTTP 请求
ApprovalController → ApprovalDecisionService:CAS 决定,事务提交后 complete Future。
+
8原线程恢复
future.get 返回 APPROVED,原调用栈继续,绝不是“重新启动一个 Agent”。
+
9一次性执行
WorkflowPersistenceService.beginExecution 先抢执行权,再进入 JSch Channel。
+
10验证和总结
保存执行结果、只读验证,工具结果回灌模型,最后写 SUCCEEDED 和 summary。
+
+ 调试技巧:每个断点只回答四件事:谁调用我?传入什么?我修改了什么状态?返回后调用方拿到什么?遇到异步时再加两问:当前是哪条线程?恢复靠内存信号还是数据库状态?
+
+
+
+ 04. 项目如何启动和搭建
+ Spring Boot 启动后扫描 Controller、Service、Mapper 和定时任务;SchemaInitializer 建表/迁移;配置从 application.yml 与环境变量合并;SSH Session 只在用户连接主机后创建。
+
+ | 启动步骤 | 发生的事 | 配置/文件 |
+ | 1. Maven 构建 | 按 Java 17 编译,装配 Spring Boot Jar | pom.xml |
+ | 2. Spring Context | 依赖注入 Controller/Service/Mapper/Scheduler | LowenSshApplication.java |
+ | 3. 数据源 | 连接 MySQL lowenssh | spring.datasource.* |
+ | 4. Schema | 建立并迁移任务、Step、审批、事件、幂等表 | schema.sql、SchemaInitializer.java |
+ | 5. 模型 | OpenAI 兼容协议连接 GLM,可换 DeepSeek/Qwen | spring.ai.openai.* |
+ | 6. 安全启动 | 未提供加密 Key 时生产启动失败 | XWSSH_CRYPTO_KEY(S) |
+ | 7. 定时器 | 扫描审批过期、任务超时、重启恢复 | xwssh.agent.*-interval |
+
+
+
+
+ 05. 第一个断点:创建任务与严格幂等
+
+
TaskController.java · 40–50 → TaskCommandService.java · 66–118@PostMapping
+public ResponseEntity<CreateTaskResponse> create(
+ @RequestHeader("Idempotency-Key") String key,
+ @RequestBody CreateTaskRequest request) {
+ CreateResult result = commandService.create(key, request);
+ if (!result.replayed()) {
+ orchestrator.start(result.response().taskId());
+ }
+ return ResponseEntity.accepted()
+ .header("Idempotency-Replayed",
+ Boolean.toString(result.replayed()))
+ .body(result.response());
+}
+
+@Transactional
+public CreateResult create(String key, CreateTaskRequest request) {
+ String requestHash = RequestFingerprint.sha256(...);
+ idempotencyMapper.insertPlaceholder(scope, key, requestHash, expiresAt);
+ IdempotencyRecordEntity record =
+ idempotencyMapper.selectForUpdate(scope, key);
+ if (!requestHash.equals(record.getRequestHash())) throw conflict;
+ if (record.getResponseJson() != null) return replay(record);
+ taskMapper.insert(task);
+ eventService.append(taskId, "task_created", ...);
+ idempotencyMapper.saveResponse(...);
+ return new CreateResult(response, false);
+}
+
+
+ - 进入 Controller:要求客户端提供 Idempotency-Key。网络超时后客户端可以安全重试。
+ - 跳入 Service:把 sessionId、hostId、任务文本规范化后做 SHA-256。Key 一样而请求不同,会返回冲突。
+ - 抢数据库唯一键:并发请求只有一个 Key 记录;随后
SELECT FOR UPDATE 串行处理。
+ - 同一事务:任务、task_created 事件、首次 HTTP 响应一起提交,不会出现“Key 占了但任务没建”的半成品。
+ - 跳回 Controller:首次请求才启动编排器;重放请求只返回之前保存的相同 body,不会启动第二份 Agent Loop。
+
+
返回:202 + taskId/status/phase。是否重放只放在响应头,首次和重放的响应体完全相同。
+
+
+ 为什么只用 UUID 不算严格幂等?
+ UUID 只能降低 ID 冲突概率,不能识别“同一个业务请求重试”。严格幂等必须保存 scope + idempotencyKey + requestHash + response,并利用唯一键和事务处理并发。
+
+
+
+ 06. HTTP 返回后,Agent 在哪里运行?
+
+
TaskWorkflowOrchestrator.java · 107–183public boolean start(String taskId) {
+ return schedule(taskId, RunMode.NORMAL);
+}
+
+private boolean schedule(String taskId, RunMode mode) {
+ if (!scheduled.add(taskId) || runtimeRegistry.isRunning(taskId)) {
+ return false;
+ }
+ workers.execute(() -> run(taskId, mode));
+ return true;
+}
+
+private void run(String taskId, RunMode mode) {
+ try (Registration ignored = runtimeRegistry.register(taskId)) {
+ AgentTaskEntity task = taskMapper.selectById(taskId);
+ LiveSession live = sessionManager.get(task.getSessionId());
+ runtimeRegistry.bindSsh(taskId, live.ssh());
+ transitionService.transition(taskId, PLANNING, PLAN, ...);
+ agentService.run(..., confirmationFactory.create(taskId), observer);
+ } catch (TaskCancelledException e) {
+ cancellationFinalizer.finalizeIfCancelling(taskId);
+ } catch (DuplicateToolExecutionException e) {
+ persistence.needsReview(taskId, e.getMessage());
+ } finally {
+ scheduled.remove(taskId);
+ }
+}
+
+
+ - Controller 不跑 Agent:
workers.execute 把工作交给固定线程池,HTTP 可以立即返回。
+ - 双重去重:
scheduled 防止排队重复,runtimeRegistry 防止运行重复。
+ - 登记运行句柄:记录工作线程和 SSH Client,取消 API 才知道该中断谁。
+ - 按 taskId 创建 confirmer/observer:审批上下文和持久化观察者不会跨任务共享。
+ - 异常分流:取消、重复执行、限额、普通异常分别收敛到不同持久化状态。
+
+
线程关系:HTTP 线程只建任务;agent-task-N 工作线程执行 Loop;审批 HTTP 使用另一条容器线程;SSE 又是独立订阅。
+
+
+
+
+
+ 07. 什么是 Function Calling?项目已经用了
+ Function Calling 不是模型直接执行 Java 方法。Java 把工具名称、参数 Schema 和描述发给模型;模型返回结构化 tool_call 意图;应用校验后才决定是否执行对应方法,并把结果作为 tool_result 回灌。
+
+
AgentService.java · 131–200ToolCallback[] callbacks = ToolCallbacks.from(tools);
+OpenAiChatOptions options = OpenAiChatOptions.builder()
+ .toolCallbacks(callbacks)
+ .internalToolExecutionEnabled(false)
+ .build();
+
+for (int round = 1; round <= maxRounds; round++) {
+ messages = contextManager.truncateToolResponses(messages);
+ messages = contextManager.compressIfNeeded(messages);
+ ChatResponse response = chatModel.call(new Prompt(messages, options));
+
+ if (!response.hasToolCalls()) return finalText;
+
+ AssistantMessage assistant = response.getResult().getOutput();
+ List<ToolResponse> rejected = screen(...);
+ if (!rejected.isEmpty()) {
+ messages.add(assistant);
+ messages.add(rejectedToolMessage);
+ continue;
+ }
+ observer.beforeToolExecution(assistant.getToolCalls());
+ ToolExecutionResult result =
+ toolCallingManager.executeToolCalls(prompt, response);
+ observer.afterToolExecution(lastToolResponses(result));
+ messages = new ArrayList<>(result.conversationHistory());
+}
+
+
+ - ToolCallbacks.from:扫描
SshTools 的 @Tool,生成模型能理解的函数描述。
+ - 关闭内部自动执行:这是最重要的开关。否则 Spring AI 收到 tool_call 就执行,安全门禁和审批插不进去。
+ - 模型返回两类数据:没有 tool_call 就是最终回答;有 tool_call 就是结构化工具意图。
+ - 先 screen:DENY/ASK 在 Java 确定性代码里执行,不信任 Prompt 里的“模型自觉”。
+ - 结果回灌:模型读取 stdout/exitCode 后决定下一步,直到没有工具调用或达到最大轮数。
+
+
一句话:模型拥有建议权,Java 状态机拥有执行权。
+
+
+
+
+
+ 08. Risk Check:规则链如何决定 ALLOW / ASK / DENY
+
+
CommandPolicyEngine.java · 23–45List<PolicyMatch> matches = policies.stream()
+ .map(policy -> policy.evaluate(context))
+ .flatMap(Optional::stream)
+ .toList();
+
+PolicyMatch winner = matches.stream()
+ .min(comparing(decision.ordinal())
+ .thenComparing(match -> -match.riskLevel().ordinal()))
+ .orElse(new PolicyMatch(
+ ASK, MEDIUM,
+ "命令不在只读白名单,需要人工确认",
+ "ask.unknown_command"));
+
+return new PolicyResult(
+ winner.decision(), winner.riskLevel(),
+ winner.reason(), ruleIds, policyVersion);
+
+
+ - 每条
CommandPolicy 独立检查一种风险,便于测试和扩展。
+ - 合并时“最严格决定获胜”:DENY 优先 ASK,ASK 优先 ALLOW;同决定选更高风险。
+ - 未知命令默认 ASK,而不是默认 ALLOW。
+ - 返回的不只是决定,还包含风险、原因、命中规则和策略版本;这些字段会写进 Step/Approval。
+
+
+
+
+ | 规则类型 | 例子 | 结果 |
+ | 绝对破坏 | rm -rf、mkfs、dd、重启机器 | DENY |
+ | 间接执行/混淆 | bash -c、Python/Node 执行、base64 | shell、变量替换 | DENY |
+ | 提权和写操作 | sudo、重启服务、修改权限、重定向写文件 | ASK |
+ | 明确只读 | df、ps、ss、systemctl status | ALLOW |
+ | 未知命令 | 未命中白名单也未命中禁止规则 | ASK |
+
+
+
+
+ 09. ASK 的完整断点跟踪:如何暂停,又如何继续
+ 先给结论:Agent 没有被销毁,也没有轮询前端。它的专用工作线程停在 CompletableFuture.get(timeout);数据库保存审批真相;另一条审批 HTTP 线程更新数据库并在事务提交后调用 future.complete,原线程从原调用栈下一行继续。
+
+
PersistentConfirmationHandler.java · 48–78 → ApprovalCoordinator.java · 29–64public boolean confirm(ConfirmationRequest request) {
+ AgentStepEntity step = stepService.createOrGet(...);
+ ApprovalDecision decision = coordinator.requestAndAwait(
+ new ApprovalRequest(taskId, stepId, ..., timeout));
+ if (decision == APPROVED) {
+ transition(taskId, RISK_CHECKING, RISK_CHECK, ...);
+ }
+ return decision == APPROVED;
+}
+
+public ApprovalDecision requestAndAwait(ApprovalRequest request) {
+ ApprovalView approval = approvalService.request(request);
+ CompletableFuture<ApprovalDecision> future =
+ waitRegistry.register(approval.approvalId());
+ try {
+ ApprovalView latest = approvalService.get(approvalId);
+ if (latest.isTerminal()) return from(latest.status());
+ return future.get(remainingMillis, MILLISECONDS);
+ } catch (TimeoutException e) {
+ return approvalService.expire(approvalId);
+ } finally {
+ waitRegistry.remove(approvalId, future);
+ }
+}
+
+
+ - 断点 A:
confirm 收到完整 toolCallId/toolName/arguments/风险信息。
+ - 断点 B:
createOrGet 用 actionDigest 取得稳定 Step;同一动作不会创建多个逻辑 Step。
+ - 断点 C:
approvalService.request 先完成数据库事务,状态变成 WAITING_APPROVAL。
+ - 断点 D:按 approvalId 注册 Future。注册后立即再读数据库,封住“用户审批比 Future 注册更快”的竞态。
+ - 断点 E:
future.get 阻塞的是 agent-task-N 工作线程,不是审批 HTTP 线程,也不是 SSE 线程。
+ - 断点 F:Future 返回 APPROVED 后跳回
confirm,再返回 true 给 screen,原 Loop 继续。
+
+
重要:“继续”不是重新创建任务;正常情况下是原线程原调用栈恢复。只有服务重启导致 JVM 调用栈丢失时,才由 RecoveryScheduler 按数据库恢复。
+
+
+ 审批请求的持久化事务
+
+
ApprovalService.java · 62–120@Transactional
+public ApprovalView request(ApprovalRequest request) {
+ AgentTaskEntity task = taskMapper.selectForUpdate(taskId);
+ AgentStepEntity step = stepMapper.selectForUpdate(stepId);
+ AgentApprovalEntity existing =
+ approvalMapper.selectByActionForUpdate(
+ taskId, toolCallId, actionDigest);
+ if (existing != null) return toView(existing);
+
+ approval.setApprovalId(UUID.randomUUID().toString());
+ approval.setStatus("PENDING");
+ approvalMapper.insertOrKeepExisting(approval);
+ stepMapper.markApprovalState(stepId, "WAITING_APPROVAL", ...);
+ transition(taskId, WAITING_APPROVAL, APPROVE, ...);
+ eventService.append(taskId, "approval_required", view);
+ return view;
+}
+
+
+ - 先锁 Task,再锁 Step/Approval,所有审批写路径保持相同锁顺序,降低死锁风险。
+ - 唯一业务键是
(taskId, toolCallId, actionDigest)。首次生成 UUID,以后复用,因此 approvalId 稳定。
+ - Approval=PENDING、Step=WAITING_APPROVAL、Task=WAITING_APPROVAL、approval_required 事件同事务提交。
+ - 事务失败时全部回滚;客户端不会看到数据库不存在的“幽灵审批”。
+
+
+
+ 审批通过时的另一条线程
+
+
ApprovalDecisionService.java · 75–158;ApprovalService.java · 202–213@Transactional
+public DecisionResult decide(...) {
+ // 幂等 Key + requestHash + 响应回放
+ AgentApprovalEntity approval =
+ approvalMapper.selectForUpdate(approvalId);
+ int updated = approvalMapper.decidePending(
+ approvalId, target, now, approval.getVersion());
+ if (updated != 1) return existingTerminal(...);
+
+ stepMapper.markApprovalState(
+ stepId, "READY_TO_EXECUTE", ...);
+ eventService.append(taskId, "approval_decided", view);
+ approvalService.completeAfterCommit(approvalId, decision);
+ return persist(idempotency, 200, body, approvalId);
+}
+
+void completeAfterCommit(String id, ApprovalDecision decision) {
+ registerSynchronization(new TransactionSynchronization() {
+ public void afterCommit() {
+ waitRegistry.complete(id, decision);
+ }
+ });
+}
+
+
+ - 审批接口本身也严格幂等:相同 Key/相同 body 回放;相同 Key/不同 body 返回 409。
+ decidePending 带 status/version 条件,只有一个并发请求能把 PENDING 改成终态。
+ - 批准后 Step 只到 READY_TO_EXECUTE,还没有执行。执行还要再过一次 CAS。
+ completeAfterCommit 非常关键:先提交数据库,再唤醒线程。若先唤醒,工作线程可能读取到旧状态甚至执行后审批事务又回滚。
+
+
+
+ 审批超时和重复审批
+
+ | 场景 | 处理 |
+ | 等待达到 expiresAt | future.get 抛 TimeoutException,调用 expire 做 PENDING→EXPIRED CAS。 |
+ | 定时扫描先到 | ExpiryScheduler 同样做 CAS,并 complete Future;两条路径只有一个成功。 |
+ | 50 个并发“批准” | 只有一个 CAS 改状态;其余读取同一终态,不重复推进。 |
+ | 先批准后注册 Future | 注册后再次读库,发现终态立即返回,不会永远等待。 |
+ | 已批准又拒绝 | 409 APPROVAL_ALREADY_DECIDED。 |
+ | 进程重启 | Future 消失,但 Approval/Task/Step 仍在数据库;RecoveryScheduler 重建流程。 |
+
+
+
+
+ 10. 为什么 SSE 只能推送,审批必须另一个 HTTP
+
+
SSE 的职责一条普通 HTTP 长连接,服务器持续向客户端发送事件。浏览器端不能沿同一 SSE 通道反向发送审批命令。适合 task_created、approval_required、执行结果和总结。
+
审批 HTTP 的职责客户端向服务器发送有副作用的决定。独立 POST 便于事务、Idempotency-Key、请求体哈希、409 冲突、审计、超时和重试。
+
+
+
TaskEventService.java · 42–87,129–139;TaskController.java · 69–80@Transactional
+public TaskEventView append(...) {
+ AgentTaskEntity task = taskMapper.selectForUpdate(taskId);
+ long sequence = task.getNextEventSequence();
+ taskMapper.advanceEventSequence(...);
+ eventMapper.insert(event);
+ publishAfterCommit(view);
+ return view;
+}
+
+public Flux<TaskEventView> stream(String taskId, long afterId) {
+ Flux<TaskEventView> history = Flux.fromIterable(replay(taskId, afterId));
+ return Flux.concat(history, publisher.live(taskId))
+ .filter(event -> advance(lastSeen, event.id()));
+}
+
+GET /api/agent/tasks/{taskId}/events
+Last-Event-ID: 17
+
+
+ - 事件先落库,再在事务提交后发布到实时 Sink。
+ - 每个任务的 sequence 单调递增,SSE id 可用于断线续传。
+ - 重连带 Last-Event-ID,服务先查询历史遗漏事件,再衔接实时流。
+ - AtomicLong 去掉“历史查询”和“实时缓冲”交界处的重复事件。
+ - 用户关掉 SSE 只代表“不再看进度”,不会取消后台任务。
+ - 应用关闭事件会主动 complete 全部实时 Sink,让无限 SSE 在 Web Server 停止前结束。
+
+
+
+ 旧接口边界:/api/agent/run 和 /api/agent/stream 没有审批回传通道,现已标记弃用并使用 RejectingConfirmationHandler:ALLOW 仍可执行,ASK 一律失败关闭。需要人工审批的调用必须迁移到 /api/agent/tasks、任务事件 SSE 和独立审批 POST。
+ 为什么不用 WebSocket?WebSocket 可以双向,但本机单用户第一版不值得增加连接状态、重连、消息确认和协议复杂度。SSE + 幂等 POST 的职责更清晰。
+
+
+
+ 11. 批准不等于执行:Step 如何保证只执行一次
+
+
WorkflowPersistenceService.java · 115–152@Transactional
+public void beginExecution(String taskId,
+ List<ExecutionClaim> claims) {
+ AgentTaskEntity task = requireActiveTask(taskId);
+ if (task.toolCalls + claims.size() > maxToolCalls) throw limit;
+
+ for (ExecutionClaim claim : orderedClaims) {
+ AgentStepEntity step = stepMapper.selectForUpdate(claim.stepId());
+ if (!RISK_CHECKED.equals(step.status)
+ && !READY_TO_EXECUTE.equals(step.status)) {
+ throw new DuplicateToolExecutionException(...);
+ }
+ }
+ taskMapper.addToolCalls(taskId, claims.size(), task.version);
+ for (ExecutionClaim claim : orderedClaims) {
+ stepMapper.claimExecution(
+ stepId, preSnapshot, step.version);
+ }
+ transition(taskId, EXECUTING, EXECUTE, "task_executing");
+}
+
+
+ - 先检查整批:模型一次可能返回多个工具调用。任一 Step 不合法,整个事务回滚,不产生半批执行。
+ - 先扣预算:最大工具次数 30,防模型绕路或死循环。
+ - 执行权 CAS:Step 只有从 RISK_CHECKED/READY_TO_EXECUTE 才能变 EXECUTING。
+ - 事务提交后才调用 SSH:数据库已经明确记录“谁取得执行权”。重复请求无法再次取得。
+
+
严格表述:项目保证应用层不重复领取同一 Step;无法宣称跨本地数据库与远端 Linux 的数学意义 exactly-once,因为两边没有分布式事务。
+
+
+
+
+
+ 12. SSH 命令为什么不会再无限等待
+
+
SshClient.java · 171–252ChannelExec channel = (ChannelExec) session.openChannel("exec");
+OutputBudget budget = new OutputBudget(maxOutputBytes);
+ActiveCommand execution = new ActiveCommand(channel);
+activeCommand.compareAndSet(null, execution);
+
+channel.connect(connectTimeout);
+long deadline = now + commandTimeout;
+while (true) {
+ while (stdout.available() > 0) drainIntoBoundedBuffer();
+ if (execution.cancelRequested.get()) { cancelled = true; break; }
+ if (channel.isClosed()) { exitCode = channel.getExitStatus(); break; }
+ if (now >= deadline) { timedOut = true; break; }
+ Thread.sleep(50);
+}
+channel.disconnect();
+if (timedOut || cancelled) verifySessionAfterForcedChannelClose();
+return new ExecResult(stdout, stderr, exitCode,
+ timedOut, cancelled, budget.truncated());
+
+
+ - 每条命令记录 deadline,默认 30 秒。
tail -f、ping、top 到点退出。
+ - stdout 和 stderr 共用 1MB 预算,避免两边各占 1MB。
+ - 超出预算后仍继续排空远端输出,只丢弃额外字节。若不排空,远端可能因管道缓冲写满而卡死。
+ - 取消/超时只关闭当前 exec Channel;随后 keepalive 检查 Session,健康则继续复用,不健康才关闭。
+ - 同一 SshClient 禁止并发命令,上层 LiveSession.lock 串行化。
+
+
+
+
+
+
+ 13. 用户取消:为什么取消 Flux 不等于取消任务
+ RUNNINGPOST cancelCANCELLING资源退出CANCELLED
+
+
TaskCancellationService.java · 59–117 → TaskRuntimeRegistry.java · 48–61@Transactional
+public CancelResult cancel(String taskId, String key) {
+ // 幂等记录 + requestHash + response replay
+ taskMapper.requestCancellation(
+ taskId, "CANCELLING", task.version);
+ eventService.append(taskId, "task_cancelling", ...);
+ idempotencyMapper.saveResponse(...);
+ afterCommit(() -> runtimeRegistry.signalCancellation(taskId));
+}
+
+Future<ChatResponse> future =
+ modelCalls.submit(() -> chatModel.call(prompt));
+observer.onModelCallStarted(future);
+
+public CancellationSignal signalCancellation(String taskId) {
+ handle.cancelRequested.set(true);
+ boolean model = handle.modelCall != null
+ && handle.modelCall.cancel(true);
+ boolean ssh = handle.sshClient != null
+ && handle.sshClient.cancelActiveCommand();
+ handle.worker.interrupt();
+ return new CancellationSignal(true, model, ssh);
+}
+
+
+ - 先持久化“应当取消”,再提交事务,保证重启后也看得到取消意图。
+ - 主决策和历史摘要两类同步模型调用都经过同一个可取消入口;Observer 把真实 Future 绑定到当前 taskId。
+ - 提交后向三处发信号:模型 Future、SSH Channel、工作线程 interrupt。取消先到而 Future 后绑定时,绑定动作会立即补发取消。
+ Future.cancel(true) 只是尽力中断,第三方 HTTP 客户端不一定立即停,所以不虚报“模型已终止”。
+ - 后台资源真正退出后,Finalizer 才把 CANCELLING 收敛到 CANCELLED。
+ - SSE Subscription 从未登记到 RuntimeRegistry,因此关闭浏览器不会误取消任务。
+
+
+
+
+
+
+ 14. 执行前快照、执行后验证与回滚边界
+ 第一版不自动回滚。支持明确验证器的动作先拍状态快照,执行后做只读检查;无法通用验证时明确写 UNSUPPORTED;失败只给回滚建议。用户真要回滚时,新建 Step,重新经过 Risk Check 和 Approve。
+
+ | 阶段 | 保存内容 | 原则 |
+ | 执行前 | 例如 systemctl 服务 active/inactive 状态 | 只读快照,不伪造完整系统快照 |
+ | 执行后 | exitCode、timeout、cancelled、truncated、摘要 | 结果先持久化 |
+ | Verify | 只读命令验证服务/进程/端口等 | 验证本身仍经过安全约束 |
+ | 失败 | 原因、验证结果、回滚建议文本 | 不自动执行高风险回滚 |
+
+
+
+
+ 15. 服务重启后怎么恢复
+
+
TaskRecoveryScheduler.java · 72–126switch (status) {
+ case CREATED, PLANNING -> orchestrator.start(taskId);
+ case VERIFYING, SUMMARIZING ->
+ orchestrator.continueAfterRestart(taskId);
+ case RISK_CHECKING -> recoverRiskChecking(task);
+ case WAITING_APPROVAL -> recoverApproval(task);
+ case EXECUTING -> persistence.needsReview(
+ taskId,
+ "无法证明远端动作是否已经发生,禁止自动重放");
+ case CANCELLING -> finalizer.finalizeIfCancelling(taskId);
+}
+
+switch (approval.status) {
+ case PENDING -> { /* 保持等待 */ }
+ case APPROVED -> orchestrator.resumeApprovedStep(taskId);
+ case REJECTED -> continueWithRejectedToolResult();
+ case EXPIRED -> failTask();
+}
+
+
+ - 定时扫描非终态任务,跳过本 JVM 已经运行的 taskId。
+ - WAITING + PENDING 不需要内存 Future 才能保存状态,数据库继续等待 HTTP 决定或过期扫描。
+ - WAITING + APPROVED 按数据库里的精确 Step 参数恢复,仍要执行权 CAS,不重新让模型猜一个相似命令。
+ - EXECUTING 是最危险状态:可能远端已成功、也可能未执行。自动重试可能造成第二次副作用,所以进入 NEEDS_REVIEW。
+
+
+
+ 面试高分点:承认本地事务与远端 SSH 之间存在不可原子提交的窗口。成熟设计不是声称“绝对 exactly once”,而是识别不确定状态并停止自动化。
+
+
+
+ 16. 每一步如何持久化
+
+ | 表 | 保存什么 | 关键约束 | 恢复作用 |
+
+ t_agent_task | 任务文本、status/phase、deadline、取消标志、计数器、summary/error、version | task_id 主键,乐观锁 version | 知道整项任务停在哪 |
+ t_agent_step | Plan、Tool、风险、参数、actionDigest、快照、执行和验证结果 | (taskId, toolCallId, actionDigest) 唯一 | 精确恢复动作并防重 |
+ t_agent_approval | approvalId、PENDING/APPROVED 等、风险、理由、到期时间 | (taskId, toolCallId, actionDigest) 唯一 | Future 丢失后仍有真相 |
+ t_agent_event | 任务内单调 sequence、事件类型、JSON payload | (taskId, sequenceNo) 唯一 | SSE Last-Event-ID 回放 |
+ t_idempotency_record | scope、Key、requestHash、resourceId、HTTP status/body、过期时间 | (scope, key) 唯一 | 网络重试原样回放 |
+ t_message | 用户、assistant、tool call/result 历史 | 按 session 顺序 | 重启后继续模型上下文 |
+
+
+ 三层幂等必须同时存在
+
+ - API 层:Idempotency-Key + requestHash + 原响应回放,处理用户重复点击、代理重试和网络超时。
+ - 业务层:Step/Approval 唯一业务键,处理同一 Tool Call 重入。
+ - 执行层:Step status/version CAS,处理并发线程和恢复扫描器抢同一执行权。
+
+
+
+
+ 17. SSH 生产安全
+
+
Host Key生产默认 StrictHostKeyChecking=yes。首次通过管理 API 预览 SHA256 指纹,用户核对后信任;Key 变化拒绝覆盖。
+
认证支持密码、私钥路径、私钥口令。SSH Agent 因当前 JSch 缺少可靠连接器明确延期,不伪装完成。
+
文件读取readRemoteFile/tailLog 改用 SFTP,恶意路径不进入 Shell 语法;tail 使用固定环形缓冲。
+
SFTP 写审批deleteFile/makeDir/moveFile 先由 ToolRiskCommand 转成等价命令,与 execCommand 共用 DENY/ASK/ALLOW、持久化审批和重启恢复。
+
凭据加密AES-GCM,随机 12 字节 IV,自带完整性校验;生产无 Key 拒绝启动。
+
密钥轮换密文带 v1:/v2: 前缀,Key Ring 同时保留新旧密钥,活动版本加密、对应版本解密。
+
纵深防御代码门禁不是唯一边界。生产还需要最小权限 Linux 用户、sudo 白名单、容器/主机隔离和日志脱敏。
+
+
+
SshClient.java · 94–148;KnownHostsService.java · 40–75if (strictHostKeyChecking) {
+ prepareKnownHosts();
+ config.put("StrictHostKeyChecking", "yes");
+}
+
+public synchronized KnownHostPreview trust(
+ String hostToken, String line, String expectedFingerprint) {
+ String actual = fingerprint(parsed.keyBytes());
+ if (!MessageDigest.isEqual(actual, expected)) throw mismatch;
+ for (String existing : knownHosts) {
+ if (sameHost && sameLine) return alreadyTrusted;
+ if (sameHost) throw new KnownHostConflictException(hostToken);
+ }
+ append(line);
+}
+
+
+ - 连接时要求 known_hosts 中已有匹配 Key,防止中间人攻击。
+ - 首次信任不是程序替用户信任网络,用户必须通过可信渠道核对指纹。
+ - 同一 Key 重复导入幂等;同一主机出现不同 Key 直接冲突,不能静默覆盖。
+
+
+
+
+
+
+ 18. 可观测性与评测
+
+ | 指标 | 名称/标签 | 回答的问题 |
+ | 模型调用 | lowenssh.agent.model.calls/duration | 模型调用几次、P95 多慢 |
+ | Token | tokens.input/output/cached | 成本多少、上下文缓存是否命中 |
+ | 策略 | decision、risk 标签 | ALLOW/ASK/DENY 分布 |
+ | 工具 | success、timed_out、cancelled | Agent 是否绕路、失败在哪里 |
+ | SSH | outcome + duration | 慢命令、超时、失败率 |
+ | 任务 | status + duration | 成功率和用户总等待时间 |
+ | 上下文 | context.compressions | 压缩触发频率 |
+
+ 指标只使用低基数状态标签,不把命令正文、密码、私钥或 API Key 放进指标。Actuator 暴露 /actuator/metrics 和 /actuator/prometheus。
+ 固定评测结果
+
+ | 类别 | 场景数 | 期望 | 实测 |
+ | 正常只读 | 10 | ALLOW | 10/10 |
+ | 高风险操作 | 10 | ASK | 10/10 |
+ | 对抗/绕过 | 10 | DENY | 10/10 |
+ | 危险误放行 | 0 | 0 |
+ | 安全误拦截 | 0 | 0 |
+
+ 不要夸大:100% 只代表这 30 个固定离线样例,不代表任意 Shell 输入都安全,也不代表模型端到端任务成功率 100%。
+
+
+
+ 19. RAG 和 MCP:能用,但本期为什么没硬接
+ RAG
+ RAG 是“先从知识库检索相关片段,再把片段放进模型上下文”。适合公司 SOP、部署文档、故障案例、服务拓扑。当前没有已确认的真实私有知识源,硬接向量库只会增加切分、Embedding、召回评测和提示注入面,不会提升 SSH 执行可靠性。
+ 将来接入原则:RAG 只能增强 Plan,不能赋予执行权限;知识片段里的命令仍要经过 Risk Check/Approve/Execute;回答必须显示来源,并建立离线召回评测。
+ MCP
+ MCP 是统一连接外部工具/数据源的协议。Prometheus、Grafana、GitHub、CMDB 很适合做 MCP 工具;核心 SSH 远程执行本期保留本地 ToolCallback,因为策略、审批、超时、取消和审计都在同一进程边界内,更容易证明。
+
+ | 能力 | 当前选择 | 原因 |
+ | SSH 命令 | 本地 ToolCallback | 安全控制强,避免远程 MCP 成为第二条执行通道 |
+ | Prometheus/GitHub/CMDB | 后续可接 MCP | 外围或只读能力,统一 Schema/鉴权有价值 |
+ | RAG | 当前不实现 | 无真实知识源,不做演示性堆栈 |
+
+
+
+
+ 20. 配置文件和内容速查
+
+ | 配置 | 默认值 | 作用 |
+ xwssh.agent.max-rounds | 25 | Agent Loop 最大轮数 |
+ idempotency-retention | 24h | 幂等响应保存时间 |
+ approval-timeout | 2m | ASK 最长等待 |
+ task-timeout | 10m | 整体任务截止时间 |
+ max-tool-calls | 30 | 工具调用预算 |
+ max-consecutive-failures | 3 | 连续失败熔断 |
+ xwssh.ssh.connect-timeout | 10s | Session/Channel 建连超时 |
+ command-timeout | 30s | 单命令超时 |
+ max-output-bytes | 1,048,576 | stdout/stderr 共享上限 |
+ strict-host-key-checking | true | 生产严格校验 Host Key |
+ known-hosts-path | ~/.lowenssh/known_hosts | 可信 Host Key 文件 |
+ policy-version | v1 | 审批/actionDigest 绑定策略语义 |
+ max-command-length | 4096 | 拒绝过长混淆命令 |
+ context.max-context-tokens | 12000 | 触发历史摘要压缩 |
+
+ 必须通过环境变量提供的秘密
+ MYSQL_PASSWORD=...
+GLM_API_KEY=...
+XWSSH_CRYPTO_KEY=...
+# 轮换时:
+XWSSH_CRYPTO_KEYS=v2=new-secret,v1=old-secret
+XWSSH_ACTIVE_CRYPTO_KEY_VERSION=v2
+XWSSH_KNOWN_HOSTS=/secure/path/known_hosts
+ docker-compose.yml 已透传上述加密配置,并把容器内 /app/security/known_hosts 挂载到 ssh-security 命名卷。复制 .env.example 为本地 .env 后填写真实值;.env 禁止提交。
+
+
+
+ 21. 验证证据
+
+
99Tests run0 failures,0 errors,2 skipped。
+
50并发审批/取消验证只有一个请求推进状态。
+
100并发创建相同 Idempotency-Key 只生成一个任务。
+
+
+ | 测试 | 覆盖 |
+ TaskPersistenceIntegrationTest | 创建幂等、冲突 Key、事件回放、状态与预算 |
+ ApprovalIntegrationTest | 稳定 approvalId、等待唤醒、竞态、超时、并发决定 |
+ TaskWorkflowOrchestratorIntegrationTest | 完整状态流、ASK、审批后执行、重启恢复、NEEDS_REVIEW |
+ SshClientIntegrationTest | 真实 SSH 协议的超时、取消、输出截断、Session 复用 |
+ SshToolsSftpSafetyTest | 恶意路径不进入 Shell exec |
+ KnownHostsServiceTest | 指纹、重复导入、Host Key 冲突 |
+ CryptoUtilTest | AES-GCM、密钥轮换、旧密文兼容 |
+ AgentSecurityEvaluationTest | 30 个正常/高风险/对抗场景并输出 JSON |
+ RejectingConfirmationHandlerTest | 旧 REST/SSE 的两种 ASK 入口都失败关闭 |
+ TaskRuntimeRegistryTest | 模型 Future、SSH Channel、工作线程取消以及先取消后绑定竞态 |
+ TaskEventPublisherTest | 应用关闭时完成无限 SSE,关闭后不再创建实时流 |
+ ContextManagerTest | 历史摘要同样使用可取消模型入口,取消信号不会被熔断逻辑吞掉 |
+
+ ./mvnw -q test
+# Tests run: 99, Failures: 0, Errors: 0, Skipped: 2
+
+./mvnw -q -DskipTests package
+git diff --check
+# 均通过
+
+# 真实 Tomcat + MySQL,保持 SSE 订阅时发送 SIGINT
+# graceful shutdown: 约 57ms(不再等待 30s)
+
+
+
+ 22. 主动承认的边界
+
+ - 规则链不能解析任意 Shell 的完整语义,不能证明命令绝对安全。
+ - 数据库与远端 Linux 没有分布式事务,EXECUTING 崩溃只能进入 NEEDS_REVIEW。
+ - 模型 Future.cancel(true) 是否真正中断网络请求取决于底层客户端。
+ - 当前是本机单用户、固定 2 个工作线程,不是多租户分布式调度平台。
+ - SSH Agent 认证未实现;当前可靠支持密码和私钥。
+ - OpenTelemetry Collector、Grafana Dashboard 未部署;已经暴露 Prometheus 指标。
+ - 30 场景评测是安全策略离线集,不是完整的模型端到端效果评测。
+ - RAG 未实现,因为没有真实私有知识源;MCP 未接入核心 SSH 工具。
+
+ 这些不是“项目做得差”,而是清楚划定第一版的可信边界。面试时主动说明,比声称“正则保证安全、幂等保证绝对一次”更成熟。
+
+
+
+ 23. 面试官追问时怎么回答
+ Q:为什么审批用 SSE + HTTP,不直接 WebSocket?
A:事件是服务器到客户端的单向通知,SSE 天然支持事件 ID 和断线重连;审批是有副作用的命令,需要事务、幂等 Key、冲突码和审计,所以独立 POST 更清晰。本机单用户版本用 WebSocket 会增加连接状态复杂度但没有明显收益。
+ Q:CompletableFuture 等待时会不会占用 Tomcat 请求线程?
A:不会。创建任务的 HTTP 已返回 202,Agent Loop 在专用 agent-task 线程池。Future.get 阻塞的是工作线程;审批 HTTP 使用另一条请求线程。Future 只负责 JVM 内唤醒,数据库才是持久化真相。
+ Q:approvalId 是 UUID,为什么还要 actionDigest?
A:UUID 只是外部标识。actionDigest 表示工具名、规范化参数、主机和策略版本对应的业务动作。唯一键阻止同一动作创建多份审批,approvalId 才能稳定复用。
+ Q:你能保证命令 exactly once 吗?
A:能保证同一 Step 在应用内只领取一次执行权,但不能跨 MySQL 与远端 Linux 宣称绝对 exactly once。若远端执行后进程在本地记结果前崩溃,状态不确定,所以重启后进入 NEEDS_REVIEW,绝不自动重放。
+ Q:为什么只取消 Flux 订阅不够?
A:SSE 只是观察通道,后台 Loop 和 SSH Channel 在别的线程。取消必须持久化 CANCELLING,并向模型 Future、工作线程和当前 Channel 分别传播信号。
+ Q:为什么不自动回滚?
A:回滚同样可能是高风险副作用,而且原状态未必能完整恢复。第一版只保存支持范围内的快照、执行后只读验证、生成回滚建议;真正回滚作为新 Step 再审批。
+ Q:为什么没有 RAG/MCP,会不会技术不够新?
A:项目的核心问题是安全执行治理,不是知识问答。没有真实 SOP 时加 RAG 没有可评测收益;核心 SSH 放远程 MCP 反而新增第二条执行边界。后续外围只读工具适合 MCP,有真实知识源后再用 RAG 增强 Plan。
+ 三分钟完整讲述模板
+ “用户先通过幂等 POST 创建任务,同一个 Key 和请求体永远返回相同 taskId。HTTP 返回后,专用线程运行 Agent Loop。Spring AI 把 SshTools 暴露成 Function Schema,但我关闭自动工具执行,让模型只能返回意图。每个 Tool Call 先被规则链评估,ALLOW 直接进入执行准备,DENY 回灌拒绝结果让模型换方案,ASK 则在同一事务写 Task、Step、Approval 和 approval_required 事件。SSE 在事务提交后推给前端,用户通过独立幂等 HTTP 审批。Agent 工作线程停在 CompletableFuture.get,审批事务提交后 complete,原调用栈恢复。批准还不够,Step 必须用 CAS 从 READY_TO_EXECUTE 变成 EXECUTING,防止重复执行。SSH 有 30 秒命令超时、1MB 输出上限和主动 Channel 取消,执行后保存结果并做只读验证。服务重启时从数据库恢复;如果崩溃前 Step 已经 EXECUTING,就进入 NEEDS_REVIEW,避免不确定副作用被重放。最后用 Micrometer 和固定评测集看成本、延迟、工具效率和安全误放行。”
+
+
+
+
+
+
+
diff --git a/mvnw b/mvnw
deleted file mode 100755
index 5272759..0000000
--- a/mvnw
+++ /dev/null
@@ -1,332 +0,0 @@
-#!/bin/sh
-# ----------------------------------------------------------------------------
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-# ----------------------------------------------------------------------------
-
-# ----------------------------------------------------------------------------
-# Apache Maven Wrapper startup batch script, version @@project.version@@
-#
-# Required ENV vars:
-# ------------------
-# JAVA_HOME - location of a JDK home dir
-#
-# Optional ENV vars
-# -----------------
-# MAVEN_OPTS - parameters passed to the Java VM when running Maven
-# e.g. to debug Maven itself, use
-# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
-# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
-# ----------------------------------------------------------------------------
-
-if [ -z "$MAVEN_SKIP_RC" ]; then
-
- if [ -f /usr/local/etc/mavenrc ]; then
- . /usr/local/etc/mavenrc
- fi
-
- if [ -f /etc/mavenrc ]; then
- . /etc/mavenrc
- fi
-
- if [ -f "$HOME/.mavenrc" ]; then
- . "$HOME/.mavenrc"
- fi
-
-fi
-
-# OS specific support. $var _must_ be set to either true or false.
-cygwin=false
-darwin=false
-mingw=false
-case "$(uname)" in
-CYGWIN*) cygwin=true ;;
-MINGW*) mingw=true ;;
-Darwin*)
- darwin=true
- # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
- # See https://developer.apple.com/library/mac/qa/qa1170/_index.html
- if [ -z "$JAVA_HOME" ]; then
- if [ -x "/usr/libexec/java_home" ]; then
- JAVA_HOME="$(/usr/libexec/java_home)"
- export JAVA_HOME
- else
- JAVA_HOME="/Library/Java/Home"
- export JAVA_HOME
- fi
- fi
- ;;
-esac
-
-if [ -z "$JAVA_HOME" ]; then
- if [ -r /etc/gentoo-release ]; then
- JAVA_HOME=$(java-config --jre-home)
- fi
-fi
-
-# For Cygwin, ensure paths are in UNIX format before anything is touched
-if $cygwin; then
- [ -n "$JAVA_HOME" ] \
- && JAVA_HOME=$(cygpath --unix "$JAVA_HOME")
- [ -n "$CLASSPATH" ] \
- && CLASSPATH=$(cygpath --path --unix "$CLASSPATH")
-fi
-
-# For Mingw, ensure paths are in UNIX format before anything is touched
-if $mingw; then
- [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] \
- && JAVA_HOME="$(
- cd "$JAVA_HOME" || (
- echo "cannot cd into $JAVA_HOME." >&2
- exit 1
- )
- pwd
- )"
-fi
-
-if [ -z "$JAVA_HOME" ]; then
- javaExecutable="$(which javac)"
- if [ -n "$javaExecutable" ] && ! [ "$(expr "$javaExecutable" : '\([^ ]*\)')" = "no" ]; then
- # readlink(1) is not available as standard on Solaris 10.
- readLink=$(which readlink)
- if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then
- if $darwin; then
- javaHome="$(dirname "$javaExecutable")"
- javaExecutable="$(cd "$javaHome" && pwd -P)/javac"
- else
- javaExecutable="$(readlink -f "$javaExecutable")"
- fi
- javaHome="$(dirname "$javaExecutable")"
- javaHome=$(expr "$javaHome" : '\(.*\)/bin')
- JAVA_HOME="$javaHome"
- export JAVA_HOME
- fi
- fi
-fi
-
-if [ -z "$JAVACMD" ]; then
- if [ -n "$JAVA_HOME" ]; then
- if [ -x "$JAVA_HOME/jre/sh/java" ]; then
- # IBM's JDK on AIX uses strange locations for the executables
- JAVACMD="$JAVA_HOME/jre/sh/java"
- else
- JAVACMD="$JAVA_HOME/bin/java"
- fi
- else
- JAVACMD="$(
- \unset -f command 2>/dev/null
- \command -v java
- )"
- fi
-fi
-
-if [ ! -x "$JAVACMD" ]; then
- echo "Error: JAVA_HOME is not defined correctly." >&2
- echo " We cannot execute $JAVACMD" >&2
- exit 1
-fi
-
-if [ -z "$JAVA_HOME" ]; then
- echo "Warning: JAVA_HOME environment variable is not set." >&2
-fi
-
-# traverses directory structure from process work directory to filesystem root
-# first directory with .mvn subdirectory is considered project base directory
-find_maven_basedir() {
- if [ -z "$1" ]; then
- echo "Path not specified to find_maven_basedir" >&2
- return 1
- fi
-
- basedir="$1"
- wdir="$1"
- while [ "$wdir" != '/' ]; do
- if [ -d "$wdir"/.mvn ]; then
- basedir=$wdir
- break
- fi
- # workaround for JBEAP-8937 (on Solaris 10/Sparc)
- if [ -d "${wdir}" ]; then
- wdir=$(
- cd "$wdir/.." || exit 1
- pwd
- )
- fi
- # end of workaround
- done
- printf '%s' "$(
- cd "$basedir" || exit 1
- pwd
- )"
-}
-
-# concatenates all lines of a file
-concat_lines() {
- if [ -f "$1" ]; then
- # Remove \r in case we run on Windows within Git Bash
- # and check out the repository with auto CRLF management
- # enabled. Otherwise, we may read lines that are delimited with
- # \r\n and produce $'-Xarg\r' rather than -Xarg due to word
- # splitting rules.
- tr -s '\r\n' ' ' <"$1"
- fi
-}
-
-log() {
- if [ "$MVNW_VERBOSE" = true ]; then
- printf '%s\n' "$1"
- fi
-}
-
-BASE_DIR=$(find_maven_basedir "$(dirname "$0")")
-if [ -z "$BASE_DIR" ]; then
- exit 1
-fi
-
-MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
-export MAVEN_PROJECTBASEDIR
-log "$MAVEN_PROJECTBASEDIR"
-
-##########################################################################################
-# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
-# This allows using the maven wrapper in projects that prohibit checking in binary data.
-##########################################################################################
-wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar"
-if [ -r "$wrapperJarPath" ]; then
- log "Found $wrapperJarPath"
-else
- log "Couldn't find $wrapperJarPath, downloading it ..."
-
- if [ -n "$MVNW_REPOURL" ]; then
- wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/@@project.version@@/maven-wrapper-@@project.version@@.jar"
- else
- wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/@@project.version@@/maven-wrapper-@@project.version@@.jar"
- fi
- while IFS="=" read -r key value; do
- # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' )
- safeValue=$(echo "$value" | tr -d '\r')
- case "$key" in wrapperUrl)
- wrapperUrl="$safeValue"
- break
- ;;
- esac
- done <"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
- log "Downloading from: $wrapperUrl"
-
- if $cygwin; then
- wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath")
- fi
-
- if command -v wget >/dev/null; then
- log "Found wget ... using wget"
- [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet"
- if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
- wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
- else
- wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
- fi
- elif command -v curl >/dev/null; then
- log "Found curl ... using curl"
- [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent"
- if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
- curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
- else
- curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
- fi
- else
- log "Falling back to using Java to download"
- javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java"
- javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class"
- # For Cygwin, switch paths to Windows format before running javac
- if $cygwin; then
- javaSource=$(cygpath --path --windows "$javaSource")
- javaClass=$(cygpath --path --windows "$javaClass")
- fi
- if [ -e "$javaSource" ]; then
- if [ ! -e "$javaClass" ]; then
- log " - Compiling MavenWrapperDownloader.java ..."
- ("$JAVA_HOME/bin/javac" "$javaSource")
- fi
- if [ -e "$javaClass" ]; then
- log " - Running MavenWrapperDownloader.java ..."
- ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath"
- fi
- fi
- fi
-fi
-##########################################################################################
-# End of extension
-##########################################################################################
-
-# If specified, validate the SHA-256 sum of the Maven wrapper jar file
-wrapperSha256Sum=""
-while IFS="=" read -r key value; do
- case "$key" in wrapperSha256Sum)
- wrapperSha256Sum=$value
- break
- ;;
- esac
-done <"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
-if [ -n "$wrapperSha256Sum" ]; then
- wrapperSha256Result=false
- if command -v sha256sum >/dev/null; then
- if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c >/dev/null 2>&1; then
- wrapperSha256Result=true
- fi
- elif command -v shasum >/dev/null; then
- if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c >/dev/null 2>&1; then
- wrapperSha256Result=true
- fi
- else
- echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
- echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." >&2
- exit 1
- fi
- if [ $wrapperSha256Result = false ]; then
- echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2
- echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2
- echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2
- exit 1
- fi
-fi
-
-MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
-
-# For Cygwin, switch paths to Windows format before running java
-if $cygwin; then
- [ -n "$JAVA_HOME" ] \
- && JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME")
- [ -n "$CLASSPATH" ] \
- && CLASSPATH=$(cygpath --path --windows "$CLASSPATH")
- [ -n "$MAVEN_PROJECTBASEDIR" ] \
- && MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR")
-fi
-
-# Provide a "standardized" way to retrieve the CLI args that will
-# work with both Windows and non-Windows executions.
-MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*"
-export MAVEN_CMD_LINE_ARGS
-
-WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
-
-# shellcheck disable=SC2086 # safe args
-exec "$JAVACMD" \
- $MAVEN_OPTS \
- $MAVEN_DEBUG_OPTS \
- -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
- "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
- ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
diff --git a/mvnw.cmd b/mvnw.cmd
deleted file mode 100644
index 708460f..0000000
--- a/mvnw.cmd
+++ /dev/null
@@ -1,206 +0,0 @@
-@REM ----------------------------------------------------------------------------
-@REM Licensed to the Apache Software Foundation (ASF) under one
-@REM or more contributor license agreements. See the NOTICE file
-@REM distributed with this work for additional information
-@REM regarding copyright ownership. The ASF licenses this file
-@REM to you under the Apache License, Version 2.0 (the
-@REM "License"); you may not use this file except in compliance
-@REM with the License. You may obtain a copy of the License at
-@REM
-@REM http://www.apache.org/licenses/LICENSE-2.0
-@REM
-@REM Unless required by applicable law or agreed to in writing,
-@REM software distributed under the License is distributed on an
-@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-@REM KIND, either express or implied. See the License for the
-@REM specific language governing permissions and limitations
-@REM under the License.
-@REM ----------------------------------------------------------------------------
-
-@REM ----------------------------------------------------------------------------
-@REM Apache Maven Wrapper startup batch script, version @@project.version@@
-@REM
-@REM Required ENV vars:
-@REM JAVA_HOME - location of a JDK home dir
-@REM
-@REM Optional ENV vars
-@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
-@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
-@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
-@REM e.g. to debug Maven itself, use
-@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
-@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
-@REM ----------------------------------------------------------------------------
-
-@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
-@echo off
-@REM set title of command window
-title %0
-@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
-@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
-
-@REM set %HOME% to equivalent of $HOME
-if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
-
-@REM Execute a user defined script before this one
-if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
-@REM check for pre script, once with legacy .bat ending and once with .cmd ending
-if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %*
-if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %*
-:skipRcPre
-
-@setlocal
-
-set ERROR_CODE=0
-
-@REM To isolate internal variables from possible post scripts, we use another setlocal
-@setlocal
-
-@REM ==== START VALIDATION ====
-if not "%JAVA_HOME%" == "" goto OkJHome
-
-echo. >&2
-echo Error: JAVA_HOME not found in your environment. >&2
-echo Please set the JAVA_HOME variable in your environment to match the >&2
-echo location of your Java installation. >&2
-echo. >&2
-goto error
-
-:OkJHome
-if exist "%JAVA_HOME%\bin\java.exe" goto init
-
-echo. >&2
-echo Error: JAVA_HOME is set to an invalid directory. >&2
-echo JAVA_HOME = "%JAVA_HOME%" >&2
-echo Please set the JAVA_HOME variable in your environment to match the >&2
-echo location of your Java installation. >&2
-echo. >&2
-goto error
-
-@REM ==== END VALIDATION ====
-
-:init
-
-@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
-@REM Fallback to current working directory if not found.
-
-set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
-IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
-
-set EXEC_DIR=%CD%
-set WDIR=%EXEC_DIR%
-:findBaseDir
-IF EXIST "%WDIR%"\.mvn goto baseDirFound
-cd ..
-IF "%WDIR%"=="%CD%" goto baseDirNotFound
-set WDIR=%CD%
-goto findBaseDir
-
-:baseDirFound
-set MAVEN_PROJECTBASEDIR=%WDIR%
-cd "%EXEC_DIR%"
-goto endDetectBaseDir
-
-:baseDirNotFound
-set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
-cd "%EXEC_DIR%"
-
-:endDetectBaseDir
-
-IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
-
-@setlocal EnableExtensions EnableDelayedExpansion
-for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
-@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
-
-:endReadAdditionalConfig
-
-SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
-set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
-set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
-
-set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/@@project.version@@/maven-wrapper-@@project.version@@.jar"
-
-FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
- IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B
-)
-
-@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
-@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
-if exist %WRAPPER_JAR% (
- if "%MVNW_VERBOSE%" == "true" (
- echo Found %WRAPPER_JAR%
- )
-) else (
- if not "%MVNW_REPOURL%" == "" (
- SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/@@project.version@@/maven-wrapper-@@project.version@@.jar"
- )
- if "%MVNW_VERBOSE%" == "true" (
- echo Couldn't find %WRAPPER_JAR%, downloading it ...
- echo Downloading from: %WRAPPER_URL%
- )
-
- powershell -Command "&{"^
- "$webclient = new-object System.Net.WebClient;"^
- "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
- "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
- "}"^
- "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^
- "}"
- if "%MVNW_VERBOSE%" == "true" (
- echo Finished downloading %WRAPPER_JAR%
- )
-)
-@REM End of extension
-
-@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file
-SET WRAPPER_SHA_256_SUM=""
-FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
- IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B
-)
-IF NOT %WRAPPER_SHA_256_SUM%=="" (
- powershell -Command "&{"^
- "Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash;"^
- "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^
- "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^
- " Write-Error 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^
- " Write-Error 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^
- " Write-Error 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^
- " exit 1;"^
- "}"^
- "}"
- if ERRORLEVEL 1 goto error
-)
-
-@REM Provide a "standardized" way to retrieve the CLI args that will
-@REM work with both Windows and non-Windows executions.
-set MAVEN_CMD_LINE_ARGS=%*
-
-%MAVEN_JAVA_EXE% ^
- %JVM_CONFIG_MAVEN_PROPS% ^
- %MAVEN_OPTS% ^
- %MAVEN_DEBUG_OPTS% ^
- -classpath %WRAPPER_JAR% ^
- "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
- %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
-if ERRORLEVEL 1 goto error
-goto end
-
-:error
-set ERROR_CODE=1
-
-:end
-@endlocal & set ERROR_CODE=%ERROR_CODE%
-
-if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost
-@REM check for post script, once with legacy .bat ending and once with .cmd ending
-if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat"
-if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd"
-:skipRcPost
-
-@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
-if "%MAVEN_BATCH_PAUSE%"=="on" pause
-
-if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE%
-
-cmd /C exit /B %ERROR_CODE%
diff --git a/pom.xml b/pom.xml
deleted file mode 100644
index b6852b9..0000000
--- a/pom.xml
+++ /dev/null
@@ -1,98 +0,0 @@
-
-