Files
ragflow/test/unit_test/agent/component/test_iterationitem_at_split.py
S 1861087787 fix(agent): defend against @ in var names at all template-split sites (#16469)
## Summary

While fixing #16467 (IterationItem crash on `@` in user-defined output
keys), an audit of `agent/**/*.py` revealed **three additional sites**
with the same vulnerability. This PR hardens all of them with
`maxsplit=1` and adds regression tests.

This is **defense-in-depth hardening**, not a behavior change. The
current `variable_ref_patt` regex constrains `var_nm` to
`[A-Za-z0-9_.-]+`, so single-`@` templates resolve exactly as before.
The `maxsplit=1` only kicks in if the trailing side itself contains `@`
— currently unreachable from the public DSL surface, but trivially
exploitable the moment a user-defined output key happens to contain `@`
(e.g. `user@email`) or the regex is ever relaxed.

> **Note on issue scope**: The primary fix for #16467 (the
`list_tenant_added_models` `ValueError` crash on `@` in model names) is
in PR #16468. This PR is a **follow-up hardening sweep** of the same
vulnerability class found in `agent/` during that audit; it does not
duplicate or replace #16468.

## Sites hardened

| File | Line | Method |
|------|------|--------|
| `agent/canvas.py` | 206 | `Graph.get_variable_value` |
| `agent/canvas.py` | 256 | `Graph.set_variable_value` |
| `agent/component/base.py` | 533 |
`ComponentBase.get_input_elements_from_text` |
| `agent/component/iterationitem.py` | 88 |
`IterationItem.output_collation` |

All now use `split("@", 1)` with an inline comment explaining the
rationale. The trailing side keeps any embedded `@`.

## Sites already safe (audited but left alone)

| File | Reason safe |
|------|------------|
| `agent/canvas.py:708` (`is_reff`) | Pre-checks `len(arr) != 2` |
| `agent/component/categorize.py` | Uses `rsplit` |
| `agent/component/iteration.py` | Pre-validates via regex |
| Other call sites | `rsplit` or regex pre-validation |

## Regression tests

9 new tests across 2 files, all `pytest.mark.p2`:

| File | Tests |
|------|-------|
| `test/unit_test/agent/test_canvas_at_split.py` | 6 —
`get_variable_value`, `set_variable_value`, round-trip, single-`@`,
missing-component |
| `test/unit_test/agent/component/test_iterationitem_at_split.py` | 3 —
`output_collation` with `@` in var, single-`@`, non-matching cid |

Each test was **verified to fail with `ValueError: too many values to
unpack (expected 2)`** when the corresponding fix is temporarily
reverted, confirming the tests actually catch the bug rather than just
exercising the happy path.

## Test results

```
9 passed in 0.04s
```

Full agent unit suite also clean (38 passed, 3 skipped; 6 unrelated
pre-existing collection errors from missing `peewee`/`requests` in local
venv — not caused by this PR).

## Related

- Issue: #16467
- Primary fix PR: #16468 (closes the issue)
- This PR: defense-in-depth follow-up, intentionally non-blocking on
#16467

---------

Co-authored-by: skbs-eng <skbs-eng@users.noreply.github.com>
2026-07-03 19:26:27 +08:00

354 lines
13 KiB
Python

