Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
186a44a
refactor(sandbox): unify workspace root to WorkspaceSpec.root
chcodex Jul 14, 2026
827ccd4
fix(harness): use dynamic workspace root in middleware prompt
chcodex Jul 14, 2026
88fc0e5
fix(harness): fix Javadoc reference to use WorkspaceSpec#getRoot()
chcodex Jul 14, 2026
d97a79e
fix(harness): close HarnessAgent in test to fix temp dir cleanup flake
chcodex Jul 14, 2026
e69d08d
test(harness): add coverage for sandbox workspace root and querySandbox
chcodex Jul 14, 2026
3d4d6d6
style(harness): fix spotless formatting in test files
chcodex Jul 14, 2026
8f092d7
fix(harness): add workspace cleanup in LocalFilesystemUserIsolationEx…
chcodex Jul 14, 2026
a905ae4
fix(harness): close WorkspaceManager in WorkspaceContextMiddlewareSan…
chcodex Jul 14, 2026
8213e99
refactor(filesystem): Remove workspaceRoot methods that are no longer…
chcodex Jul 15, 2026
f7da480
fix: resolve merge conflict in HarnessAgentSubagentStreamEventsTest
chcodex Jul 18, 2026
5e0ce9d
refactor: address AgentScopeJavaBot review suggestions
chcodex Jul 18, 2026
dea1e29
test: improve patch coverage to 60% for sandbox workspace root refact…
chcodex Jul 18, 2026
54c15d3
test: add coverage for delegate getWorkspaceRoot() and FilesystemSpec…
chcodex Jul 18, 2026
e6bf833
merge upstream/main and resolve conflicts
chcodex Jul 24, 2026
d0a3a37
merge upstream/main and resolve conflicts
chcodex Sep 25, 2026
4b1f2c9
fix(review): address oss-maintainer findings
chcodex Sep 26, 2026
4f79737
fix(review): order-independent legacy migration, bounded probe cache,…
chcodex Sep 26, 2026
ba68ece
fix(sandbox): evaluate AgentRun mount check against the defaulted wor…
chcodex Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class AgentRunFilesystemSpec extends SandboxFilesystemSpec {
private SandboxClient<?> client;
private final AgentRunSandboxClientOptions options = new AgentRunSandboxClientOptions();
private SandboxSnapshotSpec snapshotSpec = new NoopSnapshotSpec();
private WorkspaceSpec defaultWorkspaceSpec = new WorkspaceSpec();
private WorkspaceSpec defaultWorkspaceSpec = createDefaultWorkspaceSpec();

public AgentRunFilesystemSpec client(SandboxClient<?> client) {
this.client = client;
Expand Down Expand Up @@ -86,7 +86,11 @@ public AgentRunFilesystemSpec addOssMount(AgentRunOssMountConfig mount) {
}

public AgentRunFilesystemSpec workspaceRoot(String workspaceRoot) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

workspaceRoot(String) mutates defaultWorkspaceSpec directly. If a caller first supplies a WorkspaceSpec via workspaceSpec(WorkspaceSpec) and then calls workspaceRoot(String), the caller's spec object is mutated. Conversely, if workspaceRoot(String) is called first and workspaceSpec(WorkspaceSpec) second, the root set by workspaceRoot is silently discarded. Preserve the configured root when replacing defaultWorkspaceSpec, or copy the spec before mutating it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

workspaceRoot(String) mutates defaultWorkspaceSpec directly. If a caller first supplies a WorkspaceSpec via workspaceSpec(WorkspaceSpec) and then calls workspaceRoot(String), the caller's spec object is mutated. Conversely, if workspaceRoot(String) is called first and workspaceSpec(WorkspaceSpec) second, the root set by workspaceRoot is silently discarded. Preserve the configured root when replacing defaultWorkspaceSpec, or copy the spec before mutating it.

options.setWorkspaceRoot(workspaceRoot);
// Copy-on-write so the caller's WorkspaceSpec is never mutated and the two builders
// stay order-independent regardless of which is applied last.
WorkspaceSpec spec = defaultWorkspaceSpec.copy();
spec.setRoot(workspaceRoot);
this.defaultWorkspaceSpec = spec;
return this;
}

Expand All @@ -111,10 +115,17 @@ public AgentRunFilesystemSpec snapshotSpec(SandboxSnapshotSpec snapshotSpec) {
}

public AgentRunFilesystemSpec workspaceSpec(WorkspaceSpec workspaceSpec) {
this.defaultWorkspaceSpec = workspaceSpec;
this.defaultWorkspaceSpec =
workspaceSpec != null ? workspaceSpec.copy() : createDefaultWorkspaceSpec();
return this;
}

private static WorkspaceSpec createDefaultWorkspaceSpec() {
WorkspaceSpec spec = new WorkspaceSpec();
spec.setRoot(AgentRunSandboxState.DEFAULT_WORKSPACE_ROOT);
return spec;
}

@Override
protected SandboxClient<?> createClient() {
return client != null ? client : options.createClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ protected InputStream doPersistWorkspace() throws Exception {
// Persistence is handled by the NAS/OSS mount — nothing to archive.
return InputStream.nullInputStream();
}
String root = arState.getWorkspaceRoot();
String root = arState.getWorkspaceSpec().getRoot();
String cmd = "tar -cf - -C " + shellSingleQuote(root) + " . | base64 -w0";
AgentRunMcpChannel.ExecResult r = mcp.exec(cmd, null, TAR_TIMEOUT_SECONDS);
if (r.exitCode != 0) {
Expand All @@ -152,7 +152,7 @@ protected InputStream doPersistWorkspace() throws Exception {

@Override
protected void doHydrateWorkspace(InputStream archive) throws Exception {
String root = arState.getWorkspaceRoot();
String root = arState.getWorkspaceSpec().getRoot();
byte[] all = archive.readAllBytes();
if (all.length == 0) {
return;
Expand Down Expand Up @@ -189,7 +189,7 @@ protected void doHydrateWorkspace(InputStream archive) throws Exception {

@Override
protected void doSetupWorkspace() throws Exception {
mcp.exec("mkdir -p " + shellSingleQuote(arState.getWorkspaceRoot()), null, 30);
mcp.exec("mkdir -p " + shellSingleQuote(arState.getWorkspaceSpec().getRoot()), null, 30);
}

@Override
Expand All @@ -199,15 +199,15 @@ protected void doDestroyWorkspace() throws Exception {
return;
}
try {
mcp.exec("rm -rf " + shellSingleQuote(arState.getWorkspaceRoot()), null, 30);
mcp.exec("rm -rf " + shellSingleQuote(arState.getWorkspaceSpec().getRoot()), null, 30);
} catch (Exception e) {
// best-effort
}
}

@Override
protected String getWorkspaceRoot() {
return arState.getWorkspaceRoot();
public String getWorkspaceRoot() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] Removing public getWorkspaceRoot()/setWorkspaceRoot(String) from SandboxClientOptions and all concrete option/state classes is a source- and binary-incompatible change for downstream users of the extension artifacts (custom sandbox impls, Spring Boot starters, persisted state). Since changes to agentscope-* interfaces cascade to harness, distribution and all extensions, could this be done as a deprecation cycle instead — keep the old accessors delegating to workspaceSpec.getRoot() marked @Deprecated(forRemoval = true), remove them in the next major — and add a migration note to the changelog/docs? If the breaking change is intentional for 2.0, please call it out explicitly in the PR description so release notes cover it.

return arState.getWorkspaceSpec().getRoot();
}

private void ensureSandbox() throws Exception {
Expand Down Expand Up @@ -238,7 +238,7 @@ private static boolean isNotFound(Exception e) {
}

private String relativeOrAbsoluteCwd() {
String root = arState.getWorkspaceRoot();
String root = arState.getWorkspaceSpec().getRoot();
return root != null && !root.isBlank() ? root : null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,24 @@ public Sandbox create(
String sessionId = UUID.randomUUID().toString();
String sandboxId = deriveSandboxId(sessionId);

// Resolve the backend default once: the NAS/OSS mount check must run against the root
// the sandbox will actually use, otherwise a null/blank spec makes the check pass and
// doDestroyWorkspace() would rm -rf a NAS-backed workspace.
WorkspaceSpec resolvedSpec =
WorkspaceSpec.withDefaultRoot(
workspaceSpec, AgentRunSandboxState.DEFAULT_WORKSPACE_ROOT);

AgentRunSandboxState state = new AgentRunSandboxState();
state.setSessionId(sessionId);
state.setWorkspaceSpec(workspaceSpec);
state.setWorkspaceRoot(merged.getWorkspaceRoot());
state.setWorkspaceSpec(resolvedSpec);
state.setTemplateName(merged.getTemplateName());
state.setAccountId(merged.getAccountId());
state.setRegion(merged.getRegion());
state.setMcpServerUrl(merged.getMcpServerUrl());
state.setSandboxId(sandboxId);
state.setSandboxOwned(true);
state.setWorkspaceRootReady(false);
state.setWorkspaceOnNas(isWorkspaceUnderMounts(merged));
state.setWorkspaceOnNas(isWorkspaceUnderMounts(merged, resolvedSpec.getRoot()));

if (snapshotSpec != null) {
state.setSnapshot(snapshotSpec.build(sessionId));
Expand Down Expand Up @@ -143,8 +149,7 @@ private Sandbox build(AgentRunSandboxState state, AgentRunSandboxClientOptions m
return new AgentRunSandbox(state, merged, http, mcp);
}

private static boolean isWorkspaceUnderMounts(AgentRunSandboxClientOptions opt) {
String root = opt.getWorkspaceRoot();
private static boolean isWorkspaceUnderMounts(AgentRunSandboxClientOptions opt, String root) {
if (root == null || root.isBlank()) {
return false;
}
Expand Down Expand Up @@ -197,9 +202,6 @@ private AgentRunSandboxClientOptions merge(AgentRunSandboxClientOptions call) {
if (call.getOssMountConfigs() != null && !call.getOssMountConfigs().isEmpty()) {
o.setOssMountConfigs(call.getOssMountConfigs());
}
if (call.getWorkspaceRoot() != null) {
o.setWorkspaceRoot(call.getWorkspaceRoot());
}
if (call.getHttpClient() != null) {
o.setHttpClient(call.getHttpClient());
}
Expand All @@ -222,7 +224,6 @@ private static AgentRunSandboxClientOptions copy(AgentRunSandboxClientOptions sr
o.setSandboxIdleTimeoutSeconds(src.getSandboxIdleTimeoutSeconds());
o.setNasConfig(src.getNasConfig());
o.setOssMountConfigs(src.getOssMountConfigs());
o.setWorkspaceRoot(src.getWorkspaceRoot());
o.setHttpClient(src.getHttpClient());
o.setConnectTimeoutSeconds(src.getConnectTimeoutSeconds());
o.setReadTimeoutSeconds(src.getReadTimeoutSeconds());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ public class AgentRunSandboxClientOptions extends SandboxClientOptions {
private int sandboxIdleTimeoutSeconds = 1800;
private AgentRunNasMountConfig nasConfig;
private List<AgentRunOssMountConfig> ossMountConfigs = new ArrayList<>();
private String workspaceRoot = AgentRunSandboxState.DEFAULT_WORKSPACE_ROOT;
private int connectTimeoutSeconds = 30;
private int readTimeoutSeconds = 120;
private int maxRetries = 3;
Expand Down Expand Up @@ -238,16 +237,6 @@ public AgentRunSandboxClientOptions addOssMount(AgentRunOssMountConfig mount) {
return this;
}

public String getWorkspaceRoot() {
return workspaceRoot;
}

public AgentRunSandboxClientOptions setWorkspaceRoot(String workspaceRoot) {
this.workspaceRoot =
workspaceRoot != null ? workspaceRoot : AgentRunSandboxState.DEFAULT_WORKSPACE_ROOT;
return this;
}

public int getConnectTimeoutSeconds() {
return connectTimeoutSeconds;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ public class AgentRunSandboxState extends SandboxState {
public static final String DEFAULT_WORKSPACE_ROOT = "/home/agentscope/workspace";

private String sandboxId;
private String workspaceRoot = DEFAULT_WORKSPACE_ROOT;
private String templateName;
private String accountId;
private String region;
Expand All @@ -40,14 +39,6 @@ public void setSandboxId(String sandboxId) {
this.sandboxId = sandboxId;
}

public String getWorkspaceRoot() {
return workspaceRoot;
}

public void setWorkspaceRoot(String workspaceRoot) {
this.workspaceRoot = workspaceRoot != null ? workspaceRoot : DEFAULT_WORKSPACE_ROOT;
}

public String getTemplateName() {
return templateName;
}
Expand Down Expand Up @@ -89,7 +80,7 @@ public void setSandboxOwned(boolean sandboxOwned) {
}

/**
* Returns whether {@link #workspaceRoot} lives on a NAS or OSS persistent mount.
* Returns whether the workspace root configured via {@link io.agentscope.harness.agent.sandbox.WorkspaceSpec#getRoot()} lives on a NAS or OSS persistent mount.
*
* <p>When {@code true}, the workspace survives sandbox deletion and the adapter skips tar-based
* persistence; "resume" is achieved by recreating the sandbox with the same deterministic id
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright 2024-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.agentscope.extensions.sandbox.agentrun;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.agentscope.harness.agent.sandbox.WorkspaceSpec;
import org.junit.jupiter.api.Test;

class AgentRunFilesystemSpecTest {

@Test
void defaultWorkspaceSpec_hasAgentRunRoot() {
AgentRunFilesystemSpec spec = new AgentRunFilesystemSpec();
WorkspaceSpec ws = spec.workspaceSpec();
assertEquals(AgentRunSandboxState.DEFAULT_WORKSPACE_ROOT, ws.getRoot());
}

@Test
void workspaceSpec_doesNotMutateCallerObject() {
WorkspaceSpec caller = new WorkspaceSpec();
caller.setRoot("/caller/root");

new AgentRunFilesystemSpec().workspaceRoot("/explicit/root").workspaceSpec(caller);

assertEquals("/caller/root", caller.getRoot());
}

@Test
void workspaceRootAndWorkspaceSpec_areOrderIndependent() {
WorkspaceSpec caller = new WorkspaceSpec();
caller.setRoot("/caller/root");

// workspaceRoot applied last wins
WorkspaceSpec specLast =
new AgentRunFilesystemSpec()
.workspaceSpec(caller)
.workspaceRoot("/last")
.workspaceSpec();
// workspaceSpec applied last wins (its root)
WorkspaceSpec specFirst =
new AgentRunFilesystemSpec()
.workspaceRoot("/last")
.workspaceSpec(caller)
.workspaceSpec();

assertEquals("/last", specLast.getRoot());
assertEquals("/caller/root", specFirst.getRoot());
}

/**
* The mount check inside {@code AgentRunSandboxClient.create()} must run against the
* backend-defaulted root, not the raw spec: a NAS mount at the default AgentRun root has to
* be recognised so {@code doDestroyWorkspace()} does not rm -rf persistent data.
*/
@Test
void defaultRoot_isUnderAgentRunNasMount() {
String nasMount = AgentRunSandboxState.DEFAULT_WORKSPACE_ROOT;
WorkspaceSpec defaulted =
WorkspaceSpec.withDefaultRoot(null, AgentRunSandboxState.DEFAULT_WORKSPACE_ROOT);

assertEquals(nasMount, defaulted.getRoot());
assertTrue(defaulted.getRoot().startsWith(nasMount));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] Minor nit: assertTrue(defaulted.getRoot().startsWith(nasMount)) is vacuous right after assertEquals(nasMount, defaulted.getRoot()) (a string always starts with itself). The real regression this PR fixed lives in AgentRunSandboxClient.create() — the mount check now runs against resolvedSpec.getRoot(). If you want the test to guard that path, assert isWorkspaceUnderMounts(merged, resolvedSpec.getRoot()) (or the resulting state.isWorkspaceOnNas()) for a NAS mount configured at the backend default root. Non-blocking, feel free to fold it into a follow-up.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ private static AgentRunSandboxState state(String id, boolean owned) {
AgentRunSandboxState state = new AgentRunSandboxState();
state.setSandboxId(id);
state.setSandboxOwned(owned);
state.setWorkspaceRoot(AgentRunSandboxState.DEFAULT_WORKSPACE_ROOT);
WorkspaceSpec ws = new WorkspaceSpec();
ws.setRoot(AgentRunSandboxState.DEFAULT_WORKSPACE_ROOT);
state.setWorkspaceSpec(ws);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ void roundTripPreservesAllFields() {
AgentRunSandboxState state = new AgentRunSandboxState();
state.setSessionId("session-42");
state.setSandboxId("01KE8DAJ35JC8SKP9CNFRZ8CW7");
state.setWorkspaceRoot("/mnt/nas/workspace");
state.setTemplateName("agentscope-default");
state.setAccountId("123456789012");
state.setRegion("cn-hangzhou");
Expand All @@ -50,7 +49,6 @@ void roundTripPreservesAllFields() {
AgentRunSandboxState r = (AgentRunSandboxState) read;
Assertions.assertEquals("session-42", r.getSessionId());
Assertions.assertEquals("01KE8DAJ35JC8SKP9CNFRZ8CW7", r.getSandboxId());
Assertions.assertEquals("/mnt/nas/workspace", r.getWorkspaceRoot());
Assertions.assertEquals("agentscope-default", r.getTemplateName());
Assertions.assertEquals("123456789012", r.getAccountId());
Assertions.assertEquals("cn-hangzhou", r.getRegion());
Expand Down Expand Up @@ -78,7 +76,6 @@ void roundTripWithDefaultsKeepsTypeId() {
SandboxState read = client.deserializeState(json);
Assertions.assertInstanceOf(AgentRunSandboxState.class, read);
AgentRunSandboxState r = (AgentRunSandboxState) read;
Assertions.assertEquals(AgentRunSandboxState.DEFAULT_WORKSPACE_ROOT, r.getWorkspaceRoot());
Assertions.assertFalse(r.isWorkspaceOnNas());
Assertions.assertTrue(r.isSandboxOwned());
Assertions.assertFalse(r.isWorkspaceRootReady());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class DaytonaFilesystemSpec extends SandboxFilesystemSpec {
private SandboxClient<?> client;
private final DaytonaSandboxClientOptions options = new DaytonaSandboxClientOptions();
private SandboxSnapshotSpec snapshotSpec = new NoopSnapshotSpec();
private WorkspaceSpec defaultWorkspaceSpec = new WorkspaceSpec();
private WorkspaceSpec defaultWorkspaceSpec = createDefaultWorkspaceSpec();

public DaytonaFilesystemSpec client(SandboxClient<?> client) {
this.client = client;
Expand Down Expand Up @@ -76,7 +76,11 @@ public DaytonaFilesystemSpec disk(int diskGiB) {
}

public DaytonaFilesystemSpec workspaceRoot(String workspaceRoot) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

workspaceRoot(String) mutates defaultWorkspaceSpec directly. If a caller first supplies a WorkspaceSpec via workspaceSpec(WorkspaceSpec) and then calls workspaceRoot(String), the caller's spec object is mutated. Conversely, if workspaceRoot(String) is called first and workspaceSpec(WorkspaceSpec) second, the root set by workspaceRoot is silently discarded. Preserve the configured root when replacing defaultWorkspaceSpec, or copy the spec before mutating it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

workspaceRoot(String) mutates defaultWorkspaceSpec directly. If a caller first supplies a WorkspaceSpec via workspaceSpec(WorkspaceSpec) and then calls workspaceRoot(String), the caller's spec object is mutated. Conversely, if workspaceRoot(String) is called first and workspaceSpec(WorkspaceSpec) second, the root set by workspaceRoot is silently discarded. Preserve the configured root when replacing defaultWorkspaceSpec, or copy the spec before mutating it.

options.setWorkspaceRoot(workspaceRoot);
// Copy-on-write so the caller's WorkspaceSpec is never mutated and the two builders
// stay order-independent regardless of which is applied last.
WorkspaceSpec spec = defaultWorkspaceSpec.copy();
spec.setRoot(workspaceRoot);
this.defaultWorkspaceSpec = spec;
return this;
}

Expand All @@ -86,10 +90,17 @@ public DaytonaFilesystemSpec snapshotSpec(SandboxSnapshotSpec snapshotSpec) {
}

public DaytonaFilesystemSpec workspaceSpec(WorkspaceSpec workspaceSpec) {
this.defaultWorkspaceSpec = workspaceSpec;
this.defaultWorkspaceSpec =
workspaceSpec != null ? workspaceSpec.copy() : createDefaultWorkspaceSpec();
return this;
}

private static WorkspaceSpec createDefaultWorkspaceSpec() {
WorkspaceSpec spec = new WorkspaceSpec();
spec.setRoot(DaytonaSandboxState.DEFAULT_WORKSPACE_ROOT);
return spec;
}

@Override
protected SandboxClient<?> createClient() {
return client != null ? client : options.createClient();
Expand Down
Loading
Loading