diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml
index 80bbd58f7e..2c7608cbb0 100644
--- a/e2e/rust/Cargo.toml
+++ b/e2e/rust/Cargo.toml
@@ -209,11 +209,6 @@ name = "forward_proxy_jsonrpc_l7"
path = "tests/forward_proxy_jsonrpc_l7.rs"
required-features = ["e2e-host-gateway"]
-[[test]]
-name = "workspace_lifecycle"
-path = "tests/workspace_lifecycle.rs"
-required-features = ["e2e"]
-
[[test]]
name = "sandbox_templates"
path = "tests/sandbox_templates.rs"
diff --git a/e2e/rust/tests/workspace_lifecycle.rs b/e2e/rust/tests/workspace_lifecycle.rs
deleted file mode 100644
index 2fe946389e..0000000000
--- a/e2e/rust/tests/workspace_lifecycle.rs
+++ /dev/null
@@ -1,278 +0,0 @@
-// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-#![cfg(feature = "e2e")]
-
-//! E2E tests for workspace resource lifecycle.
-//!
-//! Covers:
-//! - Workspace CRUD
-//! - Provider creation scoped to a workspace
-//! - Workspace isolation (resources in one workspace are invisible in another)
-//! - `--all-workspaces` listing
-//! - Deletion guard (workspace cannot be deleted while resources exist)
-//! - Successful deletion after resource cleanup
-
-use std::process::Stdio;
-
-use openshell_e2e::harness::binary::{openshell_bin, openshell_cmd};
-use openshell_e2e::harness::output::strip_ansi;
-
-const WORKSPACE: &str = "lifecycle-test";
-const WORKSPACE_TERM: &str = "terminating-test";
-const PROVIDER: &str = "lifecycle-prov";
-const PROVIDER_TERM: &str = "term-prov";
-
-struct CliResult {
- output: String,
- success: bool,
-}
-
-async fn run_cli(args: &[&str]) -> CliResult {
- let mut cmd = openshell_cmd();
- cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
-
- let output = cmd.output().await.expect("spawn openshell command");
- let stdout = String::from_utf8_lossy(&output.stdout).to_string();
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
- let combined = format!("{stdout}{stderr}");
-
- CliResult {
- output: strip_ansi(&combined),
- success: output.status.success(),
- }
-}
-
-struct WorkspaceCleanup;
-
-impl Drop for WorkspaceCleanup {
- fn drop(&mut self) {
- let bin = openshell_bin();
- let _ = std::process::Command::new(&bin)
- .args(["provider", "delete", PROVIDER, "--workspace", WORKSPACE])
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status();
- let _ = std::process::Command::new(&bin)
- .args(["workspace", "delete", WORKSPACE])
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status();
- }
-}
-
-#[tokio::test]
-async fn workspace_full_crud_lifecycle() {
- let _cleanup = WorkspaceCleanup;
-
- // 1. Create workspace.
- let res = run_cli(&["workspace", "create", "--name", WORKSPACE]).await;
- assert!(res.success, "workspace create failed: {}", res.output);
-
- // 2. Verify workspace exists.
- let res = run_cli(&["workspace", "get", WORKSPACE]).await;
- assert!(res.success, "workspace get failed: {}", res.output);
- assert!(
- res.output.contains(WORKSPACE),
- "workspace get output should contain workspace name: {}",
- res.output
- );
-
- // 3. Create provider in the workspace.
- let res = run_cli(&[
- "provider",
- "create",
- "--name",
- PROVIDER,
- "--type",
- "openai",
- "--credential",
- "OPENAI_API_KEY=test-value",
- "--workspace",
- WORKSPACE,
- ])
- .await;
- assert!(
- res.success,
- "provider create in workspace failed: {}",
- res.output
- );
-
- // 4. List providers in workspace — should see our provider.
- let res = run_cli(&["provider", "list", "--workspace", WORKSPACE]).await;
- assert!(res.success, "provider list failed: {}", res.output);
- assert!(
- res.output.contains(PROVIDER),
- "workspace-scoped list should show the provider: {}",
- res.output
- );
-
- // 5. List providers in default workspace — should NOT see our provider.
- let res = run_cli(&["provider", "list"]).await;
- assert!(
- res.success,
- "provider list (default) failed: {}",
- res.output
- );
- assert!(
- !res.output.contains(PROVIDER),
- "default workspace should not contain the workspace-scoped provider: {}",
- res.output
- );
-
- // 6. List providers with --all-workspaces — should see our provider.
- let res = run_cli(&["provider", "list", "--all-workspaces"]).await;
- assert!(
- res.success,
- "provider list --all-workspaces failed: {}",
- res.output
- );
- assert!(
- res.output.contains(PROVIDER),
- "--all-workspaces should include the workspace-scoped provider: {}",
- res.output
- );
-
- // 7. Attempt workspace deletion — should fail because provider exists.
- let res = run_cli(&["workspace", "delete", WORKSPACE]).await;
- assert!(
- !res.success,
- "workspace delete should fail while resources exist: {}",
- res.output
- );
- assert!(
- res.output.contains("still contains resources"),
- "error should mention blocking resources: {}",
- res.output
- );
-
- // 8. Delete the provider.
- let res = run_cli(&["provider", "delete", PROVIDER, "--workspace", WORKSPACE]).await;
- assert!(res.success, "provider delete failed: {}", res.output);
-
- // 9. Workspace deletion should now succeed.
- let res = run_cli(&["workspace", "delete", WORKSPACE]).await;
- assert!(
- res.success,
- "workspace delete should succeed after resources removed: {}",
- res.output
- );
-
- // 10. Verify workspace is gone.
- let res = run_cli(&["workspace", "get", WORKSPACE]).await;
- assert!(
- !res.success,
- "workspace get should fail after deletion: {}",
- res.output
- );
-}
-
-struct TerminatingCleanup;
-
-impl Drop for TerminatingCleanup {
- fn drop(&mut self) {
- let bin = openshell_bin();
- let _ = std::process::Command::new(&bin)
- .args([
- "provider",
- "delete",
- PROVIDER_TERM,
- "--workspace",
- WORKSPACE_TERM,
- ])
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status();
- let _ = std::process::Command::new(&bin)
- .args(["workspace", "delete", WORKSPACE_TERM])
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status();
- }
-}
-
-#[tokio::test]
-async fn workspace_terminating_rejects_creates() {
- let _cleanup = TerminatingCleanup;
-
- // 1. Create workspace.
- let res = run_cli(&["workspace", "create", "--name", WORKSPACE_TERM]).await;
- assert!(res.success, "workspace create failed: {}", res.output);
-
- // 2. Create a provider to block deletion.
- let res = run_cli(&[
- "provider",
- "create",
- "--name",
- PROVIDER_TERM,
- "--type",
- "openai",
- "--credential",
- "OPENAI_API_KEY=test-value",
- "--workspace",
- WORKSPACE_TERM,
- ])
- .await;
- assert!(res.success, "provider create failed: {}", res.output);
-
- // 3. Attempt deletion — fails, but workspace is now Terminating.
- let res = run_cli(&["workspace", "delete", WORKSPACE_TERM]).await;
- assert!(
- !res.success,
- "delete should fail with blocker: {}",
- res.output
- );
-
- // 4. Workspace list should show Terminating status.
- let res = run_cli(&["workspace", "list"]).await;
- assert!(res.success, "workspace list failed: {}", res.output);
- assert!(
- res.output.contains("Terminating"),
- "workspace list should show Terminating status: {}",
- res.output
- );
-
- // 5. Creating a new provider should be rejected.
- let res = run_cli(&[
- "provider",
- "create",
- "--name",
- "should-fail",
- "--type",
- "openai",
- "--credential",
- "OPENAI_API_KEY=test-value",
- "--workspace",
- WORKSPACE_TERM,
- ])
- .await;
- assert!(
- !res.success,
- "provider create should fail in terminating workspace: {}",
- res.output
- );
- assert!(
- res.output.contains("being deleted"),
- "error should mention workspace is being deleted: {}",
- res.output
- );
-
- // 6. Delete the blocking provider (deletes still work in Terminating).
- let res = run_cli(&[
- "provider",
- "delete",
- PROVIDER_TERM,
- "--workspace",
- WORKSPACE_TERM,
- ])
- .await;
- assert!(res.success, "provider delete failed: {}", res.output);
-
- // 7. Retry deletion — should succeed (idempotent on already-Terminating).
- let res = run_cli(&["workspace", "delete", WORKSPACE_TERM]).await;
- assert!(
- res.success,
- "workspace delete should succeed after cleanup: {}",
- res.output
- );
-}
diff --git a/e2e/suites/conformance/README.md b/e2e/suites/conformance/README.md
index e0ecece555..8a492223bc 100644
--- a/e2e/suites/conformance/README.md
+++ b/e2e/suites/conformance/README.md
@@ -10,7 +10,8 @@ conformance area and user story. The CLI is the current client interface; lifecy
- `cli/tests//.rs`: test functions, steps, and assertions for a story.
- `cli/tests//helpers.rs`: setup and assertions shared by that area's stories.
-The current areas are `file_transfer`, `lifecycle`, `policy_advisor`, and `smoke`.
+The current areas are `file_transfer`, `lifecycle`, `policy_advisor`, `smoke`, and
+`workspaces`.
Each area compiles into one test binary; individual stories remain selectable by
their module prefix. `main.rs` contains only module declarations.
@@ -66,7 +67,8 @@ must supply the workload image and tools used by the selected scenarios. Missing
CLI or gateway prerequisites fail the run rather than silently passing.
Run one group with `--test file_transfer`, `--test lifecycle`,
-`--test policy_advisor`, or `--test smoke`, before the `--` separator. A group or
+`--test policy_advisor`, `--test smoke`, or `--test workspaces`, before the `--`
+separator. A group or
individual-test filter exercises only part of the suite.
The tmachine conformance testsuite runs these same entry points from the
diff --git a/e2e/suites/conformance/cli/tests/workspaces/crud.rs b/e2e/suites/conformance/cli/tests/workspaces/crud.rs
new file mode 100644
index 0000000000..57d1d22848
--- /dev/null
+++ b/e2e/suites/conformance/cli/tests/workspaces/crud.rs
@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+use super::helpers::{fails, finish, require_status, succeeds};
+use openshell_e2e_support::OpenShellRunner;
+
+/// Verify a workspace can be created, inspected, listed as Active, deleted when
+/// empty, and is gone afterwards.
+#[tokio::test]
+async fn workspace_full_crud_lifecycle() {
+ let mut runner =
+ OpenShellRunner::from_env("workspaces/crud").expect("candidate openshell CLI is available");
+ let workspace = format!("ws-{}-cr", runner.id());
+ let result = async {
+ runner.check_gateway_status().await?;
+ let r = &runner;
+ let name = workspace.as_str();
+ succeeds(
+ r,
+ "create",
+ "workspace is created",
+ &["workspace", "create", "--name", name],
+ )
+ .await?;
+ succeeds(
+ r,
+ "get",
+ "workspace can be retrieved",
+ &["workspace", "get", name],
+ )
+ .await?;
+ require_status(r, "list-active", name, Some("Active")).await?;
+ succeeds(
+ r,
+ "delete",
+ "empty workspace is deleted",
+ &["workspace", "delete", name],
+ )
+ .await?;
+ fails(
+ r,
+ "get-after-delete",
+ "deleted workspace is gone",
+ &["workspace", "get", name],
+ )
+ .await?;
+ require_status(r, "list-absent", name, None).await
+ }
+ .await;
+ if let Err(error) = finish(runner, result, &workspace, &[]).await {
+ panic!("workspaces/crud conformance story failed:\n{error}");
+ }
+}
diff --git a/e2e/suites/conformance/cli/tests/workspaces/helpers.rs b/e2e/suites/conformance/cli/tests/workspaces/helpers.rs
new file mode 100644
index 0000000000..ad855ebbbd
--- /dev/null
+++ b/e2e/suites/conformance/cli/tests/workspaces/helpers.rs
@@ -0,0 +1,215 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+use openshell_e2e_support::{CommandResult, OpenShellRunner};
+use serde_json::Value;
+use std::time::Duration;
+
+const COMMAND_TIMEOUT: Duration = Duration::from_mins(2);
+const MAX_PAGES: u32 = 100;
+
+/// Run a CLI command and return its result without judging the outcome.
+pub async fn run(
+ runner: &OpenShellRunner,
+ step: &str,
+ expectation: &str,
+ args: &[&str],
+) -> Result {
+ runner
+ .step(step)
+ .description(expectation)
+ .with_timeout(COMMAND_TIMEOUT)
+ .run(args)
+ .await
+ .map_err(|error| error.to_string())
+}
+
+/// Run a CLI command that must succeed.
+pub async fn succeeds(
+ runner: &OpenShellRunner,
+ step: &str,
+ expectation: &str,
+ args: &[&str],
+) -> Result<(), String> {
+ run(runner, step, expectation, args)
+ .await?
+ .require_success()
+}
+
+/// Run a CLI command that must fail.
+pub async fn fails(
+ runner: &OpenShellRunner,
+ step: &str,
+ expectation: &str,
+ args: &[&str],
+) -> Result<(), String> {
+ let result = run(runner, step, expectation, args).await?;
+ if result.success() {
+ return Err(result.failure_diagnostic(expectation));
+ }
+ Ok(())
+}
+
+pub fn create_template_args<'a>(workspace: &'a str, name: &'a str) -> [&'a str; 6] {
+ [
+ "sandbox",
+ "template",
+ "create",
+ name,
+ "--workspace",
+ workspace,
+ ]
+}
+
+/// Find the entry called `name` in a paginated `--output json` listing, where
+/// `collection` is the JSON key that holds the entries.
+async fn find_entry(
+ runner: &OpenShellRunner,
+ step: &str,
+ list: &[&str],
+ collection: &str,
+ name: &str,
+) -> Result