diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/local/LocalFilesystemWithShell.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/local/LocalFilesystemWithShell.java index 4081f3020a..c13f062d97 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/local/LocalFilesystemWithShell.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/local/LocalFilesystemWithShell.java @@ -56,11 +56,10 @@ public class LocalFilesystemWithShell extends LocalFilesystem implements Abstrac private final Map env; /** - * Working directory passed to {@link ProcessBuilder#directory(java.io.File)} for shell - * commands. When {@code null}, falls back to {@link #getCwd()} (with per-call namespace - * prefix). Decouples shell {@code pwd} from the filesystem root so overlay-mode callers can - * keep filesystem operations rooted at the agent workspace while shell sees the user's - * project directory. + * Shell working directory used when the current call has no namespace. Namespaced calls run + * under {@link #getCwd()} with the per-call namespace prefix instead. Decouples the default + * shell {@code pwd} from the filesystem root so overlay-mode callers can keep filesystem + * operations rooted at the agent workspace while shell sees the user's project directory. */ private final Path shellCwd; @@ -195,8 +194,8 @@ public LocalFilesystemWithShell( * @param env environment variables for shell commands ({@code null} for empty) * @param inheritEnv whether to inherit the parent process's environment variables * @param namespaceFactory optional namespace factory for path scoping ({@code null} for none) - * @param shellCwd working directory for shell command execution; when {@code null}, falls - * back to {@code rootDir} (with namespace prefix when configured) + * @param shellCwd working directory for shell command execution when no namespace applies; + * when {@code null}, falls back to {@code rootDir} */ public LocalFilesystemWithShell( Path rootDir, @@ -222,7 +221,8 @@ public LocalFilesystemWithShell( /** * Most-complete constructor: filesystem operations follow {@code mode} and {@code pathPolicy} * (see {@link LocalFilesystem#LocalFilesystem(Path, LocalFsMode, PathPolicy, int, NamespaceFactory)}); - * shell commands run with {@code pwd = shellCwd} when set, otherwise the filesystem root. + * shell commands run inside the namespaced filesystem root when a namespace applies, or with + * {@code pwd = shellCwd} when set otherwise. * * @param rootDir filesystem root for relative-path operations * @param mode path-resolution policy ({@code null} treated as {@link LocalFsMode#UNRESTRICTED}) @@ -232,8 +232,8 @@ public LocalFilesystemWithShell( * @param env environment variables for shell commands ({@code null} for empty) * @param inheritEnv whether to inherit the parent process environment * @param namespaceFactory optional per-user/session namespace factory - * @param shellCwd shell {@code pwd}; {@code null} falls back to {@code rootDir} (with - * namespace prefix when configured) + * @param shellCwd shell {@code pwd} when no namespace applies; {@code null} falls back to + * {@code rootDir} */ public LocalFilesystemWithShell( Path rootDir, @@ -296,9 +296,9 @@ public String id() { } /** - * Returns the working directory configured for shell {@code execute()} calls, or {@code null} - * when shell falls back to the filesystem root (with namespace prefix). Used by upstream - * code that needs to expose the user-visible project directory in prompts or diagnostics. + * Returns the working directory configured for non-namespaced shell {@code execute()} calls, + * or {@code null} when shell falls back to the filesystem root. Used by upstream code that + * needs to expose the user-visible project directory in prompts or diagnostics. */ public Path getShellCwd() { return shellCwd; @@ -408,26 +408,25 @@ public ExecuteResponse execute( } private Path resolveExecuteCwd(RuntimeContext rc) { - if (shellCwd != null) { - return shellCwd; - } NamespaceFactory nsf = getNamespaceFactory(); - if (nsf == null) { - return getCwd(); - } - List ns = nsf.getNamespace(rc); - if (ns == null || ns.isEmpty()) { - return getCwd(); - } - Path namespaced = getCwd(); - for (String segment : ns) { - namespaced = namespaced.resolve(segment); - } - try { - Files.createDirectories(namespaced); - } catch (IOException e) { - log.warn("Failed to create namespace directory {}: {}", namespaced, e.getMessage()); + if (nsf != null) { + List ns = nsf.getNamespace(rc); + if (ns != null && !ns.isEmpty()) { + Path namespaced = getCwd(); + for (String segment : ns) { + namespaced = namespaced.resolve(segment); + } + try { + Files.createDirectories(namespaced); + } catch (IOException e) { + log.warn( + "Failed to create namespace directory {}: {}", + namespaced, + e.getMessage()); + } + return namespaced; + } } - return namespaced; + return shellCwd != null ? shellCwd : getCwd(); } } diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/ProjectAwareOverlayTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/ProjectAwareOverlayTest.java index 69cdcf7837..46a965c221 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/ProjectAwareOverlayTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/ProjectAwareOverlayTest.java @@ -20,12 +20,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.IsolationScope; import io.agentscope.harness.agent.filesystem.local.LocalFilesystem; import io.agentscope.harness.agent.filesystem.local.LocalFilesystemWithShell; import io.agentscope.harness.agent.filesystem.model.EditResult; +import io.agentscope.harness.agent.filesystem.model.ExecuteResponse; import io.agentscope.harness.agent.filesystem.model.FileUploadResponse; import io.agentscope.harness.agent.filesystem.model.ReadResult; import io.agentscope.harness.agent.filesystem.model.WriteResult; +import io.agentscope.harness.agent.filesystem.remote.store.NamespaceFactory; import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem; import io.agentscope.harness.agent.workspace.LocalFsMode; import io.agentscope.harness.agent.workspace.PathPolicy; @@ -240,4 +243,88 @@ void execute_delegatesToShellBackend() { assertTrue(r.output().contains("hello")); assertEquals(0, r.exitCode()); } + + @Test + void execute_sessionIsolation_startsInNamespacedWorkspace() throws Exception { + assertShellWorkingDirectory(IsolationScope.SESSION, "session-1"); + } + + @Test + void execute_userIsolation_startsInNamespacedWorkspace() throws Exception { + assertShellWorkingDirectory(IsolationScope.USER, "alice"); + } + + @Test + void execute_globalIsolation_startsInProjectDirectory() throws Exception { + assertShellWorkingDirectory(IsolationScope.GLOBAL, null); + } + + @Test + void execute_withoutNamespaceOrProjectDirectory_startsInWorkspace() throws Exception { + LocalFilesystemWithShell shell = new LocalFilesystemWithShell(workspace); + + ExecuteResponse response = + shell.execute(RuntimeContext.empty(), printWorkingDirectoryCommand(), 10); + + assertEquals(0, response.exitCode()); + assertEquals( + workspace.toRealPath().toString(), + Path.of(response.output().strip()).toRealPath().toString()); + } + + @Test + void execute_nullNamespace_fallsBackToProjectDirectory() throws Exception { + LocalFilesystemWithShell shell = createShell(context -> null, project); + + ExecuteResponse response = + shell.execute(RuntimeContext.empty(), printWorkingDirectoryCommand(), 10); + + assertEquals(0, response.exitCode()); + assertEquals( + project.toRealPath().toString(), + Path.of(response.output().strip()).toRealPath().toString()); + } + + @Test + void execute_namespaceDirectoryCreationFailure_returnsError() throws Exception { + Files.writeString(workspace.resolve("blocked"), "not a directory"); + LocalFilesystemWithShell shell = createShell(context -> List.of("blocked"), project); + + ExecuteResponse response = shell.execute(RuntimeContext.empty(), "echo hello", 10); + + assertEquals(1, response.exitCode()); + assertTrue(response.output().startsWith("Error executing command (IOException):")); + } + + private void assertShellWorkingDirectory(IsolationScope isolationScope, String namespace) + throws Exception { + LocalFilesystemWithShell shell = createShell(isolationScope.toNamespaceFactory(), project); + RuntimeContext context = + RuntimeContext.builder().userId("alice").sessionId("session-1").build(); + + ExecuteResponse response = shell.execute(context, printWorkingDirectoryCommand(), 10); + + assertEquals(0, response.exitCode()); + Path expected = namespace == null ? project : workspace.resolve(namespace); + assertEquals( + expected.toRealPath().toString(), + Path.of(response.output().strip()).toRealPath().toString()); + } + + private LocalFilesystemWithShell createShell(NamespaceFactory namespaceFactory, Path shellCwd) { + return new LocalFilesystemWithShell( + workspace, + LocalFsMode.ROOTED, + PathPolicy.of(project, workspace), + 120, + 100_000, + null, + false, + namespaceFactory, + shellCwd); + } + + private static String printWorkingDirectoryCommand() { + return System.getProperty("os.name").startsWith("Windows") ? "cd" : "pwd"; + } }