diff --git a/launch/doc/source/architecture.rst b/launch/doc/source/architecture.rst
index c76aef835..74f064e60 100644
--- a/launch/doc/source/architecture.rst
+++ b/launch/doc/source/architecture.rst
@@ -128,6 +128,11 @@ There are many possible variations of a substitution, but here are some of the c
- This substitution will evaluate a python expression and get the result as a string.
- You may pass a list of Python modules to the constructor to allow the use of those modules in the evaluated expression.
+- :class:`launch.substitutions.StringStripSubstitution`
+
+ - This substitution removes leading and trailing whitespace from the result of one or more substitutions.
+ - It can remove a newline terminator from command output before composition, for example ``$(string-strip $(command 'hostname'))``.
+
- :class:`launch.substitutions.LaunchConfiguration`
- This substitution gets a launch configuration value, as a string, by name.
diff --git a/launch/launch/substitutions/__init__.py b/launch/launch/substitutions/__init__.py
index 7fa7170e7..12aebaa25 100644
--- a/launch/launch/substitutions/__init__.py
+++ b/launch/launch/substitutions/__init__.py
@@ -36,6 +36,7 @@
from .path_join_substitution import PathSubstitution
from .python_expression import PythonExpression
from .string_join_substitution import StringJoinSubstitution
+from .string_strip_substitution import StringStripSubstitution
from .substitution_failure import SubstitutionFailure
from .text_substitution import TextSubstitution
from .this_launch_file import ThisLaunchFile
@@ -64,6 +65,7 @@
'PathSubstitution',
'PythonExpression',
'StringJoinSubstitution',
+ 'StringStripSubstitution',
'SubstitutionFailure',
'TextSubstitution',
'ThisLaunchFile',
diff --git a/launch/launch/substitutions/string_strip_substitution.py b/launch/launch/substitutions/string_strip_substitution.py
new file mode 100644
index 000000000..e1fa05608
--- /dev/null
+++ b/launch/launch/substitutions/string_strip_substitution.py
@@ -0,0 +1,90 @@
+# Copyright 2026 Open Source Robotics Foundation, Inc.
+#
+# 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.
+
+"""Module for the StringStripSubstitution substitution."""
+
+from typing import Any
+from typing import Dict
+from typing import List
+from typing import Sequence
+from typing import Text
+from typing import Tuple
+from typing import Type
+
+from ..frontend.expose import expose_substitution
+from ..launch_context import LaunchContext
+from ..some_substitutions_type import SomeSubstitutionsType
+from ..substitution import Substitution
+from ..utilities import normalize_to_list_of_substitutions
+from ..utilities import perform_substitutions
+
+
+@expose_substitution('string-strip')
+class StringStripSubstitution(Substitution):
+ """
+ Substitution that removes leading and trailing whitespace from a string.
+
+ For example, command output can be stripped before it is composed with
+ other substitutions:
+
+ .. code-block:: python
+
+ hostname = StringStripSubstitution(Command('hostname'))
+
+ .. code-block:: xml
+
+
+
+ .. code-block:: yaml
+
+ - let:
+ name: hostname
+ value: "$(string-strip $(command 'hostname'))"
+
+ This strips whitespace only; it does not otherwise escape or transform the
+ value for use in another language or expression.
+ """
+
+ def __init__(self, value: SomeSubstitutionsType) -> None:
+ """
+ Create a StringStripSubstitution.
+
+ :param value: string or substitutions whose leading and trailing
+ whitespace is removed
+ """
+ super().__init__()
+ self.__value = normalize_to_list_of_substitutions(value)
+
+ @classmethod
+ def parse(
+ cls, data: Sequence[SomeSubstitutionsType]
+ ) -> Tuple[Type['StringStripSubstitution'], Dict[str, Any]]:
+ """Parse `StringStripSubstitution` substitution."""
+ if len(data) != 1:
+ raise TypeError('string-strip substitution expects 1 argument')
+ return cls, {'value': data[0]}
+
+ @property
+ def value(self) -> List[Substitution]:
+ """Getter for the value to strip."""
+ return self.__value
+
+ def describe(self) -> Text:
+ """Return a description of this substitution as a string."""
+ return 'StringStrip({})'.format(
+ ' + '.join([sub.describe() for sub in self.value]))
+
+ def perform(self, context: LaunchContext) -> Text:
+ """Perform substitutions and remove leading and trailing whitespace."""
+ return perform_substitutions(context, self.value).strip()
diff --git a/launch/test/launch/frontend/test_substitutions.py b/launch/test/launch/frontend/test_substitutions.py
index 99dd26e33..8c35f5697 100644
--- a/launch/test/launch/frontend/test_substitutions.py
+++ b/launch/test/launch/frontend/test_substitutions.py
@@ -27,6 +27,7 @@
from launch.frontend.parse_substitution import parse_substitution
from launch.substitutions import EnvironmentVariable
from launch.substitutions import PythonExpression
+from launch.substitutions import StringStripSubstitution
from launch.substitutions import TextSubstitution
from launch.substitutions import ThisLaunchFileDir
from launch.utilities import normalize_to_list_of_substitutions
@@ -208,6 +209,14 @@ def test_eval_subst():
assert 'asdbsd' == expr.perform(LaunchContext())
+def test_string_strip_subst():
+ subst = parse_substitution("$(string-strip ' $(test asd) ')")
+ assert len(subst) == 1
+ string_strip = subst[0]
+ assert isinstance(string_strip, StringStripSubstitution)
+ assert string_strip.perform(LaunchContext()) == 'asd'
+
+
def test_eval_subst_of_math_expr():
# Math module is included by default
subst = parse_substitution(r'$(eval "ceil(1.3)")')
diff --git a/launch/test/launch/substitutions/test_command.py b/launch/test/launch/substitutions/test_command.py
index dbb19eced..0d0e80376 100644
--- a/launch/test/launch/substitutions/test_command.py
+++ b/launch/test/launch/substitutions/test_command.py
@@ -19,6 +19,8 @@
from launch.launch_context import LaunchContext
from launch.substitutions import Command
+from launch.substitutions import PythonExpression
+from launch.substitutions import StringStripSubstitution
from launch.substitutions.substitution_failure import SubstitutionFailure
import pytest
@@ -48,6 +50,18 @@ def test_command(commands):
assert output == 'asd bsd csd\n'
+def test_command_output_can_be_stripped_in_python_expression(commands):
+ """Test composing a newline-emitting command with a Python expression."""
+ context = LaunchContext()
+ command = StringStripSubstitution(Command(commands['normal']))
+ expression = PythonExpression([
+ "'",
+ command,
+ "' == 'asd bsd csd'",
+ ])
+ assert expression.perform(context) == 'True'
+
+
def test_missing_command_raises(commands):
"""Test that a command that doesn't exist raises."""
context = LaunchContext()
diff --git a/launch/test/launch/substitutions/test_string_strip_substitution.py b/launch/test/launch/substitutions/test_string_strip_substitution.py
new file mode 100644
index 000000000..3e7ec74b7
--- /dev/null
+++ b/launch/test/launch/substitutions/test_string_strip_substitution.py
@@ -0,0 +1,59 @@
+# Copyright 2026 Open Source Robotics Foundation, Inc.
+#
+# 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.
+
+"""Tests for the StringStripSubstitution substitution class."""
+
+from launch import LaunchContext
+from launch.substitutions import StringStripSubstitution
+from launch.substitutions import TextSubstitution
+
+import pytest
+
+
+@pytest.mark.parametrize(
+ ('value', 'expected'),
+ [
+ ('', ''),
+ ('robot', 'robot'),
+ (' \trobot\r\n', 'robot'),
+ (' \t\r\n', ''),
+ ],
+)
+def test_string_strip(value, expected):
+ """Test stripping leading and trailing whitespace."""
+ substitution = StringStripSubstitution(value)
+ assert substitution.perform(LaunchContext()) == expected
+
+
+def test_string_strip_nested_substitutions():
+ """Test stripping a value assembled from multiple substitutions."""
+ substitution = StringStripSubstitution([
+ ' \t',
+ TextSubstitution(text='robot'),
+ TextSubstitution(text=' name'),
+ '\r\n',
+ ])
+ assert substitution.perform(LaunchContext()) == 'robot name'
+
+
+def test_string_strip_parse():
+ """Test the frontend parser contract."""
+ substitution_type, kwargs = StringStripSubstitution.parse([' value '])
+ assert substitution_type is StringStripSubstitution
+ assert kwargs == {'value': ' value '}
+
+ with pytest.raises(TypeError, match='expects 1 argument'):
+ StringStripSubstitution.parse([])
+ with pytest.raises(TypeError, match='expects 1 argument'):
+ StringStripSubstitution.parse(['one', 'two'])
diff --git a/launch_xml/test/launch_xml/test_string_strip_substitution.py b/launch_xml/test/launch_xml/test_string_strip_substitution.py
new file mode 100644
index 000000000..d508b2516
--- /dev/null
+++ b/launch_xml/test/launch_xml/test_string_strip_substitution.py
@@ -0,0 +1,48 @@
+# Copyright 2026 Open Source Robotics Foundation, Inc.
+#
+# 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.
+
+"""Test parsing a StringStripSubstitution in an XML launch file."""
+
+import io
+import textwrap
+
+from launch.actions import DeclareLaunchArgument
+from launch.actions import SetLaunchConfiguration
+from launch.frontend import Parser
+from launch.launch_context import LaunchContext
+from launch.substitutions import StringStripSubstitution
+
+
+def test_nested():
+ xml_file = textwrap.dedent(
+ """
+
+
+
+
+ """
+ )
+ root_entity, parser = Parser.load(io.StringIO(xml_file))
+ launch_description = parser.parse_description(root_entity)
+
+ assert len(launch_description.entities) == 2
+ assert isinstance(launch_description.entities[0], DeclareLaunchArgument)
+ assert isinstance(launch_description.entities[1], SetLaunchConfiguration)
+
+ context = LaunchContext()
+ launch_description.entities[0].visit(context)
+
+ let = launch_description.entities[1]
+ assert isinstance(let.value[0], StringStripSubstitution)
+ assert let.value[0].perform(context) == 'rover'
diff --git a/launch_yaml/test/launch_yaml/test_string_strip_substitution.py b/launch_yaml/test/launch_yaml/test_string_strip_substitution.py
new file mode 100644
index 000000000..86118572a
--- /dev/null
+++ b/launch_yaml/test/launch_yaml/test_string_strip_substitution.py
@@ -0,0 +1,51 @@
+# Copyright 2026 Open Source Robotics Foundation, Inc.
+#
+# 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.
+
+"""Test parsing a StringStripSubstitution in a YAML launch file."""
+
+import io
+import textwrap
+
+from launch.actions import DeclareLaunchArgument
+from launch.actions import SetLaunchConfiguration
+from launch.frontend import Parser
+from launch.launch_context import LaunchContext
+from launch.substitutions import StringStripSubstitution
+
+
+def test_nested():
+ yaml_file = textwrap.dedent(
+ """
+ launch:
+ - arg:
+ name: robot_name
+ default: " rover "
+ - let:
+ name: trimmed
+ value: "$(string-strip $(var robot_name))"
+ """
+ )
+ root_entity, parser = Parser.load(io.StringIO(yaml_file))
+ launch_description = parser.parse_description(root_entity)
+
+ assert len(launch_description.entities) == 2
+ assert isinstance(launch_description.entities[0], DeclareLaunchArgument)
+ assert isinstance(launch_description.entities[1], SetLaunchConfiguration)
+
+ context = LaunchContext()
+ launch_description.entities[0].visit(context)
+
+ let = launch_description.entities[1]
+ assert isinstance(let.value[0], StringStripSubstitution)
+ assert let.value[0].perform(context) == 'rover'