Skip to content
Open
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
6 changes: 6 additions & 0 deletions docs/mkdocs/en/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,12 @@ Assistant: Let me check what documentation is available.
- Collects specified output files and returns them
- Supports custom environment variable overrides

Both `skill_run` and `skill_exec` call `repository.skill_run_env(skill_name)`
to add skill-specific environment variables. Values returned by the hook do not
override keys already present in the call's `env` or non-empty host environment
values. Empty keys, blank values, and blocked keys such as `LD_PRELOAD` are ignored.
If the hook raises an exception, command execution continues.

**Prompt guidance**:

This tool is called by the LLM when ready to execute actual commands. The prompt should include detailed usage guidelines:
Expand Down
5 changes: 5 additions & 0 deletions docs/mkdocs/zh/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,11 @@ Assistant: Let me check what documentation is available.
- 收集指定的输出文件并返回
- 支持自定义环境变量覆盖

`skill_run` 和 `skill_exec` 都会调用 `repository.skill_run_env(skill_name)`
补充 Skill 专属环境变量。钩子返回的变量不会覆盖调用参数 `env` 中已有的键,也不会
覆盖宿主环境中的非空值。空键、空白值以及禁止注入的键(如 `LD_PRELOAD`)会被忽略;
钩子抛出异常时,命令仍继续执行。

**提示词指导**:

这个工具由 LLM 在准备好后执行实际命令。提示词应包含详细的使用指南:
Expand Down
73 changes: 73 additions & 0 deletions tests/skills/tools/test_skill_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#
# tRPC-Agent-Python is licensed under Apache-2.0.

import os
from unittest.mock import AsyncMock
from unittest.mock import MagicMock

Expand All @@ -13,10 +14,14 @@
from trpc_agent_sdk.code_executors import DEFAULT_IO_YIELD_MS
from trpc_agent_sdk.code_executors import DEFAULT_POLL_LINES
from trpc_agent_sdk.code_executors import DEFAULT_SESSION_TTL_SEC
from trpc_agent_sdk.code_executors import ENV_SKILL_NAME
from trpc_agent_sdk.code_executors import WorkspaceRunResult
from trpc_agent_sdk.skills.stager import SkillStageResult
from trpc_agent_sdk.skills.tools._skill_exec import ExecInput
from trpc_agent_sdk.skills.tools._skill_exec import PollSessionTool
from trpc_agent_sdk.skills.tools._skill_exec import SkillExecTool
from trpc_agent_sdk.skills.tools._skill_exec import WriteStdinTool
from trpc_agent_sdk.skills.tools._skill_exec import _ExecSession
from trpc_agent_sdk.skills.tools._skill_exec import _close_session
from trpc_agent_sdk.skills.tools._skill_exec import _collect_final_result
from trpc_agent_sdk.skills.tools._skill_exec import _detect_interaction
Expand All @@ -25,6 +30,7 @@
from trpc_agent_sdk.skills.tools._skill_exec import _start_session
from trpc_agent_sdk.skills.tools._skill_exec import create_exec_tools
from trpc_agent_sdk.skills.tools._skill_run import SkillRunFile
from trpc_agent_sdk.skills.tools._skill_run import SkillRunTool


def _make_exec_tool() -> SkillExecTool:
Expand All @@ -40,6 +46,7 @@ def _make_exec_tool() -> SkillExecTool:


class TestHelpers:

def test_last_non_empty_line(self):
assert _last_non_empty_line("a\n\nb\n") == "b"

Expand All @@ -59,6 +66,7 @@ def test_detect_interaction_selection(self):


class TestModelsAndConstants:

def test_exec_input_defaults(self):
inp = ExecInput(skill="s", command="echo hi")
assert inp.yield_time_ms == 0
Expand All @@ -73,6 +81,7 @@ def test_default_constants(self):


class TestSessionStore:

@pytest.mark.asyncio
async def test_put_get_remove(self):
tool = _make_exec_tool()
Expand All @@ -87,6 +96,7 @@ async def test_put_get_remove(self):


class TestFactoryAndDeclarations:

def test_create_exec_tools(self):
run_tool = MagicMock()
run_tool._repository = MagicMock()
Expand All @@ -105,6 +115,7 @@ def test_declaration_names(self):


class TestCloseSession:

@pytest.mark.asyncio
async def test_close_session(self):
sess = MagicMock()
Expand All @@ -113,6 +124,68 @@ async def test_close_session(self):
sess.proc.close.assert_awaited_once()


class TestExecutionEnvironment:

