Skip to content

feat: add OpenSandbox sandbox provider - #2655

Open
dargoner wants to merge 15 commits into
agentscope-ai:mainfrom
dargoner:feat/opensandbox-adapter-pr
Open

dargoner wants to merge 15 commits into
agentscope-ai:mainfrom
dargoner:feat/opensandbox-adapter-pr

Conversation

@dargoner

@dargoner dargoner commented Aug 10, 2026 •

Copy link
Copy Markdown

AgentScope-Java Version

2.0.3-SNAPSHOT

Description

Add OpenSandbox as a standalone Harness sandbox extension.

  • Integrate the official OpenSandbox Java SDK and register the extension in the dependency and distribution BOMs.
  • Implement sandbox creation, reconnect, start/stop/shutdown, command execution, native file transfer, workspace persistence/hydration, and state serialization.
  • Recreate a missing remote sandbox only when the SDK reports an explicit not-found response; other connection failures are propagated.
  • Store the workspace root in the Harness WorkspaceSpec contract and expose it through OpenSandbox#getWorkspaceRoot().
  • Keep the real-service smoke test opt-in: it runs only when both OPEN_SANDBOX_ENDPOINT and OPEN_SANDBOX_API_KEY are configured.
  • Document configuration and usage in the English and Chinese Harness sandbox guides.

The branch has been synchronized with the current upstream main. Additional regression coverage exercises blank sandbox IDs, lifecycle idempotency and failure composition, bind-mount archive exclusions, temporary archive cleanup, option validation, SDK output normalization, and lazy official-client construction.

Testing

  • OpenSandbox targeted suite: 47 tests passed, 0 failed, 1 opt-in real-service test skipped.
  • mvn -B -T1 -pl agentscope-extensions/agentscope-extensions-sandbox/agentscope-extensions-sandbox-opensandbox -am verify -DskipTests: 6/6 reactor modules successful, including Spotless, compilation, Javadoc, and packaging.

Checklist

  • Code has been formatted with mvn spotless:check
  • OpenSandbox unit and serialization tests are passing
  • Javadoc comments are complete and follow project conventions
  • Related English and Chinese documentation has been updated
  • Code is ready for review

@CLAassistant

CLAassistant commented Aug 10, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@codecov

codecov Bot commented Aug 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.77324% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...sions/sandbox/opensandbox/OpenSandboxEndpoint.java 94.11% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@dargoner
dargoner force-pushed the feat/opensandbox-adapter-pr branch 2 times, most recently from c127cf2 to df5b985 Compare August 10, 2026 10:54
@dargoner
dargoner force-pushed the feat/opensandbox-adapter-pr branch from df5b985 to 92ef949 Compare August 10, 2026 10:57
@AgentScopeJavaBot AgentScopeJavaBot added enhancement New feature or request area/build Build, CI, BOM, distribution area/docs Documentation area/extensions agentscope-extensions (general) labels Aug 11, 2026

@AgentScopeJavaBot AgentScopeJavaBot left a comment

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.

🤖 AI Review

This PR introduces a new OpenSandbox sandbox provider for the AgentScope Java SDK, enabling users to create and manage sandboxes through OpenSandbox Cloud's REST API. The implementation is well-structured with comprehensive test coverage, clean separation of concerns, and good documentation. The provider supports core sandbox lifecycle operations (create, start, stop, delete) and file operations, with proper error handling and timeout management.

Key strengths:

  • Complete implementation following existing provider patterns (similar to E2B, Daytona)
  • Comprehensive unit and integration tests with realistic mock scenarios
  • Clean separation between API client, provider, state, and filesystem classes
  • Good Javadoc documentation with usage examples

Areas for improvement:

  • The OpenSandboxApiException class is defined but never actually used in the codebase (dead code)
  • The retry logic in OpenSandboxApi.createSandbox() has a subtle bug where the retry loop condition is inverted
  • The integration test OpenSandboxProviderIT makes real HTTP calls to localhost which will fail in CI environments
  • The OpenSandboxSandboxState doesn't implement getWorkspaceRoot() which is needed by the harness runtime

