diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index 0138717785..5160637105 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -84,6 +84,15 @@ graph TD ### 问答中的等待与失败 +前端与飞书的 Chat 回答保留项目内文件的相对位置,例如 `./notes/report.md`,便于核对产物;机器目录、项目外路径及另外声明为私有的目录仍隐藏。项目根目录本身显示为 `[project]`。本机 Markdown 链接保留可读标题,不发送无法在飞书使用的本地链接;状态、提议和权限门禁仍使用完整路径脱敏。这仅改变展现,不授予读取或写入权限。 + +Frontend and Lark Chat answers retain project-relative filenames such as +`./notes/report.md`, while hiding machine roots, other local paths and separately +protected directories. The project root itself remains `[project]`. Local +Markdown links keep their readable labels instead of unusable local destinations; +status, proposals and gates still redact entire local paths. This changes answer +presentation, not read or write authority. + 管家与 Goal 的前端对话共用运行视图,适用于查询、编码、投研等各种任务:回答下方显示当前正在做的一步,例如「正在读取 notes.md」。使用 Codex 执行器时,展开「执行过程」可逐步查看思考、读取、搜索、运行的命令、调用的工具和修改的文件;同一步的开始与结束合并为一行,失败的步骤标出退出码,点击一行可查看完整命令、改动文件列表或思考内容。思考内容仅在模型向宿主公开时显示(有的模型只给出思考用时);不显示命令输出、工具参数与结果或文件改动内容,项目内路径显示为相对路径,其余本机路径和疑似凭据会被隐藏。「完成」只表示该步结束,不代表检查通过。这些步骤只保存在本机、仅所有者可读的会话回放记录中;回合结束超过 24 小时后,下次启动 LoopX Chat 时清理。其他执行器继续显示「最近活动」中的最近六条记录。尚未收到活动时保留等待提示,不根据等待时长推测执行进度。 ![运行中的回答显示当前活动、最近活动和中断本轮](../assets/personal-workspace/conversation-activity-desktop.png) diff --git a/loopx/chat.py b/loopx/chat.py index 0ad2d8a69c..554e116b7f 100644 --- a/loopx/chat.py +++ b/loopx/chat.py @@ -142,16 +142,45 @@ def _stable_digest(payload: dict[str, Any], *, length: int = 24) -> str: return hashlib.sha256(stable.encode("utf-8")).hexdigest()[:length] -def redact_local_paths(text: str, *, protected_paths: Iterable[Path | str] = ()) -> str: +def redact_local_paths( + text: str, + *, + protected_paths: Iterable[Path | str] = (), + project_relative: bool = False, +) -> str: + """Hide local roots; visible answers may retain a project-relative filename. + + Structured status, proposals and gates keep the default full redaction. + Additional protected roots always hide their descendants, even when nested + inside the project. This is text presentation, not filesystem admission. + """ redacted = str(text or "") replacements = _protected_path_replacements(protected_paths) + def path_parts(value: str) -> tuple[str, ...]: + decoded = unquote(value).replace("\\", "/") + if re.match(r"^[A-Za-z]:/", decoded): + decoded = decoded.casefold() + return tuple(part for part in decoded.split("/") if part not in {"", "."}) + + private_roots = [path_parts(raw) for raw, label in replacements if label == "[local-path]"] + def replace_absolute_path(match: re.Match[str]) -> str: matched = match.group(0) candidate = matched.rstrip(".,;:!?)]}") suffix = matched[len(candidate) :] for raw, label in replacements: if candidate == raw or candidate.startswith(f"{raw}/") or candidate.startswith(f"{raw}\\"): + if project_relative and label == "[project]" and candidate != raw: + relative = re.sub(r"\\+", "/", candidate[len(raw):]).lstrip("/") + components = unquote(relative).replace("\\", "/").split("/") + if ".." not in components: + # Equivalent spellings must not bypass a nested private + # root when opting into project-relative presentation. + candidate_parts = path_parts(candidate) + if any(candidate_parts[:len(root)] == root for root in private_roots): + return f"[local-path]{suffix}" + return f"./{relative}{suffix}" return f"{label}{suffix}" return f"[local-path]{suffix}" @@ -233,7 +262,7 @@ def redact_response_markdown(text: str, *, protected_paths: Iterable[Path | str] cursor = end chunks.append(line[cursor:]) lines.append("".join(chunks)) - return redact_local_paths("".join(lines), protected_paths=protected) + return redact_local_paths("".join(lines), protected_paths=protected, project_relative=True) class VisibleResponseStreamFilter: @@ -298,7 +327,7 @@ def _accept_visible(self, text: str, *, final: bool) -> str: if final: ready = self.visible_pending self.visible_pending = "" - return redact_local_paths(ready, protected_paths=self.protected_paths) + return redact_local_paths(ready, protected_paths=self.protected_paths, project_relative=True) # One chunk can hold several safe boundaries. Keep cutting until none # is left, so an early sentence never holds back a long tail that the # length fallback would otherwise release. @@ -309,7 +338,7 @@ def _accept_visible(self, text: str, *, final: bool) -> str: return "" ready = self.visible_pending[:ready_length] self.visible_pending = self.visible_pending[ready_length:] - return redact_local_paths(ready, protected_paths=self.protected_paths) + return redact_local_paths(ready, protected_paths=self.protected_paths, project_relative=True) def feed(self, chunk: str) -> str: if self.envelope_started: diff --git a/tests/test_chat_path_redaction.py b/tests/test_chat_path_redaction.py index caf5896197..9ca02819b1 100644 --- a/tests/test_chat_path_redaction.py +++ b/tests/test_chat_path_redaction.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import sys from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from threading import Thread @@ -9,6 +10,7 @@ import pytest from loopx.chat import VisibleResponseStreamFilter, parse_agent_response, redact_local_paths, redact_response_markdown +from loopx.chat_acp import ACPStdioAdapter from loopx.chat_status_api import ChatStatusRequestMixin @@ -51,13 +53,129 @@ def test_response_link_repair_preserves_code_and_does_not_rewrite_status_json(): link = "[label](/custom-volume/project/report.md)" code = f"`{link}`\n```md\n{link}\n```\n" assert redact_response_markdown(code, protected_paths=["/custom-volume/project"]) == code.replace( - "/custom-volume/project/report.md", "[project]") + "/custom-volume/project/report.md", "./report.md") assert json.loads(redact_local_paths(json.dumps({"message": link}), protected_paths=["/custom-volume/project"])) == { "message": "[label]([project])"} raw = '' + json.dumps({"message": link}) + '' assert parse_agent_response(raw, protected_paths=["/custom-volume/project"])["message"] == "label" +@pytest.mark.parametrize("root,child", [ + ("/custom-volume/project", "/notes/report.md:12"), + ("/custom-volume/project space", "/notes/report.md"), + (r"Q:\project", r"\notes\report.md"), +]) +def test_response_and_split_stream_retain_the_project_filename(root, child): + text = f"Read back `{root}{child}`.\n" + expected = "Read back `./" + child.lstrip("/\\").replace("\\", "/") + "`.\n" + assert redact_response_markdown(text, protected_paths=[root]) == expected + assert parse_agent_response(text, protected_paths=[root])["message"] == expected.strip() + for split in range(len(text) + 1): + stream = VisibleResponseStreamFilter(protected_paths=[root]) + assert stream.feed(text[:split]) + stream.feed(text[split:]) + stream.finish() == expected + + +def test_answer_path_presentation_does_not_relax_nested_private_roots_or_structured_fields(): + root = "/custom-volume/project" + secret_root = root + "/runtime" + paths = [root, secret_root] + text = f"`{root}/notes/report.md`; `{secret_root}/private/gate.json`; `/home/other/private.txt`." + assert parse_agent_response(text, protected_paths=paths)["message"] == ( + "`./notes/report.md`; `[local-path]`; `[local-path]`." + ) + raw = '' + json.dumps({ + "message": text, + "proposals": [{"kind": "todo", "text": f"Read {root}/notes/report.md", "rationale": f"See {secret_root}/private/gate.json"}], + }) + '' + parsed = parse_agent_response(raw, protected_paths=paths) + assert parsed["message"].startswith("`./notes/report.md`") + assert parsed["proposals"][0]["text"] == "Read [project]" + assert parsed["proposals"][0]["rationale"] == "See [local-path]" + + +@pytest.mark.parametrize("private_path", [ + "/custom-volume/project/./runtime/private/gate.json", + "/custom-volume/project//runtime/private/gate.json", + "/custom-volume/project/runtime/./private/gate.json", + "/custom-volume/project/%2e/runtime/private/gate.json", + "/custom-volume/project/runtime%2fprivate/gate.json", + r"Q:\project\.\runtime\private\gate.json", + r"Q:\project\\runtime\private\gate.json", + r"Q:\project\.\RUNTIME\private\gate.json", +]) +def test_answer_and_every_stream_split_hide_equivalent_nested_private_paths(private_path): + paths = [r"Q:\project", r"Q:\project\runtime"] if private_path.startswith("Q:") else [ + "/custom-volume/project", "/custom-volume/project/runtime", + ] + text = f"Read back `{private_path}`.\n" + expected = "Read back `[local-path]`.\n" + assert redact_response_markdown(text, protected_paths=paths) == expected + assert parse_agent_response(text, protected_paths=paths)["message"] == expected.strip() + for split in range(len(text) + 1): + stream = VisibleResponseStreamFilter(protected_paths=paths) + assert stream.feed(text[:split]) + stream.feed(text[split:]) + stream.finish() == expected + + +def test_acp_stdio_final_and_stream_hide_private_aliases_and_keep_public_filename(tmp_path): + project = tmp_path / "project" + private = project / "runtime" + private.mkdir(parents=True) + secret = private / "synthetic-secret.md" + secret.write_text("synthetic fixture") + aliases = [str(secret), f"{project}/./runtime/{secret.name}", f"{project}//runtime/{secret.name}"] + assert all(Path(alias).resolve() == secret.resolve() for alias in aliases) + text = "\n".join([*aliases, str(project / "notes/report.md")]) + "\n" + expected = "[local-path]\n" * len(aliases) + "./notes/report.md\n" + provider = tmp_path / "synthetic_acp.py" + provider.write_text('''import json, sys +for line in sys.stdin: + request = json.loads(line) + method = request.get("method") + if method == "initialize": + result = {"protocolVersion": 1, "agentCapabilities": {}} + elif method == "session/new": + result = {"sessionId": "fixture"} + elif method == "session/prompt": + for chunk in sys.argv[1]: + print(json.dumps({"jsonrpc": "2.0", "method": "session/update", + "params": {"sessionId": "fixture", "update": { + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": chunk}}}}), flush=True) + result = {"stopReason": "end_turn"} + else: + continue + print(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}), flush=True) +''') + events = [] + adapter = ACPStdioAdapter.start( + command=(sys.executable, str(provider), text), work_dir=project, agent_work_dir=private, + startup_timeout_sec=5, idle_timeout_sec=5, hard_timeout_sec=10, + ) + try: + response = adapter.start_turn("Report fixture locations", lambda kind, payload: events.append((kind, payload))) + finally: + adapter.close_session() + assert response["message"] == expected.strip() + assert "".join(payload["text"] for kind, payload in events if kind == "answer.delta") == expected + assert [payload["response"]["message"] for kind, payload in events if kind == "answer.final"] == [expected.strip()] + + +@pytest.mark.parametrize("suffix", ["/../other/private.txt", "/notes/../../private.txt", "/%2e%2e/private.txt"]) +def test_answer_paths_do_not_present_traversal_as_project_files(suffix): + text = "/custom-volume/project" + suffix + assert parse_agent_response(text, protected_paths=["/custom-volume/project"])["message"] == "[project]" + + +def test_stream_holds_a_long_project_filename_until_its_boundary(): + root = "/custom-volume/" + "r" * 190 + relative = "notes/" + "s" * 190 + "/report.md" + path = root + "/" + relative + stream = VisibleResponseStreamFilter(protected_paths=[root]) + assert stream.feed(path[:170]) == "" + assert stream.feed(path[170:300]) == "" + assert stream.feed(path[300:] + "\n") + stream.finish() == "./" + relative + "\n" + + @pytest.mark.parametrize("root", [ "/custom-volume/private-state", "/custom-volume/private state", "/custom-volume/" + "r" * 190,