@pytest.mark.asyncio
@pytest.mark.parametrize("tool_name", ["skill_run", "skill_exec"])
@pytest.mark.parametrize(
("call_env", "host_env", "expected"),
[
pytest.param({}, None, "injected", id="repository"),
pytest.param({"TEST_SKILL_ENV": "explicit"}, None, "explicit", id="explicit"),
pytest.param({}, "host", "host", id="host"),
pytest.param({"TEST_SKILL_ENV": "explicit"}, "host", "explicit", id="explicit-over-host"),
pytest.param({}, " ", "injected", id="blank-host"),
pytest.param({"TEST_SKILL_ENV": ""}, None, "", id="empty-explicit"),
],
)
async def test_skill_tools_apply_repository_env(self, tmp_path, monkeypatch, tool_name, call_env, host_env,
expected):
if host_env is None:
monkeypatch.delenv("TEST_SKILL_ENV", raising=False)
else:
monkeypatch.setenv("TEST_SKILL_ENV", host_env)

ctx = MagicMock()
ctx.agent_name = ""
ctx.actions.state_delta = {}
ctx.session_state = {}
ws = MagicMock(path=str(tmp_path))
workspace_runtime = MagicMock(spec=BaseWorkspaceRuntime)
workspace_runtime.manager.return_value.create_workspace = AsyncMock(return_value=ws)
workspace_runtime.fs.return_value.collect = AsyncMock(return_value=[])
runner = workspace_runtime.runner.return_value
runner.run_program = AsyncMock(return_value=WorkspaceRunResult(stdout="done", exit_code=0))
runner.start_program = AsyncMock(return_value=MagicMock())

repository = MagicMock()
repository.get_workspace_runtime.return_value = workspace_runtime
repository.skill_run_env.return_value = {"TEST_SKILL_ENV": "injected"}
run_tool = SkillRunTool(repository=repository)
run_tool.skill_stager.stage_skill = AsyncMock(return_value=SkillStageResult(workspace_skill_dir="skills/test"))
monkeypatch.setattr(_ExecSession, "yield_output", AsyncMock(return_value=("running", "", 0, 0)))
tool = run_tool if tool_name == "skill_run" else SkillExecTool(run_tool)

await tool._run_async_impl(
tool_context=ctx,
args={
"skill": " test ",
"command": "echo done",
"env": call_env
},
)

if tool_name == "skill_run":
env = runner.run_program.call_args.args[1].env
effective_env = os.environ.copy()
effective_env.update(env)
else:
effective_env = runner.start_program.call_args.args[2].env
assert effective_env["TEST_SKILL_ENV"] == expected
assert effective_env[ENV_SKILL_NAME] == "test"
repository.skill_run_env.assert_called_once_with("test")


@pytest.mark.asyncio
async def test_collect_final_result_passes_workspace_runtime_and_returns_files():
ctx = MagicMock()
Expand Down
52 changes: 52 additions & 0 deletions tests/skills/tools/test_skill_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from trpc_agent_sdk.skills.tools._skill_run import SkillRunInput
from trpc_agent_sdk.skills.tools._skill_run import SkillRunOutput
from trpc_agent_sdk.skills.tools._skill_run import SkillRunTool
from trpc_agent_sdk.skills.tools._skill_run import _apply_skill_run_env
from trpc_agent_sdk.skills.tools._skill_run import _build_editor_wrapper_script
from trpc_agent_sdk.skills.tools._skill_run import _filter_failed_empty_outputs
from trpc_agent_sdk.skills.tools._skill_run import _is_text_mime
Expand All @@ -32,6 +33,7 @@ def _make_tool() -> SkillRunTool:


class TestSchemaHelpers:

def test_inline_json_schema_refs(self):
schema = {"$defs": {"X": {"type": "string"}}, "properties": {"x": {"$ref": "#/$defs/X"}}}
out = inline_json_schema_refs(schema)
Expand All @@ -40,6 +42,54 @@ def test_inline_json_schema_refs(self):


class TestModuleHelpers:

def test_apply_skill_run_env_filters_empty_entries(self, monkeypatch):
monkeypatch.delenv("TEST_SKILL_ENV", raising=False)
repository = MagicMock()
repository.skill_run_env.return_value = {
" TEST_SKILL_ENV ": " injected ",
"": "ignored",
" ": "ignored",
"TEST_EMPTY_ENV": " ",
}
env = {}

_apply_skill_run_env(repository, "test", env)

assert env == {"TEST_SKILL_ENV": " injected "}
repository.skill_run_env.assert_called_once_with("test")

