User Story
As an operator running multiple workspaces on a single OpenShell gateway with the Docker compute driver, I want deleting a sandbox to affect only that sandbox, so that tenants sharing a gateway cannot destroy each other's running sandboxes by picking the same sandbox name.
Problem Statement
The Docker driver resolves entries in its in-memory pending map with pending_sandbox_matches (crates/openshell-driver-docker/src/lib.rs:2165):
fn pending_sandbox_matches(sandbox: &DriverSandbox, sandbox_id: &str, sandbox_name: &str) -> bool {
(!sandbox_id.is_empty() && sandbox.id == sandbox_id)
|| (!sandbox_name.is_empty() && sandbox.name == sandbox_name)
}
A record matches on id or name, and workspace is never consulted even though DriverSandbox carries it. The gateway always sends both fields (crates/openshell-server/src/compute/mod.rs:1628), so the name clause can match a record belonging to a different sandbox.
Sandbox names are unique per workspace, not globally — see sqlite_name_unique_scoped_by_workspace in crates/openshell-server/src/persistence/tests.rs:684, which asserts that ("sandbox", "id-1", "shared-name", "alpha") and ("sandbox", "id-2", "shared-name", "beta") coexist as separate records. The Docker driver has a single flat config.sandbox_namespace, so every workspace's sandboxes share one pending map.
Given sandbox A (sbx-A, name demo, workspace alpha) whose provisioning already completed and whose container was later removed out-of-band, and sandbox B (sbx-B, name demo, workspace beta) currently provisioning, delete_sandbox_inner("sbx-A", "demo") (:1065) does the following:
:1070 remove_pending_sandbox pops B's record — A is no longer in the map, and B matches on the name clause.
:1074 task.abort() kills B's in-flight provisioning task. This runs before any branching, so it happens on every path.
:1077 find_managed_container_summary filters on A's id label and returns None, entering the "container gone, pending record present" branch.
:1082 container_name_for_sandbox(&record.sandbox) resolves to B's container name.
:1084 remove_container(force: true) force-removes B's running container.
:1092 cleanup_sandbox_token_file(&record.sandbox) deletes B's token file.
:1093 returns Ok(true), so the gateway reports A as deleted and broadcasts a Deleted event for A.
No error is surfaced anywhere. Two other call sites share the same lookup: stop_sandbox_inner:1135 (aborts and cleans up the wrong sandbox on stop) and pending_snapshot:1282, which backs GetSandbox and can return another sandbox's snapshot.
reserve_pending_sandbox:1250 applies the same id == || name == test, so two same-named sandboxes cannot be pending simultaneously. That is not required here: only B is pending, and A finished provisioning long before.
Container naming already handles this correctly — container_name_for_sandbox:3638 builds openshell-{workspace}--{name}-{id} specifically so names cannot collide. The pending lookup never received the same treatment.
Docker-driver only. The Podman driver has no equivalent pending map or matcher.
Impact / Why This Matters
Deleting or stopping a sandbox in one workspace silently destroys an unrelated, actively provisioning sandbox in another workspace: its provisioning task is aborted, its container is force-removed, and its gateway JWT is deleted. The owning tenant sees a sandbox vanish with no error and no log tying it to the other workspace's request. The delete that caused it reports success.
This is a cross-tenant blast radius on a shared gateway, and it requires no race — only two sandboxes with the same name in different workspaces, which the persistence layer explicitly permits.
The only workaround is enforcing globally unique sandbox names across all workspaces out-of-band. That defeats the purpose of workspace-scoped naming and cannot be enforced through any OpenShell-supplied mechanism, since the gateway itself generates names for unnamed sandboxes.
Acceptance Criteria
Reproduction Steps
- Run a gateway with the Docker compute driver and two workspaces,
alpha and beta.
- Create sandbox
demo in workspace alpha. Wait for provisioning to complete, so the driver clears its pending record.
- Remove that container out-of-band:
docker rm -f openshell-alpha--demo-<sbx-A-id>.
- Create sandbox
demo in workspace beta. While it is still provisioning, proceed immediately to the next step.
- Delete sandbox
demo in workspace alpha.
- Observe that the workspace
beta sandbox is gone: its provisioning task was aborted, its container was force-removed, and its token file under $XDG_STATE_HOME/openshell/docker-sandbox-tokens/<namespace>/<sbx-B-id>/sandbox.jwt was deleted. The delete of the alpha sandbox reported success.
Environment
- OpenShell:
main at 118b250
- OS: any
- Runtime, deployment, or integration: Docker compute driver, single gateway serving more than one workspace. Not version-specific;
pending_sandbox_matches has had this shape since the map was introduced.
User Story
As an operator running multiple workspaces on a single OpenShell gateway with the Docker compute driver, I want deleting a sandbox to affect only that sandbox, so that tenants sharing a gateway cannot destroy each other's running sandboxes by picking the same sandbox name.
Problem Statement
The Docker driver resolves entries in its in-memory
pendingmap withpending_sandbox_matches(crates/openshell-driver-docker/src/lib.rs:2165):A record matches on id or name, and
workspaceis never consulted even thoughDriverSandboxcarries it. The gateway always sends both fields (crates/openshell-server/src/compute/mod.rs:1628), so the name clause can match a record belonging to a different sandbox.Sandbox names are unique per workspace, not globally — see
sqlite_name_unique_scoped_by_workspaceincrates/openshell-server/src/persistence/tests.rs:684, which asserts that("sandbox", "id-1", "shared-name", "alpha")and("sandbox", "id-2", "shared-name", "beta")coexist as separate records. The Docker driver has a single flatconfig.sandbox_namespace, so every workspace's sandboxes share onependingmap.Given sandbox A (
sbx-A, namedemo, workspacealpha) whose provisioning already completed and whose container was later removed out-of-band, and sandbox B (sbx-B, namedemo, workspacebeta) currently provisioning,delete_sandbox_inner("sbx-A", "demo")(:1065) does the following::1070remove_pending_sandboxpops B's record — A is no longer in the map, and B matches on the name clause.:1074task.abort()kills B's in-flight provisioning task. This runs before any branching, so it happens on every path.:1077find_managed_container_summaryfilters on A's id label and returnsNone, entering the "container gone, pending record present" branch.:1082container_name_for_sandbox(&record.sandbox)resolves to B's container name.:1084remove_container(force: true)force-removes B's running container.:1092cleanup_sandbox_token_file(&record.sandbox)deletes B's token file.:1093returnsOk(true), so the gateway reports A as deleted and broadcasts aDeletedevent for A.No error is surfaced anywhere. Two other call sites share the same lookup:
stop_sandbox_inner:1135(aborts and cleans up the wrong sandbox on stop) andpending_snapshot:1282, which backsGetSandboxand can return another sandbox's snapshot.reserve_pending_sandbox:1250applies the sameid == || name ==test, so two same-named sandboxes cannot be pending simultaneously. That is not required here: only B is pending, and A finished provisioning long before.Container naming already handles this correctly —
container_name_for_sandbox:3638buildsopenshell-{workspace}--{name}-{id}specifically so names cannot collide. Thependinglookup never received the same treatment.Docker-driver only. The Podman driver has no equivalent
pendingmap or matcher.Impact / Why This Matters
Deleting or stopping a sandbox in one workspace silently destroys an unrelated, actively provisioning sandbox in another workspace: its provisioning task is aborted, its container is force-removed, and its gateway JWT is deleted. The owning tenant sees a sandbox vanish with no error and no log tying it to the other workspace's request. The delete that caused it reports success.
This is a cross-tenant blast radius on a shared gateway, and it requires no race — only two sandboxes with the same name in different workspaces, which the persistence layer explicitly permits.
The only workaround is enforcing globally unique sandbox names across all workspaces out-of-band. That defeats the purpose of workspace-scoped naming and cannot be enforced through any OpenShell-supplied mechanism, since the gateway itself generates names for unnamed sandboxes.
Acceptance Criteria
pending_sandbox_matchesidentifies a record unambiguously: match onsandbox_idalone when it is non-empty, and fall back to name only when no id is supplied, scoped byworkspace.stop_sandbox_inner) has the same isolation.GetSandboxfor a sandbox does not return a same-named pending sandbox from another workspace.Reproduction Steps
alphaandbeta.demoin workspacealpha. Wait for provisioning to complete, so the driver clears itspendingrecord.docker rm -f openshell-alpha--demo-<sbx-A-id>.demoin workspacebeta. While it is still provisioning, proceed immediately to the next step.demoin workspacealpha.betasandbox is gone: its provisioning task was aborted, its container was force-removed, and its token file under$XDG_STATE_HOME/openshell/docker-sandbox-tokens/<namespace>/<sbx-B-id>/sandbox.jwtwas deleted. The delete of thealphasandbox reported success.Environment
mainat 118b250pending_sandbox_matcheshas had this shape since the map was introduced.