(inline comments could not be attached — line numbers fell outside PR hunks. See archived report.)

@243006306

Copy link
Copy Markdown

有人审核下这个PR吗,想用这个功能

@jujn

jujn commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

有人审核下这个PR吗,想用这个功能

可以本地打包帮试用下有无问题,暂时抽不出人力来审

覆盖空沙箱标识、幂等关闭、临时归档清理、挂载排除、参数校验及官方 SDK 空输出等边界场景。
@dargoner

dargoner commented Sep 6, 2026

Copy link
Copy Markdown
Author

Addressed the review by checking each item against the current implementation and updating the branch to the latest upstream main.

  • The referenced OpenSandboxApiException, OpenSandboxApi#createSandbox(), and OpenSandboxSandboxState symbols are not present in this PR. The implementation uses OpenSandbox, OpenSandboxSdk, and OpenSandboxState, so there is no unused API exception or inverted retry loop to remove/fix.
  • Reconnection behavior is covered explicitly: a missing sandbox is recreated only after OpenSandboxSdk#isNotFound() returns true; generic connection failures are propagated without creating another sandbox.
  • OpenSandboxIntegrationTest is an opt-in real-service smoke test. It is skipped unless both OPEN_SANDBOX_ENDPOINT and OPEN_SANDBOX_API_KEY are supplied, so the default CI run does not connect to localhost.
  • The workspace root follows the Harness contract: it is stored in SandboxState.workspaceSpec.root, and OpenSandbox#getWorkspaceRoot() returns that value. Adding a second root field to OpenSandboxState would create two sources of truth.

I also added coverage for lifecycle, configuration, archive, and SDK edge cases. The previous Ubuntu CI failure was in JsonSessionDefaultLocationTest while closing the Harness test extension, outside this module; synchronizing the latest upstream main brings in the subsequent Harness test-cleanup changes.

Local verification after the update:

  • OpenSandbox suite: 47 passed, 0 failed, 1 opt-in integration test skipped.
  • OpenSandbox reactor verify with tests skipped: 6/6 modules built successfully, including formatting, compilation, Javadoc, and packaging.

@oss-maintainer oss-maintainer left a comment

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.

Summary

New agentscope-extensions-sandbox-opensandbox module: an OpenSandbox provider for the harness sandbox layer (client + options + state + filesystem spec + Jackson module), with 47 unit tests and a documented integration test, wired into docs/v2 and the BOM/distribution poms. The structure follows the existing E2B/Kubernetes providers closely and the exec/file paths are consistently shell-quoted, so I did not find an injection issue. Two things deserve a decision before merge: the deterministic /tmp archive name used by persist/hydrate, and the unconditional field overwrite in OpenSandboxClientOptions.merge(). Not approving — a new sandbox backend at this size wants maintainer eyes on the lifecycle/ownership semantics as well as the items below.

Findings

  • [Warning] OpenSandbox.java:246 — temp persist/hydrate archive name derives from session.hashCode() only: collisions across sessions, plus /tmp is world-readable on a shared sandbox host
  • [Warning] OpenSandboxClient.java:129 — merge() copies endpoint (and every other field) from the call options unconditionally, while apiKey is null-guarded; a call options object built from the default http://localhost:8080 silently overrides a production default
  • [Info] OpenSandbox.java:141 / :64 / OfficialOpenSandboxSdk.java:125 / OpenSandboxEndpoint.java:30 — unused doDestroyWorkspace() on non-owned sandboxes, SDK handle closed in stop() rather than shutdown(), hardcoded truncated=false, and uncaught IllegalArgumentException from URI.create

Suggestions

For the temp path, mixing a random nonce into the name is enough (/tmp/agentscope-<op>-<sessionId>-<ThreadLocalRandom hex>.tar), and it also removes the cross-session collision class entirely. For merge(), either treat blank/default values as unset or document that call-level options must be complete; a null-guard like the one already used for apiKey would make the two fields behave alike.