@pytest.mark.parametrize(
"key",
[
"LD_PRELOAD",
"ld_preload",
"LD_LIBRARY_PATH",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"DYLD_FORCE_FLAT_NAMESPACE",
"OPENSSL_CONF",
],
)
def test_apply_skill_run_env_blocks_loader_keys(self, monkeypatch, key):
monkeypatch.delenv(key, raising=False)
repository = MagicMock()
repository.skill_run_env.return_value = {key: "blocked"}
env = {}

_apply_skill_run_env(repository, "test", env)

assert env == {}

def test_apply_skill_run_env_ignores_repository_errors(self):
repository = MagicMock()
repository.skill_run_env.side_effect = RuntimeError("repository env unavailable")
env = {"TEST_SKILL_ENV": "explicit"}

_apply_skill_run_env(repository, "test", env)

assert env == {"TEST_SKILL_ENV": "explicit"}

def test_is_text_mime(self):
assert _is_text_mime("text/plain") is True
assert _is_text_mime("application/json") is True
Expand Down Expand Up @@ -111,6 +161,7 @@ def test_build_editor_wrapper_script(self):


class TestModels:

def test_run_models(self):
inp = SkillRunInput(skill="s", command="echo hi")
out = SkillRunOutput()
Expand All @@ -121,6 +172,7 @@ def test_run_models(self):


class TestSkillRunToolBasics:

