From 121560b98cccd35cdf14eccf957250abc10469cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Thu, 6 Aug 2026 22:04:20 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(harness):=20=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E6=8A=80=E8=83=BD=E4=BA=A7=E7=89=A9=E6=9C=AA=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E8=A1=A5=E7=99=BB=E4=B8=BA=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=20artifact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 harness v2 重构本 PR:新版管线(workspace_file artifact + HarnessArtifactDownloads 卡片 + 安全下载端点)已覆盖产出下载主链路, 原 AgentOutputFile 表/outputs 目录/独立下载端点/消息 metadata 挂载全部 撤下,只保留本 PR 的核心增量——自动补登兜底: - runner:_execute_plan 在 structured 回写工作区相对 artifact_dir (仅 workspace_root 存在时;试运行不落工作区则不带) - invoker:模型未在结果 JSON 声明 artifacts 且运行成功时,自动扫描 artifact_dir 把净产出文件登记为 workspace_file artifact(≤20 个、 跳过空文件、拒绝越出 TaskFrame 工作区的路径;每个文件仍经 open_harness_artifact 校验+sha256),并记 general_skill_artifacts_auto_declared 事件 - prompt 修正:repair prompt 的 OUTPUT_DIR 与实际环境变量 ARTIFACT_DIR 不一致(main 预存在);两个 prompt 补充自动补登语义说明 测试:runner 回写字段/有 workspace 与否;兜底补登(sha256/大小/来源断言)、 显式声明不重复、失败运行不补登、逃逸路径拒绝;全量 1303 passed、ruff 零告警 --- .../app/core/harness_capability_invoker.py | 47 +++- backend/app/general_skills/runner.py | 5 + .../prompts/general_skill_repair_prompt.md | 4 +- .../prompts/general_skill_runner_prompt.md | 2 +- ...test_general_skill_artifact_autodeclare.py | 248 ++++++++++++++++++ 5 files changed, 297 insertions(+), 9 deletions(-) create mode 100644 backend/tests/test_general_skill_artifact_autodeclare.py diff --git a/backend/app/core/harness_capability_invoker.py b/backend/app/core/harness_capability_invoker.py index 44e26333..98aaf907 100644 --- a/backend/app/core/harness_capability_invoker.py +++ b/backend/app/core/harness_capability_invoker.py @@ -721,13 +721,17 @@ def _invoke_general_skill( for item in (structured.get("artifact_errors") or [])[:20] if isinstance(item, dict) ] + declared = response.artifacts or [ + item + for item in (structured.get("artifacts") or [])[:20] + if isinstance(item, dict) + ] + if not declared and succeeded: + # 兜底:模型未在结果 JSON 声明产物时,自动扫描本次运行的 artifact_dir 补登, + # 产出文件不因"忘了声明"而丢失(声明式仍是首选路径) + declared = self._auto_declare_artifacts(structured) artifacts, publish_errors = self._general_skill_artifacts( - response.artifacts - or [ - item - for item in (structured.get("artifacts") or [])[:20] - if isinstance(item, dict) - ], + declared, skill_slug=skill.slug, ) artifact_errors.extend(publish_errors) @@ -774,6 +778,37 @@ def _invoke_general_skill( }, } + def _auto_declare_artifacts(self, structured: dict[str, Any]) -> list[dict[str, Any]]: + """未声明产物的兜底:扫描本次运行的 artifact_dir,把净新增文件自动登记为产物。 + + 只接受 runner 写入 structured 的工作区相对 artifact_dir(我们自己注入的), + 拒绝越出 TaskFrame 工作区的路径;每个文件仍经 open_harness_artifact 校验。 + """ + artifact_dir = str(structured.get("artifact_dir") or "").strip() + if not artifact_dir: + return [] + try: + workspace_root = self.workspace_root.resolve() + root = (workspace_root / artifact_dir).resolve() + if workspace_root not in root.parents or not root.is_dir(): + return [] + declared: list[dict[str, Any]] = [] + for path in sorted(root.rglob("*")): + if not path.is_file() or path.stat().st_size == 0: + continue + relative = path.relative_to(root).as_posix() + declared.append({"path": f"{artifact_dir}/{relative}", "display_name": path.name}) + if len(declared) >= 20: + break + except OSError: + return [] + if declared: + self._emit_trace( + "general_skill_artifacts_auto_declared", + {"count": len(declared), "artifact_dir": artifact_dir}, + ) + return declared + def _general_skill_artifacts( self, declared: list[dict[str, Any]], diff --git a/backend/app/general_skills/runner.py b/backend/app/general_skills/runner.py index f3e15f81..09aebcf6 100644 --- a/backend/app/general_skills/runner.py +++ b/backend/app/general_skills/runner.py @@ -804,6 +804,11 @@ def _execute_plan( artifact_root=artifact_dir, workspace_root=workspace_root, ) + if workspace_root is not None: + # 供 invoker 在产物未声明时自动扫描补登(工作区相对路径) + structured.setdefault( + "artifact_dir", artifact_dir.relative_to(workspace_root).as_posix() + ) if return_code != 0: structured.setdefault("success", False) structured.setdefault("error", f"runner exited with code {return_code}") diff --git a/backend/app/llm/prompts/general_skill_repair_prompt.md b/backend/app/llm/prompts/general_skill_repair_prompt.md index 7cbd57de..76e43103 100644 --- a/backend/app/llm/prompts/general_skill_repair_prompt.md +++ b/backend/app/llm/prompts/general_skill_repair_prompt.md @@ -12,8 +12,8 @@ Markdown 可能非常混乱,不一定有 frontmatter、标题、固定字段 - 如果选择 runtime=`python`,code 必须是完整 Python 代码,并从标准输入读取 JSON,字段包括 query、skill_slug、skill_name、skill_workspace、output_dir、skill_files。 - skill_workspace 是运行时恢复出的技能文件夹绝对路径;如果技能依赖同目录的脚本、模板、数据或说明文件,应从 skill_workspace 中读取,不要假设文件在当前仓库。 - 程序必须向标准输出打印一个 JSON 对象。 -- 如果任务产生需要交付给用户下载的最终文件,必须写入 `OUTPUT_DIR`,并在标准输出 JSON 的 `artifacts` 数组中逐个显式声明相对 `OUTPUT_DIR` 的路径,可附带 `display_name` 和 `description`。 -- `artifacts` 只列最终交付物;不得列入输入附件、技能包文件、缓存、日志、临时文件、runner 源码或构建中间产物。修复时必须保留这一显式交付协议,禁止改成扫描目录。 +- 如果任务产生需要交付给用户下载的最终文件,必须写入 `ARTIFACT_DIR`(Python 使用 stdin 的 `artifact_dir`),并在标准输出 JSON 的 `artifacts` 数组中逐个显式声明相对该目录的路径,可附带 `display_name` 和 `description`。 +- `artifacts` 只列最终交付物;不得列入输入附件、技能包文件、缓存、日志、临时文件、runner 源码或构建中间产物。修复时必须保留这一显式交付协议,禁止改成扫描目录。未声明时系统会把 artifact 目录里的文件自动补登为下载产物(丢失 display_name/description),显式声明仍是首选。 - 只能使用 SKILL.md 或 package.files 明确提供的脚本、数据、命令、URL 和 API。不要自行发明第三方接口、备用 URL 或在线服务;如果文档没有足够执行来源,返回稳定失败 JSON,并设置 retryable=false。 - 如果外部网络不可用、API 返回异常、页面结构无法解析或结果不符合预期,程序也必须返回稳定 JSON,不要崩溃。 - 失败 JSON 不要只写 `Fetch failed` 这种粗粒度错误;必须尽量包含 attempted_urls、status_code、exception_type、exception_message、response_preview、parse_strategy、retryable。 diff --git a/backend/app/llm/prompts/general_skill_runner_prompt.md b/backend/app/llm/prompts/general_skill_runner_prompt.md index 8e8ce8a0..03d05e35 100644 --- a/backend/app/llm/prompts/general_skill_runner_prompt.md +++ b/backend/app/llm/prompts/general_skill_runner_prompt.md @@ -13,7 +13,7 @@ Markdown 可能非常混乱,不一定有 frontmatter、标题、固定字段 - skill_workspace 是运行时恢复出的技能文件夹绝对路径;如果技能依赖同目录的脚本、模板、数据或说明文件,应从 skill_workspace 中读取,不要假设文件在当前仓库。 - 程序必须向标准输出打印一个 JSON 对象。 - 如果任务产生需要交付给用户下载的最终文件,必须写入 `ARTIFACT_DIR`(Python 使用 stdin 的 `artifact_dir`),并在标准输出 JSON 的 `artifacts` 数组中逐个显式声明相对该目录的路径。可选字段为 `display_name` 和 `description`,例如 `{"success": true, "artifacts": [{"path": "report.xlsx", "display_name": "报销明细.xlsx"}]}`。不要输出 `/workspace/...` 或宿主机绝对路径。 -- `artifacts` 只列最终交付物;不得列入输入附件、技能包文件、缓存、日志、临时文件、runner 源码或构建中间产物。未在 `artifacts` 中声明的文件不会出现在对话下载区。 +- `artifacts` 只列最终交付物;不得列入输入附件、技能包文件、缓存、日志、临时文件、runner 源码或构建中间产物。未声明时系统会把 artifact 目录里的文件自动补登为下载产物(丢失 display_name/description),显式声明仍是首选。 - 只能使用 SKILL.md 或 package.files 明确提供的脚本、数据、命令、URL 和 API。不要自行发明第三方接口、备用 URL 或在线服务;如果文档没有足够执行来源,返回稳定失败 JSON,并设置 retryable=false。 - 如果外部网络不可用、API 返回异常、页面结构无法解析或结果不符合预期,程序也必须返回稳定 JSON,不要崩溃。 - 失败 JSON 不要只写 `Fetch failed` 这种粗粒度错误;必须尽量包含 attempted_urls、status_code、exception_type、exception_message、response_preview、parse_strategy、retryable。 diff --git a/backend/tests/test_general_skill_artifact_autodeclare.py b/backend/tests/test_general_skill_artifact_autodeclare.py new file mode 100644 index 00000000..64fb16d1 --- /dev/null +++ b/backend/tests/test_general_skill_artifact_autodeclare.py @@ -0,0 +1,248 @@ +"""通用技能产物自动补登:模型未在结果 JSON 声明 artifacts 时,扫描 artifact_dir 兜底。""" + +import json +from pathlib import Path +from types import SimpleNamespace + +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +from app.core.capability_manifest import ( + CapabilityDescriptor, + CapabilityManifest, + general_skill_snapshot_digest, +) +from app.core.harness_capability_invoker import HarnessCapabilityInvoker +from app.db.models import ChatSession, GeneralSkill, ModelConfig, Tenant, User +from app.general_skills.runner import GeneralSkillRunner +from app.general_skills.schema import GeneralSkillExecutionPlan, GeneralSkillRunResponse + + +def _test_engine(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as db: + db.add(Tenant(id="tenant-demo", name="Demo")) + db.add( + User( + id="user-1", + tenant_id="tenant-demo", + username="user-1", + password_hash="x", + ) + ) + db.commit() + return engine + + +def _model_config() -> ModelConfig: + return ModelConfig( + id="model-test", + tenant_id="tenant-demo", + name="测试模型", + api_key_encrypted="test", + model="test-model", + ) + + +def _chat_session() -> ChatSession: + return ChatSession(id="session-1", tenant_id="tenant-demo", user_id="user-1") + + +def _skill_and_invoker(engine, tmp_path: Path, monkeypatch, *, slug: str = "ppt-maker"): + skill = GeneralSkill( + id=f"gs-{slug}", + tenant_id="tenant-demo", + slug=slug, + name="PPT 生成", + description="生成 PPT 文件", + skill_markdown="# PPT\n", + status="published", + ) + descriptor = CapabilityDescriptor( + capability_id=skill.id, + name=f"general_skill.{slug}", + kind="general_skill", + metadata={ + "slug": skill.slug, + "content_digest": general_skill_snapshot_digest(skill), + }, + ) + with Session(engine) as db: + db.add(skill) + db.commit() + invoker = HarnessCapabilityInvoker( + db, + tenant_id="tenant-demo", + session=_chat_session(), + task_frame_id="task-artifacts", + model_config=_model_config(), + manifest=CapabilityManifest(available=[descriptor]), + active_skill=None, + active_step_id=None, + agent_id=None, + ) + # 先 read 过闸(execute 前置要求) + read = invoker._invoke_general_skill( + skill.id, descriptor.metadata, {"query": "做个 PPT", "operation": "read"} + ) + assert read["success"] is True + return invoker, skill, descriptor + + +def _fake_runner_run(tmp_workspace_artifact_dir: str, payload: dict): + def fake_run(self, skill, query, model_config, user_id, **kwargs): # noqa: ANN001 + workspace_root = Path(kwargs["workspace_root"]) + artifact_dir = workspace_root / tmp_workspace_artifact_dir + artifact_dir.mkdir(parents=True, exist_ok=True) + (artifact_dir / "季度汇报.pptx").write_bytes(b"pk-ppt-bytes") + return GeneralSkillRunResponse( + skill_slug=skill.slug, + operation="execute", + execution_trace=[], + generated_code="", + stdout="", + stderr="", + structured_result=payload, + artifacts=list(payload.get("artifacts") or []), + reply="已生成", + ) + + return fake_run + + +def test_runner_records_workspace_relative_artifact_dir(tmp_path, monkeypatch) -> None: + """runner 在 structured 里回写工作区相对 artifact_dir,供 invoker 兜底扫描。""" + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + + def fake_sandboxed_process(*_args, **kwargs): # noqa: ANN001 + return SimpleNamespace( + returncode=0, + stdout=json.dumps({"success": True}).encode(), + stderr=b"", + timed_out=False, + ) + + monkeypatch.setattr( + "app.general_skills.runner.run_sandboxed_process", fake_sandboxed_process + ) + skill = GeneralSkill( + tenant_id="tenant-demo", + slug="demo", + name="Demo", + skill_markdown="# Demo", + status="published", + ) + plan = GeneralSkillExecutionPlan(runtime="python", code="print(1)") + workspace = tmp_path / "task-ws" + _, _, structured = GeneralSkillRunner()._execute_plan( + skill, "q", plan, "user-1", [], workspace_root=workspace + ) + artifact_dir = structured.get("artifact_dir") or "" + assert artifact_dir.startswith("general_skill_") + assert artifact_dir.endswith("/artifacts") + assert not artifact_dir.startswith("/") + # 无 workspace_root(试运行路径)不带该字段 + _, _, structured_no_ws = GeneralSkillRunner()._execute_plan(skill, "q", plan, "user-1", []) + assert "artifact_dir" not in structured_no_ws + + +def test_undeclared_artifacts_auto_registered_from_artifact_dir(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + engine = _test_engine() + with Session(engine): + invoker, skill, descriptor = _skill_and_invoker(engine, tmp_path, monkeypatch) + payload = { + "success": True, + "artifact_dir": "general_skill_fake/artifacts", + # 注意:没有 artifacts 声明 + } + monkeypatch.setattr( + "app.core.harness_capability_invoker.GeneralSkillRunner.run", + _fake_runner_run("general_skill_fake/artifacts", payload), + ) + result = invoker._invoke_general_skill( + skill.id, + descriptor.metadata, + {"query": "做个 PPT", "operation": "execute"}, + ) + + assert result["success"] is True + artifacts = result["artifacts"] + assert len(artifacts) == 1 + artifact = artifacts[0] + assert artifact["path"] == "general_skill_fake/artifacts/季度汇报.pptx" + assert artifact["display_name"] == "季度汇报.pptx" + assert artifact["size"] == len(b"pk-ppt-bytes") + assert artifact["sha256"] + assert artifact["operation"] == "general_skill.execute" + assert artifact["source"] == f"general_skill.{skill.slug}" + + +def test_declared_artifacts_skip_auto_scan(tmp_path, monkeypatch) -> None: + """显式声明存在时不触发兜底扫描(不产生重复产物)。""" + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + engine = _test_engine() + with Session(engine): + invoker, skill, descriptor = _skill_and_invoker(engine, tmp_path, monkeypatch) + payload = { + "success": True, + "artifact_dir": "general_skill_fake/artifacts", + # runner 归一化后的声明形态:工作区相对路径 + "artifacts": [ + { + "path": "general_skill_fake/artifacts/季度汇报.pptx", + "display_name": "季度汇报.pptx", + }, + ], + } + monkeypatch.setattr( + "app.core.harness_capability_invoker.GeneralSkillRunner.run", + _fake_runner_run("general_skill_fake/artifacts", payload), + ) + result = invoker._invoke_general_skill( + skill.id, + descriptor.metadata, + {"query": "做个 PPT", "operation": "execute"}, + ) + + assert result["success"] is True + assert len(result["artifacts"]) == 1 + # 声明路径经归一化换算为工作区相对路径 + assert result["artifacts"][0]["path"].endswith("artifacts/季度汇报.pptx") + + +def test_failed_run_does_not_auto_register(tmp_path, monkeypatch) -> None: + """失败运行不做兜底补登(半成品文件不应出现在下载区)。""" + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + engine = _test_engine() + with Session(engine): + invoker, skill, descriptor = _skill_and_invoker(engine, tmp_path, monkeypatch) + payload = {"success": False, "error": "boom", "artifact_dir": "general_skill_fake/artifacts"} + monkeypatch.setattr( + "app.core.harness_capability_invoker.GeneralSkillRunner.run", + _fake_runner_run("general_skill_fake/artifacts", payload), + ) + result = invoker._invoke_general_skill( + skill.id, + descriptor.metadata, + {"query": "做个 PPT", "operation": "execute"}, + ) + + assert result["success"] is False + assert result["artifacts"] == [] + + +def test_auto_declare_rejects_paths_outside_workspace(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + engine = _test_engine() + with Session(engine): + invoker, _skill, _descriptor = _skill_and_invoker(engine, tmp_path, monkeypatch) + assert invoker._auto_declare_artifacts({"artifact_dir": "../escape"}) == [] + assert invoker._auto_declare_artifacts({"artifact_dir": ""}) == [] + assert invoker._auto_declare_artifacts({}) == [] + assert invoker._auto_declare_artifacts({"artifact_dir": "not/exist"}) == [] From 3f3d9058ac5af6a18967881db7ffe1113750bd27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Thu, 6 Aug 2026 23:22:32 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(agent):=20=E8=A1=A5=E5=9B=9E=20=5Ffallb?= =?UTF-8?q?ack=5Fsession=5Ftitle=5Ffrom=5Fmessage=20=E7=9A=84=20@staticmet?= =?UTF-8?q?hod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main 的 4bdab76(legacy 清理)丢了 staticmethod,实例调用即 TypeError, 新会话首轮必炸;实例调用验证+相关测试 75 passed --- backend/app/core/agent_loop.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/core/agent_loop.py b/backend/app/core/agent_loop.py index d8a82599..ff40436e 100644 --- a/backend/app/core/agent_loop.py +++ b/backend/app/core/agent_loop.py @@ -1416,6 +1416,7 @@ def _mark_session_running(self, chat_session: ChatSession) -> None: chat_session.updated_at = utc_now() self.db.add(chat_session) + @staticmethod def _fallback_session_title_from_message(message: str) -> str: return ConversationProjection.fallback_session_title(message) From 1a00a2205a9736141d1f8799f4f1415e3130ca04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Sat, 15 Aug 2026 18:18:26 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(harness):=20=E4=BA=A7=E7=89=A9=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E6=89=AB=E6=8F=8F=E9=98=B2=E5=8A=AB=E6=8C=81=E4=B8=8E?= =?UTF-8?q?=E5=99=AA=E9=9F=B3=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 维护者复核指出:模型在输出 JSON 自报 artifact_dir(如共享目录 attachments)会劫持兜底扫描,把用户上传/历史文件登记成产出;且缓存/ 中间文件不应进下载区。 - runner:artifact_dir 由 setdefault 改为强制注入,模型自报值一律覆盖, 兜底扫描目录永远指向本次运行的专属产物目录 - artifacts.py 新增 is_noise_artifact_path(点开头/__pycache__/tmp·part· log 后缀),兜底扫描排除缓存与中间文件 - 回归测试:模型自报劫持被覆盖、噪音文件全部排除、越界路径仍拒绝 --- .../app/core/harness_capability_invoker.py | 11 ++-- backend/app/general_skills/runner.py | 7 ++- backend/app/harness/__init__.py | 2 + backend/app/harness/artifacts.py | 12 +++++ ...test_general_skill_artifact_autodeclare.py | 53 +++++++++++++++++++ 5 files changed, 78 insertions(+), 7 deletions(-) diff --git a/backend/app/core/harness_capability_invoker.py b/backend/app/core/harness_capability_invoker.py index 98aaf907..ad9b050d 100644 --- a/backend/app/core/harness_capability_invoker.py +++ b/backend/app/core/harness_capability_invoker.py @@ -53,6 +53,7 @@ HarnessToolCall, HarnessToolContext, build_file_tool_registry, + is_noise_artifact_path, open_harness_artifact, publish_changed_harness_artifacts, register_command_tools, @@ -779,10 +780,12 @@ def _invoke_general_skill( } def _auto_declare_artifacts(self, structured: dict[str, Any]) -> list[dict[str, Any]]: - """未声明产物的兜底:扫描本次运行的 artifact_dir,把净新增文件自动登记为产物。 + """未声明产物的兜底:扫描本次运行的 artifact_dir,把净产出文件自动登记为产物。 - 只接受 runner 写入 structured 的工作区相对 artifact_dir(我们自己注入的), - 拒绝越出 TaskFrame 工作区的路径;每个文件仍经 open_harness_artifact 校验。 + 只接受 runner 写入 structured 的工作区相对 artifact_dir(我们强制注入的, + 模型输出里的自报值已被覆盖);拒绝越出 TaskFrame 工作区的路径;缓存/中间 + 文件(点开头、__pycache__、*.tmp/*.part/*.log)不算产出;每个文件仍经 + open_harness_artifact 校验。 """ artifact_dir = str(structured.get("artifact_dir") or "").strip() if not artifact_dir: @@ -797,6 +800,8 @@ def _auto_declare_artifacts(self, structured: dict[str, Any]) -> list[dict[str, if not path.is_file() or path.stat().st_size == 0: continue relative = path.relative_to(root).as_posix() + if is_noise_artifact_path(relative): + continue declared.append({"path": f"{artifact_dir}/{relative}", "display_name": path.name}) if len(declared) >= 20: break diff --git a/backend/app/general_skills/runner.py b/backend/app/general_skills/runner.py index 09aebcf6..781ae306 100644 --- a/backend/app/general_skills/runner.py +++ b/backend/app/general_skills/runner.py @@ -805,10 +805,9 @@ def _execute_plan( workspace_root=workspace_root, ) if workspace_root is not None: - # 供 invoker 在产物未声明时自动扫描补登(工作区相对路径) - structured.setdefault( - "artifact_dir", artifact_dir.relative_to(workspace_root).as_posix() - ) + # 供 invoker 在产物未声明时自动扫描补登(工作区相对路径); + # 强制覆盖:模型在输出 JSON 里自报的 artifact_dir 不可信,不得劫持扫描目录 + structured["artifact_dir"] = artifact_dir.relative_to(workspace_root).as_posix() if return_code != 0: structured.setdefault("success", False) structured.setdefault("error", f"runner exited with code {return_code}") diff --git a/backend/app/harness/__init__.py b/backend/app/harness/__init__.py index bfaff4f2..8adb98d0 100644 --- a/backend/app/harness/__init__.py +++ b/backend/app/harness/__init__.py @@ -2,6 +2,7 @@ HarnessArtifactAccessError, HarnessWorkspaceSnapshot, OpenedHarnessArtifact, + is_noise_artifact_path, normalize_harness_artifact_path, open_harness_artifact, publish_changed_harness_artifacts, @@ -51,6 +52,7 @@ "build_command_tool_registry", "build_file_tool_registry", "exec_command", + "is_noise_artifact_path", "normalize_harness_artifact_path", "open_harness_artifact", "publish_artifact", diff --git a/backend/app/harness/artifacts.py b/backend/app/harness/artifacts.py index 2e148d8d..bff8d2ee 100644 --- a/backend/app/harness/artifacts.py +++ b/backend/app/harness/artifacts.py @@ -80,6 +80,18 @@ def normalize_harness_artifact_path(raw_path: str) -> str: return PurePosixPath(*parts).as_posix() +_NOISE_ARTIFACT_SUFFIXES = (".tmp", ".part", ".partial", ".cache", ".log", ".lock") + + +def is_noise_artifact_path(relative_path: str) -> bool: + """缓存/中间产物判定(自动补登时排除):点开头文件、__pycache__、临时/日志后缀。""" + for part in relative_path.replace("\\", "/").split("/"): + if part.startswith(".") or part == "__pycache__": + return True + lower = relative_path.lower() + return lower.endswith(_NOISE_ARTIFACT_SUFFIXES) + + def open_harness_artifact( workspace_root: Path, raw_path: str, diff --git a/backend/tests/test_general_skill_artifact_autodeclare.py b/backend/tests/test_general_skill_artifact_autodeclare.py index 64fb16d1..93c5e9ee 100644 --- a/backend/tests/test_general_skill_artifact_autodeclare.py +++ b/backend/tests/test_general_skill_artifact_autodeclare.py @@ -151,6 +151,39 @@ def fake_sandboxed_process(*_args, **kwargs): # noqa: ANN001 assert "artifact_dir" not in structured_no_ws +def test_runner_artifact_dir_overrides_model_reported_value(tmp_path, monkeypatch) -> None: + """模型在输出 JSON 自报 artifact_dir(如共享目录 attachments)不得劫持兜底扫描目录。""" + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + + def fake_sandboxed_process(*_args, **kwargs): # noqa: ANN001 + # 模型自报共享目录,试图让兜底扫描登记别人的文件 + return SimpleNamespace( + returncode=0, + stdout=json.dumps({"success": True, "artifact_dir": "attachments"}).encode(), + stderr=b"", + timed_out=False, + ) + + monkeypatch.setattr( + "app.general_skills.runner.run_sandboxed_process", fake_sandboxed_process + ) + skill = GeneralSkill( + tenant_id="tenant-demo", + slug="demo", + name="Demo", + skill_markdown="# Demo", + status="published", + ) + plan = GeneralSkillExecutionPlan(runtime="python", code="print(1)") + workspace = tmp_path / "task-ws" + _, _, structured = GeneralSkillRunner()._execute_plan( + skill, "q", plan, "user-1", [], workspace_root=workspace + ) + # 强制覆盖为本次运行的真实产物目录,模型自报值被丢弃 + assert structured["artifact_dir"].startswith("general_skill_") + assert structured["artifact_dir"] != "attachments" + + def test_undeclared_artifacts_auto_registered_from_artifact_dir(tmp_path, monkeypatch) -> None: monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) engine = _test_engine() @@ -246,3 +279,23 @@ def test_auto_declare_rejects_paths_outside_workspace(tmp_path, monkeypatch) -> assert invoker._auto_declare_artifacts({"artifact_dir": ""}) == [] assert invoker._auto_declare_artifacts({}) == [] assert invoker._auto_declare_artifacts({"artifact_dir": "not/exist"}) == [] + + +def test_auto_declare_filters_cache_and_intermediate_files(tmp_path, monkeypatch) -> None: + """缓存/中间文件(点开头、__pycache__、tmp/part/log 后缀)不登记为产出。""" + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + engine = _test_engine() + with Session(engine): + invoker, _skill, _descriptor = _skill_and_invoker(engine, tmp_path, monkeypatch) + artifact_dir = invoker.workspace_root / "general_skill_x/artifacts" + (artifact_dir / "__pycache__").mkdir(parents=True) + (artifact_dir / "report.pptx").write_bytes(b"pk") + (artifact_dir / "cache.tmp").write_bytes(b"t") + (artifact_dir / "run.log").write_bytes(b"l") + (artifact_dir / ".hidden").write_bytes(b"h") + (artifact_dir / "__pycache__" / "mod.pyc").write_bytes(b"c") + declared = invoker._auto_declare_artifacts( + {"artifact_dir": "general_skill_x/artifacts"} + ) + paths = [item["path"] for item in declared] + assert paths == ["general_skill_x/artifacts/report.pptx"]