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
9 changes: 9 additions & 0 deletions docs/guides/personal-workspace-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 33 additions & 4 deletions loopx/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down
120 changes: 119 additions & 1 deletion tests/test_chat_path_redaction.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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 = '<loopx-review-json>' + json.dumps({"message": link}) + '</loopx-review-json>'
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 = '<loopx-review-json>' + json.dumps({
"message": text,
"proposals": [{"kind": "todo", "text": f"Read {root}/notes/report.md", "rationale": f"See {secret_root}/private/gate.json"}],
}) + '</loopx-review-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,
Expand Down
Loading