def test_resolve_cwd(self):
tool = _make_tool()
assert tool._resolve_cwd("", "skills/x") == "skills/x"
Expand Down
2 changes: 2 additions & 0 deletions trpc_agent_sdk/skills/tools/_skill_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
from ._skill_run import SkillRunInput
from ._skill_run import SkillRunOutput
from ._skill_run import SkillRunTool
from ._skill_run import _apply_skill_run_env
from ._skill_run import _filter_failed_empty_outputs
from ._skill_run import _select_primary_output
from ._skill_run import _truncate_output
Expand Down Expand Up @@ -447,6 +448,7 @@ async def _run_async_impl(
extra_env: dict[str, str] = dict(inputs.env)
if ENV_SKILL_NAME not in extra_env:
extra_env[ENV_SKILL_NAME] = normalized_skill
_apply_skill_run_env(repository, normalized_skill, extra_env)
merged_env = _build_exec_env(ws, extra_env)

# Start interactive program session via runtime runner.
Expand Down
35 changes: 20 additions & 15 deletions trpc_agent_sdk/skills/tools/_skill_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,25 @@
# ---------------------------------------------------------------------------


def _apply_skill_run_env(repository: BaseSkillRepository, skill_name: str, env: dict[str, str]) -> None:
"""Inject skill-specific env without overriding explicit or non-empty host values."""
try:
skill_env: dict[str, str] = repository.skill_run_env(skill_name)
for k, v in skill_env.items():
k = k.strip()
if not k or not v.strip():
Comment on lines +123 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 注入循环对值调用 v.strip()(第 125 行)时未做类型防护;BaseSkillRepository.skill_run_env 是抽象接口(_repository.py:125),自定义实现可返回含非字符串值(None/数字/任意对象)的 dict,此时抛 AttributeError 被外层 except Exception: pass 整体吞掉,同一技能其余 env 键全部静默丢失且无任何日志。

触发条件: 任何自定义 BaseSkillRepository.skill_run_env 返回含非 str 值的 dict(树内 loader _build_skill_run_env 保证 str,但该钩子是公开扩展点且无类型强制);skill_exec 路径此前从不调用该钩子,属本次变更新引入的可达面,skill_run 旧路径同样受影响但本次将其复制到了第二个工具。

实际影响: 注入在首个非法值处中断,技能所需凭据(如 api_key)部分或全部缺失,命令因缺环境变量失败而错误信息与真实原因无关,难以排查;已写入的前几个键造成不确定的部分注入状态。

修正方向: 对值(与键)做 str(v).strip() 强制转换(与 loader _build_skill_run_env 的 str(key)/str(val) 做法一致),或对非字符串项 continue 跳过,避免单键毁掉整批注入。

continue
if k in env: # don't override explicit tool-call env
continue
if os.environ.get(k, "").strip(): # don't override host env
continue
if k.upper() in _BLOCKED_SKILL_ENV_KEYS:
continue
env[k] = v
Comment on lines +123 to +133

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 注入循环对 skill_run_env 返回的键先做 k = k.strip(),随后的 if k in env、os.environ.get(k)、blocked 检查与最后写入 env[k] = v 均使用 strip 后的键,但调用方 env(SkillRunInput.env/ExecInput.env)与宿主环境的键未 strip——pydantic dict[str,str] 无任何键约束(_skill_run.py:335、_skill_exec.py:118),LLM 输入的 env 含首尾空白键(如 "TEST_SKILL_ENV ")时,k in env 检查放行,注入值写入 strip 后键,两种键形式并存。

触发条件: 模型/上游在 env 参数中传入含首尾空白的键(如 {"TEST_SKILL_ENV ": "explicit"}),同时 skill_run_env 返回该键的 strip 形式。skill_exec 路径下 _build_exec_env 以 os.environ.copy() 起步,空白键(如 " RUN_DIR ")不被基础键去重逻辑(if k not in extra)拦截,同键两种形式同时进入子进程环境。

实际影响: 子进程同时看到 RUN_DIR 与 " RUN_DIR " 两个环境变量:后者携带注入值(正确值被空转浪费),前者继承宿主旧值可能指向上一工作区/任意目录,skill 脚本按 $RUN_DIR/$OUTPUT_DIR 操作时行为错位且难以排查;显式 env 优先级保障被空白键静默绕过。

修正方向: 在 _apply_skill_run_env 内将调用方 env 的键统一 strip 后再做存在性检查(或对两侧键做同一归一化后比较),并补充空白键组合的回归测试。

except Exception: # pylint: disable=broad-except
pass
Comment on lines +119 to +135

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _apply_skill_run_env 的拦截检查只覆盖调用方 env(inputs.env + SKILL_NAME)与宿主 os.environ 中已存在的键,但 WORKSPACE_DIR/SKILLS_DIR/WORK_DIR/OUTPUT_DIR/RUN_DIR 这五个工作区基础键是在注入之后才由 _build_exec_env(_skill_exec.py:307-308)或 local runner 的 _build_program_env(_local_ws_runtime.py:563-567)生成并合并,且合并逻辑均为 user/env 优先、基础键仅补齐缺失。仓库 skill_run_env 返回同名键时会被直接注入并覆盖这些运行目录变量——本次变更把 skill_run_env 引入 skill_exec 路径,使该冲突面首次对该调用路径可达(此前 skill_exec 从不调用该钩子)。

触发条件: skill 配置 env: 或自定义 BaseSkillRepository.skill_run_env 返回任一工作区基础键名(loader 的 _build_skill_run_env 只过滤 7 个 blocked 键,不拦这 5 个,见 _skill_loader.py:468-484),且 agent 宿主进程恰好没有同名环境变量(宿主通常不导出这些键,os.environ.get(k) 为空即放行),无需 LLM 配合构造。

实际影响: 子进程收到的 $OUTPUT_DIR/$RUN_DIR/$WORK_DIR 指向配置值而非真实工作区目录:_build_exec_env 已用 os.makedirs 创建真实 run_dir,注入后 $RUN_DIR 错位并遗留孤儿目录;按 $OUTPUT_DIR 语义写出的产物与按 ws.path/DIR_OUT 收集的 _prepare_outputs/_AUTO_EXPORT_PATTERN 不一致,产物静默丢失或错位。

修正方向: 在 _apply_skill_run_env 内对 WORKSPACE_ENV_DIR_KEY、ENV_SKILLS_DIR、ENV_WORK_DIR、ENV_OUTPUT_DIR、ENV_RUN_DIR(均已在 code_executors/_constants.py 定义)加与 _BLOCKED_SKILL_ENV_KEYS 同等的放行排除;同时补充一条 repo env 含基础键名时断言真实目录值不被覆盖的回归测试。



def _is_text_mime(mime: str) -> bool:
"""Return True when *mime* is a text-like content type."""
if not mime:
Expand Down Expand Up @@ -770,21 +789,7 @@ async def _run_program(

# Inject skill-specific env from repository (e.g. api_key → primary_env)
repository = self._get_repository(ctx)
try:
skill_env: dict[str, str] = repository.skill_run_env(input_data.skill)
for k, v in skill_env.items():
k = k.strip()
if not k or not v.strip():
continue
if k in env: # don't override explicit tool-call env
continue
if os.environ.get(k, "").strip(): # don't override host env
continue
if k.upper() in _BLOCKED_SKILL_ENV_KEYS:
continue
env[k] = v
except Exception: # pylint: disable=broad-except
pass
_apply_skill_run_env(repository, input_data.skill, env)

# Stage editor helper if requested
await self._prepare_editor_env(ctx, ws, workspace_runtime, env, input_data.editor_text)
Expand Down
Loading