diff --git a/plugins/filter/dotenv.py b/plugins/filter/dotenv.py index 0b459dd8b7..cf95b597a8 100644 --- a/plugins/filter/dotenv.py +++ b/plugins/filter/dotenv.py @@ -11,7 +11,11 @@ # container. Wrapping the value would leak literal quotes into # ``$KEY`` ("\"password\""), breaking DB connect strings, URL parsers, # and any ``int(os.environ['KEY'])`` consumer. -# So in swarm mode the filter returns the value unchanged. +# So in swarm mode the filter returns the value unchanged, including a +# value carrying spaces: quoting one produced ``AGENT_PLATFORMS="{...}"`` +# in the broker's container, which ``json.loads`` then read as a string +# rather than a mapping. A shell-sourced file needs the opposite and must +# not contain a space at all - join such a value on a comma. # # The mode is read from ``DEPLOYMENT_MODE`` in the rendering context. # When the context is missing (e.g. unit tests calling the filter @@ -43,11 +47,7 @@ def _quote_compose_style(value: Any) -> str: def _passthrough_swarm(value: Any) -> str: if value is None: return "" - s = str(value) - if any(c in s for c in " \t\n"): - s = s.replace("\\", "\\\\").replace('"', '\\"') - return f'"{s}"' - return s + return str(value) @jinja2.pass_context diff --git a/roles/svc-ai-agent-broker/files/test/test.sh b/roles/svc-ai-agent-broker/files/test/test.sh index 002d0269ed..e7e73fd50c 100644 --- a/roles/svc-ai-agent-broker/files/test/test.sh +++ b/roles/svc-ai-agent-broker/files/test/test.sh @@ -12,7 +12,7 @@ # AGENT_KEY_ENV env var an agent carries its bearer key in # AGENT_MODEL model alias the broker configures agents with # AGENT_OPENCLAW_GROUP_PATH Keycloak path of the openclaw agent-user group -# AGENT_PLATFORMS_OFFERED platforms the broker serves with this model +# AGENT_PLATFORMS_OFFERED comma-separated platforms the broker serves # DEPLOYMENT_MODE compose or swarm # IDLE_STOP, IDLE_MINUTES lifecycle settings the broker must run with # MAX_RUNNING bound on concurrently running agents @@ -29,8 +29,8 @@ OWNER_B="cli-isolation-b" FAILED=0 for required in hermes openclaw; do - case " ${AGENT_PLATFORMS_OFFERED} " in - *" ${required} "*) ;; + case ",${AGENT_PLATFORMS_OFFERED}," in + *",${required},"*) ;; *) echo "[FAIL] ${required} is not offered by this deploy, which serves '${AGENT_PLATFORMS_OFFERED}'." >&2 echo " The broker withholds a platform whose context minimum '${AGENT_MODEL}' misses," >&2 diff --git a/roles/svc-ai-agent-broker/templates/test.env.j2 b/roles/svc-ai-agent-broker/templates/test.env.j2 index 6628429429..04d9edc979 100644 --- a/roles/svc-ai-agent-broker/templates/test.env.j2 +++ b/roles/svc-ai-agent-broker/templates/test.env.j2 @@ -6,7 +6,7 @@ AGENT_GROUP_PATH={{ AGENT_BROKER_PLATFORMS_DECLARED.hermes.group }} AGENT_KEY_ENV={{ AGENT_BROKER_PLATFORMS_DECLARED.hermes.key_env }} AGENT_MODEL={{ AGENT_BROKER_MODEL }} AGENT_OPENCLAW_GROUP_PATH={{ AGENT_BROKER_PLATFORMS_DECLARED.openclaw.group }} -AGENT_PLATFORMS_OFFERED={{ AGENT_BROKER_PLATFORMS.keys() | sort | join(' ') | dotenv_quote }} +AGENT_PLATFORMS_OFFERED={{ AGENT_BROKER_PLATFORMS.keys() | sort | join(',') | dotenv_quote }} AGENT_PORT={{ AGENT_BROKER_PLATFORMS_DECLARED.hermes.port }} AGENT_RUNTIME={{ AGENT_BROKER_RUNTIME }} DEPLOYMENT_MODE={{ DEPLOYMENT_MODE }} diff --git a/roles/svc-net-tor/files/test/onion_ports.py b/roles/svc-net-tor/files/test/onion_ports.py index adb6a81c66..2312c60f80 100755 --- a/roles/svc-net-tor/files/test/onion_ports.py +++ b/roles/svc-net-tor/files/test/onion_ports.py @@ -23,7 +23,7 @@ carrying a test toolchain. Env (rendered into test.env from templates/test.env.j2): TOR_SOCKS SOCKS proxy, host:port ONION_HOST the node onion host - ONION_PORTS space-separated forwarded ports; empty means there is + ONION_PORTS comma-separated forwarded ports; empty means there is nothing to check ONION_PORT_TIMEOUT seconds per connect attempt. 60 for the same reason the domain probe uses it: Tor's SocksTimeout default is 120s @@ -150,7 +150,11 @@ def main() -> int: timeout = float( _required("ONION_PORT_TIMEOUT", "seconds per connect, flavor-dependent") ) - ports = [int(entry) for entry in os.environ.get("ONION_PORTS", "").split()] + ports = [ + int(entry) + for entry in os.environ.get("ONION_PORTS", "").split(",") + if entry.strip() + ] if not ports: print("[INFO] no onion-forwarded ports declared; nothing to probe") diff --git a/roles/svc-net-tor/templates/test.env.j2 b/roles/svc-net-tor/templates/test.env.j2 index 6e0bec9054..04b03a6fc5 100644 --- a/roles/svc-net-tor/templates/test.env.j2 +++ b/roles/svc-net-tor/templates/test.env.j2 @@ -1,6 +1,6 @@ NGINX_SERVERS_DIR={{ lookup('nginx', 'directories.configuration.servers') }} ONION_HOST={{ lookup('config', 'svc-net-tor', 'services.tor.node') }} -ONION_PORTS={{ lookup('tor_ports') | map(attribute='onion_port') | unique | sort | join(' ') | dotenv_quote }} +ONION_PORTS={{ lookup('tor_ports') | map(attribute='onion_port') | unique | sort | join(',') | dotenv_quote }} ONION_PORT_RETRIES=2 ONION_PORT_TIMEOUT=60 RETRIES=5 diff --git a/tests/lint/filesystem/env/test_env_value_space_quoted.py b/tests/lint/filesystem/env/test_env_value_space_quoted.py index b4c83be27b..35e1a7188c 100644 --- a/tests/lint/filesystem/env/test_env_value_space_quoted.py +++ b/tests/lint/filesystem/env/test_env_value_space_quoted.py @@ -11,6 +11,10 @@ Only ``join`` with a space separator is flagged. A comma or a semicolon produces one word and is safe unquoted, and ``to_json`` output is already covered by the repository's quoting convention. +``dotenv_quote`` does NOT fix this. In swarm mode it delivers the value +verbatim, because ``docker stack deploy`` keeps the quotes it would add and +the container would then read them as part of the value. + Per-line opt-out: ``# nocheck: env-space-value-unquoted``. """ @@ -38,8 +42,6 @@ def renders_unquoted_words(line: str) -> bool: value = match.group("value").strip() if not _SPACE_JOIN.search(value): return False - if "dotenv_quote" in value: - return False return not _QUOTED.match(value) @@ -72,7 +74,9 @@ class TestEnvValueSpaceQuoted(unittest.TestCase): "and leaves the variable unset, so a consumer running under " "`set -u` dies on an unbound variable that the rendered file " "appears to contain.\n\n" - "Fix: append `| dotenv_quote`.\n\n" + "Fix: join on a comma and split on one in the consumer. " + "`| dotenv_quote` does not help: swarm passes the value " + "verbatim, so the quotes would land inside it.\n\n" f"Offending lines:\n{formatted}" ) diff --git a/tests/unit/python/plugins/filter/test_dotenv_quote.py b/tests/unit/python/plugins/filter/test_dotenv_quote.py index 9d61bddbdd..904e0bd89d 100644 --- a/tests/unit/python/plugins/filter/test_dotenv_quote.py +++ b/tests/unit/python/plugins/filter/test_dotenv_quote.py @@ -94,14 +94,14 @@ class TestDotenvQuoteModeAware(unittest.TestCase): def test_swarm_non_string_is_stringified(self): self.assertEqual(self._render(42, mode="swarm"), "42") - def test_swarm_quotes_value_with_interior_whitespace(self): + def test_swarm_passes_a_value_with_spaces_through_unchanged(self): self.assertEqual( - self._render("My Org Helpdesk", mode="swarm"), '"My Org Helpdesk"' + self._render('{"hermes": {"group": "/x"}}', mode="swarm"), + '{"hermes": {"group": "/x"}}', + "swarm delivers the value verbatim, so quoting it makes the " + "container read the quotes as part of the JSON and parse a string", ) - def test_swarm_whitespace_value_escapes_quotes_not_dollars(self): - self.assertEqual(self._render('a "b" $c', mode="swarm"), '"a \\"b\\" $c"') - def test_compose_mode_quotes_like_default(self): self.assertEqual(self._render("abc", mode="compose"), '"abc"') self.assertEqual(self._render("$ecret", mode="compose"), '"$$ecret"') diff --git a/tests/unit/python/roles/svc-net-tor/test_test_env_sourcing.py b/tests/unit/python/roles/svc-net-tor/test_test_env_sourcing.py index e0a3f8ed8c..8bbd46ef02 100644 --- a/tests/unit/python/roles/svc-net-tor/test_test_env_sourcing.py +++ b/tests/unit/python/roles/svc-net-tor/test_test_env_sourcing.py @@ -68,7 +68,12 @@ class TestTestEnvSourcing(unittest.TestCase): self.assertEqual( result.stderr, "", "sourcing test.env must not run anything" ) - self.assertEqual(result.stdout, " ".join(str(port) for port in PORTS)) + self.assertEqual( + result.stdout, + ",".join(str(port) for port in PORTS), + "the value joins on a comma so it stays one shell word " + "without quoting, which swarm would deliver verbatim", + ) if __name__ == "__main__":