Existing @AgentScopeJavaBot review on this PR lists findings (an unused OpenSandboxApiException, an inverted retry loop, an integration test that hits live HTTP) that do not match the current file set or hunk line numbers — I could not reproduce any of them against head 40547011, so I have not repeated them here.


Automated review by github-manager-bot

return "/tmp/agentscope-"
+ operation
+ "-"
+ Integer.toHexString(session.hashCode())

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.

Predictable temp archive path risks cross-session collisions

The tempArchive() method uses Integer.toHexString(session.hashCode()) to generate temp file names. String hashCode is deterministic and collision-prone—two concurrent sessions with the same hashCode will write to the same temp file path (/tmp/agentscope-persist-<hash>.tar), causing data corruption during persist/hydrate operations.

Impact: In multi-tenant or high-concurrency scenarios, one session could read another session's workspace archive, leading to data leakage or corruption.

Suggested fix: Use a random UUID or combine sessionId with a random nonce:

private String tempArchive(String operation) {
    String session = state.getSessionId() == null ? "unknown" : state.getSessionId();
    String random = Integer.toHexString(System.nanoTime() ^ session.hashCode());
    return "/tmp/agentscope-" + operation + "-" + random + ".tar";
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in efef9e9. tempArchive(...) now uses UUID.randomUUID() for every operation. persistUsesFreshTemporaryArchivePath verifies that two persist operations do not reuse the same path, removing the predictable-name collision class.

if (call == null) {
return merged;
}
merged.setEndpoint(call.getEndpoint());

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.

merge() unconditionally overwrites endpoint, losing configured defaults

The merge() method always calls merged.setEndpoint(call.getEndpoint()) without checking if the caller explicitly set it. Since OpenSandboxClientOptions initializes endpoint to "http://localhost:8080" by default, a caller who only sets apiKey will inadvertently reset the endpoint to localhost, even if the client was configured with a production endpoint.

Impact: Callers must set all fields explicitly or risk silent misconfiguration. This is especially dangerous for endpoint and apiKey—the two most critical connection parameters.

Suggested fix: Only override fields that were explicitly set by the caller. Consider using a builder pattern or tracking which fields were set:

if (call.isEndpointExplicitlySet()) {
    merged.setEndpoint(call.getEndpoint());
}

Alternatively, document that callers must provide complete options or pass null to use defaults.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in efef9e9. OpenSandboxClientOptions now tracks explicitly set fields, and merge() copies only those fields. A call-level options object built from class defaults no longer overwrites the client configured endpoint, image, timeouts, or proxy flag. callOptionsOverrideOnlyExplicitValues covers this behavior.

}

