diff --git a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bEnvdProcessClient.java b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bEnvdProcessClient.java index af8f8d0391..479b994d27 100644 --- a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bEnvdProcessClient.java +++ b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bEnvdProcessClient.java @@ -30,6 +30,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.InterruptedIOException; +import java.net.URLEncoder; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; @@ -38,10 +39,13 @@ import java.util.Objects; import java.util.concurrent.TimeUnit; import okhttp3.MediaType; +import okhttp3.MultipartBody; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Minimal Connect client for envd {@code process.Process/Start} (server streaming), sufficient @@ -49,8 +53,12 @@ */ final class E2bEnvdProcessClient { + private static final Logger log = LoggerFactory.getLogger(E2bEnvdProcessClient.class); + private static final MediaType CONNECT_JSON = MediaType.get("application/connect+json"); private static final MediaType CONNECT_PROTO = MediaType.get("application/connect+proto"); + private static final MediaType APPLICATION_OCTET_STREAM = + MediaType.get("application/octet-stream"); private static final int ENVD_PORT = 49983; private static final int OUTPUT_TRUNCATE_BYTES = 512 * 1024; private static final ObjectMapper JSON = new ObjectMapper(); @@ -126,26 +134,14 @@ private ShellCapture runShellCapture( String host = envdHost(state); String url = host + "/process.Process/Start"; byte[] envelope = encodeStartRequestEnvelope(shellCommand, cwd); - Request.Builder rb = - new Request.Builder() - .url(url) - .post(RequestBody.create(envelope, connectMediaType())) - .addHeader("Connect-Protocol-Version", "1") - .addHeader("User-Agent", "agentscope-java-e2b") - .addHeader("E2b-Sandbox-Id", state.getSandboxId()) - .addHeader("E2b-Sandbox-Port", Integer.toString(ENVD_PORT)) - .addHeader("Authorization", basicAuthUser(opt.getRunUser())); - if (state.getEnvdAccessToken() != null && !state.getEnvdAccessToken().isBlank()) { - rb.addHeader("X-Access-Token", state.getEnvdAccessToken()); - } - Request req = rb.build(); + Request req = buildEnvdRequest(url, envelope, connectMediaType(), state).build(); ByteArrayOutputStream stdout = new ByteArrayOutputStream(); ByteArrayOutputStream stderr = new ByteArrayOutputStream(); - int exit = Integer.MIN_VALUE; + int exit; try (Response res = callClient.newCall(req).execute()) { if (!res.isSuccessful()) { - String err = res.body() != null ? res.body().string() : ""; + String err = res.body().string(); throw new SandboxException.SandboxRuntimeException( SandboxErrorCode.WORKSPACE_START_ERROR, "envd Start failed HTTP " + res.code() + ": " + err); @@ -178,7 +174,9 @@ private int drainStartStream( if (lenB.length < 4) { break; } - int len = ByteBuffer.wrap(lenB).order(ByteOrder.BIG_ENDIAN).getInt() & 0x7FFFFFFF; + // Connect protocol: Message-Length is 4-byte unsigned integer, big-endian + // https://connectrpc.com/docs/protocol/#streaming-rpcs + int len = ByteBuffer.wrap(lenB).order(ByteOrder.BIG_ENDIAN).getInt(); if (len < 0 || len > 64 * 1024 * 1024) { throw new IOException("Invalid connect frame length: " + len); } @@ -186,12 +184,37 @@ private int drainStartStream( if (data.length < len) { break; } - if (flags != 0x00) { + // Connect protocol: flags bit 1 = end_stream — final envelope carries EndStreamMessage + // JSON + // https://connectrpc.com/docs/protocol/#streaming-rpcs + if ((flags & 0x02) != 0) { + EndStreamMessage endMsg = parseEndStreamResponse(data); + if (endMsg.hasError()) { + log.debug( + "drainStartStream: endStream error code={} msg={}", + endMsg.code(), + endMsg.message()); + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_START_ERROR, + "Process start failed: " + endMsg.code() + ": " + endMsg.message()); + } + log.debug("drainStartStream: endStream ok, exit={}", exit); + break; + } + // Connect protocol: flags bit 0 = compressed + // https://connectrpc.com/docs/protocol/#streaming-rpcs + if ((flags & 0x01) != 0) { + throw new IOException("Compressed connect frames not supported"); + } + // Connect protocol: flags bits 2-7 reserved for future extensions + // https://connectrpc.com/docs/protocol/#streaming-rpcs + if ((flags & 0xFC) != 0) { continue; } try { DynamicMessage sr = parseStartResponseFrame(data); if (!sr.hasField(srEventF)) { + log.debug("drainStartStream: frame with no event"); continue; } DynamicMessage pe = (DynamicMessage) sr.getField(srEventF); @@ -202,15 +225,27 @@ private int drainStartStream( } if (pe.hasField(peEndF)) { DynamicMessage end = (DynamicMessage) pe.getField(peEndF); - Descriptors.FieldDescriptor ec = - end.getDescriptorForType().findFieldByName("exit_code"); - if (end.hasField(ec)) { - Object v = end.getField(ec); - exit = v instanceof Integer ? (Integer) v : ((Long) v).intValue(); + Descriptors.Descriptor endDesc = end.getDescriptorForType(); + // Proto3: scalar sint32 defaults to 0, omitted from wire when 0. + // getField() returns proto3 default (0) when absent; hasField() returns false. + // https://protobuf.dev/programming-guides/proto3/#default + Descriptors.FieldDescriptor ecF = endDesc.findFieldByName("exit_code"); + Object v = end.getField(ecF); + exit = v instanceof Integer ? (Integer) v : ((Long) v).intValue(); + // EndEvent.error — populated when cmd.Wait() returns ExitError + // (non-zero exit code or signal). Go handler: ProcessEvent_EndEvent.Error = + // errMsg + // https://github.com/e2b-dev/infra/blob/main/packages/envd/internal/services/process/handler/handler.go + // Python SDK: error=event.event.end.error + // https://github.com/e2b-dev/e2b/blob/main/packages/python-sdk/e2b/sandbox_sync/commands/command_handle.py#L115 + Descriptors.FieldDescriptor errF = endDesc.findFieldByName("error"); + if (end.hasField(errF)) { + stderr.write( + String.valueOf(end.getField(errF)) + .getBytes(StandardCharsets.UTF_8)); } } - } catch (IOException e) { - continue; + } catch (IOException ignored) { } } return exit; @@ -231,6 +266,34 @@ private static void appendDataStream( } } + // Connect protocol EndStreamMessage JSON: {"error":{"code":"...","message":"..."}} + // Python SDK ServerStreamParser also parses end_stream envelope as JSON error: + // if EnvelopeFlags.end_stream in flags: + // data = json.loads(data) + // if "error" in data: raise make_error(data["error"]) + // return # no error → stop iteration + // https://connectrpc.com/docs/protocol/#error-end-stream + // https://github.com/e2b-dev/e2b/blob/main/packages/python-sdk/e2b_connect/client.py#L230-L238 + private static EndStreamMessage parseEndStreamResponse(byte[] data) throws IOException { + JsonNode root = JSON.readTree(data); + if (root == null || root.isNull()) { + return new EndStreamMessage(null, null); + } + JsonNode errorNode = root.path("error"); + if (errorNode.isMissingNode() || errorNode.isNull()) { + return new EndStreamMessage(null, null); + } + String code = errorNode.path("code").asText(null); + String message = errorNode.path("message").asText(null); + return new EndStreamMessage(code, message); + } + + private record EndStreamMessage(String code, String message) { + boolean hasError() { + return code != null; + } + } + DynamicMessage buildStartRequest(String shellCommand, String cwd) { Descriptors.Descriptor pcDesc = fileDescriptor.findMessageTypeByName("ProcessConfig"); DynamicMessage.Builder pcb = DynamicMessage.newBuilder(pcDesc); @@ -283,6 +346,101 @@ static byte[] encodeUnaryEnvelope(byte[] msg) { return out; } + // ---- filesystem helpers (REST API) ---- + + /** + * Upload a file to the sandbox filesystem via the E2B Filesystem REST API. Creates parent + * directories automatically and overwrites existing files. + * + * @param state sandbox state + * @param remotePath absolute path in the sandbox + * @param data file content + */ + public void uploadFile(E2bSandboxState state, String remotePath, byte[] data) throws Exception { + String host = filesystemHost(state); + String url = host + "/files?path=" + URLEncoder.encode(remotePath, StandardCharsets.UTF_8); + + RequestBody fileBody = RequestBody.create(data, APPLICATION_OCTET_STREAM); + RequestBody multipart = + new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", filenameFromPath(remotePath), fileBody) + .build(); + Request req = buildFilesystemRequest(url, state).post(multipart).build(); + + try (Response res = http.newCall(req).execute()) { + if (!res.isSuccessful()) { + String err = res.body().string(); + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_ARCHIVE_WRITE_ERROR, + "envd upload file failed HTTP " + res.code() + ": " + err); + } + } + } + + /** + * Download a file from the sandbox filesystem via the E2B Filesystem REST API. + * + * @param state sandbox state + * @param remotePath absolute path in the sandbox + * @return file content + */ + public byte[] downloadFile(E2bSandboxState state, String remotePath) throws Exception { + String host = filesystemHost(state); + String url = host + "/files?path=" + URLEncoder.encode(remotePath, StandardCharsets.UTF_8); + Request req = buildFilesystemRequest(url, state).get().build(); + try (Response res = http.newCall(req).execute()) { + if (!res.isSuccessful()) { + String err = res.body().string(); + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_ARCHIVE_READ_ERROR, + "envd download file failed HTTP " + res.code() + ": " + err); + } + return res.body().bytes(); + } + } + + private static String filenameFromPath(String path) { + int idx = path.lastIndexOf('/'); + return idx >= 0 ? path.substring(idx + 1) : path; + } + + private Request.Builder buildEnvdRequest( + String url, byte[] body, MediaType mediaType, E2bSandboxState state) { + Request.Builder rb = + new Request.Builder() + .url(url) + .post(RequestBody.create(body, mediaType)) + .addHeader("Connect-Protocol-Version", "1") + .addHeader("User-Agent", "agentscope-java-e2b") + .addHeader("E2b-Sandbox-Id", state.getSandboxId()) + .addHeader("E2b-Sandbox-Port", Integer.toString(ENVD_PORT)) + .addHeader("Authorization", basicAuthUser(opt.getRunUser())); + if (state.getEnvdAccessToken() != null && !state.getEnvdAccessToken().isBlank()) { + rb.addHeader("X-Access-Token", state.getEnvdAccessToken()); + } + return rb; + } + + private Request.Builder buildFilesystemRequest(String url, E2bSandboxState state) { + Request.Builder rb = + new Request.Builder() + .url(url) + .addHeader("User-Agent", "agentscope-java-e2b") + .addHeader("E2b-Sandbox-Id", state.getSandboxId()) + .addHeader("E2b-Sandbox-Port", Integer.toString(ENVD_PORT)) + .addHeader("Authorization", basicAuthUser(opt.getRunUser())); + if (state.getEnvdAccessToken() != null && !state.getEnvdAccessToken().isBlank()) { + rb.addHeader("X-Access-Token", state.getEnvdAccessToken()); + } + return rb; + } + + private static String filesystemHost(E2bSandboxState state) { + String dom = state.getSandboxDomain(); + return "https://sandbox." + (dom != null && !dom.isBlank() ? dom : "e2b.app"); + } + private E2bCodec codec() { return opt.getCodec() != null ? opt.getCodec() : E2bCodec.PROTO; } @@ -338,6 +496,12 @@ private DynamicMessage parseJsonStartResponse(byte[] data) throws IOException { JsonNode endNode = eventNode.path("end"); if (!endNode.isMissingNode() && !endNode.isNull()) { + // Always set end on event when node exists, even if sub-fields are empty + // (proto3 default 0 for sint32, empty string for proto3_optional). + // Also parse "error" field for signal-killed processes. + // Python SDK reads exit_code/error directly without presence check: + // exit_code=event.event.end.exit_code, error=event.event.end.error + // https://github.com/e2b-dev/e2b/blob/main/packages/python-sdk/e2b/sandbox_sync/commands/command_handle.py#L115 Descriptors.Descriptor endDesc = processEventDesc.findNestedTypeByName("EndEvent"); DynamicMessage.Builder endBuilder = DynamicMessage.newBuilder(endDesc); Descriptors.FieldDescriptor exitCodeField = endDesc.findFieldByName("exit_code"); @@ -345,9 +509,17 @@ private DynamicMessage parseJsonStartResponse(byte[] data) throws IOException { if (exitCodeNode.canConvertToInt()) { endBuilder.setField(exitCodeField, exitCodeNode.intValue()); } - if (!endBuilder.getAllFields().isEmpty()) { - event.setField(processEventDesc.findFieldByName("end"), endBuilder.build()); + Descriptors.FieldDescriptor errorField = endDesc.findFieldByName("error"); + // EndEvent.error — populated when cmd.Wait() returns ExitError + // (non-zero exit code or signal). Go handler: Error = errMsg. + // https://github.com/e2b-dev/infra/blob/main/packages/envd/internal/services/process/handler/handler.go + // Python SDK: error=event.event.end.error + // https://github.com/e2b-dev/e2b/blob/main/packages/python-sdk/e2b/sandbox_sync/commands/command_handle.py#L115 + JsonNode errorNode = endNode.path("error"); + if (errorNode.isTextual()) { + endBuilder.setField(errorField, errorNode.textValue()); } + event.setField(processEventDesc.findFieldByName("end"), endBuilder.build()); } if (!event.getAllFields().isEmpty()) { diff --git a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bFilesystemSpec.java b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bFilesystemSpec.java index 9da2ee8327..e7817bfcf8 100644 --- a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bFilesystemSpec.java +++ b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bFilesystemSpec.java @@ -124,4 +124,9 @@ protected SandboxSnapshotSpec snapshotSpec() { protected WorkspaceSpec workspaceSpec() { return defaultWorkspaceSpec; } + + @Override + public E2bSandboxFilesystem createFilesystem() { + return new E2bSandboxFilesystem(); + } } diff --git a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bSandbox.java b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bSandbox.java index 17ce991e5a..0a523baa01 100644 --- a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bSandbox.java +++ b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bSandbox.java @@ -16,7 +16,6 @@ package io.agentscope.extensions.sandbox.e2b; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.harness.agent.sandbox.AbstractBaseSandbox; import io.agentscope.harness.agent.sandbox.ExecResult; @@ -25,7 +24,7 @@ import io.agentscope.harness.agent.sandbox.WorkspaceMountSupport; import java.io.ByteArrayInputStream; import java.io.InputStream; -import java.util.Base64; +import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -42,7 +41,6 @@ public class E2bSandbox extends AbstractBaseSandbox { private static final Logger log = LoggerFactory.getLogger(E2bSandbox.class); private static final int TAR_TIMEOUT_SECONDS = 300; - private static final int B64_CHUNK = 4000; private final E2bSandboxState e2bState; private final E2bSandboxClientOptions opt; @@ -97,17 +95,42 @@ protected InputStream doPersistWorkspace() throws Exception { return new ByteArrayInputStream(E2bSnapshotRefs.encodeSnapshotId(id)); } String root = e2bState.getWorkspaceRoot(); + String tarPath = "/tmp/agentscope-ws-" + UUID.randomUUID() + ".tar"; StringBuilder script = new StringBuilder("tar "); for (String ex : WorkspaceMountSupport.tarExcludeArgsForBindMounts(e2bState.getWorkspaceSpec())) { script.append(ex).append(' '); } - script.append("-cf - -C ").append(shellSingleQuote(root)).append(" ."); - String cmd = script.toString(); - byte[] tar = envd().runShellBinaryStdout(e2bState, root, cmd, TAR_TIMEOUT_SECONDS); + script.append("-cf ") + .append(tarPath) + .append(" -C ") + .append(shellSingleQuote(root)) + .append(" ."); + envd().runShell(e2bState, root, script.toString(), TAR_TIMEOUT_SECONDS); + byte[] tar = downloadFile(tarPath); return new ByteArrayInputStream(tar); } + /** + * Upload a file to the sandbox filesystem via the E2B Filesystem REST API. + * + * @param remotePath absolute path in the sandbox + * @param data file content + */ + public void uploadFile(String remotePath, byte[] data) throws Exception { + envd().uploadFile(e2bState, remotePath, data); + } + + /** + * Download a file from the sandbox filesystem via the E2B Filesystem REST API. + * + * @param remotePath absolute path in the sandbox + * @return file content + */ + public byte[] downloadFile(String remotePath) throws Exception { + return envd().downloadFile(e2bState, remotePath); + } + @Override protected void doHydrateWorkspace(InputStream archive) throws Exception { byte[] all = archive.readAllBytes(); @@ -116,29 +139,16 @@ protected void doHydrateWorkspace(InputStream archive) throws Exception { restoreSandboxFromSnapshotTemplate(nativeId); return; } + String tarPath = "/tmp/agentscope-ws-" + UUID.randomUUID() + ".tar"; + envd().uploadFile(e2bState, tarPath, all); String root = e2bState.getWorkspaceRoot(); - String b64 = Base64.getEncoder().encodeToString(all); - envd().runShell(e2bState, root, "rm -f /tmp/agentscope-ws.b64", 30); - ObjectMapper om = new ObjectMapper(); - for (int i = 0; i < b64.length(); i += B64_CHUNK) { - String chunk = b64.substring(i, Math.min(b64.length(), i + B64_CHUNK)); - String lit = om.writeValueAsString(chunk); - String py = - "import pathlib; pathlib.Path('/tmp/agentscope-ws.b64').open('a').write(" - + lit - + ")"; - envd().runShell(e2bState, root, "python3 -c " + shellSingleQuote(py), 120); + StringBuilder script = new StringBuilder("tar "); + for (String ex : + WorkspaceMountSupport.tarExcludeArgsForBindMounts(e2bState.getWorkspaceSpec())) { + script.append(ex).append(' '); } - String pyFin = - "import base64,pathlib,subprocess; d=" - + om.writeValueAsString(root) - + "; raw=base64.standard_b64decode(pathlib.Path('/tmp/agentscope-ws.b64').read_text());" - + " subprocess.run(['tar','xf','-','-C',d],input=raw,check=True)"; - envd().runShell( - e2bState, - root, - "python3 -c " + shellSingleQuote(pyFin), - TAR_TIMEOUT_SECONDS); + script.append("xf ").append(tarPath).append(" -C ").append(shellSingleQuote(root)); + envd().runShell(e2bState, root, script.toString(), TAR_TIMEOUT_SECONDS); } @Override diff --git a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bSandboxFilesystem.java b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bSandboxFilesystem.java new file mode 100644 index 0000000000..7298c14b7f --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/main/java/io/agentscope/extensions/sandbox/e2b/E2bSandboxFilesystem.java @@ -0,0 +1,77 @@ +/* + * 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.e2b; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse; +import io.agentscope.harness.agent.filesystem.model.FileUploadResponse; +import io.agentscope.harness.agent.filesystem.sandbox.SandboxBackedFilesystem; +import io.agentscope.harness.agent.sandbox.Sandbox; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An E2B-optimized filesystem that uses the Filesystem REST API for upload/download, avoiding + * base64 overhead and the 512 KB output truncation of shell-based transfers. + */ +public class E2bSandboxFilesystem extends SandboxBackedFilesystem { + + private static final Logger log = LoggerFactory.getLogger(E2bSandboxFilesystem.class); + + @Override + public List uploadFiles( + RuntimeContext runtimeContext, List> files) { + Sandbox s = getSandbox(); + if (!(s instanceof E2bSandbox e2b)) { + return super.uploadFiles(runtimeContext, files); + } + List results = new ArrayList<>(files.size()); + for (Map.Entry file : files) { + String path = file.getKey(); + try { + e2b.uploadFile(path, file.getValue()); + results.add(FileUploadResponse.success(path)); + } catch (Exception e) { + log.warn("[e2b-sandbox-fs] upload failed for path: {}", path, e); + results.add(FileUploadResponse.fail(path, e.getMessage())); + } + } + return results; + } + + @Override + public List downloadFiles( + RuntimeContext runtimeContext, List paths) { + Sandbox s = getSandbox(); + if (!(s instanceof E2bSandbox e2b)) { + return super.downloadFiles(runtimeContext, paths); + } + List results = new ArrayList<>(paths.size()); + for (String path : paths) { + try { + byte[] content = e2b.downloadFile(path); + results.add(FileDownloadResponse.success(path, content)); + } catch (Exception e) { + log.warn("[e2b-sandbox-fs] download failed for path: {}", path, e); + results.add(FileDownloadResponse.fail(path, e.getMessage())); + } + } + return results; + } +} diff --git a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/test/java/io/agentscope/extensions/sandbox/e2b/E2bEnvdProcessClientIntegrationTest.java b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/test/java/io/agentscope/extensions/sandbox/e2b/E2bEnvdProcessClientIntegrationTest.java new file mode 100644 index 0000000000..5b4efe4b51 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/test/java/io/agentscope/extensions/sandbox/e2b/E2bEnvdProcessClientIntegrationTest.java @@ -0,0 +1,315 @@ +/* + * 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.e2b; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import io.agentscope.harness.agent.sandbox.SandboxException; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@EnabledIfSystemProperty(named = "E2B_API_KEY", matches = ".+") +class E2bEnvdProcessClientIntegrationTest { + + private static final Logger log = + LoggerFactory.getLogger(E2bEnvdProcessClientIntegrationTest.class); + + @Test + void captureRawConnectFrames() throws Exception { + String apiKey = System.getProperty("E2B_API_KEY"); + String templateId = System.getProperty("E2B_TEMPLATE_ID", "code-interpreter-v1"); + + E2bSandboxClientOptions opt = new E2bSandboxClientOptions(); + opt.setApiKey(apiKey); + opt.setTemplateId(templateId); + opt.setReadTimeoutSeconds(120); + opt.setConnectTimeoutSeconds(30); + + E2bPlatformHttp platform = new E2bPlatformHttp(opt); + E2bSandboxState state = new E2bSandboxState(); + state.setTemplateId(templateId); + + JsonNode sandboxNode = platform.createSandbox(templateId, 120); + platform.applySandboxFields(state, sandboxNode); + state.setSandboxDomain(opt.getDomain()); + + log.info( + "sandboxId={} domain={} envdAccessToken={}", + state.getSandboxId(), + state.getSandboxDomain(), + state.getEnvdAccessToken()); + + try { + E2bEnvdProcessClient client = new E2bEnvdProcessClient(opt); + byte[] envelope = client.encodeStartRequestEnvelope("exit 42", "/home/user"); + String host = "https://49983-" + state.getSandboxId() + "." + state.getSandboxDomain(); + log.info("envd host: {}", host); + + OkHttpClient rawHttp = + new OkHttpClient.Builder() + .readTimeout(120, TimeUnit.SECONDS) + .connectTimeout(30, TimeUnit.SECONDS) + .build(); + + Request.Builder rb = + new Request.Builder() + .url(host + "/process.Process/Start") + .post(RequestBody.create(envelope, client.connectMediaType())) + .addHeader("Connect-Protocol-Version", "1") + .addHeader("User-Agent", "agentscope-java-e2b-it") + .addHeader("E2b-Sandbox-Id", state.getSandboxId()) + .addHeader("E2b-Sandbox-Port", "49983") + .addHeader( + "Authorization", + "Basic " + + Base64.getEncoder() + .encodeToString( + "user:" + .getBytes( + StandardCharsets + .UTF_8))); + if (state.getEnvdAccessToken() != null && !state.getEnvdAccessToken().isBlank()) { + rb.addHeader("X-Access-Token", state.getEnvdAccessToken()); + } + + try (Response res = rawHttp.newCall(rb.build()).execute()) { + System.out.println("\n=== HTTP Response ==="); + System.out.println("Status: " + res.code() + " " + res.message()); + System.out.println("Headers:"); + for (int i = 0; i < res.headers().size(); i++) { + System.out.println( + " " + res.headers().name(i) + ": " + res.headers().value(i)); + } + + assertTrue(res.isSuccessful(), "HTTP " + res.code()); + + int frameCount = 0; + int endStreamFlags = -1; + int endStreamPayloadLen = -1; + + InputStream in = res.body().byteStream(); + ByteArrayOutputStream rawCapture = new ByteArrayOutputStream(); + + System.out.println("\n=== Frames ==="); + while (true) { + int flags = in.read(); + if (flags == -1) { + System.out.println("Stream ended (read returned -1)"); + break; + } + rawCapture.write(flags); + + byte[] lenB = in.readNBytes(4); + if (lenB.length < 4) { + System.out.println("Incomplete length field, exiting"); + break; + } + rawCapture.write(lenB); + + int len = ByteBuffer.wrap(lenB).order(ByteOrder.BIG_ENDIAN).getInt(); + if (len < 0 || len > 64 * 1024 * 1024) { + System.out.println("Invalid length: " + len); + break; + } + + byte[] data = in.readNBytes(len); + if (data.length < len) { + System.out.println("Incomplete frame data, exiting"); + break; + } + rawCapture.write(data); + + frameCount++; + // Connect protocol: bit 0 (0x01) = compressed, bit 1 (0x02) = EndStream + // https://connectrpc.com/docs/protocol/#streaming-rpcs + boolean isEndStream = (flags & 0x02) != 0; + boolean isCompressed = (flags & 0x01) != 0; + boolean hasReserved = (flags & 0xFC) != 0; + + String payloadPreview; + if (isEndStream && len <= 4096) { + payloadPreview = new String(data, StandardCharsets.UTF_8); + } else if (len > 0) { + String raw = + new String( + data, + 0, + Math.min(data.length, 160), + StandardCharsets.UTF_8); + payloadPreview = raw + (len > 160 ? "..." : "") + " [" + len + " bytes]"; + } else { + payloadPreview = "[empty]"; + } + + System.out.printf("Frame #%d: flags=0x%02X len=%d%n", frameCount, flags, len); + System.out.printf( + " bit1(0x02/EndStream)=%b bit0(0x01/Compressed)=%b reserved=%b%n", + isEndStream, isCompressed, hasReserved); + System.out.println(" payload: " + payloadPreview); + System.out.println(); + + if (isEndStream) { + endStreamFlags = flags; + endStreamPayloadLen = len; + } + } + + System.out.println("=== RESULT ==="); + System.out.println("Total frames: " + frameCount); + System.out.println("Raw bytes captured: " + rawCapture.size()); + + if (endStreamFlags >= 0) { + System.out.println( + "EndStream frame flags: 0x" + Integer.toHexString(endStreamFlags)); + System.out.println("EndStream frame payload length: " + endStreamPayloadLen); + System.out.println( + "Bit 1 (0x02/EndStream) set: " + ((endStreamFlags & 0x02) != 0)); + System.out.println( + "Bit 0 (0x01/Compressed) set: " + ((endStreamFlags & 0x01) != 0)); + + // Connect protocol: bit 1 (0x02) = EndStreamResponse + assertEquals( + 0x02, + endStreamFlags & 0x02, + "Per Connect protocol, bit 1 (0x02) must be set on EndStream"); + assertEquals( + 0, + endStreamFlags & 0x01, + "Bit 0 (0x01) is Compressed flag, should not be set"); + } else { + System.out.println("WARNING: No EndStream frame found"); + } + } + } finally { + try { + platform.killSandbox(state.getSandboxId()); + log.info("sandbox {} killed", state.getSandboxId()); + } catch (Exception e) { + log.warn("failed to kill sandbox {}: {}", state.getSandboxId(), e.getMessage()); + } + } + } + + @Test + void signalKillViaRunShell() throws Exception { + String apiKey = System.getProperty("E2B_API_KEY"); + String templateId = System.getProperty("E2B_TEMPLATE_ID", "code-interpreter-v1"); + + E2bSandboxClientOptions opt = new E2bSandboxClientOptions(); + opt.setApiKey(apiKey); + opt.setTemplateId(templateId); + // need enough read timeout for sleep 120 + processing + opt.setReadTimeoutSeconds(180); + opt.setConnectTimeoutSeconds(30); + + E2bPlatformHttp platform = new E2bPlatformHttp(opt); + E2bSandboxState state = new E2bSandboxState(); + state.setTemplateId(templateId); + + JsonNode sandboxNode = platform.createSandbox(templateId, 120); + platform.applySandboxFields(state, sandboxNode); + state.setSandboxDomain(opt.getDomain()); + + log.info( + "sandboxId={} domain={} envdAccessToken={}", + state.getSandboxId(), + state.getSandboxDomain(), + state.getEnvdAccessToken()); + + try { + E2bEnvdProcessClient client = new E2bEnvdProcessClient(opt); + + // Thread A: run sleep 120 — blocks until killed + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future future = + executor.submit( + () -> { + client.runShell(state, "/home/user", "sleep 120", 0); + return null; + }); + + // Give sleep a moment to start, then kill it with SIGKILL. + // Kill the child sleep process (not bash) — bash exits reporting + // 128+9=137 to Go handler. Use killall by exact name. + Thread.sleep(3000); + try { + client.runShell(state, "/home/user", "killall -9 -w sleep 2>/dev/null", 10); + } catch (Exception e) { + log.warn("killall failed: {}", e.getMessage()); + try { + client.runShell( + state, + "/home/user", + "kill -9 $(ps h -C sleep -o pid 2>/dev/null) 2>/dev/null", + 10); + } catch (Exception e2) { + log.warn("ps kill also failed: {}", e2.getMessage()); + } + } + + // Thread A should terminate with ExecException (process killed by signal) + ExecutionException ex = + assertThrows(ExecutionException.class, () -> future.get(180, TimeUnit.SECONDS)); + Throwable cause = ex.getCause(); + assertTrue( + cause instanceof SandboxException, + "Expected SandboxException, got: " + + cause.getClass().getName() + + ": " + + cause.getMessage()); + SandboxException.ExecException execEx = (SandboxException.ExecException) cause; + // Verify the process was killed by signal: EndEvent.error captured to + // stderr (e.g. "signal: killed"), and exit code is non-zero (-1 is + // expected from Go's ProcessState.ExitCode() when !Exited()). + assertTrue( + execEx.getExitCode() != 0, + "Exit code should be non-zero after SIGKILL, got " + execEx.getExitCode()); + assertTrue( + execEx.getStderr().contains("signal: killed"), + "stderr should contain 'signal: killed' from EndEvent.error, got: '" + + execEx.getStderr() + + "'"); + executor.shutdownNow(); + } finally { + try { + platform.killSandbox(state.getSandboxId()); + log.info("sandbox {} killed", state.getSandboxId()); + } catch (Exception e) { + log.warn("failed to kill sandbox {}: {}", state.getSandboxId(), e.getMessage()); + } + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/test/java/io/agentscope/extensions/sandbox/e2b/E2bEnvdProcessClientTest.java b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/test/java/io/agentscope/extensions/sandbox/e2b/E2bEnvdProcessClientTest.java index 902c790bf8..4b672750ea 100644 --- a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/test/java/io/agentscope/extensions/sandbox/e2b/E2bEnvdProcessClientTest.java +++ b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/test/java/io/agentscope/extensions/sandbox/e2b/E2bEnvdProcessClientTest.java @@ -17,13 +17,17 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.google.protobuf.ByteString; import com.google.protobuf.Descriptors; import com.google.protobuf.DynamicMessage; +import io.agentscope.harness.agent.sandbox.SandboxErrorCode; +import io.agentscope.harness.agent.sandbox.SandboxException; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Method; import java.nio.ByteBuffer; @@ -171,6 +175,133 @@ private static DynamicMessage dataResponse( .build(); } + // Connect protocol: final envelope (flags bit 1) is EndStreamMessage JSON. + // Python SDK ServerStreamParser: if "error" in data → raise make_error(data["error"]) + // https://connectrpc.com/docs/protocol/#error-end-stream + // https://github.com/e2b-dev/e2b/blob/main/packages/python-sdk/e2b_connect/client.py#L230-L238 + @Test + void endStreamResponseWithErrorThrows() throws Exception { + E2bEnvdProcessClient client = new E2bEnvdProcessClient(options(E2bCodec.JSON)); + String errorJson = "{\"error\":{\"code\":\"internal\",\"message\":\"process crash\"}}"; + byte[] frame = endStreamFrame(errorJson); + + SandboxException.SandboxRuntimeException ex = + assertThrows( + SandboxException.SandboxRuntimeException.class, + () -> + drainStartStream( + client, + frame, + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + assertEquals(SandboxErrorCode.WORKSPACE_START_ERROR, ex.getErrorCode()); + assertTrue(ex.getMessage().contains("internal")); + assertTrue(ex.getMessage().contains("process crash")); + } + + // Connect protocol: successful EndStreamResponse is "{}" (no error, no metadata). + // Python SDK: end_stream without "error" → return (stop iteration). + @Test + void endStreamResponseWithoutErrorBreaksCleanly() throws Exception { + E2bEnvdProcessClient client = new E2bEnvdProcessClient(options(E2bCodec.JSON)); + byte[] frame = + concatFrames( + connectFrame(responseJson(base64("hello"), null, null)), + endStreamFrame("{}")); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + int exit = drainStartStream(client, frame, stdout, stderr); + + assertEquals(Integer.MIN_VALUE, exit); + assertEquals("hello", stdout.toString(StandardCharsets.UTF_8)); + assertEquals("", stderr.toString(StandardCharsets.UTF_8)); + } + + // Proto3: sint32 exit_code defaults to 0, omitted from JSON when 0. + // getField() returns proto3 default (0) when absent — hasField() returns false. + // Python SDK reads event.event.end.exit_code directly (always returns 0 by default). + // https://protobuf.dev/programming-guides/proto3/#default + @Test + void exitCodeZeroDefaultWhenOmitted() throws Exception { + E2bEnvdProcessClient client = new E2bEnvdProcessClient(options(E2bCodec.JSON)); + byte[] frame = concatFrames(connectFrame(endEventJson(null, null)), endStreamFrame("{}")); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + int exit = drainStartStream(client, frame, stdout, stderr); + + assertEquals(0, exit); + assertEquals("", stdout.toString(StandardCharsets.UTF_8)); + assertEquals("", stderr.toString(StandardCharsets.UTF_8)); + } + + @Test + void exitCodeNonZero() throws Exception { + E2bEnvdProcessClient client = new E2bEnvdProcessClient(options(E2bCodec.JSON)); + byte[] frame = concatFrames(connectFrame(endEventJson(42, null)), endStreamFrame("{}")); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + int exit = drainStartStream(client, frame, stdout, stderr); + + assertEquals(42, exit); + assertEquals("", stdout.toString(StandardCharsets.UTF_8)); + assertEquals("", stderr.toString(StandardCharsets.UTF_8)); + } + + // EndEvent.error — populated when cmd.Wait() returns ExitError + // (non-zero exit code or signal). Go handler: Error = errMsg. + // https://github.com/e2b-dev/infra/blob/main/packages/envd/internal/services/process/handler/handler.go + // Python SDK: CommandResult(error=event.event.end.error) + // https://github.com/e2b-dev/e2b/blob/main/packages/python-sdk/e2b/sandbox_sync/commands/command_handle.py#L115 + @Test + void endEventErrorWrittenToStderr() throws Exception { + E2bEnvdProcessClient client = new E2bEnvdProcessClient(options(E2bCodec.JSON)); + byte[] frame = + concatFrames(connectFrame(endEventJson(null, "signal: 9")), endStreamFrame("{}")); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + int exit = drainStartStream(client, frame, stdout, stderr); + + assertEquals(0, exit); + assertEquals("", stdout.toString(StandardCharsets.UTF_8)); + assertTrue(stderr.toString(StandardCharsets.UTF_8).contains("signal: 9")); + } + + // Connect protocol: flags bit 0 = compressed (not supported by this client). + // https://connectrpc.com/docs/protocol/#streaming-rpcs + @Test + void compressedFrameThrowsIOException() throws Exception { + E2bEnvdProcessClient client = new E2bEnvdProcessClient(options(E2bCodec.JSON)); + byte[] frame = envelope(0x01, "{}".getBytes(StandardCharsets.UTF_8)); + + IOException ex = + assertThrows( + IOException.class, + () -> + drainStartStream( + client, + frame, + new ByteArrayOutputStream(), + new ByteArrayOutputStream())); + assertTrue(ex.getMessage().contains("Compressed")); + } + + // Connect protocol: flags bits 2-7 reserved — implementations must skip unless understood. + // https://connectrpc.com/docs/protocol/#streaming-rpcs + @Test + void reservedFlagsFrameIsSkipped() throws Exception { + E2bEnvdProcessClient client = new E2bEnvdProcessClient(options(E2bCodec.JSON)); + byte[] frame = + concatFrames( + envelope(0x04, "ignored".getBytes(StandardCharsets.UTF_8)), + connectFrame(endEventJson(3, null)), + endStreamFrame("{}")); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + int exit = drainStartStream(client, frame, stdout, stderr); + + assertEquals(3, exit); + } + private static int drainStartStream( E2bEnvdProcessClient client, byte[] connectFrame, @@ -184,11 +315,40 @@ private static int drainStartStream( ByteArrayOutputStream.class, ByteArrayOutputStream.class); method.setAccessible(true); - return (int) method.invoke(client, new ByteArrayInputStream(connectFrame), stdout, stderr); + try { + return (int) + method.invoke(client, new ByteArrayInputStream(connectFrame), stdout, stderr); + } catch (java.lang.reflect.InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof Exception) { + throw (Exception) cause; + } + throw e; + } + } + + private static byte[] envelope(int flags, byte[] payload) { + byte[] out = new byte[5 + payload.length]; + out[0] = (byte) flags; + ByteBuffer.wrap(out, 1, 4).order(ByteOrder.BIG_ENDIAN).putInt(payload.length); + System.arraycopy(payload, 0, out, 5, payload.length); + return out; } private static byte[] connectFrame(String json) { - return E2bEnvdProcessClient.encodeUnaryEnvelope(json.getBytes(StandardCharsets.UTF_8)); + return envelope(0x00, json.getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] endStreamFrame(String json) { + return envelope(0x02, json.getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] concatFrames(byte[]... frames) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] frame : frames) { + out.writeBytes(frame); + } + return out.toByteArray(); } private static byte[] connectFrames(String... jsons) { @@ -229,6 +389,23 @@ private static String responseJson(String stdout, String stderr, Integer exitCod return json.toString(); } + private static String endEventJson(Integer exitCode, String error) { + StringBuilder json = new StringBuilder("{\"event\":{\"end\":{"); + boolean needComma = false; + if (exitCode != null) { + json.append("\"exitCode\":").append(exitCode); + needComma = true; + } + if (error != null) { + if (needComma) { + json.append(','); + } + json.append("\"error\":\"").append(error).append("\""); + } + json.append("}}}"); + return json.toString(); + } + private static String base64(String value) { return Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8)); } diff --git a/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/test/java/io/agentscope/extensions/sandbox/e2b/E2bSandboxFilesystemTest.java b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/test/java/io/agentscope/extensions/sandbox/e2b/E2bSandboxFilesystemTest.java new file mode 100644 index 0000000000..26f5227225 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-e2b/src/test/java/io/agentscope/extensions/sandbox/e2b/E2bSandboxFilesystemTest.java @@ -0,0 +1,143 @@ +/* + * 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.e2b; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse; +import io.agentscope.harness.agent.filesystem.model.FileUploadResponse; +import io.agentscope.harness.agent.sandbox.ExecResult; +import io.agentscope.harness.agent.sandbox.Sandbox; +import java.util.AbstractMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class E2bSandboxFilesystemTest { + + private static final RuntimeContext RT = RuntimeContext.empty(); + + @Test + void uploadFiles_delegatesToE2bSandbox() throws Exception { + E2bSandbox sandbox = mock(E2bSandbox.class); + E2bSandboxFilesystem fs = new E2bSandboxFilesystem(); + fs.setSandbox(sandbox); + + List> files = + List.of( + new AbstractMap.SimpleEntry<>("/tmp/a.txt", "hello".getBytes()), + new AbstractMap.SimpleEntry<>("/tmp/b.txt", "world".getBytes())); + + List results = fs.uploadFiles(RT, files); + + verify(sandbox).uploadFile("/tmp/a.txt", "hello".getBytes()); + verify(sandbox).uploadFile("/tmp/b.txt", "world".getBytes()); + assertEquals(2, results.size()); + assertTrue(results.get(0).isSuccess()); + assertTrue(results.get(1).isSuccess()); + } + + @Test + void uploadFiles_returnsFailureOnException() throws Exception { + E2bSandbox sandbox = mock(E2bSandbox.class); + doThrow(new RuntimeException("upload failed")) + .when(sandbox) + .uploadFile("/tmp/a.txt", "data".getBytes()); + E2bSandboxFilesystem fs = new E2bSandboxFilesystem(); + fs.setSandbox(sandbox); + + List> files = + List.of(new AbstractMap.SimpleEntry<>("/tmp/a.txt", "data".getBytes())); + + List results = fs.uploadFiles(RT, files); + + assertEquals(1, results.size()); + assertFalse(results.get(0).isSuccess()); + assertEquals("upload failed", results.get(0).error()); + } + + @Test + void downloadFiles_delegatesToE2bSandbox() throws Exception { + E2bSandbox sandbox = mock(E2bSandbox.class); + when(sandbox.downloadFile("/tmp/a.txt")).thenReturn("hello".getBytes()); + when(sandbox.downloadFile("/tmp/b.txt")).thenReturn("world".getBytes()); + E2bSandboxFilesystem fs = new E2bSandboxFilesystem(); + fs.setSandbox(sandbox); + + List results = + fs.downloadFiles(RT, List.of("/tmp/a.txt", "/tmp/b.txt")); + + verify(sandbox).downloadFile("/tmp/a.txt"); + verify(sandbox).downloadFile("/tmp/b.txt"); + assertEquals(2, results.size()); + assertArrayEquals("hello".getBytes(), results.get(0).content()); + assertArrayEquals("world".getBytes(), results.get(1).content()); + } + + @Test + void downloadFiles_returnsFailureOnException() throws Exception { + E2bSandbox sandbox = mock(E2bSandbox.class); + when(sandbox.downloadFile("/tmp/a.txt")).thenThrow(new RuntimeException("not found")); + E2bSandboxFilesystem fs = new E2bSandboxFilesystem(); + fs.setSandbox(sandbox); + + List results = fs.downloadFiles(RT, List.of("/tmp/a.txt")); + + assertEquals(1, results.size()); + assertFalse(results.get(0).isSuccess()); + assertEquals("not found", results.get(0).error()); + } + + @Test + void uploadFiles_fallsBackToSuperWhenNotE2bSandbox() throws Exception { + Sandbox sandbox = mock(Sandbox.class); + when(sandbox.exec(any(), any(), any())).thenReturn(new ExecResult(0, "", "", false)); + E2bSandboxFilesystem fs = new E2bSandboxFilesystem(); + fs.setSandbox(sandbox); + + List> files = + List.of(new AbstractMap.SimpleEntry<>("/tmp/a.txt", "data".getBytes())); + + List results = fs.uploadFiles(RT, files); + + assertEquals(1, results.size()); + assertTrue(results.get(0).isSuccess()); + } + + @Test + void downloadFiles_fallsBackToSuperWhenNotE2bSandbox() throws Exception { + Sandbox sandbox = mock(Sandbox.class); + when(sandbox.exec(any(), any(), any())) + .thenReturn(new ExecResult(0, "ZGF0YQ==", "", false)); + E2bSandboxFilesystem fs = new E2bSandboxFilesystem(); + fs.setSandbox(sandbox); + + List results = fs.downloadFiles(RT, List.of("/tmp/a.txt")); + + assertEquals(1, results.size()); + assertTrue(results.get(0).isSuccess()); + assertArrayEquals("data".getBytes(), results.get(0).content()); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java index d669184cb9..8b811961c6 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java @@ -2047,7 +2047,7 @@ public HarnessAgent build() { SandboxContext defaultSandboxContext = null; SandboxBackedFilesystem capturedSandboxFs = null; if (sandboxFilesystemSpec != null) { - capturedSandboxFs = new SandboxBackedFilesystem(); + capturedSandboxFs = sandboxFilesystemSpec.createFilesystem(); filesystem = capturedSandboxFs; defaultSandboxContext = sandboxFilesystemSpec.toSandboxContext(resolvedWorkspace); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/SandboxFilesystemSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/SandboxFilesystemSpec.java index 205107951f..524cdec583 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/SandboxFilesystemSpec.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/SandboxFilesystemSpec.java @@ -16,6 +16,7 @@ package io.agentscope.harness.agent.filesystem.spec; import io.agentscope.harness.agent.IsolationScope; +import io.agentscope.harness.agent.filesystem.sandbox.SandboxBackedFilesystem; import io.agentscope.harness.agent.sandbox.SandboxClient; import io.agentscope.harness.agent.sandbox.SandboxClientOptions; import io.agentscope.harness.agent.sandbox.SandboxContext; @@ -55,6 +56,18 @@ public abstract class SandboxFilesystemSpec { protected abstract WorkspaceSpec workspaceSpec(); + /** + * Create a sandbox-backed filesystem for this spec. + * + *

Subclasses may override to provide a custom filesystem implementation (e.g. one that uses + * native binary I/O instead of shell-based base64 transfers). + * + * @return a new sandbox-backed filesystem + */ + public SandboxBackedFilesystem createFilesystem() { + return new SandboxBackedFilesystem(); + } + public SandboxFilesystemSpec isolationScope(IsolationScope scope) { this.isolationScope = scope; return this;