From 5831e68bdaa64c593024f2a7327b6e05503909c0 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Fri, 7 Aug 2026 21:59:47 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20shell=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=BF=9D=E6=8C=81=EF=BC=88cwd=20=E5=9B=9E?= =?UTF-8?q?=E5=86=99=20+=20env=20snapshot=20=E9=87=8D=E6=94=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐 Claude Code 一次性进程模型:每条命令独立 spawn,状态经外部文件继承。 - ShellState.cwd: Bash 命令尾 pwd -P 捕获,SessionManagerPort.updateShellState 原子重写 JSONL 首行 header;ToolRuntimeInvocation.cwd 注入 ToolUseContext, Read/Write/Grep/Glob 默认目录随 cd 迁移;fork/child 继承 shellState - ShellEnvironmentHarness: 首次 bash -lc dump export/alias/function 为 snapshot(~/.lypi/shell-state//),命中后 bash -c + source 重放, 丢失回退 -lc;session env 目录 env/*.sh 按序 source(venv 激活跨命令生效); 支持 LYPI_ENV_FILE 外部注入 - 权限分析与展示只看原始 command,wrapper 不进审批 - 显式 cwd 参数的命令绕过 harness,不污染会话状态 --- .../cn/lypi/agent/DefaultTurnExecutor.java | 44 ++++- .../contracts/runtime/SessionManagerPort.java | 16 ++ .../runtime/ToolRuntimeInvocation.java | 20 +- .../lypi/contracts/session/SessionHeader.java | 42 +++- .../cn/lypi/contracts/session/ShellState.java | 27 +++ .../contracts/ContractSerializationTest.java | 34 +++- .../cn/lypi/session/ChildSessionService.java | 4 +- .../java/cn/lypi/session/ForkService.java | 3 +- .../cn/lypi/session/JsonlSessionStore.java | 19 ++ .../cn/lypi/session/SessionManagerImpl.java | 20 +- .../lypi/tool/ToolRuntimeContextFactory.java | 9 +- .../java/cn/lypi/tool/builtin/BashTool.java | 36 +++- .../tool/builtin/ShellEnvironmentHarness.java | 185 ++++++++++++++++++ .../cn/lypi/tool/DefaultToolRuntimeTest.java | 3 +- .../cn/lypi/tool/builtin/BashToolTest.java | 103 +++++++++- .../builtin/ShellEnvironmentHarnessTest.java | 116 +++++++++++ 16 files changed, 653 insertions(+), 28 deletions(-) create mode 100644 lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellState.java create mode 100644 lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java create mode 100644 lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java diff --git a/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java b/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java index c3f68791..04d5eaf1 100644 --- a/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java +++ b/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java @@ -30,6 +30,7 @@ import cn.lypi.contracts.tool.ToolResult; import cn.lypi.contracts.tool.ToolUseRequest; import cn.lypi.contracts.runtime.ToolRuntimeInvocation; +import cn.lypi.contracts.session.ShellState; import java.nio.file.Path; import java.time.Clock; import java.time.Instant; @@ -224,8 +225,8 @@ private ContextSnapshot buildContext( ContextBuildRequest contextBuildRequest = new ContextBuildRequest( request.sessionId(), leafEntryId, - // NOTE: lypi-resource 负责从 cwd 探索 project root 和资源层级;agent-core 只传入启动层确定的 cwd 起点。 - ports.cwd(), + // NOTE: lypi-resource 负责从 cwd 探索 project root 和资源层级;cwd 跟随当前 shell 状态(cd 后随之迁移)。 + currentShellCwd(), true, skillMentions ); @@ -488,7 +489,8 @@ private List> executeTools( turnId, parentEntryId, turnRequest.abortSignal(), - turnRequest.steeringMessages() + turnRequest.steeringMessages(), + currentShellCwd() ) ); if (results.size() != toolRequests.size()) { @@ -499,9 +501,45 @@ private List> executeTools( } catch (RuntimeException failure) { throw failure; } + applyShellCwdDeltas(results); return results; } + private Path currentShellCwd() { + try { + Path shellCwd = ports.sessionManager().shellState().cwd(); + // 该 manager 可能属于另一个 cwd 的 session(如 child runtime 共享父 manager); + // 与本 runtime cwd 不一致时视为外部状态,不覆盖本 runtime 的绑定 cwd。 + if (shellCwd != null && shellCwd.toAbsolutePath().normalize().startsWith(ports.cwd())) { + return shellCwd; + } + return ports.cwd(); + } catch (RuntimeException e) { + return ports.cwd(); + } + } + + private void applyShellCwdDeltas(List> results) { + for (ToolResult result : results) { + if (result == null || result.isError() || !(result.output() instanceof String output)) { + continue; + } + java.util.regex.Matcher matcher = SHELL_CWD_PATTERN.matcher(output); + if (!matcher.find()) { + continue; + } + Path captured = Path.of(matcher.group(1).trim()); + try { + ports.sessionManager().updateShellState(ShellState.of(captured)); + } catch (RuntimeException e) { + // cwd 回写失败不阻塞工具结果 + } + } + } + + private static final java.util.regex.Pattern SHELL_CWD_PATTERN = + java.util.regex.Pattern.compile("(?m)^shellCwd=(\\S+)$"); + private void ensureToolRuntimeCwdMatches() { Path agentCwd = ports.cwd(); Path toolCwd = ports.toolRuntime().cwd().toAbsolutePath().normalize(); diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/SessionManagerPort.java b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/SessionManagerPort.java index 2b539874..77dff3d3 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/SessionManagerPort.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/SessionManagerPort.java @@ -17,6 +17,22 @@ public interface SessionManagerPort { */ SessionHandle openOrCreate(String sessionId); + /** + * 返回当前 session 的 shell 状态。 + */ + default cn.lypi.contracts.session.ShellState shellState() { + throw new UnsupportedOperationException("shell state is not supported"); + } + + /** + * 更新当前 session header 中的 shell 状态。 + * + * NOTE: 实现必须原子替换 JSONL 首行 header,不得改写历史 entry。 + */ + default SessionHandle updateShellState(cn.lypi.contracts.session.ShellState shellState) { + throw new UnsupportedOperationException("shell state is not supported"); + } + /** * 打开临时 session。 * diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java index 5971cd4e..a85f4b9d 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java @@ -13,7 +13,8 @@ public record ToolRuntimeInvocation( String turnId, String parentEntryId, AbortSignal abortSignal, - SteeringMessageSource steeringMessages + SteeringMessageSource steeringMessages, + java.nio.file.Path cwd ) { public ToolRuntimeInvocation(String sessionId, String turnId) { this(sessionId, turnId, null); @@ -23,8 +24,25 @@ public ToolRuntimeInvocation(String sessionId, String turnId, String parentEntry this(sessionId, turnId, parentEntryId, AbortSignal.none(), SteeringMessageSource.none()); } + public ToolRuntimeInvocation( + String sessionId, + String turnId, + String parentEntryId, + AbortSignal abortSignal, + SteeringMessageSource steeringMessages + ) { + this(sessionId, turnId, parentEntryId, abortSignal, steeringMessages, null); + } + public ToolRuntimeInvocation { abortSignal = abortSignal == null ? AbortSignal.none() : abortSignal; steeringMessages = steeringMessages == null ? SteeringMessageSource.none() : steeringMessages; } + + /** + * 本轮工具调用的工作目录覆盖;为空时由 runtime 默认 cwd 决定。 + */ + public ToolRuntimeInvocation withCwd(java.nio.file.Path cwdOverride) { + return new ToolRuntimeInvocation(sessionId, turnId, parentEntryId, abortSignal, steeringMessages, cwdOverride); + } } diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionHeader.java b/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionHeader.java index 31786218..117ba6f8 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionHeader.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionHeader.java @@ -28,9 +28,11 @@ public record SessionHeader( Optional initialModel, Optional initialThinkingLevel, Optional initialAgentMode, - PermissionRuntimeState initialPermissionRuntimeState + PermissionRuntimeState initialPermissionRuntimeState, + ShellState shellState ) { public SessionHeader { + shellState = shellState == null ? ShellState.of(cwd) : shellState; parentSessionId = parentSessionId == null ? Optional.empty() : parentSessionId; parentSpawnEntryId = parentSpawnEntryId == null ? Optional.empty() : parentSpawnEntryId; agentName = agentName == null ? Optional.empty() : agentName; @@ -62,7 +64,8 @@ public SessionHeader( Optional.empty(), Optional.empty(), Optional.empty(), - Optional.empty() + (PermissionRuntimeState) null, + null ); } @@ -98,7 +101,8 @@ public SessionHeader( initialAgentMode, initialPermissionMode == null ? null - : initialPermissionMode.map(PermissionRuntimeState::fromLegacy).orElse(null) + : initialPermissionMode.map(PermissionRuntimeState::fromLegacy).orElse(null), + null ); } @@ -128,7 +132,8 @@ public SessionHeader( Optional.empty(), Optional.empty(), Optional.empty(), - Optional.empty() + (PermissionRuntimeState) null, + null ); } @@ -159,7 +164,8 @@ public static SessionHeader create( @JsonProperty("initialThinkingLevel") Optional initialThinkingLevel, @JsonProperty("initialAgentMode") Optional initialAgentMode, @JsonProperty("initialPermissionRuntimeState") PermissionRuntimeState initialPermissionRuntimeState, - @JsonProperty("initialPermissionMode") Optional initialPermissionMode + @JsonProperty("initialPermissionMode") Optional initialPermissionMode, + @JsonProperty("shellState") ShellState shellState ) { PermissionRuntimeState normalizedRuntimeState = initialPermissionRuntimeState; if (normalizedRuntimeState == null && initialPermissionMode != null) { @@ -179,7 +185,31 @@ public static SessionHeader create( initialModel, initialThinkingLevel, initialAgentMode, - normalizedRuntimeState + normalizedRuntimeState, + shellState + ); + } + + /** + * 返回替换 shellState 后的副本。 + */ + public SessionHeader withShellState(ShellState newShellState) { + return new SessionHeader( + type, + version, + id, + cwd, + parentSessionId, + parentSpawnEntryId, + depth, + agentName, + agentRole, + timestamp, + initialModel, + initialThinkingLevel, + initialAgentMode, + initialPermissionRuntimeState, + newShellState ); } } diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellState.java b/lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellState.java new file mode 100644 index 00000000..fdb3098d --- /dev/null +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellState.java @@ -0,0 +1,27 @@ +package cn.lypi.contracts.session; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.nio.file.Path; +import java.util.Objects; + +/** + * 会话级 shell 状态。 + * + * NOTE: 第一阶段只承载当前 shell 工作目录;与 SessionHeader.cwd(session 创建/存储位置)语义不同, + * 该值随 Bash 捕获的 cd 结果更新。 + */ +public record ShellState(Path cwd) { + public ShellState { + Objects.requireNonNull(cwd, "cwd must not be null"); + } + + @JsonCreator + public static ShellState create(@JsonProperty("cwd") Path cwd) { + return new ShellState(cwd); + } + + public static ShellState of(Path cwd) { + return new ShellState(cwd); + } +} diff --git a/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java b/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java index 7f7ef647..a73c3992 100644 --- a/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java +++ b/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java @@ -88,6 +88,7 @@ import cn.lypi.contracts.session.CustomMessageEntry; import cn.lypi.contracts.session.SessionEntry; import cn.lypi.contracts.session.SessionHeader; +import cn.lypi.contracts.session.ShellState; import cn.lypi.contracts.session.SessionInfoEntry; import cn.lypi.contracts.skill.SkillMention; import cn.lypi.contracts.skill.SkillIndex; @@ -394,6 +395,36 @@ void sessionEntriesRoundTripOnlyForConversationPathFacts() throws Exception { } } + @Test + void sessionHeaderRoundTripKeepsShellState() throws Exception { + SessionHeader header = new SessionHeader( + "session", + 1, + "ses_shell", + Path.of("/tmp/project"), + Optional.empty(), + Instant.parse("2026-06-09T00:00:00Z") + ).withShellState(ShellState.of(Path.of("/tmp/project/subdir"))); + + String json = mapper.writeValueAsString(header); + SessionHeader restored = mapper.readValue(json, SessionHeader.class); + + assertEquals(Path.of("/tmp/project/subdir"), restored.shellState().cwd()); + assertEquals(Path.of("/tmp/project"), restored.cwd()); + } + + @Test + void legacySessionHeaderDefaultsShellStateToHeaderCwd() throws Exception { + String legacyJson = """ + {"type":"session","version":1,"id":"ses_old","cwd":"/tmp/legacy", + "timestamp":"2026-06-09T00:00:00Z"} + """; + + SessionHeader restored = mapper.readValue(legacyJson, SessionHeader.class); + + assertEquals(ShellState.of(Path.of("/tmp/legacy")), restored.shellState()); + } + @Test void sessionHeaderRoundTripKeepsSubagentRelationshipFields() throws Exception { SessionHeader header = new SessionHeader( @@ -435,7 +466,8 @@ void sessionHeaderRoundTripKeepsCanonicalPermissionRuntimeState() throws Excepti Optional.empty(), Optional.empty(), Optional.empty(), - runtimeState + runtimeState, + null ); String json = mapper.writeValueAsString(header); diff --git a/lypi-session/src/main/java/cn/lypi/session/ChildSessionService.java b/lypi-session/src/main/java/cn/lypi/session/ChildSessionService.java index 449d77e1..3220f829 100644 --- a/lypi-session/src/main/java/cn/lypi/session/ChildSessionService.java +++ b/lypi-session/src/main/java/cn/lypi/session/ChildSessionService.java @@ -6,6 +6,7 @@ import cn.lypi.contracts.session.SessionHandle; import cn.lypi.contracts.session.SessionHeader; import cn.lypi.contracts.session.SessionInfoEntry; +import cn.lypi.contracts.session.ShellState; import java.time.Clock; import java.time.Instant; import java.util.LinkedHashMap; @@ -51,7 +52,8 @@ public SessionHandle create(ChildSessionRequest request) { request.initialModel(), request.initialThinkingLevel(), request.initialAgentMode(), - request.initialPermissionRuntimeState() + request.initialPermissionRuntimeState(), + ShellState.of(request.cwd()) ); store.create(header); diff --git a/lypi-session/src/main/java/cn/lypi/session/ForkService.java b/lypi-session/src/main/java/cn/lypi/session/ForkService.java index e77ac477..b441d645 100644 --- a/lypi-session/src/main/java/cn/lypi/session/ForkService.java +++ b/lypi-session/src/main/java/cn/lypi/session/ForkService.java @@ -45,7 +45,8 @@ SessionHandle fork(ForkRequest request, SessionHeader sourceHeader, EntryTreeInd sourceHeader.initialModel(), sourceHeader.initialThinkingLevel(), sourceHeader.initialAgentMode(), - sourceHeader.initialPermissionRuntimeState() + sourceHeader.initialPermissionRuntimeState(), + sourceHeader.shellState() ); JsonlSessionStore targetStore = new JsonlSessionStore(request.targetCwd()); targetStore.create(header); diff --git a/lypi-session/src/main/java/cn/lypi/session/JsonlSessionStore.java b/lypi-session/src/main/java/cn/lypi/session/JsonlSessionStore.java index b70699f6..a4de5c55 100644 --- a/lypi-session/src/main/java/cn/lypi/session/JsonlSessionStore.java +++ b/lypi-session/src/main/java/cn/lypi/session/JsonlSessionStore.java @@ -104,6 +104,25 @@ boolean tryCreate(SessionHeader header) { } } + /** + * 原子替换 session 文件首行 header,保留其余 entry 行不变。 + */ + void rewriteHeader(SessionHeader header) { + Path file = sessionFile(header.id()); + try { + List lines = Files.readAllLines(file, StandardCharsets.UTF_8); + if (lines.isEmpty()) { + throw new SessionEngineException("Session file is empty: " + file); + } + lines.set(0, mapper.writeHeader(header)); + Path tmp = file.resolveSibling(file.getFileName() + ".tmp"); + Files.write(tmp, lines, StandardCharsets.UTF_8); + Files.move(tmp, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING, java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + throw new SessionEngineException("Failed to rewrite session header: " + file, e); + } + } + /** * 读取 session 文件并解析 header 与 entries。 */ diff --git a/lypi-session/src/main/java/cn/lypi/session/SessionManagerImpl.java b/lypi-session/src/main/java/cn/lypi/session/SessionManagerImpl.java index 2a72378a..36f34d26 100644 --- a/lypi-session/src/main/java/cn/lypi/session/SessionManagerImpl.java +++ b/lypi-session/src/main/java/cn/lypi/session/SessionManagerImpl.java @@ -17,6 +17,7 @@ import cn.lypi.contracts.session.SessionHandle; import cn.lypi.contracts.session.SessionHeader; import cn.lypi.contracts.session.SessionView; +import cn.lypi.contracts.session.ShellState; import cn.lypi.contracts.tui.SessionFileView; import java.nio.file.Path; import java.time.Clock; @@ -212,6 +213,22 @@ public synchronized SessionView currentView() { return view(index.leafId()); } + @Override + public synchronized ShellState shellState() { + ensureOpen(); + return header.shellState(); + } + + @Override + public synchronized SessionHandle updateShellState(ShellState shellState) { + ensureOpen(); + header = header.withShellState(shellState); + if (persistent) { + store.rewriteHeader(header); + } + return new SessionHandle(sessionId, store.sessionFile(sessionId), index.leafId(), index.byId()); + } + @Override public synchronized SessionView view(String leafId) { ensureOpen(); @@ -403,7 +420,8 @@ private SessionHeader initialHeader(String sessionId) { Optional.of(replayProjector.defaultModel()), Optional.of(replayProjector.defaultThinkingLevel()), Optional.of(replayProjector.defaultMode()), - replayProjector.defaultPermissionRuntimeState() + replayProjector.defaultPermissionRuntimeState(), + ShellState.of(cwd) ); } diff --git a/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java b/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java index 393b080b..18fbec2a 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java @@ -69,11 +69,18 @@ public ToolUseContext create(ToolUseRequest request, ContextSnapshot context, To return new ToolUseContext( sessionId(invocation), request.parentMessageId(), - options.cwd(), + invocationCwd(invocation), Map.copyOf(metadata) ); } + private Path invocationCwd(ToolRuntimeInvocation invocation) { + if (invocation != null && invocation.cwd() != null) { + return invocation.cwd(); + } + return options.cwd(); + } + private String sessionId(ToolRuntimeInvocation invocation) { if (invocation == null || invocation.sessionId() == null || invocation.sessionId().isBlank()) { return options.sessionId(); diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java index c6cad492..1fe942d3 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java @@ -45,15 +45,21 @@ public final class BashTool extends AbstractFileTool { private final Executor executor; private final SandboxPolicyResolver sandboxPolicyResolver; private final BashPermissionPolicy permissionPolicy; + private final ShellEnvironmentHarness shellHarness; public BashTool(Executor executor) { this(executor, new DefaultSandboxPolicyResolver(SandboxPolicyOptions.defaults())); } public BashTool(Executor executor, SandboxPolicyResolver sandboxPolicyResolver) { + this(executor, sandboxPolicyResolver, new ShellEnvironmentHarness(ShellEnvironmentHarness.defaultStateRoot())); + } + + public BashTool(Executor executor, SandboxPolicyResolver sandboxPolicyResolver, ShellEnvironmentHarness shellHarness) { this.executor = Objects.requireNonNull(executor, "executor must not be null"); this.sandboxPolicyResolver = Objects.requireNonNull(sandboxPolicyResolver, "sandboxPolicyResolver must not be null"); this.permissionPolicy = new BashPermissionPolicy(this.sandboxPolicyResolver); + this.shellHarness = Objects.requireNonNull(shellHarness, "shellHarness must not be null"); } @Override @@ -140,7 +146,7 @@ public ToolResult execute(Map input, ToolUseContext cont ? SandboxRuntimePolicy.disabled() : sandboxPolicy(context.cwd(), cwd, permissionRuntimeState, additionalPermissions); ExecutionRequest request = new ExecutionRequest( - shellCommand(input), + shellCommand(input, context), cwd, Map.of(), timeout, @@ -153,7 +159,7 @@ public ToolResult execute(Map input, ToolUseContext cont ); progress.progress(ToolProgress.phase("running", "执行 shell 命令")); ExecutionResult result = executor.execute(request, progress, abortSignal(context)); - return success(toolUseId, renderResult(result)); + return success(toolUseId, renderResult(result, context, input)); } catch (IllegalArgumentException exception) { return error(toolUseId, exception.getMessage()); } catch (IOException exception) { @@ -188,7 +194,7 @@ private AbortSignal abortSignal(ToolUseContext context) { return value instanceof AbortSignal signal ? signal : NOT_ABORTED; } - private String renderResult(ExecutionResult result) { + private String renderResult(ExecutionResult result, ToolUseContext context, Map input) { StringBuilder builder = new StringBuilder(); builder.append("exitCode=").append(result.exitCode()); if (result.timedOut()) { @@ -214,9 +220,18 @@ private String renderResult(ExecutionResult result) { builder.append("\nstderr:\n").append(result.stderr()); } result.persistedOutput().ifPresent(path -> builder.append("\npersistedOutput=").append(path)); + if (capturesShellCwd(input)) { + shellHarness.consumeCapturedCwd(context.sessionId()) + .filter(captured -> !captured.equals(context.cwd().toAbsolutePath().normalize())) + .ifPresent(captured -> builder.append("\nshellCwd=").append(captured)); + } return builder.toString(); } + private boolean capturesShellCwd(Map input) { + return stringInput(input, "cwd").isBlank(); + } + private String sanitizeCommand(String command) { return command.replaceAll("(?i)(api[_-]?key|token|password)=\\S+", "$1="); } @@ -318,11 +333,20 @@ private boolean isEmpty(AdditionalPermissionProfile permissions) { return permissions.fileSystem().isEmpty() && permissions.network().isEmpty(); } - private List shellCommand(Map input) { + private List shellCommand(Map input, ToolUseContext context) { String shell = stringInput(input, INPUT_SHELL); String resolvedShell = shell.isBlank() ? "bash" : shell; - boolean loginShell = booleanInput(input, INPUT_LOGIN_SHELL, true); - return List.of(resolvedShell, loginShell ? "-lc" : "-c", input.get("command").toString()); + String command = input.get("command").toString(); + if (!capturesShellCwd(input)) { + // 显式 cwd 的一次性命令不接入 harness 状态(不捕获、不回写) + boolean loginShell = booleanInput(input, INPUT_LOGIN_SHELL, true); + return List.of(resolvedShell, loginShell ? "-lc" : "-c", command); + } + shellHarness.ensureSnapshot(context.sessionId(), resolvedShell); + shellHarness.importEnvFile(context.sessionId(), System.getenv()); + String wrapped = shellHarness.wrap(context.sessionId(), command); + boolean loginShell = booleanInput(input, INPUT_LOGIN_SHELL, true) && !shellHarness.snapshotExists(context.sessionId()); + return List.of(resolvedShell, loginShell ? "-lc" : "-c", wrapped); } private boolean isAllowedShell(String shell) { diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java new file mode 100644 index 00000000..43a62f1f --- /dev/null +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java @@ -0,0 +1,185 @@ +package cn.lypi.tool.builtin; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.util.Comparator; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +/** + * 会话级 shell 环境 harness:snapshot 重放 + session env 脚本 + cwd 捕获。 + * + * NOTE: 对齐 Claude Code 的一次性进程模型——每条命令仍是独立进程, + * 状态通过外部文件(snapshot / env/*.sh / cwd 文件)跨命令继承,不做常驻 shell。 + */ +public final class ShellEnvironmentHarness { + static final String SNAPSHOT_FILE = "shell-snapshot.sh"; + static final String CWD_FILE = "cwd"; + static final String ENV_DIR = "env"; + static final String ENV_FILE_VARIABLE = "LYPI_ENV_FILE"; + private static final Duration SNAPSHOT_TIMEOUT = Duration.ofSeconds(10); + + private final Path stateRoot; + + public ShellEnvironmentHarness(Path stateRoot) { + this.stateRoot = Objects.requireNonNull(stateRoot, "stateRoot must not be null").toAbsolutePath().normalize(); + } + + /** + * 默认状态根目录:~/.lypi/shell-state。 + */ + public static Path defaultStateRoot() { + return Path.of(System.getProperty("user.home"), ".lypi", "shell-state"); + } + + Path sessionDir(String sessionId) { + String safeId = sessionId == null || sessionId.isBlank() ? "anonymous" : sessionId.replaceAll("[^A-Za-z0-9_-]", "_"); + return stateRoot.resolve(safeId); + } + + /** + * snapshot 是否已存在(存在则调用方可用非 login shell)。 + */ + public boolean snapshotExists(String sessionId) { + return Files.isRegularFile(sessionDir(sessionId).resolve(SNAPSHOT_FILE)); + } + + /** + * 用 login shell dump 当前环境为 snapshot 文件(export -p / alias -p / declare -f)。 + * + * 已存在或生成失败时静默跳过——失败只意味着下次命令回退 login shell。 + */ + public void ensureSnapshot(String sessionId, String shell) { + Path snapshot = sessionDir(sessionId).resolve(SNAPSHOT_FILE); + if (Files.exists(snapshot)) { + return; + } + try { + Files.createDirectories(snapshot.getParent()); + Path tmp = snapshot.resolveSibling(SNAPSHOT_FILE + ".tmp"); + Process process = new ProcessBuilder( + shell, + "-lc", + "{ export -p; alias -p; declare -f; } > " + shellQuote(tmp.toString()) + " 2>/dev/null" + ).redirectErrorStream(false).start(); + boolean exited = process.waitFor(SNAPSHOT_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + if (!exited) { + process.destroyForcibly(); + Files.deleteIfExists(tmp); + return; + } + if (process.exitValue() == 0 && Files.exists(tmp) && Files.size(tmp) > 0) { + Files.move(tmp, snapshot, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } else { + Files.deleteIfExists(tmp); + } + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + // snapshot 是优化项,失败不阻塞命令执行 + } + } + + /** + * 包装用户命令:source snapshot + session env 脚本 + eval 用户命令 + 捕获最终 cwd。 + * + * 结构(对齐 CC): + * source snapshot || true && source env/*.sh && eval '' ; rc=$?; pwd -P >| cwd ; exit $rc + * + * eval 让 snapshot 中定义的 alias 在二次解析时生效;cwd 捕获用 `;` 而非 `&&`,命令失败也记录。 + */ + public String wrap(String sessionId, String command) { + StringBuilder builder = new StringBuilder(); + Path dir = sessionDir(sessionId); + Path snapshot = dir.resolve(SNAPSHOT_FILE); + if (Files.isRegularFile(snapshot)) { + builder.append("source ").append(shellQuote(snapshot.toString())).append(" 2>/dev/null || true && "); + } + builder.append("lypi_env_dir=").append(shellQuote(dir.resolve(ENV_DIR).toString())).append("; "); + builder.append("if [ -d \"$lypi_env_dir\" ]; then "); + builder.append("for lypi_env_file in \"$lypi_env_dir\"/*.sh; do [ -f \"$lypi_env_file\" ] && source \"$lypi_env_file\"; done; "); + builder.append("fi; "); + builder.append("unset lypi_env_dir; "); + builder.append("eval ").append(shellQuote(command)).append("; "); + builder.append("lypi_rc=$?; "); + builder.append("pwd -P >| ").append(shellQuote(dir.resolve(CWD_FILE).toString())).append(" 2>/dev/null; "); + builder.append("exit $lypi_rc"); + return builder.toString(); + } + + /** + * 读取并清除上一条命令捕获的 cwd。 + */ + public Optional consumeCapturedCwd(String sessionId) { + Path file = sessionDir(sessionId).resolve(CWD_FILE); + try { + if (!Files.isRegularFile(file)) { + return Optional.empty(); + } + String content = Files.readString(file, StandardCharsets.UTF_8).trim(); + Files.deleteIfExists(file); + if (content.isEmpty()) { + return Optional.empty(); + } + Path cwd = Path.of(content); + // cwd 可能刚被命令删掉;此时不回写,调用方保留旧值(或回退启动目录) + return Files.isDirectory(cwd) ? Optional.of(cwd) : Optional.empty(); + } catch (IOException e) { + return Optional.empty(); + } + } + + /** + * 外部 runner 注入的 env 脚本(LYPI_ENV_FILE 指向的文件复制进 session env 目录)。 + */ + public void importEnvFile(String sessionId, Map environment) { + String source = environment == null ? null : environment.get(ENV_FILE_VARIABLE); + if (source == null || source.isBlank()) { + return; + } + Path sourceFile = Path.of(source); + if (!Files.isRegularFile(sourceFile)) { + return; + } + try { + Path envDir = sessionDir(sessionId).resolve(ENV_DIR); + Files.createDirectories(envDir); + Path target = envDir.resolve("00-lypi-env-file.sh"); + if (!Files.exists(target)) { + Files.copy(sourceFile, target); + } + } catch (IOException e) { + // 注入失败不阻塞执行 + } + } + + /** + * session env 目录下按文件名排序的脚本列表(测试与诊断用)。 + */ + Stream envScripts(String sessionId) { + Path envDir = sessionDir(sessionId).resolve(ENV_DIR); + if (!Files.isDirectory(envDir)) { + return Stream.empty(); + } + try { + return Files.list(envDir) + .filter(path -> path.getFileName().toString().endsWith(".sh")) + .sorted(Comparator.comparing(path -> path.getFileName().toString())); + } catch (IOException e) { + return Stream.empty(); + } + } + + private static String shellQuote(String value) { + return "'" + value.replace("'", "'\\''") + "'"; + } +} diff --git a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java index 7f49b37e..8874753d 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java @@ -376,7 +376,8 @@ void askReviewsDefaultBashEvenWhenSecurityAndToolAllow() { assertFalse(result.isError()); assertEquals(1, executor.calls.get()); - assertEquals(List.of("bash", "-lc", "echo done"), executor.request.get().command()); + assertEquals("bash", executor.request.get().command().get(0)); + assertTrue(executor.request.get().command().get(2).contains("eval 'echo done'")); assertTrue(result.newMessages().getFirst().content().getFirst().text().contains("stdout:\ndone")); } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java index df1f6140..eac7be57 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java @@ -93,7 +93,7 @@ void inputSchemaExposesShellSelectionFields() { void mapsCommandToExecutionRequestAndResult() { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(7, "out", "err", false, Optional.empty())); RecordingSandboxPolicyResolver resolver = new RecordingSandboxPolicyResolver(defaultPolicy()); - BashTool tool = new BashTool(executor, resolver); + BashTool tool = new BashTool(executor, resolver, testHarness()); List progresses = new ArrayList<>(); ToolResult result = tool.execute( @@ -103,7 +103,10 @@ void mapsCommandToExecutionRequestAndResult() { ); assertFalse(result.isError()); - assertEquals(List.of("bash", "-lc", "echo hi"), executor.request.get().command()); + assertEquals("bash", executor.request.get().command().get(0)); + assertTrue(executor.request.get().command().get(2).contains("eval 'echo hi'")); + // snapshot 可能已由其他测试预生成(-c)或尚未生成(-lc) + assertTrue(List.of("-c", "-lc").contains(executor.request.get().command().get(1))); assertEquals(tempDir, executor.request.get().cwd()); assertEquals(Duration.ofSeconds(3), executor.request.get().timeout()); assertSame(resolver.policy, executor.request.get().sandboxPolicy()); @@ -220,7 +223,91 @@ void mapsNonLoginShellCommandToExecutionRequest() { ); assertFalse(result.isError()); - assertEquals(List.of("bash", "-c", "echo hi"), executor.request.get().command()); + assertEquals("bash", executor.request.get().command().get(0)); + assertEquals("-c", executor.request.get().command().get(1)); + assertTrue(executor.request.get().command().get(2).contains("eval 'echo hi'")); + } + + @Test + void wrapsCommandWithHarnessAndCapturesShellCwd() throws Exception { + ShellEnvironmentHarness harness = testHarness(); + RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), harness); + + ToolResult result = tool.execute( + Map.of("command", "echo hi"), + context(Map.of()), + message -> { + } + ); + + assertFalse(result.isError()); + List command = executor.request.get().command(); + assertEquals("bash", command.get(0)); + String wrapped = command.get(2); + assertTrue(wrapped.contains("eval 'echo hi'"), wrapped); + assertTrue(wrapped.contains("pwd -P"), wrapped); + // RecordingExecutor 不真正执行,无 shellCwd 输出 + assertFalse(result.output().contains("shellCwd=")); + } + + @Test + void explicitCwdCommandBypassesHarness() { + ShellEnvironmentHarness harness = testHarness(); + RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), harness); + + ToolResult result = tool.execute( + Map.of("command", "echo hi", "cwd", "."), + context(Map.of()), + message -> { + } + ); + + assertFalse(result.isError()); + assertEquals(List.of("bash", "-lc", "echo hi"), executor.request.get().command()); + assertFalse(harness.snapshotExists("ses_1")); + } + + @Test + void shellCwdCapturedFromExecutedCommand() throws Exception { + ShellEnvironmentHarness harness = testHarness(); + // 用真实 bash 执行,走完整 wrap + cwd 捕获链路 + Executor realExecutor = new cn.lypi.tool.shell.HostExecutor(); + BashTool tool = new BashTool(realExecutor, new RecordingSandboxPolicyResolver(defaultPolicy()), harness); + + ToolResult result = tool.execute( + Map.of("command", "pwd"), + context(Map.of()), + message -> { + } + ); + + assertFalse(result.isError()); + assertTrue(result.output().contains("exitCode=0"), result.output()); + // pwd 未改变目录,无 shellCwd 增量(captured == context.cwd) + assertFalse(result.output().contains("shellCwd="), result.output()); + assertTrue(harness.snapshotExists("ses_1")); + } + + @Test + void sessionEnvScriptAppliesToWrappedCommand() throws Exception { + ShellEnvironmentHarness harness = testHarness(); + Path envDir = harness.sessionDir("ses_1").resolve("env"); + Files.createDirectories(envDir); + Files.writeString(envDir.resolve("01-test.sh"), "export LYPI_BASH_TOOL_TEST=persisted\n"); + Executor realExecutor = new cn.lypi.tool.shell.HostExecutor(); + BashTool tool = new BashTool(realExecutor, new RecordingSandboxPolicyResolver(defaultPolicy()), harness); + + ToolResult result = tool.execute( + Map.of("command", "echo \"$LYPI_BASH_TOOL_TEST\""), + context(Map.of()), + message -> { + } + ); + + assertFalse(result.isError()); + assertTrue(result.output().contains("persisted"), result.output()); } @Test @@ -236,7 +323,7 @@ void mapsAllowedShellToExecutionRequest() { ); assertFalse(shResult.isError()); - assertEquals(List.of("sh", "-lc", "echo hi"), executor.request.get().command()); + assertEquals("sh", executor.request.get().command().get(0)); ToolResult zshResult = tool.execute( Map.of("command", "echo hi", "shell", "zsh"), @@ -246,7 +333,7 @@ void mapsAllowedShellToExecutionRequest() { ); assertFalse(zshResult.isError()); - assertEquals(List.of("zsh", "-lc", "echo hi"), executor.request.get().command()); + assertEquals("zsh", executor.request.get().command().get(0)); ToolResult absoluteBashResult = tool.execute( Map.of("command", "echo hi", "shell", "/bin/bash"), @@ -256,7 +343,7 @@ void mapsAllowedShellToExecutionRequest() { ); assertFalse(absoluteBashResult.isError()); - assertEquals(List.of("/bin/bash", "-lc", "echo hi"), executor.request.get().command()); + assertEquals("/bin/bash", executor.request.get().command().get(0)); } @Test @@ -677,6 +764,10 @@ private ToolUseContext context(Map extraMetadata) { return new ToolUseContext("ses_1", "msg_1", tempDir, Map.copyOf(metadata)); } + private ShellEnvironmentHarness testHarness() { + return new ShellEnvironmentHarness(tempDir.resolve("shell-state")); + } + private SandboxRuntimePolicy defaultPolicy() { return policy(false, false); } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java new file mode 100644 index 00000000..35b9ffa5 --- /dev/null +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java @@ -0,0 +1,116 @@ +package cn.lypi.tool.builtin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ShellEnvironmentHarnessTest { + @TempDir + Path stateRoot; + + private ShellEnvironmentHarness harness() { + return new ShellEnvironmentHarness(stateRoot); + } + + @Test + void wrapWithoutSnapshotSourcesEnvDirAndCapturesCwd() { + ShellEnvironmentHarness harness = harness(); + + String wrapped = harness.wrap("ses_1", "echo hi"); + + assertFalse(wrapped.contains("shell-snapshot.sh")); + assertTrue(wrapped.contains("eval 'echo hi'")); + assertTrue(wrapped.contains("pwd -P >|")); + assertTrue(wrapped.contains("exit $lypi_rc")); + assertTrue(wrapped.contains("env")); + } + + @Test + void wrapWithSnapshotSourcesSnapshotFirst() throws IOException { + ShellEnvironmentHarness harness = harness(); + Path dir = harness.sessionDir("ses_1"); + Files.createDirectories(dir); + Files.writeString(dir.resolve(ShellEnvironmentHarness.SNAPSHOT_FILE), "export FOO=1\n"); + + String wrapped = harness.wrap("ses_1", "echo hi"); + + assertTrue(wrapped.startsWith("source '")); + assertTrue(wrapped.contains("shell-snapshot.sh")); + assertTrue(wrapped.contains("|| true && ")); + assertTrue(harness.snapshotExists("ses_1")); + } + + @Test + void ensureSnapshotDumpsEnvironmentOnce() { + ShellEnvironmentHarness harness = harness(); + + harness.ensureSnapshot("ses_1", "bash"); + assertTrue(harness.snapshotExists("ses_1")); + + // 第二次调用不重写(mtime 不变) + Path snapshot = harness.sessionDir("ses_1").resolve(ShellEnvironmentHarness.SNAPSHOT_FILE); + try { + long mtime = Files.getLastModifiedTime(snapshot).toMillis(); + harness.ensureSnapshot("ses_1", "bash"); + assertEquals(mtime, Files.getLastModifiedTime(snapshot).toMillis()); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + @Test + void wrappedCommandExecutesWithEnvScriptsAndCapturesCwd() throws Exception { + ShellEnvironmentHarness harness = harness(); + Path envDir = harness.sessionDir("ses_2").resolve(ShellEnvironmentHarness.ENV_DIR); + Files.createDirectories(envDir); + Files.writeString(envDir.resolve("01-first.sh"), "export LYPI_TEST_A=hello\n"); + Files.writeString(envDir.resolve("02-second.sh"), "export LYPI_TEST_B=$LYPI_TEST_A-world\n"); + + List scripts = harness.envScripts("ses_2").toList(); + assertEquals(2, scripts.size()); + assertTrue(scripts.get(0).getFileName().toString().startsWith("01")); + + String wrapped = harness.wrap("ses_2", "echo \"$LYPI_TEST_B\" && cd /"); + Process process = new ProcessBuilder("bash", "-c", wrapped) + .redirectErrorStream(true) + .start(); + String output = new String(process.getInputStream().readAllBytes()); + assertEquals(0, process.waitFor()); + assertTrue(output.contains("hello-world"), output); + + Optional cwd = harness.consumeCapturedCwd("ses_2"); + assertEquals(Optional.of(Path.of("/")), cwd); + // 已消费,再次读取为空 + assertTrue(harness.consumeCapturedCwd("ses_2").isEmpty()); + } + + @Test + void importEnvFileCopiesExternalScriptOnce() throws IOException { + ShellEnvironmentHarness harness = harness(); + Path external = stateRoot.resolve("external.sh"); + Files.writeString(external, "export LYPI_EXTERNAL=1\n"); + + harness.importEnvFile("ses_3", Map.of(ShellEnvironmentHarness.ENV_FILE_VARIABLE, external.toString())); + harness.importEnvFile("ses_3", Map.of(ShellEnvironmentHarness.ENV_FILE_VARIABLE, external.toString())); + + List scripts = harness.envScripts("ses_3").toList(); + assertEquals(1, scripts.size()); + assertEquals("export LYPI_EXTERNAL=1", Files.readString(scripts.get(0)).trim()); + } + + @Test + void sessionIdSanitizedForFilesystem() { + ShellEnvironmentHarness harness = harness(); + Path dir = harness.sessionDir("../evil/../../id"); + assertTrue(dir.startsWith(stateRoot), dir.toString()); + } +} From 4495343ab00487491ddb277c3d23e3a9d614b009 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Fri, 7 Aug 2026 22:08:56 +0800 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20bash=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=20+=20cwd=20=E5=85=A5=E5=8F=82=E7=A7=BB?= =?UTF-8?q?=E5=87=BA=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BashTool 新增 description():向模型说明 cwd 跨命令持久(cd 后文件工具 一并跟随)、环境经 snapshot 重放、export/source 不跨命令持久 - inputSchema 移除 cwd 入参;兼容期内仍接受(绝对/相对路径作一次性执行 目录,走 harness 但不回写会话状态),resolveBashCwd 语义不变 - 清理 capturesShellCwd 死分支,所有命令统一经 harness 包装 --- .../java/cn/lypi/tool/builtin/BashTool.java | 38 ++++++++++--------- .../cn/lypi/tool/builtin/BashToolTest.java | 11 ++++-- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java index 1fe942d3..4d982726 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java @@ -67,14 +67,27 @@ public String name() { return "bash"; } + @Override + public String description() { + return "Execute shell commands in the session's persistent shell state. " + + "The working directory persists across calls: `cd dir` in one command applies to all subsequent " + + "bash commands and file tools (read/write/grep/glob resolve relative paths against it), " + + "so do not pass absolute paths or repeat cd. " + + "Your login shell environment (aliases, functions, exports) is replayed from a snapshot on every call. " + + "Note: `export`/`source` inside a command do NOT persist to the next call; cross-command environment " + + "must come from session env scripts or the login profile."; + } + @Override public JsonSchema inputSchema() { return new JsonSchema(Map.of( "type", "object", "required", List.of("command"), "properties", Map.of( - "command", Map.of("type", "string"), - "cwd", Map.of("type", "string"), + "command", Map.of( + "type", "string", + "description", "Shell command executed in the session working directory (persists via cd)." + ), INPUT_SHELL, Map.of("type", "string"), INPUT_LOGIN_SHELL, Map.of("type", "boolean"), "timeoutSeconds", Map.of("type", "integer", "minimum", 1), @@ -159,7 +172,7 @@ public ToolResult execute(Map input, ToolUseContext cont ); progress.progress(ToolProgress.phase("running", "执行 shell 命令")); ExecutionResult result = executor.execute(request, progress, abortSignal(context)); - return success(toolUseId, renderResult(result, context, input)); + return success(toolUseId, renderResult(result, context)); } catch (IllegalArgumentException exception) { return error(toolUseId, exception.getMessage()); } catch (IOException exception) { @@ -194,7 +207,7 @@ private AbortSignal abortSignal(ToolUseContext context) { return value instanceof AbortSignal signal ? signal : NOT_ABORTED; } - private String renderResult(ExecutionResult result, ToolUseContext context, Map input) { + private String renderResult(ExecutionResult result, ToolUseContext context) { StringBuilder builder = new StringBuilder(); builder.append("exitCode=").append(result.exitCode()); if (result.timedOut()) { @@ -220,18 +233,12 @@ private String renderResult(ExecutionResult result, ToolUseContext context, Map< builder.append("\nstderr:\n").append(result.stderr()); } result.persistedOutput().ifPresent(path -> builder.append("\npersistedOutput=").append(path)); - if (capturesShellCwd(input)) { - shellHarness.consumeCapturedCwd(context.sessionId()) - .filter(captured -> !captured.equals(context.cwd().toAbsolutePath().normalize())) - .ifPresent(captured -> builder.append("\nshellCwd=").append(captured)); - } + shellHarness.consumeCapturedCwd(context.sessionId()) + .filter(captured -> !captured.equals(context.cwd().toAbsolutePath().normalize())) + .ifPresent(captured -> builder.append("\nshellCwd=").append(captured)); return builder.toString(); } - private boolean capturesShellCwd(Map input) { - return stringInput(input, "cwd").isBlank(); - } - private String sanitizeCommand(String command) { return command.replaceAll("(?i)(api[_-]?key|token|password)=\\S+", "$1="); } @@ -337,11 +344,6 @@ private List shellCommand(Map input, ToolUseContext cont String shell = stringInput(input, INPUT_SHELL); String resolvedShell = shell.isBlank() ? "bash" : shell; String command = input.get("command").toString(); - if (!capturesShellCwd(input)) { - // 显式 cwd 的一次性命令不接入 harness 状态(不捕获、不回写) - boolean loginShell = booleanInput(input, INPUT_LOGIN_SHELL, true); - return List.of(resolvedShell, loginShell ? "-lc" : "-c", command); - } shellHarness.ensureSnapshot(context.sessionId(), resolvedShell); shellHarness.importEnvFile(context.sessionId(), System.getenv()); String wrapped = shellHarness.wrap(context.sessionId(), command); diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java index eac7be57..ffd4841f 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java @@ -252,11 +252,16 @@ void wrapsCommandWithHarnessAndCapturesShellCwd() throws Exception { } @Test - void explicitCwdCommandBypassesHarness() { + void cwdInputIsNotInSchemaButStillAcceptedAsExecutionOverride() { ShellEnvironmentHarness harness = testHarness(); RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), harness); + @SuppressWarnings("unchecked") + Map properties = (Map) tool.inputSchema().value().get("properties"); + assertFalse(properties.containsKey("cwd")); + + // 兼容期:显式 cwd 仍接受作为一次性执行目录,但命令照常走 harness 且不回写会话状态 ToolResult result = tool.execute( Map.of("command", "echo hi", "cwd", "."), context(Map.of()), @@ -265,8 +270,8 @@ void explicitCwdCommandBypassesHarness() { ); assertFalse(result.isError()); - assertEquals(List.of("bash", "-lc", "echo hi"), executor.request.get().command()); - assertFalse(harness.snapshotExists("ses_1")); + assertEquals("bash", executor.request.get().command().get(0)); + assertTrue(executor.request.get().command().get(2).contains("eval 'echo hi'")); } @Test From e6fcbbea330e72c6772e0b92d6f249b438e85c23 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sun, 9 Aug 2026 15:04:21 +0800 Subject: [PATCH 03/13] refactor(contracts): type shell state transitions --- .../contracts/runtime/SessionManagerPort.java | 6 +-- .../runtime/ToolRuntimeInvocation.java | 2 +- .../lypi/contracts/session/SessionEntry.java | 1 + .../lypi/contracts/session/SessionHeader.java | 22 --------- .../session/ShellStateChangeEntry.java | 20 +++++++++ .../cn/lypi/contracts/tool/ToolResult.java | 21 ++++++++- .../lypi/contracts/tool/ToolStateDelta.java | 13 ++++++ .../lypi/contracts/tool/ToolUseContext.java | 8 +++- .../contracts/ContractSerializationTest.java | 45 ++++++++++++++++++- 9 files changed, 105 insertions(+), 33 deletions(-) create mode 100644 lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellStateChangeEntry.java create mode 100644 lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolStateDelta.java diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/SessionManagerPort.java b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/SessionManagerPort.java index 77dff3d3..f7044c94 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/SessionManagerPort.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/SessionManagerPort.java @@ -25,11 +25,9 @@ default cn.lypi.contracts.session.ShellState shellState() { } /** - * 更新当前 session header 中的 shell 状态。 - * - * NOTE: 实现必须原子替换 JSONL 首行 header,不得改写历史 entry。 + * Append a shell state transition to the current session branch. */ - default SessionHandle updateShellState(cn.lypi.contracts.session.ShellState shellState) { + default SessionHandle appendShellStateChange(cn.lypi.contracts.session.ShellState shellState) { throw new UnsupportedOperationException("shell state is not supported"); } diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java index a85f4b9d..83b59793 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java @@ -40,7 +40,7 @@ public ToolRuntimeInvocation( } /** - * 本轮工具调用的工作目录覆盖;为空时由 runtime 默认 cwd 决定。 + * Dynamic working directory for this tool invocation. The runtime workspace root remains stable. */ public ToolRuntimeInvocation withCwd(java.nio.file.Path cwdOverride) { return new ToolRuntimeInvocation(sessionId, turnId, parentEntryId, abortSignal, steeringMessages, cwdOverride); diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionEntry.java b/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionEntry.java index ad92f0d9..2383834e 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionEntry.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionEntry.java @@ -13,6 +13,7 @@ @JsonSubTypes.Type(value = PermissionModeChangeEntry.class, name = "permission_mode_change"), @JsonSubTypes.Type(value = PermissionRuntimeStateChangeEntry.class, name = "permission_runtime_state_change"), @JsonSubTypes.Type(value = PermissionAmendmentEntry.class, name = "permission_amendment"), + @JsonSubTypes.Type(value = ShellStateChangeEntry.class, name = "shell_state_change"), @JsonSubTypes.Type(value = CompactionEntry.class, name = "compaction"), @JsonSubTypes.Type(value = BranchSummaryEntry.class, name = "branch_summary"), @JsonSubTypes.Type(value = CustomEntry.class, name = "custom"), diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionHeader.java b/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionHeader.java index 117ba6f8..eabdb01b 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionHeader.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/session/SessionHeader.java @@ -190,26 +190,4 @@ public static SessionHeader create( ); } - /** - * 返回替换 shellState 后的副本。 - */ - public SessionHeader withShellState(ShellState newShellState) { - return new SessionHeader( - type, - version, - id, - cwd, - parentSessionId, - parentSpawnEntryId, - depth, - agentName, - agentRole, - timestamp, - initialModel, - initialThinkingLevel, - initialAgentMode, - initialPermissionRuntimeState, - newShellState - ); - } } diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellStateChangeEntry.java b/lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellStateChangeEntry.java new file mode 100644 index 00000000..8992d973 --- /dev/null +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/session/ShellStateChangeEntry.java @@ -0,0 +1,20 @@ +package cn.lypi.contracts.session; + +import java.time.Instant; +import java.util.Objects; + +/** + * Append-only fact recording a shell state transition on a session branch. + */ +public record ShellStateChangeEntry( + String id, + String parentId, + ShellState shellState, + Instant timestamp +) implements SessionEntry { + public ShellStateChangeEntry { + Objects.requireNonNull(id, "id must not be null"); + Objects.requireNonNull(shellState, "shellState must not be null"); + Objects.requireNonNull(timestamp, "timestamp must not be null"); + } +} diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolResult.java b/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolResult.java index adbceb04..d60881fb 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolResult.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolResult.java @@ -9,6 +9,23 @@ public record ToolResult( O output, boolean isError, List newMessages, - Optional replacement -) {} + Optional replacement, + Optional stateDelta +) { + public ToolResult( + O output, + boolean isError, + List newMessages, + Optional replacement + ) { + this(output, isError, newMessages, replacement, Optional.empty()); + } + public ToolResult { + stateDelta = stateDelta == null ? Optional.empty() : stateDelta; + } + + public ToolResult withStateDelta(Optional nextStateDelta) { + return new ToolResult<>(output, isError, newMessages, replacement, nextStateDelta); + } +} diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolStateDelta.java b/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolStateDelta.java new file mode 100644 index 00000000..9197e0b6 --- /dev/null +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolStateDelta.java @@ -0,0 +1,13 @@ +package cn.lypi.contracts.tool; + +import java.nio.file.Path; +import java.util.Objects; + +/** + * State changes produced by a successful tool execution. + */ +public record ToolStateDelta(Path cwd) { + public ToolStateDelta { + Objects.requireNonNull(cwd, "cwd must not be null"); + } +} diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolUseContext.java b/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolUseContext.java index 9534c7d2..8262033d 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolUseContext.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/tool/ToolUseContext.java @@ -6,7 +6,11 @@ public record ToolUseContext( String sessionId, String messageId, + Path workspaceRoot, Path cwd, Map metadata -) {} - +) { + public ToolUseContext(String sessionId, String messageId, Path cwd, Map metadata) { + this(sessionId, messageId, cwd, cwd, metadata); + } +} diff --git a/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java b/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java index a73c3992..bef98980 100644 --- a/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java +++ b/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java @@ -88,6 +88,7 @@ import cn.lypi.contracts.session.CustomMessageEntry; import cn.lypi.contracts.session.SessionEntry; import cn.lypi.contracts.session.SessionHeader; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.session.ShellState; import cn.lypi.contracts.session.SessionInfoEntry; import cn.lypi.contracts.skill.SkillMention; @@ -109,7 +110,10 @@ import cn.lypi.contracts.model.TokenUsage; import cn.lypi.contracts.tool.ToolExecutionStatus; import cn.lypi.contracts.tool.ToolOutputRef; +import cn.lypi.contracts.tool.ToolResult; import cn.lypi.contracts.tool.ToolResultSummary; +import cn.lypi.contracts.tool.ToolStateDelta; +import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.contracts.tui.DiffView; import cn.lypi.contracts.tui.GitDiffFileView; import cn.lypi.contracts.tui.GitDiffStatus; @@ -395,6 +399,34 @@ void sessionEntriesRoundTripOnlyForConversationPathFacts() throws Exception { } } + @Test + void shellStateContractsKeepWorkspaceBoundaryAndRoundTripCwdWithSpaces() throws Exception { + Path workspace = Path.of("/tmp/project"); + Path nested = workspace.resolve("dir with spaces"); + ToolUseContext context = new ToolUseContext("ses_1", "msg_1", workspace, nested, Map.of()); + ToolUseContext legacyContext = new ToolUseContext("ses_1", "msg_1", workspace, Map.of()); + + ToolStateDelta delta = new ToolStateDelta(nested); + ToolResult result = new ToolResult<>("ok", false, List.of(), Optional.empty(), Optional.of(delta)); + ToolResult legacyResult = new ToolResult<>("ok", false, List.of(), Optional.empty()); + + Instant now = Instant.parse("2026-06-09T00:00:00Z"); + SessionEntry entry = new ShellStateChangeEntry( + "entry-shell", + "entry-tool", + ShellState.of(nested), + now + ); + + assertEquals(workspace, context.workspaceRoot()); + assertEquals(nested, context.cwd()); + assertEquals(workspace, legacyContext.workspaceRoot()); + assertEquals(workspace, legacyContext.cwd()); + assertEquals(delta, result.stateDelta().orElseThrow()); + assertEquals(Optional.empty(), legacyResult.stateDelta()); + assertEquals(entry, mapper.readValue(mapper.writeValueAsString(entry), SessionEntry.class)); + } + @Test void sessionHeaderRoundTripKeepsShellState() throws Exception { SessionHeader header = new SessionHeader( @@ -403,8 +435,17 @@ void sessionHeaderRoundTripKeepsShellState() throws Exception { "ses_shell", Path.of("/tmp/project"), Optional.empty(), - Instant.parse("2026-06-09T00:00:00Z") - ).withShellState(ShellState.of(Path.of("/tmp/project/subdir"))); + Optional.empty(), + 0, + Optional.empty(), + Optional.empty(), + Instant.parse("2026-06-09T00:00:00Z"), + Optional.empty(), + Optional.empty(), + Optional.empty(), + null, + ShellState.of(Path.of("/tmp/project/subdir")) + ); String json = mapper.writeValueAsString(header); SessionHeader restored = mapper.readValue(json, SessionHeader.class); From b118b45fcba0058a4c72d24d96cbd7c5aa71c8fc Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sun, 9 Aug 2026 15:08:52 +0800 Subject: [PATCH 04/13] fix(session): append shell state changes --- .../cn/lypi/session/JsonlSessionStore.java | 19 ------- .../cn/lypi/session/SessionJsonMapper.java | 3 + .../cn/lypi/session/SessionLeafSelector.java | 2 + .../cn/lypi/session/SessionManagerImpl.java | 23 +++++--- .../session/SessionEntryBoundaryTest.java | 18 ++++++ .../lypi/session/SessionManagerImplTest.java | 36 ++++++++++++ .../session/SessionManagerReplayTest.java | 57 +++++++++++++++++++ 7 files changed, 132 insertions(+), 26 deletions(-) diff --git a/lypi-session/src/main/java/cn/lypi/session/JsonlSessionStore.java b/lypi-session/src/main/java/cn/lypi/session/JsonlSessionStore.java index a4de5c55..b70699f6 100644 --- a/lypi-session/src/main/java/cn/lypi/session/JsonlSessionStore.java +++ b/lypi-session/src/main/java/cn/lypi/session/JsonlSessionStore.java @@ -104,25 +104,6 @@ boolean tryCreate(SessionHeader header) { } } - /** - * 原子替换 session 文件首行 header,保留其余 entry 行不变。 - */ - void rewriteHeader(SessionHeader header) { - Path file = sessionFile(header.id()); - try { - List lines = Files.readAllLines(file, StandardCharsets.UTF_8); - if (lines.isEmpty()) { - throw new SessionEngineException("Session file is empty: " + file); - } - lines.set(0, mapper.writeHeader(header)); - Path tmp = file.resolveSibling(file.getFileName() + ".tmp"); - Files.write(tmp, lines, StandardCharsets.UTF_8); - Files.move(tmp, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING, java.nio.file.StandardCopyOption.ATOMIC_MOVE); - } catch (IOException e) { - throw new SessionEngineException("Failed to rewrite session header: " + file, e); - } - } - /** * 读取 session 文件并解析 header 与 entries。 */ diff --git a/lypi-session/src/main/java/cn/lypi/session/SessionJsonMapper.java b/lypi-session/src/main/java/cn/lypi/session/SessionJsonMapper.java index 1632363b..422dc47f 100644 --- a/lypi-session/src/main/java/cn/lypi/session/SessionJsonMapper.java +++ b/lypi-session/src/main/java/cn/lypi/session/SessionJsonMapper.java @@ -14,6 +14,7 @@ import cn.lypi.contracts.session.SessionEntry; import cn.lypi.contracts.session.SessionHeader; import cn.lypi.contracts.session.SessionInfoEntry; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.session.ThinkingChangeEntry; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; @@ -40,6 +41,7 @@ final class SessionJsonMapper { Map.entry("permission_mode_change", PermissionModeChangeEntry.class), Map.entry("permission_runtime_state_change", PermissionRuntimeStateChangeEntry.class), Map.entry("permission_amendment", PermissionAmendmentEntry.class), + Map.entry("shell_state_change", ShellStateChangeEntry.class), Map.entry("compaction", CompactionEntry.class), Map.entry("branch_summary", BranchSummaryEntry.class), Map.entry("custom", CustomEntry.class), @@ -55,6 +57,7 @@ final class SessionJsonMapper { Map.entry(PermissionModeChangeEntry.class, "permission_mode_change"), Map.entry(PermissionRuntimeStateChangeEntry.class, "permission_runtime_state_change"), Map.entry(PermissionAmendmentEntry.class, "permission_amendment"), + Map.entry(ShellStateChangeEntry.class, "shell_state_change"), Map.entry(CompactionEntry.class, "compaction"), Map.entry(BranchSummaryEntry.class, "branch_summary"), Map.entry(CustomEntry.class, "custom"), diff --git a/lypi-session/src/main/java/cn/lypi/session/SessionLeafSelector.java b/lypi-session/src/main/java/cn/lypi/session/SessionLeafSelector.java index 8536a717..1a012c1e 100644 --- a/lypi-session/src/main/java/cn/lypi/session/SessionLeafSelector.java +++ b/lypi-session/src/main/java/cn/lypi/session/SessionLeafSelector.java @@ -13,6 +13,7 @@ import cn.lypi.contracts.session.PermissionRuntimeStateChangeEntry; import cn.lypi.contracts.session.SessionEntry; import cn.lypi.contracts.session.SessionInfoEntry; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.session.ThinkingChangeEntry; import java.util.List; @@ -42,6 +43,7 @@ static boolean advancesNavigableLeaf(SessionEntry entry) { && !(entry instanceof PermissionModeChangeEntry) && !(entry instanceof PermissionRuntimeStateChangeEntry) && !(entry instanceof PermissionAmendmentEntry) + && !(entry instanceof ShellStateChangeEntry) && !(entry instanceof SessionInfoEntry) && !(entry instanceof LabelEntry) && !(entry instanceof CustomEntry); diff --git a/lypi-session/src/main/java/cn/lypi/session/SessionManagerImpl.java b/lypi-session/src/main/java/cn/lypi/session/SessionManagerImpl.java index 36f34d26..73508b2f 100644 --- a/lypi-session/src/main/java/cn/lypi/session/SessionManagerImpl.java +++ b/lypi-session/src/main/java/cn/lypi/session/SessionManagerImpl.java @@ -18,6 +18,7 @@ import cn.lypi.contracts.session.SessionHeader; import cn.lypi.contracts.session.SessionView; import cn.lypi.contracts.session.ShellState; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.tui.SessionFileView; import java.nio.file.Path; import java.time.Clock; @@ -216,17 +217,25 @@ public synchronized SessionView currentView() { @Override public synchronized ShellState shellState() { ensureOpen(); - return header.shellState(); + refreshFromStoreIfPersistent(); + ShellState current = header.shellState(); + for (SessionEntry entry : index.branch(index.leafId())) { + if (entry instanceof ShellStateChangeEntry change) { + current = change.shellState(); + } + } + return current; } @Override - public synchronized SessionHandle updateShellState(ShellState shellState) { + public synchronized SessionHandle appendShellStateChange(ShellState shellState) { ensureOpen(); - header = header.withShellState(shellState); - if (persistent) { - store.rewriteHeader(header); - } - return new SessionHandle(sessionId, store.sessionFile(sessionId), index.leafId(), index.byId()); + return append(new ShellStateChangeEntry( + SessionEntryIds.newEntryId(), + index.leafId(), + Objects.requireNonNull(shellState, "shellState must not be null"), + Instant.now(clock) + )); } @Override diff --git a/lypi-session/src/test/java/cn/lypi/session/SessionEntryBoundaryTest.java b/lypi-session/src/test/java/cn/lypi/session/SessionEntryBoundaryTest.java index 988968c1..d43aa9d6 100644 --- a/lypi-session/src/test/java/cn/lypi/session/SessionEntryBoundaryTest.java +++ b/lypi-session/src/test/java/cn/lypi/session/SessionEntryBoundaryTest.java @@ -4,7 +4,11 @@ import cn.lypi.contracts.memory.MemoryWriteEntry; import cn.lypi.contracts.session.SessionEntry; +import cn.lypi.contracts.session.ShellState; +import cn.lypi.contracts.session.ShellStateChangeEntry; import com.fasterxml.jackson.annotation.JsonSubTypes; +import java.nio.file.Path; +import java.time.Instant; import java.util.Arrays; import java.util.Set; import java.util.stream.Collectors; @@ -27,6 +31,7 @@ void sessionEntrySubtypesOnlyContainConversationPathFacts() { "permission_mode_change", "permission_runtime_state_change", "permission_amendment", + "shell_state_change", "compaction", "branch_summary", "custom", @@ -36,6 +41,19 @@ void sessionEntrySubtypesOnlyContainConversationPathFacts() { ); } + @Test + void shellStateChangesStayInTheBranchButDoNotBecomeNavigableLeaves() { + ShellStateChangeEntry entry = new ShellStateChangeEntry( + "entry-shell", + "entry-tool", + ShellState.of(Path.of("/tmp/project/dir with spaces")), + Instant.parse("2026-06-01T00:00:00Z") + ); + + assertThat(entry).isInstanceOf(SessionEntry.class); + assertThat(SessionLeafSelector.advancesNavigableLeaf(entry)).isFalse(); + } + @Test void fileChangeAndMemoryWriteAreNotSessionEntries() { assertThat(classExists("cn.lypi.contracts.session.FileChangeEntry")).isFalse(); diff --git a/lypi-session/src/test/java/cn/lypi/session/SessionManagerImplTest.java b/lypi-session/src/test/java/cn/lypi/session/SessionManagerImplTest.java index a76c8370..434a3840 100644 --- a/lypi-session/src/test/java/cn/lypi/session/SessionManagerImplTest.java +++ b/lypi-session/src/test/java/cn/lypi/session/SessionManagerImplTest.java @@ -25,6 +25,8 @@ import cn.lypi.contracts.session.SessionHandle; import cn.lypi.contracts.session.SessionHeader; import cn.lypi.contracts.session.SessionInfoEntry; +import cn.lypi.contracts.session.ShellState; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.session.ThinkingChangeEntry; import java.nio.file.Files; import java.nio.file.Path; @@ -66,6 +68,40 @@ void openOrCreateCreatesHeaderAndRestoresAppendedEntries() throws Exception { .containsExactly("entry_1", "entry_2"); } + @Test + void appendShellStateChangeKeepsHeaderAppendOnlyAndReplaysCurrentBranch() throws Exception { + SessionManager manager = new SessionManagerImpl(tempDir); + SessionHandle opened = manager.openOrCreate("ses_shell"); + ShellState initialState = manager.shellState(); + manager.append(new CustomMessageEntry( + "entry-tool", + null, + "tool result", + Instant.parse("2026-06-01T00:00:00Z") + )); + List linesBefore = Files.readAllLines(opened.sessionFile()); + Path changedCwd = tempDir.resolve("dir with spaces"); + + SessionHandle changed = manager.appendShellStateChange(ShellState.of(changedCwd)); + + List linesAfter = Files.readAllLines(opened.sessionFile()); + assertThat(linesAfter.getFirst()).isEqualTo(linesBefore.getFirst()); + assertThat(linesAfter).hasSize(linesBefore.size() + 1); + assertThat(changed.byId().get(changed.leafId())) + .isInstanceOfSatisfying(ShellStateChangeEntry.class, entry -> { + assertThat(entry.parentId()).isEqualTo("entry-tool"); + assertThat(entry.shellState()).isEqualTo(ShellState.of(changedCwd)); + }); + assertThat(manager.shellState()).isEqualTo(ShellState.of(changedCwd)); + + SessionManager reopened = new SessionManagerImpl(tempDir); + reopened.openOrCreate("ses_shell"); + assertThat(reopened.shellState()).isEqualTo(ShellState.of(changedCwd)); + + manager.switchLeaf("entry-tool"); + assertThat(manager.shellState()).isEqualTo(initialState); + } + @Test void openTemporaryDoesNotCreateSessionFileUntilUserMessage() { SessionManager engine = new SessionManagerImpl(tempDir); diff --git a/lypi-session/src/test/java/cn/lypi/session/SessionManagerReplayTest.java b/lypi-session/src/test/java/cn/lypi/session/SessionManagerReplayTest.java index 82e46f54..dbe431da 100644 --- a/lypi-session/src/test/java/cn/lypi/session/SessionManagerReplayTest.java +++ b/lypi-session/src/test/java/cn/lypi/session/SessionManagerReplayTest.java @@ -30,6 +30,8 @@ import cn.lypi.contracts.session.SessionHandle; import cn.lypi.contracts.session.SessionHeader; import cn.lypi.contracts.session.SessionInfoEntry; +import cn.lypi.contracts.session.ShellState; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.session.ThinkingChangeEntry; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -171,6 +173,41 @@ void reopenedSessionUsesInitialStateFromHeader() { assertThat(context.permissionMode()).isEqualTo(PermissionMode.AUTO); } + @Test + void shellStateUsesLegacyHeaderUntilCurrentBranchContainsAChangeEntry() { + Path initialCwd = tempDir.resolve("legacy cwd"); + Path firstChangedCwd = tempDir.resolve("first changed cwd"); + Path latestChangedCwd = tempDir.resolve("latest changed cwd"); + JsonlSessionStore store = new JsonlSessionStore(tempDir); + store.create(sessionHeaderWithShellState("ses_shell_replay", initialCwd)); + + SessionManager withoutChanges = new SessionManagerImpl(tempDir); + withoutChanges.openOrCreate("ses_shell_replay"); + assertThat(withoutChanges.shellState()).isEqualTo(ShellState.of(initialCwd)); + + store.append( + "ses_shell_replay", + new ShellStateChangeEntry("entry-shell-first", null, ShellState.of(firstChangedCwd), NOW) + ); + store.append( + "ses_shell_replay", + new ShellStateChangeEntry( + "entry-shell-latest", + "entry-shell-first", + ShellState.of(latestChangedCwd), + NOW.plusSeconds(1) + ) + ); + SessionManager withChange = new SessionManagerImpl(tempDir); + withChange.openOrCreate("ses_shell_replay"); + + assertThat(withChange.shellState()).isEqualTo(ShellState.of(latestChangedCwd)); + withChange.switchLeaf("entry-shell-first"); + assertThat(withChange.shellState()).isEqualTo(ShellState.of(firstChangedCwd)); + withChange.switchLeaf(null); + assertThat(withChange.shellState()).isEqualTo(ShellState.of(initialCwd)); + } + @Test void reopenedChildSessionPreservesCanonicalPermissionRuntimeStateFromHeader() { PermissionRuntimeState runtimeState = PermissionRuntimeState.fromLegacy(PermissionMode.BYPASS); @@ -476,4 +513,24 @@ private static AgentMessage textMessage(String id, String text) { Optional.empty() ); } + + private SessionHeader sessionHeaderWithShellState(String sessionId, Path shellCwd) { + return new SessionHeader( + "session", + 1, + sessionId, + tempDir, + Optional.empty(), + Optional.empty(), + 0, + Optional.empty(), + Optional.empty(), + NOW, + Optional.empty(), + Optional.empty(), + Optional.empty(), + null, + ShellState.of(shellCwd) + ); + } } From 49cff72dfbe242aabec2090d1f1bd1932719cb59 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sun, 9 Aug 2026 15:17:00 +0800 Subject: [PATCH 05/13] fix(security): keep workspace boundary stable after cd --- .../security/FileSystemPolicyChecker.java | 6 +-- .../cn/lypi/security/PathSafetyChecker.java | 15 ++++--- .../security/FileSystemPolicyCheckerTest.java | 42 +++++++++++++++++++ .../lypi/security/PathSafetyCheckerTest.java | 23 ++++++++++ .../java/cn/lypi/tool/DefaultToolRuntime.java | 2 + .../lypi/tool/ToolRuntimeContextFactory.java | 27 +++++++++--- .../tool/builtin/BashPermissionPolicy.java | 2 +- .../java/cn/lypi/tool/builtin/BashTool.java | 8 ++-- .../cn/lypi/tool/builtin/WorkspacePaths.java | 34 ++++++++++----- .../cn/lypi/tool/DefaultToolRuntimeTest.java | 35 ++++++++++++++++ .../tool/ToolRuntimeContextFactoryTest.java | 42 +++++++++++++++++++ .../cn/lypi/tool/builtin/BashToolTest.java | 15 ++++--- .../lypi/tool/builtin/WorkspacePathsTest.java | 21 ++++++++++ 13 files changed, 238 insertions(+), 34 deletions(-) diff --git a/lypi-security/src/main/java/cn/lypi/security/FileSystemPolicyChecker.java b/lypi-security/src/main/java/cn/lypi/security/FileSystemPolicyChecker.java index ce88d6a0..e2278f2d 100644 --- a/lypi-security/src/main/java/cn/lypi/security/FileSystemPolicyChecker.java +++ b/lypi-security/src/main/java/cn/lypi/security/FileSystemPolicyChecker.java @@ -115,8 +115,8 @@ private boolean matchesAnyCandidate( private boolean matchesPath(FileSystemPath path, Path candidate, ToolUseContext context) { return switch (path.kind()) { case SPECIAL -> matchesSpecialPath(path, candidate, context); - case EXACT_PATH -> matchesExactPath(path.value(), candidate, context.cwd()); - case GLOB_PATTERN -> matchesGlob(path.value(), candidate, context.cwd()); + case EXACT_PATH -> matchesExactPath(path.value(), candidate, context.workspaceRoot()); + case GLOB_PATTERN -> matchesGlob(path.value(), candidate, context.workspaceRoot()); }; } @@ -124,7 +124,7 @@ private boolean matchesSpecialPath(FileSystemPath path, Path candidate, ToolUseC FileSystemSpecialPath specialPath = FileSystemSpecialPath.fromJson(path.value()); return switch (specialPath) { case ROOT -> true; - case PROJECT_ROOTS -> matchesWorkspaceRoot(candidate, context.cwd()); + case PROJECT_ROOTS -> matchesWorkspaceRoot(candidate, context.workspaceRoot()); case TMPDIR -> isSameOrDescendant(candidate, Path.of(System.getProperty("java.io.tmpdir")).toAbsolutePath().normalize()); case SLASH_TMP -> isSameOrDescendant(candidate, Path.of("/tmp").toAbsolutePath().normalize()); case MINIMAL -> false; diff --git a/lypi-security/src/main/java/cn/lypi/security/PathSafetyChecker.java b/lypi-security/src/main/java/cn/lypi/security/PathSafetyChecker.java index 2d60e05f..2c4f94e1 100644 --- a/lypi-security/src/main/java/cn/lypi/security/PathSafetyChecker.java +++ b/lypi-security/src/main/java/cn/lypi/security/PathSafetyChecker.java @@ -62,12 +62,14 @@ Optional check(ToolUseRequest request, ToolUseContext contex } Optional checkPath(String fieldName, String rawPath, ToolUseContext context) { + Path workspace = context.workspaceRoot().toAbsolutePath().normalize(); Path cwd = context.cwd().toAbsolutePath().normalize(); Path target = cwd.resolve(rawPath).normalize(); - Optional realCwd = realPathForCwd(cwd); - Optional realPath = realCwd.map(path -> realPathForSafetyCheck(rawPath, path)); - if (realPath.isPresent() && realPath.get().startsWith(realCwd.get())) { - String realRelativePath = realCwd.get().relativize(realPath.get()).toString().replace('\\', '/'); + Optional realWorkspace = realPathForCwd(workspace); + Optional realBase = realPathForCwd(cwd); + Optional realPath = realBase.map(path -> realPathForSafetyCheck(rawPath, path)); + if (realWorkspace.isPresent() && realPath.isPresent() && realPath.get().startsWith(realWorkspace.get())) { + String realRelativePath = realWorkspace.get().relativize(realPath.get()).toString().replace('\\', '/'); if (isProtectedPath(realRelativePath)) { return Optional.of(decision( "工具路径经符号链接命中受保护路径: " + rawPath, @@ -77,7 +79,8 @@ Optional checkPath(String fieldName, String rawPath, ToolUse )); } } - if (target.startsWith(cwd) && isProtectedPath(cwd.relativize(target).toString().replace('\\', '/'))) { + if (target.startsWith(workspace) + && isProtectedPath(workspace.relativize(target).toString().replace('\\', '/'))) { return Optional.of(decision( "工具路径命中受保护路径: " + rawPath, fieldName, @@ -94,7 +97,7 @@ Optional checkPathInsideWorkspace( ToolUseContext context, Path baseCwd ) { - Path workspace = context.cwd().toAbsolutePath().normalize(); + Path workspace = context.workspaceRoot().toAbsolutePath().normalize(); Path base = baseCwd.toAbsolutePath().normalize(); Path target = base.resolve(rawPath).normalize(); Optional realWorkspace = realPathForCwd(workspace); diff --git a/lypi-security/src/test/java/cn/lypi/security/FileSystemPolicyCheckerTest.java b/lypi-security/src/test/java/cn/lypi/security/FileSystemPolicyCheckerTest.java index f5913fdc..482acf5b 100644 --- a/lypi-security/src/test/java/cn/lypi/security/FileSystemPolicyCheckerTest.java +++ b/lypi-security/src/test/java/cn/lypi/security/FileSystemPolicyCheckerTest.java @@ -53,6 +53,48 @@ void workspaceProfileAllowsWritesInsideWorkspace(@TempDir Path workspace) { assertThat(decision.reason()).isEqualTo(PermissionDecisionReason.SANDBOX_POLICY); } + @Test + void workspaceAndRelativeProfileEntriesStayRootedAtStableWorkspace(@TempDir Path workspace) throws IOException { + Path nested = Files.createDirectories(workspace.resolve("nested")); + ToolUseContext context = new ToolUseContext( + "ses_1", + "msg_1", + workspace, + nested, + Map.of("permissionMode", PermissionMode.BYPASS) + ); + ManagedPermissionProfile relativeProfile = new ManagedPermissionProfile( + FileSystemPermissionPolicy.restricted(List.of( + new FileSystemPermissionEntry(FileSystemPath.exactPath("shared"), FileSystemAccessMode.WRITE), + new FileSystemPermissionEntry(FileSystemPath.globPattern("logs/*.log"), FileSystemAccessMode.READ) + )), + NetworkPermissionPolicy.restricted() + ); + + PermissionDecision projectRoot = checker.decide( + PermissionProfiles.workspace(), + FileSystemAccessMode.WRITE, + workspace.resolve("root.txt"), + context + ); + PermissionDecision exact = checker.decide( + relativeProfile, + FileSystemAccessMode.WRITE, + workspace.resolve("shared/output.txt"), + context + ); + PermissionDecision glob = checker.decide( + relativeProfile, + FileSystemAccessMode.READ, + workspace.resolve("logs/app.log"), + context + ); + + assertThat(projectRoot.behavior()).isEqualTo(PermissionBehavior.ALLOW); + assertThat(exact.behavior()).isEqualTo(PermissionBehavior.ALLOW); + assertThat(glob.behavior()).isEqualTo(PermissionBehavior.ALLOW); + } + @Test void writeEntryAllowsReadAccess(@TempDir Path workspace) { PermissionDecision decision = checker.decide( diff --git a/lypi-security/src/test/java/cn/lypi/security/PathSafetyCheckerTest.java b/lypi-security/src/test/java/cn/lypi/security/PathSafetyCheckerTest.java index accd5c66..dce7e55a 100644 --- a/lypi-security/src/test/java/cn/lypi/security/PathSafetyCheckerTest.java +++ b/lypi-security/src/test/java/cn/lypi/security/PathSafetyCheckerTest.java @@ -76,6 +76,29 @@ void deniesAgentAndCodexMetadataPathsEvenInBypassMode() { assertThat(codexDecision.get().behavior()).isEqualTo(PermissionBehavior.DENY); } + @Test + void deniesProtectedWorkspacePathResolvedFromNestedCwd(@TempDir Path tempDir) throws IOException { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path nested = Files.createDirectories(workspace.resolve("nested")); + Files.createDirectories(workspace.resolve(".git")); + PathSafetyChecker checker = new PathSafetyChecker(); + + Optional decision = checker.check( + request("read_file", Map.of("path", "../.git/config")), + new ToolUseContext( + "ses_1", + "msg_1", + workspace, + nested, + Map.of("permissionMode", PermissionMode.BYPASS) + ) + ); + + assertThat(decision).isPresent(); + assertThat(decision.orElseThrow().behavior()).isEqualTo(PermissionBehavior.DENY); + assertThat(decision.orElseThrow().reason()).isEqualTo(PermissionDecisionReason.PATH_SAFETY); + } + @Test void allowsExistingSymlinkThatEscapesCurrentWorkingDirectoryForProfileBoundary(@TempDir Path tempDir) throws IOException { Path workspace = tempDir.resolve("workspace"); diff --git a/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java b/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java index dcd6228b..9341178d 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java @@ -836,6 +836,7 @@ private ToolUseContext withAuthorizationMetadata( return new ToolUseContext( context.sessionId(), context.messageId(), + context.workspaceRoot(), context.cwd(), Map.copyOf(metadata) ); @@ -881,6 +882,7 @@ private ToolUseContext contextWithCallMetadata( return new ToolUseContext( context.sessionId(), context.messageId(), + context.workspaceRoot(), context.cwd(), Map.copyOf(metadata) ); diff --git a/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java b/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java index 18fbec2a..6df1ed28 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java @@ -7,6 +7,8 @@ import cn.lypi.contracts.security.PermissionRuntimeState; import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.contracts.tool.ToolUseRequest; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.Map; @@ -44,6 +46,7 @@ public ToolUseContext create(ToolUseRequest request, ContextSnapshot context) { */ public ToolUseContext create(ToolUseRequest request, ContextSnapshot context, ToolRuntimeInvocation invocation) { Objects.requireNonNull(request, "request must not be null"); + Path workspaceRoot = options.cwd().toAbsolutePath().normalize(); Map metadata = new LinkedHashMap<>(); AgentMode agentMode = context == null ? AgentMode.EXECUTE : context.mode(); PermissionRuntimeState permissionRuntimeState = context == null @@ -69,16 +72,30 @@ public ToolUseContext create(ToolUseRequest request, ContextSnapshot context, To return new ToolUseContext( sessionId(invocation), request.parentMessageId(), - invocationCwd(invocation), + workspaceRoot, + validatedInvocationCwd(invocation, workspaceRoot), Map.copyOf(metadata) ); } - private Path invocationCwd(ToolRuntimeInvocation invocation) { - if (invocation != null && invocation.cwd() != null) { - return invocation.cwd(); + private Path validatedInvocationCwd(ToolRuntimeInvocation invocation, Path workspaceRoot) { + if (invocation == null || invocation.cwd() == null) { + return workspaceRoot; } - return options.cwd(); + Path candidate = invocation.cwd().toAbsolutePath().normalize(); + if (!candidate.startsWith(workspaceRoot)) { + return workspaceRoot; + } + try { + Path realWorkspace = workspaceRoot.toRealPath(); + Path realCandidate = candidate.toRealPath(); + if (Files.isDirectory(realCandidate) && realCandidate.startsWith(realWorkspace)) { + return candidate; + } + } catch (IOException exception) { + // Invalid persisted state falls back to the configured workspace root. + } + return workspaceRoot; } private String sessionId(ToolRuntimeInvocation invocation) { diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashPermissionPolicy.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashPermissionPolicy.java index effd25d2..6c5d9e54 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashPermissionPolicy.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashPermissionPolicy.java @@ -33,7 +33,7 @@ PermissionDecision decide( PermissionRuntimeState permissionRuntimeState ) { SandboxRuntimePolicy sandboxPolicy = sandboxPolicyResolver.resolve( - context.cwd(), + context.workspaceRoot(), cwd, permissionRuntimeState ); diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java index 4d982726..79246ed1 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java @@ -157,7 +157,7 @@ public ToolResult execute(Map input, ToolUseContext cont Optional additionalPermissions = additionalPermissionsForRequest(context, sandboxPermissions); SandboxRuntimePolicy sandboxPolicy = usesHostExecution(permissionRuntimeState, sandboxPermissions, context) ? SandboxRuntimePolicy.disabled() - : sandboxPolicy(context.cwd(), cwd, permissionRuntimeState, additionalPermissions); + : sandboxPolicy(context.workspaceRoot(), cwd, permissionRuntimeState, additionalPermissions); ExecutionRequest request = new ExecutionRequest( shellCommand(input, context), cwd, @@ -248,10 +248,10 @@ private SandboxPermissions sandboxPermissions(Map input) { } private Path resolveBashCwd(Map input, ToolUseContext context) throws IOException { - Path workspace = context.cwd().toAbsolutePath().normalize(); + Path dynamicCwd = context.cwd().toAbsolutePath().normalize(); String rawCwd = stringInput(input, "cwd"); - Path cwd = rawCwd.isBlank() ? workspace : Path.of(rawCwd); - Path resolved = cwd.isAbsolute() ? cwd.toAbsolutePath().normalize() : workspace.resolve(cwd).normalize(); + Path cwd = rawCwd.isBlank() ? dynamicCwd : Path.of(rawCwd); + Path resolved = cwd.isAbsolute() ? cwd.toAbsolutePath().normalize() : dynamicCwd.resolve(cwd).normalize(); return resolved.toRealPath(); } diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/WorkspacePaths.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/WorkspacePaths.java index 08bb6349..91524cc8 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/WorkspacePaths.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/WorkspacePaths.java @@ -39,11 +39,12 @@ static Path resolvePath( Object raw = input.get(fieldName); String value = raw == null ? "." : raw.toString(); Path cwd = context.cwd().toAbsolutePath().normalize(); + Path workspaceRoot = context.workspaceRoot().toAbsolutePath().normalize(); Path resolved = cwd.resolve(value).normalize(); - if (resolved.startsWith(cwd) || additionalFileSystemAllows(context, accessMode, resolved)) { + if (resolved.startsWith(workspaceRoot) || additionalFileSystemAllows(context, accessMode, resolved)) { return resolved; } - throw new IllegalArgumentException("路径越过当前工作目录: " + value); + throw new IllegalArgumentException("路径越过当前工作目录或工作区: " + value); } static String relativePath(Path path, ToolUseContext context) { @@ -65,12 +66,13 @@ static Path requireRealPathInsideWorkspace( ToolUseContext context, FileSystemAccessMode accessMode ) throws IOException { - Path realCwd = context.cwd().toRealPath(); + Path realWorkspace = context.workspaceRoot().toRealPath(); Path realPath = path.toRealPath(); - if (realPath.startsWith(realCwd) || additionalFileSystemAllows(context, accessMode, path.toAbsolutePath().normalize())) { + if (realPath.startsWith(realWorkspace) + || additionalFileSystemAllows(context, accessMode, path.toAbsolutePath().normalize())) { return realPath; } - throw new IllegalArgumentException("路径经符号链接越过当前工作目录: " + relativePath(path, context)); + throw new IllegalArgumentException("路径经符号链接越过当前工作目录或工作区: " + relativePath(path, context)); } static boolean realPathInsideWorkspace(Path path, ToolUseContext context) { @@ -79,9 +81,9 @@ static boolean realPathInsideWorkspace(Path path, ToolUseContext context) { static boolean realPathInsideWorkspace(Path path, ToolUseContext context, FileSystemAccessMode accessMode) { try { - Path realCwd = context.cwd().toRealPath(); + Path realWorkspace = context.workspaceRoot().toRealPath(); Path realPath = path.toRealPath(); - return realPath.startsWith(realCwd) + return realPath.startsWith(realWorkspace) || additionalFileSystemAllows(context, accessMode, path.toAbsolutePath().normalize()); } catch (IOException exception) { return false; @@ -200,8 +202,8 @@ private static boolean matchesAnyCandidate( private static boolean matchesPath(FileSystemPath path, Path candidate, ToolUseContext context) { return switch (path.kind()) { case SPECIAL -> matchesSpecialPath(path, candidate, context); - case EXACT_PATH -> matchesExactPath(path.value(), candidate, context.cwd()); - case GLOB_PATTERN -> matchesGlob(path.value(), candidate, context.cwd()); + case EXACT_PATH -> matchesExactPath(path.value(), candidate, context.workspaceRoot()); + case GLOB_PATTERN -> matchesGlob(path.value(), candidate, context.workspaceRoot()); }; } @@ -209,13 +211,25 @@ private static boolean matchesSpecialPath(FileSystemPath path, Path candidate, T FileSystemSpecialPath specialPath = FileSystemSpecialPath.fromJson(path.value()); return switch (specialPath) { case ROOT -> true; - case PROJECT_ROOTS -> isSameOrDescendant(candidate, context.cwd().toAbsolutePath().normalize()); + case PROJECT_ROOTS -> matchesWorkspaceRoot(candidate, context.workspaceRoot()); case TMPDIR -> isSameOrDescendant(candidate, Path.of(System.getProperty("java.io.tmpdir")).toAbsolutePath().normalize()); case SLASH_TMP -> isSameOrDescendant(candidate, Path.of("/tmp").toAbsolutePath().normalize()); case MINIMAL -> false; }; } + private static boolean matchesWorkspaceRoot(Path candidate, Path workspaceRoot) { + Path normalizedRoot = workspaceRoot.toAbsolutePath().normalize(); + if (isSameOrDescendant(candidate, normalizedRoot)) { + return true; + } + try { + return isSameOrDescendant(candidate, normalizedRoot.toRealPath()); + } catch (IOException exception) { + return false; + } + } + private static boolean matchesExactPath(String configuredPath, Path candidate, Path cwd) { Path exactPath = resolveAgainstCwd(Path.of(configuredPath), cwd); if (isSameOrDescendant(candidate, exactPath)) { diff --git a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java index 8874753d..3c501a8b 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java @@ -61,6 +61,7 @@ import cn.lypi.contracts.tool.Tool; import cn.lypi.contracts.tool.ToolExecutionStatus; import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.contracts.tool.ToolUseRequest; import cn.lypi.tool.builtin.BashTool; import cn.lypi.tool.builtin.ReadTool; @@ -315,6 +316,40 @@ void invocationOverridesStaticOptionsForLifecycleOwnership() { assertEquals("session-dynamic", end.sessionId()); } + @Test + void preservesWorkspaceRootWhileAddingCallAndAuthorizationMetadata() throws Exception { + Path nested = Files.createDirectories(tempDir.resolve("nested")); + AtomicReference captured = new AtomicReference<>(); + ToolExecutionInterceptor interceptor = ToolExecutionInterceptor.before((request, tool, context) -> { + captured.set(context); + return ToolExecutionInterceptor.BeforeResult.allow(); + }); + SecurityRuntimePort security = (request, context) -> + TestTools.decision(PermissionBehavior.ASK, "review"); + DefaultToolRuntime runtime = new DefaultToolRuntime( + new DefaultToolRegistry(), + new ToolSchemaValidator(), + new ToolExecutionPlanner(), + new ToolResultBudgeter(), + new ToolRuntimeContextFactory(ToolRuntimeOptions.builder().cwd(tempDir).build()), + interceptor, + security, + (request, tool, context, decision) -> PermissionGateResult.allow() + ); + runtime.register(TestTools.permission("write", PermissionBehavior.ALLOW)); + + ToolResult result = runtime.execute( + List.of(new ToolUseRequest("toolu_1", "write", Map.of("text", "ok"), "msg_1")), + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(nested) + ).getFirst(); + + assertFalse(result.isError()); + assertEquals(tempDir, captured.get().workspaceRoot()); + assertEquals(nested, captured.get().cwd()); + assertEquals(true, captured.get().metadata().get("permissionApprovedForHostExecution")); + } + @Test void publishesLifecycleWhenInputContainsNullValue() { RecordingEventBus events = new RecordingEventBus(); diff --git a/lypi-tool/src/test/java/cn/lypi/tool/ToolRuntimeContextFactoryTest.java b/lypi-tool/src/test/java/cn/lypi/tool/ToolRuntimeContextFactoryTest.java index 3fab638a..16c6a254 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/ToolRuntimeContextFactoryTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/ToolRuntimeContextFactoryTest.java @@ -22,11 +22,13 @@ import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.contracts.tool.ToolUseRequest; import java.math.BigDecimal; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; class ToolRuntimeContextFactoryTest { @Test @@ -143,6 +145,46 @@ void invocationOverridesStaticTurnActivitySignals() { assertSame(invocationSteering, ToolSteeringSupport.source(context)); } + @Test + void separatesStableWorkspaceRootFromValidatedInvocationCwd(@TempDir Path tempDir) throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + Path nested = Files.createDirectory(workspace.resolve("nested")); + Path outside = Files.createDirectory(tempDir.resolve("outside")); + Path escape = workspace.resolve("escape"); + Files.createSymbolicLink(escape, outside); + ToolRuntimeContextFactory factory = new ToolRuntimeContextFactory( + ToolRuntimeOptions.builder().cwd(workspace).build() + ); + ToolUseRequest request = new ToolUseRequest("toolu_1", "read", Map.of(), "msg_1"); + + ToolUseContext valid = factory.create( + request, + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(nested) + ); + ToolUseContext lexicalEscape = factory.create( + request, + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(outside) + ); + ToolUseContext missing = factory.create( + request, + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(workspace.resolve("missing")) + ); + ToolUseContext symlinkEscape = factory.create( + request, + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(escape) + ); + + assertEquals(workspace, valid.workspaceRoot()); + assertEquals(nested, valid.cwd()); + assertEquals(workspace, lexicalEscape.cwd()); + assertEquals(workspace, missing.cwd()); + assertEquals(workspace, symlinkEscape.cwd()); + } + private ContextSnapshot context(AgentMode agentMode, PermissionRuntimeState runtimeState) { return new ContextSnapshot( new SystemPrompt("system", List.of(), "hash"), diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java index ffd4841f..790274fe 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java @@ -90,7 +90,8 @@ void inputSchemaExposesShellSelectionFields() { } @Test - void mapsCommandToExecutionRequestAndResult() { + void mapsCommandToExecutionRequestAndResult() throws Exception { + Path nested = Files.createDirectory(tempDir.resolve("nested")); RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(7, "out", "err", false, Optional.empty())); RecordingSandboxPolicyResolver resolver = new RecordingSandboxPolicyResolver(defaultPolicy()); BashTool tool = new BashTool(executor, resolver, testHarness()); @@ -98,7 +99,7 @@ void mapsCommandToExecutionRequestAndResult() { ToolResult result = tool.execute( Map.of("command", "echo hi", "timeoutSeconds", 3), - context(Map.of()), + context(tempDir, nested, Map.of()), progresses::add ); @@ -107,13 +108,13 @@ void mapsCommandToExecutionRequestAndResult() { assertTrue(executor.request.get().command().get(2).contains("eval 'echo hi'")); // snapshot 可能已由其他测试预生成(-c)或尚未生成(-lc) assertTrue(List.of("-c", "-lc").contains(executor.request.get().command().get(1))); - assertEquals(tempDir, executor.request.get().cwd()); + assertEquals(nested, executor.request.get().cwd()); assertEquals(Duration.ofSeconds(3), executor.request.get().timeout()); assertSame(resolver.policy, executor.request.get().sandboxPolicy()); assertEquals(SandboxPermissions.USE_DEFAULT, executor.request.get().sandboxPermissions()); assertEquals(Optional.empty(), executor.request.get().justification()); assertEquals(tempDir, resolver.workspace.get()); - assertEquals(tempDir, resolver.cwd.get()); + assertEquals(nested, resolver.cwd.get()); assertEquals(NetworkMode.DISABLED, executor.request.get().sandboxPolicy().networkMode()); assertFalse(executor.request.get().sandboxPolicy().failIfUnavailable()); assertFalse(executor.request.get().sandboxPolicy().autoAllowBashIfSandboxed()); @@ -763,10 +764,14 @@ void stillAsksWhenSandboxIsDisabledOrExternal() { } private ToolUseContext context(Map extraMetadata) { + return context(tempDir, tempDir, extraMetadata); + } + + private ToolUseContext context(Path workspaceRoot, Path cwd, Map extraMetadata) { java.util.LinkedHashMap metadata = new java.util.LinkedHashMap<>(); metadata.put("toolUseId", "toolu_1"); metadata.putAll(extraMetadata); - return new ToolUseContext("ses_1", "msg_1", tempDir, Map.copyOf(metadata)); + return new ToolUseContext("ses_1", "msg_1", workspaceRoot, cwd, Map.copyOf(metadata)); } private ShellEnvironmentHarness testHarness() { diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/WorkspacePathsTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/WorkspacePathsTest.java index 2c9ed530..926b13a3 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/WorkspacePathsTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/WorkspacePathsTest.java @@ -37,6 +37,27 @@ void resolvesMissingPathRelativeToWorkspaceAndRejectsTraversal() { assertTrue(exception.getMessage().contains("路径越过当前工作目录")); } + @Test + void resolvesFromDynamicCwdWithoutShrinkingWorkspaceBoundary() throws Exception { + Path nested = Files.createDirectories(tempDir.resolve("nested")); + ToolUseContext context = new ToolUseContext("ses_1", "msg_1", tempDir, nested, Map.of()); + + assertEquals( + nested.resolve("file.txt"), + WorkspacePaths.resolvePath(Map.of("path", "file.txt"), context, "path") + ); + assertEquals( + tempDir.resolve("root.txt"), + WorkspacePaths.resolvePath(Map.of("path", "../root.txt"), context, "path") + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> WorkspacePaths.resolvePath(Map.of("path", "../../outside.txt"), context, "path") + ); + assertTrue(exception.getMessage().contains("工作区")); + } + @Test void resolvesApprovedOutsidePathFromAdditionalPermissions(@TempDir Path outsideDir) { ToolUseContext context = context(additionalFileSystem(outsideDir, FileSystemAccessMode.WRITE)); From accde0d8a02c47bf77a56f8cee4b3b6c3d72b655 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sun, 9 Aug 2026 15:27:08 +0800 Subject: [PATCH 06/13] fix(tool): propagate cwd deltas between calls --- .../runtime/ToolRuntimeInvocation.java | 21 ++++ .../java/cn/lypi/tool/DefaultToolRuntime.java | 99 ++++++++++++++-- .../cn/lypi/tool/FilteredToolRuntime.java | 53 ++++++++- .../java/cn/lypi/tool/ToolResultBudgeter.java | 3 +- .../lypi/tool/ToolRuntimeContextFactory.java | 2 +- .../cn/lypi/tool/DefaultToolRuntimeTest.java | 112 ++++++++++++++++++ .../cn/lypi/tool/FilteredToolRuntimeTest.java | 52 ++++++++ .../src/test/java/cn/lypi/tool/TestTools.java | 24 ++++ .../cn/lypi/tool/ToolResultBudgeterTest.java | 8 +- 9 files changed, 363 insertions(+), 11 deletions(-) diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java index 83b59793..f29a5ab3 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/runtime/ToolRuntimeInvocation.java @@ -16,6 +16,9 @@ public record ToolRuntimeInvocation( SteeringMessageSource steeringMessages, java.nio.file.Path cwd ) { + private static final AbortSignal INHERIT_ABORT_SIGNAL = () -> false; + private static final SteeringMessageSource INHERIT_STEERING_MESSAGES = java.util.Optional::empty; + public ToolRuntimeInvocation(String sessionId, String turnId) { this(sessionId, turnId, null); } @@ -39,6 +42,24 @@ public ToolRuntimeInvocation( steeringMessages = steeringMessages == null ? SteeringMessageSource.none() : steeringMessages; } + /** + * Creates an invocation that overrides only cwd and inherits runtime-configured activity signals. + */ + public static ToolRuntimeInvocation cwdOnly(java.nio.file.Path cwd) { + return new ToolRuntimeInvocation( + null, + null, + null, + INHERIT_ABORT_SIGNAL, + INHERIT_STEERING_MESSAGES, + cwd + ); + } + + public boolean inheritsRuntimeSignals() { + return abortSignal == INHERIT_ABORT_SIGNAL && steeringMessages == INHERIT_STEERING_MESSAGES; + } + /** * Dynamic working directory for this tool invocation. The runtime workspace root remains stable. */ diff --git a/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java b/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java index 9341178d..55f3a7f0 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java @@ -24,6 +24,8 @@ import cn.lypi.contracts.tool.ToolResult; import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.contracts.tool.ToolUseRequest; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -551,17 +553,31 @@ public List> execute( } TurnPermissionState turnState = turnState(invocation); + ToolRuntimeInvocation currentInvocation = invocationWithInitialCwd(invocation); List resolvedSegment = new ArrayList<>(); for (ToolCallResolver.ResolvedCall call : callResolver.resolve(requests)) { if (!call.known()) { - executeResolvedSegment(resolvedSegment, context, invocation, results, turnState); + currentInvocation = executeResolvedSegment( + resolvedSegment, + context, + currentInvocation, + results, + turnState + ); resolvedSegment.clear(); - results.set(call.index(), executeUnknownCall(call.request(), context, invocation, turnState)); + ToolResult unknownResult = executeUnknownCall( + call.request(), + context, + currentInvocation, + turnState + ); + results.set(call.index(), unknownResult); + currentInvocation = invocationAfterResults(currentInvocation, List.of(unknownResult)); continue; } resolvedSegment.add(call); } - executeResolvedSegment(resolvedSegment, context, invocation, results, turnState); + executeResolvedSegment(resolvedSegment, context, currentInvocation, results, turnState); return List.copyOf(results); } @@ -573,7 +589,7 @@ public void clearTurnState(ToolRuntimeInvocation invocation) { } } - private void executeResolvedSegment( + private ToolRuntimeInvocation executeResolvedSegment( List resolvedCalls, ContextSnapshot context, ToolRuntimeInvocation invocation, @@ -581,21 +597,31 @@ private void executeResolvedSegment( TurnPermissionState turnState ) { if (resolvedCalls.isEmpty()) { - return; + return invocation; } List calls = resolvedCalls.stream() .map(call -> new ToolExecutionPlanner.ResolvedToolCall(call.request(), call.tool())) .toList(); List batches = executionPlanner.plan(calls); int cursor = 0; + ToolRuntimeInvocation currentInvocation = invocation; for (ToolExecutionPlanner.Batch batch : batches) { List indexedBatch = resolvedCalls.subList(cursor, cursor + batch.calls().size()); - executeBatch(batch, indexedBatch, context, invocation, results, turnState); + List> batchResults = executeBatch( + batch, + indexedBatch, + context, + currentInvocation, + results, + turnState + ); + currentInvocation = invocationAfterResults(currentInvocation, batchResults); cursor += batch.calls().size(); } + return currentInvocation; } - private void executeBatch( + private List> executeBatch( ToolExecutionPlanner.Batch batch, List indexedBatch, ContextSnapshot context, @@ -617,6 +643,65 @@ private void executeBatch( for (int index = 0; index < batchResults.size(); index++) { results.set(indexedBatch.get(index).index(), batchResults.get(index)); } + return batchResults; + } + + private ToolRuntimeInvocation invocationWithInitialCwd(ToolRuntimeInvocation invocation) { + if (invocation == null) { + return null; + } + Path initialCwd = validStateCwd(invocation.cwd()) + .orElse(contextFactory.cwd().toAbsolutePath().normalize()); + return withCwd(invocation, initialCwd); + } + + private ToolRuntimeInvocation invocationAfterResults( + ToolRuntimeInvocation invocation, + List> results + ) { + Path nextCwd = invocation == null || invocation.cwd() == null + ? contextFactory.cwd().toAbsolutePath().normalize() + : invocation.cwd(); + boolean changed = false; + for (ToolResult result : results) { + if (result == null || result.stateDelta().isEmpty()) { + continue; + } + Optional candidate = validStateCwd(result.stateDelta().orElseThrow().cwd()); + if (candidate.isPresent() && !candidate.orElseThrow().equals(nextCwd)) { + nextCwd = candidate.orElseThrow(); + changed = true; + } + } + return changed ? withCwd(invocation, nextCwd) : invocation; + } + + private Optional validStateCwd(Path candidate) { + if (candidate == null) { + return Optional.empty(); + } + Path workspaceRoot = contextFactory.cwd().toAbsolutePath().normalize(); + Path normalized = candidate.toAbsolutePath().normalize(); + if (!normalized.startsWith(workspaceRoot)) { + return Optional.empty(); + } + try { + Path realWorkspaceRoot = workspaceRoot.toRealPath(); + Path realCandidate = normalized.toRealPath(); + if (Files.isDirectory(realCandidate) && realCandidate.startsWith(realWorkspaceRoot)) { + return Optional.of(normalized); + } + } catch (IOException exception) { + return Optional.empty(); + } + return Optional.empty(); + } + + private ToolRuntimeInvocation withCwd(ToolRuntimeInvocation invocation, Path cwd) { + ToolRuntimeInvocation base = invocation == null + ? ToolRuntimeInvocation.cwdOnly(cwd) + : invocation; + return invocation == null ? base : base.withCwd(cwd); } private ToolResult executeCall( diff --git a/lypi-tool/src/main/java/cn/lypi/tool/FilteredToolRuntime.java b/lypi-tool/src/main/java/cn/lypi/tool/FilteredToolRuntime.java index 16765833..76ff723c 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/FilteredToolRuntime.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/FilteredToolRuntime.java @@ -13,6 +13,8 @@ import cn.lypi.contracts.tool.ToolRegistrySnapshot; import cn.lypi.contracts.tool.ToolResult; import cn.lypi.contracts.tool.ToolUseRequest; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; @@ -85,6 +87,7 @@ public List> execute( return List.of(); } List> results = new ArrayList<>(requests.size()); + ToolRuntimeInvocation currentInvocation = invocationWithInitialCwd(invocation); for (ToolUseRequest request : requests) { Optional> resolved = delegate.resolve(request.toolName()); if (resolved.isEmpty() @@ -97,7 +100,9 @@ public List> execute( )); continue; } - results.add(delegate.execute(List.of(request), context, invocation).getFirst()); + ToolResult result = delegate.execute(List.of(request), context, currentInvocation).getFirst(); + results.add(result); + currentInvocation = invocationAfterResult(currentInvocation, result); } return List.copyOf(results); } @@ -111,6 +116,52 @@ private boolean isAllowed(String canonicalName) { return canonicalName != null && effectiveTools.contains(canonicalName); } + private ToolRuntimeInvocation invocationWithInitialCwd(ToolRuntimeInvocation invocation) { + if (invocation == null) { + return null; + } + Path initialCwd = validStateCwd(invocation.cwd()) + .orElse(delegate.cwd().toAbsolutePath().normalize()); + return withCwd(invocation, initialCwd); + } + + private ToolRuntimeInvocation invocationAfterResult(ToolRuntimeInvocation invocation, ToolResult result) { + if (result == null || result.stateDelta().isEmpty()) { + return invocation; + } + return validStateCwd(result.stateDelta().orElseThrow().cwd()) + .map(cwd -> withCwd(invocation, cwd)) + .orElse(invocation); + } + + private Optional validStateCwd(Path candidate) { + if (candidate == null) { + return Optional.empty(); + } + Path workspaceRoot = delegate.cwd().toAbsolutePath().normalize(); + Path normalized = candidate.toAbsolutePath().normalize(); + if (!normalized.startsWith(workspaceRoot)) { + return Optional.empty(); + } + try { + Path realWorkspaceRoot = workspaceRoot.toRealPath(); + Path realCandidate = normalized.toRealPath(); + if (Files.isDirectory(realCandidate) && realCandidate.startsWith(realWorkspaceRoot)) { + return Optional.of(normalized); + } + } catch (IOException exception) { + return Optional.empty(); + } + return Optional.empty(); + } + + private ToolRuntimeInvocation withCwd(ToolRuntimeInvocation invocation, Path cwd) { + ToolRuntimeInvocation base = invocation == null + ? ToolRuntimeInvocation.cwdOnly(cwd) + : invocation; + return invocation == null ? base : base.withCwd(cwd); + } + private ToolResult errorResult(ToolUseRequest request, String canonicalName, boolean alias) { String toolUseId = request.toolUseId(); String message = alias diff --git a/lypi-tool/src/main/java/cn/lypi/tool/ToolResultBudgeter.java b/lypi-tool/src/main/java/cn/lypi/tool/ToolResultBudgeter.java index 4c538958..82642300 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/ToolResultBudgeter.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/ToolResultBudgeter.java @@ -34,7 +34,8 @@ public ToolResult apply(String toolUseId, String toolName, ToolResult result.output(), result.isError(), budgetResult.messages(), - replacement + replacement, + result.stateDelta() ); } diff --git a/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java b/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java index 6df1ed28..cecd2ae4 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/ToolRuntimeContextFactory.java @@ -65,7 +65,7 @@ public ToolUseContext create(ToolUseRequest request, ContextSnapshot context, To if (parentEntryId != null && !parentEntryId.isBlank()) { metadata.put("parentEntryId", parentEntryId); } - if (invocation != null) { + if (invocation != null && !invocation.inheritsRuntimeSignals()) { metadata.put(ToolAbortSupport.METADATA_ABORT_SIGNAL, invocation.abortSignal()); metadata.put(ToolSteeringSupport.METADATA_STEERING_MESSAGES, invocation.steeringMessages()); } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java index 3c501a8b..998da682 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java @@ -350,6 +350,118 @@ void preservesWorkspaceRootWhileAddingCallAndAuthorizationMetadata() throws Exce assertEquals(true, captured.get().metadata().get("permissionApprovedForHostExecution")); } + @Test + void propagatesCwdDeltaAcrossPlannerSegmentsAndUnknownCalls() throws Exception { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path nested = Files.createDirectories(workspace.resolve("nested")); + AtomicReference captured = new AtomicReference<>(); + DefaultToolRuntime runtime = new DefaultToolRuntime( + ToolRuntimeOptions.builder().cwd(workspace).build(), + allowAllSecurity() + ); + runtime.register(TestTools.stateDeltaEcho("cd", nested)); + runtime.register(TestTools.contextCapturingEcho("probe", captured)); + + List> results = runtime.execute( + List.of( + new ToolUseRequest("toolu_cd", "cd", Map.of("text", "changed"), "msg_1"), + new ToolUseRequest("toolu_unknown", "missing", Map.of(), "msg_1"), + new ToolUseRequest("toolu_probe", "probe", Map.of("text", "probe"), "msg_1") + ), + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(workspace) + ); + + assertFalse(results.get(0).isError()); + assertTrue(results.get(1).isError()); + assertFalse(results.get(2).isError()); + assertEquals(workspace, captured.get().workspaceRoot()); + assertEquals(nested, captured.get().cwd()); + } + + @Test + void ignoresInvalidCwdDeltasAndKeepsLastValidDirectory() throws Exception { + Path workspace = Files.createDirectories(tempDir.resolve("workspace-invalid")); + Path nested = Files.createDirectories(workspace.resolve("nested")); + Path outside = Files.createDirectories(tempDir.resolve("outside")); + Path symlinkEscape = workspace.resolve("escape"); + Files.createSymbolicLink(symlinkEscape, outside); + AtomicReference afterMissing = new AtomicReference<>(); + AtomicReference afterOutside = new AtomicReference<>(); + AtomicReference afterSymlink = new AtomicReference<>(); + DefaultToolRuntime runtime = new DefaultToolRuntime( + ToolRuntimeOptions.builder().cwd(workspace).build(), + allowAllSecurity() + ); + runtime.register(TestTools.stateDeltaEcho("cd_valid", nested)); + runtime.register(TestTools.stateDeltaEcho("cd_missing", workspace.resolve("missing"))); + runtime.register(TestTools.stateDeltaEcho("cd_outside", outside)); + runtime.register(TestTools.stateDeltaEcho("cd_symlink", symlinkEscape)); + runtime.register(TestTools.contextCapturingEcho("probe_missing", afterMissing)); + runtime.register(TestTools.contextCapturingEcho("probe_outside", afterOutside)); + runtime.register(TestTools.contextCapturingEcho("probe_symlink", afterSymlink)); + + runtime.execute( + List.of( + new ToolUseRequest("toolu_valid", "cd_valid", Map.of(), "msg_1"), + new ToolUseRequest("toolu_missing", "cd_missing", Map.of(), "msg_1"), + new ToolUseRequest("toolu_probe_missing", "probe_missing", Map.of(), "msg_1"), + new ToolUseRequest("toolu_outside", "cd_outside", Map.of(), "msg_1"), + new ToolUseRequest("toolu_probe_outside", "probe_outside", Map.of(), "msg_1"), + new ToolUseRequest("toolu_symlink", "cd_symlink", Map.of(), "msg_1"), + new ToolUseRequest("toolu_probe_symlink", "probe_symlink", Map.of(), "msg_1") + ), + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(workspace) + ); + + assertEquals(nested, afterMissing.get().cwd()); + assertEquals(nested, afterOutside.get().cwd()); + assertEquals(nested, afterSymlink.get().cwd()); + } + + @Test + void cwdOnlyCursorKeepsRuntimeAbortSignal() throws Exception { + Path workspace = Files.createDirectories(tempDir.resolve("workspace-abort")); + Path nested = Files.createDirectories(workspace.resolve("nested")); + AtomicBoolean aborted = new AtomicBoolean(false); + AtomicInteger secondToolCalls = new AtomicInteger(); + ToolExecutionInterceptor interceptor = ToolExecutionInterceptor.after((request, tool, context, result) -> { + if ("cd".equals(request.toolName())) { + aborted.set(true); + } + return result; + }); + DefaultToolRuntime runtime = new DefaultToolRuntime( + new DefaultToolRegistry(), + new ToolSchemaValidator(), + new ToolExecutionPlanner(), + new ToolResultBudgeter(), + new ToolRuntimeContextFactory(ToolRuntimeOptions.builder() + .cwd(workspace) + .metadata(Map.of(ToolAbortSupport.METADATA_ABORT_SIGNAL, (AbortSignal) aborted::get)) + .build()), + interceptor, + allowAllSecurity() + ); + runtime.register(TestTools.stateDeltaEcho("cd", nested)); + runtime.register(TestTools.countingTool( + "after_cd", + InterruptBehavior.CANCEL, + secondToolCalls + )); + + runtime.execute( + List.of( + new ToolUseRequest("toolu_cd", "cd", Map.of(), "msg_1"), + new ToolUseRequest("toolu_after", "after_cd", Map.of(), "msg_1") + ), + TestTools.context(PermissionMode.ASK) + ); + + assertEquals(0, secondToolCalls.get()); + } + @Test void publishesLifecycleWhenInputContainsNullValue() { RecordingEventBus events = new RecordingEventBus(); diff --git a/lypi-tool/src/test/java/cn/lypi/tool/FilteredToolRuntimeTest.java b/lypi-tool/src/test/java/cn/lypi/tool/FilteredToolRuntimeTest.java index 3d5d2114..1d7dbeeb 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/FilteredToolRuntimeTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/FilteredToolRuntimeTest.java @@ -6,15 +6,23 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import cn.lypi.contracts.security.PermissionMode; +import cn.lypi.contracts.runtime.ToolRuntimeInvocation; import cn.lypi.contracts.subagent.SubagentToolPolicy; import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.contracts.tool.ToolUseRequest; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; class FilteredToolRuntimeTest { + @TempDir + Path tempDir; + @Test void snapshotOnlyContainsEffectiveTools() { DefaultToolRuntime delegate = runtimeWithReadGrepGlobAndBash(); @@ -92,6 +100,50 @@ void reportsConfiguredCwdFromDelegate() { assertEquals(Path.of("/tmp/project"), runtime.cwd()); } + @Test + void propagatesValidCwdAndIgnoresInvalidDeltasBetweenDelegatedCalls() throws Exception { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path nested = Files.createDirectories(workspace.resolve("nested")); + Path outside = Files.createDirectories(tempDir.resolve("outside")); + Path symlinkEscape = workspace.resolve("escape"); + Files.createSymbolicLink(symlinkEscape, outside); + AtomicReference captured = new AtomicReference<>(); + DefaultToolRuntime delegate = new DefaultToolRuntime( + ToolRuntimeOptions.builder().cwd(workspace).build(), + (request, context) -> TestTools.decision( + cn.lypi.contracts.security.PermissionBehavior.ALLOW, + "allowed" + ) + ); + delegate.register(TestTools.stateDeltaEcho("cd_valid", nested)); + delegate.register(TestTools.stateDeltaEcho("cd_missing", workspace.resolve("missing"))); + delegate.register(TestTools.stateDeltaEcho("cd_outside", outside)); + delegate.register(TestTools.stateDeltaEcho("cd_symlink", symlinkEscape)); + delegate.register(TestTools.contextCapturingEcho("probe", captured)); + FilteredToolRuntime runtime = new FilteredToolRuntime( + delegate, + new SubagentToolPolicy( + List.of(), + List.of("cd_valid", "cd_missing", "cd_outside", "cd_symlink", "probe") + ) + ); + + runtime.execute( + List.of( + new ToolUseRequest("toolu_valid", "cd_valid", Map.of(), "msg_1"), + new ToolUseRequest("toolu_missing", "cd_missing", Map.of(), "msg_1"), + new ToolUseRequest("toolu_outside", "cd_outside", Map.of(), "msg_1"), + new ToolUseRequest("toolu_symlink", "cd_symlink", Map.of(), "msg_1"), + new ToolUseRequest("toolu_probe", "probe", Map.of(), "msg_1") + ), + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(workspace) + ); + + assertEquals(workspace, captured.get().workspaceRoot()); + assertEquals(nested, captured.get().cwd()); + } + private static DefaultToolRuntime runtimeWithReadGrepGlobAndBash() { DefaultToolRuntime runtime = new DefaultToolRuntime( (request, context) -> TestTools.decision(cn.lypi.contracts.security.PermissionBehavior.ALLOW, "allowed") diff --git a/lypi-tool/src/test/java/cn/lypi/tool/TestTools.java b/lypi-tool/src/test/java/cn/lypi/tool/TestTools.java index d3dc756d..8d31dae4 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/TestTools.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/TestTools.java @@ -21,6 +21,7 @@ import cn.lypi.contracts.tool.InterruptBehavior; import cn.lypi.contracts.tool.Tool; import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolStateDelta; import cn.lypi.contracts.tool.ToolUseContext; import java.math.BigDecimal; import java.nio.file.Path; @@ -30,6 +31,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; final class TestTools { private TestTools() { @@ -82,6 +84,28 @@ public ToolResult execute(Map input, ToolUseContext cont }; } + static Tool, String> stateDeltaEcho(String name, Path cwd) { + return new EchoTool(name, List.of(), false, false, false, Duration.ZERO) { + @Override + public ToolResult execute(Map input, ToolUseContext context, ProgressSink progress) { + return super.execute(input, context, progress).withStateDelta(Optional.of(new ToolStateDelta(cwd))); + } + }; + } + + static Tool, String> contextCapturingEcho( + String name, + AtomicReference captured + ) { + return new EchoTool(name, List.of(), false, false, false, Duration.ZERO) { + @Override + public ToolResult execute(Map input, ToolUseContext context, ProgressSink progress) { + captured.set(context); + return super.execute(input, context, progress); + } + }; + } + static Tool, String> requiredTextEcho(String name) { return new EchoTool(name, List.of(), false, false, false, Duration.ZERO) { @Override diff --git a/lypi-tool/src/test/java/cn/lypi/tool/ToolResultBudgeterTest.java b/lypi-tool/src/test/java/cn/lypi/tool/ToolResultBudgeterTest.java index 7a3b29c0..3d1b8735 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/ToolResultBudgeterTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/ToolResultBudgeterTest.java @@ -6,6 +6,9 @@ import cn.lypi.contracts.context.ToolResultContentBlock; import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolStateDelta; +import java.nio.file.Path; +import java.util.Optional; import org.junit.jupiter.api.Test; class ToolResultBudgeterTest { @@ -20,7 +23,9 @@ void leavesSmallToolResultUnchanged() { @Test void replacesOversizedToolResultTextWithPreview() { - ToolResult result = TestTools.result("toolu_1", "0123456789abcdef", false); + ToolStateDelta delta = new ToolStateDelta(Path.of("/tmp/project/nested")); + ToolResult result = TestTools.result("toolu_1", "0123456789abcdef", false) + .withStateDelta(Optional.of(delta)); ToolResult budgeted = new ToolResultBudgeter().apply("toolu_1", "read", result, 8); @@ -29,5 +34,6 @@ void replacesOversizedToolResultTextWithPreview() { assertTrue(block.text().contains("工具结果已超出预算")); assertTrue(budgeted.replacement().isPresent()); assertEquals("toolu_1", budgeted.replacement().orElseThrow().toolUseId()); + assertEquals(Optional.of(delta), budgeted.stateDelta()); } } From 4536a53e609b6e3e41e415b87c7d13994e5194c1 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sun, 9 Aug 2026 15:51:27 +0800 Subject: [PATCH 07/13] fix(tool): sandbox shell environment state --- .../java/cn/lypi/tool/builtin/BashTool.java | 227 +++++++--- .../cn/lypi/tool/builtin/BuiltInTools.java | 37 +- .../tool/builtin/ShellEnvironmentHarness.java | 422 +++++++++++++----- .../lypi/tool/shell/SandboxPlatformPaths.java | 4 +- .../cn/lypi/tool/DefaultToolRuntimeTest.java | 52 ++- .../builtin/BashToolSandboxSmokeTest.java | 90 ++++ .../cn/lypi/tool/builtin/BashToolTest.java | 272 ++++++++--- .../builtin/RequestPermissionsToolTest.java | 10 +- .../builtin/ShellEnvironmentHarnessTest.java | 250 ++++++++--- 9 files changed, 1044 insertions(+), 320 deletions(-) create mode 100644 lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolSandboxSmokeTest.java diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java index 79246ed1..1ee7947d 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java @@ -10,18 +10,24 @@ import cn.lypi.contracts.runtime.Executor; import cn.lypi.contracts.runtime.SandboxPermissions; import cn.lypi.contracts.runtime.SandboxRuntimePolicy; +import cn.lypi.contracts.runtime.SandboxRuntimePolicyKind; import cn.lypi.contracts.security.AdditionalPermissionProfile; import cn.lypi.contracts.security.PermissionDecision; import cn.lypi.contracts.security.PermissionMode; import cn.lypi.contracts.security.PermissionRuntimeState; import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolStateDelta; import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.tool.shell.DefaultSandboxPolicyResolver; +import cn.lypi.tool.shell.SandboxPlatformPaths; import cn.lypi.tool.shell.SandboxPolicyOptions; import cn.lypi.tool.shell.SandboxPolicyResolver; -import java.nio.file.Path; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; import java.time.Duration; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -29,6 +35,7 @@ public final class BashTool extends AbstractFileTool { private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(120); + private static final Duration SNAPSHOT_TIMEOUT = Duration.ofSeconds(10); private static final AbortSignal NOT_ABORTED = () -> false; private static final String INPUT_SANDBOX_PERMISSIONS = "sandboxPermissions"; private static final String INPUT_ADDITIONAL_PERMISSIONS = "additionalPermissions"; @@ -88,7 +95,7 @@ public JsonSchema inputSchema() { "type", "string", "description", "Shell command executed in the session working directory (persists via cd)." ), - INPUT_SHELL, Map.of("type", "string"), + INPUT_SHELL, Map.of("type", "string", "enum", ALLOWED_SHELLS), INPUT_LOGIN_SHELL, Map.of("type", "boolean"), "timeoutSeconds", Map.of("type", "integer", "minimum", 1), INPUT_SANDBOX_PERMISSIONS, Map.of( @@ -127,8 +134,11 @@ public ValidationResult validateInput(Map input, ToolUseContext return new ValidationResult(false, List.of("sandboxPermissions=requireEscalated 时 justification 不能为空。")); } String shell = stringInput(input, INPUT_SHELL); - if (!shell.isBlank() && !isAllowedShell(shell)) { - return new ValidationResult(false, List.of("shell 仅支持 bash、sh、zsh 或 basename 为这些值的绝对路径。")); + if (!shell.isBlank() && !ALLOWED_SHELLS.contains(shell)) { + return new ValidationResult(false, List.of("shell 仅支持 bash、sh 或 zsh。")); + } + if (input.containsKey("cwd")) { + return new ValidationResult(false, List.of("cwd 由会话状态管理,不接受工具输入覆盖。")); } return new ValidationResult(true, List.of()); } @@ -139,7 +149,7 @@ public PermissionDecision checkPermissions(Map input, ToolUseCon return super.checkPermissions(input, context); } try { - Path cwd = resolveBashCwd(input, context); + Path cwd = resolveBashCwd(context); return permissionPolicy.decide(input, context, cwd, permissionRuntimeState(context)); } catch (RuntimeException | IOException exception) { return permissionPolicy.ask(input); @@ -150,29 +160,89 @@ public PermissionDecision checkPermissions(Map input, ToolUseCon public ToolResult execute(Map input, ToolUseContext context, ProgressSink progress) { String toolUseId = toolUseId(context); try { - Path cwd = resolveBashCwd(input, context); + rejectExecutionOnlyOverrides(input); + Path cwd = resolveBashCwd(context); + String shell = resolvedShell(input); + boolean loginShell = booleanInput(input, INPUT_LOGIN_SHELL, true); Duration timeout = Duration.ofSeconds(intInput(input, "timeoutSeconds", (int) DEFAULT_TIMEOUT.toSeconds(), 1, 86_400)); SandboxPermissions sandboxPermissions = sandboxPermissions(input); PermissionRuntimeState permissionRuntimeState = permissionRuntimeState(context); Optional additionalPermissions = additionalPermissionsForRequest(context, sandboxPermissions); - SandboxRuntimePolicy sandboxPolicy = usesHostExecution(permissionRuntimeState, sandboxPermissions, context) + Optional justification = sandboxPermissions == SandboxPermissions.REQUIRE_ESCALATED + ? Optional.of(stringInput(input, INPUT_JUSTIFICATION)) + : Optional.empty(); + SandboxRuntimePolicy basePolicy = usesHostExecution(permissionRuntimeState, sandboxPermissions, context) ? SandboxRuntimePolicy.disabled() : sandboxPolicy(context.workspaceRoot(), cwd, permissionRuntimeState, additionalPermissions); - ExecutionRequest request = new ExecutionRequest( - shellCommand(input, context), - cwd, - Map.of(), - timeout, - sandboxPolicy, - sandboxPermissions, - additionalPermissions, - sandboxPermissions == SandboxPermissions.REQUIRE_ESCALATED - ? Optional.of(stringInput(input, INPUT_JUSTIFICATION)) - : Optional.empty() - ); + AbortSignal signal = abortSignal(context); + shellHarness.importEnvFile(context.workspaceRoot(), context.sessionId(), System.getenv()); progress.progress(ToolProgress.phase("running", "执行 shell 命令")); - ExecutionResult result = executor.execute(request, progress, abortSignal(context)); - return success(toolUseId, renderResult(result, context)); + + if (loginShell && !shellHarness.snapshotExists(context.workspaceRoot(), context.sessionId(), shell)) { + shellHarness.prepareSnapshot(context.workspaceRoot(), context.sessionId(), shell).ifPresent(plan -> { + try (plan) { + SandboxRuntimePolicy snapshotPolicy = policyWithInternalAccess( + basePolicy, + cwd, + List.of(), + List.of(plan.captureFile()) + ); + ExecutionResult snapshotResult = executor.execute( + executionRequest( + plan.command(), + cwd, + shorterTimeout(timeout, SNAPSHOT_TIMEOUT), + snapshotPolicy, + sandboxPermissions, + additionalPermissions, + justification + ), + progress, + signal + ); + shellHarness.completeSnapshot(plan, snapshotResult); + } catch (RuntimeException ignored) { + // Snapshot is an optimization; the user command falls back to a login shell. + } + }); + } + if (signal.aborted()) { + return error(toolUseId, "命令执行已中止。"); + } + + try (ShellEnvironmentHarness.CommandPlan plan = shellHarness.prepareCommand( + context.workspaceRoot(), + context.sessionId(), + shell, + input.get("command").toString(), + loginShell + )) { + SandboxRuntimePolicy commandPolicy = policyWithInternalAccess( + basePolicy, + cwd, + plan.readOnlyFiles(), + plan.writableFiles() + ); + ExecutionResult result = executor.execute( + executionRequest( + plan.command(), + cwd, + timeout, + commandPolicy, + sandboxPermissions, + additionalPermissions, + justification + ), + progress, + signal + ); + Optional delta = shellHarness.consumeCapturedCwd( + plan, + context.workspaceRoot(), + cwd + ).filter(captured -> !captured.equals(cwd)).map(ToolStateDelta::new); + return success(toolUseId, renderResult(result)).withStateDelta(delta); + } } catch (IllegalArgumentException exception) { return error(toolUseId, exception.getMessage()); } catch (IOException exception) { @@ -207,7 +277,7 @@ private AbortSignal abortSignal(ToolUseContext context) { return value instanceof AbortSignal signal ? signal : NOT_ABORTED; } - private String renderResult(ExecutionResult result, ToolUseContext context) { + private String renderResult(ExecutionResult result) { StringBuilder builder = new StringBuilder(); builder.append("exitCode=").append(result.exitCode()); if (result.timedOut()) { @@ -233,9 +303,6 @@ private String renderResult(ExecutionResult result, ToolUseContext context) { builder.append("\nstderr:\n").append(result.stderr()); } result.persistedOutput().ifPresent(path -> builder.append("\npersistedOutput=").append(path)); - shellHarness.consumeCapturedCwd(context.sessionId()) - .filter(captured -> !captured.equals(context.cwd().toAbsolutePath().normalize())) - .ifPresent(captured -> builder.append("\nshellCwd=").append(captured)); return builder.toString(); } @@ -247,12 +314,13 @@ private SandboxPermissions sandboxPermissions(Map input) { return SandboxPermissions.fromToolValue(stringInput(input, INPUT_SANDBOX_PERMISSIONS)); } - private Path resolveBashCwd(Map input, ToolUseContext context) throws IOException { - Path dynamicCwd = context.cwd().toAbsolutePath().normalize(); - String rawCwd = stringInput(input, "cwd"); - Path cwd = rawCwd.isBlank() ? dynamicCwd : Path.of(rawCwd); - Path resolved = cwd.isAbsolute() ? cwd.toAbsolutePath().normalize() : dynamicCwd.resolve(cwd).normalize(); - return resolved.toRealPath(); + private Path resolveBashCwd(ToolUseContext context) throws IOException { + Path workspaceRoot = context.workspaceRoot().toAbsolutePath().normalize().toRealPath(); + Path cwd = context.cwd().toAbsolutePath().normalize().toRealPath(); + if (!Files.isDirectory(cwd) || !cwd.startsWith(workspaceRoot)) { + throw new IOException("当前工作目录不在 workspace 内: " + context.cwd()); + } + return cwd; } private boolean usesHostExecution( @@ -305,6 +373,83 @@ private SandboxRuntimePolicy sandboxPolicy( return sandboxPolicyResolver.resolve(workspace, cwd, permissionRuntimeState); } + private ExecutionRequest executionRequest( + List command, + Path cwd, + Duration timeout, + SandboxRuntimePolicy sandboxPolicy, + SandboxPermissions sandboxPermissions, + Optional additionalPermissions, + Optional justification + ) { + return new ExecutionRequest( + command, + cwd, + Map.of(), + timeout, + sandboxPolicy, + sandboxPermissions, + additionalPermissions, + justification + ); + } + + private SandboxRuntimePolicy policyWithInternalAccess( + SandboxRuntimePolicy policy, + Path cwd, + List readOnlyFiles, + List writableFiles + ) { + if (policy.kind() != SandboxRuntimePolicyKind.MANAGED) { + return policy; + } + LinkedHashSet allowRead = new LinkedHashSet<>( + policy.allowRead().isEmpty() ? SandboxPlatformPaths.defaultReadOnlyPaths() : policy.allowRead() + ); + LinkedHashSet allowWrite = new LinkedHashSet<>( + policy.allowWrite().isEmpty() ? List.of(cwd) : policy.allowWrite() + ); + appendExistingFiles(allowRead, readOnlyFiles); + appendExistingFiles(allowWrite, writableFiles); + return new SandboxRuntimePolicy( + policy.kind(), + List.copyOf(allowRead), + policy.denyRead(), + List.copyOf(allowWrite), + policy.denyWrite(), + policy.networkMode(), + policy.failIfUnavailable(), + policy.autoAllowBashIfSandboxed() + ); + } + + private void appendExistingFiles(LinkedHashSet target, List files) { + for (Path file : files) { + if (file != null && Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + target.add(file.toAbsolutePath().normalize()); + } + } + } + + private Duration shorterTimeout(Duration first, Duration second) { + return first.compareTo(second) <= 0 ? first : second; + } + + private void rejectExecutionOnlyOverrides(Map input) { + if (input.containsKey("cwd")) { + throw new IllegalArgumentException("cwd 由会话状态管理,不接受工具输入覆盖。"); + } + } + + private String resolvedShell(Map input) { + String shell = stringInput(input, INPUT_SHELL); + String resolved = shell.isBlank() ? "bash" : shell; + if (!ALLOWED_SHELLS.contains(resolved)) { + throw new IllegalArgumentException("shell 仅支持 bash、sh 或 zsh。"); + } + return resolved; + } + private PermissionRuntimeState permissionRuntimeState(ToolUseContext context) { Object canonical = context.metadata().get(METADATA_PERMISSION_RUNTIME_STATE); if (canonical instanceof PermissionRuntimeState permissionRuntimeState) { @@ -340,26 +485,6 @@ private boolean isEmpty(AdditionalPermissionProfile permissions) { return permissions.fileSystem().isEmpty() && permissions.network().isEmpty(); } - private List shellCommand(Map input, ToolUseContext context) { - String shell = stringInput(input, INPUT_SHELL); - String resolvedShell = shell.isBlank() ? "bash" : shell; - String command = input.get("command").toString(); - shellHarness.ensureSnapshot(context.sessionId(), resolvedShell); - shellHarness.importEnvFile(context.sessionId(), System.getenv()); - String wrapped = shellHarness.wrap(context.sessionId(), command); - boolean loginShell = booleanInput(input, INPUT_LOGIN_SHELL, true) && !shellHarness.snapshotExists(context.sessionId()); - return List.of(resolvedShell, loginShell ? "-lc" : "-c", wrapped); - } - - private boolean isAllowedShell(String shell) { - Path path = Path.of(shell); - String shellName = path.getFileName() == null ? shell : path.getFileName().toString(); - if (path.isAbsolute()) { - return ALLOWED_SHELLS.contains(shellName); - } - return ALLOWED_SHELLS.contains(shell); - } - private boolean booleanInput(Map input, String key, boolean defaultValue) { Object value = input == null ? null : input.get(key); if (value == null) { diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BuiltInTools.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BuiltInTools.java index 1c7a5c4e..28a48484 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BuiltInTools.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BuiltInTools.java @@ -33,14 +33,30 @@ private BuiltInTools() { * 创建默认内置工具集合。 */ public static List> createDefaultTools(Executor executor, SandboxPolicyResolver sandboxPolicyResolver) { + return createDefaultTools( + executor, + sandboxPolicyResolver, + new ShellEnvironmentHarness(ShellEnvironmentHarness.defaultStateRoot()) + ); + } + + /** + * 创建使用共享 shell 状态 harness 的默认内置工具集合。 + */ + public static List> createDefaultTools( + Executor executor, + SandboxPolicyResolver sandboxPolicyResolver, + ShellEnvironmentHarness shellHarness + ) { Objects.requireNonNull(executor, "executor must not be null"); Objects.requireNonNull(sandboxPolicyResolver, "sandboxPolicyResolver must not be null"); + Objects.requireNonNull(shellHarness, "shellHarness must not be null"); return List.of( new ReadTool(), new WriteTool(), new EditTool(), new RequestPermissionsTool(), - new BashTool(executor, sandboxPolicyResolver), + new BashTool(executor, sandboxPolicyResolver, shellHarness), new GrepTool(executor), new GlobTool() ); @@ -57,8 +73,25 @@ public static void registerDefaults(ToolRuntimePort runtime, Executor executor) * 注册默认内置工具集合。 */ public static void registerDefaults(ToolRuntimePort runtime, Executor executor, SandboxPolicyResolver sandboxPolicyResolver) { + registerDefaults( + runtime, + executor, + sandboxPolicyResolver, + new ShellEnvironmentHarness(ShellEnvironmentHarness.defaultStateRoot()) + ); + } + + /** + * 注册使用共享 shell 状态 harness 的默认内置工具集合。 + */ + public static void registerDefaults( + ToolRuntimePort runtime, + Executor executor, + SandboxPolicyResolver sandboxPolicyResolver, + ShellEnvironmentHarness shellHarness + ) { Objects.requireNonNull(runtime, "runtime must not be null"); - for (Tool tool : createDefaultTools(executor, sandboxPolicyResolver)) { + for (Tool tool : createDefaultTools(executor, sandboxPolicyResolver, shellHarness)) { runtime.register(tool); } } diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java index 43a62f1f..27736b4b 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java @@ -1,181 +1,373 @@ package cn.lypi.tool.builtin; +import cn.lypi.contracts.runtime.ExecutionResult; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; -import java.time.Duration; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.concurrent.TimeUnit; -import java.util.stream.Stream; +import java.util.regex.Pattern; /** - * 会话级 shell 环境 harness:snapshot 重放 + session env 脚本 + cwd 捕获。 - * - * NOTE: 对齐 Claude Code 的一次性进程模型——每条命令仍是独立进程, - * 状态通过外部文件(snapshot / env/*.sh / cwd 文件)跨命令继承,不做常驻 shell。 + * Builds shell-state file and command plans without executing subprocesses. */ public final class ShellEnvironmentHarness { - static final String SNAPSHOT_FILE = "shell-snapshot.sh"; - static final String CWD_FILE = "cwd"; static final String ENV_DIR = "env"; static final String ENV_FILE_VARIABLE = "LYPI_ENV_FILE"; - private static final Duration SNAPSHOT_TIMEOUT = Duration.ofSeconds(10); + private static final String SNAPSHOT_PREFIX = "shell-snapshot-"; + private static final Pattern VOLATILE_DIRECTORY_EXPORT = Pattern.compile( + "^(?:(?:declare|typeset)(?:\\s+-\\S+)*\\s+|export\\s+)(?:PWD|OLDPWD)(?:=|\\s|$).*" + ); private final Path stateRoot; public ShellEnvironmentHarness(Path stateRoot) { - this.stateRoot = Objects.requireNonNull(stateRoot, "stateRoot must not be null").toAbsolutePath().normalize(); + this.stateRoot = canonicalIfPresent( + Objects.requireNonNull(stateRoot, "stateRoot must not be null").toAbsolutePath().normalize() + ); } - /** - * 默认状态根目录:~/.lypi/shell-state。 - */ public static Path defaultStateRoot() { return Path.of(System.getProperty("user.home"), ".lypi", "shell-state"); } - Path sessionDir(String sessionId) { - String safeId = sessionId == null || sessionId.isBlank() ? "anonymous" : sessionId.replaceAll("[^A-Za-z0-9_-]", "_"); - return stateRoot.resolve(safeId); + record SnapshotPlan( + String shell, + Path snapshotFile, + Path captureFile, + List command + ) implements AutoCloseable { + SnapshotPlan { + Objects.requireNonNull(shell, "shell must not be null"); + Objects.requireNonNull(snapshotFile, "snapshotFile must not be null"); + Objects.requireNonNull(captureFile, "captureFile must not be null"); + command = List.copyOf(command); + } + + @Override + public void close() { + deleteIfExists(captureFile); + } + } + + record CommandPlan( + String shell, + Path snapshotFile, + List command, + Path cwdCaptureFile, + List readOnlyFiles, + List writableFiles + ) implements AutoCloseable { + CommandPlan { + Objects.requireNonNull(shell, "shell must not be null"); + Objects.requireNonNull(snapshotFile, "snapshotFile must not be null"); + command = List.copyOf(command); + Objects.requireNonNull(cwdCaptureFile, "cwdCaptureFile must not be null"); + readOnlyFiles = List.copyOf(readOnlyFiles); + writableFiles = List.copyOf(writableFiles); + } + + @Override + public void close() { + deleteIfExists(cwdCaptureFile); + } + } + + Path sessionDir(Path workspaceRoot, String sessionId) { + Path workspace = Objects.requireNonNull(workspaceRoot, "workspaceRoot must not be null") + .toAbsolutePath() + .normalize(); + String rawSessionId = sessionId == null ? "" : sessionId; + return stateRoot.resolve(sha256(workspace + "\0" + rawSessionId)); } - /** - * snapshot 是否已存在(存在则调用方可用非 login shell)。 - */ - public boolean snapshotExists(String sessionId) { - return Files.isRegularFile(sessionDir(sessionId).resolve(SNAPSHOT_FILE)); + public boolean snapshotExists(Path workspaceRoot, String sessionId, String shell) { + Path snapshot = snapshotFile(workspaceRoot, sessionId, shell); + return Files.isRegularFile(snapshot, LinkOption.NOFOLLOW_LINKS); } - /** - * 用 login shell dump 当前环境为 snapshot 文件(export -p / alias -p / declare -f)。 - * - * 已存在或生成失败时静默跳过——失败只意味着下次命令回退 login shell。 - */ - public void ensureSnapshot(String sessionId, String shell) { - Path snapshot = sessionDir(sessionId).resolve(SNAPSHOT_FILE); - if (Files.exists(snapshot)) { + Optional prepareSnapshot(Path workspaceRoot, String sessionId, String shell) { + String canonicalShell = canonicalShell(shell); + Path snapshot = snapshotFile(workspaceRoot, sessionId, canonicalShell); + if (Files.isRegularFile(snapshot, LinkOption.NOFOLLOW_LINKS)) { + return Optional.empty(); + } + try { + Path dir = ensureSessionDir(workspaceRoot, sessionId); + Path capture = Files.createTempFile(dir, ".snapshot-" + canonicalShell + "-", ".capture"); + return Optional.of(new SnapshotPlan( + canonicalShell, + snapshot, + capture, + List.of(canonicalShell, "-lc", snapshotCommand(canonicalShell, capture)) + )); + } catch (IOException exception) { + return Optional.empty(); + } + } + + void completeSnapshot(SnapshotPlan plan, ExecutionResult result) { + Objects.requireNonNull(plan, "plan must not be null"); + if (result == null || result.exitCode() != 0 || result.timedOut()) { + deleteIfExists(plan.captureFile()); return; } + Path capture = plan.captureFile(); try { - Files.createDirectories(snapshot.getParent()); - Path tmp = snapshot.resolveSibling(SNAPSHOT_FILE + ".tmp"); - Process process = new ProcessBuilder( - shell, - "-lc", - "{ export -p; alias -p; declare -f; } > " + shellQuote(tmp.toString()) + " 2>/dev/null" - ).redirectErrorStream(false).start(); - boolean exited = process.waitFor(SNAPSHOT_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); - if (!exited) { - process.destroyForcibly(); - Files.deleteIfExists(tmp); + if (!Files.isRegularFile(capture, LinkOption.NOFOLLOW_LINKS) || Files.size(capture) == 0) { + deleteIfExists(capture); return; } - if (process.exitValue() == 0 && Files.exists(tmp) && Files.size(tmp) > 0) { - Files.move(tmp, snapshot, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); - } else { - Files.deleteIfExists(tmp); - } - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); + String filtered = filterSnapshot(Files.readString(capture, StandardCharsets.UTF_8)); + if (filtered.isBlank()) { + deleteIfExists(capture); + return; } - // snapshot 是优化项,失败不阻塞命令执行 - } - } - - /** - * 包装用户命令:source snapshot + session env 脚本 + eval 用户命令 + 捕获最终 cwd。 - * - * 结构(对齐 CC): - * source snapshot || true && source env/*.sh && eval '' ; rc=$?; pwd -P >| cwd ; exit $rc - * - * eval 让 snapshot 中定义的 alias 在二次解析时生效;cwd 捕获用 `;` 而非 `&&`,命令失败也记录。 - */ - public String wrap(String sessionId, String command) { - StringBuilder builder = new StringBuilder(); - Path dir = sessionDir(sessionId); - Path snapshot = dir.resolve(SNAPSHOT_FILE); - if (Files.isRegularFile(snapshot)) { - builder.append("source ").append(shellQuote(snapshot.toString())).append(" 2>/dev/null || true && "); - } - builder.append("lypi_env_dir=").append(shellQuote(dir.resolve(ENV_DIR).toString())).append("; "); - builder.append("if [ -d \"$lypi_env_dir\" ]; then "); - builder.append("for lypi_env_file in \"$lypi_env_dir\"/*.sh; do [ -f \"$lypi_env_file\" ] && source \"$lypi_env_file\"; done; "); - builder.append("fi; "); - builder.append("unset lypi_env_dir; "); - builder.append("eval ").append(shellQuote(command)).append("; "); - builder.append("lypi_rc=$?; "); - builder.append("pwd -P >| ").append(shellQuote(dir.resolve(CWD_FILE).toString())).append(" 2>/dev/null; "); - builder.append("exit $lypi_rc"); - return builder.toString(); - } - - /** - * 读取并清除上一条命令捕获的 cwd。 - */ - public Optional consumeCapturedCwd(String sessionId) { - Path file = sessionDir(sessionId).resolve(CWD_FILE); + Files.writeString( + capture, + filtered, + StandardCharsets.UTF_8, + StandardOpenOption.TRUNCATE_EXISTING + ); + atomicReplace(capture, plan.snapshotFile()); + } catch (IOException exception) { + deleteIfExists(capture); + } + } + + CommandPlan prepareCommand( + Path workspaceRoot, + String sessionId, + String shell, + String command, + boolean loginShell + ) throws IOException { + String canonicalShell = canonicalShell(shell); + Path dir = ensureSessionDir(workspaceRoot, sessionId); + Path snapshot = snapshotFile(workspaceRoot, sessionId, canonicalShell); + boolean useSnapshot = loginShell && Files.isRegularFile(snapshot, LinkOption.NOFOLLOW_LINKS); + List scripts = envScripts(workspaceRoot, sessionId); + Path cwdCapture = Files.createTempFile(dir, ".cwd-", ".capture"); + List readOnlyFiles = new ArrayList<>(); + StringBuilder wrapped = new StringBuilder(); + if (useSnapshot) { + readOnlyFiles.add(snapshot); + wrapped.append(". ").append(shellQuote(snapshot.toString())).append(" 2>/dev/null || true; "); + } + for (Path script : scripts) { + readOnlyFiles.add(script); + wrapped.append(". ").append(shellQuote(script.toString())).append("; "); + } + wrapped.append("eval ").append(shellQuote(Objects.requireNonNull(command, "command must not be null"))).append("; "); + wrapped.append("lypi_rc=$?; "); + wrapped.append("pwd -P > ").append(shellQuote(cwdCapture.toString())).append(" 2>/dev/null; "); + wrapped.append("exit $lypi_rc"); + return new CommandPlan( + canonicalShell, + snapshot, + List.of(canonicalShell, loginShell && !useSnapshot ? "-lc" : "-c", wrapped.toString()), + cwdCapture, + readOnlyFiles, + List.of(cwdCapture) + ); + } + + Optional consumeCapturedCwd(CommandPlan plan, Path workspaceRoot, Path previousCwd) { + Objects.requireNonNull(plan, "plan must not be null"); + Objects.requireNonNull(workspaceRoot, "workspaceRoot must not be null"); + Objects.requireNonNull(previousCwd, "previousCwd must not be null"); + Path capture = plan.cwdCaptureFile(); try { - if (!Files.isRegularFile(file)) { + if (!Files.isRegularFile(capture, LinkOption.NOFOLLOW_LINKS)) { return Optional.empty(); } - String content = Files.readString(file, StandardCharsets.UTF_8).trim(); - Files.deleteIfExists(file); - if (content.isEmpty()) { + String value = stripLineEnding(Files.readString(capture, StandardCharsets.UTF_8)); + if (value.isEmpty() || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) { return Optional.empty(); } - Path cwd = Path.of(content); - // cwd 可能刚被命令删掉;此时不回写,调用方保留旧值(或回退启动目录) - return Files.isDirectory(cwd) ? Optional.of(cwd) : Optional.empty(); - } catch (IOException e) { + Path candidate = Path.of(value); + if (!candidate.isAbsolute()) { + return Optional.empty(); + } + Path normalized = candidate.toAbsolutePath().normalize(); + Path realWorkspace = workspaceRoot.toAbsolutePath().normalize().toRealPath(); + Path realCandidate = normalized.toRealPath(); + if (!Files.isDirectory(realCandidate) || !realCandidate.startsWith(realWorkspace)) { + return Optional.empty(); + } + return Optional.of(normalized); + } catch (IOException | RuntimeException exception) { return Optional.empty(); + } finally { + deleteIfExists(capture); } } - /** - * 外部 runner 注入的 env 脚本(LYPI_ENV_FILE 指向的文件复制进 session env 目录)。 - */ - public void importEnvFile(String sessionId, Map environment) { + public void importEnvFile(Path workspaceRoot, String sessionId, Map environment) { String source = environment == null ? null : environment.get(ENV_FILE_VARIABLE); if (source == null || source.isBlank()) { return; } - Path sourceFile = Path.of(source); + Path sourceFile; + try { + sourceFile = Path.of(source).toAbsolutePath().normalize(); + } catch (RuntimeException exception) { + return; + } if (!Files.isRegularFile(sourceFile)) { return; } try { - Path envDir = sessionDir(sessionId).resolve(ENV_DIR); - Files.createDirectories(envDir); + Path envDir = ensureEnvDir(ensureSessionDir(workspaceRoot, sessionId)); Path target = envDir.resolve("00-lypi-env-file.sh"); - if (!Files.exists(target)) { + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + return; + } + try { Files.copy(sourceFile, target); + } catch (FileAlreadyExistsException ignored) { + // A concurrent importer won the create-new race. } - } catch (IOException e) { - // 注入失败不阻塞执行 + } catch (IOException ignored) { + // Environment import is optional and must not block command execution. } } - /** - * session env 目录下按文件名排序的脚本列表(测试与诊断用)。 - */ - Stream envScripts(String sessionId) { - Path envDir = sessionDir(sessionId).resolve(ENV_DIR); - if (!Files.isDirectory(envDir)) { - return Stream.empty(); + List envScripts(Path workspaceRoot, String sessionId) { + Path envDir = sessionDir(workspaceRoot, sessionId).resolve(ENV_DIR); + if (!Files.isDirectory(envDir, LinkOption.NOFOLLOW_LINKS)) { + return List.of(); } - try { - return Files.list(envDir) + try (var paths = Files.list(envDir)) { + return paths + .filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) .filter(path -> path.getFileName().toString().endsWith(".sh")) - .sorted(Comparator.comparing(path -> path.getFileName().toString())); - } catch (IOException e) { - return Stream.empty(); + .sorted(Comparator.comparing(path -> path.getFileName().toString())) + .toList(); + } catch (IOException exception) { + return List.of(); + } + } + + private Path ensureSessionDir(Path workspaceRoot, String sessionId) throws IOException { + Files.createDirectories(stateRoot); + Path realRoot = stateRoot.toRealPath(); + Path dir = sessionDir(workspaceRoot, sessionId); + try { + Files.createDirectory(dir); + } catch (FileAlreadyExistsException ignored) { + // Validate the existing path below. + } + if (!Files.isDirectory(dir, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("shell state session path is not a directory: " + dir); + } + Path realDir = dir.toRealPath(); + if (!realDir.startsWith(realRoot)) { + throw new IOException("shell state session path escapes state root: " + dir); + } + return dir; + } + + private Path ensureEnvDir(Path sessionDir) throws IOException { + Path envDir = sessionDir.resolve(ENV_DIR); + try { + Files.createDirectory(envDir); + } catch (FileAlreadyExistsException ignored) { + // Validate the existing path below. + } + if (!Files.isDirectory(envDir, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("shell env path is not a directory: " + envDir); + } + Path realSessionDir = sessionDir.toRealPath(); + if (!envDir.toRealPath().startsWith(realSessionDir)) { + throw new IOException("shell env path escapes session directory: " + envDir); + } + return envDir; + } + + private Path snapshotFile(Path workspaceRoot, String sessionId, String shell) { + String canonicalShell = canonicalShell(shell); + return sessionDir(workspaceRoot, sessionId).resolve(SNAPSHOT_PREFIX + canonicalShell + ".sh"); + } + + private String snapshotCommand(String shell, Path captureFile) { + String dump = switch (shell) { + case "bash" -> "{ export -p; alias -p; declare -f; }"; + case "sh" -> "{ export -p; alias; }"; + case "zsh" -> "{ export -p; alias -L; functions; }"; + default -> throw new IllegalArgumentException("unsupported shell: " + shell); + }; + return dump + " > " + shellQuote(captureFile.toString()) + " 2>/dev/null"; + } + + private String canonicalShell(String shell) { + return switch (shell) { + case "bash", "sh", "zsh" -> shell; + default -> throw new IllegalArgumentException("shell only supports bash, sh, or zsh"); + }; + } + + private String filterSnapshot(String content) { + List kept = content.lines() + .filter(line -> !VOLATILE_DIRECTORY_EXPORT.matcher(line).matches()) + .toList(); + return kept.isEmpty() ? "" : String.join("\n", kept) + "\n"; + } + + private static String stripLineEnding(String value) { + String stripped = value; + if (stripped.endsWith("\n")) { + stripped = stripped.substring(0, stripped.length() - 1); + } + if (stripped.endsWith("\r")) { + stripped = stripped.substring(0, stripped.length() - 1); + } + return stripped; + } + + private static void atomicReplace(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException exception) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + private static Path canonicalIfPresent(Path path) { + try { + return path.toRealPath(); + } catch (IOException exception) { + return path; + } + } + + private static void deleteIfExists(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // Every invocation uses a unique file, so cleanup is best effort. } } diff --git a/lypi-tool/src/main/java/cn/lypi/tool/shell/SandboxPlatformPaths.java b/lypi-tool/src/main/java/cn/lypi/tool/shell/SandboxPlatformPaths.java index d85ab967..133650ad 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/shell/SandboxPlatformPaths.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/shell/SandboxPlatformPaths.java @@ -6,7 +6,7 @@ /** * 提供 Linux Bubblewrap 沙盒的默认平台路径。 */ -final class SandboxPlatformPaths { +public final class SandboxPlatformPaths { private static final List DEFAULT_READ_ONLY_PATHS = List.of( Path.of("/usr"), Path.of("/bin"), @@ -27,7 +27,7 @@ private SandboxPlatformPaths() { * NOTE: 缺失路径由 Bubblewrap `--ro-bind-try` 忽略,因此 Nix/NixOS * 根可以安全保留在跨发行版默认值中。 */ - static List defaultReadOnlyPaths() { + public static List defaultReadOnlyPaths() { return DEFAULT_READ_ONLY_PATHS; } } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java index 998da682..2996b227 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java @@ -66,11 +66,14 @@ import cn.lypi.tool.builtin.BashTool; import cn.lypi.tool.builtin.ReadTool; import cn.lypi.tool.builtin.RequestPermissionsTool; +import cn.lypi.tool.builtin.ShellEnvironmentHarness; import cn.lypi.tool.builtin.WriteTool; import cn.lypi.tool.mcp.McpToolAdapter; +import cn.lypi.tool.shell.DefaultSandboxPolicyResolver; import cn.lypi.tool.shell.ExecutorRegistry; import cn.lypi.tool.shell.PermissionProfileSandboxPolicyResolver; import cn.lypi.tool.shell.SandboxPolicyOptions; +import cn.lypi.tool.shell.SandboxPolicyResolver; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; @@ -210,7 +213,7 @@ void publishesWriteSummaryWithoutContentBody() { void publishesBoundedSingleLineBashSummary() { RecordingEventBus events = new RecordingEventBus(); DefaultToolRuntime runtime = runtimeWithEvents(events, allowAllSecurity()); - runtime.register(new BashTool(new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())))); + runtime.register(bashTool(new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())))); String command = "printf 'one\ntwo'\r\n" + "🙂".repeat(200); runtime.execute( @@ -514,7 +517,7 @@ void askReviewsDefaultBashEvenWhenSecurityAndToolAllow() { (request, tool, context, decision) -> PermissionGateResult.allow(), null ); - runtime.register(new BashTool(executor, (workspace, cwd) -> policy)); + runtime.register(bashTool(executor, (workspace, cwd) -> policy)); ToolResult result = runtime.execute( List.of(new ToolUseRequest("toolu_1", "bash", Map.of("command", "echo done"), "msg_1")), @@ -522,7 +525,7 @@ void askReviewsDefaultBashEvenWhenSecurityAndToolAllow() { ).getFirst(); assertFalse(result.isError()); - assertEquals(1, executor.calls.get()); + assertEquals(2, executor.calls.get()); assertEquals("bash", executor.request.get().command().get(0)); assertTrue(executor.request.get().command().get(2).contains("eval 'echo done'")); assertTrue(result.newMessages().getFirst().content().getFirst().text().contains("stdout:\ndone")); @@ -537,7 +540,7 @@ void nextBashExecutionUsesChangedPermissionRuntimeProfile() { (request, tool, context, decision) -> PermissionGateResult.allow(), null ); - runtime.register(new BashTool( + runtime.register(bashTool( executor, new PermissionProfileSandboxPolicyResolver( PermissionProfiles.workspace(), @@ -561,7 +564,7 @@ void nextBashExecutionUsesChangedPermissionRuntimeProfile() { assertEquals(SandboxRuntimePolicyKind.MANAGED, askPolicy.kind()); assertFalse(bypassResult.isError()); assertEquals(SandboxRuntimePolicyKind.DISABLED, bypassPolicy.kind()); - assertEquals(2, executor.calls.get()); + assertEquals(4, executor.calls.get()); } @Test @@ -629,7 +632,7 @@ void directAllowedBashCommandsRouteToManagedSandboxWithoutReview() { assertEquals(0, gateCalls.get()); assertEquals(0, reviewerCalls.get()); assertEquals(0, host.calls.get()); - assertEquals(commands.size() * 2, bubblewrap.calls.get()); + assertEquals(commands.size() * 4, bubblewrap.calls.get()); assertTrue(bubblewrap.requests.stream().allMatch(request -> request.sandboxPolicy().kind() == SandboxRuntimePolicyKind.MANAGED )); @@ -704,7 +707,7 @@ void reviewedBashCommandsRouteToHostForAskAndAuto() { assertEquals(commands.size(), gateCalls.get()); assertEquals(commands.size(), reviewerCalls.get()); - assertEquals(commands.size() * 2, host.calls.get()); + assertEquals(commands.size() * 4, host.calls.get()); assertEquals(0, bubblewrap.calls.get()); assertTrue(host.requests.stream().allMatch(request -> request.sandboxPolicy().kind() == SandboxRuntimePolicyKind.DISABLED @@ -759,12 +762,15 @@ void allApprovedBashPermissionShapesRouteToHost() { } assertEquals(3, gateCalls.get()); - assertEquals(3, host.calls.get()); + assertEquals(6, host.calls.get()); assertEquals(0, bubblewrap.calls.get()); assertEquals( List.of( + SandboxPermissions.USE_DEFAULT, SandboxPermissions.USE_DEFAULT, SandboxPermissions.WITH_ADDITIONAL_PERMISSIONS, + SandboxPermissions.WITH_ADDITIONAL_PERMISSIONS, + SandboxPermissions.REQUIRE_ESCALATED, SandboxPermissions.REQUIRE_ESCALATED ), host.requests.stream().map(ExecutionRequest::sandboxPermissions).toList() @@ -820,7 +826,7 @@ void ordinarySandboxFailuresDoNotRequestApprovalOrRetryOnHost() { assertTrue(text.contains(stderr)); assertNoSandboxRetryHint(result); assertEquals(0, host.calls.get()); - assertEquals(1, bubblewrap.calls.get()); + assertEquals(2, bubblewrap.calls.get()); } RecordingExecutor unavailableHost = recordingHostExecutor(); @@ -850,7 +856,7 @@ void ordinarySandboxFailuresDoNotRequestApprovalOrRetryOnHost() { assertTrue(unavailableText.contains("sandboxUnavailable=true")); assertTrue(unavailableText.contains("diagnostic=user namespaces unavailable")); assertEquals(0, unavailableHost.calls.get()); - assertEquals(1, unavailableBubblewrap.calls.get()); + assertEquals(2, unavailableBubblewrap.calls.get()); assertEquals(0, gateCalls.get()); assertEquals(0, reviewerCalls.get()); } @@ -952,9 +958,9 @@ void rememberedBashApprovalRoutesFirstCallToHostAndLaterAllowToSandbox() { assertFalse(firstResult.isError()); assertFalse(secondResult.isError()); assertEquals(1, gateCalls.get()); - assertEquals(1, host.calls.get()); + assertEquals(2, host.calls.get()); assertEquals(SandboxRuntimePolicyKind.DISABLED, host.request.get().sandboxPolicy().kind()); - assertEquals(1, bubblewrap.calls.get()); + assertEquals(2, bubblewrap.calls.get()); assertEquals(SandboxRuntimePolicyKind.MANAGED, bubblewrap.request.get().sandboxPolicy().kind()); } @@ -1270,7 +1276,7 @@ void explicitPrefixAllowExecutesWithoutAskReview() { new FilePermissionUpdateStore(tempDir) ); RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "done", "", false, Optional.empty())); - runtime.register(new BashTool(executor, (workspace, cwd) -> policy)); + runtime.register(bashTool(executor, (workspace, cwd) -> policy)); ToolUseRequest request = new ToolUseRequest( "toolu_1", @@ -1288,7 +1294,7 @@ void explicitPrefixAllowExecutesWithoutAskReview() { assertFalse(result.isError()); assertEquals(0, gateCalls.get()); - assertEquals(1, executor.calls.get()); + assertEquals(2, executor.calls.get()); } @Test @@ -2958,7 +2964,7 @@ void inlineAdditionalPermissionsApprovalAppliesOnlyCurrentBashExecution() throws allowAllSecurity(), gate ); - runtime.register(new BashTool(executor)); + runtime.register(bashTool(executor)); AdditionalPermissionProfile permissions = additionalFileSystem(approved); ToolResult bashResult = runtime.execute( @@ -2991,7 +2997,7 @@ void inlineAdditionalPermissionsApprovalAppliesOnlyCurrentBashExecution() throws assertEquals(Optional.of(permissions), executor.request.get().additionalPermissions()); assertEquals(SandboxRuntimePolicyKind.DISABLED, executor.request.get().sandboxPolicy().kind()); assertTrue(nextResult.isError()); - assertEquals(1, executor.calls.get()); + assertEquals(2, executor.calls.get()); } @Test @@ -3098,7 +3104,7 @@ private DefaultToolRuntime runtimeWithBashRouting( null, reviewer ); - runtime.register(new BashTool( + runtime.register(bashTool( new ExecutorRegistry(host, bubblewrap, true), new PermissionProfileSandboxPolicyResolver( PermissionProfiles.workspace(), @@ -3109,6 +3115,18 @@ private DefaultToolRuntime runtimeWithBashRouting( return runtime; } + private BashTool bashTool(Executor executor) { + return bashTool(executor, new DefaultSandboxPolicyResolver(SandboxPolicyOptions.defaults())); + } + + private BashTool bashTool(Executor executor, SandboxPolicyResolver sandboxPolicyResolver) { + return new BashTool( + executor, + sandboxPolicyResolver, + new ShellEnvironmentHarness(tempDir.resolve("shell-state")) + ); + } + private RecordingExecutor recordingHostExecutor() { return new RecordingExecutor(new ExecutionResult( 0, diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolSandboxSmokeTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolSandboxSmokeTest.java new file mode 100644 index 00000000..040e9458 --- /dev/null +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolSandboxSmokeTest.java @@ -0,0 +1,90 @@ +package cn.lypi.tool.builtin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import cn.lypi.contracts.runtime.ExecutionRequest; +import cn.lypi.contracts.runtime.ExecutionResult; +import cn.lypi.contracts.runtime.SandboxRuntimePolicy; +import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolUseContext; +import cn.lypi.tool.shell.BubblewrapExecutor; +import cn.lypi.tool.shell.DefaultSandboxPolicyResolver; +import cn.lypi.tool.shell.SandboxPolicyOptions; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class BashToolSandboxSmokeTest { + @TempDir + Path tempDir; + + @Test + void shellStateFilesWorkOutsideWorkspaceInManagedSandbox() throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + Path nested = Files.createDirectory(workspace.resolve("dir with spaces")); + Path stateRoot = Files.createDirectory(tempDir.resolve("state-root")); + Path probe = Files.createDirectory(tempDir.resolve("probe")); + BubblewrapExecutor executor = new BubblewrapExecutor(); + assumeTrue(realBubblewrapWorks(executor, probe), "system bubblewrap is unavailable or cannot create namespaces"); + + ShellEnvironmentHarness harness = new ShellEnvironmentHarness(stateRoot); + Path envSource = Files.writeString( + tempDir.resolve("session-env.sh"), + "export LYPI_TEST_ENV=from-env-script\n" + ); + harness.importEnvFile( + workspace, + "ses_smoke", + Map.of(ShellEnvironmentHarness.ENV_FILE_VARIABLE, envSource.toString()) + ); + BashTool tool = new BashTool( + executor, + new DefaultSandboxPolicyResolver(SandboxPolicyOptions.defaults()), + harness + ); + + ToolResult result = tool.execute( + Map.of("command", "cd 'dir with spaces' && printf '%s' \"$LYPI_TEST_ENV\""), + new ToolUseContext( + "ses_smoke", + "msg_1", + workspace, + workspace, + Map.of("toolUseId", "toolu_1") + ), + progress -> { + } + ); + + assertFalse(result.isError(), result.output()); + assertTrue(result.output().contains("from-env-script"), result.output()); + assertTrue(result.output().contains("sandboxed=true"), result.output()); + assertEquals(nested, result.stateDelta().orElseThrow().cwd()); + } + + private boolean realBubblewrapWorks(BubblewrapExecutor executor, Path cwd) { + SandboxRuntimePolicy policy = new DefaultSandboxPolicyResolver(SandboxPolicyOptions.defaults()) + .resolve(cwd, cwd); + ExecutionResult result = executor.execute( + new ExecutionRequest( + List.of("bash", "-c", "true"), + cwd, + Map.of(), + Duration.ofSeconds(5), + policy + ), + progress -> { + }, + () -> false + ); + return result.exitCode() == 0 && result.metadata().sandboxed(); + } +} diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java index 790274fe..5f037f1a 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java @@ -85,8 +85,9 @@ void inputSchemaExposesShellSelectionFields() { @SuppressWarnings("unchecked") Map properties = (Map) tool.inputSchema().value().get("properties"); - assertEquals(Map.of("type", "string"), properties.get("shell")); + assertEquals(Map.of("type", "string", "enum", List.of("bash", "sh", "zsh")), properties.get("shell")); assertEquals(Map.of("type", "boolean"), properties.get("loginShell")); + assertFalse(properties.containsKey("cwd")); } @Test @@ -110,7 +111,9 @@ void mapsCommandToExecutionRequestAndResult() throws Exception { assertTrue(List.of("-c", "-lc").contains(executor.request.get().command().get(1))); assertEquals(nested, executor.request.get().cwd()); assertEquals(Duration.ofSeconds(3), executor.request.get().timeout()); - assertSame(resolver.policy, executor.request.get().sandboxPolicy()); + assertEquals(resolver.policy.kind(), executor.request.get().sandboxPolicy().kind()); + assertTrue(executor.request.get().sandboxPolicy().allowRead().containsAll(resolver.policy.allowRead())); + assertTrue(executor.request.get().sandboxPolicy().allowWrite().containsAll(resolver.policy.allowWrite())); assertEquals(SandboxPermissions.USE_DEFAULT, executor.request.get().sandboxPermissions()); assertEquals(Optional.empty(), executor.request.get().justification()); assertEquals(tempDir, resolver.workspace.get()); @@ -123,6 +126,7 @@ void mapsCommandToExecutionRequestAndResult() throws Exception { assertTrue(result.output().contains("stderr:\nerr")); assertEquals(List.of( ToolProgress.phase("running", "执行 shell 命令"), + ToolProgress.status("executor progress", null), ToolProgress.status("executor progress", null) ), progresses); } @@ -136,7 +140,8 @@ void sameToolUsesChangedRuntimeModeForNextExecution() { PermissionProfiles.workspace(), SandboxPolicyOptions.defaults(), false - ) + ), + testHarness() ); ToolResult askResult = tool.execute( @@ -145,6 +150,7 @@ void sameToolUsesChangedRuntimeModeForNextExecution() { message -> { } ); + assertFalse(askResult.isError(), askResult.output()); SandboxRuntimePolicy askPolicy = executor.request.get().sandboxPolicy(); ToolResult bypassResult = tool.execute( Map.of("command", "true"), @@ -154,7 +160,6 @@ void sameToolUsesChangedRuntimeModeForNextExecution() { ); SandboxRuntimePolicy bypassPolicy = executor.request.get().sandboxPolicy(); - assertFalse(askResult.isError()); assertEquals(SandboxRuntimePolicyKind.MANAGED, askPolicy.kind()); assertEquals(NetworkMode.DISABLED, askPolicy.networkMode()); assertFalse(bypassResult.isError()); @@ -171,7 +176,8 @@ void canonicalRuntimeStateSupersedesLegacyPermissionModeForExecution() { PermissionProfiles.workspace(), SandboxPolicyOptions.defaults(), false - ) + ), + testHarness() ); ToolResult result = tool.execute( @@ -197,7 +203,8 @@ void legacyPermissionModeIsUsedWhenCanonicalRuntimeStateIsMissing() { PermissionProfiles.workspace(), SandboxPolicyOptions.defaults(), false - ) + ), + testHarness() ); ToolResult result = tool.execute( @@ -214,7 +221,11 @@ void legacyPermissionModeIsUsedWhenCanonicalRuntimeStateIsMissing() { @Test void mapsNonLoginShellCommandToExecutionRequest() { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool( + executor, + new RecordingSandboxPolicyResolver(defaultPolicy()), + testHarness() + ); ToolResult result = tool.execute( Map.of("command", "echo hi", "loginShell", false), @@ -227,6 +238,94 @@ void mapsNonLoginShellCommandToExecutionRequest() { assertEquals("bash", executor.request.get().command().get(0)); assertEquals("-c", executor.request.get().command().get(1)); assertTrue(executor.request.get().command().get(2).contains("eval 'echo hi'")); + assertEquals(1, executor.requests.size()); + assertFalse(executor.request.get().command().get(2).contains("shell-snapshot-")); + } + + @Test + void snapshotAndCommandShareExecutorAuthorizationAndNarrowInternalMounts() throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace-unified")); + Path stateRoot = tempDir.resolve("state-outside-workspace"); + ShellEnvironmentHarness harness = new ShellEnvironmentHarness(stateRoot); + RecordingDelegatingExecutor executor = new RecordingDelegatingExecutor(); + SandboxRuntimePolicy basePolicy = new SandboxRuntimePolicy( + List.of(), + List.of(), + List.of(), + List.of(), + NetworkMode.DISABLED, + true, + false + ); + RecordingSandboxPolicyResolver resolver = new RecordingSandboxPolicyResolver(basePolicy); + BashTool tool = new BashTool(executor, resolver, harness); + AbortSignal signal = () -> false; + + ToolResult result = tool.execute( + Map.of("command", "printf ok"), + context(workspace, workspace, Map.of("abortSignal", signal)), + progress -> { + } + ); + + assertFalse(result.isError(), result.output()); + assertEquals(2, executor.requests.size()); + ExecutionRequest snapshotRequest = executor.requests.get(0); + ExecutionRequest commandRequest = executor.requests.get(1); + assertEquals(List.of("bash", "-lc"), snapshotRequest.command().subList(0, 2)); + assertEquals(List.of("bash", "-c"), commandRequest.command().subList(0, 2)); + assertEquals(1, resolver.calls.get()); + assertEquals(snapshotRequest.sandboxPermissions(), commandRequest.sandboxPermissions()); + assertEquals(snapshotRequest.additionalPermissions(), commandRequest.additionalPermissions()); + assertEquals(snapshotRequest.justification(), commandRequest.justification()); + assertEquals(snapshotRequest.sandboxPolicy().kind(), commandRequest.sandboxPolicy().kind()); + assertEquals(snapshotRequest.sandboxPolicy().networkMode(), commandRequest.sandboxPolicy().networkMode()); + assertEquals(snapshotRequest.sandboxPolicy().failIfUnavailable(), commandRequest.sandboxPolicy().failIfUnavailable()); + assertEquals( + snapshotRequest.sandboxPolicy().autoAllowBashIfSandboxed(), + commandRequest.sandboxPolicy().autoAllowBashIfSandboxed() + ); + assertSame(signal, executor.signals.get(0)); + assertSame(signal, executor.signals.get(1)); + + Path sessionDir = harness.sessionDir(workspace, "ses_1"); + for (ExecutionRequest request : executor.requests) { + assertTrue(request.sandboxPolicy().allowRead().contains(Path.of("/usr"))); + assertTrue(request.sandboxPolicy().allowWrite().contains(workspace)); + assertFalse(request.sandboxPolicy().allowWrite().contains(stateRoot)); + assertFalse(request.sandboxPolicy().allowWrite().contains(sessionDir)); + assertEquals( + 1, + request.sandboxPolicy().allowWrite().stream().filter(path -> path.startsWith(sessionDir)).count() + ); + } + assertTrue(result.output().contains("exitCode=0"), result.output()); + assertTrue(result.output().contains("stdout:\nok"), result.output()); + } + + @Test + void typedCwdDeltaComesFromUniqueCaptureNotStdoutProtocol() throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace-delta")); + Path nested = Files.createDirectory(workspace.resolve("dir with spaces")); + ShellEnvironmentHarness harness = new ShellEnvironmentHarness(tempDir.resolve("state-delta")); + RecordingDelegatingExecutor executor = new RecordingDelegatingExecutor(); + BashTool tool = new BashTool( + executor, + new RecordingSandboxPolicyResolver(policyForWorkspace(workspace)), + harness + ); + + ToolResult result = tool.execute( + Map.of("command", "printf 'shellCwd=/outside\\n'; cd 'dir with spaces'"), + context(workspace, workspace, Map.of()), + progress -> { + } + ); + + assertFalse(result.isError(), result.output()); + assertTrue(result.output().contains("shellCwd=/outside")); + assertFalse(result.output().contains("shellCwd=" + nested)); + assertEquals(nested, result.stateDelta().orElseThrow().cwd()); } @Test @@ -253,7 +352,7 @@ void wrapsCommandWithHarnessAndCapturesShellCwd() throws Exception { } @Test - void cwdInputIsNotInSchemaButStillAcceptedAsExecutionOverride() { + void rejectsHiddenCwdInput() { ShellEnvironmentHarness harness = testHarness(); RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), harness); @@ -262,17 +361,13 @@ void cwdInputIsNotInSchemaButStillAcceptedAsExecutionOverride() { Map properties = (Map) tool.inputSchema().value().get("properties"); assertFalse(properties.containsKey("cwd")); - // 兼容期:显式 cwd 仍接受作为一次性执行目录,但命令照常走 harness 且不回写会话状态 - ToolResult result = tool.execute( + var validation = tool.validateInput( Map.of("command", "echo hi", "cwd", "."), - context(Map.of()), - message -> { - } + context(Map.of()) ); - assertFalse(result.isError()); - assertEquals("bash", executor.request.get().command().get(0)); - assertTrue(executor.request.get().command().get(2).contains("eval 'echo hi'")); + assertFalse(validation.valid()); + assertTrue(validation.messages().getFirst().contains("cwd")); } @Test @@ -293,13 +388,13 @@ void shellCwdCapturedFromExecutedCommand() throws Exception { assertTrue(result.output().contains("exitCode=0"), result.output()); // pwd 未改变目录,无 shellCwd 增量(captured == context.cwd) assertFalse(result.output().contains("shellCwd="), result.output()); - assertTrue(harness.snapshotExists("ses_1")); + assertTrue(harness.snapshotExists(tempDir, "ses_1", "bash")); } @Test void sessionEnvScriptAppliesToWrappedCommand() throws Exception { ShellEnvironmentHarness harness = testHarness(); - Path envDir = harness.sessionDir("ses_1").resolve("env"); + Path envDir = harness.sessionDir(tempDir, "ses_1").resolve("env"); Files.createDirectories(envDir); Files.writeString(envDir.resolve("01-test.sh"), "export LYPI_BASH_TOOL_TEST=persisted\n"); Executor realExecutor = new cn.lypi.tool.shell.HostExecutor(); @@ -319,7 +414,7 @@ void sessionEnvScriptAppliesToWrappedCommand() throws Exception { @Test void mapsAllowedShellToExecutionRequest() { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), testHarness()); ToolResult shResult = tool.execute( Map.of("command", "echo hi", "shell", "sh"), @@ -328,7 +423,7 @@ void mapsAllowedShellToExecutionRequest() { } ); - assertFalse(shResult.isError()); + assertFalse(shResult.isError(), shResult.output()); assertEquals("sh", executor.request.get().command().get(0)); ToolResult zshResult = tool.execute( @@ -341,15 +436,6 @@ void mapsAllowedShellToExecutionRequest() { assertFalse(zshResult.isError()); assertEquals("zsh", executor.request.get().command().get(0)); - ToolResult absoluteBashResult = tool.execute( - Map.of("command", "echo hi", "shell", "/bin/bash"), - context(Map.of()), - message -> { - } - ); - - assertFalse(absoluteBashResult.isError()); - assertEquals("/bin/bash", executor.request.get().command().get(0)); } @Test @@ -358,17 +444,24 @@ void rejectsUnsupportedShell() { var pythonResult = tool.validateInput(Map.of("command", "echo hi", "shell", "python"), context(Map.of())); var relativePathResult = tool.validateInput(Map.of("command", "echo hi", "shell", "bin/bash"), context(Map.of())); + var absolutePathResult = tool.validateInput(Map.of("command", "echo hi", "shell", "/bin/bash"), context(Map.of())); assertFalse(pythonResult.valid()); assertTrue(pythonResult.messages().getFirst().contains("shell")); assertFalse(relativePathResult.valid()); assertTrue(relativePathResult.messages().getFirst().contains("shell")); + assertFalse(absolutePathResult.valid()); + assertTrue(absolutePathResult.messages().getFirst().contains("shell")); } @Test void mapsEscalatedSandboxRequestToExecutionRequest() { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool( + executor, + new RecordingSandboxPolicyResolver(defaultPolicy()), + testHarness() + ); ToolResult result = tool.execute( Map.of( @@ -388,16 +481,22 @@ void mapsEscalatedSandboxRequestToExecutionRequest() { executor.request.get().justification() ); assertEquals(Optional.empty(), executor.request.get().additionalPermissions()); + assertEquals(2, executor.requests.size()); + assertTrue(executor.requests.stream().allMatch(request -> + request.sandboxPermissions() == SandboxPermissions.REQUIRE_ESCALATED + && request.justification().equals(Optional.of("Need host access to inspect local process state.")) + && request.sandboxPolicy().kind() == SandboxRuntimePolicyKind.DISABLED + )); } @Test - void approvedDefaultRequestUsesHostWithoutResolvingSandboxPolicy(@TempDir Path outsideDir) throws Exception { + void approvedDefaultRequestUsesHostWithoutResolvingSandboxPolicy() throws Exception { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); FailingSandboxPolicyResolver resolver = new FailingSandboxPolicyResolver(); - BashTool tool = new BashTool(executor, resolver); + BashTool tool = new BashTool(executor, resolver, testHarness()); ToolResult result = tool.execute( - Map.of("command", "pwd", "cwd", outsideDir.toString()), + Map.of("command", "pwd"), context(Map.of("permissionApprovedForHostExecution", true)), message -> { } @@ -406,14 +505,14 @@ void approvedDefaultRequestUsesHostWithoutResolvingSandboxPolicy(@TempDir Path o assertFalse(result.isError()); assertEquals(SandboxRuntimePolicyKind.DISABLED, executor.request.get().sandboxPolicy().kind()); assertEquals(0, resolver.calls.get()); - assertEquals(outsideDir.toRealPath(), executor.request.get().cwd()); + assertEquals(tempDir.toRealPath(), executor.request.get().cwd()); } @Test void approvedAdditionalPermissionsRequestUsesHostWithoutResolvingSandboxPolicy() throws Exception { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); FailingSandboxPolicyResolver resolver = new FailingSandboxPolicyResolver(); - BashTool tool = new BashTool(executor, resolver); + BashTool tool = new BashTool(executor, resolver, testHarness()); Path cacheDir = Files.createDirectory(tempDir.resolve("host-cache")); AdditionalPermissionProfile permissions = additionalWrite(cacheDir); @@ -441,7 +540,7 @@ void approvedAdditionalPermissionsRequestUsesHostWithoutResolvingSandboxPolicy() void approvedEscalatedRequestUsesHostWithoutResolvingSandboxPolicy() { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); FailingSandboxPolicyResolver resolver = new FailingSandboxPolicyResolver(); - BashTool tool = new BashTool(executor, resolver); + BashTool tool = new BashTool(executor, resolver, testHarness()); ToolResult result = tool.execute( Map.of( @@ -463,7 +562,7 @@ void approvedEscalatedRequestUsesHostWithoutResolvingSandboxPolicy() { void bypassUsesHostWithoutApprovalOrSandboxResolution() { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); FailingSandboxPolicyResolver resolver = new FailingSandboxPolicyResolver(); - BashTool tool = new BashTool(executor, resolver); + BashTool tool = new BashTool(executor, resolver, testHarness()); ToolResult result = tool.execute( Map.of("command", "pwd"), @@ -480,7 +579,7 @@ void bypassUsesHostWithoutApprovalOrSandboxResolution() { @Test void mapsApprovedAdditionalPermissionsToSingleExecutionRequest() throws Exception { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), testHarness()); Path cacheDir = Files.createDirectory(tempDir.resolve("cache")); AdditionalPermissionProfile permissions = additionalWrite(cacheDir); @@ -501,6 +600,12 @@ void mapsApprovedAdditionalPermissionsToSingleExecutionRequest() throws Exceptio assertEquals(SandboxPermissions.WITH_ADDITIONAL_PERMISSIONS, executor.request.get().sandboxPermissions()); assertEquals(Optional.of(permissions), executor.request.get().additionalPermissions()); assertEquals(Optional.empty(), executor.request.get().justification()); + assertEquals(2, executor.requests.size()); + assertTrue(executor.requests.stream().allMatch(request -> + request.sandboxPermissions() == SandboxPermissions.WITH_ADDITIONAL_PERMISSIONS + && request.additionalPermissions().equals(Optional.of(permissions)) + && request.justification().isEmpty() + )); ToolResult defaultResult = tool.execute( Map.of("command", "true"), @@ -517,7 +622,7 @@ void mapsApprovedAdditionalPermissionsToSingleExecutionRequest() throws Exceptio @Test void rejectsAdditionalPermissionsWithoutApprovedMarker() throws Exception { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), testHarness()); Path cacheDir = Files.createDirectory(tempDir.resolve("cache")); ToolResult result = tool.execute( @@ -542,7 +647,7 @@ void rendersSandboxRetryHintFromExecutionMetadata() { "bubblewrap unavailable" ); RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(126, "", "denied", false, Optional.empty(), metadata)); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), testHarness()); ToolResult result = tool.execute(Map.of("command", "id"), context(Map.of()), message -> { }); @@ -567,7 +672,7 @@ void rendersOrdinarySandboxFailuresWithoutEscalationHints() { Optional.empty(), ExecutionMetadata.sandboxed("bubblewrap") )); - BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy())); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), testHarness()); ToolResult result = tool.execute(Map.of("command", "touch output.txt"), context(Map.of()), message -> { }); @@ -597,7 +702,7 @@ void rejectsEscalatedSandboxRequestWithoutJustification() { @Test void reportsRunningPhaseBeforeExecutingCommand() { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor); + BashTool tool = new BashTool(executor, new RecordingSandboxPolicyResolver(defaultPolicy()), testHarness()); List progresses = new ArrayList<>(); tool.execute(Map.of("command", "echo hi"), context(Map.of()), progresses::add); @@ -609,57 +714,62 @@ void reportsRunningPhaseBeforeExecutingCommand() { } @Test - void supportsCwdOverrideInsideWorkspaceAndPassesAbortSignal() { - AbortSignal signal = () -> true; + void usesDynamicContextCwdAndPassesAbortSignal() throws Exception { + AbortSignal signal = () -> false; RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); - BashTool tool = new BashTool(executor); + Path nested = Files.createDirectory(tempDir.resolve("nested-cwd")); + BashTool tool = new BashTool( + executor, + new RecordingSandboxPolicyResolver(defaultPolicy()), + testHarness() + ); ToolResult result = tool.execute( - Map.of("command", "pwd", "cwd", "."), - context(Map.of("abortSignal", signal)), + Map.of("command", "pwd"), + context(tempDir, nested, Map.of("abortSignal", signal)), message -> { } ); assertFalse(result.isError()); assertSame(signal, executor.signal.get()); - assertEquals(tempDir, executor.request.get().cwd()); + assertEquals(nested, executor.request.get().cwd()); } @Test - void directAllowUsesManagedSandboxWithCwdOutsideWorkspace() throws Exception { + void rejectsContextCwdOutsideWorkspace() throws Exception { RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); RecordingSandboxPolicyResolver resolver = new RecordingSandboxPolicyResolver(defaultPolicy()); - BashTool tool = new BashTool(executor, resolver); - Path outsideDir = tempDir.getParent(); + BashTool tool = new BashTool(executor, resolver, testHarness()); + Path workspace = Files.createDirectory(tempDir.resolve("workspace-boundary")); + Path outsideDir = Files.createDirectory(tempDir.resolve("outside-boundary")); ToolResult result = tool.execute( - Map.of("command", "printf x > /etc/lypi-test", "cwd", outsideDir.toString()), - context(Map.of()), + Map.of("command", "pwd"), + context(workspace, outsideDir, Map.of()), message -> { } ); - assertFalse(result.isError()); - assertEquals(SandboxRuntimePolicyKind.MANAGED, executor.request.get().sandboxPolicy().kind()); - assertEquals(outsideDir.toRealPath(), executor.request.get().cwd()); - assertEquals(1, resolver.calls.get()); + assertTrue(result.isError()); + assertEquals(0, executor.requests.size()); + assertEquals(0, resolver.calls.get()); } @Test - void directAllowUsesManagedSandboxWithCwdSymlinkOutsideWorkspace(@TempDir Path outsideDir) throws Exception { - Files.createSymbolicLink(tempDir.resolve("outside-link"), outsideDir); + void rejectsContextCwdSymlinkEscape(@TempDir Path outsideDir) throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace-symlink")); + Path escape = Files.createSymbolicLink(workspace.resolve("outside-link"), outsideDir); RecordingExecutor executor = new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())); RecordingSandboxPolicyResolver resolver = new RecordingSandboxPolicyResolver(defaultPolicy()); - BashTool tool = new BashTool(executor, resolver); + BashTool tool = new BashTool(executor, resolver, testHarness()); - ToolResult result = tool.execute(Map.of("command", "pwd", "cwd", "outside-link"), context(Map.of()), message -> { + ToolResult result = tool.execute(Map.of("command", "pwd"), context(workspace, escape, Map.of()), message -> { }); - assertFalse(result.isError()); - assertEquals(SandboxRuntimePolicyKind.MANAGED, executor.request.get().sandboxPolicy().kind()); - assertEquals(outsideDir.toRealPath(), executor.request.get().cwd()); - assertEquals(1, resolver.calls.get()); + assertTrue(result.isError()); + assertEquals(0, executor.requests.size()); + assertEquals(0, resolver.calls.get()); } @Test @@ -782,6 +892,18 @@ private SandboxRuntimePolicy defaultPolicy() { return policy(false, false); } + private SandboxRuntimePolicy policyForWorkspace(Path workspace) { + return new SandboxRuntimePolicy( + List.of(Path.of("/usr"), Path.of("/bin"), Path.of("/lib"), Path.of("/lib64"), Path.of("/etc")), + List.of(), + List.of(workspace), + List.of(), + NetworkMode.DISABLED, + false, + false + ); + } + private SandboxRuntimePolicy policy(boolean failIfUnavailable, boolean autoAllowBashIfSandboxed) { return policy(SandboxRuntimePolicyKind.MANAGED, failIfUnavailable, autoAllowBashIfSandboxed); } @@ -819,6 +941,7 @@ private static final class RecordingExecutor implements Executor { private final ExecutionResult result; private final AtomicReference request = new AtomicReference<>(); private final AtomicReference signal = new AtomicReference<>(); + private final List requests = new ArrayList<>(); private RecordingExecutor(ExecutionResult result) { this.result = result; @@ -833,11 +956,30 @@ public String name() { public ExecutionResult execute(ExecutionRequest request, ProgressSink progress, AbortSignal signal) { this.request.set(request); this.signal.set(signal); + this.requests.add(request); progress.progress(ToolProgress.status("executor progress", null)); return result; } } + private static final class RecordingDelegatingExecutor implements Executor { + private final Executor delegate = new cn.lypi.tool.shell.HostExecutor(); + private final List requests = new ArrayList<>(); + private final List signals = new ArrayList<>(); + + @Override + public String name() { + return "recording-host"; + } + + @Override + public ExecutionResult execute(ExecutionRequest request, ProgressSink progress, AbortSignal signal) { + requests.add(request); + signals.add(signal); + return delegate.execute(request, progress, signal); + } + } + private static final class RecordingSandboxPolicyResolver implements cn.lypi.tool.shell.SandboxPolicyResolver { private final SandboxRuntimePolicy policy; private final AtomicInteger calls = new AtomicInteger(); diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/RequestPermissionsToolTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/RequestPermissionsToolTest.java index 4100ed3a..8d0f81b1 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/RequestPermissionsToolTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/RequestPermissionsToolTest.java @@ -48,6 +48,8 @@ import cn.lypi.tool.DefaultToolRuntime; import cn.lypi.tool.PermissionResponseGate; import cn.lypi.tool.ToolRuntimeOptions; +import cn.lypi.tool.shell.DefaultSandboxPolicyResolver; +import cn.lypi.tool.shell.SandboxPolicyOptions; import cn.lypi.tool.web.WebProviderRegistry; import cn.lypi.tool.web.WebSearchProvider; import cn.lypi.tool.web.WebSearchRequest; @@ -463,7 +465,11 @@ void strictAutoReviewApprovedForTurnMakesLaterCommandAsk() { executions ); runtime.register(new RequestPermissionsTool()); - runtime.register(new BashTool(executor(executions))); + runtime.register(new BashTool( + executor(executions), + new DefaultSandboxPolicyResolver(SandboxPolicyOptions.defaults()), + new ShellEnvironmentHarness(tempDir.resolve("shell-state")) + )); List> results = runtime.execute( List.of( @@ -476,7 +482,7 @@ void strictAutoReviewApprovedForTurnMakesLaterCommandAsk() { assertFalse(results.get(0).isError()); assertFalse(results.get(1).isError()); assertEquals(2, prompts.get()); - assertEquals(1, executions.get()); + assertEquals(2, executions.get()); assertEquals(ApprovalKind.REQUEST_PERMISSIONS, events.get(0).approvalKind()); assertTrue(events.get(1).message().contains("strictAutoReview")); } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java index 35b9ffa5..99107192 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java @@ -2,8 +2,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import cn.lypi.contracts.runtime.ExecutionResult; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -15,102 +17,218 @@ class ShellEnvironmentHarnessTest { @TempDir - Path stateRoot; + Path tempDir; private ShellEnvironmentHarness harness() { - return new ShellEnvironmentHarness(stateRoot); + return new ShellEnvironmentHarness(tempDir.resolve("state")); } @Test - void wrapWithoutSnapshotSourcesEnvDirAndCapturesCwd() { + void sessionDirectoriesUseWorkspaceAndRawSessionHashes() throws Exception { ShellEnvironmentHarness harness = harness(); + Path firstWorkspace = Files.createDirectory(tempDir.resolve("workspace-a")); + Path secondWorkspace = Files.createDirectory(tempDir.resolve("workspace-b")); - String wrapped = harness.wrap("ses_1", "echo hi"); + Path slashId = harness.sessionDir(firstWorkspace, "a/b"); + Path underscoreId = harness.sessionDir(firstWorkspace, "a_b"); + Path otherWorkspace = harness.sessionDir(secondWorkspace, "a/b"); - assertFalse(wrapped.contains("shell-snapshot.sh")); - assertTrue(wrapped.contains("eval 'echo hi'")); - assertTrue(wrapped.contains("pwd -P >|")); - assertTrue(wrapped.contains("exit $lypi_rc")); - assertTrue(wrapped.contains("env")); + assertTrue(slashId.startsWith(tempDir.resolve("state"))); + assertNotEquals(slashId, underscoreId); + assertNotEquals(slashId, otherWorkspace); + assertEquals(64, slashId.getFileName().toString().length()); } @Test - void wrapWithSnapshotSourcesSnapshotFirst() throws IOException { + void snapshotsAreIsolatedByShellAndFilterPwdExports() throws Exception { ShellEnvironmentHarness harness = harness(); - Path dir = harness.sessionDir("ses_1"); - Files.createDirectories(dir); - Files.writeString(dir.resolve(ShellEnvironmentHarness.SNAPSHOT_FILE), "export FOO=1\n"); - - String wrapped = harness.wrap("ses_1", "echo hi"); + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + + ShellEnvironmentHarness.SnapshotPlan bashPlan = harness + .prepareSnapshot(workspace, "ses_1", "bash") + .orElseThrow(); + try (bashPlan) { + Files.writeString( + bashPlan.captureFile(), + "declare -x OLDPWD\n" + + "declare -x PWD=\"/forged\"\n" + + "declare -x KEEP=\"ok\"\n" + + "alias ll='ls -l'\n" + + "kept_function () { echo ok; }\n" + ); + harness.completeSnapshot(bashPlan, success()); + } - assertTrue(wrapped.startsWith("source '")); - assertTrue(wrapped.contains("shell-snapshot.sh")); - assertTrue(wrapped.contains("|| true && ")); - assertTrue(harness.snapshotExists("ses_1")); + assertTrue(harness.snapshotExists(workspace, "ses_1", "bash")); + assertFalse(harness.snapshotExists(workspace, "ses_1", "sh")); + String snapshot = Files.readString(bashPlan.snapshotFile()); + assertFalse(snapshot.lines().anyMatch(line -> line.matches("declare -x (OLDPWD|PWD)(=.*)?"))); + assertTrue(snapshot.contains("declare -x KEEP=\"ok\"")); + assertTrue(snapshot.contains("alias ll='ls -l'")); + assertTrue(snapshot.contains("kept_function")); + + ShellEnvironmentHarness.SnapshotPlan shPlan = harness + .prepareSnapshot(workspace, "ses_1", "sh") + .orElseThrow(); + try (shPlan) { + assertNotEquals(bashPlan.captureFile(), shPlan.captureFile()); + assertNotEquals(bashPlan.snapshotFile(), shPlan.snapshotFile()); + assertEquals("sh", shPlan.command().getFirst()); + Files.writeString(shPlan.captureFile(), "export KEEP='sh'\n"); + harness.completeSnapshot(shPlan, success()); + } + assertTrue(harness.snapshotExists(workspace, "ses_1", "sh")); } @Test - void ensureSnapshotDumpsEnvironmentOnce() { + void commandPlanUsesSortedExplicitSourcesAndPosixSyntax() throws Exception { ShellEnvironmentHarness harness = harness(); - - harness.ensureSnapshot("ses_1", "bash"); - assertTrue(harness.snapshotExists("ses_1")); - - // 第二次调用不重写(mtime 不变) - Path snapshot = harness.sessionDir("ses_1").resolve(ShellEnvironmentHarness.SNAPSHOT_FILE); - try { - long mtime = Files.getLastModifiedTime(snapshot).toMillis(); - harness.ensureSnapshot("ses_1", "bash"); - assertEquals(mtime, Files.getLastModifiedTime(snapshot).toMillis()); - } catch (IOException e) { - throw new AssertionError(e); + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + createSnapshot(harness, workspace, "ses_1", "sh", "export SNAPSHOT_VALUE='ok'\n"); + Path envDir = harness.sessionDir(workspace, "ses_1").resolve(ShellEnvironmentHarness.ENV_DIR); + Files.createDirectories(envDir); + Path second = Files.writeString(envDir.resolve("02-second.sh"), "export SECOND=2\n"); + Path first = Files.writeString(envDir.resolve("01-first.sh"), "export FIRST=1\n"); + + try (ShellEnvironmentHarness.CommandPlan plan = harness.prepareCommand( + workspace, + "ses_1", + "sh", + "printf '%s' \"$SNAPSHOT_VALUE$FIRST$SECOND\"", + true + )) { + assertEquals("sh", plan.command().get(0)); + assertEquals("-c", plan.command().get(1)); + String wrapped = plan.command().get(2); + assertTrue(wrapped.contains(". '" + plan.snapshotFile() + "'"), wrapped); + assertTrue(wrapped.indexOf(first.toString()) < wrapped.indexOf(second.toString()), wrapped); + assertTrue(wrapped.contains("eval 'printf '"), wrapped); + assertTrue(wrapped.contains("pwd -P > '" + plan.cwdCaptureFile() + "'"), wrapped); + assertFalse(wrapped.contains("source "), wrapped); + assertFalse(wrapped.contains(">|"), wrapped); + assertEquals(List.of(plan.snapshotFile(), first, second), plan.readOnlyFiles()); + assertEquals(List.of(plan.cwdCaptureFile()), plan.writableFiles()); } } @Test - void wrappedCommandExecutesWithEnvScriptsAndCapturesCwd() throws Exception { + void nonLoginCommandDoesNotUseOrSourceExistingSnapshot() throws Exception { ShellEnvironmentHarness harness = harness(); - Path envDir = harness.sessionDir("ses_2").resolve(ShellEnvironmentHarness.ENV_DIR); - Files.createDirectories(envDir); - Files.writeString(envDir.resolve("01-first.sh"), "export LYPI_TEST_A=hello\n"); - Files.writeString(envDir.resolve("02-second.sh"), "export LYPI_TEST_B=$LYPI_TEST_A-world\n"); - - List scripts = harness.envScripts("ses_2").toList(); - assertEquals(2, scripts.size()); - assertTrue(scripts.get(0).getFileName().toString().startsWith("01")); - - String wrapped = harness.wrap("ses_2", "echo \"$LYPI_TEST_B\" && cd /"); - Process process = new ProcessBuilder("bash", "-c", wrapped) - .redirectErrorStream(true) - .start(); - String output = new String(process.getInputStream().readAllBytes()); - assertEquals(0, process.waitFor()); - assertTrue(output.contains("hello-world"), output); - - Optional cwd = harness.consumeCapturedCwd("ses_2"); - assertEquals(Optional.of(Path.of("/")), cwd); - // 已消费,再次读取为空 - assertTrue(harness.consumeCapturedCwd("ses_2").isEmpty()); + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + createSnapshot(harness, workspace, "ses_1", "bash", "export SNAPSHOT_VALUE='ok'\n"); + + try (ShellEnvironmentHarness.CommandPlan plan = harness.prepareCommand( + workspace, + "ses_1", + "bash", + "echo hi", + false + )) { + assertEquals("-c", plan.command().get(1)); + assertFalse(plan.command().get(2).contains(plan.snapshotFile().toString())); + assertFalse(plan.readOnlyFiles().contains(plan.snapshotFile())); + } } @Test - void importEnvFileCopiesExternalScriptOnce() throws IOException { + void commandPlansUseUniqueCwdFilesAndConsumeOnlyTheirOwnCapture() throws Exception { ShellEnvironmentHarness harness = harness(); - Path external = stateRoot.resolve("external.sh"); - Files.writeString(external, "export LYPI_EXTERNAL=1\n"); - - harness.importEnvFile("ses_3", Map.of(ShellEnvironmentHarness.ENV_FILE_VARIABLE, external.toString())); - harness.importEnvFile("ses_3", Map.of(ShellEnvironmentHarness.ENV_FILE_VARIABLE, external.toString())); + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + Path nested = Files.createDirectory(workspace.resolve("dir with spaces")); + + try ( + ShellEnvironmentHarness.CommandPlan first = harness.prepareCommand( + workspace, + "ses_1", + "bash", + "pwd", + false + ); + ShellEnvironmentHarness.CommandPlan second = harness.prepareCommand( + workspace, + "ses_1", + "bash", + "pwd", + false + ) + ) { + assertNotEquals(first.cwdCaptureFile(), second.cwdCaptureFile()); + Files.writeString(first.cwdCaptureFile(), nested + "\n"); + + assertEquals(Optional.of(nested), harness.consumeCapturedCwd(first, workspace, workspace)); + assertFalse(Files.exists(first.cwdCaptureFile())); + assertTrue(Files.exists(second.cwdCaptureFile())); + assertEquals(Optional.empty(), harness.consumeCapturedCwd(second, workspace, workspace)); + } + } - List scripts = harness.envScripts("ses_3").toList(); - assertEquals(1, scripts.size()); - assertEquals("export LYPI_EXTERNAL=1", Files.readString(scripts.get(0)).trim()); + @Test + void capturedCwdRejectsOutsideMissingRelativeAndSymlinkEscapePaths() throws Exception { + ShellEnvironmentHarness harness = harness(); + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + Path outside = Files.createDirectory(tempDir.resolve("outside")); + Path escape = Files.createSymbolicLink(workspace.resolve("escape"), outside); + + for (String captured : List.of( + outside.toString(), + workspace.resolve("missing").toString(), + "relative", + escape.toString() + )) { + try (ShellEnvironmentHarness.CommandPlan plan = harness.prepareCommand( + workspace, + "ses_1", + "bash", + "pwd", + false + )) { + Files.writeString(plan.cwdCaptureFile(), captured + "\n"); + assertEquals(Optional.empty(), harness.consumeCapturedCwd(plan, workspace, workspace), captured); + assertFalse(Files.exists(plan.cwdCaptureFile())); + } + } } @Test - void sessionIdSanitizedForFilesystem() { + void importEnvFileCopiesTrustedScriptOnce() throws IOException { ShellEnvironmentHarness harness = harness(); - Path dir = harness.sessionDir("../evil/../../id"); - assertTrue(dir.startsWith(stateRoot), dir.toString()); + Path workspace = Files.createDirectory(tempDir.resolve("workspace")); + Path external = Files.writeString(tempDir.resolve("external.sh"), "export LYPI_EXTERNAL=1\n"); + + harness.importEnvFile( + workspace, + "ses_1", + Map.of(ShellEnvironmentHarness.ENV_FILE_VARIABLE, external.toString()) + ); + Files.writeString(external, "export LYPI_EXTERNAL=2\n"); + harness.importEnvFile( + workspace, + "ses_1", + Map.of(ShellEnvironmentHarness.ENV_FILE_VARIABLE, external.toString()) + ); + + List scripts = harness.envScripts(workspace, "ses_1"); + assertEquals(1, scripts.size()); + assertEquals("export LYPI_EXTERNAL=1", Files.readString(scripts.getFirst()).trim()); + } + + private void createSnapshot( + ShellEnvironmentHarness harness, + Path workspace, + String sessionId, + String shell, + String content + ) throws Exception { + ShellEnvironmentHarness.SnapshotPlan plan = harness + .prepareSnapshot(workspace, sessionId, shell) + .orElseThrow(); + try (plan) { + Files.writeString(plan.captureFile(), content); + harness.completeSnapshot(plan, success()); + } + } + + private ExecutionResult success() { + return new ExecutionResult(0, "", "", false, Optional.empty()); } } From e738a7e2fe80025b4482e6e644db27509e31a934 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sun, 9 Aug 2026 16:03:35 +0800 Subject: [PATCH 08/13] fix(agent): persist typed shell state deltas --- .../cn/lypi/agent/DefaultTurnExecutor.java | 91 ++--- .../cn/lypi/agent/AgentCoreTestFixtures.java | 28 ++ .../lypi/agent/DefaultTurnExecutorTest.java | 367 ++++++++++++++++++ 3 files changed, 437 insertions(+), 49 deletions(-) diff --git a/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java b/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java index 04d5eaf1..735110db 100644 --- a/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java +++ b/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java @@ -31,6 +31,8 @@ import cn.lypi.contracts.tool.ToolUseRequest; import cn.lypi.contracts.runtime.ToolRuntimeInvocation; import cn.lypi.contracts.session.ShellState; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Clock; import java.time.Instant; @@ -140,6 +142,13 @@ private TurnState executeWithTurnId(TurnRequest request, String turnId) { contextLeafId = appendNewMessage(request.sessionId(), pendingToolMessage); newMessages.add(pendingToolMessage); } + Optional nextCwd = toolResult.stateDelta() + .flatMap(delta -> validShellCwd(delta.cwd())); + if (nextCwd.isPresent()) { + contextLeafId = ports.sessionManager() + .appendShellStateChange(ShellState.of(nextCwd.orElseThrow())) + .leafId(); + } } } if (request.abortSignal().aborted()) { @@ -479,67 +488,51 @@ private List> executeTools( TurnRequest turnRequest ) { ensureToolRuntimeCwdMatches(); - List> results; - try { - results = ports.toolRuntime().execute( - toolRequests, - context, - new ToolRuntimeInvocation( - sessionId, - turnId, - parentEntryId, - turnRequest.abortSignal(), - turnRequest.steeringMessages(), - currentShellCwd() - ) + List> results = ports.toolRuntime().execute( + toolRequests, + context, + new ToolRuntimeInvocation( + sessionId, + turnId, + parentEntryId, + turnRequest.abortSignal(), + turnRequest.steeringMessages(), + currentShellCwd() + ) + ); + if (results.size() != toolRequests.size()) { + throw new IllegalStateException( + "Tool runtime returned " + results.size() + " result(s) for " + toolRequests.size() + " request(s)" ); - if (results.size() != toolRequests.size()) { - throw new IllegalStateException( - "Tool runtime returned " + results.size() + " result(s) for " + toolRequests.size() + " request(s)" - ); - } - } catch (RuntimeException failure) { - throw failure; } - applyShellCwdDeltas(results); return results; } private Path currentShellCwd() { - try { - Path shellCwd = ports.sessionManager().shellState().cwd(); - // 该 manager 可能属于另一个 cwd 的 session(如 child runtime 共享父 manager); - // 与本 runtime cwd 不一致时视为外部状态,不覆盖本 runtime 的绑定 cwd。 - if (shellCwd != null && shellCwd.toAbsolutePath().normalize().startsWith(ports.cwd())) { - return shellCwd; - } - return ports.cwd(); - } catch (RuntimeException e) { - return ports.cwd(); - } + return validShellCwd(ports.sessionManager().shellState().cwd()).orElse(ports.cwd()); } - private void applyShellCwdDeltas(List> results) { - for (ToolResult result : results) { - if (result == null || result.isError() || !(result.output() instanceof String output)) { - continue; - } - java.util.regex.Matcher matcher = SHELL_CWD_PATTERN.matcher(output); - if (!matcher.find()) { - continue; - } - Path captured = Path.of(matcher.group(1).trim()); - try { - ports.sessionManager().updateShellState(ShellState.of(captured)); - } catch (RuntimeException e) { - // cwd 回写失败不阻塞工具结果 + private Optional validShellCwd(Path candidate) { + if (candidate == null) { + return Optional.empty(); + } + Path workspaceRoot = ports.cwd().toAbsolutePath().normalize(); + Path normalized = candidate.toAbsolutePath().normalize(); + if (!normalized.startsWith(workspaceRoot)) { + return Optional.empty(); + } + try { + Path realWorkspaceRoot = workspaceRoot.toRealPath(); + Path realCandidate = normalized.toRealPath(); + if (Files.isDirectory(realCandidate) && realCandidate.startsWith(realWorkspaceRoot)) { + return Optional.of(normalized); } + } catch (IOException | SecurityException ignored) { + return Optional.empty(); } + return Optional.empty(); } - private static final java.util.regex.Pattern SHELL_CWD_PATTERN = - java.util.regex.Pattern.compile("(?m)^shellCwd=(\\S+)$"); - private void ensureToolRuntimeCwdMatches() { Path agentCwd = ports.cwd(); Path toolCwd = ports.toolRuntime().cwd().toAbsolutePath().normalize(); diff --git a/lypi-agent-core/src/test/java/cn/lypi/agent/AgentCoreTestFixtures.java b/lypi-agent-core/src/test/java/cn/lypi/agent/AgentCoreTestFixtures.java index d3d0a7db..edb4e2e7 100644 --- a/lypi-agent-core/src/test/java/cn/lypi/agent/AgentCoreTestFixtures.java +++ b/lypi-agent-core/src/test/java/cn/lypi/agent/AgentCoreTestFixtures.java @@ -50,6 +50,8 @@ import cn.lypi.contracts.session.SessionContext; import cn.lypi.contracts.session.SessionEntry; import cn.lypi.contracts.session.SessionHandle; +import cn.lypi.contracts.session.ShellState; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.session.SessionView; import cn.lypi.contracts.session.ThinkingChangeEntry; import cn.lypi.contracts.tool.Tool; @@ -422,6 +424,7 @@ static class InMemorySessionManager implements SessionManagerPort { private String sessionId; private String leafId = ""; private final Map entries = new LinkedHashMap<>(); + private ShellState initialShellState = ShellState.of(Path.of(".").toAbsolutePath().normalize()); @Override public SessionHandle openOrCreate(String sessionId) { @@ -436,6 +439,27 @@ public SessionHandle append(SessionEntry entry) { return handle(); } + @Override + public ShellState shellState() { + ShellState current = initialShellState; + for (SessionEntry entry : branch(leafId)) { + if (entry instanceof ShellStateChangeEntry change) { + current = change.shellState(); + } + } + return current; + } + + @Override + public SessionHandle appendShellStateChange(ShellState shellState) { + return append(new ShellStateChangeEntry( + "entry-shell-state-" + entries.size(), + leafId, + shellState, + NOW + )); + } + @Override public SessionHandle switchLeaf(String leafId) { this.leafId = leafId; @@ -558,6 +582,10 @@ SessionEntry entry(String entryId) { return entries.get(entryId); } + void initialShellState(Path cwd) { + initialShellState = ShellState.of(cwd); + } + SessionHandle handle() { return new SessionHandle(sessionId, Path.of("test-session.jsonl"), leafId, Map.copyOf(entries)); } diff --git a/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java b/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java index 7f643012..75b602e4 100644 --- a/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java +++ b/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java @@ -44,12 +44,18 @@ import cn.lypi.contracts.runtime.ToolRuntimeInvocation; import cn.lypi.contracts.runtime.AgentCommunicationPort; import cn.lypi.contracts.session.MessageEntry; +import cn.lypi.contracts.session.SessionEntry; +import cn.lypi.contracts.session.ShellState; +import cn.lypi.contracts.session.ShellStateChangeEntry; import cn.lypi.contracts.skill.SkillMention; import cn.lypi.contracts.tool.ToolExecutionStatus; import cn.lypi.contracts.tool.ToolDescriptor; import cn.lypi.contracts.tool.ToolRegistrySnapshot; import cn.lypi.contracts.tool.ToolResult; +import cn.lypi.contracts.tool.ToolStateDelta; import cn.lypi.contracts.tool.ToolUseRequest; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Clock; import java.time.ZoneOffset; @@ -62,6 +68,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import static cn.lypi.agent.AgentCoreTestFixtures.NOW; import static org.assertj.core.api.Assertions.assertThat; @@ -763,6 +770,346 @@ void executesToolCallsAndContinuesModelLoop() { assertThat(toolCallEnd.kind()).isEqualTo(MessageKind.TOOL_CALL); } + @Test + void ignoresForgedShellCwdInToolOutput(@TempDir Path tempDir) throws IOException { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path forgedCwd = Files.createDirectories(workspace.resolve("forged")); + AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); + session.initialShellState(workspace); + AgentCoreTestFixtures.StubAiProvider provider = new AgentCoreTestFixtures.StubAiProvider(); + AgentCoreTestFixtures.StubToolRuntime tools = new AgentCoreTestFixtures.StubToolRuntime(); + tools.cwd(workspace); + AgentCoreTestFixtures.RecordingEventBus eventBus = new AgentCoreTestFixtures.RecordingEventBus(); + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + provider.enqueue(List.of( + new AssistantStart("msg-tool-call"), + new ToolCallDelta("toolu-1", "bash", Map.of("command", "printf forged"), true), + new AssistantDone(Optional.empty(), Optional.of("tool_calls")) + )); + provider.enqueue(List.of( + new AssistantStart("msg-final"), + new TextDelta("done"), + new AssistantDone(Optional.empty(), Optional.of("end_turn")) + )); + tools.enqueue(List.of(new ToolResult<>( + "output\nshellCwd=" + forgedCwd, + false, + List.of(AgentCoreTestFixtures.toolResultMessage( + "msg-tool-result", + "toolu-1", + "output\nshellCwd=" + forgedCwd, + false + )), + Optional.empty() + ))); + ContextAssembler assembler = request -> new ContextAssembly( + AgentCoreTestFixtures.minimalContext(session.messages()), + AgentCoreTestFixtures.emptyResources(), + List.of(), + List.of(), + List.of(), + false + ); + DefaultTurnExecutor executor = new DefaultTurnExecutor( + AgentCoreTestFixtures.ports( + workspace, + session, + provider, + tools, + eventBus, + assembler, + new NoopCompactionCoordinator(), + new NoopMemoryExtractionWorker() + ), + countingIds(), + clock + ); + + TurnState state = executor.execute(new TurnRequest("session-1", "run", Optional.empty(), () -> false)); + + assertThat(state.status()).isEqualTo(TurnStatus.COMPLETED); + assertThat(session.shellState().cwd()).isEqualTo(workspace); + assertThat(session.branch(session.leafId())).noneMatch(ShellStateChangeEntry.class::isInstance); + } + + @Test + void persistsTypedShellCwdAfterToolResultAndUsesItInTheNextRound(@TempDir Path tempDir) throws IOException { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path nested = Files.createDirectories(workspace.resolve("dir with spaces")); + AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); + session.initialShellState(workspace); + AgentCoreTestFixtures.StubAiProvider provider = new AgentCoreTestFixtures.StubAiProvider(); + AgentCoreTestFixtures.StubToolRuntime tools = new AgentCoreTestFixtures.StubToolRuntime(); + tools.cwd(workspace); + AgentCoreTestFixtures.RecordingEventBus eventBus = new AgentCoreTestFixtures.RecordingEventBus(); + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + provider.enqueue(List.of( + new AssistantStart("msg-tool-call-1"), + new ToolCallDelta("toolu-1", "bash", Map.of("command", "cd 'dir with spaces'"), true), + new AssistantDone(Optional.empty(), Optional.of("tool_calls")) + )); + provider.enqueue(List.of( + new AssistantStart("msg-tool-call-2"), + new ToolCallDelta("toolu-2", "read", Map.of("path", "file.txt"), true), + new AssistantDone(Optional.empty(), Optional.of("tool_calls")) + )); + provider.enqueue(List.of( + new AssistantStart("msg-final"), + new TextDelta("done"), + new AssistantDone(Optional.empty(), Optional.of("end_turn")) + )); + tools.enqueue(List.of(new ToolResult<>( + "changed directory", + false, + List.of(AgentCoreTestFixtures.toolResultMessage( + "msg-tool-result-1", + "toolu-1", + "changed directory", + false + )), + Optional.empty(), + Optional.of(new ToolStateDelta(nested)) + ))); + tools.enqueue(List.of(new ToolResult<>( + "content", + false, + List.of(AgentCoreTestFixtures.toolResultMessage( + "msg-tool-result-2", + "toolu-2", + "content", + false + )), + Optional.empty() + ))); + List resourceCwds = new ArrayList<>(); + cn.lypi.contracts.runtime.ResourceRuntimePort resources = new cn.lypi.contracts.runtime.ResourceRuntimePort() { + @Override + public cn.lypi.contracts.resource.ResourceSnapshot load(Path cwd) { + resourceCwds.add(cwd); + return AgentCoreTestFixtures.emptyResources(); + } + + @Override + public cn.lypi.contracts.prompt.SystemPrompt buildSystemPrompt( + cn.lypi.contracts.resource.ResourceSnapshot resourceSnapshot + ) { + return new cn.lypi.contracts.prompt.SystemPrompt("system", List.of("test"), "hash"); + } + }; + DefaultContextAssembler assembler = new DefaultContextAssembler( + session, + resources, + new ContextBudgetEstimator() + ); + DefaultTurnExecutor executor = new DefaultTurnExecutor( + new AgentCoreRuntimePorts( + workspace, + session, + provider, + tools, + AgentCoreTestFixtures.allowAllSecurityRuntime(), + resources, + eventBus, + assembler, + new cn.lypi.agent.compact.NoopToolMicroCompactor(), + new NoopCompactionCoordinator(), + new NoopMemoryExtractionWorker() + ), + countingIds(), + clock + ); + + TurnState state = executor.execute(new TurnRequest("session-1", "run", Optional.empty(), () -> false)); + + assertThat(state.status()).isEqualTo(TurnStatus.COMPLETED); + assertThat(session.shellState().cwd()).isEqualTo(nested); + List branch = session.branch(session.leafId()); + int toolResultIndex = indexOfMessage(branch, "msg-tool-result-1"); + assertThat(branch.get(toolResultIndex + 1)).isInstanceOf(ShellStateChangeEntry.class); + ShellStateChangeEntry change = (ShellStateChangeEntry) branch.get(toolResultIndex + 1); + assertThat(change.parentId()).isEqualTo(branch.get(toolResultIndex).id()); + assertThat(change.shellState().cwd()).isEqualTo(nested); + assertThat(resourceCwds).containsExactly(workspace, nested, nested); + assertThat(tools.invocations).extracting(ToolRuntimeInvocation::cwd) + .containsExactly(workspace, nested); + } + + @Test + void ignoresInvalidTypedShellCwdDeltas(@TempDir Path tempDir) throws IOException { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path outside = Files.createDirectories(tempDir.resolve("outside")); + Path missing = workspace.resolve("missing"); + Path regularFile = Files.writeString(workspace.resolve("file.txt"), "content"); + Path symlinkEscape = Files.createSymbolicLink(workspace.resolve("escape"), outside); + AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); + session.initialShellState(workspace); + AgentCoreTestFixtures.StubAiProvider provider = new AgentCoreTestFixtures.StubAiProvider(); + AgentCoreTestFixtures.StubToolRuntime tools = new AgentCoreTestFixtures.StubToolRuntime(); + tools.cwd(workspace); + AgentCoreTestFixtures.RecordingEventBus eventBus = new AgentCoreTestFixtures.RecordingEventBus(); + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + provider.enqueue(List.of( + new AssistantStart("msg-tool-calls"), + new ToolCallDelta("toolu-outside", "bash", Map.of("command", "outside"), true), + new ToolCallDelta("toolu-missing", "bash", Map.of("command", "missing"), true), + new ToolCallDelta("toolu-file", "bash", Map.of("command", "file"), true), + new ToolCallDelta("toolu-symlink", "bash", Map.of("command", "symlink"), true), + new AssistantDone(Optional.empty(), Optional.of("tool_calls")) + )); + provider.enqueue(List.of( + new AssistantStart("msg-final"), + new TextDelta("done"), + new AssistantDone(Optional.empty(), Optional.of("end_turn")) + )); + tools.enqueue(List.of( + shellStateResult("msg-result-outside", "toolu-outside", outside), + shellStateResult("msg-result-missing", "toolu-missing", missing), + shellStateResult("msg-result-file", "toolu-file", regularFile), + shellStateResult("msg-result-symlink", "toolu-symlink", symlinkEscape) + )); + ContextAssembler assembler = request -> new ContextAssembly( + AgentCoreTestFixtures.minimalContext(session.messages()), + AgentCoreTestFixtures.emptyResources(), + List.of(), + List.of(), + List.of(), + false + ); + DefaultTurnExecutor executor = new DefaultTurnExecutor( + AgentCoreTestFixtures.ports( + workspace, + session, + provider, + tools, + eventBus, + assembler, + new NoopCompactionCoordinator(), + new NoopMemoryExtractionWorker() + ), + countingIds(), + clock + ); + + TurnState state = executor.execute(new TurnRequest("session-1", "run", Optional.empty(), () -> false)); + + assertThat(state.status()).isEqualTo(TurnStatus.COMPLETED); + assertThat(session.shellState().cwd()).isEqualTo(workspace); + assertThat(session.branch(session.leafId())).noneMatch(ShellStateChangeEntry.class::isInstance); + } + + @Test + void fallsBackToWorkspaceForInvalidPersistedShellCwd(@TempDir Path tempDir) throws IOException { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path outside = Files.createDirectories(tempDir.resolve("outside")); + AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); + session.initialShellState(outside); + AgentCoreTestFixtures.StubAiProvider provider = new AgentCoreTestFixtures.StubAiProvider(); + AgentCoreTestFixtures.StubToolRuntime tools = new AgentCoreTestFixtures.StubToolRuntime(); + tools.cwd(workspace); + AgentCoreTestFixtures.RecordingEventBus eventBus = new AgentCoreTestFixtures.RecordingEventBus(); + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + provider.enqueue(List.of( + new AssistantStart("msg-tool-call"), + new ToolCallDelta("toolu-1", "read", Map.of("path", "file.txt"), true), + new AssistantDone(Optional.empty(), Optional.of("tool_calls")) + )); + provider.enqueue(List.of( + new AssistantStart("msg-final"), + new TextDelta("done"), + new AssistantDone(Optional.empty(), Optional.of("end_turn")) + )); + tools.enqueue(List.of(new ToolResult<>( + "content", + false, + List.of(AgentCoreTestFixtures.toolResultMessage("msg-tool-result", "toolu-1", "content", false)), + Optional.empty() + ))); + List requestedCwds = new ArrayList<>(); + ContextAssembler assembler = request -> { + requestedCwds.add(request.cwd()); + return new ContextAssembly( + AgentCoreTestFixtures.minimalContext(session.messages()), + AgentCoreTestFixtures.emptyResources(), + List.of(), + List.of(), + List.of(), + false + ); + }; + DefaultTurnExecutor executor = new DefaultTurnExecutor( + AgentCoreTestFixtures.ports( + workspace, + session, + provider, + tools, + eventBus, + assembler, + new NoopCompactionCoordinator(), + new NoopMemoryExtractionWorker() + ), + countingIds(), + clock + ); + + TurnState state = executor.execute(new TurnRequest("session-1", "run", Optional.empty(), () -> false)); + + assertThat(state.status()).isEqualTo(TurnStatus.COMPLETED); + assertThat(requestedCwds).containsExactly(workspace, workspace); + assertThat(tools.invocations).extracting(ToolRuntimeInvocation::cwd).containsExactly(workspace); + } + + @Test + void failsTurnWhenShellStateAppendFails(@TempDir Path tempDir) throws IOException { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Path nested = Files.createDirectories(workspace.resolve("nested")); + AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager() { + @Override + public cn.lypi.contracts.session.SessionHandle appendShellStateChange(ShellState shellState) { + throw new IllegalStateException("shell state append failed"); + } + }; + session.initialShellState(workspace); + AgentCoreTestFixtures.StubAiProvider provider = new AgentCoreTestFixtures.StubAiProvider(); + AgentCoreTestFixtures.StubToolRuntime tools = new AgentCoreTestFixtures.StubToolRuntime(); + tools.cwd(workspace); + AgentCoreTestFixtures.RecordingEventBus eventBus = new AgentCoreTestFixtures.RecordingEventBus(); + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + provider.enqueue(List.of( + new AssistantStart("msg-tool-call"), + new ToolCallDelta("toolu-1", "bash", Map.of("command", "cd nested"), true), + new AssistantDone(Optional.empty(), Optional.of("tool_calls")) + )); + tools.enqueue(List.of(shellStateResult("msg-tool-result", "toolu-1", nested))); + ContextAssembler assembler = request -> new ContextAssembly( + AgentCoreTestFixtures.minimalContext(session.messages()), + AgentCoreTestFixtures.emptyResources(), + List.of(), + List.of(), + List.of(), + false + ); + DefaultTurnExecutor executor = new DefaultTurnExecutor( + AgentCoreTestFixtures.ports( + workspace, + session, + provider, + tools, + eventBus, + assembler, + new NoopCompactionCoordinator(), + new NoopMemoryExtractionWorker() + ), + countingIds(), + clock + ); + + TurnState state = executor.execute(new TurnRequest("session-1", "run", Optional.empty(), () -> false)); + + assertThat(state.status()).isEqualTo(TurnStatus.FAILED); + assertThat(provider.contexts).hasSize(1); + assertThat(session.messages().getLast().content().getFirst().text()).contains("shell state append failed"); + } + @Test void insertsSteeringSubmittedDuringToolExecutionBeforeNextModelCall() { AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); @@ -3208,6 +3555,26 @@ private static AgentMessage toolCallMessage(String id, String toolName, String t ); } + private static ToolResult shellStateResult(String messageId, String toolUseId, Path cwd) { + return new ToolResult<>( + "cwd changed", + false, + List.of(AgentCoreTestFixtures.toolResultMessage(messageId, toolUseId, "cwd changed", false)), + Optional.empty(), + Optional.of(new ToolStateDelta(cwd)) + ); + } + + private static int indexOfMessage(List branch, String messageId) { + for (int index = 0; index < branch.size(); index++) { + SessionEntry entry = branch.get(index); + if (entry instanceof MessageEntry messageEntry && messageEntry.message().id().equals(messageId)) { + return index; + } + } + throw new AssertionError("Message entry not found: " + messageId); + } + private static String toolResultText(ContextSnapshot context, String toolUseId) { return context.messages().stream() .filter(message -> message.kind() == MessageKind.TOOL_RESULT) From ba0a1559939a2967453e7d3988d1438283334b4b Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sun, 9 Aug 2026 16:14:30 +0800 Subject: [PATCH 09/13] fix(tool): preserve explicit tmp reads in sandbox --- .../tool/shell/BubblewrapCommandBuilder.java | 18 +++++++++++- .../shell/BubblewrapCommandBuilderTest.java | 28 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/lypi-tool/src/main/java/cn/lypi/tool/shell/BubblewrapCommandBuilder.java b/lypi-tool/src/main/java/cn/lypi/tool/shell/BubblewrapCommandBuilder.java index f1b203b3..a3186970 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/shell/BubblewrapCommandBuilder.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/shell/BubblewrapCommandBuilder.java @@ -18,6 +18,7 @@ */ public final class BubblewrapCommandBuilder { private static final String EMPTY_FILE_FD = "0"; + private static final Path PRIVATE_TMP = Path.of("/tmp"); private static final List PROTECTED_METADATA_NAMES = List.of(".git", ".codex", ".agents"); /** @@ -198,7 +199,8 @@ public BuildResult buildDetailed(ExecutionRequest request, Options options) { argv.add("/proc"); } argv.add("--tmpfs"); - argv.add("/tmp"); + argv.add(PRIVATE_TMP.toString()); + appendExplicitReadOnlyPathsBelowPrivateTmp(argv, readOnlyPaths); List writableMountPaths = new ArrayList<>(); for (WritableMount writableMount : writableMounts) { Path mountPath = writableMount.mountPath(); @@ -236,6 +238,20 @@ private List readOnlyPaths(SandboxRuntimePolicy policy) { return policy.allowRead().isEmpty() ? SandboxPlatformPaths.defaultReadOnlyPaths() : policy.allowRead(); } + private void appendExplicitReadOnlyPathsBelowPrivateTmp(List argv, List readOnlyPaths) { + LinkedHashSet paths = new LinkedHashSet<>(); + for (Path path : readOnlyPaths) { + if (!path.equals(PRIVATE_TMP) && path.startsWith(PRIVATE_TMP)) { + paths.add(path); + } + } + for (Path path : paths) { + argv.add("--ro-bind-try"); + argv.add(path.toString()); + argv.add(path.toString()); + } + } + private List normalizedReadOnlyPaths(SandboxRuntimePolicy policy, List writableMounts) { List paths = new ArrayList<>(); for (Path path : readOnlyPaths(policy)) { diff --git a/lypi-tool/src/test/java/cn/lypi/tool/shell/BubblewrapCommandBuilderTest.java b/lypi-tool/src/test/java/cn/lypi/tool/shell/BubblewrapCommandBuilderTest.java index 9c196ecf..9cb5e4f6 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/shell/BubblewrapCommandBuilderTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/shell/BubblewrapCommandBuilderTest.java @@ -53,6 +53,34 @@ void buildsMinimalNetworkDisabledBwrapArgv() throws Exception { assertCommandSuffix(argv, List.of("bash", "-lc", "printf hello")); } + @Test + void reappliesExplicitTmpReadPathAfterPrivateTmpMount() throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace-tmp-read")); + Path stateDir = Files.createDirectory(tempDir.resolve("shell-state")); + Path stateFile = Files.writeString(stateDir.resolve("env.sh"), "export VALUE=ok\n"); + SandboxRuntimePolicy policy = new SandboxRuntimePolicy( + List.of(Path.of("/usr"), Path.of("/bin"), stateFile), + List.of(), + List.of(workspace), + List.of(), + NetworkMode.DISABLED, + false, + false + ); + + List argv = BubblewrapCommandBuilder.defaults().build(request(workspace, policy)); + + int privateTmp = indexOfSequence(argv, "--tmpfs", "/tmp"); + int stateFileBind = lastIndexOfSequence( + argv, + "--ro-bind-try", + stateFile.toString(), + stateFile.toString() + ); + assertTrue(privateTmp >= 0, "sandbox must mount a private /tmp"); + assertTrue(stateFileBind > privateTmp, "explicit /tmp read paths must remain visible after the private /tmp mount"); + } + @Test void usesReadonlyFullRootWhenAllowReadContainsRoot() throws Exception { Path workspace = Files.createDirectory(tempDir.resolve("workspace")); From 5929f47bfcd70d09504e4b26040516ccf938920d Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sun, 9 Aug 2026 16:24:40 +0800 Subject: [PATCH 10/13] fix(boot): configure shell state harness --- .../boot/tool/LyPiToolAutoConfiguration.java | 13 +++- .../cn/lypi/boot/tool/LyPiToolProperties.java | 23 ++++++++ .../tool/LyPiToolAutoConfigurationTest.java | 59 +++++++++++++++++++ .../tool/builtin/ShellEnvironmentHarness.java | 4 ++ 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolAutoConfiguration.java b/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolAutoConfiguration.java index 30ae4122..d1e59b56 100644 --- a/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolAutoConfiguration.java +++ b/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolAutoConfiguration.java @@ -31,6 +31,7 @@ import cn.lypi.tool.PermissionResponseGate; import cn.lypi.tool.ToolRuntimeOptions; import cn.lypi.tool.builtin.BuiltInTools; +import cn.lypi.tool.builtin.ShellEnvironmentHarness; import cn.lypi.tool.mcp.McpClientManager; import cn.lypi.tool.mcp.McpClientManagerFactory; import cn.lypi.tool.mcp.McpToolAdapter; @@ -164,6 +165,15 @@ public ExecutorRegistry executorRegistry( return new ExecutorRegistry(hostExecutor, bubblewrapExecutor, properties.getSandbox().isEnabled()); } + /** + * 创建跨主代理和子代理 runtime 共享的 shell 状态 harness。 + */ + @Bean + @ConditionalOnMissingBean(ShellEnvironmentHarness.class) + public ShellEnvironmentHarness shellEnvironmentHarness(LyPiToolProperties properties) { + return new ShellEnvironmentHarness(properties.getShell().getStateRoot()); + } + /** * 创建工具运行时。 * @@ -177,6 +187,7 @@ public ToolRuntimeFactoryPort toolRuntimeFactory( Executor executor, ObjectProvider agentCenter, SandboxPolicyResolver sandboxPolicyResolver, + ShellEnvironmentHarness shellEnvironmentHarness, ObjectProvider eventBus, ObjectProvider responseGate, ObjectProvider promptPort, @@ -248,7 +259,7 @@ private ToolRuntimePort createRuntime( new FilePermissionAmendmentStore(runtimeCwd), permissionReviewer ); - BuiltInTools.registerDefaults(runtime, executor, sandboxPolicyResolver); + BuiltInTools.registerDefaults(runtime, executor, sandboxPolicyResolver, shellEnvironmentHarness); WebResultStore webResultStore = webResultStore(webProperties, runtimeCwd); if (webProperties.isEnabled()) { registerWebFetchTool(runtime, webProperties, webResultStore); diff --git a/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolProperties.java b/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolProperties.java index 0c51d88b..22343359 100644 --- a/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolProperties.java +++ b/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolProperties.java @@ -1,11 +1,14 @@ package cn.lypi.boot.tool; import cn.lypi.contracts.runtime.NetworkMode; +import cn.lypi.tool.builtin.ShellEnvironmentHarness; +import java.nio.file.Path; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "lypi.tool") public class LyPiToolProperties { private SandboxProperties sandbox = new SandboxProperties(); + private ShellProperties shell = new ShellProperties(); public SandboxProperties getSandbox() { return sandbox; @@ -15,6 +18,14 @@ public void setSandbox(SandboxProperties sandbox) { this.sandbox = sandbox == null ? new SandboxProperties() : sandbox; } + public ShellProperties getShell() { + return shell; + } + + public void setShell(ShellProperties shell) { + this.shell = shell == null ? new ShellProperties() : shell; + } + public static class SandboxProperties { private boolean enabled = true; private NetworkMode networkMode = NetworkMode.DISABLED; @@ -53,4 +64,16 @@ public void setAutoAllowBashIfSandboxed(boolean autoAllowBashIfSandboxed) { this.autoAllowBashIfSandboxed = autoAllowBashIfSandboxed; } } + + public static class ShellProperties { + private Path stateRoot = ShellEnvironmentHarness.defaultStateRoot(); + + public Path getStateRoot() { + return stateRoot; + } + + public void setStateRoot(Path stateRoot) { + this.stateRoot = stateRoot == null ? ShellEnvironmentHarness.defaultStateRoot() : stateRoot; + } + } } diff --git a/lypi-boot/src/test/java/cn/lypi/boot/tool/LyPiToolAutoConfigurationTest.java b/lypi-boot/src/test/java/cn/lypi/boot/tool/LyPiToolAutoConfigurationTest.java index 0012356d..83cd8ab1 100644 --- a/lypi-boot/src/test/java/cn/lypi/boot/tool/LyPiToolAutoConfigurationTest.java +++ b/lypi-boot/src/test/java/cn/lypi/boot/tool/LyPiToolAutoConfigurationTest.java @@ -65,6 +65,7 @@ import cn.lypi.contracts.subagent.SubagentWaitResult; import cn.lypi.tool.PermissionGateResult; import cn.lypi.tool.PermissionPromptPort; +import cn.lypi.tool.builtin.ShellEnvironmentHarness; import cn.lypi.tool.mcp.McpClient; import cn.lypi.tool.mcp.McpClientManager; import cn.lypi.tool.mcp.McpClientManagerFactory; @@ -91,10 +92,64 @@ import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; class LyPiToolAutoConfigurationTest { + @Test + void configuresDefaultShellStateRoot() { + new ApplicationContextRunner() + .withUserConfiguration(LyPiToolAutoConfiguration.class) + .withBean(SecurityRuntimePort.class, () -> LyPiToolAutoConfigurationTest::allowAllSecurity) + .run(context -> { + assertThat(context).hasSingleBean(ShellEnvironmentHarness.class); + assertThat(context.getBean(ShellEnvironmentHarness.class).stateRoot()) + .isEqualTo(ShellEnvironmentHarness.defaultStateRoot().toAbsolutePath().normalize()); + }); + } + + @Test + void configuresCustomShellStateRootAndRegistersBash() { + Path stateRoot = Path.of("build/custom-shell-state").toAbsolutePath().normalize(); + + new ApplicationContextRunner() + .withUserConfiguration(LyPiToolAutoConfiguration.class) + .withPropertyValues("lypi.tool.shell.state-root=" + stateRoot) + .withBean(SecurityRuntimePort.class, () -> LyPiToolAutoConfigurationTest::allowAllSecurity) + .run(context -> { + ShellEnvironmentHarness harness = context.getBean(ShellEnvironmentHarness.class); + + assertThat(harness.stateRoot()).isEqualTo(stateRoot); + assertThat(context.getBean(ToolRuntimePort.class).resolve("bash")).isPresent(); + }); + } + + @Test + void sharesUserProvidedShellHarnessAcrossRuntimeFactoryPaths() { + ShellEnvironmentHarness shellHarness = new ShellEnvironmentHarness(Path.of("build/shared-shell-state")); + + new ApplicationContextRunner() + .withUserConfiguration(LyPiToolAutoConfiguration.class) + .withBean(SecurityRuntimePort.class, () -> LyPiToolAutoConfigurationTest::allowAllSecurity) + .withBean(ShellEnvironmentHarness.class, () -> shellHarness) + .run(context -> { + ToolRuntimeFactoryPort factory = context.getBean(ToolRuntimeFactoryPort.class); + ToolRuntimePort main = context.getBean(ToolRuntimePort.class); + ToolRuntimePort child = factory.create(Path.of(".")); + ToolRuntimePort filtered = factory.create( + Path.of("."), + new SubagentToolPolicy(List.of("bash"), List.of("bash")) + ); + + assertThat(context).hasSingleBean(ShellEnvironmentHarness.class); + assertThat(context.getBean(ShellEnvironmentHarness.class)).isSameAs(shellHarness); + assertThat(shellHarnessFrom(main)).isSameAs(shellHarness); + assertThat(shellHarnessFrom(child)).isSameAs(shellHarness); + assertThat(shellHarnessFrom(filtered)).isSameAs(shellHarness); + }); + } + @Test void createsSandboxExecutorChainAndRegistersDefaultTools() { new ApplicationContextRunner() @@ -774,6 +829,10 @@ private static PermissionDecision allowAllSecurity(ToolUseRequest request, ToolU ); } + private static Object shellHarnessFrom(ToolRuntimePort runtime) { + return ReflectionTestUtils.getField(runtime.resolve("bash").orElseThrow(), "shellHarness"); + } + private static ContextSnapshot context() { return context(PermissionMode.ASK); } diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java index 27736b4b..54f91434 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java @@ -43,6 +43,10 @@ public static Path defaultStateRoot() { return Path.of(System.getProperty("user.home"), ".lypi", "shell-state"); } + public Path stateRoot() { + return stateRoot; + } + record SnapshotPlan( String shell, Path snapshotFile, From 6d21f64da6ce0db250845465671843c210229fca Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sun, 9 Aug 2026 20:58:53 +0800 Subject: [PATCH 11/13] fix(tool): hide shell state internals from model --- .../java/cn/lypi/tool/builtin/BashTool.java | 18 ++----- .../builtin/BashToolModelContractTest.java | 51 +++++++++++++++++++ .../cn/lypi/tool/builtin/BashToolTest.java | 11 +++- 3 files changed, 66 insertions(+), 14 deletions(-) create mode 100644 lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolModelContractTest.java diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java index 1ee7947d..be8e74ac 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java @@ -42,6 +42,7 @@ public final class BashTool extends AbstractFileTool { private static final String INPUT_JUSTIFICATION = "justification"; private static final String INPUT_SHELL = "shell"; private static final String INPUT_LOGIN_SHELL = "loginShell"; + private static final String UNSUPPORTED_CWD_MESSAGE = "不支持的工具输入字段: cwd。"; private static final String METADATA_ADDITIONAL_PERMISSIONS = "additionalPermissions"; private static final String METADATA_APPROVED_ADDITIONAL_PERMISSIONS = "approvedAdditionalPermissions"; private static final String METADATA_PERMISSION_APPROVED_FOR_HOST_EXECUTION = "permissionApprovedForHostExecution"; @@ -76,13 +77,7 @@ public String name() { @Override public String description() { - return "Execute shell commands in the session's persistent shell state. " - + "The working directory persists across calls: `cd dir` in one command applies to all subsequent " - + "bash commands and file tools (read/write/grep/glob resolve relative paths against it), " - + "so do not pass absolute paths or repeat cd. " - + "Your login shell environment (aliases, functions, exports) is replayed from a snapshot on every call. " - + "Note: `export`/`source` inside a command do NOT persist to the next call; cross-command environment " - + "must come from session env scripts or the login profile."; + return "Execute shell commands."; } @Override @@ -91,10 +86,7 @@ public JsonSchema inputSchema() { "type", "object", "required", List.of("command"), "properties", Map.of( - "command", Map.of( - "type", "string", - "description", "Shell command executed in the session working directory (persists via cd)." - ), + "command", Map.of("type", "string"), INPUT_SHELL, Map.of("type", "string", "enum", ALLOWED_SHELLS), INPUT_LOGIN_SHELL, Map.of("type", "boolean"), "timeoutSeconds", Map.of("type", "integer", "minimum", 1), @@ -138,7 +130,7 @@ public ValidationResult validateInput(Map input, ToolUseContext return new ValidationResult(false, List.of("shell 仅支持 bash、sh 或 zsh。")); } if (input.containsKey("cwd")) { - return new ValidationResult(false, List.of("cwd 由会话状态管理,不接受工具输入覆盖。")); + return new ValidationResult(false, List.of(UNSUPPORTED_CWD_MESSAGE)); } return new ValidationResult(true, List.of()); } @@ -437,7 +429,7 @@ private Duration shorterTimeout(Duration first, Duration second) { private void rejectExecutionOnlyOverrides(Map input) { if (input.containsKey("cwd")) { - throw new IllegalArgumentException("cwd 由会话状态管理,不接受工具输入覆盖。"); + throw new IllegalArgumentException(UNSUPPORTED_CWD_MESSAGE); } } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolModelContractTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolModelContractTest.java new file mode 100644 index 00000000..50703b50 --- /dev/null +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolModelContractTest.java @@ -0,0 +1,51 @@ +package cn.lypi.tool.builtin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import cn.lypi.contracts.runtime.Executor; +import cn.lypi.contracts.tool.ToolDescriptor; +import cn.lypi.tool.DefaultToolRegistry; +import java.util.Locale; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class BashToolModelContractTest { + @Test + void exposesOnlyPublicBashCapabilityAndInputs() { + Executor unusedExecutor = new Executor() { + @Override + public String name() { + return "unused"; + } + + @Override + public cn.lypi.contracts.runtime.ExecutionResult execute( + cn.lypi.contracts.runtime.ExecutionRequest request, + cn.lypi.contracts.common.ProgressSink progress, + cn.lypi.contracts.common.AbortSignal signal + ) { + throw new AssertionError("model contract test must not execute bash"); + } + }; + DefaultToolRegistry registry = new DefaultToolRegistry(); + registry.register(new BashTool(unusedExecutor)); + + ToolDescriptor descriptor = registry.snapshot().tools().getFirst(); + assertEquals("Execute shell commands.", descriptor.description()); + String normalizedDescription = descriptor.description().toLowerCase(Locale.ROOT); + for (String implementationTerm : new String[] { + "cwd", "working directory", "persistent", "snapshot", "export", "source" + }) { + assertFalse( + normalizedDescription.contains(implementationTerm), + () -> "model-visible description contains shell-state implementation term: " + implementationTerm + ); + } + + @SuppressWarnings("unchecked") + Map properties = (Map) descriptor.inputSchema().value().get("properties"); + assertEquals(Map.of("type", "string"), properties.get("command")); + assertFalse(properties.containsKey("cwd")); + } +} diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java index 5f037f1a..b7fe551c 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java @@ -367,7 +367,16 @@ void rejectsHiddenCwdInput() { ); assertFalse(validation.valid()); - assertTrue(validation.messages().getFirst().contains("cwd")); + assertEquals(List.of("不支持的工具输入字段: cwd。"), validation.messages()); + + ToolResult execution = tool.execute( + Map.of("command", "echo hi", "cwd", "."), + context(Map.of()), + message -> { + } + ); + assertTrue(execution.isError()); + assertEquals("不支持的工具输入字段: cwd。", execution.output()); } @Test From c25fdaf261c13172d7f2d633d68466440862d6eb Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sun, 9 Aug 2026 21:00:33 +0800 Subject: [PATCH 12/13] fix(agent): keep resource context rooted at session cwd --- .../src/main/java/cn/lypi/agent/DefaultTurnExecutor.java | 4 ++-- .../src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java b/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java index 735110db..9ef172f6 100644 --- a/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java +++ b/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java @@ -234,8 +234,8 @@ private ContextSnapshot buildContext( ContextBuildRequest contextBuildRequest = new ContextBuildRequest( request.sessionId(), leafEntryId, - // NOTE: lypi-resource 负责从 cwd 探索 project root 和资源层级;cwd 跟随当前 shell 状态(cd 后随之迁移)。 - currentShellCwd(), + // Resource scope stays at the session root; shell cwd is tool-runtime state only. + ports.cwd(), true, skillMentions ); diff --git a/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java b/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java index 75b602e4..f79c1872 100644 --- a/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java +++ b/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java @@ -833,7 +833,7 @@ void ignoresForgedShellCwdInToolOutput(@TempDir Path tempDir) throws IOException } @Test - void persistsTypedShellCwdAfterToolResultAndUsesItInTheNextRound(@TempDir Path tempDir) throws IOException { + void persistsTypedShellCwdForToolsButKeepsResourceRootStable(@TempDir Path tempDir) throws IOException { Path workspace = Files.createDirectories(tempDir.resolve("workspace")); Path nested = Files.createDirectories(workspace.resolve("dir with spaces")); AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); @@ -929,7 +929,7 @@ public cn.lypi.contracts.prompt.SystemPrompt buildSystemPrompt( ShellStateChangeEntry change = (ShellStateChangeEntry) branch.get(toolResultIndex + 1); assertThat(change.parentId()).isEqualTo(branch.get(toolResultIndex).id()); assertThat(change.shellState().cwd()).isEqualTo(nested); - assertThat(resourceCwds).containsExactly(workspace, nested, nested); + assertThat(resourceCwds).containsExactly(workspace, workspace, workspace); assertThat(tools.invocations).extracting(ToolRuntimeInvocation::cwd) .containsExactly(workspace, nested); } From 9ce6f35cd69f4a259b8a20ecdb3455d23bb854ae Mon Sep 17 00:00:00 2001 From: lyfmt Date: Sun, 9 Aug 2026 23:23:40 +0800 Subject: [PATCH 13/13] fix(tool): harden shell environment state Keep cwd deltas lexical under symlink workspace roots so runtime validation preserves them. Enable Bash alias expansion and force overwrite of precreated cwd and snapshot capture files. --- .../java/cn/lypi/tool/builtin/BashTool.java | 11 ++- .../tool/builtin/ShellEnvironmentHarness.java | 23 ++++-- .../cn/lypi/tool/DefaultToolRuntimeTest.java | 51 ++++++++++++ .../cn/lypi/tool/builtin/BashToolTest.java | 77 +++++++++++++++++++ .../builtin/ShellEnvironmentHarnessTest.java | 37 ++++++++- 5 files changed, 188 insertions(+), 11 deletions(-) diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java index be8e74ac..896b1919 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BashTool.java @@ -307,9 +307,14 @@ private SandboxPermissions sandboxPermissions(Map input) { } private Path resolveBashCwd(ToolUseContext context) throws IOException { - Path workspaceRoot = context.workspaceRoot().toAbsolutePath().normalize().toRealPath(); - Path cwd = context.cwd().toAbsolutePath().normalize().toRealPath(); - if (!Files.isDirectory(cwd) || !cwd.startsWith(workspaceRoot)) { + Path workspaceRoot = context.workspaceRoot().toAbsolutePath().normalize(); + Path cwd = context.cwd().toAbsolutePath().normalize(); + if (!cwd.startsWith(workspaceRoot)) { + throw new IOException("当前工作目录不在 workspace 内: " + context.cwd()); + } + Path realWorkspaceRoot = workspaceRoot.toRealPath(); + Path realCwd = cwd.toRealPath(); + if (!Files.isDirectory(realCwd) || !realCwd.startsWith(realWorkspaceRoot)) { throw new IOException("当前工作目录不在 workspace 内: " + context.cwd()); } return cwd; diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java index 54f91434..51f14ad0 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/ShellEnvironmentHarness.java @@ -166,6 +166,9 @@ CommandPlan prepareCommand( Path cwdCapture = Files.createTempFile(dir, ".cwd-", ".capture"); List readOnlyFiles = new ArrayList<>(); StringBuilder wrapped = new StringBuilder(); + if ("bash".equals(canonicalShell)) { + wrapped.append("shopt -s expand_aliases; "); + } if (useSnapshot) { readOnlyFiles.add(snapshot); wrapped.append(". ").append(shellQuote(snapshot.toString())).append(" 2>/dev/null || true; "); @@ -176,7 +179,7 @@ CommandPlan prepareCommand( } wrapped.append("eval ").append(shellQuote(Objects.requireNonNull(command, "command must not be null"))).append("; "); wrapped.append("lypi_rc=$?; "); - wrapped.append("pwd -P > ").append(shellQuote(cwdCapture.toString())).append(" 2>/dev/null; "); + wrapped.append("pwd -P >| ").append(shellQuote(cwdCapture.toString())).append(" 2>/dev/null; "); wrapped.append("exit $lypi_rc"); return new CommandPlan( canonicalShell, @@ -205,13 +208,21 @@ Optional consumeCapturedCwd(CommandPlan plan, Path workspaceRoot, Path pre if (!candidate.isAbsolute()) { return Optional.empty(); } - Path normalized = candidate.toAbsolutePath().normalize(); - Path realWorkspace = workspaceRoot.toAbsolutePath().normalize().toRealPath(); - Path realCandidate = normalized.toRealPath(); + Path lexicalWorkspace = workspaceRoot.toAbsolutePath().normalize(); + Path realWorkspace = lexicalWorkspace.toRealPath(); + Path realCandidate = candidate.toAbsolutePath().normalize().toRealPath(); if (!Files.isDirectory(realCandidate) || !realCandidate.startsWith(realWorkspace)) { return Optional.empty(); } - return Optional.of(normalized); + Path lexicalCandidate = lexicalWorkspace + .resolve(realWorkspace.relativize(realCandidate)) + .normalize(); + if (!lexicalCandidate.startsWith(lexicalWorkspace) + || !Files.isDirectory(lexicalCandidate) + || !lexicalCandidate.toRealPath().equals(realCandidate)) { + return Optional.empty(); + } + return Optional.of(lexicalCandidate); } catch (IOException | RuntimeException exception) { return Optional.empty(); } finally { @@ -313,7 +324,7 @@ private String snapshotCommand(String shell, Path captureFile) { case "zsh" -> "{ export -p; alias -L; functions; }"; default -> throw new IllegalArgumentException("unsupported shell: " + shell); }; - return dump + " > " + shellQuote(captureFile.toString()) + " 2>/dev/null"; + return dump + " >| " + shellQuote(captureFile.toString()) + " 2>/dev/null"; } private String canonicalShell(String shell) { diff --git a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java index 2996b227..fc5c1f12 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java @@ -71,6 +71,7 @@ import cn.lypi.tool.mcp.McpToolAdapter; import cn.lypi.tool.shell.DefaultSandboxPolicyResolver; import cn.lypi.tool.shell.ExecutorRegistry; +import cn.lypi.tool.shell.HostExecutor; import cn.lypi.tool.shell.PermissionProfileSandboxPolicyResolver; import cn.lypi.tool.shell.SandboxPolicyOptions; import cn.lypi.tool.shell.SandboxPolicyResolver; @@ -382,6 +383,56 @@ void propagatesCwdDeltaAcrossPlannerSegmentsAndUnknownCalls() throws Exception { assertEquals(nested, captured.get().cwd()); } + @Test + void bashCwdDeltaFromSymlinkWorkspaceAppliesToFollowingRead() throws Exception { + Path realWorkspace = Files.createDirectory(tempDir.resolve("runtime-real-workspace")); + Path nested = Files.createDirectory(realWorkspace.resolve("nested")); + Files.writeString(nested.resolve("marker.txt"), "nested-marker\n"); + Path workspaceLink = Files.createSymbolicLink( + tempDir.resolve("runtime-workspace-link"), + realWorkspace + ); + SandboxRuntimePolicy policy = new SandboxRuntimePolicy( + List.of(), + List.of(), + List.of(workspaceLink), + List.of(), + NetworkMode.DISABLED, + false, + true + ); + DefaultToolRuntime runtime = new DefaultToolRuntime( + ToolRuntimeOptions.builder().cwd(workspaceLink).build(), + allowAllSecurity(), + (request, tool, context, decision) -> PermissionGateResult.allow(), + null + ); + runtime.register(bashTool(new HostExecutor(), (workspace, cwd) -> policy)); + runtime.register(new ReadTool()); + + List> results = runtime.execute( + List.of( + new ToolUseRequest( + "toolu_cd", + "bash", + Map.of("command", "cd nested", "loginShell", false), + "msg_1" + ), + new ToolUseRequest("toolu_read", "read", Map.of("path", "marker.txt"), "msg_1") + ), + TestTools.context(PermissionMode.ASK), + new ToolRuntimeInvocation("ses_1", "turn_1").withCwd(workspaceLink) + ); + + assertFalse(results.get(0).isError(), results.get(0).output().toString()); + assertEquals( + workspaceLink.resolve("nested"), + results.get(0).stateDelta().orElseThrow().cwd() + ); + assertFalse(results.get(1).isError(), results.get(1).output().toString()); + assertTrue(results.get(1).output().toString().contains("nested-marker")); + } + @Test void ignoresInvalidCwdDeltasAndKeepsLastValidDirectory() throws Exception { Path workspace = Files.createDirectories(tempDir.resolve("workspace-invalid")); diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java index b7fe551c..82366854 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BashToolTest.java @@ -328,6 +328,83 @@ void typedCwdDeltaComesFromUniqueCaptureNotStdoutProtocol() throws Exception { assertEquals(nested, result.stateDelta().orElseThrow().cwd()); } + @Test + void cwdDeltaStaysLexicalWhenWorkspaceRootIsSymlink() throws Exception { + Path realWorkspace = Files.createDirectory(tempDir.resolve("real-workspace")); + Files.createDirectory(realWorkspace.resolve("nested")); + Path workspaceLink = Files.createSymbolicLink(tempDir.resolve("workspace-link"), realWorkspace); + ShellEnvironmentHarness harness = new ShellEnvironmentHarness(tempDir.resolve("state-symlink")); + BashTool tool = new BashTool( + new cn.lypi.tool.shell.HostExecutor(), + new RecordingSandboxPolicyResolver(policyForWorkspace(workspaceLink)), + harness + ); + + ToolResult result = tool.execute( + Map.of("command", "cd nested", "loginShell", false), + context(workspaceLink, workspaceLink, Map.of()), + ignored -> { + } + ); + + assertFalse(result.isError(), result.output()); + assertEquals(workspaceLink.resolve("nested"), result.stateDelta().orElseThrow().cwd()); + } + + @Test + void restoredBashAliasExecutesInNonInteractiveCommand() throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace-alias")); + ShellEnvironmentHarness harness = new ShellEnvironmentHarness(tempDir.resolve("state-alias")); + ShellEnvironmentHarness.SnapshotPlan snapshot = harness + .prepareSnapshot(workspace, "ses_1", "bash") + .orElseThrow(); + try (snapshot) { + Files.writeString(snapshot.captureFile(), "alias lypi_alias='printf alias-restored'\n"); + harness.completeSnapshot( + snapshot, + new ExecutionResult(0, "", "", false, Optional.empty()) + ); + } + BashTool tool = new BashTool( + new cn.lypi.tool.shell.HostExecutor(), + new RecordingSandboxPolicyResolver(policyForWorkspace(workspace)), + harness + ); + + ToolResult result = tool.execute( + Map.of("command", "lypi_alias"), + context(workspace, workspace, Map.of()), + ignored -> { + } + ); + + assertFalse(result.isError(), result.output()); + assertTrue(result.output().contains("exitCode=0"), result.output()); + assertTrue(result.output().contains("alias-restored"), result.output()); + } + + @Test + void cwdCaptureOverridesNoclobberEnabledByUserCommand() throws Exception { + Path workspace = Files.createDirectory(tempDir.resolve("workspace-noclobber")); + Path nested = Files.createDirectory(workspace.resolve("nested")); + ShellEnvironmentHarness harness = new ShellEnvironmentHarness(tempDir.resolve("state-noclobber")); + BashTool tool = new BashTool( + new cn.lypi.tool.shell.HostExecutor(), + new RecordingSandboxPolicyResolver(policyForWorkspace(workspace)), + harness + ); + + ToolResult result = tool.execute( + Map.of("command", "set -C; cd nested", "loginShell", false), + context(workspace, workspace, Map.of()), + ignored -> { + } + ); + + assertFalse(result.isError(), result.output()); + assertEquals(nested, result.stateDelta().orElseThrow().cwd()); + } + @Test void wrapsCommandWithHarnessAndCapturesShellCwd() throws Exception { ShellEnvironmentHarness harness = testHarness(); diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java index 99107192..e5e282dc 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/ShellEnvironmentHarnessTest.java @@ -5,10 +5,14 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import cn.lypi.contracts.runtime.ExecutionRequest; import cn.lypi.contracts.runtime.ExecutionResult; +import cn.lypi.contracts.runtime.SandboxRuntimePolicy; +import cn.lypi.tool.shell.HostExecutor; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.Optional; @@ -103,14 +107,43 @@ void commandPlanUsesSortedExplicitSourcesAndPosixSyntax() throws Exception { assertTrue(wrapped.contains(". '" + plan.snapshotFile() + "'"), wrapped); assertTrue(wrapped.indexOf(first.toString()) < wrapped.indexOf(second.toString()), wrapped); assertTrue(wrapped.contains("eval 'printf '"), wrapped); - assertTrue(wrapped.contains("pwd -P > '" + plan.cwdCaptureFile() + "'"), wrapped); + assertTrue(wrapped.contains("pwd -P >| '" + plan.cwdCaptureFile() + "'"), wrapped); assertFalse(wrapped.contains("source "), wrapped); - assertFalse(wrapped.contains(">|"), wrapped); assertEquals(List.of(plan.snapshotFile(), first, second), plan.readOnlyFiles()); assertEquals(List.of(plan.cwdCaptureFile()), plan.writableFiles()); } } + @Test + void snapshotCaptureOverridesNoclobberForPrecreatedFile() throws Exception { + ShellEnvironmentHarness harness = harness(); + Path workspace = Files.createDirectory(tempDir.resolve("workspace-noclobber-snapshot")); + Path bashEnv = Files.writeString(tempDir.resolve("enable-noclobber.sh"), "set -C\n"); + ShellEnvironmentHarness.SnapshotPlan plan = harness + .prepareSnapshot(workspace, "ses_noclobber", "bash") + .orElseThrow(); + + try (plan) { + ExecutionResult result = new HostExecutor().execute( + new ExecutionRequest( + plan.command(), + workspace, + Map.of("BASH_ENV", bashEnv.toString()), + Duration.ofSeconds(5), + SandboxRuntimePolicy.disabled() + ), + ignored -> { + }, + () -> false + ); + + assertEquals(0, result.exitCode(), result.stderr()); + harness.completeSnapshot(plan, result); + } + + assertTrue(harness.snapshotExists(workspace, "ses_noclobber", "bash")); + } + @Test void nonLoginCommandDoesNotUseOrSourceExistingSnapshot() throws Exception { ShellEnvironmentHarness harness = harness();