@Override
protected void doDestroyWorkspace() throws Exception {

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.

doDestroyWorkspace() is defined but never called

The doDestroyWorkspace() method (line 141-143) executes rm -rf <workspaceRoot>, but it's never invoked from shutdown() or any other lifecycle method. For owned sandboxes, sdk.kill() destroys the entire container, making explicit cleanup redundant. For non-owned sandboxes (where isSandboxOwned() is false), the workspace directory persists after shutdown.

Impact: Minor—this matches the E2B pattern where doDestroyWorkspace() is also not called from shutdown(). However, if the intent is to clean up non-owned sandboxes, consider calling doDestroyWorkspace() before closeHandle() in shutdown() when !sandboxOwned.

Suggested action: Clarify the contract in a comment or remove the method if it's intentionally unused.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No code change. doDestroyWorkspace() is required by AbstractBaseSandbox and is intentionally not called from shutdown(), because shutdown() must not delete an externally managed workspace. Docker, Kubernetes, and E2B follow the same ownership boundary. Owned OpenSandbox instances are destroyed through sdk.kill(), while the protected method remains available for explicit cleanup and testing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Follow-up in 370a9b2: the ownership boundary is now documented on doDestroyWorkspace().

execution == null || execution.getExitCode() == null
? -1
: execution.getExitCode();
return new ExecResult(exitCode, stdout, stderr, false);

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.

truncated flag is always false, even when commands are killed by timeout

The exec() method hardcodes truncated=false when constructing ExecResult. If the SDK kills a command due to the timeout (e.g., exit code 124 for timeout command, or 137 for SIGKILL), the result doesn't indicate that output may be incomplete.

Impact: Callers cannot distinguish between a command that exited cleanly with exit code 0 and one that was killed mid-execution but happened to return a non-zero exit code. This could lead to incorrect assumptions about command completion.

Suggested fix: Detect timeout-killed commands and set truncated=true:

boolean truncated = exitCode == 124 || exitCode == 137;
return new ExecResult(exitCode, stdout, stderr, truncated);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No code change. ExecResult.truncated means the captured output exceeded the capture limit, not that the process timed out; that is its record contract. Exit codes 124 and 137 are not reliable timeout signals because user commands can return them directly. This adapter does not swallow SDK timeout failures, and setting truncated for those exit codes would conflate two different states.

try {
super.stop();
} finally {
closeHandle();

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.

stop() closes the SDK handle, forcing reconnect on next start()

The stop() method calls closeHandle() (line 64), which closes the SDK connection. A subsequent start() call will trigger ensureSandbox(), which reconnects to the existing sandbox. This works correctly but incurs an extra reconnect cycle.

Impact: Performance overhead in stop-then-start scenarios (e.g., snapshot persistence). The E2B implementation keeps the handle open during stop() and only closes it during shutdown().

Suggested action: Consider whether closing the handle in stop() is necessary. If the intent is to release resources, document this behavior. Otherwise, keep the handle open and only close it in shutdown().

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No code change. Closing the SDK handle in stop() is deliberate. stop() is the lifecycle boundary used for call-scoped and external sandboxes; retaining the handle would keep SDK connection resources alive after the call. It does not destroy the sandbox, and start() reconnects to the existing sandbox. The reconnect cost is accepted for deterministic connection cleanup.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Follow-up in 370a9b2: the reconnect/resource-release intent is now documented on stop().

if (raw.isEmpty()) {
throw new IllegalArgumentException("endpoint must not be blank");
}
URI uri = URI.create(raw.contains("://") ? raw : "http://" + raw);

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.

URI.create() can throw uncaught IllegalArgumentException on malformed input

The parse() method calls URI.create(raw.contains("://") ? raw : "http://" + raw) without catching IllegalArgumentException. If the input contains non-ASCII characters or invalid URI syntax, URI.create() throws an exception with a generic message like "Illegal character in path", bypassing the user-friendly error at line 37.

Impact: Minor—callers still get an IllegalArgumentException, but the error message is less helpful. For example, parse("http://example.com:8080/path with spaces") throws "Illegal character in path at index 28" instead of "Invalid OpenSandbox endpoint: ...".

Suggested fix: Wrap URI.create() in a try-catch:

URI uri;
try {
    uri = URI.create(raw.contains("://") ? raw : "http://" + raw);
} catch (IllegalArgumentException e) {
    throw new IllegalArgumentException("Invalid OpenSandbox endpoint: " + value, e);
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in efef9e9. OpenSandboxEndpoint.parse() now catches the IllegalArgumentException from URI.create() and rethrows Invalid OpenSandbox endpoint: <value> with the original exception as cause. malformedEndpointUsesFriendlyValidationMessage covers malformed path input.

为持久化临时归档加入随机 UUID,避免会话哈希碰撞。按显式设置字段合并调用级配置,防止默认 endpoint 覆盖客户端配置。统一包装 URI 解析异常并补充回归测试。
@dargoner

Copy link
Copy Markdown
Author

Addressed the review in efef9e9.

Fixed:

  • temporary persist/hydrate archive names now use a per-operation UUID
  • call-level options now only override fields that were explicitly set
  • malformed endpoint URIs now retain the user-facing validation message and original cause

Kept intentionally:

  • doDestroyWorkspace() is required by AbstractBaseSandbox but is not called by shutdown(), matching the external-sandbox ownership contract used by the other backends
  • ExecResult.truncated describes capture truncation, not process timeout
  • stop() closes the SDK handle to release connection resources without destroying the sandbox

Verification:

  • OpenSandbox unit suite: 45 passed, 0 failed
  • OpenSandbox reactor verify -DskipTests: 8/8 modules succeeded

Inline replies follow on each thread.

说明 stop 关闭的是 SDK 连接而非沙箱本身,并说明 doDestroyWorkspace 仅供显式清理,shutdown 遵守托管沙箱所有权边界。
@dargoner

Copy link
Copy Markdown
Author

@oss-maintainer Follow-up: CI is now fully green on 370a9b2c (build (ubuntu-latest), build (windows-latest), validate, Check License, Check Module Sync, and codecov/patch). The earlier Windows failure was on the previous head and was an unrelated JdkHttpTransportTest timing failure; it did not reproduce on this head.

All six review findings have been either fixed or explicitly resolved in the inline threads. No further code changes are pending.

@oss-maintainer oss-maintainer left a comment

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.

Summary

Delta re-review of 40547011 → 370a9b2c (2 commits, 7 files). All four points from the previous pass are genuinely addressed, and CI is green on this head (build (ubuntu-latest), build (windows-latest), validate, Check License, Check Module Sync, codecov/patch, license/cla):

  • Collision-prone archive name — persistWorkspace() now appends a random UUID to the temporary archive path, and the new persistUsesFreshTemporaryArchivePath test drives the production persistWorkspace() entry point rather than a helper, so it actually guards the regression.
  • Default endpoint overriding client config — merge() now gates every one of the nine fields behind an explicit isXxxSet() flag instead of copying unconditionally; OpenSandboxClientOptionsTest covers the explicit-set path. Correct shape for the bug that was reported.
  • URI.create() throwing raw IllegalArgumentException — wrapped at the parse boundary and the cause is preserved, with a regression test reaching OpenSandboxEndpoint.parse().
  • Lifecycle comments — the stop() comment ("closes the SDK connection without destroying the sandbox") and the doDestroyWorkspace() comment ("explicit cleanup only") both match what the method bodies do; no contradiction between the docs and the ownership boundary.

Two residual nits below, neither blocking.

Findings

  • [Warning] OpenSandboxClientOptions.java:73 — setApiKey(null) marks the field as explicitly set, so a null call-level key now overrides the client default instead of being skipped.
  • [Info] OpenSandboxClientOptions.java:37 — copyOf goes through the public setters, so a copy reports every field as explicitly set; harmless at the moment, but the flags are not a faithful "user set this" carrier once copied.

Suggestions

For the first item, apiKeySet = apiKey != null; keeps the "null means unset" contract that the rest of the class implicitly assumes — apiKey is currently the only setter that accepts null, so it is the only one that can push a null over a configured default.


Automated review by github-manager-bot


public void setApiKey(String apiKey) {
this.apiKey = apiKey;
apiKeySet = true;

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] setApiKey(null) now marks the field as explicitly set, so a call-level options object whose apiKey is null overwrites the client default with null in merge() — the previous if (call.getApiKey() != null) guard prevented exactly that. Every other setter here rejects null/blank input, so apiKey is the only one that can carry an explicit null through this path, and the result is a request sent without authorization rather than with the configured key. If "null means unset" is the intended contract, use apiKeySet = apiKey != null; here (and keep the isApiKeySet() gate in merge()); if null is meant to clear the key, worth saying so in the javadoc so the two behaviours are not confused.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in af5f331. setApiKey(null) now records the field as unset, so a call-level null no longer overrides a configured default key during merge(). nullApiKeyDoesNotOverrideConfiguredDefault exercises create(...) and start() and verifies that the configured key is preserved.

private int readyTimeoutSeconds = 30;
private int requestTimeoutSeconds = 30;
private boolean useServerProxy;
private boolean endpointSet;

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] The new *Set flags are not preserved by copyOf — it assigns through the public setters, so every copied field ends up flagged as explicitly set. Harmless today: merge() only reads the flags on the caller-supplied call argument, and every internal copy (OpenSandboxClient constructor, resume, OpenSandbox) is on the default side. But it means a copyOf result is no longer a faithful "did the user set this" carrier, and passing one as call in the future would re-introduce the default-overrides-client-config bug these commits just fixed. Consider copying the values by field assignment in copyOf, or copying the nine flags over explicitly, so the invariant is local to this class.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in af5f331. copyOf(...) now copies the explicit-set flags together with the values, so copying a default options object no longer marks every field as explicitly set. copyOfPreservesExplicitFieldFlags covers both set and unset fields.

@dargoner

dargoner commented Sep 15, 2026 •

Copy link
Copy Markdown
Author

Addressed both residual delta-review items in af5f331:

  • setApiKey(null) is now treated as unset, so it cannot clear a configured client-level API key through call-level option merging.
  • copyOf(...) now preserves the explicit-set flags as well as field values.

Verification after the final edit:

  • OpenSandbox unit suite: 47 passed, 0 failed
  • OpenSandbox reactor verify -DskipTests: 8/8 modules succeeded
  • CI on af5f331c: build (ubuntu-latest), build (windows-latest), validate, Check License, Check Module Sync, and codecov/patch all passed

@oss-maintainer oss-maintainer left a comment

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.

Summary

Delta re-review of 370a9b2c..af5f331c — the remaining items from my previous pass are closed, and the fix is at the right layer: copyOf now copies fields and explicit-set flags directly instead of routing through setters (so a copy no longer fabricates "explicitly set" state), and setApiKey(null) is treated as unset. Both directions are pinned by tests (copyOfPreservesExplicitFieldFlags, nullApiKeyDoesNotOverrideConfiguredDefault), and CI is green on this head with the branch mergeable.

One follow-up worth deciding before merge, plus two nits:

  • [Warning] the "null means unset" convention now applies only to apiKey; the other reference-typed setters still mark themselves explicitly set on null, so the same override-with-null bug remains reachable for endpoint/image/entrypoint/resourceLimits.
  • [Info] copyOf is now a hand-maintained 18-field assignment list with no completeness guard.

Findings

3 inline comments — see the diff. Nothing here reopens a previous thread.

Notes

Approving the resolution of both items; the setter-convention question is a one-line decision (fix or document), so @dargoner please reply either way and I will not need another full pass. This comment is also the reply to your @oss-maintainer ping — thanks for the verification run output, it made the delta trivial to follow.


Automated review by github-manager-bot

*/
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
apiKeySet = apiKey != null;

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] The new semantics are right for apiKey, but they make the setters inconsistent about null: setEndpoint(null) (line 63), setImage(null) (line 88), setEntrypoint(null) and setResourceLimits(null) still set their explicit flag, so a call-level options object carrying an explicit null endpoint/image will still override a configured client default — the exact failure mode this commit closes for the API key. For a secret this matters most, but a call-level setEndpoint(null) silently pointing the client at the default endpoint (or an image=null overriding a configured one) is the same class of bug.

Either apply the same "null means unset" convention to the other reference-typed setters, or — if null is meant to be a meaningful explicit value for them — say so in the Javadoc of each setter, because the current asymmetry is not discoverable from the API.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Checked against the current implementation: setEndpoint(null), setImage(null), setEntrypoint(null), and setResourceLimits(null) all throw before assigning the field or setting the explicit flag. OpenSandboxEndpoint.parse() rejects the null endpoint first, and the other setters validate at method entry, so there is no call-level null path that can override a configured default for these fields.

I added rejectedNullValuesDoNotBecomeExplicitOptions to pin that behavior, and the class Javadoc now states that only apiKey accepts null as unset.

return useServerProxySet;
}

