Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 14 additions & 4 deletions frontend/src/adk/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ export interface SandboxRequestOptions {
onUsage?: (update: SandboxTokenUsageUpdate) => void;
}

export interface SandboxListOptions extends SandboxRequestOptions {
autoResumeSnapshots?: boolean;
}

export interface SandboxStartOptions extends SandboxRequestOptions {
displayName?: string;
persistent?: boolean;
Expand Down Expand Up @@ -284,11 +288,11 @@ export interface SandboxReply {
}

export interface AgentKitSandboxClient {
listSessions(options?: SandboxRequestOptions): Promise<SandboxAgentResource[]>;
listSessions(options?: SandboxListOptions): Promise<SandboxAgentResource[]>;
startSession(options?: SandboxStartOptions): Promise<SandboxSession>;
listAgentSessions(
kind: SandboxAgentKind,
options?: SandboxRequestOptions,
options?: SandboxListOptions,
): Promise<SandboxAgentResource[]>;
startAgentSession(
kind: SandboxAgentKind,
Expand Down Expand Up @@ -611,6 +615,12 @@ function parseSnapshot(
};
}

function sandboxListUrl(base: string, options?: SandboxListOptions): string {
if (!options?.autoResumeSnapshots) return base;
const params = new URLSearchParams({ autoResumeSnapshots: "true" });
return `${base}?${params.toString()}`;
}

const DEFAULT_PERMISSIONS: SandboxPermissions = {
approvalPolicy: "on-request",
approvalsReviewer: "user",
Expand Down Expand Up @@ -1013,7 +1023,7 @@ async function sandboxJson(
export const sandboxClient: AgentKitSandboxClient = {
async listSessions(options = {}) {
const response = await studioFetch(
SANDBOX_API,
sandboxListUrl(SANDBOX_API, options),
{
method: "GET",
headers: sandboxHeaders(),
Expand Down Expand Up @@ -1059,7 +1069,7 @@ export const sandboxClient: AgentKitSandboxClient = {

async listAgentSessions(kind, options = {}) {
const response = await studioFetch(
`/web/${kind}/sessions`,
sandboxListUrl(`/web/${kind}/sessions`, options),
{
method: "GET",
headers: sandboxHeaders(),
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/ui/MyAgents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -540,8 +540,14 @@ export function MyAgents({
setSandboxAgents([]);
try {
const sessions = type === "codex"
? await sandboxClient.listSessions({ signal: controller.signal })
: await sandboxClient.listAgentSessions(type, { signal: controller.signal });
? await sandboxClient.listSessions({
signal: controller.signal,
autoResumeSnapshots: true,
})
: await sandboxClient.listAgentSessions(type, {
signal: controller.signal,
autoResumeSnapshots: true,
});
if (sandboxRequestRef.current !== requestId) return;
setSandboxAgents(sessions.map(sandboxToAgent));
} catch (cause) {
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/ui/new-chat-modes/NewChatAgentPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,14 @@ export function NewChatAgentPicker({
setSandboxSessions([]);
try {
const sessions = type === "codex"
? await sandboxClient.listSessions({ signal: controller.signal })
: await sandboxClient.listAgentSessions(type, { signal: controller.signal });
? await sandboxClient.listSessions({
signal: controller.signal,
autoResumeSnapshots: true,
})
: await sandboxClient.listAgentSessions(type, {
signal: controller.signal,
autoResumeSnapshots: true,
});
if (requestIdRef.current !== requestId) return;
setSandboxSessions(sessions);
setLoadedSandboxType(type);
Expand Down
10 changes: 8 additions & 2 deletions frontend/tests/myAgents.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,14 @@ test("renders only account-backed Runtime and Sandbox agents", () => {
pageSource,
/codex-code-review|codex-test-coverage|openclaw-research|hermes-data-analysis/,
);
assert.match(pageSource, /sandboxClient\.listSessions\(\{ signal: controller\.signal \}\)/);
assert.match(pageSource, /sandboxClient\.listAgentSessions\(type, \{ signal: controller\.signal \}\)/);
assert.match(
pageSource,
/sandboxClient\.listSessions\(\{[\s\S]*?signal: controller\.signal,[\s\S]*?autoResumeSnapshots: true,[\s\S]*?\}\)/,
);
assert.match(
pageSource,
/sandboxClient\.listAgentSessions\(type, \{[\s\S]*?signal: controller\.signal,[\s\S]*?autoResumeSnapshots: true,[\s\S]*?\}\)/,
);
assert.match(pageSource, /sessions\.map\(sandboxToAgent\)/);
});

Expand Down
31 changes: 31 additions & 0 deletions frontend/tests/sandboxThreadsClient.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,34 @@ test("sends persistence explicitly for default and temporary agents", async (t)
},
]);
});

test("requests snapshot auto-resume when listing sandbox agents", async (t) => {
const previousFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = previousFetch;
});
const requests = [];
globalThis.fetch = async (url, init) => {
requests.push({ url, method: init.method });
return new Response(JSON.stringify({ sessions: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};

await sandboxClient.listSessions({ autoResumeSnapshots: true });
await sandboxClient.listAgentSessions("openclaw", {
autoResumeSnapshots: true,
});

assert.deepEqual(requests, [
{
url: "/web/sandbox/sessions?autoResumeSnapshots=true",
method: "GET",
},
{
url: "/web/openclaw/sessions?autoResumeSnapshots=true",
method: "GET",
},
]);
});
96 changes: 94 additions & 2 deletions tests/cli/test_frontend_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,7 @@ def test_managed_agent_snapshot_is_listed_resumed_and_deleted() -> None:
headers={"X-Test-User": "alice"},
)
admin_list = client.get(
"/web/openclaw/sessions",
"/web/openclaw/sessions?autoResumeSnapshots=false",
headers={"X-Test-User": "admin", "X-Test-Role": "admin"},
)
denied = client.post(
Expand Down Expand Up @@ -752,6 +752,52 @@ def test_managed_agent_snapshot_is_listed_resumed_and_deleted() -> None:
assert [item.snapshot_id for item in gateway.deleted_snapshots] == ["snapshot-bob"]


def test_managed_agent_admin_listing_auto_resumes_current_kind_snapshots() -> None:
gateway = _FakeGateway()
gateway.snapshots["snapshot-openclaw"] = SandboxCloudSnapshot(
tool_id="tool-openclaw-snapshot",
snapshot_id="snapshot-openclaw",
session_id="expired-openclaw",
user_session_id="user-openclaw",
region="cn-beijing",
status="Ready",
reason="Expired",
created_at="2026-08-06T09:00:00Z",
display_name="OpenClaw Agent",
created_by="alice",
)
gateway.snapshots["snapshot-hermes"] = SandboxCloudSnapshot(
tool_id="tool-hermes-snapshot",
snapshot_id="snapshot-hermes",
session_id="expired-hermes",
user_session_id="user-hermes",
region="cn-beijing",
status="Ready",
reason="Expired",
created_at="2026-08-06T09:01:00Z",
display_name="Hermes Agent",
created_by="alice",
)

with TestClient(_agent_app(gateway)) as client:
ordinary = client.get(
"/web/openclaw/sessions",
headers={"X-Test-User": "alice"},
)
admin = client.get(
"/web/openclaw/sessions",
headers={"X-Test-User": "admin", "X-Test-Role": "admin"},
)

assert ordinary.status_code == 200
assert "snapshots" not in ordinary.json()
assert "resumed-snapshot-openclaw" in {
item["sessionId"] for item in admin.json()["sessions"]
}
assert "snapshots" not in admin.json()
assert "resumed-snapshot-hermes" not in gateway.sessions


def test_managed_agent_routes_enforce_username_scope() -> None:
gateway = _FakeGateway()
with TestClient(_agent_app(gateway)) as client:
Expand Down Expand Up @@ -1816,7 +1862,7 @@ def test_sandbox_snapshot_is_wakeable_for_admin_only() -> None:
headers={"X-Test-User": "alice"},
)
admin_list = client.get(
"/web/sandbox/sessions",
"/web/sandbox/sessions?autoResumeSnapshots=false",
headers={"X-Test-User": "admin", "X-Test-Role": "admin"},
)
resumed = client.post(
Expand All @@ -1843,6 +1889,52 @@ def test_sandbox_snapshot_is_wakeable_for_admin_only() -> None:
assert deleted.json() == {"deleted": True}


def test_sandbox_admin_listing_auto_resumes_snapshots() -> None:
gateway = _FakeGateway()
gateway.snapshots["snapshot-alice"] = SandboxCloudSnapshot(
tool_id="tool-studio-snapshot",
snapshot_id="snapshot-alice",
session_id="expired-alice",
user_session_id="user-alice",
region="cn-beijing",
status="Ready",
reason="Expired",
created_at="2026-08-06T09:00:00Z",
display_name="Alice Codex",
created_by="alice",
)
gateway.snapshots["snapshot-failed"] = SandboxCloudSnapshot(
tool_id="tool-studio-snapshot",
snapshot_id="snapshot-failed",
session_id="failed-session",
user_session_id="user-failed",
region="cn-beijing",
status="Failed",
reason="Create failed",
created_at="2026-08-06T10:00:00Z",
display_name="Failed Codex",
created_by="alice",
)

with TestClient(_app(gateway)) as client:
ordinary = client.get(
"/web/sandbox/sessions",
headers={"X-Test-User": "alice"},
)
admin = client.get(
"/web/sandbox/sessions",
headers={"X-Test-User": "admin", "X-Test-Role": "admin"},
)

assert ordinary.status_code == 200
assert "snapshots" not in ordinary.json()
assert "resumed-snapshot-alice" in {
item["sessionId"] for item in admin.json()["sessions"]
}
assert "snapshots" not in admin.json()
assert "resumed-snapshot-failed" not in gateway.sessions


def test_sandbox_list_scope_follows_user_role() -> None:
gateway = _FakeGateway()
with TestClient(_app(gateway)) as client:
Expand Down
Loading
Loading