#
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
#
# 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.
#
"""Hardening regression test for `@`-splitting in
`agent/component/iterationitem.py:82` (inside `output_collation`).
Before the fix, `o["ref"].split("@")` raised `ValueError: too many values
to unpack` whenever a user-defined output key contained `@` (e.g. an
output named `foo@bar` referenced from another component). After the
fix, the call uses `split("@", 1)`, preserving the trailing `@`-bearing
key in `var` so the collation append can proceed normally.
The module is loaded via `importlib` after stubbing its heavyweight
imports — same isolation strategy as
`test/testcases/test_web_api/test_canvas_app/test_iterationitem_unit.py`.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import MagicMock
import pytest
# ─── Module loader ────────────────────────────────────────────────────
def _load_iterationitem_module(monkeypatch):
"""Load `agent.component.iterationitem` in isolation with stubs.
Unlike `test_canvas_at_split.py`, the iterationitem module's import
graph is small enough that we can run the real `common.*` modules
(after stubbing their `quart` dependency). That gives us high
fidelity on the `_param.outputs` iteration logic without dragging
in the canvas-level graph (which the tests don't need).
Returns:
The loaded `agent.component.iterationitem` module object.
"""
repo_root = Path(__file__).resolve().parents[4]
# Stub `quart` because `common.connection_utils` imports from it.
quart_stub = ModuleType("quart")
quart_stub.make_response = MagicMock()
quart_stub.jsonify = MagicMock()
monkeypatch.setitem(sys.modules, "quart", quart_stub)
# Stub the heavy transitive imports so we never touch pandas/jinja2/etc.
common_pkg = ModuleType("common")
common_pkg.__path__ = [str(repo_root / "common")]
monkeypatch.setitem(sys.modules, "common", common_pkg)
conn_spec = importlib.util.spec_from_file_location("common.connection_utils", repo_root / "common" / "connection_utils.py")
conn_mod = importlib.util.module_from_spec(conn_spec)
monkeypatch.setitem(sys.modules, "common.connection_utils", conn_mod)
conn_spec.loader.exec_module(conn_mod)
misc_spec = importlib.util.spec_from_file_location("common.misc_utils", repo_root / "common" / "misc_utils.py")
misc_mod = importlib.util.module_from_spec(misc_spec)
monkeypatch.setitem(sys.modules, "common.misc_utils", misc_mod)
misc_spec.loader.exec_module(misc_mod)
constants_mod = ModuleType("common.constants")
class _RetCode:
"""Minimal stand-in for `common.constants.RetCode`.
Only the two integer sentinels actually referenced by
`iterationitem.py` are defined; everything else would otherwise
require loading the entire constants module.
"""
SUCCESS = 0
EXCEPTION_ERROR = 100
constants_mod.RetCode = _RetCode
monkeypatch.setitem(sys.modules, "common.constants", constants_mod)
agent_pkg = ModuleType("agent")
agent_pkg.__path__ = [str(repo_root / "agent")]
monkeypatch.setitem(sys.modules, "agent", agent_pkg)
agent_settings = ModuleType("agent.settings")
agent_settings.FLOAT_ZERO = 1e-8
agent_settings.PARAM_MAXDEPTH = 5
monkeypatch.setitem(sys.modules, "agent.settings", agent_settings)
component_pkg = ModuleType("agent.component")
component_pkg.__path__ = [str(repo_root / "agent" / "component")]
monkeypatch.setitem(sys.modules, "agent.component", component_pkg)
# Provide a minimal `ComponentBase` for the iterationitem module to
# inherit from. Only the surface used by `output_collation` matters:
# `output(var_nm)`, `set_output(key, value)`, plus the parent lookup
# `get_parent()` is defined on the test instance directly.
class _ComponentBaseStub:
"""No-op stand-in for the real `ComponentBase`.
`iterationitem.IterationItem` extends this class, so any
instantiation path that isn't bypassed via `__new__` would
require it to be importable. We deliberately no-op every
method so that nothing incidental fires during loading.
"""
def __init__(self, *a, **kw):
"""Accept and discard all args; the stub is purely nominal."""
pass
def output(self, var_nm=None):
"""Return an empty string for any variable lookup."""
return ""
def set_output(self, key, value):
"""Discard writes; iterationitem test paths verify via
`parent._param.outputs`, not via the base class."""
pass
class _ComponentParamBaseStub:
"""Stand-in for `ComponentParamBase` with mutable `outputs`/`inputs`."""
def __init__(self):
"""Initialize empty `outputs` and `inputs` dicts."""
self.outputs = {}
self.inputs = {}
def check(self):
"""Always succeed; validation is not exercised in these tests."""
return True
base_mod = ModuleType("agent.component.base")
base_mod.ComponentBase = _ComponentBaseStub
base_mod.ComponentParamBase = _ComponentParamBaseStub
monkeypatch.setitem(sys.modules, "agent.component.base", base_mod)
iterationitem_spec = importlib.util.spec_from_file_location(
"agent.component.iterationitem",
repo_root / "agent" / "component" / "iterationitem.py",
)
iterationitem_mod = importlib.util.module_from_spec(iterationitem_spec)
monkeypatch.setitem(sys.modules, "agent.component.iterationitem", iterationitem_mod)
iterationitem_spec.loader.exec_module(iterationitem_mod)
return iterationitem_mod
# ─── Fixtures ─────────────────────────────────────────────────────────
class _ParentStub:
"""Parent iteration component.
`_param.outputs` is keyed by output name; each entry has a `"ref"`
pointing to a child component (`"<cid>@<var_name>"`).
"""
def __init__(self, _id, outputs, component_name="Iteration"):
"""Store `_id`, default each output's `"value"` to `[]`, expose
the canonical `outputs` dict on a `_param` SimpleNamespace."""
self._id = _id
self.component_name = component_name
# Normalize: every output entry must carry a `"value"` key so
# `p.output(k)` and the post-append `p.set_output(k, res)` work
# the same way they would on a real `ComponentBase`.
normalized = {}
for name, payload in outputs.items():
entry = dict(payload)
entry.setdefault("value", [])
normalized[name] = entry
self._param = SimpleNamespace(outputs=normalized)
def output(self, var_nm):
"""Return the `"value"` of the named output, or `""` if absent."""
return self._param.outputs.get(var_nm, {}).get("value", "")
def set_output(self, key, value):
"""Write `value` into `_param.outputs[key]["value"]`, creating
the entry with the appropriate `type` sentinel on first write."""
if key not in self._param.outputs:
self._param.outputs[key] = {"value": None, "type": str(type(value))}
self._param.outputs[key]["value"] = value
class _ChildStub:
"""Child component inside an iteration.
`_param.outputs` stores the child component's own outputs in the
`ComponentBase` `{"value": ...}` shape.
"""
def __init__(self, _id, parent, output_values):
"""Bind to a parent and seed `_param.outputs` from a flat dict.
Args:
_id: Child component id used as the `<cid>` half of refs.
parent: The parent iteration component (for `get_parent`).
output_values: `{output_name: value}` flat mapping, wrapped
into the `ComponentBase` `{"value": ..., "type": ...}`
shape before storage.
"""
self._id = _id
self._parent = parent
self.component_name = "Generate"
self._param = SimpleNamespace(outputs={k: {"value": v, "type": str(type(v))} for k, v in output_values.items()})
def get_parent(self):
"""Return the parent iteration component passed at construction."""
return self._parent
def output(self, var_nm):
"""Return the `"value"` of the named output, or `""` if absent."""
return self._param.outputs.get(var_nm, {}).get("value", "")
def set_output(self, key, value):
"""Write `value` into `_param.outputs[key]["value"]`, creating
the entry with the appropriate `type` sentinel on first write."""
if key not in self._param.outputs:
self._param.outputs[key] = {"value": None, "type": str(type(value))}
self._param.outputs[key]["value"] = value
class _CanvasStub:
"""Minimal canvas: only `components` (dict) and `get_component_obj`
are touched by `output_collation`."""
def __init__(self, components):
"""Store the `{cid: component}` lookup used by `get_component_obj`."""
self.components = components
def get_component_obj(self, cid):
"""Look up a component by id; the stub never returns `None`."""
return self.components[cid]
def _make_iteration_item(module, canvas, parent):
"""Construct an `IterationItem` bypassing its normal `__init__`.
`output_collation` only touches `self._canvas`, `self._id`, and
`self.get_parent()`, so a stripped-down instance is sufficient.
"""
inst = module.IterationItem.__new__(module.IterationItem)
inst._canvas = canvas
inst._id = "IterationItem:test"
inst._param = SimpleNamespace(outputs={}, inputs={})
inst._idx = 0
inst.get_parent = MagicMock(return_value=parent)
return inst
# ─── Tests ────────────────────────────────────────────────────────────
@pytest.mark.p2
def test_output_collation_accepts_at_in_var_name(monkeypatch):
"""The parent iteration's output `result` references the child's
output `foo@bar` (a user-defined key containing `@`). Before the
fix, `o["ref"].split("@")` raised `ValueError: too many values to
unpack`. After the fix, `output_collation` must:
1. Not raise.
2. Append the child's `foo@bar` value into the parent's `result`
list (i.e. treat `var = "foo@bar"` as a literal output name).
"""
module = _load_iterationitem_module(monkeypatch)
parent_outputs = {
"result": {"ref": "child-1@foo@bar"},
}
parent = _ParentStub("iter-pid", parent_outputs)
child = _ChildStub(
_id="child-1",
parent=parent,
output_values={"foo@bar": "value-A"},
)
canvas = _CanvasStub({"child-1": child})
item = _make_iteration_item(module, canvas, parent)
# Should NOT raise ValueError.
item.output_collation()
# The child's `foo@bar` value should have been appended to the
# parent's `result` list.
assert parent._param.outputs["result"]["value"] == ["value-A"]
@pytest.mark.p2
def test_output_collation_single_at_still_works(monkeypatch):
"""Sanity check: the existing single-`@` ref path is unaffected."""
module = _load_iterationitem_module(monkeypatch)
parent_outputs = {
"result": {"ref": "child-1@normal_var"},
}
parent = _ParentStub("iter-pid", parent_outputs)
child = _ChildStub(
_id="child-1",
parent=parent,
output_values={"normal_var": "value-B"},
)
canvas = _CanvasStub({"child-1": child})
item = _make_iteration_item(module, canvas, parent)
item.output_collation()
assert parent._param.outputs["result"]["value"] == ["value-B"]
@pytest.mark.p2
def test_output_collation_skips_non_matching_cid(monkeypatch):
"""When the ref's `cid` doesn't match the current `cid`, the loop
must skip silently even if `var` contains `@`. This guards against
an over-eager fix that might, e.g., re-split on the wrong side."""
module = _load_iterationitem_module(monkeypatch)
# The ref points at `other-cid`, not `child-1`.
parent_outputs = {
"result": {"ref": "other-cid@foo@bar"},
}
parent = _ParentStub("iter-pid", parent_outputs)
child = _ChildStub(
_id="child-1",
parent=parent,
output_values={"foo@bar": "ignored"},
)
canvas = _CanvasStub({"child-1": child})
item = _make_iteration_item(module, canvas, parent)
# Must not raise.
item.output_collation()
# Nothing should have been appended (the ref didn't match this cid).
assert parent._param.outputs["result"]["value"] == []