static OpenSandboxClientOptions copyOf(OpenSandboxClientOptions source) {

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] Switching copyOf to direct field assignment is the correct fix for the flag-preservation bug, but it turns the method into a hand-maintained list of 18 assignments: a field added to the class later can be forgotten here and will be silently dropped on copy, and the existing copyIsIndependentAndPreservesAllValues test only asserts the handful of fields its author remembered.

If there is no way to assert completeness in a unit test here, a // When adding a field: also copy it here and add an assertion in OpenSandboxClientOptionsTest#copyOfPreservesExplicitFieldFlags comment above the method is the cheap mitigation. Alternatively a BeanUtils/reflection-free toBuilder()-style copy that keeps field lists in one place.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Documented in 99fa2fd. copyOf(...) now has an inline guard comment requiring future option fields to copy both the value and explicit-set flag, and to extend copyOfPreservesExplicitFieldFlags.

Map<String, String> nullValue = new LinkedHashMap<>();
nullValue.put("cpu", null);
assertThrows(IllegalArgumentException.class, () -> options.setResourceLimits(nullValue));
options.setApiKey("secret");

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] Small test-placement nit: assertFalse(options.isApiKeySet()) after setApiKey(null) encodes a semantic contract ("null means unset"), not rejection of an invalid value, and this test is named/asserted around IllegalArgumentException paths. Moving the three lines into the same test that covers nullApiKeyDoesNotOverrideConfiguredDefault (or its own one-assertion test) would make the contract easier to find when someone later wonders why one setter behaves differently from the rest.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Moved in 99fa2fd. The API-key semantics assertion now lives in a dedicated nullApiKeyMeansUnset test; invalidValuesAreRejected is limited to rejection paths again.

