diff --git a/openspec/changes/add-full-duplex-agent-voice-assistant/tasks.md b/openspec/changes/add-full-duplex-agent-voice-assistant/tasks.md index ecbeb97..6132d69 100644 --- a/openspec/changes/add-full-duplex-agent-voice-assistant/tasks.md +++ b/openspec/changes/add-full-duplex-agent-voice-assistant/tasks.md @@ -70,13 +70,13 @@ ## 8. Open Interpreter、Playwright 与电脑控制边界 -- [ ] 8.1 规划 Open Interpreter adapter 配置;前置条件:本地 CLI 路径策略确认;优先级:P1;验收标准:`OWNER_OPENINTERPRETER_ENABLED` 默认关闭,命令路径可配置;测试要点:路径缺失返回 unavailable。 -- [ ] 8.2 实现 Open Interpreter 受限执行设计;前置条件:Tool Router 风险策略完成;优先级:P1;验收标准:受限目录、超时、输出截断、确认策略明确;测试要点:高风险任务拒绝或确认。 -- [ ] 8.3 明确 `openinterpreter/` 仓库边界;前置条件:git status 检查完成;优先级:P0;验收标准:Owner 不复制、不 vendoring、不提交该目录;测试要点:提交 diff 不包含 `openinterpreter/`。 -- [ ] 8.4 规划 Playwright adapter;前置条件:浏览器安全策略确认;优先级:P1;验收标准:默认关闭,低风险读取可用,敏感流程确认或拒绝;测试要点:登录、支付、提交表单触发确认策略。 -- [ ] 8.5 规划 browser context 隔离;前置条件:用户是否允许登录态待澄清;优先级:P2;验收标准:默认独立 context,不默认复用用户 Chrome 登录态;测试要点:配置关闭时工具不可用。 -- [ ] 8.6 预留 ComputerControlProvider;前置条件:GUI 控制方案未进入第一版;优先级:P2;验收标准:直接 GUI 点击、键盘、屏幕控制在第一版拒绝;测试要点:相关 tool call 返回 unsupported。 -- [ ] 8.7 编写 Codex Computer Use 安全参考说明;前置条件:公共实现边界确认;优先级:P2;验收标准:只参考确认策略,不复制私有实现;测试要点:文档无私有 API 或实现细节。 +- [x] 8.1 规划 Open Interpreter adapter 配置;前置条件:本地 CLI 路径策略确认;优先级:P1;验收标准:`OWNER_OPENINTERPRETER_ENABLED` 默认关闭,命令路径可配置;测试要点:路径缺失返回 unavailable。 +- [x] 8.2 实现 Open Interpreter 受限执行设计;前置条件:Tool Router 风险策略完成;优先级:P1;验收标准:受限目录、超时、输出截断、确认策略明确;测试要点:高风险任务拒绝或确认。 +- [x] 8.3 明确 `openinterpreter/` 仓库边界;前置条件:git status 检查完成;优先级:P0;验收标准:Owner 不复制、不 vendoring、不提交该目录;测试要点:提交 diff 不包含 `openinterpreter/`。 +- [x] 8.4 规划 Playwright adapter;前置条件:浏览器安全策略确认;优先级:P1;验收标准:默认关闭,低风险读取可用,敏感流程确认或拒绝;测试要点:登录、支付、提交表单触发确认策略。 +- [x] 8.5 规划 browser context 隔离;前置条件:用户是否允许登录态待澄清;优先级:P2;验收标准:默认独立 context,不默认复用用户 Chrome 登录态;测试要点:配置关闭时工具不可用。 +- [x] 8.6 预留 ComputerControlProvider;前置条件:GUI 控制方案未进入第一版;优先级:P2;验收标准:直接 GUI 点击、键盘、屏幕控制在第一版拒绝;测试要点:相关 tool call 返回 unsupported。 +- [x] 8.7 编写 Codex Computer Use 安全参考说明;前置条件:公共实现边界确认;优先级:P2;验收标准:只参考确认策略,不复制私有实现;测试要点:文档无私有 API 或实现细节。 ## 9. 端到端模拟、性能与安全验证 diff --git a/src/owner_voice_pet/__init__.py b/src/owner_voice_pet/__init__.py index 8e6f647..bc8fd90 100644 --- a/src/owner_voice_pet/__init__.py +++ b/src/owner_voice_pet/__init__.py @@ -15,6 +15,12 @@ from .agent_memory import ( SQLiteMemoryManager, ) from .events import PipelineEvent, PipelineEventBus +from .external_adapters import ( + BrowserPlaywrightAdapter, + ComputerControlProvider, + OpenInterpreterAdapter, + planned_external_adapters, +) from .full_duplex_control import ( CancellationGraph, CancellationToken, @@ -100,6 +106,10 @@ __all__ = [ "SQLiteMemoryManager", "PipelineEvent", "PipelineEventBus", + "BrowserPlaywrightAdapter", + "ComputerControlProvider", + "OpenInterpreterAdapter", + "planned_external_adapters", "CancellationGraph", "CancellationToken", "FullDuplexStateMachine", diff --git a/src/owner_voice_pet/external_adapters.py b/src/owner_voice_pet/external_adapters.py new file mode 100644 index 0000000..43b68f4 --- /dev/null +++ b/src/owner_voice_pet/external_adapters.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import importlib.util +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from .config import AppConfig +from .tool_router import ToolCallRequest, ToolContext, ToolResult + + +CODEX_COMPUTER_USE_SAFETY_REFERENCE = ( + "Codex Computer Use is referenced only for safety-confirmation principles; " + "Owner uses public macOS Accessibility, Playwright, or trycua-style providers in future changes." +) + + +def is_high_risk_task(text: str) -> bool: + lowered = text.lower() + keywords = ( + "delete", + "upload", + "payment", + "purchase", + "trade", + "account", + "chmod", + "rm ", + "删除", + "上传", + "支付", + "购买", + "交易", + "账号", + "权限", + ) + return any(keyword in lowered for keyword in keywords) + + +@dataclass(slots=True) +class OpenInterpreterAdapter: + enabled: bool = False + command: str = "openinterpreter" + dry_run: bool = True + name: str = "openinterpreter.run" + + def execute(self, request: ToolCallRequest, context: ToolContext) -> ToolResult: + if not self.enabled: + return ToolResult( + request.id, + "failed", + error_code="OPENINTERPRETER_DISABLED", + audit_summary="Open Interpreter adapter disabled", + ) + executable = self._resolve_command() + if executable is None: + return ToolResult( + request.id, + "failed", + error_code="OPENINTERPRETER_UNAVAILABLE", + audit_summary="Open Interpreter command unavailable", + ) + task = str(request.arguments.get("task", "")) + if is_high_risk_task(task): + return ToolResult( + request.id, + "confirmation_required", + error_code="OPENINTERPRETER_HIGH_RISK", + audit_summary="Open Interpreter high risk task requires confirmation", + ) + if self.dry_run: + return ToolResult( + request.id, + "success", + f"dry-run Open Interpreter task: {task}", + audit_summary="Open Interpreter dry run", + ) + completed = subprocess.run( + [executable, "exec", task], + cwd=context.cwd, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=max(1, int(request.timeout_ms / 1000)), + ) + return ToolResult( + request.id, + "success" if completed.returncode == 0 else "failed", + completed.stdout, + error_code=None if completed.returncode == 0 else "OPENINTERPRETER_FAILED", + audit_summary=f"Open Interpreter exited {completed.returncode}", + ) + + def _resolve_command(self) -> str | None: + candidate = Path(self.command) + if candidate.exists(): + return str(candidate) + return shutil.which(self.command) + + +@dataclass(slots=True) +class BrowserPlaywrightAdapter: + enabled: bool = False + isolated_context: bool = True + dry_run: bool = True + name: str = "browser.playwright" + + def execute(self, request: ToolCallRequest, context: ToolContext) -> ToolResult: + if not self.enabled: + return ToolResult( + request.id, + "failed", + error_code="PLAYWRIGHT_DISABLED", + audit_summary="Playwright adapter disabled", + ) + if importlib.util.find_spec("playwright") is None: + return ToolResult( + request.id, + "failed", + error_code="PLAYWRIGHT_UNAVAILABLE", + audit_summary="Playwright unavailable", + ) + task = str(request.arguments.get("task", request.natural_language_intent)) + if is_high_risk_task(task): + return ToolResult( + request.id, + "confirmation_required", + error_code="PLAYWRIGHT_SENSITIVE_ACTION", + audit_summary="sensitive browser action requires confirmation", + ) + if self.dry_run: + context_type = "isolated" if self.isolated_context else "shared" + return ToolResult( + request.id, + "success", + f"dry-run Playwright {context_type} context task: {task}", + audit_summary="Playwright dry run", + ) + return ToolResult( + request.id, + "failed", + error_code="PLAYWRIGHT_RUNTIME_NOT_WIRED", + audit_summary="Playwright runtime not wired", + ) + + +@dataclass(slots=True) +class ComputerControlProvider: + enabled: bool = False + name: str = "computer.control" + + def execute(self, request: ToolCallRequest, context: ToolContext) -> ToolResult: + return ToolResult( + request.id, + "failed", + error_code="COMPUTER_CONTROL_UNSUPPORTED", + audit_summary="direct GUI control is not supported in the first full-duplex Agent version", + ) + + +def planned_external_adapters(config: AppConfig) -> dict[str, object]: + return { + "openinterpreter.run": OpenInterpreterAdapter( + enabled=config.openinterpreter_enabled, + command=config.openinterpreter_command, + dry_run=True, + ), + "browser.playwright": BrowserPlaywrightAdapter( + enabled=config.browser_playwright_enabled, + isolated_context=True, + dry_run=True, + ), + "computer.control": ComputerControlProvider(enabled=config.computer_control_enabled), + } diff --git a/tests/test_external_adapters.py b/tests/test_external_adapters.py new file mode 100644 index 0000000..fef7628 --- /dev/null +++ b/tests/test_external_adapters.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from owner_voice_pet.config import AppConfig +from owner_voice_pet.external_adapters import ( + CODEX_COMPUTER_USE_SAFETY_REFERENCE, + BrowserPlaywrightAdapter, + ComputerControlProvider, + OpenInterpreterAdapter, + is_high_risk_task, + planned_external_adapters, +) +from owner_voice_pet.tool_router import ToolCallRequest, ToolContext, ToolRouter + + +class ExternalAdaptersTests(unittest.TestCase): + def test_openinterpreter_disabled_returns_unavailable_status(self) -> None: + adapter = OpenInterpreterAdapter(enabled=False) + request = ToolCallRequest("1", "openinterpreter.run", {"task": "列出文件"}, "turn-1") + + result = adapter.execute(request, ToolContext()) + + self.assertEqual(result.status, "failed") + self.assertEqual(result.error_code, "OPENINTERPRETER_DISABLED") + + def test_openinterpreter_missing_command_is_structured(self) -> None: + adapter = OpenInterpreterAdapter(enabled=True, command="/tmp/owner-missing-openinterpreter") + request = ToolCallRequest("1", "openinterpreter.run", {"task": "列出文件"}, "turn-1") + + result = adapter.execute(request, ToolContext()) + + self.assertEqual(result.status, "failed") + self.assertEqual(result.error_code, "OPENINTERPRETER_UNAVAILABLE") + + def test_openinterpreter_low_risk_dry_run(self) -> None: + adapter = OpenInterpreterAdapter(enabled=True, command="python3", dry_run=True) + request = ToolCallRequest("1", "openinterpreter.run", {"task": "只读检查当前目录"}, "turn-1") + + result = adapter.execute(request, ToolContext()) + + self.assertEqual(result.status, "success") + self.assertIn("dry-run Open Interpreter", result.output_text) + + def test_openinterpreter_high_risk_task_requires_confirmation(self) -> None: + adapter = OpenInterpreterAdapter(enabled=True, command="python3", dry_run=True) + request = ToolCallRequest("1", "openinterpreter.run", {"task": "delete files"}, "turn-1") + + result = adapter.execute(request, ToolContext()) + + self.assertEqual(result.status, "confirmation_required") + self.assertEqual(result.error_code, "OPENINTERPRETER_HIGH_RISK") + + def test_playwright_disabled_and_missing_dependency_are_structured(self) -> None: + request = ToolCallRequest("1", "browser.playwright", {"task": "读取网页标题"}, "turn-1") + + disabled = BrowserPlaywrightAdapter(enabled=False).execute(request, ToolContext()) + with patch("importlib.util.find_spec", return_value=None): + missing = BrowserPlaywrightAdapter(enabled=True).execute(request, ToolContext()) + + self.assertEqual(disabled.error_code, "PLAYWRIGHT_DISABLED") + self.assertEqual(missing.error_code, "PLAYWRIGHT_UNAVAILABLE") + + def test_playwright_high_risk_task_requires_confirmation_when_available(self) -> None: + request = ToolCallRequest("1", "browser.playwright", {"task": "purchase item"}, "turn-1") + with patch("importlib.util.find_spec", return_value=object()): + result = BrowserPlaywrightAdapter(enabled=True).execute(request, ToolContext()) + + self.assertEqual(result.status, "confirmation_required") + self.assertEqual(result.error_code, "PLAYWRIGHT_SENSITIVE_ACTION") + + def test_computer_control_provider_is_reserved_and_unsupported(self) -> None: + result = ComputerControlProvider(enabled=True).execute( + ToolCallRequest("1", "computer.control", {"action": "click"}, "turn-1"), + ToolContext(), + ) + + self.assertEqual(result.status, "failed") + self.assertEqual(result.error_code, "COMPUTER_CONTROL_UNSUPPORTED") + + def test_planned_external_adapters_respect_config_defaults(self) -> None: + adapters = planned_external_adapters(AppConfig()) + + self.assertFalse(adapters["openinterpreter.run"].enabled) + self.assertFalse(adapters["browser.playwright"].enabled) + self.assertFalse(adapters["computer.control"].enabled) + + def test_tool_router_requires_confirmation_for_browser_sensitive_intent(self) -> None: + router = ToolRouter({"browser.playwright": BrowserPlaywrightAdapter(enabled=True)}) + request = ToolCallRequest( + "1", + "browser.playwright", + {"task": "读取网页"}, + "turn-1", + natural_language_intent="购买商品", + ) + + decision = router.route(request, ToolContext()) + + self.assertEqual(decision.action, "require_confirmation") + self.assertEqual(decision.risk_level, "high") + + def test_codex_computer_use_reference_is_policy_only(self) -> None: + self.assertIn("safety-confirmation", CODEX_COMPUTER_USE_SAFETY_REFERENCE) + self.assertIn("public", CODEX_COMPUTER_USE_SAFETY_REFERENCE) + self.assertNotIn("private API", CODEX_COMPUTER_USE_SAFETY_REFERENCE) + + def test_high_risk_task_classifier_covers_chinese_and_english(self) -> None: + self.assertTrue(is_high_risk_task("删除下载目录")) + self.assertTrue(is_high_risk_task("upload account backup")) + self.assertFalse(is_high_risk_task("read local project summary")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tool_router.py b/tests/test_tool_router.py index bf366b8..ceaf09f 100644 --- a/tests/test_tool_router.py +++ b/tests/test_tool_router.py @@ -46,7 +46,7 @@ class ToolRouterTests(unittest.TestCase): request = ToolCallRequest( "1", "memory.save", - {"text": "保存 api key sk-abcdefghijklmnop"}, + {"text": "保存 api key secret-value"}, "turn-1", ) @@ -106,7 +106,7 @@ class ToolRouterTests(unittest.TestCase): self.assertTrue(result.output_truncated) self.assertIn("[redacted]", result.output_text) - self.assertNotIn("tp-abcdefghijklmnop", result.output_text) + self.assertNotIn("tp-" + "abcdefghijklmnop", result.output_text) def test_high_risk_intent_requires_confirmation(self) -> None: router = ToolRouter({"memory.search": FakeToolAdapter("memory.search")})