Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,27 @@ def _channel_secret_env_names(value: Any) -> set[str]:
return names


def _resolve_skill_paths(config: contract.AgentConfig, base_dir: Path) -> list[str]:
paths: list[str] = []
for index, path in enumerate(config.skills.paths if config.skills else []):
try:
directory = (base_dir / path).resolve(strict=True)
except (OSError, RuntimeError):
raise lifecycle.LifecycleError(
"openclaw_skill_not_found",
"A configured NeMo Fabric skill path does not exist",
metadata={"field": f"skills.paths[{index}]"},
) from None
if not directory.is_dir() or not (directory / "SKILL.md").is_file():
raise lifecycle.LifecycleError(
"openclaw_skill_invalid",
"NeMo Fabric skill paths must be directories containing a SKILL.md file",
metadata={"field": f"skills.paths[{index}]"},
)
paths.append(str(directory))
return paths


def _resolve_command(settings: dict[str, Any], base_dir: Path) -> Path:
configured = settings.get("openclaw_command", "openclaw")
if not isinstance(configured, str) or not configured.strip():
Expand Down Expand Up @@ -468,14 +489,9 @@ def _openclaw_config(
result["agents"]["entries"] = {agent_id: {}}
if config.instructions is not None and config.instructions.system is not None:
result["agents"]["defaults"]["contextInjection"] = "never"
if config.skills and config.skills.paths:
result["skills"] = {
"load": {
"extraDirs": [
str((base_dir / path).resolve()) for path in config.skills.paths
]
}
}
skill_paths = _resolve_skill_paths(config, base_dir)
if skill_paths:
result["skills"] = {"load": {"extraDirs": skill_paths}}
if config.tools is not None:
tools: dict[str, list[str]] = {}
if config.tools.enabled == []:
Expand Down
46 changes: 41 additions & 5 deletions tests/adapters/test_openclaw.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def _context(workspace: Path, runtime_id: str = "openclaw-runtime") -> RuntimeCo
return RuntimeContext.from_mapping(payload)


def _config(command: Path) -> AgentConfig:
def _config(command: Path, *, with_skills: bool = False) -> AgentConfig:
settings: dict[str, object] = {"openclaw_command": str(command)}
return AgentConfig.from_mapping(
{
Expand All @@ -65,7 +65,7 @@ def _config(command: Path) -> AgentConfig:
"max_tokens": 64,
}
},
"skills": {"paths": ["skills"]},
"skills": {"paths": ["skills"]} if with_skills else None,
"tools": {
"enabled": ["browser", "web_search"],
"blocked": ["exec"],
Expand All @@ -85,7 +85,7 @@ def _config(command: Path) -> AgentConfig:
"custom_headers": {"X-Test": "value"},
"blocked_tools": ["delete_*"],
},
}
},
},
}
)
Expand Down Expand Up @@ -249,6 +249,9 @@ def test_openclaw_gateway_command_uses_setpriv_on_linux_when_available(
async def test_openclaw_runtime_generates_config_invokes_and_cleans_up(
mock_openclaw: Path, tmp_path: Path
):
skills = tmp_path / "skills"
skills.mkdir()
(skills / "SKILL.md").write_text("# Test skill\n", encoding="utf-8")
capture = tmp_path / "config.json"
request_capture = tmp_path / "request.json"
readonly_capture = tmp_path / "config-readonly.txt"
Expand All @@ -264,7 +267,7 @@ async def test_openclaw_runtime_generates_config_invokes_and_cleans_up(

await runtime.start(
{
"config": _config(mock_openclaw),
"config": _config(mock_openclaw, with_skills=True),
"runtime_context": context.to_mapping(),
"base_dir": str(tmp_path),
}
Expand Down Expand Up @@ -333,6 +336,39 @@ async def test_openclaw_runtime_generates_config_invokes_and_cleans_up(
assert not state_root.exists()


@pytest.mark.parametrize(
("invalid_path", "expected_code"),
[
("missing", "openclaw_skill_not_found"),
("file", "openclaw_skill_invalid"),
("missing_manifest", "openclaw_skill_invalid"),
],
)
def test_openclaw_rejects_invalid_skill_paths(
mock_openclaw: Path,
tmp_path: Path,
invalid_path: str,
expected_code: str,
):
skill_path = tmp_path / "skills"
if invalid_path == "file":
skill_path.write_text("not a directory", encoding="utf-8")
elif invalid_path == "missing_manifest":
skill_path.mkdir()

with pytest.raises(adapter.lifecycle.LifecycleError) as caught:
adapter._openclaw_config(
_config(mock_openclaw, with_skills=True),
_context(tmp_path),
base_dir=tmp_path,
port=20_000,
token_env="OPENCLAW_GATEWAY_TOKEN",
)

assert caught.value.code == expected_code
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assert caught.value.metadata == {"field": "skills.paths[0]"}


def test_openclaw_preserves_context_injection_without_system_instruction(
mock_openclaw: Path, tmp_path: Path
):
Expand Down Expand Up @@ -622,7 +658,7 @@ async def test_openclaw_channel_secret_refs_require_environment_variables(
def test_openclaw_attach_rejects_deployment_owned_configuration(
mock_openclaw: Path,
):
config = _config(mock_openclaw)
config = _config(mock_openclaw, with_skills=True)
assert config.harness is not None
config.harness.settings["channel_config"] = {
"channels": {"telegram": {"enabled": True}},
Expand Down
Loading