@dargoner

dargoner commented Sep 16, 2026 •

Copy link
Copy Markdown
Author

Follow-up in 99fa2fd:

  • Kept null rejection for endpoint/image/entrypoint/resourceLimits. Verification showed those setters throw before assigning the field or explicit-set flag, so they cannot override client defaults with null.
  • Added rejectedNullValuesDoNotBecomeExplicitOptions to pin that boundary and documented the apiKey-only null semantics in the class Javadoc.
  • Added a copyOf(...) maintenance guard comment and moved the apiKey semantics assertion into a dedicated test.

Verification:

  • OpenSandbox unit suite: 49 passed, 0 failed
  • OpenSandbox reactor verify -DskipTests: 8/8 modules succeeded
  • CI on 99fa2fdd: build (ubuntu-latest), build (windows-latest), validate, Check License, Check Module Sync, and codecov/patch all passed

@oss-maintainer oss-maintainer left a comment

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.

Summary

Delta re-review af5f331c → 99fa2fdd. The follow-up is test-only plus documentation: the two new cases pin exactly the boundary my previous COMMENTED review asked to be made explicit — rejectedNullValuesDoNotBecomeExplicitOptions asserts that a rejected null throws and leaves isEndpointSet()/isImageSet()/isEntrypointSet()/isResourceLimitsSet() false, so a rejected setter cannot silently upgrade into an "explicit" option that overrides client defaults; nullApiKeyMeansUnset pins the one setter where null is meaningful. The class javadoc now states that exception for setApiKey in one place instead of leaving readers to infer it from the merge logic, and the note above copyOf records the value+flag pairing rule that the copy helper is easy to break when a field is added.

No production behaviour changes in this delta — copyOf itself is untouched apart from the comment — so the earlier analysis stands. CLA signed (license/cla=success), CI SUCCESS on 99fa2fdd, mergeable/clean. Approving; the blocked merge state is branch-protection (still REVIEW_REQUIRED/needs a maintainer), not something on the author's side.


Automated review by github-manager-bot

保留 OpenSandbox 实现并合入官方最新依赖、核心与服务代码。

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/build Build, CI, BOM, distribution area/docs Documentation area/extensions agentscope-extensions (general) enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants