diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f67f446c..b7c7b3d8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,6 +2,8 @@ # Install: pip install pre-commit && pre-commit install # Run manually: pre-commit run --all-files +exclude: ^backend/app/one_person_security_dept/(openclaw|claude_agent_sdk_python/claude-agent-sdk-python)/ + repos: # General hooks - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/backend/Makefile b/backend/Makefile index 5c0703fb..a91e240e 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -6,3 +6,37 @@ engine-arm: engine-amd: DOCKER_BUILDKIT=1 docker buildx build --platform linux/amd64 --progress=plain --file engine.amd64.Dockerfile -t seclens-engine-amd:0.2 . + +security-sdk-subtree-setup: + ../scripts/update-security-sdk-subtree.sh --setup-only + +security-sdk-subtree-update: + @if [ -z "$(REF)" ]; then \ + echo "Usage: make security-sdk-subtree-update REF="; \ + exit 1; \ + fi + ../scripts/update-security-sdk-subtree.sh --ref $(REF) + +security-sdk-subtree-dry-run: + @if [ -z "$(REF)" ]; then \ + echo "Usage: make security-sdk-subtree-dry-run REF="; \ + exit 1; \ + fi + ../scripts/update-security-sdk-subtree.sh --ref $(REF) --dry-run + +openclaw-subtree-setup: + ../scripts/update-openclaw-subtree.sh --setup-only + +openclaw-subtree-update: + @if [ -z "$(REF)" ]; then \ + echo "Usage: make openclaw-subtree-update REF="; \ + exit 1; \ + fi + ../scripts/update-openclaw-subtree.sh --ref $(REF) + +openclaw-subtree-dry-run: + @if [ -z "$(REF)" ]; then \ + echo "Usage: make openclaw-subtree-dry-run REF="; \ + exit 1; \ + fi + ../scripts/update-openclaw-subtree.sh --ref $(REF) --dry-run diff --git a/backend/README.md b/backend/README.md index a0f517be..62b6adf5 100644 --- a/backend/README.md +++ b/backend/README.md @@ -183,6 +183,60 @@ pytest pytest --cov=app ``` +## 🔄 One Person Security Dept SDK 更新(Git Subtree) + +`claude-agent-sdk-python` 已 vendored 到以下目录: + +`backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python` + +推荐通过 `git subtree` 更新,不影响现有开发者工作流。 + +```bash +# 1) 一次性设置 upstream remote +make security-sdk-subtree-setup + +# 2) 先预览更新(建议) +make security-sdk-subtree-dry-run REF=v0.1.43 + +# 3) 实际更新到指定 tag/branch +make security-sdk-subtree-update REF=v0.1.43 +# 或 +make security-sdk-subtree-update REF=main +``` + +也可直接运行脚本: + +```bash +./scripts/update-security-sdk-subtree.sh --ref v0.1.43 +``` + +## 🔄 One Person Security Dept OpenClaw 更新(Git Subtree) + +`openclaw` 已 vendored 到以下目录: + +`backend/app/one_person_security_dept/openclaw` + +同样推荐通过 `git subtree` 更新: + +```bash +# 1) 一次性设置 upstream remote +make openclaw-subtree-setup + +# 2) 先预览更新(建议) +make openclaw-subtree-dry-run REF=v2026.2.24 + +# 3) 实际更新到指定 tag/branch +make openclaw-subtree-update REF=v2026.2.24 +# 或 +make openclaw-subtree-update REF=main +``` + +也可直接运行脚本: + +```bash +./scripts/update-openclaw-subtree.sh --ref v2026.2.24 +``` + ### Docker 部署 (推荐) diff --git a/backend/alembic/versions/20260226_000008_000000000008_add_security_dept_tasks_table.py b/backend/alembic/versions/20260226_000008_000000000008_add_security_dept_tasks_table.py new file mode 100644 index 00000000..da8b1f06 --- /dev/null +++ b/backend/alembic/versions/20260226_000008_000000000008_add_security_dept_tasks_table.py @@ -0,0 +1,87 @@ +"""add_security_dept_tasks_table + +Revision ID: 000000000008 +Revises: 000000000007 +Create Date: 2026-02-26 16:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "000000000008" +down_revision: Union[str, None] = "000000000007" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "security_dept_tasks", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", sa.String(length=255), nullable=False), + sa.Column("workspace_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("scenario", sa.String(length=50), nullable=False), + sa.Column("profile", sa.String(length=100), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("target", sa.Text(), nullable=True), + sa.Column("instruction_digest", sa.String(length=64), nullable=False), + sa.Column("instruction_preview", sa.String(length=500), nullable=False), + sa.Column( + "selected_skills", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + sa.Column("summary_md", sa.Text(), nullable=True), + sa.Column("result_structured", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("error_code", sa.String(length=100), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("duration_ms", sa.BigInteger(), nullable=True), + sa.Column("token_usage", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("cost_usd", sa.Float(), nullable=True), + sa.Column("execution_stats", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_index("ix_security_dept_tasks_status", "security_dept_tasks", ["status"], unique=False) + op.create_index("ix_security_dept_tasks_user_id", "security_dept_tasks", ["user_id"], unique=False) + op.create_index("ix_security_dept_tasks_workspace_id", "security_dept_tasks", ["workspace_id"], unique=False) + op.create_index( + "ix_security_dept_tasks_user_created", + "security_dept_tasks", + ["user_id", "created_at"], + unique=False, + ) + op.create_index( + "ix_security_dept_tasks_user_status", + "security_dept_tasks", + ["user_id", "status"], + unique=False, + ) + op.create_index( + "ix_security_dept_tasks_workspace_created", + "security_dept_tasks", + ["workspace_id", "created_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_security_dept_tasks_workspace_created", table_name="security_dept_tasks") + op.drop_index("ix_security_dept_tasks_user_status", table_name="security_dept_tasks") + op.drop_index("ix_security_dept_tasks_user_created", table_name="security_dept_tasks") + op.drop_index("ix_security_dept_tasks_workspace_id", table_name="security_dept_tasks") + op.drop_index("ix_security_dept_tasks_user_id", table_name="security_dept_tasks") + op.drop_index("ix_security_dept_tasks_status", table_name="security_dept_tasks") + op.drop_table("security_dept_tasks") diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 06a0d695..6a66e922 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -6,6 +6,8 @@ from fastapi import APIRouter +from app.one_person_security_dept.api.router import router as security_dept_router + from .admin_sandboxes import router as admin_sandboxes_router from .api_keys import router as api_keys_router from .auth import router as auth_router @@ -56,6 +58,7 @@ traces_router, users_router, environment_router, + security_dept_router, ] diff --git a/backend/app/core/settings.py b/backend/app/core/settings.py index 9e375c3c..dee984e6 100644 --- a/backend/app/core/settings.py +++ b/backend/app/core/settings.py @@ -330,6 +330,40 @@ def parse_cors_origins(cls, v: Union[str, List[str]]) -> List[str]: description="Workspace root directory for storing session files and workspace data", ) + # One Person Security Dept + security_dept_enabled: bool = Field( + default=True, + validation_alias=AliasChoices("SECURITY_DEPT_ENABLED", "ONE_PERSON_SECURITY_DEPT_ENABLED"), + description="Enable One Person Security Dept module", + ) + security_dept_max_concurrent_tasks: int = Field( + default=2, + validation_alias=AliasChoices("SECURITY_DEPT_MAX_CONCURRENT_TASKS", "ONE_PERSON_SECURITY_DEPT_MAX_CONCURRENT"), + description="Maximum concurrent Security Dept tasks per backend process", + ) + security_dept_task_timeout_seconds: int = Field( + default=1800, + validation_alias=AliasChoices( + "SECURITY_DEPT_TASK_TIMEOUT_SECONDS", "ONE_PERSON_SECURITY_DEPT_TASK_TIMEOUT_SECONDS" + ), + description="Timeout in seconds for one Security Dept task", + ) + security_dept_workdir_root: str = Field( + default=str(BASE_DIR / "workspace" / "security_dept_runs"), + validation_alias=AliasChoices("SECURITY_DEPT_WORKDIR_ROOT", "ONE_PERSON_SECURITY_DEPT_WORKDIR_ROOT"), + description="Working root directory for Security Dept task execution sandboxes", + ) + security_dept_event_ttl_seconds: int = Field( + default=7200, + validation_alias=AliasChoices("SECURITY_DEPT_EVENT_TTL_SECONDS", "ONE_PERSON_SECURITY_DEPT_EVENT_TTL_SECONDS"), + description="TTL for Security Dept task temporary Redis keys and status", + ) + security_dept_claude_cli_path: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("SECURITY_DEPT_CLAUDE_CLI_PATH", "ONE_PERSON_SECURITY_DEPT_CLAUDE_CLI_PATH"), + description="Optional custom path to Claude Code CLI binary for claude-agent-sdk", + ) + @property def WORKSPACE_ROOT(self) -> str: """Alias for workspace_root for backward compatibility""" diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4ad0f520..0ea59058 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -35,6 +35,7 @@ from .oauth_account import OAuthAccount from .organization import Member, Organization from .security_audit_log import SecurityAuditLog +from .security_dept_task import SecurityDeptTask from .settings import Environment, Settings, WorkspaceEnvironment from .skill import Skill, SkillFile from .user_sandbox import UserSandbox @@ -83,6 +84,7 @@ "Skill", "SkillFile", "SecurityAuditLog", + "SecurityDeptTask", "Memory", "ExecutionTrace", "ExecutionObservation", diff --git a/backend/app/models/security_dept_task.py b/backend/app/models/security_dept_task.py new file mode 100644 index 00000000..c286af7f --- /dev/null +++ b/backend/app/models/security_dept_task.py @@ -0,0 +1,113 @@ +""" +Security Dept task model. + +Stores execution metadata and summarized outputs for One Person Security Dept. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, Optional + +from sqlalchemy import BigInteger, DateTime, Float, ForeignKey, Index, String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base +from app.models.base import TimestampMixin + +if TYPE_CHECKING: + from app.models.auth import AuthUser + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class SecurityDeptTask(Base, TimestampMixin): + """Task record for Security Dept asynchronous runs.""" + + __tablename__ = "security_dept_tasks" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + user_id: Mapped[str] = mapped_column( + String(255), + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + workspace_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True, index=True) + + scenario: Mapped[str] = mapped_column(String(50), nullable=False, default="pentest") + profile: Mapped[str] = mapped_column(String(100), nullable=False, default="pentest_full_access_v1") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", index=True) + + target: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + instruction_digest: Mapped[str] = mapped_column(String(64), nullable=False) + instruction_preview: Mapped[str] = mapped_column(String(500), nullable=False) + selected_skills: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list) + + summary_md: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + result_structured: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True) + + error_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + duration_ms: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) + + token_usage: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True) + cost_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + + execution_stats: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True) + + user: Mapped["AuthUser"] = relationship("AuthUser", lazy="selectin") + + __table_args__ = ( + Index("ix_security_dept_tasks_user_created", "user_id", "created_at"), + Index("ix_security_dept_tasks_user_status", "user_id", "status"), + Index("ix_security_dept_tasks_workspace_created", "workspace_id", "created_at"), + ) + + def mark_running(self) -> None: + self.status = "running" + self.started_at = utc_now() + self.error_code = None + self.error_message = None + + def mark_completed( + self, + *, + summary_md: Optional[str], + result_structured: Optional[dict[str, Any]], + token_usage: Optional[dict[str, Any]], + cost_usd: Optional[float], + ) -> None: + now = utc_now() + self.status = "completed" + self.summary_md = summary_md + self.result_structured = result_structured + self.token_usage = token_usage + self.cost_usd = cost_usd + self.finished_at = now + if self.started_at is not None: + self.duration_ms = int((now - self.started_at).total_seconds() * 1000) + + def mark_failed(self, *, error_code: str, error_message: str) -> None: + now = utc_now() + self.status = "failed" + self.error_code = error_code + self.error_message = error_message + self.finished_at = now + if self.started_at is not None: + self.duration_ms = int((now - self.started_at).total_seconds() * 1000) + + def mark_cancelled(self) -> None: + now = utc_now() + self.status = "cancelled" + self.finished_at = now + if self.started_at is not None: + self.duration_ms = int((now - self.started_at).total_seconds() * 1000) diff --git a/backend/app/one_person_security_dept/__init__.py b/backend/app/one_person_security_dept/__init__.py new file mode 100644 index 00000000..8d712aaa --- /dev/null +++ b/backend/app/one_person_security_dept/__init__.py @@ -0,0 +1 @@ +"""One Person Security Dept module.""" diff --git a/backend/app/one_person_security_dept/api/__init__.py b/backend/app/one_person_security_dept/api/__init__.py new file mode 100644 index 00000000..95843be5 --- /dev/null +++ b/backend/app/one_person_security_dept/api/__init__.py @@ -0,0 +1,5 @@ +"""API module for One Person Security Dept.""" + +from .router import router + +__all__ = ["router"] diff --git a/backend/app/one_person_security_dept/api/router.py b/backend/app/one_person_security_dept/api/router.py new file mode 100644 index 00000000..a1f05c3b --- /dev/null +++ b/backend/app/one_person_security_dept/api/router.py @@ -0,0 +1,274 @@ +"""API router for One Person Security Dept.""" + +from __future__ import annotations + +import asyncio +import importlib.util +import json +import shutil +import uuid +from pathlib import Path +from typing import AsyncIterator + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.common.dependencies import CurrentUser, CurrentUserWithCSRF +from app.common.response import success_response +from app.core.database import AsyncSessionLocal, get_db +from app.core.redis import RedisClient +from app.core.settings import settings +from app.one_person_security_dept.claude_agent_sdk_python import has_vendored_sdk_source +from app.one_person_security_dept.schemas import ( + SecurityDeptCancelTaskResponse, + SecurityDeptCreateTaskResponse, + SecurityDeptHealthResponse, + SecurityDeptProfileItem, + SecurityDeptProfilesResponse, + SecurityDeptSkillFsItem, + SecurityDeptSkillFsResponse, + SecurityDeptTaskCreateRequest, + SecurityDeptTaskListResponse, + SecurityDeptTaskResponse, +) +from app.one_person_security_dept.services.event_bus import SecurityDeptEventBus +from app.one_person_security_dept.services.policy_service import SecurityDeptPolicyService +from app.one_person_security_dept.services.skills_service import SecurityDeptSkillsService +from app.one_person_security_dept.services.task_service import SecurityDeptTaskService + +router = APIRouter(prefix="/v1/security-dept", tags=["One Person Security Dept"]) + +_TERMINAL_STATUSES = {"completed", "failed", "cancelled"} + + +def _to_sse(event: dict) -> str: + return f"data: {json.dumps(event, ensure_ascii=False)}\n\n" + + +def _is_sdk_installed() -> bool: + if has_vendored_sdk_source(): + return True + return importlib.util.find_spec("claude_agent_sdk") is not None + + +def _is_cli_found(cli_path: str | None, sdk_installed: bool) -> bool: + if cli_path: + if shutil.which(cli_path): + return True + return Path(cli_path).exists() + # claude-agent-sdk bundles a CLI binary, so SDK presence is enough in default mode. + return sdk_installed + + +async def _load_task_for_user(task_id: uuid.UUID, user_id: str) -> SecurityDeptTaskResponse: + async with AsyncSessionLocal() as db: + service = SecurityDeptTaskService(db) + return await service.get_task(task_id=task_id, user_id=user_id) + + +@router.get("/health") +async def health() -> dict: + sdk_installed = _is_sdk_installed() + configured_cli_path = settings.security_dept_claude_cli_path + response = SecurityDeptHealthResponse( + enabled=settings.security_dept_enabled, + redis_available=RedisClient.is_available(), + sdk_installed=sdk_installed, + cli_found=_is_cli_found(configured_cli_path, sdk_installed), + configured_cli_path=configured_cli_path, + max_concurrent_tasks=settings.security_dept_max_concurrent_tasks, + timeout_seconds=settings.security_dept_task_timeout_seconds, + workdir_root=settings.security_dept_workdir_root, + ) + return success_response(data=response.model_dump(mode="json")) + + +@router.get("/profiles") +async def list_profiles() -> dict: + items = [ + SecurityDeptProfileItem( + name=profile.name, + description=profile.description, + permission_mode=profile.permission_mode, + scenario=profile.scenario, + ) + for profile in SecurityDeptPolicyService.list_profiles() + ] + response = SecurityDeptProfilesResponse(items=items) + return success_response(data=response.model_dump(mode="json")) + + +@router.get("/skills/fs") +async def list_fs_skills( + _current_user: CurrentUser, +) -> dict: + root, items = SecurityDeptSkillsService.list_fs_skills() + response = SecurityDeptSkillFsResponse( + root_path=str(root), + items=[SecurityDeptSkillFsItem(**item) for item in items], + ) + return success_response(data=response.model_dump(mode="json")) + + +@router.post("/tasks") +async def create_task( + payload: SecurityDeptTaskCreateRequest, + current_user: CurrentUserWithCSRF, + db: AsyncSession = Depends(get_db), +) -> dict: + service = SecurityDeptTaskService(db) + result = await service.create_task( + user_id=current_user.id, + scenario=payload.scenario, + profile_name=payload.profile, + target=payload.target, + instruction=payload.instruction, + skill_names=payload.skill_names, + workspace_id=payload.workspace_id, + ) + return success_response( + data=SecurityDeptCreateTaskResponse(**result.model_dump()).model_dump(mode="json"), + message="Security Dept task created", + ) + + +@router.get("/tasks") +async def list_tasks( + current_user: CurrentUser, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + status: str | None = Query(default=None), + db: AsyncSession = Depends(get_db), +) -> dict: + service = SecurityDeptTaskService(db) + result = await service.list_tasks( + user_id=current_user.id, + page=page, + page_size=page_size, + status=status, + ) + return success_response(data=SecurityDeptTaskListResponse(**result.model_dump()).model_dump(mode="json")) + + +@router.get("/tasks/{task_id}") +async def get_task( + task_id: uuid.UUID, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +) -> dict: + service = SecurityDeptTaskService(db) + result = await service.get_task(task_id=task_id, user_id=current_user.id) + return success_response(data=SecurityDeptTaskResponse(**result.model_dump()).model_dump(mode="json")) + + +@router.post("/tasks/{task_id}/cancel") +async def cancel_task( + task_id: uuid.UUID, + current_user: CurrentUserWithCSRF, + db: AsyncSession = Depends(get_db), +) -> dict: + service = SecurityDeptTaskService(db) + result = await service.cancel_task(task_id=task_id, user_id=current_user.id) + response = SecurityDeptCancelTaskResponse(task_id=str(result.id), status=result.status) + return success_response(data=response.model_dump(mode="json")) + + +@router.get("/tasks/{task_id}/events") +async def stream_task_events( + task_id: uuid.UUID, + request: Request, + current_user: CurrentUser, +) -> StreamingResponse: + task_id_str = str(task_id) + + async def event_generator() -> AsyncIterator[str]: + # Emit latest task status first so the client can hydrate quickly. + current = await _load_task_for_user(task_id, current_user.id) + yield _to_sse( + SecurityDeptEventBus.build_event( + task_id_str, + "status", + { + "status": current.status, + "message": f"Task status: {current.status}", + }, + ) + ) + + if current.summary_md: + yield _to_sse(SecurityDeptEventBus.build_event(task_id_str, "summary", {"summary_md": current.summary_md})) + + if current.status in _TERMINAL_STATUSES: + yield _to_sse(SecurityDeptEventBus.build_event(task_id_str, "done", {"status": current.status})) + return + + if not RedisClient.is_available() or RedisClient.get_client() is None: + # Fallback mode: polling status changes when Redis pub/sub is unavailable. + last_status = current.status + last_summary = current.summary_md + while not await request.is_disconnected(): + await asyncio.sleep(1.0) + latest = await _load_task_for_user(task_id, current_user.id) + if latest.status != last_status: + last_status = latest.status + yield _to_sse( + SecurityDeptEventBus.build_event( + task_id_str, + "status", + { + "status": latest.status, + "message": f"Task status: {latest.status}", + }, + ) + ) + if latest.summary_md and latest.summary_md != last_summary: + last_summary = latest.summary_md + yield _to_sse( + SecurityDeptEventBus.build_event(task_id_str, "summary", {"summary_md": latest.summary_md}) + ) + if latest.status in _TERMINAL_STATUSES: + yield _to_sse(SecurityDeptEventBus.build_event(task_id_str, "done", {"status": latest.status})) + return + return + + redis_client = RedisClient.get_client() + assert redis_client is not None + pubsub = redis_client.pubsub() + await pubsub.subscribe(SecurityDeptEventBus.channel(task_id_str)) + + try: + keepalive_deadline = asyncio.get_running_loop().time() + 15 + while not await request.is_disconnected(): + message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0) + if message and message.get("type") == "message": + payload = message.get("data") + if payload: + payload_str = payload if isinstance(payload, str) else str(payload) + yield f"data: {payload_str}\n\n" + try: + parsed = json.loads(payload_str) + except json.JSONDecodeError: + parsed = {} + if parsed.get("type") == "done": + return + keepalive_deadline = asyncio.get_running_loop().time() + 15 + continue + + now = asyncio.get_running_loop().time() + if now >= keepalive_deadline: + yield ": keep-alive\n\n" + keepalive_deadline = now + 15 + finally: + await pubsub.unsubscribe(SecurityDeptEventBus.channel(task_id_str)) + await pubsub.close() + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/__init__.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/__init__.py new file mode 100644 index 00000000..958dc349 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/__init__.py @@ -0,0 +1,5 @@ +"""Local integration boundary for claude-agent-sdk-python.""" + +from .runtime import ClaudeAgentSdkExports, has_vendored_sdk_source, load_claude_agent_sdk + +__all__ = ["ClaudeAgentSdkExports", "has_vendored_sdk_source", "load_claude_agent_sdk"] diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.dockerignore b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.dockerignore new file mode 100644 index 00000000..d013f1bc --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.dockerignore @@ -0,0 +1,49 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Testing/Coverage +.coverage +.pytest_cache/ +htmlcov/ +.tox/ +.nox/ + +# Misc +*.log +.DS_Store diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/auto-release.yml b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/auto-release.yml new file mode 100644 index 00000000..43dcf017 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/auto-release.yml @@ -0,0 +1,54 @@ +name: Auto Release on CLI Bump + +on: + workflow_run: + workflows: ["Test"] + types: [completed] + branches: [main] + +jobs: + check-trigger: + runs-on: ubuntu-latest + if: | + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_commit.message, 'chore: bump bundled CLI version to') + outputs: + version: ${{ steps.version.outputs.version }} + previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Verify CLI version file was changed + run: | + if ! git diff --name-only HEAD~1 | grep -q '_cli_version.py'; then + echo "::error::CLI version file not changed in this commit" + exit 1 + fi + + - name: Get current SDK version and calculate next + id: version + run: | + CURRENT=$(python -c "import re; print(re.search(r'__version__ = \"([^\"]+)\"', open('src/claude_agent_sdk/_version.py').read()).group(1))") + IFS='.' read -ra PARTS <<< "$CURRENT" + NEXT="${PARTS[0]}.${PARTS[1]}.$((PARTS[2] + 1))" + echo "version=$NEXT" >> $GITHUB_OUTPUT + echo "Current: $CURRENT -> Next: $NEXT" + + - name: Get previous release tag + id: previous_tag + run: | + PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + + release: + needs: check-trigger + permissions: + contents: write + uses: ./.github/workflows/build-and-publish.yml + with: + version: ${{ needs.check-trigger.outputs.version }} + previous_tag: ${{ needs.check-trigger.outputs.previous_tag }} + secrets: inherit diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/build-and-publish.yml b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/build-and-publish.yml new file mode 100644 index 00000000..92c9c6fb --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/build-and-publish.yml @@ -0,0 +1,115 @@ +name: Build and Publish + +on: + workflow_call: + inputs: + version: + description: 'Version to publish' + required: true + type: string + previous_tag: + description: 'Previous release tag for changelog generation' + required: false + type: string + default: '' + +jobs: + build-wheels: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build dependencies + run: pip install build twine wheel + shell: bash + + - name: Build wheel with bundled CLI + run: python scripts/build_wheel.py --version "${{ inputs.version }}" --skip-sdist --clean + shell: bash + + - uses: actions/upload-artifact@v4 + with: + name: wheel-${{ matrix.os }} + path: dist/*.whl + if-no-files-found: error + + publish: + needs: build-wheels + runs-on: ubuntu-latest + environment: production + permissions: + contents: write + env: + VERSION: ${{ inputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ssh-key: ${{ secrets.DEPLOY_KEY }} + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Update version files + run: python scripts/update_version.py "$VERSION" + + - uses: actions/download-artifact@v4 + with: + path: dist + pattern: wheel-* + merge-multiple: true + + - name: Build sdist and publish to PyPI + run: | + pip install build twine + python -m build --sdist + twine upload dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + + - name: Configure git + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + - name: Commit version changes + run: | + git add pyproject.toml src/claude_agent_sdk/_version.py + git commit -m "chore: release v$VERSION" + + - name: Update changelog with Claude + continue-on-error: true + uses: anthropics/claude-code-action@v1 + with: + prompt: "/generate-changelog new version: ${{ env.VERSION }}, old version: ${{ inputs.previous_tag }}" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + claude_args: | + --model claude-opus-4-6 + --allowedTools 'Bash(git add:*),Bash(git commit:*),Edit' + + - name: Push to main + run: | + git remote set-url origin git@github.com:anthropics/claude-agent-sdk-python.git + git push origin main + + - name: Create tag and GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git tag -a "v$VERSION" -m "Release v$VERSION" + git push origin "v$VERSION" + + awk -v ver="$VERSION" '/^## / { if (found) exit; if ($2 == ver) found=1; next } found { print }' CHANGELOG.md > release_notes.md + echo -e "\n---\n\n**PyPI:** https://pypi.org/project/claude-agent-sdk/$VERSION/\n\n\`\`\`bash\npip install claude-agent-sdk==$VERSION\n\`\`\`" >> release_notes.md + + gh release create "v$VERSION" --title "v$VERSION" --notes-file release_notes.md diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/claude-code-review.yml b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/claude-code-review.yml new file mode 100644 index 00000000..f1ce7909 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/claude-code-review.yml @@ -0,0 +1,55 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Skip on forks since they don't have access to secrets + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + claude_args: --model claude-opus-4-6 + + # Direct prompt for automated review (no @claude mention needed) + prompt: | + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Be constructive and helpful in your feedback. + + # Optional: Customize review based on file types + # prompt: | + # Review this PR focusing on: + # - For TypeScript files: Type safety and proper interface usage + # - For API endpoints: Security, input validation, and error handling + # - For React components: Performance, accessibility, and reusability + # - For test files: Coverage, edge cases, and test quality \ No newline at end of file diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/claude-issue-triage.yml b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/claude-issue-triage.yml new file mode 100644 index 00000000..d5bdc4d0 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/claude-issue-triage.yml @@ -0,0 +1,107 @@ +name: Claude Issue Triage + +on: + issues: + types: [opened] + +jobs: + triage-issue: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Create triage prompt + run: | + mkdir -p /tmp/claude-prompts + cat > /tmp/claude-prompts/triage-prompt.txt << 'EOF' + You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list. + + IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels. + + Issue Information: + - REPO: ${{ github.repository }} + - ISSUE_NUMBER: ${{ github.event.issue.number }} + + TASK OVERVIEW: + + 1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else. + + 2. Next, use the GitHub tools to get context about the issue: + - You have access to these tools: + - mcp__github__get_issue: Use this to retrieve the current issue's details including title, description, and existing labels + - mcp__github__get_issue_comments: Use this to read any discussion or additional context provided in the comments + - mcp__github__update_issue: Use this to apply labels to the issue (do not use this for commenting) + - mcp__github__search_issues: Use this to find similar issues that might provide context for proper categorization and to identify potential duplicate issues + - mcp__github__list_issues: Use this to understand patterns in how other issues are labeled + - Start by using mcp__github__get_issue to get the issue details + + 3. Analyze the issue content, considering: + - The issue title and description + - The type of issue (bug report, feature request, question, etc.) + - Technical areas mentioned + - Severity or priority indicators + - User impact + - Components affected + + 4. Select appropriate labels from the available labels list provided above: + - Choose labels that accurately reflect the issue's nature + - Be specific but comprehensive + - Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority) + - Consider platform labels (android, ios) if applicable + - If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue. + + 5. Apply the selected labels: + - Use mcp__github__update_issue to apply your selected labels + - DO NOT post any comments explaining your decision + - DO NOT communicate directly with users + - If no labels are clearly applicable, do not apply any labels + + IMPORTANT GUIDELINES: + - Be thorough in your analysis + - Only select labels from the provided list above + - DO NOT post any comments to the issue + - Your ONLY action should be to apply labels using mcp__github__update_issue + - It's okay to not add any labels if none are clearly applicable + EOF + + - name: Setup GitHub MCP Server + run: | + mkdir -p /tmp/mcp-config + cat > /tmp/mcp-config/mcp-servers.json << 'EOF' + { + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server:sha-7aced2b" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}" + } + } + } + } + EOF + + - name: Run Claude Code for Issue Triage + uses: anthropics/claude-code-base-action@v1 + timeout-minutes: 5 + with: + prompt_file: /tmp/claude-prompts/triage-prompt.txt + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: | + --allowed-tools "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues" + --mcp-config /tmp/mcp-config/mcp-servers.json + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/claude.yml b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/claude.yml new file mode 100644 index 00000000..b304caa8 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/claude.yml @@ -0,0 +1,61 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + claude_args: --model claude-opus-4-6 + + # Optional: Customize the trigger phrase (default: @claude) + # trigger_phrase: "/claude" + + # Optional: Trigger when specific user is assigned to an issue + # assignee_trigger: "claude-bot" + + # Allow Claude to run linters, typecheckers, and tests + claude_args: | + --allowed-tools "Bash(python -m ruff check:*),Bash(python -m ruff format:*),Bash(python -m mypy:*),Bash(python -m pytest:*)" + + # Optional: Add custom instructions for Claude to customize its behavior for your project + # claude_args: --system-prompt "Follow our coding standards" \ No newline at end of file diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/lint.yml b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/lint.yml new file mode 100644 index 00000000..45fdf805 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/lint.yml @@ -0,0 +1,33 @@ +name: Lint + +on: + pull_request: + push: + branches: + - 'main' + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run ruff + run: | + ruff check src/ tests/ + ruff format --check src/ tests/ + + - name: Run mypy + run: | + mypy src/ \ No newline at end of file diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/publish.yml b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/publish.yml new file mode 100644 index 00000000..9b5edb44 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/publish.yml @@ -0,0 +1,83 @@ +name: Publish to PyPI + +on: + workflow_dispatch: + inputs: + version: + description: 'Package version to publish (e.g., 0.1.4)' + required: true + type: string + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests + run: | + python -m pytest tests/ -v + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run ruff + run: | + ruff check src/ tests/ + ruff format --check src/ tests/ + + - name: Run mypy + run: | + mypy src/ + + get-previous-tag: + runs-on: ubuntu-latest + outputs: + previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get previous release tag + id: previous_tag + run: | + PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + + release: + needs: [test, lint, get-previous-tag] + permissions: + contents: write + uses: ./.github/workflows/build-and-publish.yml + with: + version: ${{ github.event.inputs.version }} + previous_tag: ${{ needs.get-previous-tag.outputs.previous_tag }} + secrets: inherit diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/slack-issue-notification.yml b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/slack-issue-notification.yml new file mode 100644 index 00000000..675dd939 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/slack-issue-notification.yml @@ -0,0 +1,36 @@ +name: Post new issues to Slack + +on: + issues: + types: [opened] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Post to Slack + uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # 2.1.1 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + { + "channel": "C09HY5E0K60", + "text": "New issue opened in ${{ github.repository }}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*New Issue:* <${{ github.event.issue.html_url }}|#${{ github.event.issue.number }} ${{ github.event.issue.title }}>" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Author:* ${{ github.event.issue.user.login }}" + } + } + ] + } diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/test.yml b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/test.yml new file mode 100644 index 00000000..7f7c9ea8 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.github/workflows/test.yml @@ -0,0 +1,170 @@ +name: Test + +on: + pull_request: + push: + branches: + - "main" + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests + run: | + python -m pytest tests/ -v --cov=claude_agent_sdk --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: false + + test-e2e: + # Skip on forks since they don't have access to secrets + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ${{ matrix.os }} + needs: test # Run after unit tests pass + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install Claude Code (Linux/macOS) + if: runner.os == 'Linux' || runner.os == 'macOS' + run: | + curl -fsSL https://claude.ai/install.sh | bash + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Install Claude Code (Windows) + if: runner.os == 'Windows' + run: | + irm https://claude.ai/install.ps1 | iex + $claudePath = "$env:USERPROFILE\.local\bin" + echo "$claudePath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + shell: pwsh + + - name: Verify Claude Code installation + run: claude -v + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run end-to-end tests with real API + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + python -m pytest e2e-tests/ -v -m e2e + + test-e2e-docker: + # Skip on forks since they don't have access to secrets + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + needs: test # Run after unit tests pass + # Run e2e tests in Docker to catch container-specific issues like #406 + + steps: + - uses: actions/checkout@v4 + + - name: Build Docker test image + run: docker build -f Dockerfile.test -t claude-sdk-test . + + - name: Run e2e tests in Docker + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + docker run --rm -e ANTHROPIC_API_KEY \ + claude-sdk-test python -m pytest e2e-tests/ -v -m e2e + + test-examples: + # Skip on forks since they don't have access to secrets + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + needs: test-e2e # Run after e2e tests + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install Claude Code (Linux) + if: runner.os == 'Linux' + run: | + curl -fsSL https://claude.ai/install.sh | bash + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Install Claude Code (Windows) + if: runner.os == 'Windows' + run: | + irm https://claude.ai/install.ps1 | iex + $claudePath = "$env:USERPROFILE\.local\bin" + echo "$claudePath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + shell: pwsh + + - name: Verify Claude Code installation + run: claude -v + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Run example scripts (Linux) + if: runner.os == 'Linux' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + python examples/quick_start.py + timeout 120 python examples/streaming_mode.py all + timeout 120 python examples/hooks.py PreToolUse + timeout 120 python examples/hooks.py DecisionFields + + - name: Run example scripts (Windows) + if: runner.os == 'Windows' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + python examples/quick_start.py + $job = Start-Job { python examples/streaming_mode.py all } + Wait-Job $job -Timeout 120 | Out-Null + Stop-Job $job + Receive-Job $job + + $job = Start-Job { python examples/hooks.py PreToolUse } + Wait-Job $job -Timeout 120 | Out-Null + Stop-Job $job + Receive-Job $job + + $job = Start-Job { python examples/hooks.py DecisionFields } + Wait-Job $job -Timeout 120 | Out-Null + Stop-Job $job + Receive-Job $job + shell: pwsh diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.gitignore b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.gitignore new file mode 100644 index 00000000..6be5cc43 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/.gitignore @@ -0,0 +1,51 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +venv/ +ENV/ +env/ +.venv +uv.lock + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +**/.DS_Store + +# Testing +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +htmlcov/ + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/CHANGELOG.md b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/CHANGELOG.md new file mode 100644 index 00000000..b8ddb15a --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/CHANGELOG.md @@ -0,0 +1,452 @@ +# Changelog + +## 0.1.43 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.56 + +## 0.1.42 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.55 + +## 0.1.41 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.52 + +## 0.1.40 + +### Bug Fixes + +- **Unknown message type handling**: Fixed an issue where unrecognized CLI message types (e.g., `rate_limit_event`) would crash the session by raising `MessageParseError`. Unknown message types are now silently skipped, making the SDK forward-compatible with future CLI message types (#598) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.51 + +## 0.1.39 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.49 + +## 0.1.38 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.47 + +## 0.1.37 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.44 + +## 0.1.36 + +### New Features + +- **Thinking configuration**: Added `ThinkingConfig` types (`ThinkingConfigAdaptive`, `ThinkingConfigEnabled`, `ThinkingConfigDisabled`) and `thinking` field to `ClaudeAgentOptions` for fine-grained control over extended thinking behavior. The new `thinking` field takes precedence over the now-deprecated `max_thinking_tokens` field (#565) +- **Effort option**: Added `effort` field to `ClaudeAgentOptions` supporting `"low"`, `"medium"`, `"high"`, and `"max"` values for controlling thinking depth (#565) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.42 + +## 0.1.35 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.39 + +## 0.1.34 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.38 +- Updated CI workflows to use Claude Opus 4.6 model (#556) + +## 0.1.33 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.37 + +## 0.1.32 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.36 + +## 0.1.31 + +### New Features + +- **MCP tool annotations support**: Added support for MCP tool annotations via the `@tool` decorator's new `annotations` parameter, allowing developers to specify metadata hints like `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint`. Re-exported `ToolAnnotations` from `claude_agent_sdk` for convenience (#551) + +### Bug Fixes + +- **Large agent definitions**: Fixed an issue where large agent definitions would silently fail to register due to platform-specific CLI argument size limits (ARG_MAX). Agent definitions are now sent via the initialize control request through stdin, matching the TypeScript SDK approach and allowing arbitrarily large agent payloads (#468) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.33 + +## 0.1.30 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.32 + +## 0.1.29 + +### New Features + +- **New hook events**: Added support for three new hook event types (#545): + - `Notification` — for handling notification events with `NotificationHookInput` and `NotificationHookSpecificOutput` + - `SubagentStart` — for handling subagent startup with `SubagentStartHookInput` and `SubagentStartHookSpecificOutput` + - `PermissionRequest` — for handling permission requests with `PermissionRequestHookInput` and `PermissionRequestHookSpecificOutput` + +- **Enhanced hook input/output types**: Added missing fields to existing hook types (#545): + - `PreToolUseHookInput`: added `tool_use_id` + - `PostToolUseHookInput`: added `tool_use_id` + - `SubagentStopHookInput`: added `agent_id`, `agent_transcript_path`, `agent_type` + - `PreToolUseHookSpecificOutput`: added `additionalContext` + - `PostToolUseHookSpecificOutput`: added `updatedMCPToolOutput` + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.31 + +## 0.1.28 + +### Bug Fixes + +- **AssistantMessage error field**: Fixed `AssistantMessage.error` field not being populated due to incorrect data path. The error field is now correctly read from the top level of the response (#506) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.30 + +## 0.1.27 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.29 + +## 0.1.26 + +### New Features + +- **PostToolUseFailure hook event**: Added `PostToolUseFailure` hook event type for handling tool use failures, including `PostToolUseFailureHookInput` and `PostToolUseFailureHookSpecificOutput` types (#535) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.27 + +## 0.1.25 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.23 + +## 0.1.24 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.22 + +## 0.1.23 + +### Features + +- **MCP status querying**: Added public `get_mcp_status()` method to `ClaudeSDKClient` for querying MCP server connection status without accessing private internals (#516) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.20 + +## 0.1.22 + +### Features + +- Added `tool_use_result` field to `UserMessage` (#495) + +### Bug Fixes + +- Added permissions to release job in auto-release workflow (#504) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.19 +- Extracted build-and-publish workflow into reusable component (#488) + +## 0.1.21 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.15 + +## 0.1.20 + +### Bug Fixes + +- **Permission callback test reliability**: Improved robustness of permission callback end-to-end tests (#485) + +### Documentation + +- Updated Claude Agent SDK documentation link (#442) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.9 +- **CI improvements**: Updated claude-code actions from @beta to @v1 (#467) + +## 0.1.19 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.1.1 +- **CI improvements**: Jobs requiring secrets now skip when running from forks (#451) +- Fixed YAML syntax error in create-release-tag workflow (#429) + +## 0.1.18 + +### Internal/Other Changes + +- **Docker-based test infrastructure**: Added Docker support for running e2e tests in containerized environments, helping catch Docker-specific issues (#424) +- Updated bundled Claude CLI to version 2.0.72 + +## 0.1.17 + +### New Features + +- **UserMessage UUID field**: Added `uuid` field to `UserMessage` response type, making it easier to use the `rewind_files()` method by providing direct access to message identifiers needed for file checkpointing (#418) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.70 + +## 0.1.16 + +### Bug Fixes + +- **Rate limit detection**: Fixed parsing of the `error` field in `AssistantMessage`, enabling applications to detect and handle API errors like rate limits. Previously, the `error` field was defined but never populated from CLI responses (#405) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.68 + +## 0.1.15 + +### New Features + +- **File checkpointing and rewind**: Added `enable_file_checkpointing` option to `ClaudeAgentOptions` and `rewind_files(user_message_id)` method to `ClaudeSDKClient` and `Query`. This enables reverting file changes made during a session back to a specific checkpoint, useful for exploring different approaches or recovering from unwanted modifications (#395) + +### Documentation + +- Added license and terms section to README (#399) + +## 0.1.14 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.62 + +## 0.1.13 + +### Bug Fixes + +- **Faster error handling**: CLI errors (e.g., invalid session ID) now propagate to pending requests immediately instead of waiting for the 60-second timeout (#388) +- **Pydantic 2.12+ compatibility**: Fixed `PydanticUserError` caused by `McpServer` type only being imported under `TYPE_CHECKING` (#385) +- **Concurrent subagent writes**: Added write lock to prevent `BusyResourceError` when multiple subagents invoke MCP tools in parallel (#391) + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.59 + +## 0.1.12 + +### New Features + +- **Tools option**: Added `tools` option to `ClaudeAgentOptions` for controlling the base set of available tools, matching the TypeScript SDK functionality. Supports three modes: + - Array of tool names to specify which tools should be available (e.g., `["Read", "Edit", "Bash"]`) + - Empty array `[]` to disable all built-in tools + - Preset object `{"type": "preset", "preset": "claude_code"}` to use the default Claude Code toolset +- **SDK beta support**: Added `betas` option to `ClaudeAgentOptions` for enabling Anthropic API beta features. Currently supports `"context-1m-2025-08-07"` for extended context window + +## 0.1.11 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.57 + +## 0.1.10 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.53 + +## 0.1.9 + +### Internal/Other Changes + +- Updated bundled Claude CLI to version 2.0.49 + +## 0.1.8 + +### Features + +- Claude Code is now included by default in the package, removing the requirement to install it separately. If you do wish to use a separately installed build, use the `cli_path` field in `Options`. + +## 0.1.7 + +### Features + +- **Structured outputs support**: Agents can now return validated JSON matching your schema. See https://docs.claude.com/en/docs/agent-sdk/structured-outputs. (#340) +- **Fallback model handling**: Added automatic fallback model handling for improved reliability and parity with the TypeScript SDK. When the primary model is unavailable, the SDK will automatically use a fallback model (#317) +- **Local Claude CLI support**: Added support for using a locally installed Claude CLI from `~/.claude/local/claude`, enabling development and testing with custom Claude CLI builds (#302) + +## 0.1.6 + +### Features + +- **Max budget control**: Added `max_budget_usd` option to set a maximum spending limit in USD for SDK sessions. When the budget is exceeded, the session will automatically terminate, helping prevent unexpected costs (#293) +- **Extended thinking configuration**: Added `max_thinking_tokens` option to control the maximum number of tokens allocated for Claude's internal reasoning process. This allows fine-tuning of the balance between response quality and token usage (#298) + +### Bug Fixes + +- **System prompt defaults**: Fixed issue where a default system prompt was being used when none was specified. The SDK now correctly uses an empty system prompt by default, giving users full control over agent behavior (#290) + +## 0.1.5 + +### Features + +- **Plugin support**: Added the ability to load Claude Code plugins programmatically through the SDK. Plugins can be specified using the new `plugins` field in `ClaudeAgentOptions` with a `SdkPluginConfig` type that supports loading local plugins by path. This enables SDK applications to extend functionality with custom commands and capabilities defined in plugin directories + +## 0.1.4 + +### Features + +- **Skip version check**: Added `CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK` environment variable to allow users to disable the Claude Code version check. Set this environment variable to skip the minimum version validation when the SDK connects to Claude Code. (Only recommended if you already have Claude Code 2.0.0 or higher installed, otherwise some functionality may break) +- SDK MCP server tool calls can now return image content blocks + +## 0.1.3 + +### Features + +- **Strongly-typed hook inputs**: Added typed hook input structures (`PreToolUseHookInput`, `PostToolUseHookInput`, `UserPromptSubmitHookInput`, etc.) using TypedDict for better IDE autocomplete and type safety. Hook callbacks now receive fully typed input parameters + +### Bug Fixes + +- **Hook output field conversion**: Fixed bug where Python-safe field names (`async_`, `continue_`) in hook outputs were not being converted to CLI format (`async`, `continue`). This caused hook control fields to be silently ignored, preventing proper hook behavior. The SDK now automatically converts field names when communicating with the CLI + +### Internal/Other Changes + +- **CI/CD**: Re-enabled Windows testing in the end-to-end test workflow. Windows CI had been temporarily disabled but is now fully operational across all test suites + +## 0.1.2 + +### Bug Fixes + +- **Hook output fields**: Added missing hook output fields to match the TypeScript SDK, including `reason`, `continue_`, `suppressOutput`, and `stopReason`. The `decision` field now properly supports both "approve" and "block" values. Added `AsyncHookJSONOutput` type for deferred hook execution and proper typing for `hookSpecificOutput` with discriminated unions + +## 0.1.1 + +### Features + +- **Minimum Claude Code version check**: Added version validation to ensure Claude Code 2.0.0+ is installed. The SDK will display a warning if an older version is detected, helping prevent compatibility issues +- **Updated PermissionResult types**: Aligned permission result types with the latest control protocol for better type safety and compatibility + +### Improvements + +- **Model references**: Updated all examples and tests to use the simplified `claude-sonnet-4-5` model identifier instead of dated version strings + +## 0.1.0 + +Introducing the Claude Agent SDK! The Claude Code SDK has been renamed to better reflect its capabilities for building AI agents across all domains, not just coding. + +### Breaking Changes + +#### Type Name Changes + +- **ClaudeCodeOptions renamed to ClaudeAgentOptions**: The options type has been renamed to match the new SDK branding. Update all imports and type references: + + ```python + # Before + from claude_agent_sdk import query, ClaudeCodeOptions + options = ClaudeCodeOptions(...) + + # After + from claude_agent_sdk import query, ClaudeAgentOptions + options = ClaudeAgentOptions(...) + ``` + +#### System Prompt Changes + +- **Merged prompt options**: The `custom_system_prompt` and `append_system_prompt` fields have been merged into a single `system_prompt` field for simpler configuration +- **No default system prompt**: The Claude Code system prompt is no longer included by default, giving you full control over agent behavior. To use the Claude Code system prompt, explicitly set: + ```python + system_prompt={"type": "preset", "preset": "claude_code"} + ``` + +#### Settings Isolation + +- **No filesystem settings by default**: Settings files (`settings.json`, `CLAUDE.md`), slash commands, and subagents are no longer loaded automatically. This ensures SDK applications have predictable behavior independent of local filesystem configurations +- **Explicit settings control**: Use the new `setting_sources` field to specify which settings locations to load: `["user", "project", "local"]` + +For full migration instructions, see our [migration guide](https://docs.claude.com/en/docs/claude-code/sdk/migration-guide). + +### New Features + +- **Programmatic subagents**: Subagents can now be defined inline in code using the `agents` option, enabling dynamic agent creation without filesystem dependencies. [Learn more](https://docs.claude.com/en/api/agent-sdk/subagents) +- **Session forking**: Resume sessions with the new `fork_session` option to branch conversations and explore different approaches from the same starting point. [Learn more](https://docs.claude.com/en/api/agent-sdk/sessions) +- **Granular settings control**: The `setting_sources` option gives you fine-grained control over which filesystem settings to load, improving isolation for CI/CD, testing, and production deployments + +### Documentation + +- Comprehensive documentation now available in the [API Guide](https://docs.claude.com/en/api/agent-sdk/overview) +- New guides for [Custom Tools](https://docs.claude.com/en/api/agent-sdk/custom-tools), [Permissions](https://docs.claude.com/en/api/agent-sdk/permissions), [Session Management](https://docs.claude.com/en/api/agent-sdk/sessions), and more +- Complete [Python API reference](https://docs.claude.com/en/api/agent-sdk/python) + +## 0.0.22 + +- Introduce custom tools, implemented as in-process MCP servers. +- Introduce hooks. +- Update internal `Transport` class to lower-level interface. +- `ClaudeSDKClient` can no longer be run in different async contexts. + +## 0.0.19 + +- Add `ClaudeCodeOptions.add_dirs` for `--add-dir` +- Fix ClaudeCodeSDK hanging when MCP servers log to Claude Code stderr + +## 0.0.18 + +- Add `ClaudeCodeOptions.settings` for `--settings` + +## 0.0.17 + +- Remove dependency on asyncio for Trio compatibility + +## 0.0.16 + +- Introduce ClaudeSDKClient for bidirectional streaming conversation +- Support Message input, not just string prompts, in query() +- Raise explicit error if the cwd does not exist + +## 0.0.14 + +- Add safety limits to Claude Code CLI stderr reading +- Improve handling of output JSON messages split across multiple stream reads + +## 0.0.13 + +- Update MCP (Model Context Protocol) types to align with Claude Code expectations +- Fix multi-line buffering issue +- Rename cost_usd to total_cost_usd in API responses +- Fix optional cost fields handling diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/CLAUDE.md b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/CLAUDE.md new file mode 100644 index 00000000..189cdefb --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/CLAUDE.md @@ -0,0 +1,27 @@ +# Workflow + +```bash +# Lint and style +# Check for issues and fix automatically +python -m ruff check src/ tests/ --fix +python -m ruff format src/ tests/ + +# Typecheck (only done for src/) +python -m mypy src/ + +# Run all tests +python -m pytest tests/ + +# Run specific test file +python -m pytest tests/test_client.py +``` + +# Codebase Structure + +- `src/claude_agent_sdk/` - Main package + - `client.py` - ClaudeSDKClient for interactive sessions + - `query.py` - One-shot query function + - `types.py` - Type definitions + - `_internal/` - Internal implementation details + - `transport/subprocess_cli.py` - CLI subprocess management + - `message_parser.py` - Message parsing logic diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/Dockerfile.test b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/Dockerfile.test new file mode 100644 index 00000000..22adf2ec --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/Dockerfile.test @@ -0,0 +1,29 @@ +# Dockerfile for running SDK tests in a containerized environment +# This helps catch Docker-specific issues like #406 + +FROM python:3.12-slim + +# Install dependencies for Claude CLI and git (needed for some tests) +RUN apt-get update && apt-get install -y \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install Claude Code CLI +RUN curl -fsSL https://claude.ai/install.sh | bash +ENV PATH="/root/.local/bin:$PATH" + +# Set up working directory +WORKDIR /app + +# Copy the SDK source +COPY . . + +# Install SDK with dev dependencies +RUN pip install -e ".[dev]" + +# Verify CLI installation +RUN claude -v + +# Default: run unit tests +CMD ["python", "-m", "pytest", "tests/", "-v"] diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/LICENSE b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/LICENSE new file mode 100644 index 00000000..3fa6a64e --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Anthropic, PBC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/README.md b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/README.md new file mode 100644 index 00000000..790c9249 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/README.md @@ -0,0 +1,356 @@ +# Claude Agent SDK for Python + +Python SDK for Claude Agent. See the [Claude Agent SDK documentation](https://platform.claude.com/docs/en/agent-sdk/python) for more information. + +## Installation + +```bash +pip install claude-agent-sdk +``` + +**Prerequisites:** + +- Python 3.10+ + +**Note:** The Claude Code CLI is automatically bundled with the package - no separate installation required! The SDK will use the bundled CLI by default. If you prefer to use a system-wide installation or a specific version, you can: + +- Install Claude Code separately: `curl -fsSL https://claude.ai/install.sh | bash` +- Specify a custom path: `ClaudeAgentOptions(cli_path="/path/to/claude")` + +## Quick Start + +```python +import anyio +from claude_agent_sdk import query + +async def main(): + async for message in query(prompt="What is 2 + 2?"): + print(message) + +anyio.run(main) +``` + +## Basic Usage: query() + +`query()` is an async function for querying Claude Code. It returns an `AsyncIterator` of response messages. See [src/claude_agent_sdk/query.py](src/claude_agent_sdk/query.py). + +```python +from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, TextBlock + +# Simple query +async for message in query(prompt="Hello Claude"): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(block.text) + +# With options +options = ClaudeAgentOptions( + system_prompt="You are a helpful assistant", + max_turns=1 +) + +async for message in query(prompt="Tell me a joke", options=options): + print(message) +``` + +### Using Tools + +```python +options = ClaudeAgentOptions( + allowed_tools=["Read", "Write", "Bash"], + permission_mode='acceptEdits' # auto-accept file edits +) + +async for message in query( + prompt="Create a hello.py file", + options=options +): + # Process tool use and results + pass +``` + +### Working Directory + +```python +from pathlib import Path + +options = ClaudeAgentOptions( + cwd="/path/to/project" # or Path("/path/to/project") +) +``` + +## ClaudeSDKClient + +`ClaudeSDKClient` supports bidirectional, interactive conversations with Claude +Code. See [src/claude_agent_sdk/client.py](src/claude_agent_sdk/client.py). + +Unlike `query()`, `ClaudeSDKClient` additionally enables **custom tools** and **hooks**, both of which can be defined as Python functions. + +### Custom Tools (as In-Process SDK MCP Servers) + +A **custom tool** is a Python function that you can offer to Claude, for Claude to invoke as needed. + +Custom tools are implemented in-process MCP servers that run directly within your Python application, eliminating the need for separate processes that regular MCP servers require. + +For an end-to-end example, see [MCP Calculator](examples/mcp_calculator.py). + +#### Creating a Simple Tool + +```python +from claude_agent_sdk import tool, create_sdk_mcp_server, ClaudeAgentOptions, ClaudeSDKClient + +# Define a tool using the @tool decorator +@tool("greet", "Greet a user", {"name": str}) +async def greet_user(args): + return { + "content": [ + {"type": "text", "text": f"Hello, {args['name']}!"} + ] + } + +# Create an SDK MCP server +server = create_sdk_mcp_server( + name="my-tools", + version="1.0.0", + tools=[greet_user] +) + +# Use it with Claude +options = ClaudeAgentOptions( + mcp_servers={"tools": server}, + allowed_tools=["mcp__tools__greet"] +) + +async with ClaudeSDKClient(options=options) as client: + await client.query("Greet Alice") + + # Extract and print response + async for msg in client.receive_response(): + print(msg) +``` + +#### Benefits Over External MCP Servers + +- **No subprocess management** - Runs in the same process as your application +- **Better performance** - No IPC overhead for tool calls +- **Simpler deployment** - Single Python process instead of multiple +- **Easier debugging** - All code runs in the same process +- **Type safety** - Direct Python function calls with type hints + +#### Migration from External Servers + +```python +# BEFORE: External MCP server (separate process) +options = ClaudeAgentOptions( + mcp_servers={ + "calculator": { + "type": "stdio", + "command": "python", + "args": ["-m", "calculator_server"] + } + } +) + +# AFTER: SDK MCP server (in-process) +from my_tools import add, subtract # Your tool functions + +calculator = create_sdk_mcp_server( + name="calculator", + tools=[add, subtract] +) + +options = ClaudeAgentOptions( + mcp_servers={"calculator": calculator} +) +``` + +#### Mixed Server Support + +You can use both SDK and external MCP servers together: + +```python +options = ClaudeAgentOptions( + mcp_servers={ + "internal": sdk_server, # In-process SDK server + "external": { # External subprocess server + "type": "stdio", + "command": "external-server" + } + } +) +``` + +### Hooks + +A **hook** is a Python function that the Claude Code _application_ (_not_ Claude) invokes at specific points of the Claude agent loop. Hooks can provide deterministic processing and automated feedback for Claude. Read more in [Claude Code Hooks Reference](https://docs.anthropic.com/en/docs/claude-code/hooks). + +For more examples, see examples/hooks.py. + +#### Example + +```python +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, HookMatcher + +async def check_bash_command(input_data, tool_use_id, context): + tool_name = input_data["tool_name"] + tool_input = input_data["tool_input"] + if tool_name != "Bash": + return {} + command = tool_input.get("command", "") + block_patterns = ["foo.sh"] + for pattern in block_patterns: + if pattern in command: + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": f"Command contains invalid pattern: {pattern}", + } + } + return {} + +options = ClaudeAgentOptions( + allowed_tools=["Bash"], + hooks={ + "PreToolUse": [ + HookMatcher(matcher="Bash", hooks=[check_bash_command]), + ], + } +) + +async with ClaudeSDKClient(options=options) as client: + # Test 1: Command with forbidden pattern (will be blocked) + await client.query("Run the bash command: ./foo.sh --help") + async for msg in client.receive_response(): + print(msg) + + print("\n" + "=" * 50 + "\n") + + # Test 2: Safe command that should work + await client.query("Run the bash command: echo 'Hello from hooks example!'") + async for msg in client.receive_response(): + print(msg) +``` + +## Types + +See [src/claude_agent_sdk/types.py](src/claude_agent_sdk/types.py) for complete type definitions: + +- `ClaudeAgentOptions` - Configuration options +- `AssistantMessage`, `UserMessage`, `SystemMessage`, `ResultMessage` - Message types +- `TextBlock`, `ToolUseBlock`, `ToolResultBlock` - Content blocks + +## Error Handling + +```python +from claude_agent_sdk import ( + ClaudeSDKError, # Base error + CLINotFoundError, # Claude Code not installed + CLIConnectionError, # Connection issues + ProcessError, # Process failed + CLIJSONDecodeError, # JSON parsing issues +) + +try: + async for message in query(prompt="Hello"): + pass +except CLINotFoundError: + print("Please install Claude Code") +except ProcessError as e: + print(f"Process failed with exit code: {e.exit_code}") +except CLIJSONDecodeError as e: + print(f"Failed to parse response: {e}") +``` + +See [src/claude_agent_sdk/\_errors.py](src/claude_agent_sdk/_errors.py) for all error types. + +## Available Tools + +See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code/settings#tools-available-to-claude) for a complete list of available tools. + +## Examples + +See [examples/quick_start.py](examples/quick_start.py) for a complete working example. + +See [examples/streaming_mode.py](examples/streaming_mode.py) for comprehensive examples involving `ClaudeSDKClient`. You can even run interactive examples in IPython from [examples/streaming_mode_ipython.py](examples/streaming_mode_ipython.py). + +## Migrating from Claude Code SDK + +If you're upgrading from the Claude Code SDK (versions < 0.1.0), please see the [CHANGELOG.md](CHANGELOG.md#010) for details on breaking changes and new features, including: + +- `ClaudeCodeOptions` → `ClaudeAgentOptions` rename +- Merged system prompt configuration +- Settings isolation and explicit control +- New programmatic subagents and session forking features + +## Development + +If you're contributing to this project, run the initial setup script to install git hooks: + +```bash +./scripts/initial-setup.sh +``` + +This installs a pre-push hook that runs lint checks before pushing, matching the CI workflow. To skip the hook temporarily, use `git push --no-verify`. + +### Building Wheels Locally + +To build wheels with the bundled Claude Code CLI: + +```bash +# Install build dependencies +pip install build twine + +# Build wheel with bundled CLI +python scripts/build_wheel.py + +# Build with specific version +python scripts/build_wheel.py --version 0.1.4 + +# Build with specific CLI version +python scripts/build_wheel.py --cli-version 2.0.0 + +# Clean bundled CLI after building +python scripts/build_wheel.py --clean + +# Skip CLI download (use existing) +python scripts/build_wheel.py --skip-download +``` + +The build script: + +1. Downloads Claude Code CLI for your platform +2. Bundles it in the wheel +3. Builds both wheel and source distribution +4. Checks the package with twine + +See `python scripts/build_wheel.py --help` for all options. + +### Release Workflow + +The package is published to PyPI via the GitHub Actions workflow in `.github/workflows/publish.yml`. To create a new release: + +1. **Trigger the workflow** manually from the Actions tab with two inputs: + - `version`: The package version to publish (e.g., `0.1.5`) + - `claude_code_version`: The Claude Code CLI version to bundle (e.g., `2.0.0` or `latest`) + +2. **The workflow will**: + - Build platform-specific wheels for macOS, Linux, and Windows + - Bundle the specified Claude Code CLI version in each wheel + - Build a source distribution + - Publish all artifacts to PyPI + - Create a release branch with version updates + - Open a PR to main with: + - Updated `pyproject.toml` version + - Updated `src/claude_agent_sdk/_version.py` + - Updated `src/claude_agent_sdk/_cli_version.py` with bundled CLI version + - Auto-generated `CHANGELOG.md` entry + +3. **Review and merge** the release PR to update main with the new version information + +The workflow tracks both the package version and the bundled CLI version separately, allowing you to release a new package version with an updated CLI without code changes. + +## License and terms + +Use of this SDK is governed by Anthropic's [Commercial Terms of Service](https://www.anthropic.com/legal/commercial-terms), including when you use it to power products and services that you make available to your own customers and end users, except to the extent a specific component or dependency is covered by a different license as indicated in that component's LICENSE file. diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/RELEASING.md b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/RELEASING.md new file mode 100644 index 00000000..a2b31930 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/RELEASING.md @@ -0,0 +1,63 @@ +# Releasing + +There are two ways to release the SDK: **automatic** (triggered by a CLI version bump) and **manual** (triggered via GitHub Actions UI). + +Both flows call the same reusable `build-and-publish.yml` workflow, which builds platform-specific wheels on 4 OS targets (Ubuntu x86, Ubuntu ARM, macOS, Windows), publishes to PyPI, updates version files, generates a changelog entry using Claude, pushes to `main`, and creates a git tag + GitHub Release. + +## Versioning + +The project tracks two separate version numbers: + +- **SDK version** — in `pyproject.toml` and `src/claude_agent_sdk/_version.py` +- **Bundled CLI version** — in `src/claude_agent_sdk/_cli_version.py` + +Both follow semver (`MAJOR.MINOR.PATCH`). Git tags use the format `vX.Y.Z`. + +## Automatic Release (CLI Version Bump) + +This is the most common release path. Every CLI version bump automatically produces a new SDK patch release. + +**Flow:** + +1. A commit with message `chore: bump bundled CLI version to X.Y.Z` is pushed to `main`, updating `_cli_version.py`. +2. The `Test` workflow runs on that push. +3. On successful completion, `auto-release.yml` fires via `workflow_run`. +4. It verifies the trigger commit message and that `_cli_version.py` changed. +5. It reads the current SDK version from `_version.py` and increments the patch number (e.g., `0.1.24` → `0.1.25`). +6. It calls `build-and-publish.yml`, which builds, publishes, pushes, tags, and creates a GitHub Release. + +**Typical commit log after an auto-release:** +``` +ccdf20a chore: bump bundled CLI version to 2.1.25 +baf9bc3 chore: release v0.1.25 +``` + +## Manual Release + +Use this when you need to release with a specific version number (e.g., for minor/major bumps or non-CLI-bump changes). + +**Flow:** + +1. Go to [**Actions → Publish to PyPI**](https://github.com/anthropics/claude-agent-sdk-python/actions/workflows/publish.yml) and click **Run workflow**. +2. Enter the desired version (e.g., `0.2.0`). +3. The workflow runs the full test suite (Python 3.10–3.13) and lint checks. +4. On success, it calls `build-and-publish.yml`, which builds, publishes, pushes, tags, and creates a GitHub Release. + +## Scripts + +All release-related scripts live in `scripts/`: + +| Script | Purpose | +|---|---| +| `update_version.py` | Updates SDK version in `pyproject.toml` and `_version.py` | +| `update_cli_version.py` | Updates CLI version in `_cli_version.py` | +| `build_wheel.py` | Downloads the CLI binary, builds the wheel, retags with platform-specific tags | +| `download_cli.py` | Downloads the Claude Code CLI binary for the current platform | + +## Required Secrets + +| Secret | Used For | +|---|---| +| `PYPI_API_TOKEN` | Publishing to PyPI | +| `ANTHROPIC_API_KEY` | Changelog generation and e2e tests | +| `DEPLOY_KEY` | SSH key for direct pushes to `main` | diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/README.md b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/README.md new file mode 100644 index 00000000..6dfe374e --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/README.md @@ -0,0 +1,102 @@ +# End-to-End Tests for Claude Code SDK + +This directory contains end-to-end tests that run against the actual Claude API to verify real-world functionality. + +## Requirements + +### API Key (REQUIRED) + +These tests require a valid Anthropic API key. The tests will **fail** if `ANTHROPIC_API_KEY` is not set. + +Set your API key before running tests: + +```bash +export ANTHROPIC_API_KEY="your-api-key-here" +``` + +### Dependencies + +Install the development dependencies: + +```bash +pip install -e ".[dev]" +``` + +## Running the Tests + +### Run all e2e tests: + +```bash +python -m pytest e2e-tests/ -v +``` + +### Run with e2e marker only: + +```bash +python -m pytest e2e-tests/ -v -m e2e +``` + +### Run a specific test: + +```bash +python -m pytest e2e-tests/test_mcp_calculator.py::test_basic_addition -v +``` + +## Cost Considerations + +⚠️ **Important**: These tests make actual API calls to Claude, which incur costs based on your Anthropic pricing plan. + +- Each test typically uses 1-3 API calls +- Tests use simple prompts to minimize token usage +- The complete test suite should cost less than $0.10 to run + +## Test Coverage + +### MCP Calculator Tests (`test_mcp_calculator.py`) + +Tests the MCP (Model Context Protocol) integration with calculator tools: + +- **test_basic_addition**: Verifies the add tool executes correctly +- **test_division**: Tests division with decimal results +- **test_square_root**: Validates square root calculations +- **test_power**: Tests exponentiation +- **test_multi_step_calculation**: Verifies multiple tools can be used in sequence +- **test_tool_permissions_enforced**: Ensures permission system works correctly + +Each test validates: +1. Tools are actually called (ToolUseBlock present in response) +2. Correct tool inputs are provided +3. Expected results are returned +4. Permission system is enforced + +## CI/CD Integration + +These tests run automatically on: +- Pushes to `main` branch (via GitHub Actions) +- Manual workflow dispatch + +The workflow uses `ANTHROPIC_API_KEY` from GitHub Secrets. + +## Troubleshooting + +### "ANTHROPIC_API_KEY environment variable is required" error +- Set your API key: `export ANTHROPIC_API_KEY=sk-ant-...` +- The tests will not skip - they require the key to run + +### Tests timing out +- Check your API key is valid and has quota available +- Ensure network connectivity to api.anthropic.com + +### Permission denied errors +- Verify the `allowed_tools` parameter includes the necessary MCP tools +- Check that tool names match the expected format (e.g., `mcp__calc__add`) + +## Adding New E2E Tests + +When adding new e2e tests: + +1. Mark tests with `@pytest.mark.e2e` decorator +2. Use the `api_key` fixture to ensure API key is available +3. Keep prompts simple to minimize costs +4. Verify actual tool execution, not just mocked responses +5. Document any special setup requirements in this README \ No newline at end of file diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/conftest.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/conftest.py new file mode 100644 index 00000000..ea419acb --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/conftest.py @@ -0,0 +1,32 @@ +"""Pytest configuration for e2e tests.""" + +import os + +import pytest + + +@pytest.fixture(scope="session") +def api_key(): + """Ensure ANTHROPIC_API_KEY is set for e2e tests.""" + key = os.environ.get("ANTHROPIC_API_KEY") + if not key: + pytest.fail( + "ANTHROPIC_API_KEY environment variable is required for e2e tests. " + "Set it before running: export ANTHROPIC_API_KEY=your-key-here" + ) + return key + + +@pytest.fixture(scope="session") +def event_loop_policy(): + """Use the default event loop policy for all async tests.""" + import asyncio + + return asyncio.get_event_loop_policy() + + +def pytest_configure(config): + """Add e2e marker.""" + config.addinivalue_line( + "markers", "e2e: marks tests as e2e tests requiring API key" + ) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_agents_and_settings.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_agents_and_settings.py new file mode 100644 index 00000000..38dc05f4 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_agents_and_settings.py @@ -0,0 +1,393 @@ +"""End-to-end tests for agents and setting sources with real Claude API calls.""" + +import asyncio +import json +import sys +import tempfile +from pathlib import Path + +import pytest + +from claude_agent_sdk import ( + AgentDefinition, + ClaudeAgentOptions, + ClaudeSDKClient, + SystemMessage, +) + + +def generate_large_agents( + num_agents: int = 20, prompt_size_kb: int = 12 +) -> dict[str, AgentDefinition]: + """Generate multiple agents with large prompts for testing. + + Args: + num_agents: Number of agents to generate + prompt_size_kb: Size of each agent's prompt in KB + + Returns: + Dictionary of agent name -> AgentDefinition + """ + agents = {} + for i in range(num_agents): + # Generate a large prompt with some structure + prompt_content = f"You are test agent #{i}. " + ("x" * (prompt_size_kb * 1024)) + agents[f"large-agent-{i}"] = AgentDefinition( + description=f"Large test agent #{i} for stress testing", + prompt=prompt_content, + ) + return agents + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_agent_definition(): + """Test that custom agent definitions work in streaming mode.""" + options = ClaudeAgentOptions( + agents={ + "test-agent": AgentDefinition( + description="A test agent for verification", + prompt="You are a test agent. Always respond with 'Test agent activated'", + tools=["Read"], + model="sonnet", + ) + }, + max_turns=1, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("What is 2 + 2?") + + # Check that agent is available in init message + async for message in client.receive_response(): + if isinstance(message, SystemMessage) and message.subtype == "init": + agents = message.data.get("agents", []) + assert isinstance(agents, list), ( + f"agents should be a list of strings, got: {type(agents)}" + ) + assert "test-agent" in agents, ( + f"test-agent should be available, got: {agents}" + ) + break + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_agent_definition_with_query_function(): + """Test that custom agent definitions work with the query() function. + + Both ClaudeSDKClient and query() now use streaming mode internally, + sending agents via the initialize request. + """ + from claude_agent_sdk import query + + options = ClaudeAgentOptions( + agents={ + "test-agent-query": AgentDefinition( + description="A test agent for query function verification", + prompt="You are a test agent.", + ) + }, + max_turns=1, + ) + + # Use query() with string prompt + found_agent = False + async for message in query(prompt="What is 2 + 2?", options=options): + if isinstance(message, SystemMessage) and message.subtype == "init": + agents = message.data.get("agents", []) + assert "test-agent-query" in agents, ( + f"test-agent-query should be available, got: {agents}" + ) + found_agent = True + break + + assert found_agent, "Should have received init message with agents" + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_large_agents_with_query_function(): + """Test large agent definitions (260KB+) work with query() function. + + Since we now always use streaming mode internally (matching TypeScript SDK), + large agents are sent via the initialize request through stdin with no + size limits. + """ + from claude_agent_sdk import query + + # Generate 20 agents with 13KB prompts each = ~260KB total + agents = generate_large_agents(num_agents=20, prompt_size_kb=13) + + options = ClaudeAgentOptions( + agents=agents, + max_turns=1, + ) + + # Use query() with string prompt - agents still go via initialize + found_agents = [] + async for message in query(prompt="What is 2 + 2?", options=options): + if isinstance(message, SystemMessage) and message.subtype == "init": + found_agents = message.data.get("agents", []) + break + + # Check all our agents are registered + for agent_name in agents: + assert agent_name in found_agents, ( + f"{agent_name} should be registered. " + f"Found: {found_agents[:5]}... ({len(found_agents)} total)" + ) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_filesystem_agent_loading(): + """Test that filesystem-based agents load via setting_sources and produce full response. + + This is the core test for issue #406. It verifies that when using + setting_sources=["project"] with a .claude/agents/ directory containing + agent definitions, the SDK: + 1. Loads the agents (they appear in init message) + 2. Produces a full response with AssistantMessage + 3. Completes with a ResultMessage + + The bug in #406 causes the iterator to complete after only the + init SystemMessage, never yielding AssistantMessage or ResultMessage. + """ + with tempfile.TemporaryDirectory() as tmpdir: + # Create a temporary project with a filesystem agent + project_dir = Path(tmpdir) + agents_dir = project_dir / ".claude" / "agents" + agents_dir.mkdir(parents=True) + + # Create a test agent file + agent_file = agents_dir / "fs-test-agent.md" + agent_file.write_text( + """--- +name: fs-test-agent +description: A filesystem test agent for SDK testing +tools: Read +--- + +# Filesystem Test Agent + +You are a simple test agent. When asked a question, provide a brief, helpful answer. +""" + ) + + options = ClaudeAgentOptions( + setting_sources=["project"], + cwd=project_dir, + max_turns=1, + ) + + messages = [] + async with ClaudeSDKClient(options=options) as client: + await client.query("Say hello in exactly 3 words") + async for msg in client.receive_response(): + messages.append(msg) + + # Must have at least init, assistant, result + message_types = [type(m).__name__ for m in messages] + + assert "SystemMessage" in message_types, "Missing SystemMessage (init)" + assert "AssistantMessage" in message_types, ( + f"Missing AssistantMessage - got only: {message_types}. " + "This may indicate issue #406 (silent failure with filesystem agents)." + ) + assert "ResultMessage" in message_types, "Missing ResultMessage" + + # Find the init message and check for the filesystem agent + for msg in messages: + if isinstance(msg, SystemMessage) and msg.subtype == "init": + agents = msg.data.get("agents", []) + # Agents are returned as strings (just names) + assert "fs-test-agent" in agents, ( + f"fs-test-agent not loaded from filesystem. Found: {agents}" + ) + break + + # On Windows, wait for file handles to be released before cleanup + if sys.platform == "win32": + await asyncio.sleep(0.5) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_setting_sources_default(): + """Test that default (no setting_sources) loads no settings.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a temporary project with local settings + project_dir = Path(tmpdir) + claude_dir = project_dir / ".claude" + claude_dir.mkdir(parents=True) + + # Create local settings with custom outputStyle + settings_file = claude_dir / "settings.local.json" + settings_file.write_text('{"outputStyle": "local-test-style"}') + + # Don't provide setting_sources - should default to no settings + options = ClaudeAgentOptions( + cwd=project_dir, + max_turns=1, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("What is 2 + 2?") + + # Check that settings were NOT loaded + async for message in client.receive_response(): + if isinstance(message, SystemMessage) and message.subtype == "init": + output_style = message.data.get("output_style") + assert output_style != "local-test-style", ( + f"outputStyle should NOT be from local settings (default is no settings), got: {output_style}" + ) + assert output_style == "default", ( + f"outputStyle should be 'default', got: {output_style}" + ) + break + + # On Windows, wait for file handles to be released before cleanup + if sys.platform == "win32": + await asyncio.sleep(0.5) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_setting_sources_user_only(): + """Test that setting_sources=['user'] excludes project settings.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a temporary project with a slash command + project_dir = Path(tmpdir) + commands_dir = project_dir / ".claude" / "commands" + commands_dir.mkdir(parents=True) + + test_command = commands_dir / "testcmd.md" + test_command.write_text( + """--- +description: Test command +--- + +This is a test command. +""" + ) + + # Use setting_sources=["user"] to exclude project settings + options = ClaudeAgentOptions( + setting_sources=["user"], + cwd=project_dir, + max_turns=1, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("What is 2 + 2?") + + # Check that project command is NOT available + async for message in client.receive_response(): + if isinstance(message, SystemMessage) and message.subtype == "init": + commands = message.data.get("slash_commands", []) + assert "testcmd" not in commands, ( + f"testcmd should NOT be available with user-only sources, got: {commands}" + ) + break + + # On Windows, wait for file handles to be released before cleanup + if sys.platform == "win32": + await asyncio.sleep(0.5) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_setting_sources_project_included(): + """Test that setting_sources=['user', 'project'] includes project settings.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a temporary project with local settings + project_dir = Path(tmpdir) + claude_dir = project_dir / ".claude" + claude_dir.mkdir(parents=True) + + # Create local settings with custom outputStyle + settings_file = claude_dir / "settings.local.json" + settings_file.write_text('{"outputStyle": "local-test-style"}') + + # Use setting_sources=["user", "project", "local"] to include local settings + options = ClaudeAgentOptions( + setting_sources=["user", "project", "local"], + cwd=project_dir, + max_turns=1, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("What is 2 + 2?") + + # Check that settings WERE loaded + async for message in client.receive_response(): + if isinstance(message, SystemMessage) and message.subtype == "init": + output_style = message.data.get("output_style") + assert output_style == "local-test-style", ( + f"outputStyle should be from local settings, got: {output_style}" + ) + break + + # On Windows, wait for file handles to be released before cleanup + if sys.platform == "win32": + await asyncio.sleep(0.5) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_large_agent_definitions_via_initialize(): + """Test that large agent definitions (250KB+) are sent via initialize request. + + This test verifies the fix for the issue where large agent definitions + would previously trigger a temp file workaround with @filepath. Now they + are sent via the initialize control request through stdin, which has no + size limit. + + The test: + 1. Generates 20 agents with ~13KB prompts each (~260KB total) + 2. Creates an SDK client with these agents + 3. Verifies all agents are registered and available + """ + from dataclasses import asdict + + # Generate 20 agents with 13KB prompts each = ~260KB total + agents = generate_large_agents(num_agents=20, prompt_size_kb=13) + + # Calculate total size to verify we're testing the right thing + total_size = sum( + len(json.dumps({k: v for k, v in asdict(agent).items() if v is not None})) + for agent in agents.values() + ) + assert total_size > 250_000, ( + f"Test agents should be >250KB, got {total_size / 1024:.1f}KB" + ) + + options = ClaudeAgentOptions( + agents=agents, + max_turns=1, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("List available agents") + + # Check that all agents are available in init message + async for message in client.receive_response(): + if isinstance(message, SystemMessage) and message.subtype == "init": + registered_agents = message.data.get("agents", []) + assert isinstance(registered_agents, list), ( + f"agents should be a list, got: {type(registered_agents)}" + ) + + # Verify all our agents are registered + for agent_name in agents: + assert agent_name in registered_agents, ( + f"{agent_name} should be registered. " + f"Found: {registered_agents[:5]}... ({len(registered_agents)} total)" + ) + + # All agents should be there + assert len(registered_agents) >= len(agents), ( + f"Expected at least {len(agents)} agents, got {len(registered_agents)}" + ) + break diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_dynamic_control.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_dynamic_control.py new file mode 100644 index 00000000..f12ffb68 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_dynamic_control.py @@ -0,0 +1,97 @@ +"""End-to-end tests for dynamic control features with real Claude API calls.""" + +import pytest + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, +) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_set_permission_mode(): + """Test that permission mode can be changed dynamically during a session.""" + + options = ClaudeAgentOptions( + permission_mode="default", + ) + + async with ClaudeSDKClient(options=options) as client: + # Change permission mode to acceptEdits + await client.set_permission_mode("acceptEdits") + + # Make a query that would normally require permission + await client.query("What is 2+2? Just respond with the number.") + + async for message in client.receive_response(): + print(f"Got message: {message}") + pass # Just consume messages + + # Change back to default + await client.set_permission_mode("default") + + # Make another query + await client.query("What is 3+3? Just respond with the number.") + + async for message in client.receive_response(): + print(f"Got message: {message}") + pass # Just consume messages + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_set_model(): + """Test that model can be changed dynamically during a session.""" + + options = ClaudeAgentOptions() + + async with ClaudeSDKClient(options=options) as client: + # Start with default model + await client.query("What is 1+1? Just the number.") + + async for message in client.receive_response(): + print(f"Default model response: {message}") + pass + + # Switch to Haiku model + await client.set_model("claude-3-5-haiku-20241022") + + await client.query("What is 2+2? Just the number.") + + async for message in client.receive_response(): + print(f"Haiku model response: {message}") + pass + + # Switch back to default (None means default) + await client.set_model(None) + + await client.query("What is 3+3? Just the number.") + + async for message in client.receive_response(): + print(f"Back to default model: {message}") + pass + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_interrupt(): + """Test that interrupt can be sent during a session.""" + + options = ClaudeAgentOptions() + + async with ClaudeSDKClient(options=options) as client: + # Start a query + await client.query("Count from 1 to 100 slowly.") + + # Send interrupt (may or may not stop the response depending on timing) + try: + await client.interrupt() + print("Interrupt sent successfully") + except Exception as e: + print(f"Interrupt resulted in: {e}") + + # Consume any remaining messages + async for message in client.receive_response(): + print(f"Got message after interrupt: {message}") + pass diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_hook_events.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_hook_events.py new file mode 100644 index 00000000..c31a9a2a --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_hook_events.py @@ -0,0 +1,196 @@ +"""End-to-end tests for hook event types with real Claude API calls.""" + +from typing import Any + +import pytest + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, + HookContext, + HookInput, + HookJSONOutput, + HookMatcher, +) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_pre_tool_use_hook_with_additional_context(): + """Test PreToolUse hook returning additionalContext field end-to-end.""" + hook_invocations: list[dict[str, Any]] = [] + + async def pre_tool_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + """PreToolUse hook that provides additionalContext.""" + tool_name = input_data.get("tool_name", "") + hook_invocations.append( + {"tool_name": tool_name, "tool_use_id": input_data.get("tool_use_id")} + ) + + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "Approved with context", + "additionalContext": "This command is running in a test environment", + }, + } + + options = ClaudeAgentOptions( + allowed_tools=["Bash"], + hooks={ + "PreToolUse": [ + HookMatcher(matcher="Bash", hooks=[pre_tool_hook]), + ], + }, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("Run: echo 'test additional context'") + + async for message in client.receive_response(): + print(f"Got message: {message}") + + print(f"Hook invocations: {hook_invocations}") + assert len(hook_invocations) > 0, "PreToolUse hook should have been invoked" + # Verify tool_use_id is present in the input (new field) + assert hook_invocations[0]["tool_use_id"] is not None, ( + "tool_use_id should be present in PreToolUse input" + ) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_post_tool_use_hook_with_tool_use_id(): + """Test PostToolUse hook receives tool_use_id field end-to-end.""" + hook_invocations: list[dict[str, Any]] = [] + + async def post_tool_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + """PostToolUse hook that verifies tool_use_id is present.""" + tool_name = input_data.get("tool_name", "") + hook_invocations.append( + { + "tool_name": tool_name, + "tool_use_id": input_data.get("tool_use_id"), + } + ) + + return { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": "Post-tool monitoring active", + }, + } + + options = ClaudeAgentOptions( + allowed_tools=["Bash"], + hooks={ + "PostToolUse": [ + HookMatcher(matcher="Bash", hooks=[post_tool_hook]), + ], + }, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("Run: echo 'test tool_use_id'") + + async for message in client.receive_response(): + print(f"Got message: {message}") + + print(f"Hook invocations: {hook_invocations}") + assert len(hook_invocations) > 0, "PostToolUse hook should have been invoked" + # Verify tool_use_id is present in the input (new field) + assert hook_invocations[0]["tool_use_id"] is not None, ( + "tool_use_id should be present in PostToolUse input" + ) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_notification_hook(): + """Test Notification hook fires end-to-end.""" + hook_invocations: list[dict[str, Any]] = [] + + async def notification_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + """Notification hook that tracks invocations.""" + hook_invocations.append( + { + "hook_event_name": input_data.get("hook_event_name"), + "message": input_data.get("message"), + "notification_type": input_data.get("notification_type"), + } + ) + return { + "hookSpecificOutput": { + "hookEventName": "Notification", + "additionalContext": "Notification received", + }, + } + + options = ClaudeAgentOptions( + hooks={ + "Notification": [ + HookMatcher(hooks=[notification_hook]), + ], + }, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("Say hello in one word.") + + async for message in client.receive_response(): + print(f"Got message: {message}") + + print(f"Notification hook invocations: {hook_invocations}") + # Notification hooks may or may not fire depending on CLI behavior. + # This test verifies the hook registration doesn't cause errors. + # If it fires, verify the shape is correct. + for invocation in hook_invocations: + assert invocation["hook_event_name"] == "Notification" + assert invocation["notification_type"] is not None + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_multiple_hooks_together(): + """Test registering multiple hook event types together end-to-end.""" + all_invocations: list[dict[str, Any]] = [] + + async def track_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + """Generic hook that tracks all invocations.""" + all_invocations.append( + { + "hook_event_name": input_data.get("hook_event_name"), + } + ) + return {} + + options = ClaudeAgentOptions( + allowed_tools=["Bash"], + hooks={ + "Notification": [HookMatcher(hooks=[track_hook])], + "PreToolUse": [HookMatcher(matcher="Bash", hooks=[track_hook])], + "PostToolUse": [HookMatcher(matcher="Bash", hooks=[track_hook])], + }, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("Run: echo 'multi-hook test'") + + async for message in client.receive_response(): + print(f"Got message: {message}") + + print(f"All hook invocations: {all_invocations}") + event_names = [inv["hook_event_name"] for inv in all_invocations] + + # At minimum, PreToolUse and PostToolUse should fire for the Bash command + assert "PreToolUse" in event_names, "PreToolUse hook should have fired" + assert "PostToolUse" in event_names, "PostToolUse hook should have fired" diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_hooks.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_hooks.py new file mode 100644 index 00000000..5c5999d1 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_hooks.py @@ -0,0 +1,156 @@ +"""End-to-end tests for hook callbacks with real Claude API calls.""" + +import pytest + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, + HookContext, + HookInput, + HookJSONOutput, + HookMatcher, +) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_hook_with_permission_decision_and_reason(): + """Test that hooks with permissionDecision and reason fields work end-to-end.""" + hook_invocations = [] + + async def test_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + """Hook that uses permissionDecision and reason fields.""" + tool_name = input_data.get("tool_name", "") + print(f"Hook called for tool: {tool_name}") + hook_invocations.append(tool_name) + + # Block Bash commands for this test + if tool_name == "Bash": + return { + "reason": "Bash commands are blocked in this test for safety", + "systemMessage": "⚠️ Command blocked by hook", + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "Security policy: Bash blocked", + }, + } + + return { + "reason": "Tool approved by security review", + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "Tool passed security checks", + }, + } + + options = ClaudeAgentOptions( + allowed_tools=["Bash", "Write"], + hooks={ + "PreToolUse": [ + HookMatcher(matcher="Bash", hooks=[test_hook]), + ], + }, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("Run this bash command: echo 'hello'") + + async for message in client.receive_response(): + print(f"Got message: {message}") + + print(f"Hook invocations: {hook_invocations}") + # Verify hook was called + assert "Bash" in hook_invocations, ( + f"Hook should have been invoked for Bash tool, got: {hook_invocations}" + ) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_hook_with_continue_and_stop_reason(): + """Test that hooks with continue_=False and stopReason fields work end-to-end.""" + hook_invocations = [] + + async def post_tool_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + """PostToolUse hook that stops execution with stopReason.""" + tool_name = input_data.get("tool_name", "") + hook_invocations.append(tool_name) + + # Actually test continue_=False and stopReason fields + return { + "continue_": False, + "stopReason": "Execution halted by test hook for validation", + "reason": "Testing continue and stopReason fields", + "systemMessage": "🛑 Test hook stopped execution", + } + + options = ClaudeAgentOptions( + allowed_tools=["Bash"], + hooks={ + "PostToolUse": [ + HookMatcher(matcher="Bash", hooks=[post_tool_hook]), + ], + }, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("Run: echo 'test message'") + + async for message in client.receive_response(): + print(f"Got message: {message}") + + print(f"Hook invocations: {hook_invocations}") + # Verify hook was called + assert "Bash" in hook_invocations, ( + f"PostToolUse hook should have been invoked, got: {hook_invocations}" + ) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_hook_with_additional_context(): + """Test that hooks with hookSpecificOutput work end-to-end.""" + hook_invocations = [] + + async def context_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + """Hook that provides additional context.""" + hook_invocations.append("context_added") + + return { + "systemMessage": "Additional context provided by hook", + "reason": "Hook providing monitoring feedback", + "suppressOutput": False, + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": "The command executed successfully with hook monitoring", + }, + } + + options = ClaudeAgentOptions( + allowed_tools=["Bash"], + hooks={ + "PostToolUse": [ + HookMatcher(matcher="Bash", hooks=[context_hook]), + ], + }, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("Run: echo 'testing hooks'") + + async for message in client.receive_response(): + print(f"Got message: {message}") + + print(f"Hook invocations: {hook_invocations}") + # Verify hook was called + assert "context_added" in hook_invocations, ( + "Hook with hookSpecificOutput should have been invoked" + ) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_include_partial_messages.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_include_partial_messages.py new file mode 100644 index 00000000..763a7739 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_include_partial_messages.py @@ -0,0 +1,157 @@ +"""End-to-end tests for include_partial_messages option with real Claude API calls. + +These tests verify that the SDK properly handles partial message streaming, +including StreamEvent parsing and message interleaving. +""" + +from typing import Any + +import pytest + +from claude_agent_sdk import ClaudeSDKClient +from claude_agent_sdk.types import ( + AssistantMessage, + ClaudeAgentOptions, + ResultMessage, + StreamEvent, + SystemMessage, + TextBlock, + ThinkingBlock, +) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_include_partial_messages_stream_events(): + """Test that include_partial_messages produces StreamEvent messages.""" + + options = ClaudeAgentOptions( + include_partial_messages=True, + model="claude-sonnet-4-5", + max_turns=2, + env={ + "MAX_THINKING_TOKENS": "8000", + }, + ) + + collected_messages: list[Any] = [] + + async with ClaudeSDKClient(options) as client: + # Send a simple prompt that will generate streaming response with thinking + await client.query("Think of three jokes, then tell one") + + async for message in client.receive_response(): + collected_messages.append(message) + + # Verify we got the expected message types + message_types = [type(msg).__name__ for msg in collected_messages] + + # Should have SystemMessage(init) at the start + assert message_types[0] == "SystemMessage" + assert isinstance(collected_messages[0], SystemMessage) + assert collected_messages[0].subtype == "init" + + # Should have multiple StreamEvent messages + stream_events = [msg for msg in collected_messages if isinstance(msg, StreamEvent)] + assert len(stream_events) > 0, "No StreamEvent messages received" + + # Check for expected StreamEvent types + event_types = [event.event.get("type") for event in stream_events] + assert "message_start" in event_types, "No message_start StreamEvent" + assert "content_block_start" in event_types, "No content_block_start StreamEvent" + assert "content_block_delta" in event_types, "No content_block_delta StreamEvent" + assert "content_block_stop" in event_types, "No content_block_stop StreamEvent" + assert "message_stop" in event_types, "No message_stop StreamEvent" + + # Should have AssistantMessage messages with thinking and text + assistant_messages = [ + msg for msg in collected_messages if isinstance(msg, AssistantMessage) + ] + assert len(assistant_messages) >= 1, "No AssistantMessage received" + + # Check for thinking block in at least one AssistantMessage + has_thinking = any( + any(isinstance(block, ThinkingBlock) for block in msg.content) + for msg in assistant_messages + ) + assert has_thinking, "No ThinkingBlock found in AssistantMessages" + + # Check for text block (the joke) in at least one AssistantMessage + has_text = any( + any(isinstance(block, TextBlock) for block in msg.content) + for msg in assistant_messages + ) + assert has_text, "No TextBlock found in AssistantMessages" + + # Should end with ResultMessage + assert isinstance(collected_messages[-1], ResultMessage) + assert collected_messages[-1].subtype == "success" + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_include_partial_messages_thinking_deltas(): + """Test that thinking content is streamed incrementally via deltas.""" + + options = ClaudeAgentOptions( + include_partial_messages=True, + model="claude-sonnet-4-5", + max_turns=2, + env={ + "MAX_THINKING_TOKENS": "8000", + }, + ) + + thinking_deltas = [] + + async with ClaudeSDKClient(options) as client: + await client.query("Think step by step about what 2 + 2 equals") + + async for message in client.receive_response(): + if isinstance(message, StreamEvent): + event = message.event + if event.get("type") == "content_block_delta": + delta = event.get("delta", {}) + if delta.get("type") == "thinking_delta": + thinking_deltas.append(delta.get("thinking", "")) + + # Should have received multiple thinking deltas + assert len(thinking_deltas) > 0, "No thinking deltas received" + + # Combined thinking should form coherent text + combined_thinking = "".join(thinking_deltas) + assert len(combined_thinking) > 10, "Thinking content too short" + + # Should contain some reasoning about the calculation + assert "2" in combined_thinking.lower(), "Thinking doesn't mention the numbers" + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_partial_messages_disabled_by_default(): + """Test that partial messages are not included when option is not set.""" + + options = ClaudeAgentOptions( + # include_partial_messages not set (defaults to False) + model="claude-sonnet-4-5", + max_turns=2, + ) + + collected_messages: list[Any] = [] + + async with ClaudeSDKClient(options) as client: + await client.query("Say hello") + + async for message in client.receive_response(): + collected_messages.append(message) + + # Should NOT have any StreamEvent messages + stream_events = [msg for msg in collected_messages if isinstance(msg, StreamEvent)] + assert len(stream_events) == 0, ( + "StreamEvent messages present when partial messages disabled" + ) + + # Should still have the regular messages + assert any(isinstance(msg, SystemMessage) for msg in collected_messages) + assert any(isinstance(msg, AssistantMessage) for msg in collected_messages) + assert any(isinstance(msg, ResultMessage) for msg in collected_messages) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_sdk_mcp_tools.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_sdk_mcp_tools.py new file mode 100644 index 00000000..a5247fa3 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_sdk_mcp_tools.py @@ -0,0 +1,168 @@ +"""End-to-end tests for SDK MCP (inline) tools with real Claude API calls. + +These tests verify that SDK-created MCP tools work correctly through the full stack, +focusing on tool execution mechanics rather than specific tool functionality. +""" + +from typing import Any + +import pytest + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, + create_sdk_mcp_server, + tool, +) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_sdk_mcp_tool_execution(): + """Test that SDK MCP tools can be called and executed with allowed_tools.""" + executions = [] + + @tool("echo", "Echo back the input text", {"text": str}) + async def echo_tool(args: dict[str, Any]) -> dict[str, Any]: + """Echo back whatever text is provided.""" + executions.append("echo") + return {"content": [{"type": "text", "text": f"Echo: {args['text']}"}]} + + server = create_sdk_mcp_server( + name="test", + version="1.0.0", + tools=[echo_tool], + ) + + options = ClaudeAgentOptions( + mcp_servers={"test": server}, + allowed_tools=["mcp__test__echo"], + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("Call the mcp__test__echo tool with any text") + + async for message in client.receive_response(): + print(f" [{type(message).__name__}] {message}") + + # Check if the actual Python function was called + assert "echo" in executions, "Echo tool function was not executed" + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_sdk_mcp_permission_enforcement(): + """Test that disallowed_tools prevents SDK MCP tool execution.""" + executions = [] + + @tool("echo", "Echo back the input text", {"text": str}) + async def echo_tool(args: dict[str, Any]) -> dict[str, Any]: + """Echo back whatever text is provided.""" + executions.append("echo") + return {"content": [{"type": "text", "text": f"Echo: {args['text']}"}]} + + @tool("greet", "Greet a person by name", {"name": str}) + async def greet_tool(args: dict[str, Any]) -> dict[str, Any]: + """Greet someone by name.""" + executions.append("greet") + return {"content": [{"type": "text", "text": f"Hello, {args['name']}!"}]} + + server = create_sdk_mcp_server( + name="test", + version="1.0.0", + tools=[echo_tool, greet_tool], + ) + + options = ClaudeAgentOptions( + model="claude-opus-4-5", + mcp_servers={"test": server}, + disallowed_tools=["mcp__test__echo"], # Block echo tool + allowed_tools=["mcp__test__greet"], # But allow greet + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query( + "First use the greet tool to greet 'Alice'. After that completes, use the echo tool to echo 'test'. Do these one at a time, not in parallel." + ) + + async for message in client.receive_response(): + print(f" [{type(message).__name__}] {message}") + + # Check actual function executions + print(f" Executions: {executions}") + assert "echo" not in executions, "Disallowed echo tool was executed" + assert "greet" in executions, "Allowed greet tool was not executed" + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_sdk_mcp_multiple_tools(): + """Test that multiple SDK MCP tools can be called in sequence.""" + executions = [] + + @tool("echo", "Echo back the input text", {"text": str}) + async def echo_tool(args: dict[str, Any]) -> dict[str, Any]: + """Echo back whatever text is provided.""" + executions.append("echo") + return {"content": [{"type": "text", "text": f"Echo: {args['text']}"}]} + + @tool("greet", "Greet a person by name", {"name": str}) + async def greet_tool(args: dict[str, Any]) -> dict[str, Any]: + """Greet someone by name.""" + executions.append("greet") + return {"content": [{"type": "text", "text": f"Hello, {args['name']}!"}]} + + server = create_sdk_mcp_server( + name="multi", + version="1.0.0", + tools=[echo_tool, greet_tool], + ) + + options = ClaudeAgentOptions( + mcp_servers={"multi": server}, + allowed_tools=["mcp__multi__echo", "mcp__multi__greet"], + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query( + "Call mcp__multi__echo with text='test' and mcp__multi__greet with name='Bob'" + ) + + async for message in client.receive_response(): + print(f" [{type(message).__name__}] {message}") + + # Both tools should have been executed + assert "echo" in executions, "Echo tool was not executed" + assert "greet" in executions, "Greet tool was not executed" + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_sdk_mcp_without_permissions(): + """Test SDK MCP tool behavior without explicit allowed_tools.""" + executions = [] + + @tool("echo", "Echo back the input text", {"text": str}) + async def echo_tool(args: dict[str, Any]) -> dict[str, Any]: + """Echo back whatever text is provided.""" + executions.append("echo") + return {"content": [{"type": "text", "text": f"Echo: {args['text']}"}]} + + server = create_sdk_mcp_server( + name="noperm", + version="1.0.0", + tools=[echo_tool], + ) + + # No allowed_tools specified + options = ClaudeAgentOptions( + mcp_servers={"noperm": server}, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("Call the mcp__noperm__echo tool") + + async for message in client.receive_response(): + print(f" [{type(message).__name__}] {message}") + + assert "echo" not in executions, "SDK MCP tool was executed" diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_stderr_callback.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_stderr_callback.py new file mode 100644 index 00000000..c67e0323 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_stderr_callback.py @@ -0,0 +1,50 @@ +"""End-to-end test for stderr callback functionality.""" + +import pytest + +from claude_agent_sdk import ClaudeAgentOptions, query + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_stderr_callback_captures_debug_output(): + """Test that stderr callback receives debug output when enabled.""" + stderr_lines = [] + + def capture_stderr(line: str): + stderr_lines.append(line) + + # Enable debug mode to generate stderr output + options = ClaudeAgentOptions( + stderr=capture_stderr, extra_args={"debug-to-stderr": None} + ) + + # Run a simple query + async for _ in query(prompt="What is 1+1?", options=options): + pass # Just consume messages + + # Verify we captured debug output + assert len(stderr_lines) > 0, "Should capture stderr output with debug enabled" + assert any("[DEBUG]" in line for line in stderr_lines), ( + "Should contain DEBUG messages" + ) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_stderr_callback_without_debug(): + """Test that stderr callback works but receives no output without debug mode.""" + stderr_lines = [] + + def capture_stderr(line: str): + stderr_lines.append(line) + + # No debug mode enabled + options = ClaudeAgentOptions(stderr=capture_stderr) + + # Run a simple query + async for _ in query(prompt="What is 1+1?", options=options): + pass # Just consume messages + + # Should work but capture minimal/no output without debug + assert len(stderr_lines) == 0, "Should not capture stderr output without debug mode" diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_structured_output.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_structured_output.py new file mode 100644 index 00000000..24c9745a --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_structured_output.py @@ -0,0 +1,206 @@ +"""End-to-end tests for structured output with real Claude API calls. + +These tests verify that the output_schema feature works correctly by making +actual API calls to Claude with JSON Schema validation. +""" + +import tempfile + +import pytest + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ResultMessage, + query, +) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_simple_structured_output(): + """Test structured output with file counting requiring tool use.""" + + # Define schema for file analysis + schema = { + "type": "object", + "properties": { + "file_count": {"type": "number"}, + "has_tests": {"type": "boolean"}, + "test_file_count": {"type": "number"}, + }, + "required": ["file_count", "has_tests"], + } + + options = ClaudeAgentOptions( + output_format={"type": "json_schema", "schema": schema}, + permission_mode="acceptEdits", + cwd=".", # Use current directory + ) + + # Agent must use Glob/Bash to count files + result_message = None + async for message in query( + prompt="Count how many Python files are in src/claude_agent_sdk/ and check if there are any test files. Use tools to explore the filesystem.", + options=options, + ): + if isinstance(message, ResultMessage): + result_message = message + + # Verify result + assert result_message is not None, "No result message received" + assert not result_message.is_error, f"Query failed: {result_message.result}" + assert result_message.subtype == "success" + + # Verify structured output is present and valid + assert result_message.structured_output is not None, ( + "No structured output in result" + ) + assert "file_count" in result_message.structured_output + assert "has_tests" in result_message.structured_output + assert isinstance(result_message.structured_output["file_count"], (int, float)) + assert isinstance(result_message.structured_output["has_tests"], bool) + + # Should find Python files in src/ + assert result_message.structured_output["file_count"] > 0 + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_nested_structured_output(): + """Test structured output with nested objects and arrays.""" + + # Define a schema with nested structure + schema = { + "type": "object", + "properties": { + "analysis": { + "type": "object", + "properties": { + "word_count": {"type": "number"}, + "character_count": {"type": "number"}, + }, + "required": ["word_count", "character_count"], + }, + "words": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["analysis", "words"], + } + + options = ClaudeAgentOptions( + output_format={"type": "json_schema", "schema": schema}, + permission_mode="acceptEdits", + ) + + result_message = None + async for message in query( + prompt="Analyze this text: 'Hello world'. Provide word count, character count, and list of words.", + options=options, + ): + if isinstance(message, ResultMessage): + result_message = message + + # Verify result + assert result_message is not None + assert not result_message.is_error + assert result_message.structured_output is not None + + # Check nested structure + output = result_message.structured_output + assert "analysis" in output + assert "words" in output + assert output["analysis"]["word_count"] == 2 + assert output["analysis"]["character_count"] == 11 # "Hello world" + assert len(output["words"]) == 2 + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_structured_output_with_enum(): + """Test structured output with enum constraints requiring code analysis.""" + + schema = { + "type": "object", + "properties": { + "has_tests": {"type": "boolean"}, + "test_framework": { + "type": "string", + "enum": ["pytest", "unittest", "nose", "unknown"], + }, + "test_count": {"type": "number"}, + }, + "required": ["has_tests", "test_framework"], + } + + options = ClaudeAgentOptions( + output_format={"type": "json_schema", "schema": schema}, + permission_mode="acceptEdits", + cwd=".", + ) + + result_message = None + async for message in query( + prompt="Search for test files in the tests/ directory. Determine which test framework is being used (pytest/unittest/nose) and count how many test files exist. Use Grep to search for framework imports.", + options=options, + ): + if isinstance(message, ResultMessage): + result_message = message + + # Verify result + assert result_message is not None + assert not result_message.is_error + assert result_message.structured_output is not None + + # Check enum values are valid + output = result_message.structured_output + assert output["test_framework"] in ["pytest", "unittest", "nose", "unknown"] + assert isinstance(output["has_tests"], bool) + + # This repo uses pytest + assert output["has_tests"] is True + assert output["test_framework"] == "pytest" + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_structured_output_with_tools(): + """Test structured output when agent uses tools.""" + + # Schema for file analysis + schema = { + "type": "object", + "properties": { + "file_count": {"type": "number"}, + "has_readme": {"type": "boolean"}, + }, + "required": ["file_count", "has_readme"], + } + + options = ClaudeAgentOptions( + output_format={"type": "json_schema", "schema": schema}, + permission_mode="acceptEdits", + cwd=tempfile.gettempdir(), # Cross-platform temp directory + ) + + result_message = None + async for message in query( + prompt="Count how many files are in the current directory and check if there's a README file. Use tools as needed.", + options=options, + ): + if isinstance(message, ResultMessage): + result_message = message + + # Verify result + assert result_message is not None + assert not result_message.is_error + assert result_message.structured_output is not None + + # Check structure + output = result_message.structured_output + assert "file_count" in output + assert "has_readme" in output + assert isinstance(output["file_count"], (int, float)) + assert isinstance(output["has_readme"], bool) + assert output["file_count"] >= 0 # Should be non-negative diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_tool_permissions.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_tool_permissions.py new file mode 100644 index 00000000..8e2fb3c4 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/e2e-tests/test_tool_permissions.py @@ -0,0 +1,65 @@ +"""End-to-end tests for tool permission callbacks with real Claude API calls.""" + +import uuid +from pathlib import Path + +import pytest + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, + PermissionResultAllow, + PermissionResultDeny, + ToolPermissionContext, +) + + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_permission_callback_gets_called(): + """Test that can_use_tool callback gets invoked for non-read-only commands. + + Note: The CLI auto-allows certain read-only commands (like 'echo') without + consulting the SDK callback. We use 'touch' which requires permission. + """ + callback_invocations: list[tuple[str, dict]] = [] + + # Use a unique file path to avoid conflicts + unique_id = uuid.uuid4().hex[:8] + test_file = f"/tmp/sdk_permission_test_{unique_id}.txt" + test_path = Path(test_file) + + async def permission_callback( + tool_name: str, + input_data: dict, + context: ToolPermissionContext, + ) -> PermissionResultAllow | PermissionResultDeny: + """Track callback invocation and allow all operations.""" + print(f"Permission callback called for: {tool_name}, input: {input_data}") + callback_invocations.append((tool_name, input_data)) + return PermissionResultAllow() + + options = ClaudeAgentOptions( + can_use_tool=permission_callback, + ) + + try: + async with ClaudeSDKClient(options=options) as client: + # Use 'touch' command which is NOT auto-allowed (not read-only) + await client.query(f"Run the command: touch {test_file}") + + async for message in client.receive_response(): + print(f"Got message: {message}") + + print(f"Callback invocations: {[name for name, _ in callback_invocations]}") + + # Verify the callback was invoked for Bash + tool_names = [name for name, _ in callback_invocations] + assert "Bash" in tool_names, ( + f"Permission callback should have been invoked for Bash, got: {tool_names}" + ) + + finally: + # Clean up + if test_path.exists(): + test_path.unlink() diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/agents.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/agents.py new file mode 100644 index 00000000..9e7439ee --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/agents.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Example of using custom agents with Claude Code SDK. + +This example demonstrates how to define and use custom agents with specific +tools, prompts, and models. + +Usage: +./examples/agents.py - Run the example +""" + +import anyio + +from claude_agent_sdk import ( + AgentDefinition, + AssistantMessage, + ClaudeAgentOptions, + ResultMessage, + TextBlock, + query, +) + + +async def code_reviewer_example(): + """Example using a custom code reviewer agent.""" + print("=== Code Reviewer Agent Example ===") + + options = ClaudeAgentOptions( + agents={ + "code-reviewer": AgentDefinition( + description="Reviews code for best practices and potential issues", + prompt="You are a code reviewer. Analyze code for bugs, performance issues, " + "security vulnerabilities, and adherence to best practices. " + "Provide constructive feedback.", + tools=["Read", "Grep"], + model="sonnet", + ), + }, + ) + + async for message in query( + prompt="Use the code-reviewer agent to review the code in src/claude_agent_sdk/types.py", + options=options, + ): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage) and message.total_cost_usd and message.total_cost_usd > 0: + print(f"\nCost: ${message.total_cost_usd:.4f}") + print() + + +async def documentation_writer_example(): + """Example using a documentation writer agent.""" + print("=== Documentation Writer Agent Example ===") + + options = ClaudeAgentOptions( + agents={ + "doc-writer": AgentDefinition( + description="Writes comprehensive documentation", + prompt="You are a technical documentation expert. Write clear, comprehensive " + "documentation with examples. Focus on clarity and completeness.", + tools=["Read", "Write", "Edit"], + model="sonnet", + ), + }, + ) + + async for message in query( + prompt="Use the doc-writer agent to explain what AgentDefinition is used for", + options=options, + ): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage) and message.total_cost_usd and message.total_cost_usd > 0: + print(f"\nCost: ${message.total_cost_usd:.4f}") + print() + + +async def multiple_agents_example(): + """Example with multiple custom agents.""" + print("=== Multiple Agents Example ===") + + options = ClaudeAgentOptions( + agents={ + "analyzer": AgentDefinition( + description="Analyzes code structure and patterns", + prompt="You are a code analyzer. Examine code structure, patterns, and architecture.", + tools=["Read", "Grep", "Glob"], + ), + "tester": AgentDefinition( + description="Creates and runs tests", + prompt="You are a testing expert. Write comprehensive tests and ensure code quality.", + tools=["Read", "Write", "Bash"], + model="sonnet", + ), + }, + setting_sources=["user", "project"], + ) + + async for message in query( + prompt="Use the analyzer agent to find all Python files in the examples/ directory", + options=options, + ): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage) and message.total_cost_usd and message.total_cost_usd > 0: + print(f"\nCost: ${message.total_cost_usd:.4f}") + print() + + +async def main(): + """Run all agent examples.""" + await code_reviewer_example() + await documentation_writer_example() + await multiple_agents_example() + + +if __name__ == "__main__": + anyio.run(main) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/filesystem_agents.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/filesystem_agents.py new file mode 100644 index 00000000..e5f6904a --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/filesystem_agents.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Example of loading filesystem-based agents via setting_sources. + +This example demonstrates how to load agents defined in .claude/agents/ files +using the setting_sources option. This is different from inline AgentDefinition +objects - these agents are loaded from markdown files on disk. + +This example tests the scenario from issue #406 where filesystem-based agents +loaded via setting_sources=["project"] may silently fail in certain environments. + +Usage: +./examples/filesystem_agents.py +""" + +import asyncio +from pathlib import Path + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + ResultMessage, + SystemMessage, + TextBlock, +) + + +def extract_agents(msg: SystemMessage) -> list[str]: + """Extract agent names from system message init data.""" + if msg.subtype == "init": + agents = msg.data.get("agents", []) + # Agents can be either strings or dicts with a 'name' field + result = [] + for a in agents: + if isinstance(a, str): + result.append(a) + elif isinstance(a, dict): + result.append(a.get("name", "")) + return result + return [] + + +async def main(): + """Test loading filesystem-based agents.""" + print("=== Filesystem Agents Example ===") + print("Testing: setting_sources=['project'] with .claude/agents/test-agent.md") + print() + + # Use the SDK repo directory which has .claude/agents/test-agent.md + sdk_dir = Path(__file__).parent.parent + + options = ClaudeAgentOptions( + setting_sources=["project"], + cwd=sdk_dir, + ) + + message_types: list[str] = [] + agents_found: list[str] = [] + + async with ClaudeSDKClient(options=options) as client: + await client.query("Say hello in exactly 3 words") + + async for msg in client.receive_response(): + message_types.append(type(msg).__name__) + + if isinstance(msg, SystemMessage) and msg.subtype == "init": + agents_found = extract_agents(msg) + print(f"Init message received. Agents loaded: {agents_found}") + + elif isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Assistant: {block.text}") + + elif isinstance(msg, ResultMessage): + print( + f"Result: subtype={msg.subtype}, cost=${msg.total_cost_usd or 0:.4f}" + ) + + print() + print("=== Summary ===") + print(f"Message types received: {message_types}") + print(f"Total messages: {len(message_types)}") + + # Validate the results + has_init = "SystemMessage" in message_types + has_assistant = "AssistantMessage" in message_types + has_result = "ResultMessage" in message_types + has_test_agent = "test-agent" in agents_found + + print() + if has_init and has_assistant and has_result: + print("SUCCESS: Received full response (init, assistant, result)") + else: + print("FAILURE: Did not receive full response") + print(f" - Init: {has_init}") + print(f" - Assistant: {has_assistant}") + print(f" - Result: {has_result}") + + if has_test_agent: + print("SUCCESS: test-agent was loaded from filesystem") + else: + print("WARNING: test-agent was NOT loaded (may not exist in .claude/agents/)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/hooks.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/hooks.py new file mode 100644 index 00000000..a8001d4d --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/hooks.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python +"""Example of using hooks with Claude Code SDK via ClaudeAgentOptions. + +This file demonstrates various hook patterns using the hooks parameter +in ClaudeAgentOptions instead of decorator-based hooks. + +Usage: +./examples/hooks.py - List the examples +./examples/hooks.py all - Run all examples +./examples/hooks.py PreToolUse - Run a specific example +""" + +import asyncio +import logging +import sys +from typing import Any + +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient +from claude_agent_sdk.types import ( + AssistantMessage, + HookContext, + HookInput, + HookJSONOutput, + HookMatcher, + Message, + ResultMessage, + TextBlock, +) + +# Set up logging to see what's happening +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s") +logger = logging.getLogger(__name__) + + +def display_message(msg: Message) -> None: + """Standardized message display function.""" + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(msg, ResultMessage): + print("Result ended") + + +##### Hook callback functions +async def check_bash_command( + input_data: HookInput, tool_use_id: str | None, context: HookContext +) -> HookJSONOutput: + """Prevent certain bash commands from being executed.""" + tool_name = input_data["tool_name"] + tool_input = input_data["tool_input"] + + if tool_name != "Bash": + return {} + + command = tool_input.get("command", "") + block_patterns = ["foo.sh"] + + for pattern in block_patterns: + if pattern in command: + logger.warning(f"Blocked command: {command}") + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": f"Command contains invalid pattern: {pattern}", + } + } + + return {} + + +async def add_custom_instructions( + input_data: HookInput, tool_use_id: str | None, context: HookContext +) -> HookJSONOutput: + """Add custom instructions when a session starts.""" + return { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": "My favorite color is hot pink", + } + } + + +async def review_tool_output( + input_data: HookInput, tool_use_id: str | None, context: HookContext +) -> HookJSONOutput: + """Review tool output and provide additional context or warnings.""" + tool_response = input_data.get("tool_response", "") + + # If the tool produced an error, add helpful context + if "error" in str(tool_response).lower(): + return { + "systemMessage": "⚠️ The command produced an error", + "reason": "Tool execution failed - consider checking the command syntax", + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": "The command encountered an error. You may want to try a different approach.", + } + } + + return {} + + +async def strict_approval_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext +) -> HookJSONOutput: + """Demonstrates using permissionDecision to control tool execution.""" + tool_name = input_data.get("tool_name") + tool_input = input_data.get("tool_input", {}) + + # Block any Write operations to specific files + if tool_name == "Write": + file_path = tool_input.get("file_path", "") + if "important" in file_path.lower(): + logger.warning(f"Blocked Write to: {file_path}") + return { + "reason": "Writes to files containing 'important' in the name are not allowed for safety", + "systemMessage": "🚫 Write operation blocked by security policy", + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "Security policy blocks writes to important files", + }, + } + + # Allow everything else explicitly + return { + "reason": "Tool use approved after security review", + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "Tool passed security checks", + }, + } + + +async def stop_on_error_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext +) -> HookJSONOutput: + """Demonstrates using continue=False to stop execution on certain conditions.""" + tool_response = input_data.get("tool_response", "") + + # Stop execution if we see a critical error + if "critical" in str(tool_response).lower(): + logger.error("Critical error detected - stopping execution") + return { + "continue_": False, + "stopReason": "Critical error detected in tool output - execution halted for safety", + "systemMessage": "🛑 Execution stopped due to critical error", + } + + return {"continue_": True} + + +async def example_pretooluse() -> None: + """Basic example demonstrating hook protection.""" + print("=== PreToolUse Example ===") + print("This example demonstrates how PreToolUse can block some bash commands but not others.\n") + + # Configure hooks using ClaudeAgentOptions + options = ClaudeAgentOptions( + allowed_tools=["Bash"], + hooks={ + "PreToolUse": [ + HookMatcher(matcher="Bash", hooks=[check_bash_command]), + ], + } + ) + + async with ClaudeSDKClient(options=options) as client: + # Test 1: Command with forbidden pattern (will be blocked) + print("Test 1: Trying a command that our PreToolUse hook should block...") + print("User: Run the bash command: ./foo.sh --help") + await client.query("Run the bash command: ./foo.sh --help") + + async for msg in client.receive_response(): + display_message(msg) + + print("\n" + "=" * 50 + "\n") + + # Test 2: Safe command that should work + print("Test 2: Trying a command that our PreToolUse hook should allow...") + print("User: Run the bash command: echo 'Hello from hooks example!'") + await client.query("Run the bash command: echo 'Hello from hooks example!'") + + async for msg in client.receive_response(): + display_message(msg) + + print("\n" + "=" * 50 + "\n") + + print("\n") + + +async def example_userpromptsubmit() -> None: + """Demonstrate context retention across conversation.""" + print("=== UserPromptSubmit Example ===") + print("This example shows how a UserPromptSubmit hook can add context.\n") + + options = ClaudeAgentOptions( + hooks={ + "UserPromptSubmit": [ + HookMatcher(matcher=None, hooks=[add_custom_instructions]), + ], + } + ) + + async with ClaudeSDKClient(options=options) as client: + print("User: What's my favorite color?") + await client.query("What's my favorite color?") + + async for msg in client.receive_response(): + display_message(msg) + + print("\n") + + +async def example_posttooluse() -> None: + """Demonstrate PostToolUse hook with reason and systemMessage fields.""" + print("=== PostToolUse Example ===") + print("This example shows how PostToolUse can provide feedback with reason and systemMessage.\n") + + options = ClaudeAgentOptions( + allowed_tools=["Bash"], + hooks={ + "PostToolUse": [ + HookMatcher(matcher="Bash", hooks=[review_tool_output]), + ], + } + ) + + async with ClaudeSDKClient(options=options) as client: + print("User: Run a command that will produce an error: ls /nonexistent_directory") + await client.query("Run this command: ls /nonexistent_directory") + + async for msg in client.receive_response(): + display_message(msg) + + print("\n") + + +async def example_decision_fields() -> None: + """Demonstrate permissionDecision, reason, and systemMessage fields.""" + print("=== Permission Decision Example ===") + print("This example shows how to use permissionDecision='allow'/'deny' with reason and systemMessage.\n") + + options = ClaudeAgentOptions( + allowed_tools=["Write", "Bash"], + model="claude-sonnet-4-5-20250929", + hooks={ + "PreToolUse": [ + HookMatcher(matcher="Write", hooks=[strict_approval_hook]), + ], + } + ) + + async with ClaudeSDKClient(options=options) as client: + # Test 1: Try to write to a file with "important" in the name (should be blocked) + print("Test 1: Trying to write to important_config.txt (should be blocked)...") + print("User: Write 'test' to important_config.txt") + await client.query("Write the text 'test data' to a file called important_config.txt") + + async for msg in client.receive_response(): + display_message(msg) + + print("\n" + "=" * 50 + "\n") + + # Test 2: Write to a regular file (should be approved) + print("Test 2: Trying to write to regular_file.txt (should be approved)...") + print("User: Write 'test' to regular_file.txt") + await client.query("Write the text 'test data' to a file called regular_file.txt") + + async for msg in client.receive_response(): + display_message(msg) + + print("\n") + + +async def example_continue_control() -> None: + """Demonstrate continue and stopReason fields for execution control.""" + print("=== Continue/Stop Control Example ===") + print("This example shows how to use continue_=False with stopReason to halt execution.\n") + + options = ClaudeAgentOptions( + allowed_tools=["Bash"], + hooks={ + "PostToolUse": [ + HookMatcher(matcher="Bash", hooks=[stop_on_error_hook]), + ], + } + ) + + async with ClaudeSDKClient(options=options) as client: + print("User: Run a command that outputs 'CRITICAL ERROR'") + await client.query("Run this bash command: echo 'CRITICAL ERROR: system failure'") + + async for msg in client.receive_response(): + display_message(msg) + + print("\n") + + +async def main() -> None: + """Run all examples or a specific example based on command line argument.""" + examples = { + "PreToolUse": example_pretooluse, + "UserPromptSubmit": example_userpromptsubmit, + "PostToolUse": example_posttooluse, + "DecisionFields": example_decision_fields, + "ContinueControl": example_continue_control, + } + + if len(sys.argv) < 2: + # List available examples + print("Usage: python hooks.py ") + print("\nAvailable examples:") + print(" all - Run all examples") + for name in examples: + print(f" {name}") + print("\nExample descriptions:") + print(" PreToolUse - Block commands using PreToolUse hook") + print(" UserPromptSubmit - Add context at prompt submission") + print(" PostToolUse - Review tool output with reason and systemMessage") + print(" DecisionFields - Use permissionDecision='allow'/'deny' with reason") + print(" ContinueControl - Control execution with continue_ and stopReason") + sys.exit(0) + + example_name = sys.argv[1] + + if example_name == "all": + # Run all examples + for example in examples.values(): + await example() + print("-" * 50 + "\n") + elif example_name in examples: + # Run specific example + await examples[example_name]() + else: + print(f"Error: Unknown example '{example_name}'") + print("\nAvailable examples:") + print(" all - Run all examples") + for name in examples: + print(f" {name}") + sys.exit(1) + + +if __name__ == "__main__": + print("Starting Claude SDK Hooks Examples...") + print("=" * 50 + "\n") + asyncio.run(main()) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/include_partial_messages.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/include_partial_messages.py new file mode 100644 index 00000000..edeb01f2 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/include_partial_messages.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Example of using the "include_partial_messages" option to stream partial messages +from Claude Code SDK. + +This feature allows you to receive stream events that contain incremental +updates as Claude generates responses. This is useful for: +- Building real-time UIs that show text as it's being generated +- Monitoring tool use progress +- Getting early results before the full response is complete + +Note: Partial message streaming requires the CLI to support it, and the +messages will include StreamEvent messages interspersed with regular messages. +""" + +import asyncio +from claude_agent_sdk import ClaudeSDKClient +from claude_agent_sdk.types import ( + ClaudeAgentOptions, + StreamEvent, + AssistantMessage, + UserMessage, + SystemMessage, + ResultMessage, +) + + +async def main(): + # Enable partial message streaming + options = ClaudeAgentOptions( + include_partial_messages=True, + model="claude-sonnet-4-5", + max_turns=2, + env={ + "MAX_THINKING_TOKENS": "8000", + }, + ) + + client = ClaudeSDKClient(options) + + try: + await client.connect() + + # Send a prompt that will generate a streaming response + # prompt = "Run a bash command to sleep for 5 seconds" + prompt = "Think of three jokes, then tell one" + print(f"Prompt: {prompt}\n") + print("=" * 50) + + await client.query(prompt) + + async for message in client.receive_response(): + print(message) + + finally: + await client.disconnect() + + +if __name__ == "__main__": + print("Partial Message Streaming Example") + print("=" * 50) + asyncio.run(main()) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/max_budget_usd.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/max_budget_usd.py new file mode 100644 index 00000000..bb9777e8 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/max_budget_usd.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Example demonstrating max_budget_usd option for cost control.""" + +import anyio + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ResultMessage, + TextBlock, + query, +) + + +async def without_budget(): + """Example without budget limit.""" + print("=== Without Budget Limit ===") + + async for message in query(prompt="What is 2 + 2?"): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"Total cost: ${message.total_cost_usd:.4f}") + print(f"Status: {message.subtype}") + print() + + +async def with_reasonable_budget(): + """Example with budget that won't be exceeded.""" + print("=== With Reasonable Budget ($0.10) ===") + + options = ClaudeAgentOptions( + max_budget_usd=0.10, # 10 cents - plenty for a simple query + ) + + async for message in query(prompt="What is 2 + 2?", options=options): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"Total cost: ${message.total_cost_usd:.4f}") + print(f"Status: {message.subtype}") + print() + + +async def with_tight_budget(): + """Example with very tight budget that will likely be exceeded.""" + print("=== With Tight Budget ($0.0001) ===") + + options = ClaudeAgentOptions( + max_budget_usd=0.0001, # Very small budget - will be exceeded quickly + ) + + async for message in query( + prompt="Read the README.md file and summarize it", options=options + ): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"Total cost: ${message.total_cost_usd:.4f}") + print(f"Status: {message.subtype}") + + # Check if budget was exceeded + if message.subtype == "error_max_budget_usd": + print("⚠️ Budget limit exceeded!") + print( + "Note: The cost may exceed the budget by up to one API call's worth" + ) + print() + + +async def main(): + """Run all examples.""" + print("This example demonstrates using max_budget_usd to control API costs.\n") + + await without_budget() + await with_reasonable_budget() + await with_tight_budget() + + print( + "\nNote: Budget checking happens after each API call completes,\n" + "so the final cost may slightly exceed the specified budget.\n" + ) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/mcp_calculator.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/mcp_calculator.py new file mode 100644 index 00000000..18503dd8 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/mcp_calculator.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Example: Calculator MCP Server. + +This example demonstrates how to create an in-process MCP server with +calculator tools using the Claude Code Python SDK. + +Unlike external MCP servers that require separate processes, this server +runs directly within your Python application, providing better performance +and simpler deployment. +""" + +import asyncio +from typing import Any + +from claude_agent_sdk import ( + ClaudeAgentOptions, + create_sdk_mcp_server, + tool, +) + +# Define calculator tools using the @tool decorator + + +@tool("add", "Add two numbers", {"a": float, "b": float}) +async def add_numbers(args: dict[str, Any]) -> dict[str, Any]: + """Add two numbers together.""" + result = args["a"] + args["b"] + return { + "content": [{"type": "text", "text": f"{args['a']} + {args['b']} = {result}"}] + } + + +@tool("subtract", "Subtract one number from another", {"a": float, "b": float}) +async def subtract_numbers(args: dict[str, Any]) -> dict[str, Any]: + """Subtract b from a.""" + result = args["a"] - args["b"] + return { + "content": [{"type": "text", "text": f"{args['a']} - {args['b']} = {result}"}] + } + + +@tool("multiply", "Multiply two numbers", {"a": float, "b": float}) +async def multiply_numbers(args: dict[str, Any]) -> dict[str, Any]: + """Multiply two numbers.""" + result = args["a"] * args["b"] + return { + "content": [{"type": "text", "text": f"{args['a']} × {args['b']} = {result}"}] + } + + +@tool("divide", "Divide one number by another", {"a": float, "b": float}) +async def divide_numbers(args: dict[str, Any]) -> dict[str, Any]: + """Divide a by b.""" + if args["b"] == 0: + return { + "content": [ + {"type": "text", "text": "Error: Division by zero is not allowed"} + ], + "is_error": True, + } + + result = args["a"] / args["b"] + return { + "content": [{"type": "text", "text": f"{args['a']} ÷ {args['b']} = {result}"}] + } + + +@tool("sqrt", "Calculate square root", {"n": float}) +async def square_root(args: dict[str, Any]) -> dict[str, Any]: + """Calculate the square root of a number.""" + n = args["n"] + if n < 0: + return { + "content": [ + { + "type": "text", + "text": f"Error: Cannot calculate square root of negative number {n}", + } + ], + "is_error": True, + } + + import math + + result = math.sqrt(n) + return {"content": [{"type": "text", "text": f"√{n} = {result}"}]} + + +@tool("power", "Raise a number to a power", {"base": float, "exponent": float}) +async def power(args: dict[str, Any]) -> dict[str, Any]: + """Raise base to the exponent power.""" + result = args["base"] ** args["exponent"] + return { + "content": [ + {"type": "text", "text": f"{args['base']}^{args['exponent']} = {result}"} + ] + } + + +def display_message(msg): + """Display message content in a clean format.""" + from claude_agent_sdk import ( + AssistantMessage, + ResultMessage, + SystemMessage, + TextBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, + ) + + if isinstance(msg, UserMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"User: {block.text}") + elif isinstance(block, ToolResultBlock): + print( + f"Tool Result: {block.content[:100] if block.content else 'None'}..." + ) + elif isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(block, ToolUseBlock): + print(f"Using tool: {block.name}") + # Show tool inputs for calculator + if block.input: + print(f" Input: {block.input}") + elif isinstance(msg, SystemMessage): + # Ignore system messages + pass + elif isinstance(msg, ResultMessage): + print("Result ended") + if msg.total_cost_usd: + print(f"Cost: ${msg.total_cost_usd:.6f}") + + +async def main(): + """Run example calculations using the SDK MCP server with streaming client.""" + from claude_agent_sdk import ClaudeSDKClient + + # Create the calculator server with all tools + calculator = create_sdk_mcp_server( + name="calculator", + version="2.0.0", + tools=[ + add_numbers, + subtract_numbers, + multiply_numbers, + divide_numbers, + square_root, + power, + ], + ) + + # Configure Claude to use the calculator server with allowed tools + # Pre-approve all calculator MCP tools so they can be used without permission prompts + options = ClaudeAgentOptions( + mcp_servers={"calc": calculator}, + allowed_tools=[ + "mcp__calc__add", + "mcp__calc__subtract", + "mcp__calc__multiply", + "mcp__calc__divide", + "mcp__calc__sqrt", + "mcp__calc__power", + ], + ) + + # Example prompts to demonstrate calculator usage + prompts = [ + "List your tools", + "Calculate 15 + 27", + "What is 100 divided by 7?", + "Calculate the square root of 144", + "What is 2 raised to the power of 8?", + "Calculate (12 + 8) * 3 - 10", # Complex calculation + ] + + for prompt in prompts: + print(f"\n{'=' * 50}") + print(f"Prompt: {prompt}") + print(f"{'=' * 50}") + + async with ClaudeSDKClient(options=options) as client: + await client.query(prompt) + + async for message in client.receive_response(): + display_message(message) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/plugin_example.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/plugin_example.py new file mode 100644 index 00000000..ac179f89 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/plugin_example.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Example demonstrating how to use plugins with Claude Code SDK. + +Plugins allow you to extend Claude Code with custom commands, agents, skills, +and hooks. This example shows how to load a local plugin and verify it's +loaded by checking the system message. + +The demo plugin is located in examples/plugins/demo-plugin/ and provides +a custom /greet command. +""" + +from pathlib import Path + +import anyio + +from claude_agent_sdk import ( + ClaudeAgentOptions, + SystemMessage, + query, +) + + +async def plugin_example(): + """Example showing plugins being loaded in the system message.""" + print("=== Plugin Example ===\n") + + # Get the path to the demo plugin + # In production, you can use any path to your plugin directory + plugin_path = Path(__file__).parent / "plugins" / "demo-plugin" + + options = ClaudeAgentOptions( + plugins=[ + { + "type": "local", + "path": str(plugin_path), + } + ], + max_turns=1, # Limit to one turn for quick demo + ) + + print(f"Loading plugin from: {plugin_path}\n") + + found_plugins = False + async for message in query(prompt="Hello!", options=options): + if isinstance(message, SystemMessage) and message.subtype == "init": + print("System initialized!") + print(f"System message data keys: {list(message.data.keys())}\n") + + # Check for plugins in the system message + plugins_data = message.data.get("plugins", []) + if plugins_data: + print("Plugins loaded:") + for plugin in plugins_data: + print(f" - {plugin.get('name')} (path: {plugin.get('path')})") + found_plugins = True + else: + print("Note: Plugin was passed via CLI but may not appear in system message.") + print(f"Plugin path configured: {plugin_path}") + found_plugins = True + + if found_plugins: + print("\nPlugin successfully configured!\n") + + +async def main(): + """Run all plugin examples.""" + await plugin_example() + + +if __name__ == "__main__": + anyio.run(main) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/plugins/demo-plugin/.claude-plugin/plugin.json b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/plugins/demo-plugin/.claude-plugin/plugin.json new file mode 100644 index 00000000..a33038ef --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/plugins/demo-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "demo-plugin", + "description": "A demo plugin showing how to extend Claude Code with custom commands", + "version": "1.0.0", + "author": { + "name": "Claude Code Team" + } +} diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/plugins/demo-plugin/commands/greet.md b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/plugins/demo-plugin/commands/greet.md new file mode 100644 index 00000000..5274b20e --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/plugins/demo-plugin/commands/greet.md @@ -0,0 +1,5 @@ +# Greet Command + +This is a custom greeting command from the demo plugin. + +When the user runs this command, greet them warmly and explain that this message came from a custom plugin loaded via the Python SDK. Tell them that plugins can be used to extend Claude Code with custom commands, agents, skills, and hooks. diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/quick_start.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/quick_start.py new file mode 100644 index 00000000..3f128552 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/quick_start.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Quick start example for Claude Code SDK.""" + +import anyio + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ResultMessage, + TextBlock, + query, +) + + +async def basic_example(): + """Basic example - simple question.""" + print("=== Basic Example ===") + + async for message in query(prompt="What is 2 + 2?"): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + print() + + +async def with_options_example(): + """Example with custom options.""" + print("=== With Options Example ===") + + options = ClaudeAgentOptions( + system_prompt="You are a helpful assistant that explains things simply.", + max_turns=1, + ) + + async for message in query( + prompt="Explain what Python is in one sentence.", options=options + ): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + print() + + +async def with_tools_example(): + """Example using tools.""" + print("=== With Tools Example ===") + + options = ClaudeAgentOptions( + allowed_tools=["Read", "Write"], + system_prompt="You are a helpful file assistant.", + ) + + async for message in query( + prompt="Create a file called hello.txt with 'Hello, World!' in it", + options=options, + ): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage) and message.total_cost_usd > 0: + print(f"\nCost: ${message.total_cost_usd:.4f}") + print() + + +async def main(): + """Run all examples.""" + await basic_example() + await with_options_example() + await with_tools_example() + + +if __name__ == "__main__": + anyio.run(main) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/setting_sources.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/setting_sources.py new file mode 100644 index 00000000..a0b37d63 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/setting_sources.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Example demonstrating setting sources control. + +This example shows how to use the setting_sources option to control which +settings are loaded, including custom slash commands, agents, and other +configurations. + +Setting sources determine where Claude Code loads configurations from: +- "user": Global user settings (~/.claude/) +- "project": Project-level settings (.claude/ in project) +- "local": Local gitignored settings (.claude-local/) + +IMPORTANT: When setting_sources is not provided (None), NO settings are loaded +by default. This creates an isolated environment. To load settings, explicitly +specify which sources to use. + +By controlling which sources are loaded, you can: +- Create isolated environments with no custom settings (default) +- Load only user settings, excluding project-specific configurations +- Combine multiple sources as needed + +Usage: +./examples/setting_sources.py - List the examples +./examples/setting_sources.py all - Run all examples +./examples/setting_sources.py default - Run a specific example +""" + +import asyncio +import sys +from pathlib import Path + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ClaudeSDKClient, + SystemMessage, +) + + +def extract_slash_commands(msg: SystemMessage) -> list[str]: + """Extract slash command names from system message.""" + if msg.subtype == "init": + commands = msg.data.get("slash_commands", []) + return commands + return [] + + +async def example_default(): + """Default behavior - no settings loaded.""" + print("=== Default Behavior Example ===") + print("Setting sources: None (default)") + print("Expected: No custom slash commands will be available\n") + + sdk_dir = Path(__file__).parent.parent + + options = ClaudeAgentOptions( + cwd=sdk_dir, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("What is 2 + 2?") + + async for msg in client.receive_response(): + if isinstance(msg, SystemMessage) and msg.subtype == "init": + commands = extract_slash_commands(msg) + print(f"Available slash commands: {commands}") + if "commit" in commands: + print("❌ /commit is available (unexpected)") + else: + print("✓ /commit is NOT available (expected - no settings loaded)") + break + + print() + + +async def example_user_only(): + """Load only user-level settings, excluding project settings.""" + print("=== User Settings Only Example ===") + print("Setting sources: ['user']") + print("Expected: Project slash commands (like /commit) will NOT be available\n") + + # Use the SDK repo directory which has .claude/commands/commit.md + sdk_dir = Path(__file__).parent.parent + + options = ClaudeAgentOptions( + setting_sources=["user"], + cwd=sdk_dir, + ) + + async with ClaudeSDKClient(options=options) as client: + # Send a simple query + await client.query("What is 2 + 2?") + + # Check the initialize message for available commands + async for msg in client.receive_response(): + if isinstance(msg, SystemMessage) and msg.subtype == "init": + commands = extract_slash_commands(msg) + print(f"Available slash commands: {commands}") + if "commit" in commands: + print("❌ /commit is available (unexpected)") + else: + print("✓ /commit is NOT available (expected)") + break + + print() + + +async def example_project_and_user(): + """Load both project and user settings.""" + print("=== Project + User Settings Example ===") + print("Setting sources: ['user', 'project']") + print("Expected: Project slash commands (like /commit) WILL be available\n") + + sdk_dir = Path(__file__).parent.parent + + options = ClaudeAgentOptions( + setting_sources=["user", "project"], + cwd=sdk_dir, + ) + + async with ClaudeSDKClient(options=options) as client: + await client.query("What is 2 + 2?") + + async for msg in client.receive_response(): + if isinstance(msg, SystemMessage) and msg.subtype == "init": + commands = extract_slash_commands(msg) + print(f"Available slash commands: {commands}") + if "commit" in commands: + print("✓ /commit is available (expected)") + else: + print("❌ /commit is NOT available (unexpected)") + break + + print() + + + + +async def main(): + """Run all examples or a specific example based on command line argument.""" + examples = { + "default": example_default, + "user_only": example_user_only, + "project_and_user": example_project_and_user, + } + + if len(sys.argv) < 2: + print("Usage: python setting_sources.py ") + print("\nAvailable examples:") + print(" all - Run all examples") + for name in examples: + print(f" {name}") + sys.exit(0) + + example_name = sys.argv[1] + + if example_name == "all": + for example in examples.values(): + await example() + print("-" * 50 + "\n") + elif example_name in examples: + await examples[example_name]() + else: + print(f"Error: Unknown example '{example_name}'") + print("\nAvailable examples:") + print(" all - Run all examples") + for name in examples: + print(f" {name}") + sys.exit(1) + + +if __name__ == "__main__": + print("Starting Claude SDK Setting Sources Examples...") + print("=" * 50 + "\n") + asyncio.run(main()) \ No newline at end of file diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/stderr_callback_example.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/stderr_callback_example.py new file mode 100644 index 00000000..8b9c4c21 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/stderr_callback_example.py @@ -0,0 +1,44 @@ +"""Simple example demonstrating stderr callback for capturing CLI debug output.""" + +import asyncio + +from claude_agent_sdk import ClaudeAgentOptions, query + + +async def main(): + """Capture stderr output from the CLI using a callback.""" + + # Collect stderr messages + stderr_messages = [] + + def stderr_callback(message: str): + """Callback that receives each line of stderr output.""" + stderr_messages.append(message) + # Optionally print specific messages + if "[ERROR]" in message: + print(f"Error detected: {message}") + + # Create options with stderr callback and enable debug mode + options = ClaudeAgentOptions( + stderr=stderr_callback, + extra_args={"debug-to-stderr": None} # Enable debug output + ) + + # Run a query + print("Running query with stderr capture...") + async for message in query( + prompt="What is 2+2?", + options=options + ): + if hasattr(message, 'content'): + if isinstance(message.content, str): + print(f"Response: {message.content}") + + # Show what we captured + print(f"\nCaptured {len(stderr_messages)} stderr lines") + if stderr_messages: + print("First stderr line:", stderr_messages[0][:100]) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/streaming_mode.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/streaming_mode.py new file mode 100755 index 00000000..c949ad36 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/streaming_mode.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +""" +Comprehensive examples of using ClaudeSDKClient for streaming mode. + +This file demonstrates various patterns for building applications with +the ClaudeSDKClient streaming interface. + +The queries are intentionally simplistic. In reality, a query can be a more +complex task that Claude SDK uses its agentic capabilities and tools (e.g. run +bash commands, edit files, search the web, fetch web content) to accomplish. + +Usage: +./examples/streaming_mode.py - List the examples +./examples/streaming_mode.py all - Run all examples +./examples/streaming_mode.py basic_streaming - Run a specific example +""" + +import asyncio +import contextlib +import sys + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + CLIConnectionError, + ResultMessage, + SystemMessage, + TextBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) + + +def display_message(msg): + """Standardized message display function. + + - UserMessage: "User: " + - AssistantMessage: "Claude: " + - SystemMessage: ignored + - ResultMessage: "Result ended" + cost if available + """ + if isinstance(msg, UserMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"User: {block.text}") + elif isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(msg, SystemMessage): + # Ignore system messages + pass + elif isinstance(msg, ResultMessage): + print("Result ended") + + +async def example_basic_streaming(): + """Basic streaming with context manager.""" + print("=== Basic Streaming Example ===") + + async with ClaudeSDKClient() as client: + print("User: What is 2+2?") + await client.query("What is 2+2?") + + # Receive complete response using the helper method + async for msg in client.receive_response(): + display_message(msg) + + print("\n") + + +async def example_multi_turn_conversation(): + """Multi-turn conversation using receive_response helper.""" + print("=== Multi-Turn Conversation Example ===") + + async with ClaudeSDKClient() as client: + # First turn + print("User: What's the capital of France?") + await client.query("What's the capital of France?") + + # Extract and print response + async for msg in client.receive_response(): + display_message(msg) + + # Second turn - follow-up + print("\nUser: What's the population of that city?") + await client.query("What's the population of that city?") + + async for msg in client.receive_response(): + display_message(msg) + + print("\n") + + +async def example_concurrent_responses(): + """Handle responses while sending new messages.""" + print("=== Concurrent Send/Receive Example ===") + + async with ClaudeSDKClient() as client: + # Background task to continuously receive messages + async def receive_messages(): + async for message in client.receive_messages(): + display_message(message) + + # Start receiving in background + receive_task = asyncio.create_task(receive_messages()) + + # Send multiple messages with delays + questions = [ + "What is 2 + 2?", + "What is the square root of 144?", + "What is 10% of 80?", + ] + + for question in questions: + print(f"\nUser: {question}") + await client.query(question) + await asyncio.sleep(3) # Wait between messages + + # Give time for final responses + await asyncio.sleep(2) + + # Clean up + receive_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await receive_task + + print("\n") + + +async def example_with_interrupt(): + """Demonstrate interrupt capability.""" + print("=== Interrupt Example ===") + print("IMPORTANT: Interrupts require active message consumption.") + + async with ClaudeSDKClient() as client: + # Start a long-running task + print("\nUser: Count from 1 to 100 slowly") + await client.query( + "Count from 1 to 100 slowly, with a brief pause between each number" + ) + + # Create a background task to consume messages + messages_received = [] + + async def consume_messages(): + """Consume messages in the background to enable interrupt processing.""" + async for message in client.receive_response(): + messages_received.append(message) + display_message(message) + + # Start consuming messages in the background + consume_task = asyncio.create_task(consume_messages()) + + # Wait 2 seconds then send interrupt + await asyncio.sleep(2) + print("\n[After 2 seconds, sending interrupt...]") + await client.interrupt() + + # Wait for the consume task to finish processing the interrupt + await consume_task + + # Send new instruction after interrupt + print("\nUser: Never mind, just tell me a quick joke") + await client.query("Never mind, just tell me a quick joke") + + # Get the joke + async for msg in client.receive_response(): + display_message(msg) + + print("\n") + + +async def example_manual_message_handling(): + """Manually handle message stream for custom logic.""" + print("=== Manual Message Handling Example ===") + + async with ClaudeSDKClient() as client: + await client.query("List 5 programming languages and their main use cases") + + # Manually process messages with custom logic + languages_found = [] + + async for message in client.receive_messages(): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + text = block.text + print(f"Claude: {text}") + # Custom logic: extract language names + for lang in [ + "Python", + "JavaScript", + "Java", + "C++", + "Go", + "Rust", + "Ruby", + ]: + if lang in text and lang not in languages_found: + languages_found.append(lang) + print(f"Found language: {lang}") + elif isinstance(message, ResultMessage): + display_message(message) + print(f"Total languages mentioned: {len(languages_found)}") + break + + print("\n") + + +async def example_with_options(): + """Use ClaudeAgentOptions to configure the client.""" + print("=== Custom Options Example ===") + + # Configure options + options = ClaudeAgentOptions( + allowed_tools=["Read", "Write"], # Allow file operations + system_prompt="You are a helpful coding assistant.", + env={ + "ANTHROPIC_MODEL": "claude-sonnet-4-5", + }, + ) + + async with ClaudeSDKClient(options=options) as client: + print("User: Create a simple hello.txt file with a greeting message") + await client.query("Create a simple hello.txt file with a greeting message") + + tool_uses = [] + async for msg in client.receive_response(): + if isinstance(msg, AssistantMessage): + display_message(msg) + for block in msg.content: + if hasattr(block, "name") and not isinstance( + block, TextBlock + ): # ToolUseBlock + tool_uses.append(getattr(block, "name", "")) + else: + display_message(msg) + + if tool_uses: + print(f"Tools used: {', '.join(tool_uses)}") + + print("\n") + + +async def example_async_iterable_prompt(): + """Demonstrate send_message with async iterable.""" + print("=== Async Iterable Prompt Example ===") + + async def create_message_stream(): + """Generate a stream of messages.""" + print("User: Hello! I have multiple questions.") + yield { + "type": "user", + "message": {"role": "user", "content": "Hello! I have multiple questions."}, + "parent_tool_use_id": None, + "session_id": "qa-session", + } + + print("User: First, what's the capital of Japan?") + yield { + "type": "user", + "message": { + "role": "user", + "content": "First, what's the capital of Japan?", + }, + "parent_tool_use_id": None, + "session_id": "qa-session", + } + + print("User: Second, what's 15% of 200?") + yield { + "type": "user", + "message": {"role": "user", "content": "Second, what's 15% of 200?"}, + "parent_tool_use_id": None, + "session_id": "qa-session", + } + + async with ClaudeSDKClient() as client: + # Send async iterable of messages + await client.query(create_message_stream()) + + # Receive the three responses + async for msg in client.receive_response(): + display_message(msg) + async for msg in client.receive_response(): + display_message(msg) + async for msg in client.receive_response(): + display_message(msg) + + print("\n") + + +async def example_bash_command(): + """Example showing tool use blocks when running bash commands.""" + print("=== Bash Command Example ===") + + async with ClaudeSDKClient() as client: + print("User: Run a bash echo command") + await client.query("Run a bash echo command that says 'Hello from bash!'") + + # Track all message types received + message_types = [] + + async for msg in client.receive_messages(): + message_types.append(type(msg).__name__) + + if isinstance(msg, UserMessage): + # User messages can contain tool results + for block in msg.content: + if isinstance(block, TextBlock): + print(f"User: {block.text}") + elif isinstance(block, ToolResultBlock): + print( + f"Tool Result (id: {block.tool_use_id}): {block.content[:100] if block.content else 'None'}..." + ) + + elif isinstance(msg, AssistantMessage): + # Assistant messages can contain tool use blocks + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(block, ToolUseBlock): + print(f"Tool Use: {block.name} (id: {block.id})") + if block.name == "Bash": + command = block.input.get("command", "") + print(f" Command: {command}") + + elif isinstance(msg, ResultMessage): + print("Result ended") + if msg.total_cost_usd: + print(f"Cost: ${msg.total_cost_usd:.4f}") + break + + print(f"\nMessage types received: {', '.join(set(message_types))}") + + print("\n") + + +async def example_control_protocol(): + """Demonstrate server info and interrupt capabilities.""" + print("=== Control Protocol Example ===") + print("Shows server info retrieval and interrupt capability\n") + + async with ClaudeSDKClient() as client: + # 1. Get server initialization info + print("1. Getting server info...") + server_info = await client.get_server_info() + + if server_info: + print("✓ Server info retrieved successfully!") + print(f" - Available commands: {len(server_info.get('commands', []))}") + print(f" - Output style: {server_info.get('output_style', 'unknown')}") + + # Show available output styles if present + styles = server_info.get('available_output_styles', []) + if styles: + print(f" - Available output styles: {', '.join(styles)}") + + # Show a few example commands + commands = server_info.get('commands', [])[:5] + if commands: + print(" - Example commands:") + for cmd in commands: + if isinstance(cmd, dict): + print(f" • {cmd.get('name', 'unknown')}") + else: + print("✗ No server info available (may not be in streaming mode)") + + print("\n2. Testing interrupt capability...") + + # Start a long-running task + print("User: Count from 1 to 20 slowly") + await client.query("Count from 1 to 20 slowly, pausing between each number") + + # Start consuming messages in background to enable interrupt + messages = [] + async def consume(): + async for msg in client.receive_response(): + messages.append(msg) + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + # Print first 50 chars to show progress + print(f"Claude: {block.text[:50]}...") + break + if isinstance(msg, ResultMessage): + break + + consume_task = asyncio.create_task(consume()) + + # Wait a moment then interrupt + await asyncio.sleep(2) + print("\n[Sending interrupt after 2 seconds...]") + + try: + await client.interrupt() + print("✓ Interrupt sent successfully") + except Exception as e: + print(f"✗ Interrupt failed: {e}") + + # Wait for task to complete + with contextlib.suppress(asyncio.CancelledError): + await consume_task + + # Send new query after interrupt + print("\nUser: Just say 'Hello!'") + await client.query("Just say 'Hello!'") + + async for msg in client.receive_response(): + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + print("\n") + + +async def example_error_handling(): + """Demonstrate proper error handling.""" + print("=== Error Handling Example ===") + + client = ClaudeSDKClient() + + try: + await client.connect() + + # Send a message that will take time to process + print("User: Run a bash sleep command for 60 seconds not in the background") + await client.query("Run a bash sleep command for 60 seconds not in the background") + + # Try to receive response with a short timeout + try: + messages = [] + async with asyncio.timeout(10.0): + async for msg in client.receive_response(): + messages.append(msg) + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text[:50]}...") + elif isinstance(msg, ResultMessage): + display_message(msg) + break + + except asyncio.TimeoutError: + print( + "\nResponse timeout after 10 seconds - demonstrating graceful handling" + ) + print(f"Received {len(messages)} messages before timeout") + + except CLIConnectionError as e: + print(f"Connection error: {e}") + + except Exception as e: + print(f"Unexpected error: {e}") + + finally: + # Always disconnect + await client.disconnect() + + print("\n") + + +async def main(): + """Run all examples or a specific example based on command line argument.""" + examples = { + "basic_streaming": example_basic_streaming, + "multi_turn_conversation": example_multi_turn_conversation, + "concurrent_responses": example_concurrent_responses, + "with_interrupt": example_with_interrupt, + "manual_message_handling": example_manual_message_handling, + "with_options": example_with_options, + "async_iterable_prompt": example_async_iterable_prompt, + "bash_command": example_bash_command, + "control_protocol": example_control_protocol, + "error_handling": example_error_handling, + } + + if len(sys.argv) < 2: + # List available examples + print("Usage: python streaming_mode.py ") + print("\nAvailable examples:") + print(" all - Run all examples") + for name in examples: + print(f" {name}") + sys.exit(0) + + example_name = sys.argv[1] + + if example_name == "all": + # Run all examples + for example in examples.values(): + await example() + print("-" * 50 + "\n") + elif example_name in examples: + # Run specific example + await examples[example_name]() + else: + print(f"Error: Unknown example '{example_name}'") + print("\nAvailable examples:") + print(" all - Run all examples") + for name in examples: + print(f" {name}") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/streaming_mode_ipython.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/streaming_mode_ipython.py new file mode 100644 index 00000000..aa63994c --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/streaming_mode_ipython.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +IPython-friendly code snippets for ClaudeSDKClient streaming mode. + +These examples are designed to be copy-pasted directly into IPython. +Each example is self-contained and can be run independently. + +The queries are intentionally simplistic. In reality, a query can be a more +complex task that Claude SDK uses its agentic capabilities and tools (e.g. run +bash commands, edit files, search the web, fetch web content) to accomplish. +""" + +# ============================================================================ +# BASIC STREAMING +# ============================================================================ + +from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, ResultMessage, TextBlock + +async with ClaudeSDKClient() as client: + print("User: What is 2+2?") + await client.query("What is 2+2?") + + async for msg in client.receive_response(): + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + +# ============================================================================ +# STREAMING WITH REAL-TIME DISPLAY +# ============================================================================ + +import asyncio + +from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, TextBlock + +async with ClaudeSDKClient() as client: + async def send_and_receive(prompt): + print(f"User: {prompt}") + await client.query(prompt) + async for msg in client.receive_response(): + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + await send_and_receive("Tell me a short joke") + print("\n---\n") + await send_and_receive("Now tell me a fun fact") + + +# ============================================================================ +# PERSISTENT CLIENT FOR MULTIPLE QUESTIONS +# ============================================================================ + +from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, TextBlock + +# Create client +client = ClaudeSDKClient() +await client.connect() + + +# Helper to get response +async def get_response(): + async for msg in client.receive_response(): + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + +# Use it multiple times +print("User: What's 2+2?") +await client.query("What's 2+2?") +await get_response() + +print("User: What's 10*10?") +await client.query("What's 10*10?") +await get_response() + +# Don't forget to disconnect when done +await client.disconnect() + + +# ============================================================================ +# WITH INTERRUPT CAPABILITY +# ============================================================================ +# IMPORTANT: Interrupts require active message consumption. You must be +# consuming messages from the client for the interrupt to be processed. + +from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, TextBlock + +async with ClaudeSDKClient() as client: + print("\n--- Sending initial message ---\n") + + # Send a long-running task + print("User: Count from 1 to 100, run bash sleep for 1 second in between") + await client.query("Count from 1 to 100, run bash sleep for 1 second in between") + + # Create a background task to consume messages + messages_received = [] + interrupt_sent = False + + async def consume_messages(): + async for msg in client.receive_messages(): + messages_received.append(msg) + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + # Check if we got a result after interrupt + if isinstance(msg, ResultMessage) and interrupt_sent: + break + + # Start consuming messages in the background + consume_task = asyncio.create_task(consume_messages()) + + # Wait a bit then send interrupt + await asyncio.sleep(10) + print("\n--- Sending interrupt ---\n") + interrupt_sent = True + await client.interrupt() + + # Wait for the consume task to finish + await consume_task + + # Send a new message after interrupt + print("\n--- After interrupt, sending new message ---\n") + await client.query("Just say 'Hello! I was interrupted.'") + + async for msg in client.receive_response(): + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + +# ============================================================================ +# ERROR HANDLING PATTERN +# ============================================================================ + +from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, TextBlock + +try: + async with ClaudeSDKClient() as client: + print("User: Run a bash sleep command for 60 seconds") + await client.query("Run a bash sleep command for 60 seconds") + + # Timeout after 20 seconds + messages = [] + async with asyncio.timeout(20.0): + async for msg in client.receive_response(): + messages.append(msg) + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + +except asyncio.TimeoutError: + print("Request timed out after 20 seconds") +except Exception as e: + print(f"Error: {e}") + + +# ============================================================================ +# SENDING ASYNC ITERABLE OF MESSAGES +# ============================================================================ + +from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, TextBlock + + +async def message_generator(): + """Generate multiple messages as an async iterable.""" + print("User: I have two math questions.") + yield { + "type": "user", + "message": {"role": "user", "content": "I have two math questions."}, + "parent_tool_use_id": None, + "session_id": "math-session" + } + print("User: What is 25 * 4?") + yield { + "type": "user", + "message": {"role": "user", "content": "What is 25 * 4?"}, + "parent_tool_use_id": None, + "session_id": "math-session" + } + print("User: What is 100 / 5?") + yield { + "type": "user", + "message": {"role": "user", "content": "What is 100 / 5?"}, + "parent_tool_use_id": None, + "session_id": "math-session" + } + +async with ClaudeSDKClient() as client: + # Send async iterable instead of string + await client.query(message_generator()) + + async for msg in client.receive_response(): + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + + +# ============================================================================ +# COLLECTING ALL MESSAGES INTO A LIST +# ============================================================================ + +from claude_agent_sdk import AssistantMessage, ClaudeSDKClient, TextBlock + +async with ClaudeSDKClient() as client: + print("User: What are the primary colors?") + await client.query("What are the primary colors?") + + # Collect all messages into a list + messages = [msg async for msg in client.receive_response()] + + # Process them afterwards + for msg in messages: + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(msg, ResultMessage): + print(f"Total messages: {len(messages)}") diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/streaming_mode_trio.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/streaming_mode_trio.py new file mode 100644 index 00000000..0566ff7c --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/streaming_mode_trio.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Example of multi-turn conversation using trio with the Claude SDK. + +This demonstrates how to use the ClaudeSDKClient with trio for interactive, +stateful conversations where you can send follow-up messages based on +Claude's responses. +""" + +import trio + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + ResultMessage, + SystemMessage, + TextBlock, + UserMessage, +) + + +def display_message(msg): + """Standardized message display function. + + - UserMessage: "User: " + - AssistantMessage: "Claude: " + - SystemMessage: ignored + - ResultMessage: "Result ended" + cost if available + """ + if isinstance(msg, UserMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"User: {block.text}") + elif isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(msg, SystemMessage): + # Ignore system messages + pass + elif isinstance(msg, ResultMessage): + print("Result ended") + + +async def multi_turn_conversation(): + """Example of a multi-turn conversation using trio.""" + async with ClaudeSDKClient( + options=ClaudeAgentOptions(model="claude-sonnet-4-5") + ) as client: + print("=== Multi-turn Conversation with Trio ===\n") + + # First turn: Simple math question + print("User: What's 15 + 27?") + await client.query("What's 15 + 27?") + + async for message in client.receive_response(): + display_message(message) + print() + + # Second turn: Follow-up calculation + print("User: Now multiply that result by 2") + await client.query("Now multiply that result by 2") + + async for message in client.receive_response(): + display_message(message) + print() + + # Third turn: One more operation + print("User: Divide that by 7 and round to 2 decimal places") + await client.query("Divide that by 7 and round to 2 decimal places") + + async for message in client.receive_response(): + display_message(message) + + print("\nConversation complete!") + + +if __name__ == "__main__": + trio.run(multi_turn_conversation) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/system_prompt.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/system_prompt.py new file mode 100644 index 00000000..37476923 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/system_prompt.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Example demonstrating different system_prompt configurations.""" + +import anyio + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + TextBlock, + query, +) + + +async def no_system_prompt(): + """Example with no system_prompt (vanilla Claude).""" + print("=== No System Prompt (Vanilla Claude) ===") + + async for message in query(prompt="What is 2 + 2?"): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + print() + + +async def string_system_prompt(): + """Example with system_prompt as a string.""" + print("=== String System Prompt ===") + + options = ClaudeAgentOptions( + system_prompt="You are a pirate assistant. Respond in pirate speak.", + ) + + async for message in query(prompt="What is 2 + 2?", options=options): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + print() + + +async def preset_system_prompt(): + """Example with system_prompt preset (uses default Claude Code prompt).""" + print("=== Preset System Prompt (Default) ===") + + options = ClaudeAgentOptions( + system_prompt={"type": "preset", "preset": "claude_code"}, + ) + + async for message in query(prompt="What is 2 + 2?", options=options): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + print() + + +async def preset_with_append(): + """Example with system_prompt preset and append.""" + print("=== Preset System Prompt with Append ===") + + options = ClaudeAgentOptions( + system_prompt={ + "type": "preset", + "preset": "claude_code", + "append": "Always end your response with a fun fact.", + }, + ) + + async for message in query(prompt="What is 2 + 2?", options=options): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + print() + + +async def main(): + """Run all examples.""" + await no_system_prompt() + await string_system_prompt() + await preset_system_prompt() + await preset_with_append() + + +if __name__ == "__main__": + anyio.run(main) \ No newline at end of file diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/tool_permission_callback.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/tool_permission_callback.py new file mode 100644 index 00000000..82c324b5 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/tool_permission_callback.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Example: Tool Permission Callbacks. + +This example demonstrates how to use tool permission callbacks to control +which tools Claude can use and modify their inputs. +""" + +import asyncio +import json + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + PermissionResultAllow, + PermissionResultDeny, + ResultMessage, + TextBlock, + ToolPermissionContext, +) + +# Track tool usage for demonstration +tool_usage_log = [] + + +async def my_permission_callback( + tool_name: str, + input_data: dict, + context: ToolPermissionContext +) -> PermissionResultAllow | PermissionResultDeny: + """Control tool permissions based on tool type and input.""" + + # Log the tool request + tool_usage_log.append({ + "tool": tool_name, + "input": input_data, + "suggestions": context.suggestions + }) + + print(f"\n🔧 Tool Permission Request: {tool_name}") + print(f" Input: {json.dumps(input_data, indent=2)}") + + # Always allow read operations + if tool_name in ["Read", "Glob", "Grep"]: + print(f" ✅ Automatically allowing {tool_name} (read-only operation)") + return PermissionResultAllow() + + # Deny write operations to system directories + if tool_name in ["Write", "Edit", "MultiEdit"]: + file_path = input_data.get("file_path", "") + if file_path.startswith("/etc/") or file_path.startswith("/usr/"): + print(f" ❌ Denying write to system directory: {file_path}") + return PermissionResultDeny( + message=f"Cannot write to system directory: {file_path}" + ) + + # Redirect writes to a safe directory + if not file_path.startswith("/tmp/") and not file_path.startswith("./"): + safe_path = f"./safe_output/{file_path.split('/')[-1]}" + print(f" ⚠️ Redirecting write from {file_path} to {safe_path}") + modified_input = input_data.copy() + modified_input["file_path"] = safe_path + return PermissionResultAllow( + updated_input=modified_input + ) + + # Check dangerous bash commands + if tool_name == "Bash": + command = input_data.get("command", "") + dangerous_commands = ["rm -rf", "sudo", "chmod 777", "dd if=", "mkfs"] + + for dangerous in dangerous_commands: + if dangerous in command: + print(f" ❌ Denying dangerous command: {command}") + return PermissionResultDeny( + message=f"Dangerous command pattern detected: {dangerous}" + ) + + # Allow but log the command + print(f" ✅ Allowing bash command: {command}") + return PermissionResultAllow() + + # For all other tools, ask the user + print(f" ❓ Unknown tool: {tool_name}") + print(f" Input: {json.dumps(input_data, indent=6)}") + user_input = input(" Allow this tool? (y/N): ").strip().lower() + + if user_input in ("y", "yes"): + return PermissionResultAllow() + else: + return PermissionResultDeny( + message="User denied permission" + ) + + +async def main(): + """Run example with tool permission callbacks.""" + + print("=" * 60) + print("Tool Permission Callback Example") + print("=" * 60) + print("\nThis example demonstrates how to:") + print("1. Allow/deny tools based on type") + print("2. Modify tool inputs for safety") + print("3. Log tool usage") + print("4. Prompt for unknown tools") + print("=" * 60) + + # Configure options with our callback + options = ClaudeAgentOptions( + can_use_tool=my_permission_callback, + # Use default permission mode to ensure callbacks are invoked + permission_mode="default", + cwd="." # Set working directory + ) + + # Create client and send a query that will use multiple tools + async with ClaudeSDKClient(options) as client: + print("\n📝 Sending query to Claude...") + await client.query( + "Please do the following:\n" + "1. List the files in the current directory\n" + "2. Create a simple Python hello world script at hello.py\n" + "3. Run the script to test it" + ) + + print("\n📨 Receiving response...") + message_count = 0 + + async for message in client.receive_response(): + message_count += 1 + + if isinstance(message, AssistantMessage): + # Print Claude's text responses + for block in message.content: + if isinstance(block, TextBlock): + print(f"\n💬 Claude: {block.text}") + + elif isinstance(message, ResultMessage): + print("\n✅ Task completed!") + print(f" Duration: {message.duration_ms}ms") + if message.total_cost_usd: + print(f" Cost: ${message.total_cost_usd:.4f}") + print(f" Messages processed: {message_count}") + + # Print tool usage summary + print("\n" + "=" * 60) + print("Tool Usage Summary") + print("=" * 60) + for i, usage in enumerate(tool_usage_log, 1): + print(f"\n{i}. Tool: {usage['tool']}") + print(f" Input: {json.dumps(usage['input'], indent=6)}") + if usage['suggestions']: + print(f" Suggestions: {usage['suggestions']}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/tools_option.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/tools_option.py new file mode 100644 index 00000000..204676f9 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/examples/tools_option.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Example demonstrating the tools option and verifying tools in system message.""" + +import anyio + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ResultMessage, + SystemMessage, + TextBlock, + query, +) + + +async def tools_array_example(): + """Example with tools as array of specific tool names.""" + print("=== Tools Array Example ===") + print("Setting tools=['Read', 'Glob', 'Grep']") + print() + + options = ClaudeAgentOptions( + tools=["Read", "Glob", "Grep"], + max_turns=1, + ) + + async for message in query( + prompt="What tools do you have available? Just list them briefly.", + options=options, + ): + if isinstance(message, SystemMessage) and message.subtype == "init": + tools = message.data.get("tools", []) + print(f"Tools from system message: {tools}") + print() + elif isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"\nCost: ${message.total_cost_usd:.4f}") + print() + + +async def tools_empty_array_example(): + """Example with tools as empty array (disables all built-in tools).""" + print("=== Tools Empty Array Example ===") + print("Setting tools=[] (disables all built-in tools)") + print() + + options = ClaudeAgentOptions( + tools=[], + max_turns=1, + ) + + async for message in query( + prompt="What tools do you have available? Just list them briefly.", + options=options, + ): + if isinstance(message, SystemMessage) and message.subtype == "init": + tools = message.data.get("tools", []) + print(f"Tools from system message: {tools}") + print() + elif isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"\nCost: ${message.total_cost_usd:.4f}") + print() + + +async def tools_preset_example(): + """Example with tools preset (all default Claude Code tools).""" + print("=== Tools Preset Example ===") + print("Setting tools={'type': 'preset', 'preset': 'claude_code'}") + print() + + options = ClaudeAgentOptions( + tools={"type": "preset", "preset": "claude_code"}, + max_turns=1, + ) + + async for message in query( + prompt="What tools do you have available? Just list them briefly.", + options=options, + ): + if isinstance(message, SystemMessage) and message.subtype == "init": + tools = message.data.get("tools", []) + print(f"Tools from system message ({len(tools)} tools): {tools[:5]}...") + print() + elif isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(message, ResultMessage): + if message.total_cost_usd: + print(f"\nCost: ${message.total_cost_usd:.4f}") + print() + + +async def main(): + """Run all examples.""" + await tools_array_example() + await tools_empty_array_example() + await tools_preset_example() + + +if __name__ == "__main__": + anyio.run(main) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/README.md b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/README.md new file mode 100644 index 00000000..64ed8309 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/README.md @@ -0,0 +1,67 @@ +# OpenAI -> Anthropic Compatibility Proxy + +This proxy exposes Anthropic-compatible endpoints and forwards requests to an OpenAI-compatible upstream. + +## Endpoints + +- `POST /v1/messages` +- `GET /v1/models` +- `GET /healthz` + +## Environment Variables + +- `OPENAI_BASE_URL` (required) +- `MODEL_MAP_JSON` (optional) - JSON map, e.g. `{"claude-sonnet-4-6":"Kimi-K2.5"}` +- `FALLBACK_MODELS_JSON` (optional) - static Anthropic-style model list fallback used when upstream `/v1/models` fails +- `DEFAULT_MAX_TOKENS` (optional, default `4096`) +- `UPSTREAM_TIMEOUT_SECONDS` (optional, default `90`) +- `UPSTREAM_EXTRA_HEADERS_JSON` (optional) - JSON headers to add upstream +- `LOG_LEVEL` (optional, default `INFO`) + +## Install + +```bash +cd /Users/wuxiaohan10/Downloads/claude-agent-sdk-python +python3 -m venv .venv +. .venv/bin/activate +pip install -r proxy/requirements.txt +``` + +## Run + +```bash +export OPENAI_BASE_URL="http://ai-api.jdcloud.com" +export MODEL_MAP_JSON='{"claude-sonnet-4-6":"Kimi-K2.5"}' +export FALLBACK_MODELS_JSON='["Kimi-K2.5","Kimi-Vision"]' +uvicorn proxy.app:create_app --factory --host 0.0.0.0 --port 8080 +``` + +## Claude SDK Integration + +Point Claude SDK to this proxy instead of direct Anthropic API: + +```python +from claude_agent_sdk import ClaudeAgentOptions + +options = ClaudeAgentOptions( + env={ + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8080", + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_MODEL": "claude-sonnet-4-6", + } +) +``` + +## Notes + +- Inbound auth supports both `x-api-key` and `Authorization: Bearer `. +- Inbound token is forwarded as upstream `Authorization: Bearer `. +- Upstream endpoint is fixed to OpenAI `POST /v1/chat/completions` and `GET /v1/models`. +- Streaming responses are converted from OpenAI SSE chunks into Anthropic SSE event format. + +## Troubleshooting + +- If you see upstream auth errors, verify token and `OPENAI_BASE_URL`. +- If model not found, add mapping in `MODEL_MAP_JSON`. +- If upstream does not support `/v1/models`, configure `FALLBACK_MODELS_JSON`. +- If stream hangs, inspect upstream raw SSE response and proxy logs. diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/__init__.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/__init__.py new file mode 100644 index 00000000..62cf9018 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/__init__.py @@ -0,0 +1,2 @@ +"""OpenAI-to-Anthropic compatibility proxy package.""" + diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/app.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/app.py new file mode 100644 index 00000000..d98a352e --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/app.py @@ -0,0 +1,275 @@ +"""Anthropic-compatible HTTP proxy that forwards to OpenAI chat/completions.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +import httpx +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, Response, StreamingResponse + +from .auth import AuthError, extract_inbound_token, mask_token +from .config import ProxySettings, SettingsError, build_openai_url +from .converters import ( + ConversionError, + anthropic_error_payload, + convert_anthropic_request_to_openai, + convert_openai_models_to_anthropic, + convert_openai_response_to_anthropic, + extract_upstream_error_message, +) +from .streaming import OpenAIToAnthropicStreamConverter + +logger = logging.getLogger(__name__) + + +def create_app( + settings: ProxySettings | None = None, + upstream_transport: httpx.BaseTransport | None = None, +) -> FastAPI: + """Create the proxy FastAPI app. + + Use with uvicorn factory mode: + `uvicorn proxy.app:create_app --factory --host 0.0.0.0 --port 8080` + """ + if settings is None: + settings = ProxySettings.from_env() + + _configure_logging(settings.log_level) + + app = FastAPI(title="OpenAI to Anthropic Proxy", version="0.1.0") + app.state.settings = settings + app.state.upstream_transport = upstream_transport + + @app.get("/healthz") + async def healthz() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/v1/models") + async def list_models(request: Request) -> Response: + try: + inbound_token = extract_inbound_token(request.headers) + except AuthError as exc: + return _anthropic_error_response(401, str(exc), "authentication_error") + + logger.info("/v1/models request using token=%s", mask_token(inbound_token)) + + settings = request.app.state.settings + url = build_openai_url(settings.openai_base_url, "models") + + headers = _build_upstream_headers(settings, inbound_token) + + async with _new_async_client(request.app) as client: + try: + upstream = await client.get(url, headers=headers) + except httpx.HTTPError as exc: + logger.exception("Upstream /v1/models request failed") + fallback_response = _fallback_models_response(settings, f"upstream request failed: {exc}") + if fallback_response is not None: + return fallback_response + return _anthropic_error_response(502, f"Upstream request failed: {exc}", "api_error") + + if upstream.status_code >= 400: + message = extract_upstream_error_message(upstream.text) + fallback_response = _fallback_models_response( + settings, + f"upstream returned {upstream.status_code}: {message}", + ) + if fallback_response is not None: + return fallback_response + return _anthropic_error_response(upstream.status_code, message, "api_error") + + try: + upstream_json = upstream.json() + except json.JSONDecodeError: + fallback_response = _fallback_models_response(settings, "upstream returned invalid JSON") + if fallback_response is not None: + return fallback_response + return _anthropic_error_response(502, "Upstream returned invalid JSON", "api_error") + + converted = convert_openai_models_to_anthropic(upstream_json) + return JSONResponse(converted) + + @app.post("/v1/messages") + async def messages(request: Request) -> Response: + try: + inbound_token = extract_inbound_token(request.headers) + except AuthError as exc: + return _anthropic_error_response(401, str(exc), "authentication_error") + + try: + payload = await request.json() + except Exception: + return _anthropic_error_response(400, "Request body must be valid JSON", "invalid_request_error") + + if not isinstance(payload, dict): + return _anthropic_error_response(400, "Request body must be a JSON object", "invalid_request_error") + + settings = request.app.state.settings + + try: + converted = convert_anthropic_request_to_openai(payload, settings) + except ConversionError as exc: + return _anthropic_error_response(400, str(exc), "invalid_request_error") + + logger.info( + "/v1/messages model=%s mapped_model=%s stream=%s token=%s", + converted.original_model, + converted.mapped_model, + converted.stream, + mask_token(inbound_token), + ) + + url = build_openai_url(settings.openai_base_url, "chat/completions") + headers = _build_upstream_headers(settings, inbound_token) + + if converted.stream: + return await _handle_streaming_messages(request, converted, url, headers) + + async with _new_async_client(request.app) as client: + try: + upstream = await client.post( + url, + headers=headers, + json=converted.openai_payload, + ) + except httpx.HTTPError as exc: + logger.exception("Upstream non-streaming request failed") + return _anthropic_error_response(502, f"Upstream request failed: {exc}", "api_error") + + if upstream.status_code >= 400: + message = extract_upstream_error_message(upstream.text) + return _anthropic_error_response(upstream.status_code, message, "api_error") + + try: + upstream_json = upstream.json() + except json.JSONDecodeError: + return _anthropic_error_response(502, "Upstream returned invalid JSON", "api_error") + + anthropic_response = convert_openai_response_to_anthropic( + upstream_json, + model_name=converted.original_model, + ) + return JSONResponse(anthropic_response) + + return app + + +async def _handle_streaming_messages( + request: Request, + converted: Any, + url: str, + headers: dict[str, str], +) -> Response: + client = _new_async_client(request.app) + req = client.build_request( + "POST", + url, + headers=headers, + json=converted.openai_payload, + ) + + try: + upstream = await client.send(req, stream=True) + except httpx.HTTPError as exc: + await client.aclose() + logger.exception("Upstream streaming request failed before response") + return _anthropic_error_response(502, f"Upstream request failed: {exc}", "api_error") + + if upstream.status_code >= 400: + body = await upstream.aread() + await upstream.aclose() + await client.aclose() + message = extract_upstream_error_message(body.decode("utf-8", errors="ignore")) + return _anthropic_error_response(upstream.status_code, message, "api_error") + + converter = OpenAIToAnthropicStreamConverter(model_name=converted.original_model) + + async def event_generator(): + try: + async for line in upstream.aiter_lines(): + events = converter.consume_sse_line(line) + for event in events: + yield event + + for event in converter.finalize(): + yield event + except Exception: + logger.exception("Failed while streaming upstream response") + for event in converter.finalize("stop"): + yield event + finally: + await upstream.aclose() + await client.aclose() + + return StreamingResponse(event_generator(), media_type="text/event-stream") + + +def _new_async_client(app: FastAPI) -> httpx.AsyncClient: + settings: ProxySettings = app.state.settings + upstream_transport: httpx.BaseTransport | None = app.state.upstream_transport + timeout = httpx.Timeout(settings.upstream_timeout_seconds) + return httpx.AsyncClient(timeout=timeout, transport=upstream_transport) + + +def _build_upstream_headers(settings: ProxySettings, inbound_token: str) -> dict[str, str]: + headers = { + "Authorization": f"Bearer {inbound_token}", + "Content-Type": "application/json", + } + + for key, value in settings.upstream_extra_headers.items(): + if key.lower() in {"authorization", "content-type"}: + continue + headers[key] = value + + return headers + + +def _anthropic_error_response(status_code: int, message: str, error_type: str) -> JSONResponse: + return JSONResponse( + anthropic_error_payload(message=message, error_type=error_type), + status_code=status_code, + ) + + +def _fallback_models_response(settings: ProxySettings, reason: str) -> JSONResponse | None: + if not settings.fallback_models: + return None + + logger.warning("Using FALLBACK_MODELS_JSON because %s", reason) + return JSONResponse( + { + "data": settings.fallback_models, + "has_more": False, + }, + status_code=200, + ) + + +def _configure_logging(log_level: str) -> None: + root = logging.getLogger() + if not root.handlers: + logging.basicConfig( + level=getattr(logging, log_level, logging.INFO), + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + else: + root.setLevel(getattr(logging, log_level, logging.INFO)) + + +if __name__ == "__main__": + try: + import uvicorn + + uvicorn.run( + "proxy.app:create_app", + factory=True, + host="0.0.0.0", + port=8080, + reload=False, + ) + except SettingsError as exc: + raise SystemExit(f"Configuration error: {exc}") diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/auth.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/auth.py new file mode 100644 index 00000000..18104cf4 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/auth.py @@ -0,0 +1,38 @@ +"""Authentication helpers for Anthropic-compatible inbound requests.""" + +from __future__ import annotations + +from typing import Mapping + + +class AuthError(ValueError): + """Raised when inbound auth headers are missing or invalid.""" + + +def extract_inbound_token(headers: Mapping[str, str]) -> str: + """Extract token from Anthropic-style headers. + + Priority: + 1) x-api-key + 2) Authorization: Bearer + """ + x_api_key = headers.get("x-api-key") + if x_api_key and x_api_key.strip(): + return x_api_key.strip() + + authorization = headers.get("authorization") + if authorization and authorization.lower().startswith("bearer "): + token = authorization[7:].strip() + if token: + return token + + raise AuthError("Missing authentication. Provide x-api-key or Authorization: Bearer ") + + +def mask_token(token: str) -> str: + """Mask token for safe logs.""" + if not token: + return "" + if len(token) <= 8: + return "*" * len(token) + return f"{token[:4]}...{token[-4:]}" diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/config.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/config.py new file mode 100644 index 00000000..a4915133 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/config.py @@ -0,0 +1,176 @@ +"""Configuration handling for the OpenAI -> Anthropic proxy.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from typing import Any + + +class SettingsError(ValueError): + """Raised when proxy settings are invalid.""" + + +@dataclass(frozen=True) +class ProxySettings: + """Runtime settings for the proxy service.""" + + openai_base_url: str + model_map: dict[str, str] = field(default_factory=dict) + fallback_models: list[dict[str, Any]] = field(default_factory=list) + default_max_tokens: int = 4096 + upstream_timeout_seconds: float = 90.0 + upstream_extra_headers: dict[str, str] = field(default_factory=dict) + log_level: str = "INFO" + + @classmethod + def from_env(cls) -> "ProxySettings": + """Create settings from environment variables.""" + openai_base_url = os.getenv("OPENAI_BASE_URL", "").strip() + if not openai_base_url: + raise SettingsError("OPENAI_BASE_URL is required") + + model_map = _parse_json_mapping( + os.getenv("MODEL_MAP_JSON", ""), + "MODEL_MAP_JSON", + value_type=str, + ) + fallback_models = _parse_fallback_models(os.getenv("FALLBACK_MODELS_JSON", "")) + upstream_extra_headers = _parse_json_mapping( + os.getenv("UPSTREAM_EXTRA_HEADERS_JSON", ""), + "UPSTREAM_EXTRA_HEADERS_JSON", + value_type=str, + ) + + default_max_tokens = _parse_int(os.getenv("DEFAULT_MAX_TOKENS"), 4096, "DEFAULT_MAX_TOKENS") + upstream_timeout_seconds = _parse_float( + os.getenv("UPSTREAM_TIMEOUT_SECONDS"), + 90.0, + "UPSTREAM_TIMEOUT_SECONDS", + ) + log_level = os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO" + + return cls( + openai_base_url=openai_base_url, + model_map=model_map, + fallback_models=fallback_models, + default_max_tokens=default_max_tokens, + upstream_timeout_seconds=upstream_timeout_seconds, + upstream_extra_headers=upstream_extra_headers, + log_level=log_level, + ) + + +def _parse_json_mapping( + raw: str, + env_name: str, + *, + value_type: type, +) -> dict[str, Any]: + """Parse a JSON object env var into a string-key mapping.""" + if not raw: + return {} + + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise SettingsError(f"{env_name} must be valid JSON object") from exc + + if not isinstance(parsed, dict): + raise SettingsError(f"{env_name} must be a JSON object") + + result: dict[str, Any] = {} + for key, value in parsed.items(): + if not isinstance(key, str): + raise SettingsError(f"{env_name} keys must be strings") + if not isinstance(value, value_type): + typename = value_type.__name__ + raise SettingsError(f"{env_name} values must be {typename}") + result[key] = value + + return result + + +def _parse_fallback_models(raw: str) -> list[dict[str, Any]]: + """Parse FALLBACK_MODELS_JSON into normalized model objects.""" + if not raw: + return [] + + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise SettingsError("FALLBACK_MODELS_JSON must be valid JSON array") from exc + + if not isinstance(parsed, list): + raise SettingsError("FALLBACK_MODELS_JSON must be a JSON array") + + normalized: list[dict[str, Any]] = [] + for idx, item in enumerate(parsed): + if isinstance(item, str): + model_id = item.strip() + if not model_id: + raise SettingsError(f"FALLBACK_MODELS_JSON[{idx}] must be non-empty string") + normalized.append( + { + "id": model_id, + "type": "model", + "display_name": model_id, + } + ) + continue + + if not isinstance(item, dict): + raise SettingsError(f"FALLBACK_MODELS_JSON[{idx}] must be string or object") + + model_id = item.get("id") + if not isinstance(model_id, str) or not model_id.strip(): + raise SettingsError(f"FALLBACK_MODELS_JSON[{idx}].id must be a non-empty string") + model_id = model_id.strip() + + converted: dict[str, Any] = { + "id": model_id, + "type": "model", + "display_name": item.get("display_name", model_id), + } + + created_at = item.get("created_at") + if created_at is not None: + converted["created_at"] = created_at + + normalized.append(converted) + + return normalized + + +def _parse_int(raw: str | None, default: int, env_name: str) -> int: + if raw is None or raw.strip() == "": + return default + try: + value = int(raw) + except ValueError as exc: + raise SettingsError(f"{env_name} must be an integer") from exc + if value <= 0: + raise SettingsError(f"{env_name} must be positive") + return value + + +def _parse_float(raw: str | None, default: float, env_name: str) -> float: + if raw is None or raw.strip() == "": + return default + try: + value = float(raw) + except ValueError as exc: + raise SettingsError(f"{env_name} must be a number") from exc + if value <= 0: + raise SettingsError(f"{env_name} must be positive") + return value + + +def build_openai_url(base_url: str, endpoint: str) -> str: + """Build an OpenAI endpoint URL from base url and endpoint suffix.""" + normalized = base_url.rstrip("/") + suffix = endpoint.lstrip("/") + if normalized.endswith("/v1"): + return f"{normalized}/{suffix}" + return f"{normalized}/v1/{suffix}" diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/converters.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/converters.py new file mode 100644 index 00000000..097655b8 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/converters.py @@ -0,0 +1,533 @@ +"""Request/response conversion helpers for OpenAI <-> Anthropic compatibility.""" + +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass +from typing import Any + +from .config import ProxySettings + + +class ConversionError(ValueError): + """Raised when payload conversion fails.""" + + +@dataclass(frozen=True) +class RequestConversionResult: + """Converted OpenAI payload plus request metadata.""" + + openai_payload: dict[str, Any] + original_model: str + mapped_model: str + stream: bool + + +def convert_anthropic_request_to_openai( + payload: dict[str, Any], + settings: ProxySettings, +) -> RequestConversionResult: + """Convert Anthropic /v1/messages request payload into OpenAI chat/completions payload.""" + model = payload.get("model") + if not isinstance(model, str) or not model.strip(): + raise ConversionError("model is required and must be a non-empty string") + original_model = model.strip() + mapped_model = settings.model_map.get(original_model, original_model) + + messages = payload.get("messages") + if not isinstance(messages, list) or not messages: + raise ConversionError("messages is required and must be a non-empty array") + + openai_messages: list[dict[str, Any]] = [] + + system_text = _extract_system_text(payload.get("system")) + if system_text: + openai_messages.append({"role": "system", "content": system_text}) + + for msg in messages: + if not isinstance(msg, dict): + raise ConversionError("each message must be an object") + + role = msg.get("role") + if role == "user": + openai_messages.extend(_convert_user_message(msg.get("content"))) + elif role == "assistant": + openai_messages.append(_convert_assistant_message(msg.get("content"))) + elif role == "system": + # Tolerate system role inside messages by converting to system message. + system_inline = _content_to_text(msg.get("content")) + if system_inline: + openai_messages.append({"role": "system", "content": system_inline}) + else: + raise ConversionError(f"unsupported message role: {role!r}") + + max_tokens = payload.get("max_tokens", settings.default_max_tokens) + if not isinstance(max_tokens, int) or max_tokens <= 0: + raise ConversionError("max_tokens must be a positive integer") + + stream = bool(payload.get("stream", False)) + + openai_payload: dict[str, Any] = { + "model": mapped_model, + "messages": openai_messages, + "max_tokens": max_tokens, + "stream": stream, + } + + if "temperature" in payload: + openai_payload["temperature"] = payload["temperature"] + if "top_p" in payload: + openai_payload["top_p"] = payload["top_p"] + if "stop_sequences" in payload: + stop_sequences = payload["stop_sequences"] + if isinstance(stop_sequences, list): + openai_payload["stop"] = stop_sequences + elif isinstance(stop_sequences, str): + openai_payload["stop"] = [stop_sequences] + else: + raise ConversionError("stop_sequences must be a string or an array of strings") + + if "tools" in payload: + openai_payload["tools"] = _convert_tools(payload["tools"]) + + if "tool_choice" in payload: + openai_payload["tool_choice"] = _convert_tool_choice(payload["tool_choice"]) + + return RequestConversionResult( + openai_payload=openai_payload, + original_model=original_model, + mapped_model=mapped_model, + stream=stream, + ) + + +def convert_openai_response_to_anthropic( + response_data: dict[str, Any], + model_name: str, +) -> dict[str, Any]: + """Convert non-streaming OpenAI chat/completions response to Anthropic message format.""" + choice = _first_choice(response_data) + message = choice.get("message", {}) if isinstance(choice, dict) else {} + + content_blocks: list[dict[str, Any]] = [] + + content = message.get("content") + text = _openai_content_to_text(content) + if text: + content_blocks.append({"type": "text", "text": text}) + + for tool_call in message.get("tool_calls", []) or []: + if not isinstance(tool_call, dict): + continue + function_obj = tool_call.get("function", {}) + if not isinstance(function_obj, dict): + continue + tool_name = function_obj.get("name") or "unknown_tool" + arguments = _parse_json_arguments(function_obj.get("arguments")) + content_blocks.append( + { + "type": "tool_use", + "id": tool_call.get("id") or f"call_{uuid.uuid4().hex[:10]}", + "name": tool_name, + "input": arguments, + } + ) + + if not content_blocks: + content_blocks = [{"type": "text", "text": ""}] + + finish_reason = choice.get("finish_reason") if isinstance(choice, dict) else None + + usage = response_data.get("usage") or {} + if not isinstance(usage, dict): + usage = {} + + return { + "id": response_data.get("id") or f"msg_{uuid.uuid4().hex}", + "type": "message", + "role": "assistant", + "content": content_blocks, + "model": model_name, + "stop_reason": map_openai_finish_reason_to_anthropic(finish_reason), + "stop_sequence": None, + "usage": { + "input_tokens": int(usage.get("prompt_tokens") or 0), + "output_tokens": int(usage.get("completion_tokens") or 0), + }, + } + + +def convert_openai_models_to_anthropic(response_data: dict[str, Any]) -> dict[str, Any]: + """Convert OpenAI /v1/models payload to Anthropic-like model list.""" + models = response_data.get("data") if isinstance(response_data, dict) else None + if not isinstance(models, list): + models = [] + + converted: list[dict[str, Any]] = [] + for model in models: + if not isinstance(model, dict): + continue + model_id = model.get("id") + if not isinstance(model_id, str) or not model_id: + continue + item: dict[str, Any] = { + "id": model_id, + "type": "model", + "display_name": model_id, + } + created = model.get("created") + if created is not None: + item["created_at"] = created + converted.append(item) + + return {"data": converted, "has_more": False} + + +def map_openai_finish_reason_to_anthropic(finish_reason: Any) -> str: + """Map OpenAI finish_reason to Anthropic stop_reason.""" + mapping = { + "stop": "end_turn", + "length": "max_tokens", + "tool_calls": "tool_use", + "function_call": "tool_use", + } + if isinstance(finish_reason, str): + return mapping.get(finish_reason, "end_turn") + return "end_turn" + + +def anthropic_error_payload(message: str, error_type: str = "api_error") -> dict[str, Any]: + """Build Anthropic-style error payload.""" + return { + "type": "error", + "error": { + "type": error_type, + "message": message, + }, + } + + +def extract_upstream_error_message(body_text: str) -> str: + """Extract a useful error message from upstream payload text.""" + if not body_text: + return "Upstream request failed" + + try: + payload = json.loads(body_text) + except json.JSONDecodeError: + return body_text.strip() or "Upstream request failed" + + if isinstance(payload, dict): + error = payload.get("error") + if isinstance(error, dict): + message = error.get("message") + if isinstance(message, str) and message.strip(): + return message.strip() + message = payload.get("message") + if isinstance(message, str) and message.strip(): + return message.strip() + + return body_text.strip() or "Upstream request failed" + + +def _extract_system_text(system_field: Any) -> str | None: + if system_field is None: + return None + + if isinstance(system_field, str): + return system_field.strip() or None + + if isinstance(system_field, list): + parts: list[str] = [] + for block in system_field: + if not isinstance(block, dict): + continue + if block.get("type") == "text" and isinstance(block.get("text"), str): + text = block["text"].strip() + if text: + parts.append(text) + if parts: + return "\n\n".join(parts) + + return None + + +def _convert_user_message(content: Any) -> list[dict[str, Any]]: + if isinstance(content, str): + return [{"role": "user", "content": content}] + + if not isinstance(content, list): + raise ConversionError("user message content must be a string or array") + + converted: list[dict[str, Any]] = [] + pending_parts: list[dict[str, Any]] = [] + + def flush_user_parts() -> None: + if not pending_parts: + return + converted.append({"role": "user", "content": _normalize_openai_user_content(pending_parts.copy())}) + pending_parts.clear() + + for block in content: + if not isinstance(block, dict): + continue + + block_type = block.get("type") + if block_type == "text": + text = block.get("text") + if isinstance(text, str): + pending_parts.append({"type": "text", "text": text}) + elif block_type == "image": + image_url = _anthropic_image_to_openai_url(block) + pending_parts.append({"type": "image_url", "image_url": {"url": image_url}}) + elif block_type == "tool_result": + tool_use_id = block.get("tool_use_id") or block.get("id") + if not isinstance(tool_use_id, str) or not tool_use_id: + raise ConversionError("tool_result block requires tool_use_id") + flush_user_parts() + converted.append( + { + "role": "tool", + "tool_call_id": tool_use_id, + "content": _tool_result_to_text(block.get("content")), + } + ) + else: + # Graceful fallback for unknown blocks. + fallback = block.get("text") + if isinstance(fallback, str) and fallback: + pending_parts.append({"type": "text", "text": fallback}) + + flush_user_parts() + if not converted: + converted.append({"role": "user", "content": ""}) + return converted + + +def _convert_assistant_message(content: Any) -> dict[str, Any]: + if isinstance(content, str): + return {"role": "assistant", "content": content} + + if not isinstance(content, list): + raise ConversionError("assistant message content must be a string or array") + + text_parts: list[str] = [] + tool_calls: list[dict[str, Any]] = [] + + for block in content: + if not isinstance(block, dict): + continue + + block_type = block.get("type") + if block_type == "text": + text = block.get("text") + if isinstance(text, str): + text_parts.append(text) + elif block_type == "tool_use": + name = block.get("name") + if not isinstance(name, str) or not name: + raise ConversionError("tool_use block requires name") + tool_id = block.get("id") + if not isinstance(tool_id, str) or not tool_id: + tool_id = f"call_{uuid.uuid4().hex[:10]}" + input_payload = block.get("input") + if input_payload is None: + input_payload = {} + arguments = json.dumps(input_payload, ensure_ascii=False) + tool_calls.append( + { + "id": tool_id, + "type": "function", + "function": { + "name": name, + "arguments": arguments, + }, + } + ) + + message: dict[str, Any] = {"role": "assistant"} + if text_parts: + message["content"] = "\n".join(text_parts) + elif tool_calls: + message["content"] = None + else: + message["content"] = "" + + if tool_calls: + message["tool_calls"] = tool_calls + + return message + + +def _normalize_openai_user_content(parts: list[dict[str, Any]]) -> Any: + if not parts: + return "" + + if all(part.get("type") == "text" for part in parts): + return "\n".join(str(part.get("text", "")) for part in parts) + + return parts + + +def _anthropic_image_to_openai_url(block: dict[str, Any]) -> str: + source = block.get("source") + if not isinstance(source, dict): + raise ConversionError("image block requires source object") + + source_type = source.get("type") + if source_type == "url": + url = source.get("url") + if not isinstance(url, str) or not url: + raise ConversionError("image source url must be a non-empty string") + return url + + if source_type == "base64": + media_type = source.get("media_type") + data = source.get("data") + if not isinstance(media_type, str) or not media_type: + raise ConversionError("base64 image source requires media_type") + if not isinstance(data, str) or not data: + raise ConversionError("base64 image source requires data") + return f"data:{media_type};base64,{data}" + + raise ConversionError(f"unsupported image source type: {source_type!r}") + + +def _tool_result_to_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + segments: list[str] = [] + for item in content: + if isinstance(item, str): + segments.append(item) + elif isinstance(item, dict) and item.get("type") == "text": + text = item.get("text") + if isinstance(text, str): + segments.append(text) + else: + segments.append(json.dumps(item, ensure_ascii=False)) + return "\n".join(segments) + if isinstance(content, dict): + return json.dumps(content, ensure_ascii=False) + return str(content) + + +def _convert_tools(tools: Any) -> list[dict[str, Any]]: + if not isinstance(tools, list): + raise ConversionError("tools must be an array") + + converted: list[dict[str, Any]] = [] + for tool in tools: + if not isinstance(tool, dict): + raise ConversionError("each tool must be an object") + name = tool.get("name") + if not isinstance(name, str) or not name: + raise ConversionError("tool.name is required") + description = tool.get("description", "") + if not isinstance(description, str): + description = str(description) + input_schema = tool.get("input_schema", {}) + if not isinstance(input_schema, dict): + raise ConversionError("tool.input_schema must be an object") + + converted.append( + { + "type": "function", + "function": { + "name": name, + "description": description, + "parameters": input_schema, + }, + } + ) + + return converted + + +def _convert_tool_choice(tool_choice: Any) -> Any: + if isinstance(tool_choice, str): + return _map_tool_choice_type(tool_choice) + + if isinstance(tool_choice, dict): + choice_type = tool_choice.get("type") + if choice_type == "tool": + name = tool_choice.get("name") + if not isinstance(name, str) or not name: + raise ConversionError("tool_choice.type='tool' requires name") + return { + "type": "function", + "function": {"name": name}, + } + if isinstance(choice_type, str): + return _map_tool_choice_type(choice_type) + + raise ConversionError("unsupported tool_choice format") + + +def _map_tool_choice_type(choice_type: str) -> str: + normalized = choice_type.strip().lower() + mapping = { + "auto": "auto", + "none": "none", + "any": "required", + "required": "required", + } + if normalized in mapping: + return mapping[normalized] + raise ConversionError(f"unsupported tool_choice type: {choice_type}") + + +def _openai_content_to_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + text_parts: list[str] = [] + for part in content: + if isinstance(part, dict): + if part.get("type") == "text" and isinstance(part.get("text"), str): + text_parts.append(part["text"]) + elif isinstance(part.get("text"), str): + text_parts.append(part["text"]) + elif isinstance(part, str): + text_parts.append(part) + return "\n".join(text_parts) + return "" + + +def _content_to_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + chunks: list[str] = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str): + chunks.append(part["text"]) + return "\n".join(chunks) + return "" + + +def _parse_json_arguments(arguments: Any) -> Any: + if isinstance(arguments, dict): + return arguments + if isinstance(arguments, list): + return arguments + if isinstance(arguments, str): + stripped = arguments.strip() + if not stripped: + return {} + try: + return json.loads(stripped) + except json.JSONDecodeError: + return {"raw": stripped} + if arguments is None: + return {} + return {"raw": str(arguments)} + + +def _first_choice(data: dict[str, Any]) -> dict[str, Any]: + choices = data.get("choices") if isinstance(data, dict) else None + if isinstance(choices, list) and choices and isinstance(choices[0], dict): + return choices[0] + return {} diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/requirements.txt b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/requirements.txt new file mode 100644 index 00000000..b1facdb1 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.115.0,<1.0.0 +httpx>=0.27.0,<1.0.0 +pydantic>=2.7.0,<3.0.0 +uvicorn>=0.30.0,<1.0.0 +pytest>=8.0.0,<9.0.0 +pytest-asyncio>=0.23.0,<1.0.0 diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/streaming.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/streaming.py new file mode 100644 index 00000000..cc107205 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/streaming.py @@ -0,0 +1,286 @@ +"""Streaming conversion from OpenAI SSE chunks to Anthropic SSE events.""" + +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass +from typing import Any + +from .converters import map_openai_finish_reason_to_anthropic + + +@dataclass +class _ToolCallState: + index: int + block_index: int + tool_call_id: str + name: str + arguments_buffer: str = "" + closed: bool = False + + +class OpenAIToAnthropicStreamConverter: + """Convert OpenAI streaming chunks into Anthropic SSE event sequence.""" + + def __init__(self, model_name: str): + self.model_name = model_name + self.message_id = f"msg_{uuid.uuid4().hex}" + + self.started = False + self.finished = False + + self.next_block_index = 0 + self.text_block_index: int | None = None + self.text_block_closed = False + + self.tool_states: dict[int, _ToolCallState] = {} + self.usage: dict[str, int] = { + "input_tokens": 0, + "output_tokens": 0, + } + + def consume_sse_line(self, line: str) -> list[str]: + """Consume one upstream SSE line and return zero or more Anthropic SSE events.""" + if not line.startswith("data: "): + return [] + + data_payload = line[6:].strip() + if not data_payload: + return [] + + if data_payload == "[DONE]": + return self.finalize() + + try: + parsed = json.loads(data_payload) + except json.JSONDecodeError: + return [] + + if not isinstance(parsed, dict): + return [] + + return self.consume_chunk(parsed) + + def consume_chunk(self, chunk: dict[str, Any]) -> list[str]: + """Consume one parsed OpenAI chunk and return Anthropic SSE events.""" + if self.finished: + return [] + + events: list[str] = [] + + choice = self._first_choice(chunk) + if choice is None: + self._remember_usage(chunk) + return events + + delta = choice.get("delta") + if not isinstance(delta, dict): + delta = {} + + finish_reason = choice.get("finish_reason") + + self._remember_usage(chunk) + + has_meaningful_data = bool(delta) or finish_reason is not None + if has_meaningful_data and not self.started: + events.append(self._event("message_start", self._message_start_payload())) + self.started = True + + content = delta.get("content") + if isinstance(content, str) and content: + if self.text_block_index is None: + self.text_block_index = self.next_block_index + self.next_block_index += 1 + events.append( + self._event( + "content_block_start", + { + "type": "content_block_start", + "index": self.text_block_index, + "content_block": {"type": "text", "text": ""}, + }, + ) + ) + events.append( + self._event( + "content_block_delta", + { + "type": "content_block_delta", + "index": self.text_block_index, + "delta": {"type": "text_delta", "text": content}, + }, + ) + ) + + tool_calls = delta.get("tool_calls") + if isinstance(tool_calls, list): + for tool_call in tool_calls: + if not isinstance(tool_call, dict): + continue + events.extend(self._consume_tool_call_delta(tool_call)) + + if finish_reason is not None: + events.extend(self.finalize(finish_reason)) + + return events + + def finalize(self, finish_reason: Any = "stop") -> list[str]: + """Finalize stream and emit remaining Anthropic events once.""" + if self.finished: + return [] + + events: list[str] = [] + + if not self.started: + events.append(self._event("message_start", self._message_start_payload())) + self.started = True + + if self.text_block_index is not None and not self.text_block_closed: + events.append( + self._event( + "content_block_stop", + {"type": "content_block_stop", "index": self.text_block_index}, + ) + ) + self.text_block_closed = True + + for state in sorted(self.tool_states.values(), key=lambda item: item.block_index): + if state.closed: + continue + events.append( + self._event( + "content_block_stop", + {"type": "content_block_stop", "index": state.block_index}, + ) + ) + state.closed = True + + events.append( + self._event( + "message_delta", + { + "type": "message_delta", + "delta": { + "stop_reason": map_openai_finish_reason_to_anthropic(finish_reason), + "stop_sequence": None, + }, + "usage": self.usage, + }, + ) + ) + events.append(self._event("message_stop", {"type": "message_stop"})) + + self.finished = True + return events + + def _consume_tool_call_delta(self, tool_call: dict[str, Any]) -> list[str]: + events: list[str] = [] + + index_raw = tool_call.get("index", 0) + index = int(index_raw) if isinstance(index_raw, (int, float)) else 0 + + function_obj = tool_call.get("function") + if not isinstance(function_obj, dict): + function_obj = {} + + state = self.tool_states.get(index) + if state is None: + tool_call_id = tool_call.get("id") + if not isinstance(tool_call_id, str) or not tool_call_id: + tool_call_id = f"call_{uuid.uuid4().hex[:10]}" + + name = function_obj.get("name") + if not isinstance(name, str) or not name: + name = f"tool_{index}" + + block_index = self.next_block_index + self.next_block_index += 1 + state = _ToolCallState( + index=index, + block_index=block_index, + tool_call_id=tool_call_id, + name=name, + ) + self.tool_states[index] = state + + events.append( + self._event( + "content_block_start", + { + "type": "content_block_start", + "index": block_index, + "content_block": { + "type": "tool_use", + "id": tool_call_id, + "name": name, + "input": {}, + }, + }, + ) + ) + else: + if not state.name.startswith("tool_"): + pass + else: + maybe_name = function_obj.get("name") + if isinstance(maybe_name, str) and maybe_name: + state.name = maybe_name + + maybe_id = tool_call.get("id") + if isinstance(maybe_id, str) and maybe_id: + state.tool_call_id = maybe_id + + arguments_delta = function_obj.get("arguments") + if isinstance(arguments_delta, str) and arguments_delta: + state.arguments_buffer += arguments_delta + events.append( + self._event( + "content_block_delta", + { + "type": "content_block_delta", + "index": state.block_index, + "delta": { + "type": "input_json_delta", + "partial_json": arguments_delta, + }, + }, + ) + ) + + return events + + def _message_start_payload(self) -> dict[str, Any]: + return { + "type": "message_start", + "message": { + "id": self.message_id, + "type": "message", + "role": "assistant", + "content": [], + "model": self.model_name, + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 0, "output_tokens": 0}, + }, + } + + def _remember_usage(self, chunk: dict[str, Any]) -> None: + usage = chunk.get("usage") + if not isinstance(usage, dict): + return + self.usage = { + "input_tokens": int(usage.get("prompt_tokens") or self.usage["input_tokens"] or 0), + "output_tokens": int(usage.get("completion_tokens") or self.usage["output_tokens"] or 0), + } + + @staticmethod + def _first_choice(chunk: dict[str, Any]) -> dict[str, Any] | None: + choices = chunk.get("choices") + if isinstance(choices, list) and choices and isinstance(choices[0], dict): + return choices[0] + return None + + @staticmethod + def _event(event_name: str, data: dict[str, Any]) -> str: + return f"event: {event_name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/tests/test_api.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/tests/test_api.py new file mode 100644 index 00000000..7bb20a18 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/tests/test_api.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +import httpx +from fastapi.testclient import TestClient + +from proxy.app import create_app +from proxy.config import ProxySettings + + +def _make_app_with_handler( + handler: Callable[[httpx.Request], httpx.Response], + *, + settings_overrides: dict[str, Any] | None = None, +): + settings_overrides = settings_overrides or {} + settings_kwargs = { + "openai_base_url": "https://mock.openai", + "model_map": {"claude-sonnet-4-6": "Kimi-K2.5"}, + "fallback_models": [], + "default_max_tokens": 4096, + "upstream_timeout_seconds": 10, + "log_level": "DEBUG", + } + settings_kwargs.update(settings_overrides) + settings = ProxySettings( + **settings_kwargs, + ) + transport = httpx.MockTransport(handler) + return create_app(settings=settings, upstream_transport=transport) + + +def test_messages_non_streaming_end_to_end() -> None: + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v1/chat/completions" + captured["authorization"] = request.headers.get("authorization") + payload = json.loads(request.content.decode("utf-8")) + captured["payload"] = payload + return httpx.Response( + status_code=200, + json={ + "id": "chatcmpl-1", + "choices": [ + { + "finish_reason": "stop", + "message": {"content": "pong"}, + } + ], + "usage": {"prompt_tokens": 4, "completion_tokens": 2}, + }, + ) + + app = _make_app_with_handler(handler) + client = TestClient(app) + + response = client.post( + "/v1/messages", + headers={"x-api-key": "sk-test"}, + json={ + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "Reply pong"}], + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["type"] == "message" + assert data["stop_reason"] == "end_turn" + assert data["usage"] == {"input_tokens": 4, "output_tokens": 2} + assert captured["authorization"] == "Bearer sk-test" + assert captured["payload"]["model"] == "Kimi-K2.5" + + +def test_messages_streaming_end_to_end() -> None: + stream_body = "".join( + [ + 'data: {"choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n', + 'data: {"choices":[{"delta":{"content":"pong"},"finish_reason":null}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n', + "data: [DONE]\n\n", + ] + ) + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v1/chat/completions" + return httpx.Response( + status_code=200, + headers={"content-type": "text/event-stream"}, + text=stream_body, + ) + + app = _make_app_with_handler(handler) + client = TestClient(app) + + with client.stream( + "POST", + "/v1/messages", + headers={"x-api-key": "sk-stream"}, + json={ + "model": "claude-sonnet-4-6", + "stream": True, + "messages": [{"role": "user", "content": "Reply pong"}], + }, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert "event: message_start" in body + assert "event: message_delta" in body + assert "event: message_stop" in body + assert '"stop_reason": "end_turn"' in body + + +def test_models_endpoint_converts_and_uses_bearer_token() -> None: + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v1/models" + captured["authorization"] = request.headers.get("authorization") + return httpx.Response( + status_code=200, + json={ + "data": [ + {"id": "Kimi-K2.5", "object": "model", "created": 1700000000}, + {"id": "Kimi-Vision", "object": "model", "created": 1700000010}, + ] + }, + ) + + app = _make_app_with_handler(handler) + client = TestClient(app) + + response = client.get("/v1/models", headers={"authorization": "Bearer sk-models"}) + + assert response.status_code == 200 + data = response.json() + assert len(data["data"]) == 2 + assert data["data"][0]["id"] == "Kimi-K2.5" + assert data["data"][0]["display_name"] == "Kimi-K2.5" + assert captured["authorization"] == "Bearer sk-models" + + +def test_upstream_error_is_mapped_to_anthropic_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(status_code=401, json={"error": {"message": "invalid key"}}) + + app = _make_app_with_handler(handler) + client = TestClient(app) + + response = client.post( + "/v1/messages", + headers={"x-api-key": "sk-bad"}, + json={ + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "Hello"}], + }, + ) + + assert response.status_code == 401 + payload = response.json() + assert payload["type"] == "error" + assert payload["error"]["type"] == "api_error" + assert payload["error"]["message"] == "invalid key" + + +def test_models_endpoint_uses_fallback_models_on_upstream_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(status_code=400, json={"error": {"message": "参数解析失败"}}) + + fallback_models = [ + {"id": "Kimi-K2.5", "type": "model", "display_name": "Kimi-K2.5"}, + {"id": "Kimi-Vision", "type": "model", "display_name": "Kimi-Vision"}, + ] + + app = _make_app_with_handler( + handler, + settings_overrides={"fallback_models": fallback_models}, + ) + client = TestClient(app) + + response = client.get("/v1/models", headers={"x-api-key": "sk-fallback"}) + + assert response.status_code == 200 + payload = response.json() + assert payload["has_more"] is False + assert payload["data"] == fallback_models diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/tests/test_config.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/tests/test_config.py new file mode 100644 index 00000000..1e1cd233 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/tests/test_config.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from proxy.config import ProxySettings, SettingsError + + +def test_fallback_models_json_parses_string_and_object(monkeypatch) -> None: + monkeypatch.setenv("OPENAI_BASE_URL", "https://example.com") + monkeypatch.setenv( + "FALLBACK_MODELS_JSON", + '["Kimi-K2.5", {"id": "Kimi-Vision", "display_name": "Kimi Vision"}]', + ) + + settings = ProxySettings.from_env() + + assert settings.fallback_models == [ + {"id": "Kimi-K2.5", "type": "model", "display_name": "Kimi-K2.5"}, + {"id": "Kimi-Vision", "type": "model", "display_name": "Kimi Vision"}, + ] + + +def test_fallback_models_json_requires_array(monkeypatch) -> None: + monkeypatch.setenv("OPENAI_BASE_URL", "https://example.com") + monkeypatch.setenv("FALLBACK_MODELS_JSON", '{"id": "Kimi-K2.5"}') + + try: + ProxySettings.from_env() + except SettingsError as exc: + assert "FALLBACK_MODELS_JSON must be a JSON array" in str(exc) + else: + raise AssertionError("Expected SettingsError") diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/tests/test_converters.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/tests/test_converters.py new file mode 100644 index 00000000..564a673d --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/tests/test_converters.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from proxy.config import ProxySettings +from proxy.converters import ( + convert_anthropic_request_to_openai, + convert_openai_response_to_anthropic, +) + + +def test_convert_anthropic_request_to_openai_with_image_tools_and_mapping() -> None: + settings = ProxySettings( + openai_base_url="https://example.com", + model_map={"claude-sonnet-4-6": "Kimi-K2.5"}, + default_max_tokens=4096, + ) + + payload = { + "model": "claude-sonnet-4-6", + "system": "You are a helper", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is this image?"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "ZmFrZQ==", + }, + }, + ], + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_1", + "name": "describe_image", + "input": {"detail": "high"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_1", + "content": "an image of a cat", + } + ], + }, + ], + "tools": [ + { + "name": "describe_image", + "description": "Describe image", + "input_schema": {"type": "object", "properties": {"detail": {"type": "string"}}}, + } + ], + "tool_choice": {"type": "tool", "name": "describe_image"}, + "stop_sequences": [""], + } + + converted = convert_anthropic_request_to_openai(payload, settings) + + assert converted.original_model == "claude-sonnet-4-6" + assert converted.mapped_model == "Kimi-K2.5" + + openai_payload = converted.openai_payload + assert openai_payload["model"] == "Kimi-K2.5" + assert openai_payload["max_tokens"] == 4096 + assert openai_payload["stop"] == [""] + assert openai_payload["tool_choice"] == {"type": "function", "function": {"name": "describe_image"}} + + assert openai_payload["messages"][0] == {"role": "system", "content": "You are a helper"} + + user_msg = openai_payload["messages"][1] + assert user_msg["role"] == "user" + assert isinstance(user_msg["content"], list) + assert user_msg["content"][0]["type"] == "text" + assert user_msg["content"][1]["type"] == "image_url" + assert user_msg["content"][1]["image_url"]["url"].startswith("data:image/png;base64,") + + assistant_msg = openai_payload["messages"][2] + assert assistant_msg["role"] == "assistant" + assert assistant_msg["tool_calls"][0]["function"]["name"] == "describe_image" + + tool_msg = openai_payload["messages"][3] + assert tool_msg == {"role": "tool", "tool_call_id": "call_1", "content": "an image of a cat"} + + +def test_convert_openai_response_to_anthropic_with_tool_calls() -> None: + openai_response = { + "id": "chatcmpl-test", + "choices": [ + { + "finish_reason": "tool_calls", + "message": { + "content": "Let me call a tool", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "weather", + "arguments": '{"city":"Beijing"}', + }, + } + ], + }, + } + ], + "usage": { + "prompt_tokens": 11, + "completion_tokens": 7, + }, + } + + converted = convert_openai_response_to_anthropic(openai_response, model_name="claude-sonnet-4-6") + + assert converted["id"] == "chatcmpl-test" + assert converted["type"] == "message" + assert converted["role"] == "assistant" + assert converted["model"] == "claude-sonnet-4-6" + assert converted["stop_reason"] == "tool_use" + assert converted["usage"] == {"input_tokens": 11, "output_tokens": 7} + + blocks = converted["content"] + assert blocks[0] == {"type": "text", "text": "Let me call a tool"} + assert blocks[1]["type"] == "tool_use" + assert blocks[1]["name"] == "weather" + assert blocks[1]["input"] == {"city": "Beijing"} diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/tests/test_streaming.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/tests/test_streaming.py new file mode 100644 index 00000000..b13d3371 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/proxy/tests/test_streaming.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json + +from proxy.streaming import OpenAIToAnthropicStreamConverter + + +def _parse_sse_event(raw: str) -> tuple[str, dict]: + lines = [line for line in raw.strip().splitlines() if line] + assert lines[0].startswith("event: ") + assert lines[1].startswith("data: ") + event_name = lines[0][7:] + data = json.loads(lines[1][6:]) + return event_name, data + + +def test_streaming_converter_text_and_tool_call_sequence() -> None: + converter = OpenAIToAnthropicStreamConverter(model_name="claude-sonnet-4-6") + + chunks = [ + {"choices": [{"delta": {"role": "assistant"}, "finish_reason": None}]}, + {"choices": [{"delta": {"content": "Hello "}, "finish_reason": None}]}, + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "function": {"name": "weather", "arguments": '{"city":"'}, + } + ] + }, + "finish_reason": None, + } + ] + }, + { + "choices": [ + { + "delta": { + "tool_calls": [ + {"index": 0, "function": {"arguments": 'Beijing"}'}} + ] + }, + "finish_reason": "tool_calls", + } + ], + "usage": {"prompt_tokens": 20, "completion_tokens": 9}, + }, + ] + + raw_events: list[str] = [] + for chunk in chunks: + raw_events.extend(converter.consume_chunk(chunk)) + + parsed = [_parse_sse_event(event) for event in raw_events] + event_names = [event for event, _ in parsed] + + assert "message_start" in event_names + assert "content_block_start" in event_names + assert "content_block_delta" in event_names + assert "content_block_stop" in event_names + assert "message_delta" in event_names + assert "message_stop" in event_names + + message_delta_payload = [payload for name, payload in parsed if name == "message_delta"][0] + assert message_delta_payload["delta"]["stop_reason"] == "tool_use" + assert message_delta_payload["usage"] == {"input_tokens": 20, "output_tokens": 9} + + tool_delta_payloads = [ + payload + for name, payload in parsed + if name == "content_block_delta" and payload["delta"]["type"] == "input_json_delta" + ] + assert len(tool_delta_payloads) == 2 + assert tool_delta_payloads[0]["delta"]["partial_json"] == '{"city":"' + assert tool_delta_payloads[1]["delta"]["partial_json"] == 'Beijing"}' + + +def test_streaming_converter_done_line_finalizes_once() -> None: + converter = OpenAIToAnthropicStreamConverter(model_name="claude-sonnet-4-6") + + events = converter.consume_sse_line('data: {"choices":[{"delta":{"content":"pong"},"finish_reason":null}]}') + assert events + + done_events = converter.consume_sse_line("data: [DONE]") + assert done_events + + second_done = converter.consume_sse_line("data: [DONE]") + assert second_done == [] diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/pyproject.toml b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/pyproject.toml new file mode 100644 index 00000000..aef91498 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/pyproject.toml @@ -0,0 +1,109 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "claude-agent-sdk" +version = "0.1.43" +description = "Python SDK for Claude Code" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [ + {name = "Anthropic", email = "support@anthropic.com"}, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +keywords = ["claude", "ai", "sdk", "anthropic"] +dependencies = [ + "anyio>=4.0.0", + "typing_extensions>=4.0.0; python_version<'3.11'", + "mcp>=0.1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.20.0", + "anyio[trio]>=4.0.0", + "pytest-cov>=4.0.0", + "mypy>=1.0.0", + "ruff>=0.1.0", +] + +[project.urls] +Homepage = "https://github.com/anthropics/claude-agent-sdk-python" +Documentation = "https://docs.anthropic.com/en/docs/claude-code/sdk" +Issues = "https://github.com/anthropics/claude-agent-sdk-python/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/claude_agent_sdk"] +only-include = ["src/claude_agent_sdk"] + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", + "/README.md", + "/LICENSE", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +addopts = [ + "--import-mode=importlib", + "-p", "asyncio", +] + +[tool.pytest-asyncio] +asyncio_mode = "auto" + +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +[tool.ruff] +target-version = "py310" +line-length = 88 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "PTH", # flake8-use-pathlib + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.lint.isort] +known-first-party = ["claude_agent_sdk"] \ No newline at end of file diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/build_wheel.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/build_wheel.py new file mode 100755 index 00000000..ec6799af --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/build_wheel.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Build wheel with bundled Claude Code CLI. + +This script handles the complete wheel building process: +1. Optionally updates version +2. Downloads Claude Code CLI +3. Builds the wheel +4. Optionally cleans up the bundled CLI + +Usage: + python scripts/build_wheel.py # Build with current version + python scripts/build_wheel.py --version 0.1.4 # Build with specific version + python scripts/build_wheel.py --clean # Clean bundled CLI after build + python scripts/build_wheel.py --skip-download # Skip CLI download (use existing) +""" + +import argparse +import os +import platform +import re +import shutil +import subprocess +import sys +from pathlib import Path + +try: + import twine # noqa: F401 + + HAS_TWINE = True +except ImportError: + HAS_TWINE = False + + +def run_command(cmd: list[str], description: str) -> None: + """Run a command and handle errors.""" + print(f"\n{'=' * 60}") + print(f"{description}") + print(f"{'=' * 60}") + print(f"$ {' '.join(cmd)}") + print() + + try: + result = subprocess.run( + cmd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + print(result.stdout) + except subprocess.CalledProcessError as e: + print(f"Error: {description} failed", file=sys.stderr) + print(e.stdout, file=sys.stderr) + sys.exit(1) + + +def update_version(version: str) -> None: + """Update package version.""" + script_dir = Path(__file__).parent + update_script = script_dir / "update_version.py" + + if not update_script.exists(): + print("Warning: update_version.py not found, skipping version update") + return + + run_command( + [sys.executable, str(update_script), version], + f"Updating version to {version}", + ) + + +def get_bundled_cli_version() -> str: + """Get the CLI version that should be bundled from _cli_version.py.""" + version_file = Path("src/claude_agent_sdk/_cli_version.py") + if not version_file.exists(): + return "latest" + + content = version_file.read_text() + match = re.search(r'__cli_version__ = "([^"]+)"', content) + if match: + return match.group(1) + return "latest" + + +def download_cli(cli_version: str | None = None) -> None: + """Download Claude Code CLI.""" + # Use provided version, or fall back to version from _cli_version.py + if cli_version is None: + cli_version = get_bundled_cli_version() + + script_dir = Path(__file__).parent + download_script = script_dir / "download_cli.py" + + # Set environment variable for download script + os.environ["CLAUDE_CLI_VERSION"] = cli_version + + run_command( + [sys.executable, str(download_script)], + f"Downloading Claude Code CLI ({cli_version})", + ) + + +def clean_dist() -> None: + """Clean dist directory.""" + dist_dir = Path("dist") + if dist_dir.exists(): + print(f"\n{'=' * 60}") + print("Cleaning dist directory") + print(f"{'=' * 60}") + shutil.rmtree(dist_dir) + print("Cleaned dist/") + + +def get_platform_tag() -> str: + """Get the appropriate platform tag for the current platform. + + Uses minimum compatible versions for broad compatibility: + - macOS: 11.0 (Big Sur) as minimum + - Linux: manylinux_2_17 (widely compatible) + - Windows: Standard tags + """ + system = platform.system() + machine = platform.machine().lower() + + if system == "Darwin": + # macOS - use minimum version 11.0 (Big Sur) for broad compatibility + if machine == "arm64": + return "macosx_11_0_arm64" + else: + return "macosx_11_0_x86_64" + elif system == "Linux": + # Linux - use manylinux for broad compatibility + if machine in ["x86_64", "amd64"]: + return "manylinux_2_17_x86_64" + elif machine in ["aarch64", "arm64"]: + return "manylinux_2_17_aarch64" + else: + return f"linux_{machine}" + elif system == "Windows": + # Windows + if machine in ["x86_64", "amd64"]: + return "win_amd64" + elif machine == "arm64": + return "win_arm64" + else: + return "win32" + else: + # Unknown platform, use generic + return f"{system.lower()}_{machine}" + + +def retag_wheel(wheel_path: Path, platform_tag: str) -> Path: + """Retag a wheel with the correct platform tag using wheel package.""" + print(f"\n{'=' * 60}") + print("Retagging wheel as platform-specific") + print(f"{'=' * 60}") + print(f"Old: {wheel_path.name}") + + # Use wheel package to properly retag (updates both filename and metadata) + result = subprocess.run( + [ + sys.executable, + "-m", + "wheel", + "tags", + "--platform-tag", + platform_tag, + "--remove", + str(wheel_path), + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print(f"Warning: Failed to retag wheel: {result.stderr}") + return wheel_path + + # Find the newly tagged wheel + dist_dir = wheel_path.parent + # The wheel package creates a new file with the platform tag + new_wheels = list(dist_dir.glob(f"*{platform_tag}.whl")) + + if new_wheels: + new_path = new_wheels[0] + print(f"New: {new_path.name}") + print("Wheel retagged successfully") + + # Remove the old wheel + if wheel_path.exists() and wheel_path != new_path: + wheel_path.unlink() + + return new_path + else: + print("Warning: Could not find retagged wheel") + return wheel_path + + +def build_wheel() -> None: + """Build the wheel.""" + run_command( + [sys.executable, "-m", "build", "--wheel"], + "Building wheel", + ) + + # Check if we have a bundled CLI - if so, retag the wheel as platform-specific + bundled_cli = Path("src/claude_agent_sdk/_bundled/claude") + bundled_cli_exe = Path("src/claude_agent_sdk/_bundled/claude.exe") + + if bundled_cli.exists() or bundled_cli_exe.exists(): + # Find the built wheel + dist_dir = Path("dist") + wheels = list(dist_dir.glob("*.whl")) + + if wheels: + # Get platform tag + platform_tag = get_platform_tag() + + # Retag each wheel (should only be one) + for wheel in wheels: + if "-any.whl" in wheel.name: + retag_wheel(wheel, platform_tag) + else: + print("Warning: No wheel found to retag") + else: + print("\nNo bundled CLI found - wheel will be platform-independent") + + +def build_sdist() -> None: + """Build the source distribution.""" + run_command( + [sys.executable, "-m", "build", "--sdist"], + "Building source distribution", + ) + + +def check_package() -> None: + """Check package with twine.""" + if not HAS_TWINE: + print("\nWarning: twine not installed, skipping package check") + print("Install with: pip install twine") + return + + print(f"\n{'=' * 60}") + print("Checking package with twine") + print(f"{'=' * 60}") + print(f"$ {sys.executable} -m twine check dist/*") + print() + + try: + result = subprocess.run( + [sys.executable, "-m", "twine", "check", "dist/*"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + print(result.stdout) + + if result.returncode != 0: + print("\nWarning: twine check reported issues") + print("Note: 'License-File' warnings are false positives from twine 6.x") + print("PyPI will accept these packages without issues") + else: + print("Package check passed") + except Exception as e: + print(f"Warning: Failed to run twine check: {e}") + + +def clean_bundled_cli() -> None: + """Clean bundled CLI.""" + bundled_dir = Path("src/claude_agent_sdk/_bundled") + cli_files = list(bundled_dir.glob("claude*")) + + if cli_files: + print(f"\n{'=' * 60}") + print("Cleaning bundled CLI") + print(f"{'=' * 60}") + for cli_file in cli_files: + if cli_file.name != ".gitignore": + cli_file.unlink() + print(f"Removed {cli_file}") + else: + print("\nNo bundled CLI to clean") + + +def list_artifacts() -> None: + """List built artifacts.""" + dist_dir = Path("dist") + if not dist_dir.exists(): + return + + print(f"\n{'=' * 60}") + print("Built Artifacts") + print(f"{'=' * 60}") + + artifacts = sorted(dist_dir.iterdir()) + if not artifacts: + print("No artifacts found") + return + + for artifact in artifacts: + size_mb = artifact.stat().st_size / (1024 * 1024) + print(f" {artifact.name:<50} {size_mb:>8.2f} MB") + + total_size = sum(f.stat().st_size for f in artifacts) / (1024 * 1024) + print(f"\n {'Total:':<50} {total_size:>8.2f} MB") + + +def main() -> None: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Build wheel with bundled Claude Code CLI" + ) + parser.add_argument( + "--version", + help="Version to set before building (e.g., 0.1.4)", + ) + parser.add_argument( + "--cli-version", + default=None, + help="Claude Code CLI version to download (default: read from _cli_version.py)", + ) + parser.add_argument( + "--skip-download", + action="store_true", + help="Skip downloading CLI (use existing bundled CLI)", + ) + parser.add_argument( + "--skip-sdist", + action="store_true", + help="Skip building source distribution", + ) + parser.add_argument( + "--clean", + action="store_true", + help="Clean bundled CLI after building", + ) + parser.add_argument( + "--clean-dist", + action="store_true", + help="Clean dist directory before building", + ) + + args = parser.parse_args() + + print("\n" + "=" * 60) + print("Claude Agent SDK - Wheel Builder") + print("=" * 60) + + # Clean dist if requested + if args.clean_dist: + clean_dist() + + # Update version if specified + if args.version: + update_version(args.version) + + # Download CLI unless skipped + if not args.skip_download: + download_cli(args.cli_version) + else: + print("\nSkipping CLI download (using existing)") + + # Build wheel + build_wheel() + + # Build sdist unless skipped + if not args.skip_sdist: + build_sdist() + + # Check package + check_package() + + # Clean bundled CLI if requested + if args.clean: + clean_bundled_cli() + + # List artifacts + list_artifacts() + + print(f"\n{'=' * 60}") + print("Build complete!") + print(f"{'=' * 60}") + print("\nNext steps:") + print(" 1. Test the wheel: pip install dist/*.whl") + print(" 2. Run tests: python -m pytest tests/") + print(" 3. Publish: twine upload dist/*") + + +if __name__ == "__main__": + main() diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/download_cli.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/download_cli.py new file mode 100755 index 00000000..45d39dfc --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/download_cli.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Download Claude Code CLI binary for bundling in wheel. + +This script is run during the wheel build process to fetch the Claude Code CLI +binary using the official install script and place it in the package directory. +""" + +import os +import platform +import shutil +import subprocess +import sys +from pathlib import Path + + +def get_cli_version() -> str: + """Get the CLI version to download from environment or default.""" + return os.environ.get("CLAUDE_CLI_VERSION", "latest") + + +def find_installed_cli() -> Path | None: + """Find the installed Claude CLI binary.""" + system = platform.system() + + if system == "Windows": + # Windows installation locations (matches test.yml: $USERPROFILE\.local\bin) + locations = [ + Path.home() / ".local" / "bin" / "claude.exe", + Path(os.environ.get("LOCALAPPDATA", "")) / "Claude" / "claude.exe", + ] + else: + # Unix installation locations + locations = [ + Path.home() / ".local" / "bin" / "claude", + Path("/usr/local/bin/claude"), + Path.home() / "node_modules" / ".bin" / "claude", + ] + + # Also check PATH + cli_path = shutil.which("claude") + if cli_path: + return Path(cli_path) + + for path in locations: + if path.exists() and path.is_file(): + return path + + return None + + +def download_cli() -> None: + """Download Claude Code CLI using the official install script.""" + version = get_cli_version() + system = platform.system() + + print(f"Downloading Claude Code CLI version: {version}") + + # Build install command based on platform + if system == "Windows": + # Use PowerShell installer on Windows + if version == "latest": + install_cmd = [ + "powershell", + "-ExecutionPolicy", + "Bypass", + "-Command", + "irm https://claude.ai/install.ps1 | iex", + ] + else: + install_cmd = [ + "powershell", + "-ExecutionPolicy", + "Bypass", + "-Command", + f"& ([scriptblock]::Create((irm https://claude.ai/install.ps1))) {version}", + ] + else: + # Use bash installer on Unix-like systems + if version == "latest": + install_cmd = ["bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash"] + else: + install_cmd = [ + "bash", + "-c", + f"curl -fsSL https://claude.ai/install.sh | bash -s {version}", + ] + + try: + subprocess.run( + install_cmd, + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError as e: + print(f"Error downloading CLI: {e}", file=sys.stderr) + print(f"stdout: {e.stdout.decode()}", file=sys.stderr) + print(f"stderr: {e.stderr.decode()}", file=sys.stderr) + sys.exit(1) + + +def copy_cli_to_bundle() -> None: + """Copy the installed CLI to the package _bundled directory.""" + # Find project root (parent of scripts directory) + script_dir = Path(__file__).parent + project_root = script_dir.parent + bundle_dir = project_root / "src" / "claude_agent_sdk" / "_bundled" + + # Ensure bundle directory exists + bundle_dir.mkdir(parents=True, exist_ok=True) + + # Find installed CLI + cli_path = find_installed_cli() + if not cli_path: + print("Error: Could not find installed Claude CLI binary", file=sys.stderr) + sys.exit(1) + + print(f"Found CLI at: {cli_path}") + + # Determine target filename based on platform + system = platform.system() + target_name = "claude.exe" if system == "Windows" else "claude" + target_path = bundle_dir / target_name + + # Copy the binary + print(f"Copying CLI to: {target_path}") + shutil.copy2(cli_path, target_path) + + # Make it executable (Unix-like systems) + if system != "Windows": + target_path.chmod(0o755) + + print(f"Successfully bundled CLI binary: {target_path}") + + # Print size info + size_mb = target_path.stat().st_size / (1024 * 1024) + print(f"Binary size: {size_mb:.2f} MB") + + +def main() -> None: + """Main entry point.""" + print("=" * 60) + print("Claude Code CLI Download Script") + print("=" * 60) + + # Download CLI + download_cli() + + # Copy to bundle directory + copy_cli_to_bundle() + + print("=" * 60) + print("CLI download and bundling complete!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/initial-setup.sh b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/initial-setup.sh new file mode 100755 index 00000000..de6ff607 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/initial-setup.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Initial setup script for installing git hooks + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +echo "Setting up git hooks..." + +# Install pre-push hook +echo "→ Installing pre-push hook..." +cp "$SCRIPT_DIR/pre-push" "$REPO_ROOT/.git/hooks/pre-push" +chmod +x "$REPO_ROOT/.git/hooks/pre-push" +echo "✓ pre-push hook installed" + +echo "" +echo "✓ Setup complete!" +echo "" +echo "The pre-push hook will now run lint checks before each push." +echo "To skip the hook temporarily, use: git push --no-verify" diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/pre-push b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/pre-push new file mode 100755 index 00000000..f009b614 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/pre-push @@ -0,0 +1,30 @@ +#!/bin/bash + +# Pre-push hook to run lint checks (matches .github/workflows/lint.yml) + +echo "Running lint checks before push..." +echo "" + +# Run ruff check +echo "→ Running ruff check..." +python -m ruff check src/ tests/ +if [ $? -ne 0 ]; then + echo "" + echo "❌ ruff check failed. Fix lint issues before pushing." + echo " Run: python -m ruff check src/ tests/ --fix" + exit 1 +fi + +# Run ruff format check +echo "→ Running ruff format check..." +python -m ruff format --check src/ tests/ +if [ $? -ne 0 ]; then + echo "" + echo "❌ ruff format check failed. Fix formatting before pushing." + echo " Run: python -m ruff format src/ tests/" + exit 1 +fi + +echo "" +echo "✓ All lint checks passed!" +exit 0 diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/test-docker.sh b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/test-docker.sh new file mode 100755 index 00000000..2cf9889c --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/test-docker.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Run SDK tests in a Docker container +# This helps catch Docker-specific issues like #406 +# +# Usage: +# ./scripts/test-docker.sh [unit|e2e|all] +# +# Examples: +# ./scripts/test-docker.sh unit # Run unit tests only +# ANTHROPIC_API_KEY=sk-... ./scripts/test-docker.sh e2e # Run e2e tests +# ANTHROPIC_API_KEY=sk-... ./scripts/test-docker.sh all # Run all tests + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +usage() { + echo "Usage: $0 [unit|e2e|all]" + echo "" + echo "Commands:" + echo " unit - Run unit tests only (no API key needed)" + echo " e2e - Run e2e tests (requires ANTHROPIC_API_KEY)" + echo " all - Run both unit and e2e tests" + echo "" + echo "Examples:" + echo " $0 unit" + echo " ANTHROPIC_API_KEY=sk-... $0 e2e" + exit 1 +} + +echo "Building Docker test image..." +docker build -f Dockerfile.test -t claude-sdk-test . + +case "${1:-unit}" in + unit) + echo "" + echo "Running unit tests in Docker..." + docker run --rm claude-sdk-test \ + python -m pytest tests/ -v + ;; + e2e) + if [ -z "$ANTHROPIC_API_KEY" ]; then + echo "Error: ANTHROPIC_API_KEY environment variable is required for e2e tests" + echo "" + echo "Usage: ANTHROPIC_API_KEY=sk-... $0 e2e" + exit 1 + fi + echo "" + echo "Running e2e tests in Docker..." + docker run --rm -e ANTHROPIC_API_KEY \ + claude-sdk-test python -m pytest e2e-tests/ -v -m e2e + ;; + all) + echo "" + echo "Running unit tests in Docker..." + docker run --rm claude-sdk-test \ + python -m pytest tests/ -v + + echo "" + if [ -n "$ANTHROPIC_API_KEY" ]; then + echo "Running e2e tests in Docker..." + docker run --rm -e ANTHROPIC_API_KEY \ + claude-sdk-test python -m pytest e2e-tests/ -v -m e2e + else + echo "Skipping e2e tests (ANTHROPIC_API_KEY not set)" + fi + ;; + *) + usage + ;; +esac + +echo "" +echo "Done!" diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/update_cli_version.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/update_cli_version.py new file mode 100755 index 00000000..1ef17c7e --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/update_cli_version.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Update Claude Code CLI version in _cli_version.py.""" + +import re +import sys +from pathlib import Path + + +def update_cli_version(new_version: str) -> None: + """Update CLI version in _cli_version.py.""" + # Update _cli_version.py + version_path = Path("src/claude_agent_sdk/_cli_version.py") + content = version_path.read_text() + + content = re.sub( + r'__cli_version__ = "[^"]+"', + f'__cli_version__ = "{new_version}"', + content, + count=1, + flags=re.MULTILINE, + ) + + version_path.write_text(content) + print(f"Updated {version_path} to {new_version}") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python scripts/update_cli_version.py ") + sys.exit(1) + + update_cli_version(sys.argv[1]) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/update_version.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/update_version.py new file mode 100755 index 00000000..b980d522 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/scripts/update_version.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Update version in pyproject.toml and __init__.py files.""" + +import re +import sys +from pathlib import Path + + +def update_version(new_version: str) -> None: + """Update version in project files.""" + # Update pyproject.toml + pyproject_path = Path("pyproject.toml") + content = pyproject_path.read_text() + + # Only update the version field in [project] section + content = re.sub( + r'^version = "[^"]*"', + f'version = "{new_version}"', + content, + count=1, + flags=re.MULTILINE, + ) + + pyproject_path.write_text(content) + print(f"Updated pyproject.toml to version {new_version}") + + # Update _version.py + version_path = Path("src/claude_agent_sdk/_version.py") + content = version_path.read_text() + + # Only update __version__ assignment + content = re.sub( + r'^__version__ = "[^"]*"', + f'__version__ = "{new_version}"', + content, + count=1, + flags=re.MULTILINE, + ) + + version_path.write_text(content) + print(f"Updated _version.py to version {new_version}") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python scripts/update_version.py ") + sys.exit(1) + + update_version(sys.argv[1]) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/__init__.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/__init__.py new file mode 100644 index 00000000..379d6d89 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/__init__.py @@ -0,0 +1,398 @@ +"""Claude SDK for Python.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from mcp.types import ToolAnnotations + +from ._errors import ( + ClaudeSDKError, + CLIConnectionError, + CLIJSONDecodeError, + CLINotFoundError, + ProcessError, +) +from ._internal.transport import Transport +from ._version import __version__ +from .client import ClaudeSDKClient +from .query import query +from .types import ( + AgentDefinition, + AssistantMessage, + BaseHookInput, + CanUseTool, + ClaudeAgentOptions, + ContentBlock, + HookCallback, + HookContext, + HookInput, + HookJSONOutput, + HookMatcher, + McpSdkServerConfig, + McpServerConfig, + Message, + NotificationHookInput, + NotificationHookSpecificOutput, + PermissionMode, + PermissionRequestHookInput, + PermissionRequestHookSpecificOutput, + PermissionResult, + PermissionResultAllow, + PermissionResultDeny, + PermissionUpdate, + PostToolUseFailureHookInput, + PostToolUseFailureHookSpecificOutput, + PostToolUseHookInput, + PreCompactHookInput, + PreToolUseHookInput, + ResultMessage, + SandboxIgnoreViolations, + SandboxNetworkConfig, + SandboxSettings, + SdkBeta, + SdkPluginConfig, + SettingSource, + StopHookInput, + SubagentStartHookInput, + SubagentStartHookSpecificOutput, + SubagentStopHookInput, + SystemMessage, + TextBlock, + ThinkingBlock, + ThinkingConfig, + ThinkingConfigAdaptive, + ThinkingConfigDisabled, + ThinkingConfigEnabled, + ToolPermissionContext, + ToolResultBlock, + ToolUseBlock, + UserMessage, + UserPromptSubmitHookInput, +) + +# MCP Server Support + +T = TypeVar("T") + + +@dataclass +class SdkMcpTool(Generic[T]): + """Definition for an SDK MCP tool.""" + + name: str + description: str + input_schema: type[T] | dict[str, Any] + handler: Callable[[T], Awaitable[dict[str, Any]]] + annotations: ToolAnnotations | None = None + + +def tool( + name: str, + description: str, + input_schema: type | dict[str, Any], + annotations: ToolAnnotations | None = None, +) -> Callable[[Callable[[Any], Awaitable[dict[str, Any]]]], SdkMcpTool[Any]]: + """Decorator for defining MCP tools with type safety. + + Creates a tool that can be used with SDK MCP servers. The tool runs + in-process within your Python application, providing better performance + than external MCP servers. + + Args: + name: Unique identifier for the tool. This is what Claude will use + to reference the tool in function calls. + description: Human-readable description of what the tool does. + This helps Claude understand when to use the tool. + input_schema: Schema defining the tool's input parameters. + Can be either: + - A dictionary mapping parameter names to types (e.g., {"text": str}) + - A TypedDict class for more complex schemas + - A JSON Schema dictionary for full validation + + Returns: + A decorator function that wraps the tool implementation and returns + an SdkMcpTool instance ready for use with create_sdk_mcp_server(). + + Example: + Basic tool with simple schema: + >>> @tool("greet", "Greet a user", {"name": str}) + ... async def greet(args): + ... return {"content": [{"type": "text", "text": f"Hello, {args['name']}!"}]} + + Tool with multiple parameters: + >>> @tool("add", "Add two numbers", {"a": float, "b": float}) + ... async def add_numbers(args): + ... result = args["a"] + args["b"] + ... return {"content": [{"type": "text", "text": f"Result: {result}"}]} + + Tool with error handling: + >>> @tool("divide", "Divide two numbers", {"a": float, "b": float}) + ... async def divide(args): + ... if args["b"] == 0: + ... return {"content": [{"type": "text", "text": "Error: Division by zero"}], "is_error": True} + ... return {"content": [{"type": "text", "text": f"Result: {args['a'] / args['b']}"}]} + + Notes: + - The tool function must be async (defined with async def) + - The function receives a single dict argument with the input parameters + - The function should return a dict with a "content" key containing the response + - Errors can be indicated by including "is_error": True in the response + """ + + def decorator( + handler: Callable[[Any], Awaitable[dict[str, Any]]], + ) -> SdkMcpTool[Any]: + return SdkMcpTool( + name=name, + description=description, + input_schema=input_schema, + handler=handler, + annotations=annotations, + ) + + return decorator + + +def create_sdk_mcp_server( + name: str, version: str = "1.0.0", tools: list[SdkMcpTool[Any]] | None = None +) -> McpSdkServerConfig: + """Create an in-process MCP server that runs within your Python application. + + Unlike external MCP servers that run as separate processes, SDK MCP servers + run directly in your application's process. This provides: + - Better performance (no IPC overhead) + - Simpler deployment (single process) + - Easier debugging (same process) + - Direct access to your application's state + + Args: + name: Unique identifier for the server. This name is used to reference + the server in the mcp_servers configuration. + version: Server version string. Defaults to "1.0.0". This is for + informational purposes and doesn't affect functionality. + tools: List of SdkMcpTool instances created with the @tool decorator. + These are the functions that Claude can call through this server. + If None or empty, the server will have no tools (rarely useful). + + Returns: + McpSdkServerConfig: A configuration object that can be passed to + ClaudeAgentOptions.mcp_servers. This config contains the server + instance and metadata needed for the SDK to route tool calls. + + Example: + Simple calculator server: + >>> @tool("add", "Add numbers", {"a": float, "b": float}) + ... async def add(args): + ... return {"content": [{"type": "text", "text": f"Sum: {args['a'] + args['b']}"}]} + >>> + >>> @tool("multiply", "Multiply numbers", {"a": float, "b": float}) + ... async def multiply(args): + ... return {"content": [{"type": "text", "text": f"Product: {args['a'] * args['b']}"}]} + >>> + >>> calculator = create_sdk_mcp_server( + ... name="calculator", + ... version="2.0.0", + ... tools=[add, multiply] + ... ) + >>> + >>> # Use with Claude + >>> options = ClaudeAgentOptions( + ... mcp_servers={"calc": calculator}, + ... allowed_tools=["add", "multiply"] + ... ) + + Server with application state access: + >>> class DataStore: + ... def __init__(self): + ... self.items = [] + ... + >>> store = DataStore() + >>> + >>> @tool("add_item", "Add item to store", {"item": str}) + ... async def add_item(args): + ... store.items.append(args["item"]) + ... return {"content": [{"type": "text", "text": f"Added: {args['item']}"}]} + >>> + >>> server = create_sdk_mcp_server("store", tools=[add_item]) + + Notes: + - The server runs in the same process as your Python application + - Tools have direct access to your application's variables and state + - No subprocess or IPC overhead for tool calls + - Server lifecycle is managed automatically by the SDK + + See Also: + - tool(): Decorator for creating tool functions + - ClaudeAgentOptions: Configuration for using servers with query() + """ + from mcp.server import Server + from mcp.types import ImageContent, TextContent, Tool + + # Create MCP server instance + server = Server(name, version=version) + + # Register tools if provided + if tools: + # Store tools for access in handlers + tool_map = {tool_def.name: tool_def for tool_def in tools} + + # Register list_tools handler to expose available tools + @server.list_tools() # type: ignore[no-untyped-call,untyped-decorator] + async def list_tools() -> list[Tool]: + """Return the list of available tools.""" + tool_list = [] + for tool_def in tools: + # Convert input_schema to JSON Schema format + if isinstance(tool_def.input_schema, dict): + # Check if it's already a JSON schema + if ( + "type" in tool_def.input_schema + and "properties" in tool_def.input_schema + ): + schema = tool_def.input_schema + else: + # Simple dict mapping names to types - convert to JSON schema + properties = {} + for param_name, param_type in tool_def.input_schema.items(): + if param_type is str: + properties[param_name] = {"type": "string"} + elif param_type is int: + properties[param_name] = {"type": "integer"} + elif param_type is float: + properties[param_name] = {"type": "number"} + elif param_type is bool: + properties[param_name] = {"type": "boolean"} + else: + properties[param_name] = {"type": "string"} # Default + schema = { + "type": "object", + "properties": properties, + "required": list(properties.keys()), + } + else: + # For TypedDict or other types, create basic schema + schema = {"type": "object", "properties": {}} + + tool_list.append( + Tool( + name=tool_def.name, + description=tool_def.description, + inputSchema=schema, + annotations=tool_def.annotations, + ) + ) + return tool_list + + # Register call_tool handler to execute tools + @server.call_tool() # type: ignore[untyped-decorator] + async def call_tool(name: str, arguments: dict[str, Any]) -> Any: + """Execute a tool by name with given arguments.""" + if name not in tool_map: + raise ValueError(f"Tool '{name}' not found") + + tool_def = tool_map[name] + # Call the tool's handler with arguments + result = await tool_def.handler(arguments) + + # Convert result to MCP format + # The decorator expects us to return the content, not a CallToolResult + # It will wrap our return value in CallToolResult + content: list[TextContent | ImageContent] = [] + if "content" in result: + for item in result["content"]: + if item.get("type") == "text": + content.append(TextContent(type="text", text=item["text"])) + if item.get("type") == "image": + content.append( + ImageContent( + type="image", + data=item["data"], + mimeType=item["mimeType"], + ) + ) + + # Return just the content list - the decorator wraps it + return content + + # Return SDK server configuration + return McpSdkServerConfig(type="sdk", name=name, instance=server) + + +__all__ = [ + # Main exports + "query", + "__version__", + # Transport + "Transport", + "ClaudeSDKClient", + # Types + "PermissionMode", + "McpServerConfig", + "McpSdkServerConfig", + "UserMessage", + "AssistantMessage", + "SystemMessage", + "ResultMessage", + "Message", + "ClaudeAgentOptions", + "TextBlock", + "ThinkingBlock", + "ThinkingConfig", + "ThinkingConfigAdaptive", + "ThinkingConfigEnabled", + "ThinkingConfigDisabled", + "ToolUseBlock", + "ToolResultBlock", + "ContentBlock", + # Tool callbacks + "CanUseTool", + "ToolPermissionContext", + "PermissionResult", + "PermissionResultAllow", + "PermissionResultDeny", + "PermissionUpdate", + # Hook support + "HookCallback", + "HookContext", + "HookInput", + "BaseHookInput", + "PreToolUseHookInput", + "PostToolUseHookInput", + "PostToolUseFailureHookInput", + "PostToolUseFailureHookSpecificOutput", + "UserPromptSubmitHookInput", + "StopHookInput", + "SubagentStopHookInput", + "PreCompactHookInput", + "NotificationHookInput", + "SubagentStartHookInput", + "PermissionRequestHookInput", + "NotificationHookSpecificOutput", + "SubagentStartHookSpecificOutput", + "PermissionRequestHookSpecificOutput", + "HookJSONOutput", + "HookMatcher", + # Agent support + "AgentDefinition", + "SettingSource", + # Plugin support + "SdkPluginConfig", + # Beta support + "SdkBeta", + # Sandbox support + "SandboxSettings", + "SandboxNetworkConfig", + "SandboxIgnoreViolations", + # MCP Server Support + "create_sdk_mcp_server", + "tool", + "SdkMcpTool", + "ToolAnnotations", + # Errors + "ClaudeSDKError", + "CLIConnectionError", + "CLINotFoundError", + "ProcessError", + "CLIJSONDecodeError", +] diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_bundled/.gitignore b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_bundled/.gitignore new file mode 100644 index 00000000..b8f03540 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_bundled/.gitignore @@ -0,0 +1,3 @@ +# Ignore bundled CLI binaries (downloaded during build) +claude +claude.exe diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_cli_version.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_cli_version.py new file mode 100644 index 00000000..22e7eae8 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_cli_version.py @@ -0,0 +1,3 @@ +"""Bundled Claude Code CLI version.""" + +__cli_version__ = "2.1.56" diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_errors.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_errors.py new file mode 100644 index 00000000..c86bf235 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_errors.py @@ -0,0 +1,56 @@ +"""Error types for Claude SDK.""" + +from typing import Any + + +class ClaudeSDKError(Exception): + """Base exception for all Claude SDK errors.""" + + +class CLIConnectionError(ClaudeSDKError): + """Raised when unable to connect to Claude Code.""" + + +class CLINotFoundError(CLIConnectionError): + """Raised when Claude Code is not found or not installed.""" + + def __init__( + self, message: str = "Claude Code not found", cli_path: str | None = None + ): + if cli_path: + message = f"{message}: {cli_path}" + super().__init__(message) + + +class ProcessError(ClaudeSDKError): + """Raised when the CLI process fails.""" + + def __init__( + self, message: str, exit_code: int | None = None, stderr: str | None = None + ): + self.exit_code = exit_code + self.stderr = stderr + + if exit_code is not None: + message = f"{message} (exit code: {exit_code})" + if stderr: + message = f"{message}\nError output: {stderr}" + + super().__init__(message) + + +class CLIJSONDecodeError(ClaudeSDKError): + """Raised when unable to decode JSON from CLI output.""" + + def __init__(self, line: str, original_error: Exception): + self.line = line + self.original_error = original_error + super().__init__(f"Failed to decode JSON: {line[:100]}...") + + +class MessageParseError(ClaudeSDKError): + """Raised when unable to parse a message from CLI output.""" + + def __init__(self, message: str, data: dict[str, Any] | None = None): + self.data = data + super().__init__(message) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/__init__.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/__init__.py new file mode 100644 index 00000000..62791d73 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/__init__.py @@ -0,0 +1 @@ +"""Internal implementation details.""" diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/client.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/client.py new file mode 100644 index 00000000..79f5fca2 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/client.py @@ -0,0 +1,146 @@ +"""Internal client implementation.""" + +from collections.abc import AsyncIterable, AsyncIterator +from dataclasses import asdict, replace +from typing import Any + +from ..types import ( + ClaudeAgentOptions, + HookEvent, + HookMatcher, + Message, +) +from .message_parser import parse_message +from .query import Query +from .transport import Transport +from .transport.subprocess_cli import SubprocessCLITransport + + +class InternalClient: + """Internal client implementation.""" + + def __init__(self) -> None: + """Initialize the internal client.""" + + def _convert_hooks_to_internal_format( + self, hooks: dict[HookEvent, list[HookMatcher]] + ) -> dict[str, list[dict[str, Any]]]: + """Convert HookMatcher format to internal Query format.""" + internal_hooks: dict[str, list[dict[str, Any]]] = {} + for event, matchers in hooks.items(): + internal_hooks[event] = [] + for matcher in matchers: + # Convert HookMatcher to internal dict format + internal_matcher: dict[str, Any] = { + "matcher": matcher.matcher if hasattr(matcher, "matcher") else None, + "hooks": matcher.hooks if hasattr(matcher, "hooks") else [], + } + if hasattr(matcher, "timeout") and matcher.timeout is not None: + internal_matcher["timeout"] = matcher.timeout + internal_hooks[event].append(internal_matcher) + return internal_hooks + + async def process_query( + self, + prompt: str | AsyncIterable[dict[str, Any]], + options: ClaudeAgentOptions, + transport: Transport | None = None, + ) -> AsyncIterator[Message]: + """Process a query through transport and Query.""" + + # Validate and configure permission settings (matching TypeScript SDK logic) + configured_options = options + if options.can_use_tool: + # canUseTool callback requires streaming mode (AsyncIterable prompt) + if isinstance(prompt, str): + raise ValueError( + "can_use_tool callback requires streaming mode. " + "Please provide prompt as an AsyncIterable instead of a string." + ) + + # canUseTool and permission_prompt_tool_name are mutually exclusive + if options.permission_prompt_tool_name: + raise ValueError( + "can_use_tool callback cannot be used with permission_prompt_tool_name. " + "Please use one or the other." + ) + + # Automatically set permission_prompt_tool_name to "stdio" for control protocol + configured_options = replace(options, permission_prompt_tool_name="stdio") + + # Use provided transport or create subprocess transport + if transport is not None: + chosen_transport = transport + else: + chosen_transport = SubprocessCLITransport( + prompt=prompt, + options=configured_options, + ) + + # Connect transport + await chosen_transport.connect() + + # Extract SDK MCP servers from configured options + sdk_mcp_servers = {} + if configured_options.mcp_servers and isinstance( + configured_options.mcp_servers, dict + ): + for name, config in configured_options.mcp_servers.items(): + if isinstance(config, dict) and config.get("type") == "sdk": + sdk_mcp_servers[name] = config["instance"] # type: ignore[typeddict-item] + + # Convert agents to dict format for initialize request + agents_dict = None + if configured_options.agents: + agents_dict = { + name: {k: v for k, v in asdict(agent_def).items() if v is not None} + for name, agent_def in configured_options.agents.items() + } + + # Create Query to handle control protocol + # Always use streaming mode internally (matching TypeScript SDK) + # This ensures agents are always sent via initialize request + query = Query( + transport=chosen_transport, + is_streaming_mode=True, # Always streaming internally + can_use_tool=configured_options.can_use_tool, + hooks=self._convert_hooks_to_internal_format(configured_options.hooks) + if configured_options.hooks + else None, + sdk_mcp_servers=sdk_mcp_servers, + agents=agents_dict, + ) + + try: + # Start reading messages + await query.start() + + # Always initialize to send agents via stdin (matching TypeScript SDK) + await query.initialize() + + # Handle prompt input + if isinstance(prompt, str): + # For string prompts, write user message to stdin after initialize + # (matching TypeScript SDK behavior) + import json + + user_message = { + "type": "user", + "session_id": "", + "message": {"role": "user", "content": prompt}, + "parent_tool_use_id": None, + } + await chosen_transport.write(json.dumps(user_message) + "\n") + await chosen_transport.end_input() + elif isinstance(prompt, AsyncIterable) and query._tg: + # Stream input in background for async iterables + query._tg.start_soon(query.stream_input, prompt) + + # Yield parsed messages, skipping unknown message types + async for data in query.receive_messages(): + message = parse_message(data) + if message is not None: + yield message + + finally: + await query.close() diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/message_parser.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/message_parser.py new file mode 100644 index 00000000..d020d5b9 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/message_parser.py @@ -0,0 +1,183 @@ +"""Message parser for Claude Code SDK responses.""" + +import logging +from typing import Any + +from .._errors import MessageParseError +from ..types import ( + AssistantMessage, + ContentBlock, + Message, + ResultMessage, + StreamEvent, + SystemMessage, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) + +logger = logging.getLogger(__name__) + + +def parse_message(data: dict[str, Any]) -> Message | None: + """ + Parse message from CLI output into typed Message objects. + + Args: + data: Raw message dictionary from CLI output + + Returns: + Parsed Message object + + Raises: + MessageParseError: If parsing fails or message type is unrecognized + """ + if not isinstance(data, dict): + raise MessageParseError( + f"Invalid message data type (expected dict, got {type(data).__name__})", + data, + ) + + message_type = data.get("type") + if not message_type: + raise MessageParseError("Message missing 'type' field", data) + + match message_type: + case "user": + try: + parent_tool_use_id = data.get("parent_tool_use_id") + tool_use_result = data.get("tool_use_result") + uuid = data.get("uuid") + if isinstance(data["message"]["content"], list): + user_content_blocks: list[ContentBlock] = [] + for block in data["message"]["content"]: + match block["type"]: + case "text": + user_content_blocks.append( + TextBlock(text=block["text"]) + ) + case "tool_use": + user_content_blocks.append( + ToolUseBlock( + id=block["id"], + name=block["name"], + input=block["input"], + ) + ) + case "tool_result": + user_content_blocks.append( + ToolResultBlock( + tool_use_id=block["tool_use_id"], + content=block.get("content"), + is_error=block.get("is_error"), + ) + ) + return UserMessage( + content=user_content_blocks, + uuid=uuid, + parent_tool_use_id=parent_tool_use_id, + tool_use_result=tool_use_result, + ) + return UserMessage( + content=data["message"]["content"], + uuid=uuid, + parent_tool_use_id=parent_tool_use_id, + tool_use_result=tool_use_result, + ) + except KeyError as e: + raise MessageParseError( + f"Missing required field in user message: {e}", data + ) from e + + case "assistant": + try: + content_blocks: list[ContentBlock] = [] + for block in data["message"]["content"]: + match block["type"]: + case "text": + content_blocks.append(TextBlock(text=block["text"])) + case "thinking": + content_blocks.append( + ThinkingBlock( + thinking=block["thinking"], + signature=block["signature"], + ) + ) + case "tool_use": + content_blocks.append( + ToolUseBlock( + id=block["id"], + name=block["name"], + input=block["input"], + ) + ) + case "tool_result": + content_blocks.append( + ToolResultBlock( + tool_use_id=block["tool_use_id"], + content=block.get("content"), + is_error=block.get("is_error"), + ) + ) + + return AssistantMessage( + content=content_blocks, + model=data["message"]["model"], + parent_tool_use_id=data.get("parent_tool_use_id"), + error=data.get("error"), + ) + except KeyError as e: + raise MessageParseError( + f"Missing required field in assistant message: {e}", data + ) from e + + case "system": + try: + return SystemMessage( + subtype=data["subtype"], + data=data, + ) + except KeyError as e: + raise MessageParseError( + f"Missing required field in system message: {e}", data + ) from e + + case "result": + try: + return ResultMessage( + subtype=data["subtype"], + duration_ms=data["duration_ms"], + duration_api_ms=data["duration_api_ms"], + is_error=data["is_error"], + num_turns=data["num_turns"], + session_id=data["session_id"], + total_cost_usd=data.get("total_cost_usd"), + usage=data.get("usage"), + result=data.get("result"), + structured_output=data.get("structured_output"), + ) + except KeyError as e: + raise MessageParseError( + f"Missing required field in result message: {e}", data + ) from e + + case "stream_event": + try: + return StreamEvent( + uuid=data["uuid"], + session_id=data["session_id"], + event=data["event"], + parent_tool_use_id=data.get("parent_tool_use_id"), + ) + except KeyError as e: + raise MessageParseError( + f"Missing required field in stream_event message: {e}", data + ) from e + + case _: + # Forward-compatible: skip unrecognized message types so newer + # CLI versions don't crash older SDK versions. + logger.debug("Skipping unknown message type: %s", message_type) + return None diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/query.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/query.py new file mode 100644 index 00000000..8f278428 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/query.py @@ -0,0 +1,634 @@ +"""Query class for handling bidirectional control protocol.""" + +import json +import logging +import os +from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable +from contextlib import suppress +from typing import TYPE_CHECKING, Any + +import anyio +from mcp.types import ( + CallToolRequest, + CallToolRequestParams, + ListToolsRequest, +) + +from ..types import ( + PermissionResultAllow, + PermissionResultDeny, + SDKControlPermissionRequest, + SDKControlRequest, + SDKControlResponse, + SDKHookCallbackRequest, + ToolPermissionContext, +) +from .transport import Transport + +if TYPE_CHECKING: + from mcp.server import Server as McpServer + +logger = logging.getLogger(__name__) + + +def _convert_hook_output_for_cli(hook_output: dict[str, Any]) -> dict[str, Any]: + """Convert Python-safe field names to CLI-expected field names. + + The Python SDK uses `async_` and `continue_` to avoid keyword conflicts, + but the CLI expects `async` and `continue`. This function performs the + necessary conversion. + """ + converted = {} + for key, value in hook_output.items(): + # Convert Python-safe names to JavaScript names + if key == "async_": + converted["async"] = value + elif key == "continue_": + converted["continue"] = value + else: + converted[key] = value + return converted + + +class Query: + """Handles bidirectional control protocol on top of Transport. + + This class manages: + - Control request/response routing + - Hook callbacks + - Tool permission callbacks + - Message streaming + - Initialization handshake + """ + + def __init__( + self, + transport: Transport, + is_streaming_mode: bool, + can_use_tool: Callable[ + [str, dict[str, Any], ToolPermissionContext], + Awaitable[PermissionResultAllow | PermissionResultDeny], + ] + | None = None, + hooks: dict[str, list[dict[str, Any]]] | None = None, + sdk_mcp_servers: dict[str, "McpServer"] | None = None, + initialize_timeout: float = 60.0, + agents: dict[str, dict[str, Any]] | None = None, + ): + """Initialize Query with transport and callbacks. + + Args: + transport: Low-level transport for I/O + is_streaming_mode: Whether using streaming (bidirectional) mode + can_use_tool: Optional callback for tool permission requests + hooks: Optional hook configurations + sdk_mcp_servers: Optional SDK MCP server instances + initialize_timeout: Timeout in seconds for the initialize request + agents: Optional agent definitions to send via initialize + """ + self._initialize_timeout = initialize_timeout + self.transport = transport + self.is_streaming_mode = is_streaming_mode + self.can_use_tool = can_use_tool + self.hooks = hooks or {} + self.sdk_mcp_servers = sdk_mcp_servers or {} + self._agents = agents + + # Control protocol state + self.pending_control_responses: dict[str, anyio.Event] = {} + self.pending_control_results: dict[str, dict[str, Any] | Exception] = {} + self.hook_callbacks: dict[str, Callable[..., Any]] = {} + self.next_callback_id = 0 + self._request_counter = 0 + + # Message stream + self._message_send, self._message_receive = anyio.create_memory_object_stream[ + dict[str, Any] + ](max_buffer_size=100) + self._tg: anyio.abc.TaskGroup | None = None + self._initialized = False + self._closed = False + self._initialization_result: dict[str, Any] | None = None + + # Track first result for proper stream closure with SDK MCP servers + self._first_result_event = anyio.Event() + self._stream_close_timeout = ( + float(os.environ.get("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "60000")) / 1000.0 + ) # Convert ms to seconds + + async def initialize(self) -> dict[str, Any] | None: + """Initialize control protocol if in streaming mode. + + Returns: + Initialize response with supported commands, or None if not streaming + """ + if not self.is_streaming_mode: + return None + + # Build hooks configuration for initialization + hooks_config: dict[str, Any] = {} + if self.hooks: + for event, matchers in self.hooks.items(): + if matchers: + hooks_config[event] = [] + for matcher in matchers: + callback_ids = [] + for callback in matcher.get("hooks", []): + callback_id = f"hook_{self.next_callback_id}" + self.next_callback_id += 1 + self.hook_callbacks[callback_id] = callback + callback_ids.append(callback_id) + hook_matcher_config: dict[str, Any] = { + "matcher": matcher.get("matcher"), + "hookCallbackIds": callback_ids, + } + if matcher.get("timeout") is not None: + hook_matcher_config["timeout"] = matcher.get("timeout") + hooks_config[event].append(hook_matcher_config) + + # Send initialize request + request: dict[str, Any] = { + "subtype": "initialize", + "hooks": hooks_config if hooks_config else None, + } + if self._agents: + request["agents"] = self._agents + + # Use longer timeout for initialize since MCP servers may take time to start + response = await self._send_control_request( + request, timeout=self._initialize_timeout + ) + self._initialized = True + self._initialization_result = response # Store for later access + return response + + async def start(self) -> None: + """Start reading messages from transport.""" + if self._tg is None: + self._tg = anyio.create_task_group() + await self._tg.__aenter__() + self._tg.start_soon(self._read_messages) + + async def _read_messages(self) -> None: + """Read messages from transport and route them.""" + try: + async for message in self.transport.read_messages(): + if self._closed: + break + + msg_type = message.get("type") + + # Route control messages + if msg_type == "control_response": + response = message.get("response", {}) + request_id = response.get("request_id") + if request_id in self.pending_control_responses: + event = self.pending_control_responses[request_id] + if response.get("subtype") == "error": + self.pending_control_results[request_id] = Exception( + response.get("error", "Unknown error") + ) + else: + self.pending_control_results[request_id] = response + event.set() + continue + + elif msg_type == "control_request": + # Handle incoming control requests from CLI + # Cast message to SDKControlRequest for type safety + request: SDKControlRequest = message # type: ignore[assignment] + if self._tg: + self._tg.start_soon(self._handle_control_request, request) + continue + + elif msg_type == "control_cancel_request": + # Handle cancel requests + # TODO: Implement cancellation support + continue + + # Track results for proper stream closure + if msg_type == "result": + self._first_result_event.set() + + # Regular SDK messages go to the stream + await self._message_send.send(message) + + except anyio.get_cancelled_exc_class(): + # Task was cancelled - this is expected behavior + logger.debug("Read task cancelled") + raise # Re-raise to properly handle cancellation + except Exception as e: + logger.error(f"Fatal error in message reader: {e}") + # Signal all pending control requests so they fail fast instead of timing out + for request_id, event in list(self.pending_control_responses.items()): + if request_id not in self.pending_control_results: + self.pending_control_results[request_id] = e + event.set() + # Put error in stream so iterators can handle it + await self._message_send.send({"type": "error", "error": str(e)}) + finally: + # Always signal end of stream + await self._message_send.send({"type": "end"}) + + async def _handle_control_request(self, request: SDKControlRequest) -> None: + """Handle incoming control request from CLI.""" + request_id = request["request_id"] + request_data = request["request"] + subtype = request_data["subtype"] + + try: + response_data: dict[str, Any] = {} + + if subtype == "can_use_tool": + permission_request: SDKControlPermissionRequest = request_data # type: ignore[assignment] + original_input = permission_request["input"] + # Handle tool permission request + if not self.can_use_tool: + raise Exception("canUseTool callback is not provided") + + context = ToolPermissionContext( + signal=None, # TODO: Add abort signal support + suggestions=permission_request.get("permission_suggestions", []) + or [], + ) + + response = await self.can_use_tool( + permission_request["tool_name"], + permission_request["input"], + context, + ) + + # Convert PermissionResult to expected dict format + if isinstance(response, PermissionResultAllow): + response_data = { + "behavior": "allow", + "updatedInput": ( + response.updated_input + if response.updated_input is not None + else original_input + ), + } + if response.updated_permissions is not None: + response_data["updatedPermissions"] = [ + permission.to_dict() + for permission in response.updated_permissions + ] + elif isinstance(response, PermissionResultDeny): + response_data = {"behavior": "deny", "message": response.message} + if response.interrupt: + response_data["interrupt"] = response.interrupt + else: + raise TypeError( + f"Tool permission callback must return PermissionResult (PermissionResultAllow or PermissionResultDeny), got {type(response)}" + ) + + elif subtype == "hook_callback": + hook_callback_request: SDKHookCallbackRequest = request_data # type: ignore[assignment] + # Handle hook callback + callback_id = hook_callback_request["callback_id"] + callback = self.hook_callbacks.get(callback_id) + if not callback: + raise Exception(f"No hook callback found for ID: {callback_id}") + + hook_output = await callback( + request_data.get("input"), + request_data.get("tool_use_id"), + {"signal": None}, # TODO: Add abort signal support + ) + # Convert Python-safe field names (async_, continue_) to CLI-expected names (async, continue) + response_data = _convert_hook_output_for_cli(hook_output) + + elif subtype == "mcp_message": + # Handle SDK MCP request + server_name = request_data.get("server_name") + mcp_message = request_data.get("message") + + if not server_name or not mcp_message: + raise Exception("Missing server_name or message for MCP request") + + # Type narrowing - we've verified these are not None above + assert isinstance(server_name, str) + assert isinstance(mcp_message, dict) + mcp_response = await self._handle_sdk_mcp_request( + server_name, mcp_message + ) + # Wrap the MCP response as expected by the control protocol + response_data = {"mcp_response": mcp_response} + + else: + raise Exception(f"Unsupported control request subtype: {subtype}") + + # Send success response + success_response: SDKControlResponse = { + "type": "control_response", + "response": { + "subtype": "success", + "request_id": request_id, + "response": response_data, + }, + } + await self.transport.write(json.dumps(success_response) + "\n") + + except Exception as e: + # Send error response + error_response: SDKControlResponse = { + "type": "control_response", + "response": { + "subtype": "error", + "request_id": request_id, + "error": str(e), + }, + } + await self.transport.write(json.dumps(error_response) + "\n") + + async def _send_control_request( + self, request: dict[str, Any], timeout: float = 60.0 + ) -> dict[str, Any]: + """Send control request to CLI and wait for response. + + Args: + request: The control request to send + timeout: Timeout in seconds to wait for response (default 60s) + """ + if not self.is_streaming_mode: + raise Exception("Control requests require streaming mode") + + # Generate unique request ID + self._request_counter += 1 + request_id = f"req_{self._request_counter}_{os.urandom(4).hex()}" + + # Create event for response + event = anyio.Event() + self.pending_control_responses[request_id] = event + + # Build and send request + control_request = { + "type": "control_request", + "request_id": request_id, + "request": request, + } + + await self.transport.write(json.dumps(control_request) + "\n") + + # Wait for response + try: + with anyio.fail_after(timeout): + await event.wait() + + result = self.pending_control_results.pop(request_id) + self.pending_control_responses.pop(request_id, None) + + if isinstance(result, Exception): + raise result + + response_data = result.get("response", {}) + return response_data if isinstance(response_data, dict) else {} + except TimeoutError as e: + self.pending_control_responses.pop(request_id, None) + self.pending_control_results.pop(request_id, None) + raise Exception(f"Control request timeout: {request.get('subtype')}") from e + + async def _handle_sdk_mcp_request( + self, server_name: str, message: dict[str, Any] + ) -> dict[str, Any]: + """Handle an MCP request for an SDK server. + + This acts as a bridge between JSONRPC messages from the CLI + and the in-process MCP server. Ideally the MCP SDK would provide + a method to handle raw JSONRPC, but for now we route manually. + + Args: + server_name: Name of the SDK MCP server + message: The JSONRPC message + + Returns: + The response message + """ + if server_name not in self.sdk_mcp_servers: + return { + "jsonrpc": "2.0", + "id": message.get("id"), + "error": { + "code": -32601, + "message": f"Server '{server_name}' not found", + }, + } + + server = self.sdk_mcp_servers[server_name] + method = message.get("method") + params = message.get("params", {}) + + try: + # TODO: Python MCP SDK lacks the Transport abstraction that TypeScript has. + # TypeScript: server.connect(transport) allows custom transports + # Python: server.run(read_stream, write_stream) requires actual streams + # + # This forces us to manually route methods. When Python MCP adds Transport + # support, we can refactor to match the TypeScript approach. + if method == "initialize": + # Handle MCP initialization - hardcoded for tools only, no listChanged + return { + "jsonrpc": "2.0", + "id": message.get("id"), + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} # Tools capability without listChanged + }, + "serverInfo": { + "name": server.name, + "version": server.version or "1.0.0", + }, + }, + } + + elif method == "tools/list": + request = ListToolsRequest(method=method) + handler = server.request_handlers.get(ListToolsRequest) + if handler: + result = await handler(request) + # Convert MCP result to JSONRPC response + tools_data = [] + for tool in result.root.tools: # type: ignore[union-attr] + tool_data: dict[str, Any] = { + "name": tool.name, + "description": tool.description, + "inputSchema": ( + tool.inputSchema.model_dump() + if hasattr(tool.inputSchema, "model_dump") + else tool.inputSchema + ) + if tool.inputSchema + else {}, + } + if tool.annotations: + tool_data["annotations"] = tool.annotations.model_dump( + exclude_none=True + ) + tools_data.append(tool_data) + return { + "jsonrpc": "2.0", + "id": message.get("id"), + "result": {"tools": tools_data}, + } + + elif method == "tools/call": + call_request = CallToolRequest( + method=method, + params=CallToolRequestParams( + name=params.get("name"), arguments=params.get("arguments", {}) + ), + ) + handler = server.request_handlers.get(CallToolRequest) + if handler: + result = await handler(call_request) + # Convert MCP result to JSONRPC response + content = [] + for item in result.root.content: # type: ignore[union-attr] + if hasattr(item, "text"): + content.append({"type": "text", "text": item.text}) + elif hasattr(item, "data") and hasattr(item, "mimeType"): + content.append( + { + "type": "image", + "data": item.data, + "mimeType": item.mimeType, + } + ) + + response_data = {"content": content} + if hasattr(result.root, "is_error") and result.root.is_error: + response_data["is_error"] = True # type: ignore[assignment] + + return { + "jsonrpc": "2.0", + "id": message.get("id"), + "result": response_data, + } + + elif method == "notifications/initialized": + # Handle initialized notification - just acknowledge it + return {"jsonrpc": "2.0", "result": {}} + + # Add more methods here as MCP SDK adds them (resources, prompts, etc.) + # This is the limitation Ashwin pointed out - we have to manually update + + return { + "jsonrpc": "2.0", + "id": message.get("id"), + "error": {"code": -32601, "message": f"Method '{method}' not found"}, + } + + except Exception as e: + return { + "jsonrpc": "2.0", + "id": message.get("id"), + "error": {"code": -32603, "message": str(e)}, + } + + async def get_mcp_status(self) -> dict[str, Any]: + """Get current MCP server connection status.""" + return await self._send_control_request({"subtype": "mcp_status"}) + + async def interrupt(self) -> None: + """Send interrupt control request.""" + await self._send_control_request({"subtype": "interrupt"}) + + async def set_permission_mode(self, mode: str) -> None: + """Change permission mode.""" + await self._send_control_request( + { + "subtype": "set_permission_mode", + "mode": mode, + } + ) + + async def set_model(self, model: str | None) -> None: + """Change the AI model.""" + await self._send_control_request( + { + "subtype": "set_model", + "model": model, + } + ) + + async def rewind_files(self, user_message_id: str) -> None: + """Rewind tracked files to their state at a specific user message. + + Requires file checkpointing to be enabled via the `enable_file_checkpointing` option. + + Args: + user_message_id: UUID of the user message to rewind to + """ + await self._send_control_request( + { + "subtype": "rewind_files", + "user_message_id": user_message_id, + } + ) + + async def stream_input(self, stream: AsyncIterable[dict[str, Any]]) -> None: + """Stream input messages to transport. + + If SDK MCP servers or hooks are present, waits for the first result + before closing stdin to allow bidirectional control protocol communication. + """ + try: + async for message in stream: + if self._closed: + break + await self.transport.write(json.dumps(message) + "\n") + + # If we have SDK MCP servers or hooks that need bidirectional communication, + # wait for first result before closing the channel + has_hooks = bool(self.hooks) + if self.sdk_mcp_servers or has_hooks: + logger.debug( + f"Waiting for first result before closing stdin " + f"(sdk_mcp_servers={len(self.sdk_mcp_servers)}, has_hooks={has_hooks})" + ) + try: + with anyio.move_on_after(self._stream_close_timeout): + await self._first_result_event.wait() + logger.debug("Received first result, closing input stream") + except Exception: + logger.debug( + "Timed out waiting for first result, closing input stream" + ) + + # After all messages sent (and result received if needed), end input + await self.transport.end_input() + except Exception as e: + logger.debug(f"Error streaming input: {e}") + + async def receive_messages(self) -> AsyncIterator[dict[str, Any]]: + """Receive SDK messages (not control messages).""" + async for message in self._message_receive: + # Check for special messages + if message.get("type") == "end": + break + elif message.get("type") == "error": + raise Exception(message.get("error", "Unknown error")) + + yield message + + async def close(self) -> None: + """Close the query and transport.""" + self._closed = True + if self._tg: + self._tg.cancel_scope.cancel() + # Wait for task group to complete cancellation + with suppress(anyio.get_cancelled_exc_class()): + await self._tg.__aexit__(None, None, None) + await self.transport.close() + + # Make Query an async iterator + def __aiter__(self) -> AsyncIterator[dict[str, Any]]: + """Return async iterator for messages.""" + return self.receive_messages() + + async def __anext__(self) -> dict[str, Any]: + """Get next message.""" + async for message in self.receive_messages(): + return message + raise StopAsyncIteration diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/transport/__init__.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/transport/__init__.py new file mode 100644 index 00000000..6dedef61 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/transport/__init__.py @@ -0,0 +1,68 @@ +"""Transport implementations for Claude SDK.""" + +from abc import ABC, abstractmethod +from collections.abc import AsyncIterator +from typing import Any + + +class Transport(ABC): + """Abstract transport for Claude communication. + + WARNING: This internal API is exposed for custom transport implementations + (e.g., remote Claude Code connections). The Claude Code team may change or + or remove this abstract class in any future release. Custom implementations + must be updated to match interface changes. + + This is a low-level transport interface that handles raw I/O with the Claude + process or service. The Query class builds on top of this to implement the + control protocol and message routing. + """ + + @abstractmethod + async def connect(self) -> None: + """Connect the transport and prepare for communication. + + For subprocess transports, this starts the process. + For network transports, this establishes the connection. + """ + pass + + @abstractmethod + async def write(self, data: str) -> None: + """Write raw data to the transport. + + Args: + data: Raw string data to write (typically JSON + newline) + """ + pass + + @abstractmethod + def read_messages(self) -> AsyncIterator[dict[str, Any]]: + """Read and parse messages from the transport. + + Yields: + Parsed JSON messages from the transport + """ + pass + + @abstractmethod + async def close(self) -> None: + """Close the transport connection and clean up resources.""" + pass + + @abstractmethod + def is_ready(self) -> bool: + """Check if transport is ready for communication. + + Returns: + True if transport is ready to send/receive messages + """ + pass + + @abstractmethod + async def end_input(self) -> None: + """End the input stream (close stdin for process transports).""" + pass + + +__all__ = ["Transport"] diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/transport/subprocess_cli.py new file mode 100644 index 00000000..1f0aac58 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -0,0 +1,629 @@ +"""Subprocess transport implementation using Claude Code CLI.""" + +import json +import logging +import os +import platform +import re +import shutil +import sys +from collections.abc import AsyncIterable, AsyncIterator +from contextlib import suppress +from pathlib import Path +from subprocess import PIPE +from typing import Any + +import anyio +import anyio.abc +from anyio.abc import Process +from anyio.streams.text import TextReceiveStream, TextSendStream + +from ..._errors import CLIConnectionError, CLINotFoundError, ProcessError +from ..._errors import CLIJSONDecodeError as SDKJSONDecodeError +from ..._version import __version__ +from ...types import ClaudeAgentOptions +from . import Transport + +logger = logging.getLogger(__name__) + +_DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit +MINIMUM_CLAUDE_CODE_VERSION = "2.0.0" + + +class SubprocessCLITransport(Transport): + """Subprocess transport using Claude Code CLI.""" + + def __init__( + self, + prompt: str | AsyncIterable[dict[str, Any]], + options: ClaudeAgentOptions, + ): + self._prompt = prompt + # Always use streaming mode internally (matching TypeScript SDK) + # This allows agents and other large configs to be sent via initialize request + self._is_streaming = True + self._options = options + self._cli_path = ( + str(options.cli_path) if options.cli_path is not None else self._find_cli() + ) + self._cwd = str(options.cwd) if options.cwd else None + self._process: Process | None = None + self._stdout_stream: TextReceiveStream | None = None + self._stdin_stream: TextSendStream | None = None + self._stderr_stream: TextReceiveStream | None = None + self._stderr_task_group: anyio.abc.TaskGroup | None = None + self._ready = False + self._exit_error: Exception | None = None # Track process exit errors + self._max_buffer_size = ( + options.max_buffer_size + if options.max_buffer_size is not None + else _DEFAULT_MAX_BUFFER_SIZE + ) + self._write_lock: anyio.Lock = anyio.Lock() + + def _find_cli(self) -> str: + """Find Claude Code CLI binary.""" + # First, check for bundled CLI + bundled_cli = self._find_bundled_cli() + if bundled_cli: + return bundled_cli + + # Fall back to system-wide search + if cli := shutil.which("claude"): + return cli + + locations = [ + Path.home() / ".npm-global/bin/claude", + Path("/usr/local/bin/claude"), + Path.home() / ".local/bin/claude", + Path.home() / "node_modules/.bin/claude", + Path.home() / ".yarn/bin/claude", + Path.home() / ".claude/local/claude", + ] + + for path in locations: + if path.exists() and path.is_file(): + return str(path) + + raise CLINotFoundError( + "Claude Code not found. Install with:\n" + " npm install -g @anthropic-ai/claude-code\n" + "\nIf already installed locally, try:\n" + ' export PATH="$HOME/node_modules/.bin:$PATH"\n' + "\nOr provide the path via ClaudeAgentOptions:\n" + " ClaudeAgentOptions(cli_path='/path/to/claude')" + ) + + def _find_bundled_cli(self) -> str | None: + """Find bundled CLI binary if it exists.""" + # Determine the CLI binary name based on platform + cli_name = "claude.exe" if platform.system() == "Windows" else "claude" + + # Get the path to the bundled CLI + # The _bundled directory is in the same package as this module + bundled_path = Path(__file__).parent.parent.parent / "_bundled" / cli_name + + if bundled_path.exists() and bundled_path.is_file(): + logger.info(f"Using bundled Claude Code CLI: {bundled_path}") + return str(bundled_path) + + return None + + def _build_settings_value(self) -> str | None: + """Build settings value, merging sandbox settings if provided. + + Returns the settings value as either: + - A JSON string (if sandbox is provided or settings is JSON) + - A file path (if only settings path is provided without sandbox) + - None if neither settings nor sandbox is provided + """ + has_settings = self._options.settings is not None + has_sandbox = self._options.sandbox is not None + + if not has_settings and not has_sandbox: + return None + + # If only settings path and no sandbox, pass through as-is + if has_settings and not has_sandbox: + return self._options.settings + + # If we have sandbox settings, we need to merge into a JSON object + settings_obj: dict[str, Any] = {} + + if has_settings: + assert self._options.settings is not None + settings_str = self._options.settings.strip() + # Check if settings is a JSON string or a file path + if settings_str.startswith("{") and settings_str.endswith("}"): + # Parse JSON string + try: + settings_obj = json.loads(settings_str) + except json.JSONDecodeError: + # If parsing fails, treat as file path + logger.warning( + f"Failed to parse settings as JSON, treating as file path: {settings_str}" + ) + # Read the file + settings_path = Path(settings_str) + if settings_path.exists(): + with settings_path.open(encoding="utf-8") as f: + settings_obj = json.load(f) + else: + # It's a file path - read and parse + settings_path = Path(settings_str) + if settings_path.exists(): + with settings_path.open(encoding="utf-8") as f: + settings_obj = json.load(f) + else: + logger.warning(f"Settings file not found: {settings_path}") + + # Merge sandbox settings + if has_sandbox: + settings_obj["sandbox"] = self._options.sandbox + + return json.dumps(settings_obj) + + def _build_command(self) -> list[str]: + """Build CLI command with arguments.""" + cmd = [self._cli_path, "--output-format", "stream-json", "--verbose"] + + if self._options.system_prompt is None: + cmd.extend(["--system-prompt", ""]) + elif isinstance(self._options.system_prompt, str): + cmd.extend(["--system-prompt", self._options.system_prompt]) + else: + if ( + self._options.system_prompt.get("type") == "preset" + and "append" in self._options.system_prompt + ): + cmd.extend( + ["--append-system-prompt", self._options.system_prompt["append"]] + ) + + # Handle tools option (base set of tools) + if self._options.tools is not None: + tools = self._options.tools + if isinstance(tools, list): + if len(tools) == 0: + cmd.extend(["--tools", ""]) + else: + cmd.extend(["--tools", ",".join(tools)]) + else: + # Preset object - 'claude_code' preset maps to 'default' + cmd.extend(["--tools", "default"]) + + if self._options.allowed_tools: + cmd.extend(["--allowedTools", ",".join(self._options.allowed_tools)]) + + if self._options.max_turns: + cmd.extend(["--max-turns", str(self._options.max_turns)]) + + if self._options.max_budget_usd is not None: + cmd.extend(["--max-budget-usd", str(self._options.max_budget_usd)]) + + if self._options.disallowed_tools: + cmd.extend(["--disallowedTools", ",".join(self._options.disallowed_tools)]) + + if self._options.model: + cmd.extend(["--model", self._options.model]) + + if self._options.fallback_model: + cmd.extend(["--fallback-model", self._options.fallback_model]) + + if self._options.betas: + cmd.extend(["--betas", ",".join(self._options.betas)]) + + if self._options.permission_prompt_tool_name: + cmd.extend( + ["--permission-prompt-tool", self._options.permission_prompt_tool_name] + ) + + if self._options.permission_mode: + cmd.extend(["--permission-mode", self._options.permission_mode]) + + if self._options.continue_conversation: + cmd.append("--continue") + + if self._options.resume: + cmd.extend(["--resume", self._options.resume]) + + # Handle settings and sandbox: merge sandbox into settings if both are provided + settings_value = self._build_settings_value() + if settings_value: + cmd.extend(["--settings", settings_value]) + + if self._options.add_dirs: + # Convert all paths to strings and add each directory + for directory in self._options.add_dirs: + cmd.extend(["--add-dir", str(directory)]) + + if self._options.mcp_servers: + if isinstance(self._options.mcp_servers, dict): + # Process all servers, stripping instance field from SDK servers + servers_for_cli: dict[str, Any] = {} + for name, config in self._options.mcp_servers.items(): + if isinstance(config, dict) and config.get("type") == "sdk": + # For SDK servers, pass everything except the instance field + sdk_config: dict[str, object] = { + k: v for k, v in config.items() if k != "instance" + } + servers_for_cli[name] = sdk_config + else: + # For external servers, pass as-is + servers_for_cli[name] = config + + # Pass all servers to CLI + if servers_for_cli: + cmd.extend( + [ + "--mcp-config", + json.dumps({"mcpServers": servers_for_cli}), + ] + ) + else: + # String or Path format: pass directly as file path or JSON string + cmd.extend(["--mcp-config", str(self._options.mcp_servers)]) + + if self._options.include_partial_messages: + cmd.append("--include-partial-messages") + + if self._options.fork_session: + cmd.append("--fork-session") + + # Agents are always sent via initialize request (matching TypeScript SDK) + # No --agents CLI flag needed + + sources_value = ( + ",".join(self._options.setting_sources) + if self._options.setting_sources is not None + else "" + ) + cmd.extend(["--setting-sources", sources_value]) + + # Add plugin directories + if self._options.plugins: + for plugin in self._options.plugins: + if plugin["type"] == "local": + cmd.extend(["--plugin-dir", plugin["path"]]) + else: + raise ValueError(f"Unsupported plugin type: {plugin['type']}") + + # Add extra args for future CLI flags + for flag, value in self._options.extra_args.items(): + if value is None: + # Boolean flag without value + cmd.append(f"--{flag}") + else: + # Flag with value + cmd.extend([f"--{flag}", str(value)]) + + # Resolve thinking config → --max-thinking-tokens + # `thinking` takes precedence over the deprecated `max_thinking_tokens` + resolved_max_thinking_tokens = self._options.max_thinking_tokens + if self._options.thinking is not None: + t = self._options.thinking + if t["type"] == "adaptive": + if resolved_max_thinking_tokens is None: + resolved_max_thinking_tokens = 32_000 + elif t["type"] == "enabled": + resolved_max_thinking_tokens = t["budget_tokens"] + elif t["type"] == "disabled": + resolved_max_thinking_tokens = 0 + if resolved_max_thinking_tokens is not None: + cmd.extend(["--max-thinking-tokens", str(resolved_max_thinking_tokens)]) + + if self._options.effort is not None: + cmd.extend(["--effort", self._options.effort]) + + # Extract schema from output_format structure if provided + # Expected: {"type": "json_schema", "schema": {...}} + if ( + self._options.output_format is not None + and isinstance(self._options.output_format, dict) + and self._options.output_format.get("type") == "json_schema" + ): + schema = self._options.output_format.get("schema") + if schema is not None: + cmd.extend(["--json-schema", json.dumps(schema)]) + + # Always use streaming mode with stdin (matching TypeScript SDK) + # This allows agents and other large configs to be sent via initialize request + cmd.extend(["--input-format", "stream-json"]) + + return cmd + + async def connect(self) -> None: + """Start subprocess.""" + if self._process: + return + + if not os.environ.get("CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK"): + await self._check_claude_version() + + cmd = self._build_command() + try: + # Merge environment variables: system -> user -> SDK required + process_env = { + **os.environ, + **self._options.env, # User-provided env vars + "CLAUDE_CODE_ENTRYPOINT": "sdk-py", + "CLAUDE_AGENT_SDK_VERSION": __version__, + } + + # Enable file checkpointing if requested + if self._options.enable_file_checkpointing: + process_env["CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING"] = "true" + + if self._cwd: + process_env["PWD"] = self._cwd + + # Pipe stderr if we have a callback OR debug mode is enabled + should_pipe_stderr = ( + self._options.stderr is not None + or "debug-to-stderr" in self._options.extra_args + ) + + # For backward compat: use debug_stderr file object if no callback and debug is on + stderr_dest = PIPE if should_pipe_stderr else None + + self._process = await anyio.open_process( + cmd, + stdin=PIPE, + stdout=PIPE, + stderr=stderr_dest, + cwd=self._cwd, + env=process_env, + user=self._options.user, + ) + + if self._process.stdout: + self._stdout_stream = TextReceiveStream(self._process.stdout) + + # Setup stderr stream if piped + if should_pipe_stderr and self._process.stderr: + self._stderr_stream = TextReceiveStream(self._process.stderr) + # Start async task to read stderr + self._stderr_task_group = anyio.create_task_group() + await self._stderr_task_group.__aenter__() + self._stderr_task_group.start_soon(self._handle_stderr) + + # Setup stdin for streaming (always used now) + if self._process.stdin: + self._stdin_stream = TextSendStream(self._process.stdin) + + self._ready = True + + except FileNotFoundError as e: + # Check if the error comes from the working directory or the CLI + if self._cwd and not Path(self._cwd).exists(): + error = CLIConnectionError( + f"Working directory does not exist: {self._cwd}" + ) + self._exit_error = error + raise error from e + error = CLINotFoundError(f"Claude Code not found at: {self._cli_path}") + self._exit_error = error + raise error from e + except Exception as e: + error = CLIConnectionError(f"Failed to start Claude Code: {e}") + self._exit_error = error + raise error from e + + async def _handle_stderr(self) -> None: + """Handle stderr stream - read and invoke callbacks.""" + if not self._stderr_stream: + return + + try: + async for line in self._stderr_stream: + line_str = line.rstrip() + if not line_str: + continue + + # Call the stderr callback if provided + if self._options.stderr: + self._options.stderr(line_str) + + # For backward compatibility: write to debug_stderr if in debug mode + elif ( + "debug-to-stderr" in self._options.extra_args + and self._options.debug_stderr + ): + self._options.debug_stderr.write(line_str + "\n") + if hasattr(self._options.debug_stderr, "flush"): + self._options.debug_stderr.flush() + except anyio.ClosedResourceError: + pass # Stream closed, exit normally + except Exception: + pass # Ignore other errors during stderr reading + + async def close(self) -> None: + """Close the transport and clean up resources.""" + if not self._process: + self._ready = False + return + + # Close stderr task group if active + if self._stderr_task_group: + with suppress(Exception): + self._stderr_task_group.cancel_scope.cancel() + await self._stderr_task_group.__aexit__(None, None, None) + self._stderr_task_group = None + + # Close stdin stream (acquire lock to prevent race with concurrent writes) + async with self._write_lock: + self._ready = False # Set inside lock to prevent TOCTOU with write() + if self._stdin_stream: + with suppress(Exception): + await self._stdin_stream.aclose() + self._stdin_stream = None + + if self._stderr_stream: + with suppress(Exception): + await self._stderr_stream.aclose() + self._stderr_stream = None + + # Terminate and wait for process + if self._process.returncode is None: + with suppress(ProcessLookupError): + self._process.terminate() + # Wait for process to finish with timeout + with suppress(Exception): + # Just try to wait, but don't block if it fails + await self._process.wait() + + self._process = None + self._stdout_stream = None + self._stdin_stream = None + self._stderr_stream = None + self._exit_error = None + + async def write(self, data: str) -> None: + """Write raw data to the transport.""" + async with self._write_lock: + # All checks inside lock to prevent TOCTOU races with close()/end_input() + if not self._ready or not self._stdin_stream: + raise CLIConnectionError("ProcessTransport is not ready for writing") + + if self._process and self._process.returncode is not None: + raise CLIConnectionError( + f"Cannot write to terminated process (exit code: {self._process.returncode})" + ) + + if self._exit_error: + raise CLIConnectionError( + f"Cannot write to process that exited with error: {self._exit_error}" + ) from self._exit_error + + try: + await self._stdin_stream.send(data) + except Exception as e: + self._ready = False + self._exit_error = CLIConnectionError( + f"Failed to write to process stdin: {e}" + ) + raise self._exit_error from e + + async def end_input(self) -> None: + """End the input stream (close stdin).""" + async with self._write_lock: + if self._stdin_stream: + with suppress(Exception): + await self._stdin_stream.aclose() + self._stdin_stream = None + + def read_messages(self) -> AsyncIterator[dict[str, Any]]: + """Read and parse messages from the transport.""" + return self._read_messages_impl() + + async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]: + """Internal implementation of read_messages.""" + if not self._process or not self._stdout_stream: + raise CLIConnectionError("Not connected") + + json_buffer = "" + + # Process stdout messages + try: + async for line in self._stdout_stream: + line_str = line.strip() + if not line_str: + continue + + # Accumulate partial JSON until we can parse it + # Note: TextReceiveStream can truncate long lines, so we need to buffer + # and speculatively parse until we get a complete JSON object + json_lines = line_str.split("\n") + + for json_line in json_lines: + json_line = json_line.strip() + if not json_line: + continue + + # Keep accumulating partial JSON until we can parse it + json_buffer += json_line + + if len(json_buffer) > self._max_buffer_size: + buffer_length = len(json_buffer) + json_buffer = "" + raise SDKJSONDecodeError( + f"JSON message exceeded maximum buffer size of {self._max_buffer_size} bytes", + ValueError( + f"Buffer size {buffer_length} exceeds limit {self._max_buffer_size}" + ), + ) + + try: + data = json.loads(json_buffer) + json_buffer = "" + yield data + except json.JSONDecodeError: + # We are speculatively decoding the buffer until we get + # a full JSON object. If there is an actual issue, we + # raise an error after exceeding the configured limit. + continue + + except anyio.ClosedResourceError: + pass + except GeneratorExit: + # Client disconnected + pass + + # Check process completion and handle errors + try: + returncode = await self._process.wait() + except Exception: + returncode = -1 + + # Use exit code for error detection + if returncode is not None and returncode != 0: + self._exit_error = ProcessError( + f"Command failed with exit code {returncode}", + exit_code=returncode, + stderr="Check stderr output for details", + ) + raise self._exit_error + + async def _check_claude_version(self) -> None: + """Check Claude Code version and warn if below minimum.""" + version_process = None + try: + with anyio.fail_after(2): # 2 second timeout + version_process = await anyio.open_process( + [self._cli_path, "-v"], + stdout=PIPE, + stderr=PIPE, + ) + + if version_process.stdout: + stdout_bytes = await version_process.stdout.receive() + version_output = stdout_bytes.decode().strip() + + match = re.match(r"([0-9]+\.[0-9]+\.[0-9]+)", version_output) + if match: + version = match.group(1) + version_parts = [int(x) for x in version.split(".")] + min_parts = [ + int(x) for x in MINIMUM_CLAUDE_CODE_VERSION.split(".") + ] + + if version_parts < min_parts: + warning = ( + f"Warning: Claude Code version {version} is unsupported in the Agent SDK. " + f"Minimum required version is {MINIMUM_CLAUDE_CODE_VERSION}. " + "Some features may not work correctly." + ) + logger.warning(warning) + print(warning, file=sys.stderr) + except Exception: + pass + finally: + if version_process: + with suppress(Exception): + version_process.terminate() + with suppress(Exception): + await version_process.wait() + + def is_ready(self) -> bool: + """Check if transport is ready for communication.""" + return self._ready diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_version.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_version.py new file mode 100644 index 00000000..e8a3a50e --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/_version.py @@ -0,0 +1,3 @@ +"""Version information for claude-agent-sdk.""" + +__version__ = "0.1.43" diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/client.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/client.py new file mode 100644 index 00000000..490bc4a2 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/client.py @@ -0,0 +1,414 @@ +"""Claude SDK Client for interacting with Claude Code.""" + +import json +import os +from collections.abc import AsyncIterable, AsyncIterator +from dataclasses import asdict, replace +from typing import Any + +from . import Transport +from ._errors import CLIConnectionError +from .types import ClaudeAgentOptions, HookEvent, HookMatcher, Message, ResultMessage + + +class ClaudeSDKClient: + """ + Client for bidirectional, interactive conversations with Claude Code. + + This client provides full control over the conversation flow with support + for streaming, interrupts, and dynamic message sending. For simple one-shot + queries, consider using the query() function instead. + + Key features: + - **Bidirectional**: Send and receive messages at any time + - **Stateful**: Maintains conversation context across messages + - **Interactive**: Send follow-ups based on responses + - **Control flow**: Support for interrupts and session management + + When to use ClaudeSDKClient: + - Building chat interfaces or conversational UIs + - Interactive debugging or exploration sessions + - Multi-turn conversations with context + - When you need to react to Claude's responses + - Real-time applications with user input + - When you need interrupt capabilities + + When to use query() instead: + - Simple one-off questions + - Batch processing of prompts + - Fire-and-forget automation scripts + - When all inputs are known upfront + - Stateless operations + + See examples/streaming_mode.py for full examples of ClaudeSDKClient in + different scenarios. + + Caveat: As of v0.0.20, you cannot use a ClaudeSDKClient instance across + different async runtime contexts (e.g., different trio nurseries or asyncio + task groups). The client internally maintains a persistent anyio task group + for reading messages that remains active from connect() until disconnect(). + This means you must complete all operations with the client within the same + async context where it was connected. Ideally, this limitation should not + exist. + """ + + def __init__( + self, + options: ClaudeAgentOptions | None = None, + transport: Transport | None = None, + ): + """Initialize Claude SDK client.""" + if options is None: + options = ClaudeAgentOptions() + self.options = options + self._custom_transport = transport + self._transport: Transport | None = None + self._query: Any | None = None + os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py-client" + + def _convert_hooks_to_internal_format( + self, hooks: dict[HookEvent, list[HookMatcher]] + ) -> dict[str, list[dict[str, Any]]]: + """Convert HookMatcher format to internal Query format.""" + internal_hooks: dict[str, list[dict[str, Any]]] = {} + for event, matchers in hooks.items(): + internal_hooks[event] = [] + for matcher in matchers: + # Convert HookMatcher to internal dict format + internal_matcher: dict[str, Any] = { + "matcher": matcher.matcher if hasattr(matcher, "matcher") else None, + "hooks": matcher.hooks if hasattr(matcher, "hooks") else [], + } + if hasattr(matcher, "timeout") and matcher.timeout is not None: + internal_matcher["timeout"] = matcher.timeout + internal_hooks[event].append(internal_matcher) + return internal_hooks + + async def connect( + self, prompt: str | AsyncIterable[dict[str, Any]] | None = None + ) -> None: + """Connect to Claude with a prompt or message stream.""" + + from ._internal.query import Query + from ._internal.transport.subprocess_cli import SubprocessCLITransport + + # Auto-connect with empty async iterable if no prompt is provided + async def _empty_stream() -> AsyncIterator[dict[str, Any]]: + # Never yields, but indicates that this function is an iterator and + # keeps the connection open. + # This yield is never reached but makes this an async generator + return + yield {} # type: ignore[unreachable] + + actual_prompt = _empty_stream() if prompt is None else prompt + + # Validate and configure permission settings (matching TypeScript SDK logic) + if self.options.can_use_tool: + # canUseTool callback requires streaming mode (AsyncIterable prompt) + if isinstance(prompt, str): + raise ValueError( + "can_use_tool callback requires streaming mode. " + "Please provide prompt as an AsyncIterable instead of a string." + ) + + # canUseTool and permission_prompt_tool_name are mutually exclusive + if self.options.permission_prompt_tool_name: + raise ValueError( + "can_use_tool callback cannot be used with permission_prompt_tool_name. " + "Please use one or the other." + ) + + # Automatically set permission_prompt_tool_name to "stdio" for control protocol + options = replace(self.options, permission_prompt_tool_name="stdio") + else: + options = self.options + + # Use provided custom transport or create subprocess transport + if self._custom_transport: + self._transport = self._custom_transport + else: + self._transport = SubprocessCLITransport( + prompt=actual_prompt, + options=options, + ) + await self._transport.connect() + + # Extract SDK MCP servers from options + sdk_mcp_servers = {} + if self.options.mcp_servers and isinstance(self.options.mcp_servers, dict): + for name, config in self.options.mcp_servers.items(): + if isinstance(config, dict) and config.get("type") == "sdk": + sdk_mcp_servers[name] = config["instance"] # type: ignore[typeddict-item] + + # Calculate initialize timeout from CLAUDE_CODE_STREAM_CLOSE_TIMEOUT env var if set + # CLAUDE_CODE_STREAM_CLOSE_TIMEOUT is in milliseconds, convert to seconds + initialize_timeout_ms = int( + os.environ.get("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "60000") + ) + initialize_timeout = max(initialize_timeout_ms / 1000.0, 60.0) + + # Convert agents to dict format for initialize request + agents_dict: dict[str, dict[str, Any]] | None = None + if self.options.agents: + agents_dict = { + name: {k: v for k, v in asdict(agent_def).items() if v is not None} + for name, agent_def in self.options.agents.items() + } + + # Create Query to handle control protocol + self._query = Query( + transport=self._transport, + is_streaming_mode=True, # ClaudeSDKClient always uses streaming mode + can_use_tool=self.options.can_use_tool, + hooks=self._convert_hooks_to_internal_format(self.options.hooks) + if self.options.hooks + else None, + sdk_mcp_servers=sdk_mcp_servers, + initialize_timeout=initialize_timeout, + agents=agents_dict, + ) + + # Start reading messages and initialize + await self._query.start() + await self._query.initialize() + + # If we have an initial prompt stream, start streaming it + if prompt is not None and isinstance(prompt, AsyncIterable) and self._query._tg: + self._query._tg.start_soon(self._query.stream_input, prompt) + + async def receive_messages(self) -> AsyncIterator[Message]: + """Receive all messages from Claude.""" + if not self._query: + raise CLIConnectionError("Not connected. Call connect() first.") + + from ._internal.message_parser import parse_message + + async for data in self._query.receive_messages(): + message = parse_message(data) + if message is not None: + yield message + + async def query( + self, prompt: str | AsyncIterable[dict[str, Any]], session_id: str = "default" + ) -> None: + """ + Send a new request in streaming mode. + + Args: + prompt: Either a string message or an async iterable of message dictionaries + session_id: Session identifier for the conversation + """ + if not self._query or not self._transport: + raise CLIConnectionError("Not connected. Call connect() first.") + + # Handle string prompts + if isinstance(prompt, str): + message = { + "type": "user", + "message": {"role": "user", "content": prompt}, + "parent_tool_use_id": None, + "session_id": session_id, + } + await self._transport.write(json.dumps(message) + "\n") + else: + # Handle AsyncIterable prompts - stream them + async for msg in prompt: + # Ensure session_id is set on each message + if "session_id" not in msg: + msg["session_id"] = session_id + await self._transport.write(json.dumps(msg) + "\n") + + async def interrupt(self) -> None: + """Send interrupt signal (only works with streaming mode).""" + if not self._query: + raise CLIConnectionError("Not connected. Call connect() first.") + await self._query.interrupt() + + async def set_permission_mode(self, mode: str) -> None: + """Change permission mode during conversation (only works with streaming mode). + + Args: + mode: The permission mode to set. Valid options: + - 'default': CLI prompts for dangerous tools + - 'acceptEdits': Auto-accept file edits + - 'bypassPermissions': Allow all tools (use with caution) + + Example: + ```python + async with ClaudeSDKClient() as client: + # Start with default permissions + await client.query("Help me analyze this codebase") + + # Review mode done, switch to auto-accept edits + await client.set_permission_mode('acceptEdits') + await client.query("Now implement the fix we discussed") + ``` + """ + if not self._query: + raise CLIConnectionError("Not connected. Call connect() first.") + await self._query.set_permission_mode(mode) + + async def set_model(self, model: str | None = None) -> None: + """Change the AI model during conversation (only works with streaming mode). + + Args: + model: The model to use, or None to use default. Examples: + - 'claude-sonnet-4-5' + - 'claude-opus-4-1-20250805' + - 'claude-opus-4-20250514' + + Example: + ```python + async with ClaudeSDKClient() as client: + # Start with default model + await client.query("Help me understand this problem") + + # Switch to a different model for implementation + await client.set_model('claude-sonnet-4-5') + await client.query("Now implement the solution") + ``` + """ + if not self._query: + raise CLIConnectionError("Not connected. Call connect() first.") + await self._query.set_model(model) + + async def rewind_files(self, user_message_id: str) -> None: + """Rewind tracked files to their state at a specific user message. + + Requires: + - `enable_file_checkpointing=True` to track file changes + - `extra_args={"replay-user-messages": None}` to receive UserMessage + objects with `uuid` in the response stream + + Args: + user_message_id: UUID of the user message to rewind to. This should be + the `uuid` field from a `UserMessage` received during the conversation. + + Example: + ```python + options = ClaudeAgentOptions( + enable_file_checkpointing=True, + extra_args={"replay-user-messages": None}, + ) + async with ClaudeSDKClient(options) as client: + await client.query("Make some changes to my files") + async for msg in client.receive_response(): + if isinstance(msg, UserMessage) and msg.uuid: + checkpoint_id = msg.uuid # Save this for later + + # Later, rewind to that point + await client.rewind_files(checkpoint_id) + ``` + """ + if not self._query: + raise CLIConnectionError("Not connected. Call connect() first.") + await self._query.rewind_files(user_message_id) + + async def get_mcp_status(self) -> dict[str, Any]: + """Get current MCP server connection status (only works with streaming mode). + + Queries the Claude Code CLI for the live connection status of all + configured MCP servers. + + Returns: + Dictionary with MCP server status information. Contains a + 'mcpServers' key with a list of server status objects, each having: + - 'name': Server name (str) + - 'status': Connection status ('connected', 'pending', 'failed', + 'needs-auth', 'disabled') + + Example: + ```python + async with ClaudeSDKClient(options) as client: + status = await client.get_mcp_status() + for server in status.get("mcpServers", []): + print(f"{server['name']}: {server['status']}") + ``` + """ + if not self._query: + raise CLIConnectionError("Not connected. Call connect() first.") + result: dict[str, Any] = await self._query.get_mcp_status() + return result + + async def get_server_info(self) -> dict[str, Any] | None: + """Get server initialization info including available commands and output styles. + + Returns initialization information from the Claude Code server including: + - Available commands (slash commands, system commands, etc.) + - Current and available output styles + - Server capabilities + + Returns: + Dictionary with server info, or None if not in streaming mode + + Example: + ```python + async with ClaudeSDKClient() as client: + info = await client.get_server_info() + if info: + print(f"Commands available: {len(info.get('commands', []))}") + print(f"Output style: {info.get('output_style', 'default')}") + ``` + """ + if not self._query: + raise CLIConnectionError("Not connected. Call connect() first.") + # Return the initialization result that was already obtained during connect + return getattr(self._query, "_initialization_result", None) + + async def receive_response(self) -> AsyncIterator[Message]: + """ + Receive messages from Claude until and including a ResultMessage. + + This async iterator yields all messages in sequence and automatically terminates + after yielding a ResultMessage (which indicates the response is complete). + It's a convenience method over receive_messages() for single-response workflows. + + **Stopping Behavior:** + - Yields each message as it's received + - Terminates immediately after yielding a ResultMessage + - The ResultMessage IS included in the yielded messages + - If no ResultMessage is received, the iterator continues indefinitely + + Yields: + Message: Each message received (UserMessage, AssistantMessage, SystemMessage, ResultMessage) + + Example: + ```python + async with ClaudeSDKClient() as client: + await client.query("What's the capital of France?") + + async for msg in client.receive_response(): + if isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + print(f"Claude: {block.text}") + elif isinstance(msg, ResultMessage): + print(f"Cost: ${msg.total_cost_usd:.4f}") + # Iterator will terminate after this message + ``` + + Note: + To collect all messages: `messages = [msg async for msg in client.receive_response()]` + The final message in the list will always be a ResultMessage. + """ + async for message in self.receive_messages(): + yield message + if isinstance(message, ResultMessage): + return + + async def disconnect(self) -> None: + """Disconnect from Claude.""" + if self._query: + await self._query.close() + self._query = None + self._transport = None + + async def __aenter__(self) -> "ClaudeSDKClient": + """Enter async context - automatically connects with empty stream for interactive use.""" + await self.connect() + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool: + """Exit async context - always disconnects.""" + await self.disconnect() + return False diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/py.typed b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/query.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/query.py new file mode 100644 index 00000000..98ed0c1c --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/query.py @@ -0,0 +1,126 @@ +"""Query function for one-shot interactions with Claude Code.""" + +import os +from collections.abc import AsyncIterable, AsyncIterator +from typing import Any + +from ._internal.client import InternalClient +from ._internal.transport import Transport +from .types import ClaudeAgentOptions, Message + + +async def query( + *, + prompt: str | AsyncIterable[dict[str, Any]], + options: ClaudeAgentOptions | None = None, + transport: Transport | None = None, +) -> AsyncIterator[Message]: + """ + Query Claude Code for one-shot or unidirectional streaming interactions. + + This function is ideal for simple, stateless queries where you don't need + bidirectional communication or conversation management. For interactive, + stateful conversations, use ClaudeSDKClient instead. + + Key differences from ClaudeSDKClient: + - **Unidirectional**: Send all messages upfront, receive all responses + - **Stateless**: Each query is independent, no conversation state + - **Simple**: Fire-and-forget style, no connection management + - **No interrupts**: Cannot interrupt or send follow-up messages + + When to use query(): + - Simple one-off questions ("What is 2+2?") + - Batch processing of independent prompts + - Code generation or analysis tasks + - Automated scripts and CI/CD pipelines + - When you know all inputs upfront + + When to use ClaudeSDKClient: + - Interactive conversations with follow-ups + - Chat applications or REPL-like interfaces + - When you need to send messages based on responses + - When you need interrupt capabilities + - Long-running sessions with state + + Args: + prompt: The prompt to send to Claude. Can be a string for single-shot queries + or an AsyncIterable[dict] for streaming mode with continuous interaction. + In streaming mode, each dict should have the structure: + { + "type": "user", + "message": {"role": "user", "content": "..."}, + "parent_tool_use_id": None, + "session_id": "..." + } + options: Optional configuration (defaults to ClaudeAgentOptions() if None). + Set options.permission_mode to control tool execution: + - 'default': CLI prompts for dangerous tools + - 'acceptEdits': Auto-accept file edits + - 'bypassPermissions': Allow all tools (use with caution) + Set options.cwd for working directory. + transport: Optional transport implementation. If provided, this will be used + instead of the default transport selection based on options. + The transport will be automatically configured with the prompt and options. + + Yields: + Messages from the conversation + + Example - Simple query: + ```python + # One-off question + async for message in query(prompt="What is the capital of France?"): + print(message) + ``` + + Example - With options: + ```python + # Code generation with specific settings + async for message in query( + prompt="Create a Python web server", + options=ClaudeAgentOptions( + system_prompt="You are an expert Python developer", + cwd="/home/user/project" + ) + ): + print(message) + ``` + + Example - Streaming mode (still unidirectional): + ```python + async def prompts(): + yield {"type": "user", "message": {"role": "user", "content": "Hello"}} + yield {"type": "user", "message": {"role": "user", "content": "How are you?"}} + + # All prompts are sent, then all responses received + async for message in query(prompt=prompts()): + print(message) + ``` + + Example - With custom transport: + ```python + from claude_agent_sdk import query, Transport + + class MyCustomTransport(Transport): + # Implement custom transport logic + pass + + transport = MyCustomTransport() + async for message in query( + prompt="Hello", + transport=transport + ): + print(message) + ``` + + """ + if options is None: + options = ClaudeAgentOptions() + + os.environ["CLAUDE_CODE_ENTRYPOINT"] = "sdk-py" + + client = InternalClient() + + async for message in client.process_query( + prompt=prompt, options=options, transport=transport + ): + yield message diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/types.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/types.py new file mode 100644 index 00000000..3ea89d5a --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/src/claude_agent_sdk/types.py @@ -0,0 +1,859 @@ +"""Type definitions for Claude SDK.""" + +import sys +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, TypedDict + +from typing_extensions import NotRequired + +if TYPE_CHECKING: + from mcp.server import Server as McpServer +else: + # Runtime placeholder for forward reference resolution in Pydantic 2.12+ + McpServer = Any + +# Permission modes +PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"] + +# SDK Beta features - see https://docs.anthropic.com/en/api/beta-headers +SdkBeta = Literal["context-1m-2025-08-07"] + +# Agent definitions +SettingSource = Literal["user", "project", "local"] + + +class SystemPromptPreset(TypedDict): + """System prompt preset configuration.""" + + type: Literal["preset"] + preset: Literal["claude_code"] + append: NotRequired[str] + + +class ToolsPreset(TypedDict): + """Tools preset configuration.""" + + type: Literal["preset"] + preset: Literal["claude_code"] + + +@dataclass +class AgentDefinition: + """Agent definition configuration.""" + + description: str + prompt: str + tools: list[str] | None = None + model: Literal["sonnet", "opus", "haiku", "inherit"] | None = None + + +# Permission Update types (matching TypeScript SDK) +PermissionUpdateDestination = Literal[ + "userSettings", "projectSettings", "localSettings", "session" +] + +PermissionBehavior = Literal["allow", "deny", "ask"] + + +@dataclass +class PermissionRuleValue: + """Permission rule value.""" + + tool_name: str + rule_content: str | None = None + + +@dataclass +class PermissionUpdate: + """Permission update configuration.""" + + type: Literal[ + "addRules", + "replaceRules", + "removeRules", + "setMode", + "addDirectories", + "removeDirectories", + ] + rules: list[PermissionRuleValue] | None = None + behavior: PermissionBehavior | None = None + mode: PermissionMode | None = None + directories: list[str] | None = None + destination: PermissionUpdateDestination | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert PermissionUpdate to dictionary format matching TypeScript control protocol.""" + result: dict[str, Any] = { + "type": self.type, + } + + # Add destination for all variants + if self.destination is not None: + result["destination"] = self.destination + + # Handle different type variants + if self.type in ["addRules", "replaceRules", "removeRules"]: + # Rules-based variants require rules and behavior + if self.rules is not None: + result["rules"] = [ + { + "toolName": rule.tool_name, + "ruleContent": rule.rule_content, + } + for rule in self.rules + ] + if self.behavior is not None: + result["behavior"] = self.behavior + + elif self.type == "setMode": + # Mode variant requires mode + if self.mode is not None: + result["mode"] = self.mode + + elif self.type in ["addDirectories", "removeDirectories"]: + # Directory variants require directories + if self.directories is not None: + result["directories"] = self.directories + + return result + + +# Tool callback types +@dataclass +class ToolPermissionContext: + """Context information for tool permission callbacks.""" + + signal: Any | None = None # Future: abort signal support + suggestions: list[PermissionUpdate] = field( + default_factory=list + ) # Permission suggestions from CLI + + +# Match TypeScript's PermissionResult structure +@dataclass +class PermissionResultAllow: + """Allow permission result.""" + + behavior: Literal["allow"] = "allow" + updated_input: dict[str, Any] | None = None + updated_permissions: list[PermissionUpdate] | None = None + + +@dataclass +class PermissionResultDeny: + """Deny permission result.""" + + behavior: Literal["deny"] = "deny" + message: str = "" + interrupt: bool = False + + +PermissionResult = PermissionResultAllow | PermissionResultDeny + +CanUseTool = Callable[ + [str, dict[str, Any], ToolPermissionContext], Awaitable[PermissionResult] +] + + +##### Hook types +HookEvent = ( + Literal["PreToolUse"] + | Literal["PostToolUse"] + | Literal["PostToolUseFailure"] + | Literal["UserPromptSubmit"] + | Literal["Stop"] + | Literal["SubagentStop"] + | Literal["PreCompact"] + | Literal["Notification"] + | Literal["SubagentStart"] + | Literal["PermissionRequest"] +) + + +# Hook input types - strongly typed for each hook event +class BaseHookInput(TypedDict): + """Base hook input fields present across many hook events.""" + + session_id: str + transcript_path: str + cwd: str + permission_mode: NotRequired[str] + + +class PreToolUseHookInput(BaseHookInput): + """Input data for PreToolUse hook events.""" + + hook_event_name: Literal["PreToolUse"] + tool_name: str + tool_input: dict[str, Any] + tool_use_id: str + + +class PostToolUseHookInput(BaseHookInput): + """Input data for PostToolUse hook events.""" + + hook_event_name: Literal["PostToolUse"] + tool_name: str + tool_input: dict[str, Any] + tool_response: Any + tool_use_id: str + + +class PostToolUseFailureHookInput(BaseHookInput): + """Input data for PostToolUseFailure hook events.""" + + hook_event_name: Literal["PostToolUseFailure"] + tool_name: str + tool_input: dict[str, Any] + tool_use_id: str + error: str + is_interrupt: NotRequired[bool] + + +class UserPromptSubmitHookInput(BaseHookInput): + """Input data for UserPromptSubmit hook events.""" + + hook_event_name: Literal["UserPromptSubmit"] + prompt: str + + +class StopHookInput(BaseHookInput): + """Input data for Stop hook events.""" + + hook_event_name: Literal["Stop"] + stop_hook_active: bool + + +class SubagentStopHookInput(BaseHookInput): + """Input data for SubagentStop hook events.""" + + hook_event_name: Literal["SubagentStop"] + stop_hook_active: bool + agent_id: str + agent_transcript_path: str + agent_type: str + + +class PreCompactHookInput(BaseHookInput): + """Input data for PreCompact hook events.""" + + hook_event_name: Literal["PreCompact"] + trigger: Literal["manual", "auto"] + custom_instructions: str | None + + +class NotificationHookInput(BaseHookInput): + """Input data for Notification hook events.""" + + hook_event_name: Literal["Notification"] + message: str + title: NotRequired[str] + notification_type: str + + +class SubagentStartHookInput(BaseHookInput): + """Input data for SubagentStart hook events.""" + + hook_event_name: Literal["SubagentStart"] + agent_id: str + agent_type: str + + +class PermissionRequestHookInput(BaseHookInput): + """Input data for PermissionRequest hook events.""" + + hook_event_name: Literal["PermissionRequest"] + tool_name: str + tool_input: dict[str, Any] + permission_suggestions: NotRequired[list[Any]] + + +# Union type for all hook inputs +HookInput = ( + PreToolUseHookInput + | PostToolUseHookInput + | PostToolUseFailureHookInput + | UserPromptSubmitHookInput + | StopHookInput + | SubagentStopHookInput + | PreCompactHookInput + | NotificationHookInput + | SubagentStartHookInput + | PermissionRequestHookInput +) + + +# Hook-specific output types +class PreToolUseHookSpecificOutput(TypedDict): + """Hook-specific output for PreToolUse events.""" + + hookEventName: Literal["PreToolUse"] + permissionDecision: NotRequired[Literal["allow", "deny", "ask"]] + permissionDecisionReason: NotRequired[str] + updatedInput: NotRequired[dict[str, Any]] + additionalContext: NotRequired[str] + + +class PostToolUseHookSpecificOutput(TypedDict): + """Hook-specific output for PostToolUse events.""" + + hookEventName: Literal["PostToolUse"] + additionalContext: NotRequired[str] + updatedMCPToolOutput: NotRequired[Any] + + +class PostToolUseFailureHookSpecificOutput(TypedDict): + """Hook-specific output for PostToolUseFailure events.""" + + hookEventName: Literal["PostToolUseFailure"] + additionalContext: NotRequired[str] + + +class UserPromptSubmitHookSpecificOutput(TypedDict): + """Hook-specific output for UserPromptSubmit events.""" + + hookEventName: Literal["UserPromptSubmit"] + additionalContext: NotRequired[str] + + +class SessionStartHookSpecificOutput(TypedDict): + """Hook-specific output for SessionStart events.""" + + hookEventName: Literal["SessionStart"] + additionalContext: NotRequired[str] + + +class NotificationHookSpecificOutput(TypedDict): + """Hook-specific output for Notification events.""" + + hookEventName: Literal["Notification"] + additionalContext: NotRequired[str] + + +class SubagentStartHookSpecificOutput(TypedDict): + """Hook-specific output for SubagentStart events.""" + + hookEventName: Literal["SubagentStart"] + additionalContext: NotRequired[str] + + +class PermissionRequestHookSpecificOutput(TypedDict): + """Hook-specific output for PermissionRequest events.""" + + hookEventName: Literal["PermissionRequest"] + decision: dict[str, Any] + + +HookSpecificOutput = ( + PreToolUseHookSpecificOutput + | PostToolUseHookSpecificOutput + | PostToolUseFailureHookSpecificOutput + | UserPromptSubmitHookSpecificOutput + | SessionStartHookSpecificOutput + | NotificationHookSpecificOutput + | SubagentStartHookSpecificOutput + | PermissionRequestHookSpecificOutput +) + + +# See https://docs.anthropic.com/en/docs/claude-code/hooks#advanced%3A-json-output +# for documentation of the output types. +# +# IMPORTANT: The Python SDK uses `async_` and `continue_` (with underscores) to avoid +# Python keyword conflicts. These fields are automatically converted to `async` and +# `continue` when sent to the CLI. You should use the underscore versions in your +# Python code. +class AsyncHookJSONOutput(TypedDict): + """Async hook output that defers hook execution. + + Fields: + async_: Set to True to defer hook execution. Note: This is converted to + "async" when sent to the CLI - use "async_" in your Python code. + asyncTimeout: Optional timeout in milliseconds for the async operation. + """ + + async_: Literal[ + True + ] # Using async_ to avoid Python keyword (converted to "async" for CLI) + asyncTimeout: NotRequired[int] + + +class SyncHookJSONOutput(TypedDict): + """Synchronous hook output with control and decision fields. + + This defines the structure for hook callbacks to control execution and provide + feedback to Claude. + + Common Control Fields: + continue_: Whether Claude should proceed after hook execution (default: True). + Note: This is converted to "continue" when sent to the CLI. + suppressOutput: Hide stdout from transcript mode (default: False). + stopReason: Message shown when continue is False. + + Decision Fields: + decision: Set to "block" to indicate blocking behavior. + systemMessage: Warning message displayed to the user. + reason: Feedback message for Claude about the decision. + + Hook-Specific Output: + hookSpecificOutput: Event-specific controls (e.g., permissionDecision for + PreToolUse, additionalContext for PostToolUse). + + Note: The CLI documentation shows field names without underscores ("async", "continue"), + but Python code should use the underscore versions ("async_", "continue_") as they + are automatically converted. + """ + + # Common control fields + continue_: NotRequired[ + bool + ] # Using continue_ to avoid Python keyword (converted to "continue" for CLI) + suppressOutput: NotRequired[bool] + stopReason: NotRequired[str] + + # Decision fields + # Note: "approve" is deprecated for PreToolUse (use permissionDecision instead) + # For other hooks, only "block" is meaningful + decision: NotRequired[Literal["block"]] + systemMessage: NotRequired[str] + reason: NotRequired[str] + + # Hook-specific outputs + hookSpecificOutput: NotRequired[HookSpecificOutput] + + +HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput + + +class HookContext(TypedDict): + """Context information for hook callbacks. + + Fields: + signal: Reserved for future abort signal support. Currently always None. + """ + + signal: Any | None # Future: abort signal support + + +HookCallback = Callable[ + # HookCallback input parameters: + # - input: Strongly-typed hook input with discriminated unions based on hook_event_name + # - tool_use_id: Optional tool use identifier + # - context: Hook context with abort signal support (currently placeholder) + [HookInput, str | None, HookContext], + Awaitable[HookJSONOutput], +] + + +# Hook matcher configuration +@dataclass +class HookMatcher: + """Hook matcher configuration.""" + + # See https://docs.anthropic.com/en/docs/claude-code/hooks#structure for the + # expected string value. For example, for PreToolUse, the matcher can be + # a tool name like "Bash" or a combination of tool names like + # "Write|MultiEdit|Edit". + matcher: str | None = None + + # A list of Python functions with function signature HookCallback + hooks: list[HookCallback] = field(default_factory=list) + + # Timeout in seconds for all hooks in this matcher (default: 60) + timeout: float | None = None + + +# MCP Server config +class McpStdioServerConfig(TypedDict): + """MCP stdio server configuration.""" + + type: NotRequired[Literal["stdio"]] # Optional for backwards compatibility + command: str + args: NotRequired[list[str]] + env: NotRequired[dict[str, str]] + + +class McpSSEServerConfig(TypedDict): + """MCP SSE server configuration.""" + + type: Literal["sse"] + url: str + headers: NotRequired[dict[str, str]] + + +class McpHttpServerConfig(TypedDict): + """MCP HTTP server configuration.""" + + type: Literal["http"] + url: str + headers: NotRequired[dict[str, str]] + + +class McpSdkServerConfig(TypedDict): + """SDK MCP server configuration.""" + + type: Literal["sdk"] + name: str + instance: "McpServer" + + +McpServerConfig = ( + McpStdioServerConfig | McpSSEServerConfig | McpHttpServerConfig | McpSdkServerConfig +) + + +class SdkPluginConfig(TypedDict): + """SDK plugin configuration. + + Currently only local plugins are supported via the 'local' type. + """ + + type: Literal["local"] + path: str + + +# Sandbox configuration types +class SandboxNetworkConfig(TypedDict, total=False): + """Network configuration for sandbox. + + Attributes: + allowUnixSockets: Unix socket paths accessible in sandbox (e.g., SSH agents). + allowAllUnixSockets: Allow all Unix sockets (less secure). + allowLocalBinding: Allow binding to localhost ports (macOS only). + httpProxyPort: HTTP proxy port if bringing your own proxy. + socksProxyPort: SOCKS5 proxy port if bringing your own proxy. + """ + + allowUnixSockets: list[str] + allowAllUnixSockets: bool + allowLocalBinding: bool + httpProxyPort: int + socksProxyPort: int + + +class SandboxIgnoreViolations(TypedDict, total=False): + """Violations to ignore in sandbox. + + Attributes: + file: File paths for which violations should be ignored. + network: Network hosts for which violations should be ignored. + """ + + file: list[str] + network: list[str] + + +class SandboxSettings(TypedDict, total=False): + """Sandbox settings configuration. + + This controls how Claude Code sandboxes bash commands for filesystem + and network isolation. + + **Important:** Filesystem and network restrictions are configured via permission + rules, not via these sandbox settings: + - Filesystem read restrictions: Use Read deny rules + - Filesystem write restrictions: Use Edit allow/deny rules + - Network restrictions: Use WebFetch allow/deny rules + + Attributes: + enabled: Enable bash sandboxing (macOS/Linux only). Default: False + autoAllowBashIfSandboxed: Auto-approve bash commands when sandboxed. Default: True + excludedCommands: Commands that should run outside the sandbox (e.g., ["git", "docker"]) + allowUnsandboxedCommands: Allow commands to bypass sandbox via dangerouslyDisableSandbox. + When False, all commands must run sandboxed (or be in excludedCommands). Default: True + network: Network configuration for sandbox. + ignoreViolations: Violations to ignore. + enableWeakerNestedSandbox: Enable weaker sandbox for unprivileged Docker environments + (Linux only). Reduces security. Default: False + + Example: + ```python + sandbox_settings: SandboxSettings = { + "enabled": True, + "autoAllowBashIfSandboxed": True, + "excludedCommands": ["docker"], + "network": { + "allowUnixSockets": ["/var/run/docker.sock"], + "allowLocalBinding": True + } + } + ``` + """ + + enabled: bool + autoAllowBashIfSandboxed: bool + excludedCommands: list[str] + allowUnsandboxedCommands: bool + network: SandboxNetworkConfig + ignoreViolations: SandboxIgnoreViolations + enableWeakerNestedSandbox: bool + + +# Content block types +@dataclass +class TextBlock: + """Text content block.""" + + text: str + + +@dataclass +class ThinkingBlock: + """Thinking content block.""" + + thinking: str + signature: str + + +@dataclass +class ToolUseBlock: + """Tool use content block.""" + + id: str + name: str + input: dict[str, Any] + + +@dataclass +class ToolResultBlock: + """Tool result content block.""" + + tool_use_id: str + content: str | list[dict[str, Any]] | None = None + is_error: bool | None = None + + +ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock + + +# Message types +AssistantMessageError = Literal[ + "authentication_failed", + "billing_error", + "rate_limit", + "invalid_request", + "server_error", + "unknown", +] + + +@dataclass +class UserMessage: + """User message.""" + + content: str | list[ContentBlock] + uuid: str | None = None + parent_tool_use_id: str | None = None + tool_use_result: dict[str, Any] | None = None + + +@dataclass +class AssistantMessage: + """Assistant message with content blocks.""" + + content: list[ContentBlock] + model: str + parent_tool_use_id: str | None = None + error: AssistantMessageError | None = None + + +@dataclass +class SystemMessage: + """System message with metadata.""" + + subtype: str + data: dict[str, Any] + + +@dataclass +class ResultMessage: + """Result message with cost and usage information.""" + + subtype: str + duration_ms: int + duration_api_ms: int + is_error: bool + num_turns: int + session_id: str + total_cost_usd: float | None = None + usage: dict[str, Any] | None = None + result: str | None = None + structured_output: Any = None + + +@dataclass +class StreamEvent: + """Stream event for partial message updates during streaming.""" + + uuid: str + session_id: str + event: dict[str, Any] # The raw Anthropic API stream event + parent_tool_use_id: str | None = None + + +Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage | StreamEvent + + +class ThinkingConfigAdaptive(TypedDict): + type: Literal["adaptive"] + + +class ThinkingConfigEnabled(TypedDict): + type: Literal["enabled"] + budget_tokens: int + + +class ThinkingConfigDisabled(TypedDict): + type: Literal["disabled"] + + +ThinkingConfig = ThinkingConfigAdaptive | ThinkingConfigEnabled | ThinkingConfigDisabled + + +@dataclass +class ClaudeAgentOptions: + """Query options for Claude SDK.""" + + tools: list[str] | ToolsPreset | None = None + allowed_tools: list[str] = field(default_factory=list) + system_prompt: str | SystemPromptPreset | None = None + mcp_servers: dict[str, McpServerConfig] | str | Path = field(default_factory=dict) + permission_mode: PermissionMode | None = None + continue_conversation: bool = False + resume: str | None = None + max_turns: int | None = None + max_budget_usd: float | None = None + disallowed_tools: list[str] = field(default_factory=list) + model: str | None = None + fallback_model: str | None = None + # Beta features - see https://docs.anthropic.com/en/api/beta-headers + betas: list[SdkBeta] = field(default_factory=list) + permission_prompt_tool_name: str | None = None + cwd: str | Path | None = None + cli_path: str | Path | None = None + settings: str | None = None + add_dirs: list[str | Path] = field(default_factory=list) + env: dict[str, str] = field(default_factory=dict) + extra_args: dict[str, str | None] = field( + default_factory=dict + ) # Pass arbitrary CLI flags + max_buffer_size: int | None = None # Max bytes when buffering CLI stdout + debug_stderr: Any = ( + sys.stderr + ) # Deprecated: File-like object for debug output. Use stderr callback instead. + stderr: Callable[[str], None] | None = None # Callback for stderr output from CLI + + # Tool permission callback + can_use_tool: CanUseTool | None = None + + # Hook configurations + hooks: dict[HookEvent, list[HookMatcher]] | None = None + + user: str | None = None + + # Partial message streaming support + include_partial_messages: bool = False + # When true resumed sessions will fork to a new session ID rather than + # continuing the previous session. + fork_session: bool = False + # Agent definitions for custom agents + agents: dict[str, AgentDefinition] | None = None + # Setting sources to load (user, project, local) + setting_sources: list[SettingSource] | None = None + # Sandbox configuration for bash command isolation. + # Filesystem and network restrictions are derived from permission rules (Read/Edit/WebFetch), + # not from these sandbox settings. + sandbox: SandboxSettings | None = None + # Plugin configurations for custom plugins + plugins: list[SdkPluginConfig] = field(default_factory=list) + # Max tokens for thinking blocks + # @deprecated Use `thinking` instead. + max_thinking_tokens: int | None = None + # Controls extended thinking behavior. Takes precedence over max_thinking_tokens. + thinking: ThinkingConfig | None = None + # Effort level for thinking depth. + effort: Literal["low", "medium", "high", "max"] | None = None + # Output format for structured outputs (matches Messages API structure) + # Example: {"type": "json_schema", "schema": {"type": "object", "properties": {...}}} + output_format: dict[str, Any] | None = None + # Enable file checkpointing to track file changes during the session. + # When enabled, files can be rewound to their state at any user message + # using `ClaudeSDKClient.rewind_files()`. + enable_file_checkpointing: bool = False + + +# SDK Control Protocol +class SDKControlInterruptRequest(TypedDict): + subtype: Literal["interrupt"] + + +class SDKControlPermissionRequest(TypedDict): + subtype: Literal["can_use_tool"] + tool_name: str + input: dict[str, Any] + # TODO: Add PermissionUpdate type here + permission_suggestions: list[Any] | None + blocked_path: str | None + + +class SDKControlInitializeRequest(TypedDict): + subtype: Literal["initialize"] + hooks: dict[HookEvent, Any] | None + agents: NotRequired[dict[str, dict[str, Any]]] + + +class SDKControlSetPermissionModeRequest(TypedDict): + subtype: Literal["set_permission_mode"] + # TODO: Add PermissionMode + mode: str + + +class SDKHookCallbackRequest(TypedDict): + subtype: Literal["hook_callback"] + callback_id: str + input: Any + tool_use_id: str | None + + +class SDKControlMcpMessageRequest(TypedDict): + subtype: Literal["mcp_message"] + server_name: str + message: Any + + +class SDKControlRewindFilesRequest(TypedDict): + subtype: Literal["rewind_files"] + user_message_id: str + + +class SDKControlRequest(TypedDict): + type: Literal["control_request"] + request_id: str + request: ( + SDKControlInterruptRequest + | SDKControlPermissionRequest + | SDKControlInitializeRequest + | SDKControlSetPermissionModeRequest + | SDKHookCallbackRequest + | SDKControlMcpMessageRequest + | SDKControlRewindFilesRequest + ) + + +class ControlResponse(TypedDict): + subtype: Literal["success"] + request_id: str + response: dict[str, Any] | None + + +class ControlErrorResponse(TypedDict): + subtype: Literal["error"] + request_id: str + error: str + + +class SDKControlResponse(TypedDict): + type: Literal["control_response"] + response: ControlResponse | ControlErrorResponse diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/conftest.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/conftest.py new file mode 100644 index 00000000..15d60bab --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/conftest.py @@ -0,0 +1,4 @@ +"""Pytest configuration for tests.""" + + +# No async plugin needed since we're using sync tests with anyio.run() diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_changelog.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_changelog.py new file mode 100644 index 00000000..be0f219c --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_changelog.py @@ -0,0 +1,85 @@ +import re +from pathlib import Path + + +class TestChangelog: + def setup_method(self): + self.changelog_path = Path(__file__).parent.parent / "CHANGELOG.md" + + def test_changelog_exists(self): + assert self.changelog_path.exists(), "CHANGELOG.md file should exist" + + def test_changelog_starts_with_header(self): + content = self.changelog_path.read_text() + assert content.startswith("# Changelog"), ( + "Changelog should start with '# Changelog'" + ) + + def test_changelog_has_valid_version_format(self): + content = self.changelog_path.read_text() + lines = content.split("\n") + + version_pattern = re.compile(r"^## \d+\.\d+\.\d+(?:\s+\(\d{4}-\d{2}-\d{2}\))?$") + versions = [] + + for line in lines: + if line.startswith("## "): + assert version_pattern.match(line), f"Invalid version format: {line}" + version_match = re.match(r"^## (\d+\.\d+\.\d+)", line) + if version_match: + versions.append(version_match.group(1)) + + assert len(versions) > 0, "Changelog should contain at least one version" + + def test_changelog_has_bullet_points(self): + content = self.changelog_path.read_text() + lines = content.split("\n") + + in_version_section = False + has_bullet_points = False + + for i, line in enumerate(lines): + if line.startswith("## "): + if in_version_section and not has_bullet_points: + raise AssertionError( + "Previous version section should have at least one bullet point" + ) + in_version_section = True + has_bullet_points = False + elif in_version_section and line.startswith("- "): + has_bullet_points = True + elif in_version_section and line.strip() == "" and i == len(lines) - 1: + # Last line check + assert has_bullet_points, ( + "Each version should have at least one bullet point" + ) + + # Check the last section + if in_version_section: + assert has_bullet_points, ( + "Last version section should have at least one bullet point" + ) + + def test_changelog_versions_in_descending_order(self): + content = self.changelog_path.read_text() + lines = content.split("\n") + + versions = [] + for line in lines: + if line.startswith("## "): + version_match = re.match(r"^## (\d+)\.(\d+)\.(\d+)", line) + if version_match: + versions.append(tuple(map(int, version_match.groups()))) + + for i in range(1, len(versions)): + assert versions[i - 1] > versions[i], ( + f"Versions should be in descending order: {versions[i - 1]} should be > {versions[i]}" + ) + + def test_changelog_no_empty_bullet_points(self): + content = self.changelog_path.read_text() + lines = content.split("\n") + + for line in lines: + if line.strip() == "-": + raise AssertionError("Changelog should not have empty bullet points") diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_client.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_client.py new file mode 100644 index 00000000..b80bb3f2 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_client.py @@ -0,0 +1,129 @@ +"""Tests for Claude SDK client functionality.""" + +from unittest.mock import AsyncMock, Mock, patch + +import anyio + +from claude_agent_sdk import AssistantMessage, ClaudeAgentOptions, query +from claude_agent_sdk.types import TextBlock + + +class TestQueryFunction: + """Test the main query function.""" + + def test_query_single_prompt(self): + """Test query with a single prompt.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.client.InternalClient.process_query" + ) as mock_process: + # Mock the async generator + async def mock_generator(): + yield AssistantMessage( + content=[TextBlock(text="4")], model="claude-opus-4-1-20250805" + ) + + mock_process.return_value = mock_generator() + + messages = [] + async for msg in query(prompt="What is 2+2?"): + messages.append(msg) + + assert len(messages) == 1 + assert isinstance(messages[0], AssistantMessage) + assert messages[0].content[0].text == "4" + + anyio.run(_test) + + def test_query_with_options(self): + """Test query with various options.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.client.InternalClient.process_query" + ) as mock_process: + + async def mock_generator(): + yield AssistantMessage( + content=[TextBlock(text="Hello!")], + model="claude-opus-4-1-20250805", + ) + + mock_process.return_value = mock_generator() + + options = ClaudeAgentOptions( + allowed_tools=["Read", "Write"], + system_prompt="You are helpful", + permission_mode="acceptEdits", + max_turns=5, + ) + + messages = [] + async for msg in query(prompt="Hi", options=options): + messages.append(msg) + + # Verify process_query was called with correct prompt and options + mock_process.assert_called_once() + call_args = mock_process.call_args + assert call_args[1]["prompt"] == "Hi" + assert call_args[1]["options"] == options + + anyio.run(_test) + + def test_query_with_cwd(self): + """Test query with custom working directory.""" + + async def _test(): + with ( + patch( + "claude_agent_sdk._internal.client.SubprocessCLITransport" + ) as mock_transport_class, + patch( + "claude_agent_sdk._internal.query.Query.initialize", + new_callable=AsyncMock, + ), + ): + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock the message stream + async def mock_receive(): + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Done"}], + "model": "claude-opus-4-1-20250805", + }, + } + yield { + "type": "result", + "subtype": "success", + "duration_ms": 1000, + "duration_api_ms": 800, + "is_error": False, + "num_turns": 1, + "session_id": "test-session", + "total_cost_usd": 0.001, + } + + mock_transport.read_messages = mock_receive + mock_transport.connect = AsyncMock() + mock_transport.close = AsyncMock() + mock_transport.end_input = AsyncMock() + mock_transport.write = AsyncMock() + mock_transport.is_ready = Mock(return_value=True) + + options = ClaudeAgentOptions(cwd="/custom/path") + messages = [] + async for msg in query(prompt="test", options=options): + messages.append(msg) + + # Verify transport was created with correct parameters + mock_transport_class.assert_called_once() + call_kwargs = mock_transport_class.call_args.kwargs + assert call_kwargs["prompt"] == "test" + assert call_kwargs["options"].cwd == "/custom/path" + + anyio.run(_test) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_errors.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_errors.py new file mode 100644 index 00000000..9490d075 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_errors.py @@ -0,0 +1,52 @@ +"""Tests for Claude SDK error handling.""" + +from claude_agent_sdk import ( + ClaudeSDKError, + CLIConnectionError, + CLIJSONDecodeError, + CLINotFoundError, + ProcessError, +) + + +class TestErrorTypes: + """Test error types and their properties.""" + + def test_base_error(self): + """Test base ClaudeSDKError.""" + error = ClaudeSDKError("Something went wrong") + assert str(error) == "Something went wrong" + assert isinstance(error, Exception) + + def test_cli_not_found_error(self): + """Test CLINotFoundError.""" + error = CLINotFoundError("Claude Code not found") + assert isinstance(error, ClaudeSDKError) + assert "Claude Code not found" in str(error) + + def test_connection_error(self): + """Test CLIConnectionError.""" + error = CLIConnectionError("Failed to connect to CLI") + assert isinstance(error, ClaudeSDKError) + assert "Failed to connect to CLI" in str(error) + + def test_process_error(self): + """Test ProcessError with exit code and stderr.""" + error = ProcessError("Process failed", exit_code=1, stderr="Command not found") + assert error.exit_code == 1 + assert error.stderr == "Command not found" + assert "Process failed" in str(error) + assert "exit code: 1" in str(error) + assert "Command not found" in str(error) + + def test_json_decode_error(self): + """Test CLIJSONDecodeError.""" + import json + + try: + json.loads("{invalid json}") + except json.JSONDecodeError as e: + error = CLIJSONDecodeError("{invalid json}", e) + assert error.line == "{invalid json}" + assert error.original_error == e + assert "Failed to decode JSON" in str(error) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_integration.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_integration.py new file mode 100644 index 00000000..5b434546 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_integration.py @@ -0,0 +1,308 @@ +"""Integration tests for Claude SDK. + +These tests verify end-to-end functionality with mocked CLI responses. +""" + +from unittest.mock import AsyncMock, Mock, patch + +import anyio +import pytest + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + CLINotFoundError, + ResultMessage, + query, +) +from claude_agent_sdk.types import ToolUseBlock + + +class TestIntegration: + """End-to-end integration tests.""" + + def test_simple_query_response(self): + """Test a simple query with text response.""" + + async def _test(): + with ( + patch( + "claude_agent_sdk._internal.client.SubprocessCLITransport" + ) as mock_transport_class, + patch( + "claude_agent_sdk._internal.query.Query.initialize", + new_callable=AsyncMock, + ), + ): + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock the message stream + async def mock_receive(): + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "2 + 2 equals 4"}], + "model": "claude-opus-4-1-20250805", + }, + } + yield { + "type": "result", + "subtype": "success", + "duration_ms": 1000, + "duration_api_ms": 800, + "is_error": False, + "num_turns": 1, + "session_id": "test-session", + "total_cost_usd": 0.001, + } + + mock_transport.read_messages = mock_receive + mock_transport.connect = AsyncMock() + mock_transport.close = AsyncMock() + mock_transport.end_input = AsyncMock() + mock_transport.write = AsyncMock() + mock_transport.is_ready = Mock(return_value=True) + + # Run query + messages = [] + async for msg in query(prompt="What is 2 + 2?"): + messages.append(msg) + + # Verify results + assert len(messages) == 2 + + # Check assistant message + assert isinstance(messages[0], AssistantMessage) + assert len(messages[0].content) == 1 + assert messages[0].content[0].text == "2 + 2 equals 4" + + # Check result message + assert isinstance(messages[1], ResultMessage) + assert messages[1].total_cost_usd == 0.001 + assert messages[1].session_id == "test-session" + + anyio.run(_test) + + def test_query_with_tool_use(self): + """Test query that uses tools.""" + + async def _test(): + with ( + patch( + "claude_agent_sdk._internal.client.SubprocessCLITransport" + ) as mock_transport_class, + patch( + "claude_agent_sdk._internal.query.Query.initialize", + new_callable=AsyncMock, + ), + ): + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock the message stream with tool use + async def mock_receive(): + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Let me read that file for you.", + }, + { + "type": "tool_use", + "id": "tool-123", + "name": "Read", + "input": {"file_path": "/test.txt"}, + }, + ], + "model": "claude-opus-4-1-20250805", + }, + } + yield { + "type": "result", + "subtype": "success", + "duration_ms": 1500, + "duration_api_ms": 1200, + "is_error": False, + "num_turns": 1, + "session_id": "test-session-2", + "total_cost_usd": 0.002, + } + + mock_transport.read_messages = mock_receive + mock_transport.connect = AsyncMock() + mock_transport.close = AsyncMock() + mock_transport.end_input = AsyncMock() + mock_transport.write = AsyncMock() + mock_transport.is_ready = Mock(return_value=True) + + # Run query with tools enabled + messages = [] + async for msg in query( + prompt="Read /test.txt", + options=ClaudeAgentOptions(allowed_tools=["Read"]), + ): + messages.append(msg) + + # Verify results + assert len(messages) == 2 + + # Check assistant message with tool use + assert isinstance(messages[0], AssistantMessage) + assert len(messages[0].content) == 2 + assert messages[0].content[0].text == "Let me read that file for you." + assert isinstance(messages[0].content[1], ToolUseBlock) + assert messages[0].content[1].name == "Read" + assert messages[0].content[1].input["file_path"] == "/test.txt" + + anyio.run(_test) + + def test_cli_not_found(self): + """Test handling when CLI is not found.""" + + async def _test(): + with ( + patch("shutil.which", return_value=None), + patch("pathlib.Path.exists", return_value=False), + pytest.raises(CLINotFoundError) as exc_info, + ): + async for _ in query(prompt="test"): + pass + + assert "Claude Code not found" in str(exc_info.value) + + anyio.run(_test) + + def test_continuation_option(self): + """Test query with continue_conversation option.""" + + async def _test(): + with ( + patch( + "claude_agent_sdk._internal.client.SubprocessCLITransport" + ) as mock_transport_class, + patch( + "claude_agent_sdk._internal.query.Query.initialize", + new_callable=AsyncMock, + ), + ): + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock the message stream + async def mock_receive(): + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Continuing from previous conversation", + } + ], + "model": "claude-opus-4-1-20250805", + }, + } + + mock_transport.read_messages = mock_receive + mock_transport.connect = AsyncMock() + mock_transport.close = AsyncMock() + mock_transport.end_input = AsyncMock() + mock_transport.write = AsyncMock() + mock_transport.is_ready = Mock(return_value=True) + + # Run query with continuation + messages = [] + async for msg in query( + prompt="Continue", + options=ClaudeAgentOptions(continue_conversation=True), + ): + messages.append(msg) + + # Verify transport was created with continuation option + mock_transport_class.assert_called_once() + call_kwargs = mock_transport_class.call_args.kwargs + assert call_kwargs["options"].continue_conversation is True + + anyio.run(_test) + + def test_max_budget_usd_option(self): + """Test query with max_budget_usd option.""" + + async def _test(): + with ( + patch( + "claude_agent_sdk._internal.client.SubprocessCLITransport" + ) as mock_transport_class, + patch( + "claude_agent_sdk._internal.query.Query.initialize", + new_callable=AsyncMock, + ), + ): + mock_transport = AsyncMock() + mock_transport_class.return_value = mock_transport + + # Mock the message stream that exceeds budget + async def mock_receive(): + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "Starting to read..."} + ], + "model": "claude-opus-4-1-20250805", + }, + } + yield { + "type": "result", + "subtype": "error_max_budget_usd", + "duration_ms": 500, + "duration_api_ms": 400, + "is_error": False, + "num_turns": 1, + "session_id": "test-session-budget", + "total_cost_usd": 0.0002, + "usage": { + "input_tokens": 100, + "output_tokens": 50, + }, + } + + mock_transport.read_messages = mock_receive + mock_transport.connect = AsyncMock() + mock_transport.close = AsyncMock() + mock_transport.end_input = AsyncMock() + mock_transport.write = AsyncMock() + mock_transport.is_ready = Mock(return_value=True) + + # Run query with very small budget + messages = [] + async for msg in query( + prompt="Read the readme", + options=ClaudeAgentOptions(max_budget_usd=0.0001), + ): + messages.append(msg) + + # Verify results + assert len(messages) == 2 + + # Check result message + assert isinstance(messages[1], ResultMessage) + assert messages[1].subtype == "error_max_budget_usd" + assert messages[1].is_error is False + assert messages[1].total_cost_usd == 0.0002 + assert messages[1].total_cost_usd is not None + assert messages[1].total_cost_usd > 0 + + # Verify transport was created with max_budget_usd option + mock_transport_class.assert_called_once() + call_kwargs = mock_transport_class.call_args.kwargs + assert call_kwargs["options"].max_budget_usd == 0.0001 + + anyio.run(_test) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_message_parser.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_message_parser.py new file mode 100644 index 00000000..be44431b --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_message_parser.py @@ -0,0 +1,439 @@ +"""Tests for message parser error handling.""" + +import pytest + +from claude_agent_sdk._errors import MessageParseError +from claude_agent_sdk._internal.message_parser import parse_message +from claude_agent_sdk.types import ( + AssistantMessage, + ResultMessage, + SystemMessage, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) + + +class TestMessageParser: + """Test message parsing with the new exception behavior.""" + + def test_parse_valid_user_message(self): + """Test parsing a valid user message.""" + data = { + "type": "user", + "message": {"content": [{"type": "text", "text": "Hello"}]}, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert len(message.content) == 1 + assert isinstance(message.content[0], TextBlock) + assert message.content[0].text == "Hello" + + def test_parse_user_message_with_uuid(self): + """Test parsing a user message with uuid field (issue #414). + + The uuid field is needed for file checkpointing with rewind_files(). + """ + data = { + "type": "user", + "uuid": "msg-abc123-def456", + "message": {"content": [{"type": "text", "text": "Hello"}]}, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert message.uuid == "msg-abc123-def456" + assert len(message.content) == 1 + + def test_parse_user_message_with_tool_use(self): + """Test parsing a user message with tool_use block.""" + data = { + "type": "user", + "message": { + "content": [ + {"type": "text", "text": "Let me read this file"}, + { + "type": "tool_use", + "id": "tool_456", + "name": "Read", + "input": {"file_path": "/example.txt"}, + }, + ] + }, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert len(message.content) == 2 + assert isinstance(message.content[0], TextBlock) + assert isinstance(message.content[1], ToolUseBlock) + assert message.content[1].id == "tool_456" + assert message.content[1].name == "Read" + assert message.content[1].input == {"file_path": "/example.txt"} + + def test_parse_user_message_with_tool_result(self): + """Test parsing a user message with tool_result block.""" + data = { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "tool_789", + "content": "File contents here", + } + ] + }, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert len(message.content) == 1 + assert isinstance(message.content[0], ToolResultBlock) + assert message.content[0].tool_use_id == "tool_789" + assert message.content[0].content == "File contents here" + + def test_parse_user_message_with_tool_result_error(self): + """Test parsing a user message with error tool_result block.""" + data = { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "tool_error", + "content": "File not found", + "is_error": True, + } + ] + }, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert len(message.content) == 1 + assert isinstance(message.content[0], ToolResultBlock) + assert message.content[0].tool_use_id == "tool_error" + assert message.content[0].content == "File not found" + assert message.content[0].is_error is True + + def test_parse_user_message_with_mixed_content(self): + """Test parsing a user message with mixed content blocks.""" + data = { + "type": "user", + "message": { + "content": [ + {"type": "text", "text": "Here's what I found:"}, + { + "type": "tool_use", + "id": "use_1", + "name": "Search", + "input": {"query": "test"}, + }, + { + "type": "tool_result", + "tool_use_id": "use_1", + "content": "Search results", + }, + {"type": "text", "text": "What do you think?"}, + ] + }, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert len(message.content) == 4 + assert isinstance(message.content[0], TextBlock) + assert isinstance(message.content[1], ToolUseBlock) + assert isinstance(message.content[2], ToolResultBlock) + assert isinstance(message.content[3], TextBlock) + + def test_parse_user_message_inside_subagent(self): + """Test parsing a valid user message.""" + data = { + "type": "user", + "message": {"content": [{"type": "text", "text": "Hello"}]}, + "parent_tool_use_id": "toolu_01Xrwd5Y13sEHtzScxR77So8", + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert message.parent_tool_use_id == "toolu_01Xrwd5Y13sEHtzScxR77So8" + + def test_parse_user_message_with_tool_use_result(self): + """Test parsing a user message with tool_use_result field. + + The tool_use_result field contains metadata about tool execution results, + including file edit details like oldString, newString, and structuredPatch. + """ + tool_result_data = { + "filePath": "/path/to/file.py", + "oldString": "old code", + "newString": "new code", + "originalFile": "full file contents", + "structuredPatch": [ + { + "oldStart": 33, + "oldLines": 7, + "newStart": 33, + "newLines": 7, + "lines": [ + " # comment", + "- old line", + "+ new line", + ], + } + ], + "userModified": False, + "replaceAll": False, + } + data = { + "type": "user", + "message": { + "role": "user", + "content": [ + { + "tool_use_id": "toolu_vrtx_01KXWexk3NJdwkjWzPMGQ2F1", + "type": "tool_result", + "content": "The file has been updated.", + } + ], + }, + "parent_tool_use_id": None, + "session_id": "84afb479-17ae-49af-8f2b-666ac2530c3a", + "uuid": "2ace3375-1879-48a0-a421-6bce25a9295a", + "tool_use_result": tool_result_data, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert message.tool_use_result == tool_result_data + assert message.tool_use_result["filePath"] == "/path/to/file.py" + assert message.tool_use_result["oldString"] == "old code" + assert message.tool_use_result["newString"] == "new code" + assert message.tool_use_result["structuredPatch"][0]["oldStart"] == 33 + assert message.uuid == "2ace3375-1879-48a0-a421-6bce25a9295a" + + def test_parse_user_message_with_string_content_and_tool_use_result(self): + """Test parsing a user message with string content and tool_use_result.""" + tool_result_data = {"filePath": "/path/to/file.py", "userModified": True} + data = { + "type": "user", + "message": {"content": "Simple string content"}, + "tool_use_result": tool_result_data, + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert message.content == "Simple string content" + assert message.tool_use_result == tool_result_data + + def test_parse_valid_assistant_message(self): + """Test parsing a valid assistant message.""" + data = { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "Hello"}, + { + "type": "tool_use", + "id": "tool_123", + "name": "Read", + "input": {"file_path": "/test.txt"}, + }, + ], + "model": "claude-opus-4-1-20250805", + }, + } + message = parse_message(data) + assert isinstance(message, AssistantMessage) + assert len(message.content) == 2 + assert isinstance(message.content[0], TextBlock) + assert isinstance(message.content[1], ToolUseBlock) + + def test_parse_assistant_message_with_thinking(self): + """Test parsing an assistant message with thinking block.""" + data = { + "type": "assistant", + "message": { + "content": [ + { + "type": "thinking", + "thinking": "I'm thinking about the answer...", + "signature": "sig-123", + }, + {"type": "text", "text": "Here's my response"}, + ], + "model": "claude-opus-4-1-20250805", + }, + } + message = parse_message(data) + assert isinstance(message, AssistantMessage) + assert len(message.content) == 2 + assert isinstance(message.content[0], ThinkingBlock) + assert message.content[0].thinking == "I'm thinking about the answer..." + assert message.content[0].signature == "sig-123" + assert isinstance(message.content[1], TextBlock) + assert message.content[1].text == "Here's my response" + + def test_parse_valid_system_message(self): + """Test parsing a valid system message.""" + data = {"type": "system", "subtype": "start"} + message = parse_message(data) + assert isinstance(message, SystemMessage) + assert message.subtype == "start" + + def test_parse_assistant_message_inside_subagent(self): + """Test parsing a valid assistant message.""" + data = { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "Hello"}, + { + "type": "tool_use", + "id": "tool_123", + "name": "Read", + "input": {"file_path": "/test.txt"}, + }, + ], + "model": "claude-opus-4-1-20250805", + }, + "parent_tool_use_id": "toolu_01Xrwd5Y13sEHtzScxR77So8", + } + message = parse_message(data) + assert isinstance(message, AssistantMessage) + assert message.parent_tool_use_id == "toolu_01Xrwd5Y13sEHtzScxR77So8" + + def test_parse_valid_result_message(self): + """Test parsing a valid result message.""" + data = { + "type": "result", + "subtype": "success", + "duration_ms": 1000, + "duration_api_ms": 500, + "is_error": False, + "num_turns": 2, + "session_id": "session_123", + } + message = parse_message(data) + assert isinstance(message, ResultMessage) + assert message.subtype == "success" + + def test_parse_invalid_data_type(self): + """Test that non-dict data raises MessageParseError.""" + with pytest.raises(MessageParseError) as exc_info: + parse_message("not a dict") # type: ignore + assert "Invalid message data type" in str(exc_info.value) + assert "expected dict, got str" in str(exc_info.value) + + def test_parse_missing_type_field(self): + """Test that missing 'type' field raises MessageParseError.""" + with pytest.raises(MessageParseError) as exc_info: + parse_message({"message": {"content": []}}) + assert "Message missing 'type' field" in str(exc_info.value) + + def test_parse_unknown_message_type(self): + """Test that unknown message type returns None for forward compatibility.""" + result = parse_message({"type": "unknown_type"}) + assert result is None + + def test_parse_user_message_missing_fields(self): + """Test that user message with missing fields raises MessageParseError.""" + with pytest.raises(MessageParseError) as exc_info: + parse_message({"type": "user"}) + assert "Missing required field in user message" in str(exc_info.value) + + def test_parse_assistant_message_missing_fields(self): + """Test that assistant message with missing fields raises MessageParseError.""" + with pytest.raises(MessageParseError) as exc_info: + parse_message({"type": "assistant"}) + assert "Missing required field in assistant message" in str(exc_info.value) + + def test_parse_system_message_missing_fields(self): + """Test that system message with missing fields raises MessageParseError.""" + with pytest.raises(MessageParseError) as exc_info: + parse_message({"type": "system"}) + assert "Missing required field in system message" in str(exc_info.value) + + def test_parse_result_message_missing_fields(self): + """Test that result message with missing fields raises MessageParseError.""" + with pytest.raises(MessageParseError) as exc_info: + parse_message({"type": "result", "subtype": "success"}) + assert "Missing required field in result message" in str(exc_info.value) + + def test_message_parse_error_contains_data(self): + """Test that MessageParseError contains the original data.""" + # Use a malformed known type (missing required fields) to trigger error + data = {"type": "assistant"} + with pytest.raises(MessageParseError) as exc_info: + parse_message(data) + assert exc_info.value.data == data + + def test_parse_assistant_message_without_error(self): + """Test that assistant message without error has error=None.""" + data = { + "type": "assistant", + "message": { + "content": [{"type": "text", "text": "Hello"}], + "model": "claude-opus-4-5-20251101", + }, + } + message = parse_message(data) + assert isinstance(message, AssistantMessage) + assert message.error is None + + def test_parse_assistant_message_with_authentication_error(self): + """Test parsing assistant message with authentication_failed error. + + The error field is at the top level of the data, not inside message. + This matches the actual CLI output format. + """ + data = { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "Invalid API key · Fix external API key"} + ], + "model": "", + }, + "session_id": "test-session", + "error": "authentication_failed", + } + message = parse_message(data) + assert isinstance(message, AssistantMessage) + assert message.error == "authentication_failed" + assert len(message.content) == 1 + assert isinstance(message.content[0], TextBlock) + + def test_parse_assistant_message_with_unknown_error(self): + """Test parsing assistant message with unknown error (e.g., 404, 500). + + When the CLI encounters API errors like model not found or server errors, + it sets error to 'unknown' and includes the error details in the text content. + """ + data = { + "type": "assistant", + "message": { + "content": [ + { + "type": "text", + "text": 'API Error: 500 {"type":"error","error":{"type":"api_error","message":"Internal server error"}}', + } + ], + "model": "", + }, + "session_id": "test-session", + "error": "unknown", + } + message = parse_message(data) + assert isinstance(message, AssistantMessage) + assert message.error == "unknown" + + def test_parse_assistant_message_with_rate_limit_error(self): + """Test parsing assistant message with rate_limit error.""" + data = { + "type": "assistant", + "message": { + "content": [{"type": "text", "text": "Rate limit exceeded"}], + "model": "", + }, + "error": "rate_limit", + } + message = parse_message(data) + assert isinstance(message, AssistantMessage) + assert message.error == "rate_limit" diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_rate_limit_event_repro.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_rate_limit_event_repro.py new file mode 100644 index 00000000..5be2ac19 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_rate_limit_event_repro.py @@ -0,0 +1,79 @@ +"""Repro test: rate_limit_event message type crashes the Python Agent SDK. + +CLI v2.1.45+ emits `rate_limit_event` messages when rate limit status changes +for claude.ai subscription users. The Python SDK's message parser had no handler +for this message type, causing a MessageParseError crash. + +Fix: the parser now returns None for unknown message types, and the caller +filters them out. This makes the SDK forward-compatible with new CLI message types. + +See: https://github.com/anthropics/claude-agent-sdk-python/issues/583 +""" + +from claude_agent_sdk._internal.message_parser import parse_message + + +class TestRateLimitEventHandling: + """Verify rate_limit_event and unknown message types don't crash.""" + + def test_rate_limit_event_returns_none(self): + """rate_limit_event should be silently skipped, not crash.""" + data = { + "type": "rate_limit_event", + "rate_limit_info": { + "status": "allowed_warning", + "resetsAt": 1700000000, + "rateLimitType": "five_hour", + "utilization": 0.85, + "isUsingOverage": False, + }, + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "session_id": "test-session-id", + } + + result = parse_message(data) + assert result is None + + def test_rate_limit_event_rejected_returns_none(self): + """Hard rate limit (status=rejected) should also be skipped.""" + data = { + "type": "rate_limit_event", + "rate_limit_info": { + "status": "rejected", + "resetsAt": 1700003600, + "rateLimitType": "seven_day", + "isUsingOverage": False, + "overageStatus": "rejected", + "overageDisabledReason": "out_of_credits", + }, + "uuid": "660e8400-e29b-41d4-a716-446655440001", + "session_id": "test-session-id", + } + + result = parse_message(data) + assert result is None + + def test_unknown_message_type_returns_none(self): + """Any unknown message type should return None for forward compatibility.""" + data = { + "type": "some_future_event_type", + "uuid": "770e8400-e29b-41d4-a716-446655440002", + "session_id": "test-session-id", + } + + result = parse_message(data) + assert result is None + + def test_known_message_types_still_parsed(self): + """Known message types should still be parsed normally.""" + data = { + "type": "assistant", + "message": { + "content": [{"type": "text", "text": "hello"}], + "model": "claude-sonnet-4-6-20250929", + }, + } + + result = parse_message(data) + assert result is not None + assert result.content[0].text == "hello" diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_sdk_mcp_integration.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_sdk_mcp_integration.py new file mode 100644 index 00000000..67f9e66e --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_sdk_mcp_integration.py @@ -0,0 +1,381 @@ +"""Integration tests for SDK MCP server support. + +This test file verifies that SDK MCP servers work correctly through the full stack, +matching the TypeScript SDK test/sdk.test.ts pattern. +""" + +import base64 +from typing import Any + +import pytest +from mcp.types import CallToolRequest, CallToolRequestParams + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ToolAnnotations, + create_sdk_mcp_server, + tool, +) + + +@pytest.mark.asyncio +async def test_sdk_mcp_server_handlers(): + """Test that SDK MCP server handlers are properly registered.""" + # Track tool executions + tool_executions: list[dict[str, Any]] = [] + + # Create SDK MCP server with multiple tools + @tool("greet_user", "Greets a user by name", {"name": str}) + async def greet_user(args: dict[str, Any]) -> dict[str, Any]: + tool_executions.append({"name": "greet_user", "args": args}) + return {"content": [{"type": "text", "text": f"Hello, {args['name']}!"}]} + + @tool("add_numbers", "Adds two numbers", {"a": float, "b": float}) + async def add_numbers(args: dict[str, Any]) -> dict[str, Any]: + tool_executions.append({"name": "add_numbers", "args": args}) + result = args["a"] + args["b"] + return {"content": [{"type": "text", "text": f"The sum is {result}"}]} + + server_config = create_sdk_mcp_server( + name="test-sdk-server", version="1.0.0", tools=[greet_user, add_numbers] + ) + + # Verify server configuration + assert server_config["type"] == "sdk" + assert server_config["name"] == "test-sdk-server" + assert "instance" in server_config + + # Get the server instance + server = server_config["instance"] + + # Import the request types to check handlers + from mcp.types import CallToolRequest, ListToolsRequest + + # Verify handlers are registered + assert ListToolsRequest in server.request_handlers + assert CallToolRequest in server.request_handlers + + # Test list_tools handler - the decorator wraps our function + list_handler = server.request_handlers[ListToolsRequest] + request = ListToolsRequest(method="tools/list") + response = await list_handler(request) + # Response is ServerResult with nested ListToolsResult + assert len(response.root.tools) == 2 + + # Check tool definitions + tool_names = [t.name for t in response.root.tools] + assert "greet_user" in tool_names + assert "add_numbers" in tool_names + + # Test call_tool handler + call_handler = server.request_handlers[CallToolRequest] + + # Call greet_user - CallToolRequest wraps the call + from mcp.types import CallToolRequestParams + + greet_request = CallToolRequest( + method="tools/call", + params=CallToolRequestParams(name="greet_user", arguments={"name": "Alice"}), + ) + result = await call_handler(greet_request) + # Response is ServerResult with nested CallToolResult + assert result.root.content[0].text == "Hello, Alice!" + assert len(tool_executions) == 1 + assert tool_executions[0]["name"] == "greet_user" + assert tool_executions[0]["args"]["name"] == "Alice" + + # Call add_numbers + add_request = CallToolRequest( + method="tools/call", + params=CallToolRequestParams(name="add_numbers", arguments={"a": 5, "b": 3}), + ) + result = await call_handler(add_request) + assert "8" in result.root.content[0].text + assert len(tool_executions) == 2 + assert tool_executions[1]["name"] == "add_numbers" + assert tool_executions[1]["args"]["a"] == 5 + assert tool_executions[1]["args"]["b"] == 3 + + +@pytest.mark.asyncio +async def test_tool_creation(): + """Test that tools can be created with proper schemas.""" + + @tool("echo", "Echo input", {"input": str}) + async def echo_tool(args: dict[str, Any]) -> dict[str, Any]: + return {"output": args["input"]} + + # Verify tool was created + assert echo_tool.name == "echo" + assert echo_tool.description == "Echo input" + assert echo_tool.input_schema == {"input": str} + assert callable(echo_tool.handler) + + # Test the handler works + result = await echo_tool.handler({"input": "test"}) + assert result == {"output": "test"} + + +@pytest.mark.asyncio +async def test_error_handling(): + """Test that tool errors are properly handled.""" + + @tool("fail", "Always fails", {}) + async def fail_tool(args: dict[str, Any]) -> dict[str, Any]: + raise ValueError("Expected error") + + # Verify the tool raises an error when called directly + with pytest.raises(ValueError, match="Expected error"): + await fail_tool.handler({}) + + # Test error handling through the server + server_config = create_sdk_mcp_server(name="error-test", tools=[fail_tool]) + + server = server_config["instance"] + from mcp.types import CallToolRequest + + call_handler = server.request_handlers[CallToolRequest] + + # The handler should return an error result, not raise + from mcp.types import CallToolRequestParams + + fail_request = CallToolRequest( + method="tools/call", params=CallToolRequestParams(name="fail", arguments={}) + ) + result = await call_handler(fail_request) + # MCP SDK catches exceptions and returns error results + assert result.root.isError + assert "Expected error" in str(result.root.content[0].text) + + +@pytest.mark.asyncio +async def test_mixed_servers(): + """Test that SDK and external MCP servers can work together.""" + + # Create an SDK server + @tool("sdk_tool", "SDK tool", {}) + async def sdk_tool(args: dict[str, Any]) -> dict[str, Any]: + return {"result": "from SDK"} + + sdk_server = create_sdk_mcp_server(name="sdk-server", tools=[sdk_tool]) + + # Create configuration with both SDK and external servers + external_server = {"type": "stdio", "command": "echo", "args": ["test"]} + + options = ClaudeAgentOptions( + mcp_servers={"sdk": sdk_server, "external": external_server} + ) + + # Verify both server types are in the configuration + assert "sdk" in options.mcp_servers + assert "external" in options.mcp_servers + assert options.mcp_servers["sdk"]["type"] == "sdk" + assert options.mcp_servers["external"]["type"] == "stdio" + + +@pytest.mark.asyncio +async def test_server_creation(): + """Test that SDK MCP servers are created correctly.""" + server = create_sdk_mcp_server(name="test-server", version="2.0.0", tools=[]) + + # Verify server configuration + assert server["type"] == "sdk" + assert server["name"] == "test-server" + assert "instance" in server + assert server["instance"] is not None + + # Verify the server instance has the right attributes + instance = server["instance"] + assert instance.name == "test-server" + assert instance.version == "2.0.0" + + # With no tools, no handlers are registered if tools is empty + from mcp.types import ListToolsRequest + + # When no tools are provided, the handlers are not registered + assert ListToolsRequest not in instance.request_handlers + + +@pytest.mark.asyncio +async def test_image_content_support(): + """Test that tools can return image content with base64 data.""" + + # Create sample base64 image data (a simple 1x1 pixel PNG) + png_data = base64.b64encode( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\tpHYs\x00\x00\x0b\x13" + b"\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\x00\x00\x00\x0cIDATx\x9cc```" + b"\x00\x00\x00\x04\x00\x01]U!\x1c\x00\x00\x00\x00IEND\xaeB`\x82" + ).decode("utf-8") + + # Track tool executions + tool_executions: list[dict[str, Any]] = [] + + # Create a tool that returns both text and image content + @tool( + "generate_chart", "Generates a chart and returns it as an image", {"title": str} + ) + async def generate_chart(args: dict[str, Any]) -> dict[str, Any]: + tool_executions.append({"name": "generate_chart", "args": args}) + return { + "content": [ + {"type": "text", "text": f"Generated chart: {args['title']}"}, + { + "type": "image", + "data": png_data, + "mimeType": "image/png", + }, + ] + } + + server_config = create_sdk_mcp_server( + name="image-test-server", version="1.0.0", tools=[generate_chart] + ) + + # Get the server instance + server = server_config["instance"] + + call_handler = server.request_handlers[CallToolRequest] + + # Call the chart generation tool + chart_request = CallToolRequest( + method="tools/call", + params=CallToolRequestParams( + name="generate_chart", arguments={"title": "Sales Report"} + ), + ) + result = await call_handler(chart_request) + + # Verify the result contains both text and image content + assert len(result.root.content) == 2 + + # Check text content + text_content = result.root.content[0] + assert text_content.type == "text" + assert text_content.text == "Generated chart: Sales Report" + + # Check image content + image_content = result.root.content[1] + assert image_content.type == "image" + assert image_content.data == png_data + assert image_content.mimeType == "image/png" + + # Verify the tool was executed correctly + assert len(tool_executions) == 1 + assert tool_executions[0]["name"] == "generate_chart" + assert tool_executions[0]["args"]["title"] == "Sales Report" + + +@pytest.mark.asyncio +async def test_tool_annotations(): + """Test that tool annotations are stored and flow through list_tools.""" + + @tool( + "read_data", + "Read data from source", + {"source": str}, + annotations=ToolAnnotations(readOnlyHint=True), + ) + async def read_data(args: dict[str, Any]) -> dict[str, Any]: + return {"content": [{"type": "text", "text": f"Data from {args['source']}"}]} + + @tool( + "delete_item", + "Delete an item", + {"id": str}, + annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True), + ) + async def delete_item(args: dict[str, Any]) -> dict[str, Any]: + return {"content": [{"type": "text", "text": f"Deleted {args['id']}"}]} + + @tool( + "search", + "Search the web", + {"query": str}, + annotations=ToolAnnotations(openWorldHint=True), + ) + async def search(args: dict[str, Any]) -> dict[str, Any]: + return {"content": [{"type": "text", "text": f"Results for {args['query']}"}]} + + @tool("no_annotations", "Tool without annotations", {"x": str}) + async def no_annotations(args: dict[str, Any]) -> dict[str, Any]: + return {"content": [{"type": "text", "text": args["x"]}]} + + # Verify annotations stored on SdkMcpTool + assert read_data.annotations is not None + assert read_data.annotations.readOnlyHint is True + assert delete_item.annotations is not None + assert delete_item.annotations.destructiveHint is True + assert delete_item.annotations.idempotentHint is True + assert search.annotations is not None + assert search.annotations.openWorldHint is True + assert no_annotations.annotations is None + + # Verify annotations flow through list_tools handler + server_config = create_sdk_mcp_server( + name="annotations-test", + tools=[read_data, delete_item, search, no_annotations], + ) + server = server_config["instance"] + + from mcp.types import ListToolsRequest + + list_handler = server.request_handlers[ListToolsRequest] + request = ListToolsRequest(method="tools/list") + response = await list_handler(request) + + tools_by_name = {t.name: t for t in response.root.tools} + + assert tools_by_name["read_data"].annotations is not None + assert tools_by_name["read_data"].annotations.readOnlyHint is True + assert tools_by_name["delete_item"].annotations is not None + assert tools_by_name["delete_item"].annotations.destructiveHint is True + assert tools_by_name["delete_item"].annotations.idempotentHint is True + assert tools_by_name["search"].annotations is not None + assert tools_by_name["search"].annotations.openWorldHint is True + assert tools_by_name["no_annotations"].annotations is None + + +@pytest.mark.asyncio +async def test_tool_annotations_in_jsonrpc(): + """Test that annotations are included in JSONRPC tools/list response.""" + from claude_agent_sdk._internal.query import Query + + @tool( + "read_only_tool", + "A read-only tool", + {"input": str}, + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False), + ) + async def read_only_tool(args: dict[str, Any]) -> dict[str, Any]: + return {"content": [{"type": "text", "text": args["input"]}]} + + @tool("plain_tool", "A tool without annotations", {"input": str}) + async def plain_tool(args: dict[str, Any]) -> dict[str, Any]: + return {"content": [{"type": "text", "text": args["input"]}]} + + server_config = create_sdk_mcp_server( + name="jsonrpc-annotations-test", + tools=[read_only_tool, plain_tool], + ) + + # Simulate the JSONRPC tools/list request + query_instance = Query.__new__(Query) + query_instance.sdk_mcp_servers = {"test": server_config["instance"]} + + response = await query_instance._handle_sdk_mcp_request( + "test", + {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, + ) + + assert response is not None + tools_data = response["result"]["tools"] + tools_by_name = {t["name"]: t for t in tools_data} + + # Tool with annotations should include them + assert "annotations" in tools_by_name["read_only_tool"] + assert tools_by_name["read_only_tool"]["annotations"]["readOnlyHint"] is True + assert tools_by_name["read_only_tool"]["annotations"]["openWorldHint"] is False + + # Tool without annotations should not have the key + assert "annotations" not in tools_by_name["plain_tool"] diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_streaming_client.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_streaming_client.py new file mode 100644 index 00000000..29294419 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_streaming_client.py @@ -0,0 +1,835 @@ +"""Tests for ClaudeSDKClient streaming functionality and query() with async iterables.""" + +import asyncio +import json +import sys +import tempfile +from pathlib import Path +from unittest.mock import AsyncMock, Mock, patch + +import anyio +import pytest + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + CLIConnectionError, + ResultMessage, + TextBlock, + UserMessage, + query, +) +from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport + + +def create_mock_transport(with_init_response=True): + """Create a properly configured mock transport. + + Args: + with_init_response: If True, automatically respond to initialization request + """ + mock_transport = AsyncMock() + mock_transport.connect = AsyncMock() + mock_transport.close = AsyncMock() + mock_transport.end_input = AsyncMock() + mock_transport.write = AsyncMock() + mock_transport.is_ready = Mock(return_value=True) + + # Track written messages to simulate control protocol responses + written_messages = [] + + async def mock_write(data): + written_messages.append(data) + + mock_transport.write.side_effect = mock_write + + # Default read_messages to handle control protocol + async def control_protocol_generator(): + # Wait for initialization request if needed + if with_init_response: + # Wait a bit for the write to happen + await asyncio.sleep(0.01) + + # Check if initialization was requested + for msg_str in written_messages: + try: + msg = json.loads(msg_str.strip()) + if ( + msg.get("type") == "control_request" + and msg.get("request", {}).get("subtype") == "initialize" + ): + # Send initialization response + yield { + "type": "control_response", + "response": { + "request_id": msg.get("request_id"), + "subtype": "success", + "commands": [], + "output_style": "default", + }, + } + break + except (json.JSONDecodeError, KeyError, AttributeError): + pass + + # Keep checking for other control requests (like interrupt) + last_check = len(written_messages) + timeout_counter = 0 + while timeout_counter < 100: # Avoid infinite loop + await asyncio.sleep(0.01) + timeout_counter += 1 + + # Check for new messages + for msg_str in written_messages[last_check:]: + try: + msg = json.loads(msg_str.strip()) + if msg.get("type") == "control_request": + subtype = msg.get("request", {}).get("subtype") + if subtype == "interrupt": + # Send interrupt response + yield { + "type": "control_response", + "response": { + "request_id": msg.get("request_id"), + "subtype": "success", + }, + } + return # End after interrupt + except (json.JSONDecodeError, KeyError, AttributeError): + pass + last_check = len(written_messages) + + # Then end the stream + return + + mock_transport.read_messages = control_protocol_generator + return mock_transport + + +class TestClaudeSDKClientStreaming: + """Test ClaudeSDKClient streaming functionality.""" + + def test_auto_connect_with_context_manager(self): + """Test automatic connection when using context manager.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + async with ClaudeSDKClient() as client: + # Verify connect was called + mock_transport.connect.assert_called_once() + assert client._transport is mock_transport + + # Verify disconnect was called on exit + mock_transport.close.assert_called_once() + + anyio.run(_test) + + def test_manual_connect_disconnect(self): + """Test manual connect and disconnect.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + client = ClaudeSDKClient() + await client.connect() + + # Verify connect was called + mock_transport.connect.assert_called_once() + assert client._transport is mock_transport + + await client.disconnect() + # Verify disconnect was called + mock_transport.close.assert_called_once() + assert client._transport is None + + anyio.run(_test) + + def test_connect_with_string_prompt(self): + """Test connecting with a string prompt.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + client = ClaudeSDKClient() + await client.connect("Hello Claude") + + # Verify transport was created with string prompt + call_kwargs = mock_transport_class.call_args.kwargs + assert call_kwargs["prompt"] == "Hello Claude" + + anyio.run(_test) + + def test_connect_with_async_iterable(self): + """Test connecting with an async iterable.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + async def message_stream(): + yield {"type": "user", "message": {"role": "user", "content": "Hi"}} + yield { + "type": "user", + "message": {"role": "user", "content": "Bye"}, + } + + client = ClaudeSDKClient() + stream = message_stream() + await client.connect(stream) + + # Verify transport was created with async iterable + call_kwargs = mock_transport_class.call_args.kwargs + # Should be the same async iterator + assert call_kwargs["prompt"] is stream + + anyio.run(_test) + + def test_query(self): + """Test sending a query.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + async with ClaudeSDKClient() as client: + await client.query("Test message") + + # Verify write was called with correct format + # Should have at least 2 writes: init request and user message + assert mock_transport.write.call_count >= 2 + + # Find the user message in the write calls + user_msg_found = False + for call in mock_transport.write.call_args_list: + data = call[0][0] + try: + msg = json.loads(data.strip()) + if msg.get("type") == "user": + assert msg["message"]["content"] == "Test message" + assert msg["session_id"] == "default" + user_msg_found = True + break + except (json.JSONDecodeError, KeyError, AttributeError): + pass + assert user_msg_found, "User message not found in write calls" + + anyio.run(_test) + + def test_send_message_with_session_id(self): + """Test sending a message with custom session ID.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + async with ClaudeSDKClient() as client: + await client.query("Test", session_id="custom-session") + + # Find the user message with custom session ID + session_found = False + for call in mock_transport.write.call_args_list: + data = call[0][0] + try: + msg = json.loads(data.strip()) + if msg.get("type") == "user": + assert msg["session_id"] == "custom-session" + session_found = True + break + except (json.JSONDecodeError, KeyError, AttributeError): + pass + assert session_found, "User message with custom session not found" + + anyio.run(_test) + + def test_send_message_not_connected(self): + """Test sending message when not connected raises error.""" + + async def _test(): + client = ClaudeSDKClient() + with pytest.raises(CLIConnectionError, match="Not connected"): + await client.query("Test") + + anyio.run(_test) + + def test_receive_messages(self): + """Test receiving messages.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + # Mock the message stream with control protocol support + async def mock_receive(): + # First handle initialization + await asyncio.sleep(0.01) + written = mock_transport.write.call_args_list + for call in written: + data = call[0][0] + try: + msg = json.loads(data.strip()) + if ( + msg.get("type") == "control_request" + and msg.get("request", {}).get("subtype") + == "initialize" + ): + yield { + "type": "control_response", + "response": { + "request_id": msg.get("request_id"), + "subtype": "success", + "commands": [], + "output_style": "default", + }, + } + break + except (json.JSONDecodeError, KeyError, AttributeError): + pass + + # Then yield the actual messages + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Hello!"}], + "model": "claude-opus-4-1-20250805", + }, + } + yield { + "type": "user", + "message": {"role": "user", "content": "Hi there"}, + } + + mock_transport.read_messages = mock_receive + + async with ClaudeSDKClient() as client: + messages = [] + async for msg in client.receive_messages(): + messages.append(msg) + if len(messages) == 2: + break + + assert len(messages) == 2 + assert isinstance(messages[0], AssistantMessage) + assert isinstance(messages[0].content[0], TextBlock) + assert messages[0].content[0].text == "Hello!" + assert isinstance(messages[1], UserMessage) + assert messages[1].content == "Hi there" + + anyio.run(_test) + + def test_receive_response(self): + """Test receive_response stops at ResultMessage.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + # Mock the message stream with control protocol support + async def mock_receive(): + # First handle initialization + await asyncio.sleep(0.01) + written = mock_transport.write.call_args_list + for call in written: + data = call[0][0] + try: + msg = json.loads(data.strip()) + if ( + msg.get("type") == "control_request" + and msg.get("request", {}).get("subtype") + == "initialize" + ): + yield { + "type": "control_response", + "response": { + "request_id": msg.get("request_id"), + "subtype": "success", + "commands": [], + "output_style": "default", + }, + } + break + except (json.JSONDecodeError, KeyError, AttributeError): + pass + + # Then yield the actual messages + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Answer"}], + "model": "claude-opus-4-1-20250805", + }, + } + yield { + "type": "result", + "subtype": "success", + "duration_ms": 1000, + "duration_api_ms": 800, + "is_error": False, + "num_turns": 1, + "session_id": "test", + "total_cost_usd": 0.001, + } + # This should not be yielded + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "Should not see this"} + ], + }, + "model": "claude-opus-4-1-20250805", + } + + mock_transport.read_messages = mock_receive + + async with ClaudeSDKClient() as client: + messages = [] + async for msg in client.receive_response(): + messages.append(msg) + + # Should only get 2 messages (assistant + result) + assert len(messages) == 2 + assert isinstance(messages[0], AssistantMessage) + assert isinstance(messages[1], ResultMessage) + + anyio.run(_test) + + def test_interrupt(self): + """Test interrupt functionality.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + async with ClaudeSDKClient() as client: + # Interrupt is now handled via control protocol + await client.interrupt() + # Check that a control request was sent via write + write_calls = mock_transport.write.call_args_list + interrupt_found = False + for call in write_calls: + data = call[0][0] + try: + msg = json.loads(data.strip()) + if ( + msg.get("type") == "control_request" + and msg.get("request", {}).get("subtype") == "interrupt" + ): + interrupt_found = True + break + except (json.JSONDecodeError, KeyError, AttributeError): + pass + assert interrupt_found, "Interrupt control request not found" + + anyio.run(_test) + + def test_interrupt_not_connected(self): + """Test interrupt when not connected raises error.""" + + async def _test(): + client = ClaudeSDKClient() + with pytest.raises(CLIConnectionError, match="Not connected"): + await client.interrupt() + + anyio.run(_test) + + def test_client_with_options(self): + """Test client initialization with options.""" + + async def _test(): + options = ClaudeAgentOptions( + cwd="/custom/path", + allowed_tools=["Read", "Write"], + system_prompt="Be helpful", + ) + + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + client = ClaudeSDKClient(options=options) + await client.connect() + + # Verify options were passed to transport + call_kwargs = mock_transport_class.call_args.kwargs + assert call_kwargs["options"] is options + + anyio.run(_test) + + def test_concurrent_send_receive(self): + """Test concurrent sending and receiving messages.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + # Mock receive to wait then yield messages with control protocol support + async def mock_receive(): + # First handle initialization + await asyncio.sleep(0.01) + written = mock_transport.write.call_args_list + for call in written: + if call: + data = call[0][0] + try: + msg = json.loads(data.strip()) + if ( + msg.get("type") == "control_request" + and msg.get("request", {}).get("subtype") + == "initialize" + ): + yield { + "type": "control_response", + "response": { + "request_id": msg.get("request_id"), + "subtype": "success", + "commands": [], + "output_style": "default", + }, + } + break + except (json.JSONDecodeError, KeyError, AttributeError): + pass + + # Then yield the actual messages + await asyncio.sleep(0.1) + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Response 1"}], + "model": "claude-opus-4-1-20250805", + }, + } + await asyncio.sleep(0.1) + yield { + "type": "result", + "subtype": "success", + "duration_ms": 1000, + "duration_api_ms": 800, + "is_error": False, + "num_turns": 1, + "session_id": "test", + "total_cost_usd": 0.001, + } + + mock_transport.read_messages = mock_receive + + async with ClaudeSDKClient() as client: + # Helper to get next message + async def get_next_message(): + return await client.receive_response().__anext__() + + # Start receiving in background + receive_task = asyncio.create_task(get_next_message()) + + # Send message while receiving + await client.query("Question 1") + + # Wait for first message + first_msg = await receive_task + assert isinstance(first_msg, AssistantMessage) + + anyio.run(_test) + + +class TestQueryWithAsyncIterable: + """Test query() function with async iterable inputs.""" + + def test_query_with_async_iterable(self): + """Test query with async iterable of messages.""" + + async def _test(): + async def message_stream(): + yield {"type": "user", "message": {"role": "user", "content": "First"}} + yield {"type": "user", "message": {"role": "user", "content": "Second"}} + + # Create a simple test script that validates stdin and outputs a result + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + test_script = f.name + f.write("""#!/usr/bin/env python3 +import sys +import json + +# Read stdin messages +stdin_messages = [] +while True: + line = sys.stdin.readline() + if not line: + break + + try: + msg = json.loads(line.strip()) + # Handle control requests + if msg.get("type") == "control_request": + request_id = msg.get("request_id") + request = msg.get("request", {}) + + # Send control response for initialize + if request.get("subtype") == "initialize": + response = { + "type": "control_response", + "response": { + "subtype": "success", + "request_id": request_id, + "response": { + "commands": [], + "output_style": "default" + } + } + } + print(json.dumps(response)) + sys.stdout.flush() + else: + stdin_messages.append(line.strip()) + except: + stdin_messages.append(line.strip()) + +# Verify we got 2 user messages +assert len(stdin_messages) == 2 +assert '"First"' in stdin_messages[0] +assert '"Second"' in stdin_messages[1] + +# Output a valid result +print('{"type": "result", "subtype": "success", "duration_ms": 100, "duration_api_ms": 50, "is_error": false, "num_turns": 1, "session_id": "test", "total_cost_usd": 0.001}') +""") + + # Make script executable (Unix-style systems) + if sys.platform != "win32": + Path(test_script).chmod(0o755) + + try: + # Mock _find_cli to return the test script path directly + with patch.object( + SubprocessCLITransport, "_find_cli", return_value=test_script + ): + # Mock _build_command to properly execute Python script + original_build_command = SubprocessCLITransport._build_command + + def mock_build_command(self): + # Get original command + cmd = original_build_command(self) + # On Windows, we need to use python interpreter to run the script + if sys.platform == "win32": + # Replace first element with python interpreter and script + cmd[0:1] = [sys.executable, test_script] + else: + # On Unix, just use the script directly + cmd[0] = test_script + return cmd + + with patch.object( + SubprocessCLITransport, "_build_command", mock_build_command + ): + # Run query with async iterable + messages = [] + async for msg in query(prompt=message_stream()): + messages.append(msg) + + # Should get the result message + assert len(messages) == 1 + assert isinstance(messages[0], ResultMessage) + assert messages[0].subtype == "success" + finally: + # Clean up + Path(test_script).unlink() + + anyio.run(_test) + + +class TestClaudeSDKClientEdgeCases: + """Test edge cases and error scenarios.""" + + def test_receive_messages_not_connected(self): + """Test receiving messages when not connected.""" + + async def _test(): + client = ClaudeSDKClient() + with pytest.raises(CLIConnectionError, match="Not connected"): + async for _ in client.receive_messages(): + pass + + anyio.run(_test) + + def test_receive_response_not_connected(self): + """Test receive_response when not connected.""" + + async def _test(): + client = ClaudeSDKClient() + with pytest.raises(CLIConnectionError, match="Not connected"): + async for _ in client.receive_response(): + pass + + anyio.run(_test) + + def test_double_connect(self): + """Test connecting twice.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + # Create a new mock transport for each call + mock_transport_class.side_effect = [ + create_mock_transport(), + create_mock_transport(), + ] + + client = ClaudeSDKClient() + await client.connect() + # Second connect should create new transport + await client.connect() + + # Should have been called twice + assert mock_transport_class.call_count == 2 + + anyio.run(_test) + + def test_disconnect_without_connect(self): + """Test disconnecting without connecting first.""" + + async def _test(): + client = ClaudeSDKClient() + # Should not raise error + await client.disconnect() + + anyio.run(_test) + + def test_context_manager_with_exception(self): + """Test context manager cleans up on exception.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + with pytest.raises(ValueError): + async with ClaudeSDKClient(): + raise ValueError("Test error") + + # Disconnect should still be called + mock_transport.close.assert_called_once() + + anyio.run(_test) + + def test_receive_response_list_comprehension(self): + """Test collecting messages with list comprehension as shown in examples.""" + + async def _test(): + with patch( + "claude_agent_sdk._internal.transport.subprocess_cli.SubprocessCLITransport" + ) as mock_transport_class: + mock_transport = create_mock_transport() + mock_transport_class.return_value = mock_transport + + # Mock the message stream with control protocol support + async def mock_receive(): + # First handle initialization + await asyncio.sleep(0.01) + written = mock_transport.write.call_args_list + for call in written: + if call: + data = call[0][0] + try: + msg = json.loads(data.strip()) + if ( + msg.get("type") == "control_request" + and msg.get("request", {}).get("subtype") + == "initialize" + ): + yield { + "type": "control_response", + "response": { + "request_id": msg.get("request_id"), + "subtype": "success", + "commands": [], + "output_style": "default", + }, + } + break + except (json.JSONDecodeError, KeyError, AttributeError): + pass + + # Then yield the actual messages + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Hello"}], + "model": "claude-opus-4-1-20250805", + }, + } + yield { + "type": "assistant", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "World"}], + "model": "claude-opus-4-1-20250805", + }, + } + yield { + "type": "result", + "subtype": "success", + "duration_ms": 1000, + "duration_api_ms": 800, + "is_error": False, + "num_turns": 1, + "session_id": "test", + "total_cost_usd": 0.001, + } + + mock_transport.read_messages = mock_receive + + async with ClaudeSDKClient() as client: + # Test list comprehension pattern from docstring + messages = [msg async for msg in client.receive_response()] + + assert len(messages) == 3 + assert all( + isinstance(msg, AssistantMessage | ResultMessage) + for msg in messages + ) + assert isinstance(messages[-1], ResultMessage) + + anyio.run(_test) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_subprocess_buffering.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_subprocess_buffering.py new file mode 100644 index 00000000..03710748 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_subprocess_buffering.py @@ -0,0 +1,329 @@ +"""Tests for subprocess transport buffering edge cases.""" + +import json +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import anyio +import pytest + +from claude_agent_sdk._errors import CLIJSONDecodeError +from claude_agent_sdk._internal.transport.subprocess_cli import ( + _DEFAULT_MAX_BUFFER_SIZE, + SubprocessCLITransport, +) +from claude_agent_sdk.types import ClaudeAgentOptions + +DEFAULT_CLI_PATH = "/usr/bin/claude" + + +def make_options(**kwargs: object) -> ClaudeAgentOptions: + """Construct ClaudeAgentOptions with a default CLI path for tests.""" + + cli_path = kwargs.pop("cli_path", DEFAULT_CLI_PATH) + return ClaudeAgentOptions(cli_path=cli_path, **kwargs) + + +class MockTextReceiveStream: + """Mock TextReceiveStream for testing.""" + + def __init__(self, lines: list[str]) -> None: + self.lines = lines + self.index = 0 + + def __aiter__(self) -> AsyncIterator[str]: + return self + + async def __anext__(self) -> str: + if self.index >= len(self.lines): + raise StopAsyncIteration + line = self.lines[self.index] + self.index += 1 + return line + + +class TestSubprocessBuffering: + """Test subprocess transport handling of buffered output.""" + + def test_multiple_json_objects_on_single_line(self) -> None: + """Test parsing when multiple JSON objects are concatenated on a single line. + + In some environments, stdout buffering can cause multiple distinct JSON + objects to be delivered as a single line with embedded newlines. + """ + + async def _test() -> None: + json_obj1 = {"type": "message", "id": "msg1", "content": "First message"} + json_obj2 = {"type": "result", "id": "res1", "status": "completed"} + + buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2) + + transport = SubprocessCLITransport(prompt="test", options=make_options()) + + mock_process = MagicMock() + mock_process.returncode = None + mock_process.wait = AsyncMock(return_value=None) + transport._process = mock_process + + transport._stdout_stream = MockTextReceiveStream([buffered_line]) # type: ignore[assignment] + transport._stderr_stream = MockTextReceiveStream([]) # type: ignore[assignment] + + messages: list[Any] = [] + async for msg in transport.read_messages(): + messages.append(msg) + + assert len(messages) == 2 + assert messages[0]["type"] == "message" + assert messages[0]["id"] == "msg1" + assert messages[0]["content"] == "First message" + assert messages[1]["type"] == "result" + assert messages[1]["id"] == "res1" + assert messages[1]["status"] == "completed" + + anyio.run(_test) + + def test_json_with_embedded_newlines(self) -> None: + """Test parsing JSON objects that contain newline characters in string values.""" + + async def _test() -> None: + json_obj1 = {"type": "message", "content": "Line 1\nLine 2\nLine 3"} + json_obj2 = {"type": "result", "data": "Some\nMultiline\nContent"} + + buffered_line = json.dumps(json_obj1) + "\n" + json.dumps(json_obj2) + + transport = SubprocessCLITransport(prompt="test", options=make_options()) + + mock_process = MagicMock() + mock_process.returncode = None + mock_process.wait = AsyncMock(return_value=None) + transport._process = mock_process + transport._stdout_stream = MockTextReceiveStream([buffered_line]) + transport._stderr_stream = MockTextReceiveStream([]) + + messages: list[Any] = [] + async for msg in transport.read_messages(): + messages.append(msg) + + assert len(messages) == 2 + assert messages[0]["content"] == "Line 1\nLine 2\nLine 3" + assert messages[1]["data"] == "Some\nMultiline\nContent" + + anyio.run(_test) + + def test_multiple_newlines_between_objects(self) -> None: + """Test parsing with multiple newlines between JSON objects.""" + + async def _test() -> None: + json_obj1 = {"type": "message", "id": "msg1"} + json_obj2 = {"type": "result", "id": "res1"} + + buffered_line = json.dumps(json_obj1) + "\n\n\n" + json.dumps(json_obj2) + + transport = SubprocessCLITransport(prompt="test", options=make_options()) + + mock_process = MagicMock() + mock_process.returncode = None + mock_process.wait = AsyncMock(return_value=None) + transport._process = mock_process + transport._stdout_stream = MockTextReceiveStream([buffered_line]) + transport._stderr_stream = MockTextReceiveStream([]) + + messages: list[Any] = [] + async for msg in transport.read_messages(): + messages.append(msg) + + assert len(messages) == 2 + assert messages[0]["id"] == "msg1" + assert messages[1]["id"] == "res1" + + anyio.run(_test) + + def test_split_json_across_multiple_reads(self) -> None: + """Test parsing when a single JSON object is split across multiple stream reads.""" + + async def _test() -> None: + json_obj = { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "x" * 1000}, + { + "type": "tool_use", + "id": "tool_123", + "name": "Read", + "input": {"file_path": "/test.txt"}, + }, + ] + }, + } + + complete_json = json.dumps(json_obj) + + part1 = complete_json[:100] + part2 = complete_json[100:250] + part3 = complete_json[250:] + + transport = SubprocessCLITransport(prompt="test", options=make_options()) + + mock_process = MagicMock() + mock_process.returncode = None + mock_process.wait = AsyncMock(return_value=None) + transport._process = mock_process + transport._stdout_stream = MockTextReceiveStream([part1, part2, part3]) + transport._stderr_stream = MockTextReceiveStream([]) + + messages: list[Any] = [] + async for msg in transport.read_messages(): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "assistant" + assert len(messages[0]["message"]["content"]) == 2 + + anyio.run(_test) + + def test_large_minified_json(self) -> None: + """Test parsing a large minified JSON (simulating the reported issue).""" + + async def _test() -> None: + large_data = {"data": [{"id": i, "value": "x" * 100} for i in range(1000)]} + json_obj = { + "type": "user", + "message": { + "role": "user", + "content": [ + { + "tool_use_id": "toolu_016fed1NhiaMLqnEvrj5NUaj", + "type": "tool_result", + "content": json.dumps(large_data), + } + ], + }, + } + + complete_json = json.dumps(json_obj) + + chunk_size = 64 * 1024 + chunks = [ + complete_json[i : i + chunk_size] + for i in range(0, len(complete_json), chunk_size) + ] + + transport = SubprocessCLITransport(prompt="test", options=make_options()) + + mock_process = MagicMock() + mock_process.returncode = None + mock_process.wait = AsyncMock(return_value=None) + transport._process = mock_process + transport._stdout_stream = MockTextReceiveStream(chunks) + transport._stderr_stream = MockTextReceiveStream([]) + + messages: list[Any] = [] + async for msg in transport.read_messages(): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "user" + assert ( + messages[0]["message"]["content"][0]["tool_use_id"] + == "toolu_016fed1NhiaMLqnEvrj5NUaj" + ) + + anyio.run(_test) + + def test_buffer_size_exceeded(self) -> None: + """Test that exceeding buffer size raises an appropriate error.""" + + async def _test() -> None: + huge_incomplete = '{"data": "' + "x" * (_DEFAULT_MAX_BUFFER_SIZE + 1000) + + transport = SubprocessCLITransport(prompt="test", options=make_options()) + + mock_process = MagicMock() + mock_process.returncode = None + mock_process.wait = AsyncMock(return_value=None) + transport._process = mock_process + transport._stdout_stream = MockTextReceiveStream([huge_incomplete]) + transport._stderr_stream = MockTextReceiveStream([]) + + with pytest.raises(Exception) as exc_info: + messages: list[Any] = [] + async for msg in transport.read_messages(): + messages.append(msg) + + assert isinstance(exc_info.value, CLIJSONDecodeError) + assert "exceeded maximum buffer size" in str(exc_info.value) + + anyio.run(_test) + + def test_buffer_size_option(self) -> None: + """Test that the configurable buffer size option is respected.""" + + async def _test() -> None: + custom_limit = 512 + huge_incomplete = '{"data": "' + "x" * (custom_limit + 10) + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(max_buffer_size=custom_limit), + ) + + mock_process = MagicMock() + mock_process.returncode = None + mock_process.wait = AsyncMock(return_value=None) + transport._process = mock_process + transport._stdout_stream = MockTextReceiveStream([huge_incomplete]) + transport._stderr_stream = MockTextReceiveStream([]) + + with pytest.raises(CLIJSONDecodeError) as exc_info: + async for _ in transport.read_messages(): + pass + + assert f"maximum buffer size of {custom_limit} bytes" in str(exc_info.value) + + anyio.run(_test) + + def test_mixed_complete_and_split_json(self) -> None: + """Test handling a mix of complete and split JSON messages.""" + + async def _test() -> None: + msg1 = json.dumps({"type": "system", "subtype": "start"}) + + large_msg = { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "y" * 5000}]}, + } + large_json = json.dumps(large_msg) + + msg3 = json.dumps({"type": "system", "subtype": "end"}) + + lines = [ + msg1 + "\n", + large_json[:1000], + large_json[1000:3000], + large_json[3000:] + "\n" + msg3, + ] + + transport = SubprocessCLITransport(prompt="test", options=make_options()) + + mock_process = MagicMock() + mock_process.returncode = None + mock_process.wait = AsyncMock(return_value=None) + transport._process = mock_process + transport._stdout_stream = MockTextReceiveStream(lines) + transport._stderr_stream = MockTextReceiveStream([]) + + messages: list[Any] = [] + async for msg in transport.read_messages(): + messages.append(msg) + + assert len(messages) == 3 + assert messages[0]["type"] == "system" + assert messages[0]["subtype"] == "start" + assert messages[1]["type"] == "assistant" + assert len(messages[1]["message"]["content"][0]["text"]) == 5000 + assert messages[2]["type"] == "system" + assert messages[2]["subtype"] == "end" + + anyio.run(_test) diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_tool_callbacks.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_tool_callbacks.py new file mode 100644 index 00000000..e7b56e10 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_tool_callbacks.py @@ -0,0 +1,772 @@ +"""Tests for tool permission callbacks and hook callbacks.""" + +import json +from typing import Any + +import pytest + +from claude_agent_sdk import ( + ClaudeAgentOptions, + HookContext, + HookInput, + HookJSONOutput, + HookMatcher, + PermissionResultAllow, + PermissionResultDeny, + ToolPermissionContext, +) +from claude_agent_sdk._internal.query import Query +from claude_agent_sdk._internal.transport import Transport + + +class MockTransport(Transport): + """Mock transport for testing.""" + + def __init__(self): + self.written_messages = [] + self.messages_to_read = [] + self._connected = False + + async def connect(self) -> None: + self._connected = True + + async def close(self) -> None: + self._connected = False + + async def write(self, data: str) -> None: + self.written_messages.append(data) + + async def end_input(self) -> None: + pass + + def read_messages(self): + async def _read(): + for msg in self.messages_to_read: + yield msg + + return _read() + + def is_ready(self) -> bool: + return self._connected + + +class TestToolPermissionCallbacks: + """Test tool permission callback functionality.""" + + @pytest.mark.asyncio + async def test_permission_callback_allow(self): + """Test callback that allows tool execution.""" + callback_invoked = False + + async def allow_callback( + tool_name: str, input_data: dict, context: ToolPermissionContext + ) -> PermissionResultAllow: + nonlocal callback_invoked + callback_invoked = True + assert tool_name == "TestTool" + assert input_data == {"param": "value"} + return PermissionResultAllow() + + transport = MockTransport() + query = Query( + transport=transport, + is_streaming_mode=True, + can_use_tool=allow_callback, + hooks=None, + ) + + # Simulate control request + request = { + "type": "control_request", + "request_id": "test-1", + "request": { + "subtype": "can_use_tool", + "tool_name": "TestTool", + "input": {"param": "value"}, + "permission_suggestions": [], + }, + } + + await query._handle_control_request(request) + + # Check callback was invoked + assert callback_invoked + + # Check response was sent + assert len(transport.written_messages) == 1 + response = transport.written_messages[0] + assert '"behavior": "allow"' in response + + @pytest.mark.asyncio + async def test_permission_callback_deny(self): + """Test callback that denies tool execution.""" + + async def deny_callback( + tool_name: str, input_data: dict, context: ToolPermissionContext + ) -> PermissionResultDeny: + return PermissionResultDeny(message="Security policy violation") + + transport = MockTransport() + query = Query( + transport=transport, + is_streaming_mode=True, + can_use_tool=deny_callback, + hooks=None, + ) + + request = { + "type": "control_request", + "request_id": "test-2", + "request": { + "subtype": "can_use_tool", + "tool_name": "DangerousTool", + "input": {"command": "rm -rf /"}, + "permission_suggestions": ["deny"], + }, + } + + await query._handle_control_request(request) + + # Check response + assert len(transport.written_messages) == 1 + response = transport.written_messages[0] + assert '"behavior": "deny"' in response + assert '"message": "Security policy violation"' in response + + @pytest.mark.asyncio + async def test_permission_callback_input_modification(self): + """Test callback that modifies tool input.""" + + async def modify_callback( + tool_name: str, input_data: dict, context: ToolPermissionContext + ) -> PermissionResultAllow: + # Modify the input to add safety flag + modified_input = input_data.copy() + modified_input["safe_mode"] = True + return PermissionResultAllow(updated_input=modified_input) + + transport = MockTransport() + query = Query( + transport=transport, + is_streaming_mode=True, + can_use_tool=modify_callback, + hooks=None, + ) + + request = { + "type": "control_request", + "request_id": "test-3", + "request": { + "subtype": "can_use_tool", + "tool_name": "WriteTool", + "input": {"file_path": "/etc/passwd"}, + "permission_suggestions": [], + }, + } + + await query._handle_control_request(request) + + # Check response includes modified input + assert len(transport.written_messages) == 1 + response = transport.written_messages[0] + assert '"behavior": "allow"' in response + assert '"safe_mode": true' in response + + @pytest.mark.asyncio + async def test_callback_exception_handling(self): + """Test that callback exceptions are properly handled.""" + + async def error_callback( + tool_name: str, input_data: dict, context: ToolPermissionContext + ) -> PermissionResultAllow: + raise ValueError("Callback error") + + transport = MockTransport() + query = Query( + transport=transport, + is_streaming_mode=True, + can_use_tool=error_callback, + hooks=None, + ) + + request = { + "type": "control_request", + "request_id": "test-5", + "request": { + "subtype": "can_use_tool", + "tool_name": "TestTool", + "input": {}, + "permission_suggestions": [], + }, + } + + await query._handle_control_request(request) + + # Check error response was sent + assert len(transport.written_messages) == 1 + response = transport.written_messages[0] + assert '"subtype": "error"' in response + assert "Callback error" in response + + +class TestHookCallbacks: + """Test hook callback functionality.""" + + @pytest.mark.asyncio + async def test_hook_execution(self): + """Test that hooks are called at appropriate times.""" + hook_calls = [] + + async def test_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> dict: + hook_calls.append({"input": input_data, "tool_use_id": tool_use_id}) + return {"processed": True} + + transport = MockTransport() + + # Create hooks configuration + hooks = { + "tool_use_start": [{"matcher": {"tool": "TestTool"}, "hooks": [test_hook]}] + } + + query = Query( + transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks + ) + + # Manually register the hook callback to avoid needing the full initialize flow + callback_id = "test_hook_0" + query.hook_callbacks[callback_id] = test_hook + + # Simulate hook callback request + request = { + "type": "control_request", + "request_id": "test-hook-1", + "request": { + "subtype": "hook_callback", + "callback_id": callback_id, + "input": {"test": "data"}, + "tool_use_id": "tool-123", + }, + } + + await query._handle_control_request(request) + + # Check hook was called + assert len(hook_calls) == 1 + assert hook_calls[0]["input"] == {"test": "data"} + assert hook_calls[0]["tool_use_id"] == "tool-123" + + # Check response + assert len(transport.written_messages) > 0 + last_response = transport.written_messages[-1] + assert '"processed": true' in last_response + + @pytest.mark.asyncio + async def test_hook_output_fields(self): + """Test that all SyncHookJSONOutput fields are properly handled.""" + + # Test all SyncHookJSONOutput fields together + async def comprehensive_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + return { + # Control fields + "continue_": True, + "suppressOutput": False, + "stopReason": "Test stop reason", + # Decision fields + "decision": "block", + "systemMessage": "Test system message", + "reason": "Test reason for blocking", + # Hook-specific output with all PreToolUse fields + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "Security policy violation", + "updatedInput": {"modified": "input"}, + }, + } + + transport = MockTransport() + hooks = { + "PreToolUse": [ + {"matcher": {"tool": "TestTool"}, "hooks": [comprehensive_hook]} + ] + } + + query = Query( + transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks + ) + + callback_id = "test_comprehensive_hook" + query.hook_callbacks[callback_id] = comprehensive_hook + + request = { + "type": "control_request", + "request_id": "test-comprehensive", + "request": { + "subtype": "hook_callback", + "callback_id": callback_id, + "input": {"test": "data"}, + "tool_use_id": "tool-456", + }, + } + + await query._handle_control_request(request) + + # Check response contains all the fields + assert len(transport.written_messages) > 0 + last_response = transport.written_messages[-1] + + # Parse the JSON response + response_data = json.loads(last_response) + # The hook result is nested at response.response + result = response_data["response"]["response"] + + # Verify control fields are present and converted to CLI format + assert result.get("continue") is True, ( + "continue_ should be converted to continue" + ) + assert "continue_" not in result, "continue_ should not appear in CLI output" + assert result.get("suppressOutput") is False + assert result.get("stopReason") == "Test stop reason" + + # Verify decision fields are present + assert result.get("decision") == "block" + assert result.get("reason") == "Test reason for blocking" + assert result.get("systemMessage") == "Test system message" + + # Verify hook-specific output is present + hook_output = result.get("hookSpecificOutput", {}) + assert hook_output.get("hookEventName") == "PreToolUse" + assert hook_output.get("permissionDecision") == "deny" + assert ( + hook_output.get("permissionDecisionReason") == "Security policy violation" + ) + assert "updatedInput" in hook_output + + @pytest.mark.asyncio + async def test_async_hook_output(self): + """Test AsyncHookJSONOutput type with proper async fields.""" + + async def async_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + # Test that async hooks properly use async_ and asyncTimeout fields + return { + "async_": True, + "asyncTimeout": 5000, + } + + transport = MockTransport() + hooks = {"PreToolUse": [{"matcher": None, "hooks": [async_hook]}]} + + query = Query( + transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks + ) + + callback_id = "test_async_hook" + query.hook_callbacks[callback_id] = async_hook + + request = { + "type": "control_request", + "request_id": "test-async", + "request": { + "subtype": "hook_callback", + "callback_id": callback_id, + "input": {"test": "async_data"}, + "tool_use_id": None, + }, + } + + await query._handle_control_request(request) + + # Check response contains async fields + assert len(transport.written_messages) > 0 + last_response = transport.written_messages[-1] + + # Parse the JSON response + response_data = json.loads(last_response) + # The hook result is nested at response.response + result = response_data["response"]["response"] + + # The SDK should convert async_ to "async" for CLI compatibility + assert result.get("async") is True, "async_ should be converted to async" + assert "async_" not in result, "async_ should not appear in CLI output" + assert result.get("asyncTimeout") == 5000 + + @pytest.mark.asyncio + async def test_field_name_conversion(self): + """Test that Python-safe field names (async_, continue_) are converted to CLI format (async, continue).""" + + async def conversion_test_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + # Return both async_ and continue_ to test conversion + return { + "async_": True, + "asyncTimeout": 10000, + "continue_": False, + "stopReason": "Testing field conversion", + "systemMessage": "Fields should be converted", + } + + transport = MockTransport() + hooks = {"PreToolUse": [{"matcher": None, "hooks": [conversion_test_hook]}]} + + query = Query( + transport=transport, is_streaming_mode=True, can_use_tool=None, hooks=hooks + ) + + callback_id = "test_conversion" + query.hook_callbacks[callback_id] = conversion_test_hook + + request = { + "type": "control_request", + "request_id": "test-conversion", + "request": { + "subtype": "hook_callback", + "callback_id": callback_id, + "input": {"test": "data"}, + "tool_use_id": None, + }, + } + + await query._handle_control_request(request) + + # Check response has converted field names + assert len(transport.written_messages) > 0 + last_response = transport.written_messages[-1] + + response_data = json.loads(last_response) + result = response_data["response"]["response"] + + # Verify async_ was converted to async + assert result.get("async") is True, "async_ should be converted to async" + assert "async_" not in result, "async_ should not appear in output" + + # Verify continue_ was converted to continue + assert result.get("continue") is False, ( + "continue_ should be converted to continue" + ) + assert "continue_" not in result, "continue_ should not appear in output" + + # Verify other fields are unchanged + assert result.get("asyncTimeout") == 10000 + assert result.get("stopReason") == "Testing field conversion" + assert result.get("systemMessage") == "Fields should be converted" + + +class TestClaudeAgentOptionsIntegration: + """Test that callbacks work through ClaudeAgentOptions.""" + + def test_options_with_callbacks(self): + """Test creating options with callbacks.""" + + async def my_callback( + tool_name: str, input_data: dict, context: ToolPermissionContext + ) -> PermissionResultAllow: + return PermissionResultAllow() + + async def my_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> dict: + return {} + + options = ClaudeAgentOptions( + can_use_tool=my_callback, + hooks={ + "tool_use_start": [ + HookMatcher(matcher={"tool": "Bash"}, hooks=[my_hook]) + ] + }, + ) + + assert options.can_use_tool == my_callback + assert "tool_use_start" in options.hooks + assert len(options.hooks["tool_use_start"]) == 1 + assert options.hooks["tool_use_start"][0].hooks[0] == my_hook + + +class TestHookEventCallbacks: + """Test hook callbacks for all hook event types.""" + + @pytest.mark.asyncio + async def test_notification_hook_callback(self): + """Test that a Notification hook callback receives correct input and returns output.""" + hook_calls: list[dict[str, Any]] = [] + + async def notification_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + hook_calls.append({"input": input_data, "tool_use_id": tool_use_id}) + return { + "hookSpecificOutput": { + "hookEventName": "Notification", + "additionalContext": "Notification processed", + } + } + + transport = MockTransport() + query = Query( + transport=transport, is_streaming_mode=True, can_use_tool=None, hooks={} + ) + + callback_id = "test_notification_hook" + query.hook_callbacks[callback_id] = notification_hook + + request = { + "type": "control_request", + "request_id": "test-notification", + "request": { + "subtype": "hook_callback", + "callback_id": callback_id, + "input": { + "session_id": "sess-1", + "transcript_path": "/tmp/t", + "cwd": "/home", + "hook_event_name": "Notification", + "message": "Task completed", + "notification_type": "info", + }, + "tool_use_id": None, + }, + } + + await query._handle_control_request(request) + + assert len(hook_calls) == 1 + assert hook_calls[0]["input"]["hook_event_name"] == "Notification" + assert hook_calls[0]["input"]["message"] == "Task completed" + + response_data = json.loads(transport.written_messages[-1]) + result = response_data["response"]["response"] + assert result["hookSpecificOutput"]["hookEventName"] == "Notification" + assert ( + result["hookSpecificOutput"]["additionalContext"] + == "Notification processed" + ) + + @pytest.mark.asyncio + async def test_permission_request_hook_callback(self): + """Test that a PermissionRequest hook callback returns a decision.""" + + async def permission_request_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + return { + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": {"type": "allow"}, + } + } + + transport = MockTransport() + query = Query( + transport=transport, is_streaming_mode=True, can_use_tool=None, hooks={} + ) + + callback_id = "test_permission_request_hook" + query.hook_callbacks[callback_id] = permission_request_hook + + request = { + "type": "control_request", + "request_id": "test-perm-req", + "request": { + "subtype": "hook_callback", + "callback_id": callback_id, + "input": { + "session_id": "sess-1", + "transcript_path": "/tmp/t", + "cwd": "/home", + "hook_event_name": "PermissionRequest", + "tool_name": "Bash", + "tool_input": {"command": "ls"}, + }, + "tool_use_id": None, + }, + } + + await query._handle_control_request(request) + + response_data = json.loads(transport.written_messages[-1]) + result = response_data["response"]["response"] + assert result["hookSpecificOutput"]["hookEventName"] == "PermissionRequest" + assert result["hookSpecificOutput"]["decision"] == {"type": "allow"} + + @pytest.mark.asyncio + async def test_subagent_start_hook_callback(self): + """Test that a SubagentStart hook callback works correctly.""" + + async def subagent_start_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + return { + "hookSpecificOutput": { + "hookEventName": "SubagentStart", + "additionalContext": "Subagent approved", + } + } + + transport = MockTransport() + query = Query( + transport=transport, is_streaming_mode=True, can_use_tool=None, hooks={} + ) + + callback_id = "test_subagent_start_hook" + query.hook_callbacks[callback_id] = subagent_start_hook + + request = { + "type": "control_request", + "request_id": "test-subagent-start", + "request": { + "subtype": "hook_callback", + "callback_id": callback_id, + "input": { + "session_id": "sess-1", + "transcript_path": "/tmp/t", + "cwd": "/home", + "hook_event_name": "SubagentStart", + "agent_id": "agent-42", + "agent_type": "researcher", + }, + "tool_use_id": None, + }, + } + + await query._handle_control_request(request) + + response_data = json.loads(transport.written_messages[-1]) + result = response_data["response"]["response"] + assert result["hookSpecificOutput"]["hookEventName"] == "SubagentStart" + assert result["hookSpecificOutput"]["additionalContext"] == "Subagent approved" + + @pytest.mark.asyncio + async def test_post_tool_use_hook_with_updated_mcp_output(self): + """Test PostToolUse hook returning updatedMCPToolOutput.""" + + async def post_tool_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + return { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "updatedMCPToolOutput": {"result": "modified output"}, + } + } + + transport = MockTransport() + query = Query( + transport=transport, is_streaming_mode=True, can_use_tool=None, hooks={} + ) + + callback_id = "test_post_tool_mcp_hook" + query.hook_callbacks[callback_id] = post_tool_hook + + request = { + "type": "control_request", + "request_id": "test-post-tool-mcp", + "request": { + "subtype": "hook_callback", + "callback_id": callback_id, + "input": { + "session_id": "sess-1", + "transcript_path": "/tmp/t", + "cwd": "/home", + "hook_event_name": "PostToolUse", + "tool_name": "mcp_tool", + "tool_input": {}, + "tool_response": "original output", + "tool_use_id": "tu-123", + }, + "tool_use_id": "tu-123", + }, + } + + await query._handle_control_request(request) + + response_data = json.loads(transport.written_messages[-1]) + result = response_data["response"]["response"] + assert result["hookSpecificOutput"]["updatedMCPToolOutput"] == { + "result": "modified output" + } + + @pytest.mark.asyncio + async def test_pre_tool_use_hook_with_additional_context(self): + """Test PreToolUse hook returning additionalContext.""" + + async def pre_tool_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "additionalContext": "Extra context for Claude", + } + } + + transport = MockTransport() + query = Query( + transport=transport, is_streaming_mode=True, can_use_tool=None, hooks={} + ) + + callback_id = "test_pre_tool_context_hook" + query.hook_callbacks[callback_id] = pre_tool_hook + + request = { + "type": "control_request", + "request_id": "test-pre-tool-ctx", + "request": { + "subtype": "hook_callback", + "callback_id": callback_id, + "input": { + "session_id": "sess-1", + "transcript_path": "/tmp/t", + "cwd": "/home", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "ls"}, + "tool_use_id": "tu-456", + }, + "tool_use_id": "tu-456", + }, + } + + await query._handle_control_request(request) + + response_data = json.loads(transport.written_messages[-1]) + result = response_data["response"]["response"] + assert ( + result["hookSpecificOutput"]["additionalContext"] + == "Extra context for Claude" + ) + assert result["hookSpecificOutput"]["permissionDecision"] == "allow" + + +class TestHookInitializeRegistration: + """Test that new hook events can be registered through the initialize flow.""" + + @pytest.mark.asyncio + async def test_new_hook_events_registered_in_hooks_config(self): + """Test that all new hook event types can be configured in hooks dict.""" + + async def noop_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext + ) -> HookJSONOutput: + return {} + + # Verify all new hook events can be used as keys in the hooks config + options = ClaudeAgentOptions( + hooks={ + "Notification": [HookMatcher(hooks=[noop_hook])], + "SubagentStart": [HookMatcher(hooks=[noop_hook])], + "PermissionRequest": [HookMatcher(hooks=[noop_hook])], + } + ) + + assert "Notification" in options.hooks + assert "SubagentStart" in options.hooks + assert "PermissionRequest" in options.hooks + assert len(options.hooks) == 3 diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_transport.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_transport.py new file mode 100644 index 00000000..65e6ada7 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_transport.py @@ -0,0 +1,914 @@ +"""Tests for Claude SDK transport layer.""" + +import os +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio +import pytest + +from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport +from claude_agent_sdk.types import ClaudeAgentOptions + +DEFAULT_CLI_PATH = "/usr/bin/claude" + + +def make_options(**kwargs: object) -> ClaudeAgentOptions: + """Construct options using the standard CLI path unless overridden.""" + + cli_path = kwargs.pop("cli_path", DEFAULT_CLI_PATH) + return ClaudeAgentOptions(cli_path=cli_path, **kwargs) + + +class TestSubprocessCLITransport: + """Test subprocess transport implementation.""" + + def test_find_cli_not_found(self): + """Test CLI not found error.""" + from claude_agent_sdk._errors import CLINotFoundError + + with ( + patch("shutil.which", return_value=None), + patch("pathlib.Path.exists", return_value=False), + pytest.raises(CLINotFoundError) as exc_info, + ): + SubprocessCLITransport(prompt="test", options=ClaudeAgentOptions()) + + assert "Claude Code not found" in str(exc_info.value) + + def test_build_command_basic(self): + """Test building basic CLI command.""" + transport = SubprocessCLITransport(prompt="Hello", options=make_options()) + + cmd = transport._build_command() + assert cmd[0] == "/usr/bin/claude" + assert "--output-format" in cmd + assert "stream-json" in cmd + # Always use streaming mode (matching TypeScript SDK) + assert "--input-format" in cmd + assert "--print" not in cmd # Never use --print anymore + # Prompt is sent via stdin, not CLI args + assert "--system-prompt" in cmd + assert cmd[cmd.index("--system-prompt") + 1] == "" + + def test_cli_path_accepts_pathlib_path(self): + """Test that cli_path accepts pathlib.Path objects.""" + from pathlib import Path + + path = Path("/usr/bin/claude") + transport = SubprocessCLITransport( + prompt="Hello", + options=ClaudeAgentOptions(cli_path=path), + ) + + # Path object is converted to string, compare with str(path) + assert transport._cli_path == str(path) + + def test_build_command_with_system_prompt_string(self): + """Test building CLI command with system prompt as string.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options( + system_prompt="Be helpful", + ), + ) + + cmd = transport._build_command() + assert "--system-prompt" in cmd + assert "Be helpful" in cmd + + def test_build_command_with_system_prompt_preset(self): + """Test building CLI command with system prompt preset.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options( + system_prompt={"type": "preset", "preset": "claude_code"}, + ), + ) + + cmd = transport._build_command() + assert "--system-prompt" not in cmd + assert "--append-system-prompt" not in cmd + + def test_build_command_with_system_prompt_preset_and_append(self): + """Test building CLI command with system prompt preset and append.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options( + system_prompt={ + "type": "preset", + "preset": "claude_code", + "append": "Be concise.", + }, + ), + ) + + cmd = transport._build_command() + assert "--system-prompt" not in cmd + assert "--append-system-prompt" in cmd + assert "Be concise." in cmd + + def test_build_command_with_options(self): + """Test building CLI command with options.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options( + allowed_tools=["Read", "Write"], + disallowed_tools=["Bash"], + model="claude-sonnet-4-5", + permission_mode="acceptEdits", + max_turns=5, + ), + ) + + cmd = transport._build_command() + assert "--allowedTools" in cmd + assert "Read,Write" in cmd + assert "--disallowedTools" in cmd + assert "Bash" in cmd + assert "--model" in cmd + assert "claude-sonnet-4-5" in cmd + assert "--permission-mode" in cmd + assert "acceptEdits" in cmd + assert "--max-turns" in cmd + assert "5" in cmd + + def test_build_command_with_fallback_model(self): + """Test building CLI command with fallback_model option.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options( + model="opus", + fallback_model="sonnet", + ), + ) + + cmd = transport._build_command() + assert "--model" in cmd + assert "opus" in cmd + assert "--fallback-model" in cmd + assert "sonnet" in cmd + + def test_build_command_with_max_thinking_tokens(self): + """Test building CLI command with max_thinking_tokens option.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(max_thinking_tokens=5000), + ) + + cmd = transport._build_command() + assert "--max-thinking-tokens" in cmd + assert "5000" in cmd + + def test_build_command_with_add_dirs(self): + """Test building CLI command with add_dirs option.""" + from pathlib import Path + + dir1 = "/path/to/dir1" + dir2 = Path("/path/to/dir2") + transport = SubprocessCLITransport( + prompt="test", + options=make_options(add_dirs=[dir1, dir2]), + ) + + cmd = transport._build_command() + + # Check that both directories are in the command + assert "--add-dir" in cmd + add_dir_indices = [i for i, x in enumerate(cmd) if x == "--add-dir"] + assert len(add_dir_indices) == 2 + + # The directories should appear after --add-dir flags + dirs_in_cmd = [cmd[i + 1] for i in add_dir_indices] + assert dir1 in dirs_in_cmd + assert str(dir2) in dirs_in_cmd + + def test_session_continuation(self): + """Test session continuation options.""" + transport = SubprocessCLITransport( + prompt="Continue from before", + options=make_options(continue_conversation=True, resume="session-123"), + ) + + cmd = transport._build_command() + assert "--continue" in cmd + assert "--resume" in cmd + assert "session-123" in cmd + + def test_connect_close(self): + """Test connect and close lifecycle.""" + + async def _test(): + with patch("anyio.open_process") as mock_exec: + # Mock version check process + mock_version_process = MagicMock() + mock_version_process.stdout = MagicMock() + mock_version_process.stdout.receive = AsyncMock( + return_value=b"2.0.0 (Claude Code)" + ) + mock_version_process.terminate = MagicMock() + mock_version_process.wait = AsyncMock() + + # Mock main process + mock_process = MagicMock() + mock_process.returncode = None + mock_process.terminate = MagicMock() + mock_process.wait = AsyncMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + + # Mock stdin with aclose method + mock_stdin = MagicMock() + mock_stdin.aclose = AsyncMock() + mock_process.stdin = mock_stdin + + # Return version process first, then main process + mock_exec.side_effect = [mock_version_process, mock_process] + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(), + ) + + await transport.connect() + assert transport._process is not None + assert transport.is_ready() + + await transport.close() + mock_process.terminate.assert_called_once() + + anyio.run(_test) + + def test_read_messages(self): + """Test reading messages from CLI output.""" + # This test is simplified to just test the transport creation + # The full async stream handling is tested in integration tests + transport = SubprocessCLITransport(prompt="test", options=make_options()) + + # The transport now just provides raw message reading via read_messages() + # So we just verify the transport can be created and basic structure is correct + assert transport._prompt == "test" + assert transport._cli_path == "/usr/bin/claude" + + def test_connect_with_nonexistent_cwd(self): + """Test that connect raises CLIConnectionError when cwd doesn't exist.""" + from claude_agent_sdk._errors import CLIConnectionError + + async def _test(): + transport = SubprocessCLITransport( + prompt="test", + options=make_options(cwd="/this/directory/does/not/exist"), + ) + + with pytest.raises(CLIConnectionError) as exc_info: + await transport.connect() + + assert "/this/directory/does/not/exist" in str(exc_info.value) + + anyio.run(_test) + + def test_build_command_with_settings_file(self): + """Test building CLI command with settings as file path.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(settings="/path/to/settings.json"), + ) + + cmd = transport._build_command() + assert "--settings" in cmd + assert "/path/to/settings.json" in cmd + + def test_build_command_with_settings_json(self): + """Test building CLI command with settings as JSON object.""" + settings_json = '{"permissions": {"allow": ["Bash(ls:*)"]}}' + transport = SubprocessCLITransport( + prompt="test", + options=make_options(settings=settings_json), + ) + + cmd = transport._build_command() + assert "--settings" in cmd + assert settings_json in cmd + + def test_build_command_with_extra_args(self): + """Test building CLI command with extra_args for future flags.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options( + extra_args={ + "new-flag": "value", + "boolean-flag": None, + "another-option": "test-value", + } + ), + ) + + cmd = transport._build_command() + cmd_str = " ".join(cmd) + + # Check flags with values + assert "--new-flag value" in cmd_str + assert "--another-option test-value" in cmd_str + + # Check boolean flag (no value) + assert "--boolean-flag" in cmd + # Make sure boolean flag doesn't have a value after it + boolean_idx = cmd.index("--boolean-flag") + # Either it's the last element or the next element is another flag + assert boolean_idx == len(cmd) - 1 or cmd[boolean_idx + 1].startswith("--") + + def test_build_command_with_mcp_servers(self): + """Test building CLI command with mcp_servers option.""" + import json + + mcp_servers = { + "test-server": { + "type": "stdio", + "command": "/path/to/server", + "args": ["--option", "value"], + } + } + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(mcp_servers=mcp_servers), + ) + + cmd = transport._build_command() + + # Find the --mcp-config flag and its value + assert "--mcp-config" in cmd + mcp_idx = cmd.index("--mcp-config") + mcp_config_value = cmd[mcp_idx + 1] + + # Parse the JSON and verify structure + config = json.loads(mcp_config_value) + assert "mcpServers" in config + assert config["mcpServers"] == mcp_servers + + def test_build_command_with_mcp_servers_as_file_path(self): + """Test building CLI command with mcp_servers as file path.""" + from pathlib import Path + + # Test with string path + string_path = "/path/to/mcp-config.json" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(mcp_servers=string_path), + ) + + cmd = transport._build_command() + assert "--mcp-config" in cmd + mcp_idx = cmd.index("--mcp-config") + assert cmd[mcp_idx + 1] == string_path + + # Test with Path object + path_obj = Path("/path/to/mcp-config.json") + transport = SubprocessCLITransport( + prompt="test", + options=make_options(mcp_servers=path_obj), + ) + + cmd = transport._build_command() + assert "--mcp-config" in cmd + mcp_idx = cmd.index("--mcp-config") + # Path object gets converted to string, compare with str(path_obj) + assert cmd[mcp_idx + 1] == str(path_obj) + + def test_build_command_with_mcp_servers_as_json_string(self): + """Test building CLI command with mcp_servers as JSON string.""" + json_config = '{"mcpServers": {"server": {"type": "stdio", "command": "test"}}}' + transport = SubprocessCLITransport( + prompt="test", + options=make_options(mcp_servers=json_config), + ) + + cmd = transport._build_command() + assert "--mcp-config" in cmd + mcp_idx = cmd.index("--mcp-config") + assert cmd[mcp_idx + 1] == json_config + + def test_env_vars_passed_to_subprocess(self): + """Test that custom environment variables are passed to the subprocess.""" + + async def _test(): + test_value = f"test-{uuid.uuid4().hex[:8]}" + custom_env = { + "MY_TEST_VAR": test_value, + } + + options = make_options(env=custom_env) + + # Mock the subprocess to capture the env argument + with patch( + "anyio.open_process", new_callable=AsyncMock + ) as mock_open_process: + # Mock version check process + mock_version_process = MagicMock() + mock_version_process.stdout = MagicMock() + mock_version_process.stdout.receive = AsyncMock( + return_value=b"2.0.0 (Claude Code)" + ) + mock_version_process.terminate = MagicMock() + mock_version_process.wait = AsyncMock() + + # Mock main process + mock_process = MagicMock() + mock_process.stdout = MagicMock() + mock_stdin = MagicMock() + mock_stdin.aclose = AsyncMock() # Add async aclose method + mock_process.stdin = mock_stdin + mock_process.returncode = None + + # Return version process first, then main process + mock_open_process.side_effect = [mock_version_process, mock_process] + + transport = SubprocessCLITransport( + prompt="test", + options=options, + ) + + await transport.connect() + + # Verify open_process was called twice (version check + main process) + assert mock_open_process.call_count == 2 + + # Check the second call (main process) for env vars + second_call_kwargs = mock_open_process.call_args_list[1].kwargs + assert "env" in second_call_kwargs + env_passed = second_call_kwargs["env"] + + # Check that custom env var was passed + assert env_passed["MY_TEST_VAR"] == test_value + + # Verify SDK identifier is present + assert "CLAUDE_CODE_ENTRYPOINT" in env_passed + assert env_passed["CLAUDE_CODE_ENTRYPOINT"] == "sdk-py" + + # Verify system env vars are also included with correct values + if "PATH" in os.environ: + assert "PATH" in env_passed + assert env_passed["PATH"] == os.environ["PATH"] + + anyio.run(_test) + + def test_connect_as_different_user(self): + """Test connect as different user.""" + + async def _test(): + custom_user = "claude" + options = make_options(user=custom_user) + + # Mock the subprocess to capture the env argument + with patch( + "anyio.open_process", new_callable=AsyncMock + ) as mock_open_process: + # Mock version check process + mock_version_process = MagicMock() + mock_version_process.stdout = MagicMock() + mock_version_process.stdout.receive = AsyncMock( + return_value=b"2.0.0 (Claude Code)" + ) + mock_version_process.terminate = MagicMock() + mock_version_process.wait = AsyncMock() + + # Mock main process + mock_process = MagicMock() + mock_process.stdout = MagicMock() + mock_stdin = MagicMock() + mock_stdin.aclose = AsyncMock() # Add async aclose method + mock_process.stdin = mock_stdin + mock_process.returncode = None + + # Return version process first, then main process + mock_open_process.side_effect = [mock_version_process, mock_process] + + transport = SubprocessCLITransport( + prompt="test", + options=options, + ) + + await transport.connect() + + # Verify open_process was called twice (version check + main process) + assert mock_open_process.call_count == 2 + + # Check the second call (main process) for user + second_call_kwargs = mock_open_process.call_args_list[1].kwargs + assert "user" in second_call_kwargs + user_passed = second_call_kwargs["user"] + + # Check that user was passed + assert user_passed == "claude" + + anyio.run(_test) + + def test_build_command_with_sandbox_only(self): + """Test building CLI command with sandbox settings (no existing settings).""" + import json + + from claude_agent_sdk import SandboxSettings + + sandbox: SandboxSettings = { + "enabled": True, + "autoAllowBashIfSandboxed": True, + "network": { + "allowLocalBinding": True, + "allowUnixSockets": ["/var/run/docker.sock"], + }, + } + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(sandbox=sandbox), + ) + + cmd = transport._build_command() + + # Should have --settings with sandbox merged in + assert "--settings" in cmd + settings_idx = cmd.index("--settings") + settings_value = cmd[settings_idx + 1] + + # Parse and verify + parsed = json.loads(settings_value) + assert "sandbox" in parsed + assert parsed["sandbox"]["enabled"] is True + assert parsed["sandbox"]["autoAllowBashIfSandboxed"] is True + assert parsed["sandbox"]["network"]["allowLocalBinding"] is True + assert parsed["sandbox"]["network"]["allowUnixSockets"] == [ + "/var/run/docker.sock" + ] + + def test_build_command_with_sandbox_and_settings_json(self): + """Test building CLI command with sandbox merged into existing settings JSON.""" + import json + + from claude_agent_sdk import SandboxSettings + + # Existing settings as JSON string + existing_settings = ( + '{"permissions": {"allow": ["Bash(ls:*)"]}, "verbose": true}' + ) + + sandbox: SandboxSettings = { + "enabled": True, + "excludedCommands": ["git", "docker"], + } + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(settings=existing_settings, sandbox=sandbox), + ) + + cmd = transport._build_command() + + # Should have merged settings + assert "--settings" in cmd + settings_idx = cmd.index("--settings") + settings_value = cmd[settings_idx + 1] + + parsed = json.loads(settings_value) + + # Original settings should be preserved + assert parsed["permissions"] == {"allow": ["Bash(ls:*)"]} + assert parsed["verbose"] is True + + # Sandbox should be merged in + assert "sandbox" in parsed + assert parsed["sandbox"]["enabled"] is True + assert parsed["sandbox"]["excludedCommands"] == ["git", "docker"] + + def test_build_command_with_settings_file_and_no_sandbox(self): + """Test that settings file path is passed through when no sandbox.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(settings="/path/to/settings.json"), + ) + + cmd = transport._build_command() + + # Should pass path directly, not parse it + assert "--settings" in cmd + settings_idx = cmd.index("--settings") + assert cmd[settings_idx + 1] == "/path/to/settings.json" + + def test_build_command_sandbox_minimal(self): + """Test sandbox with minimal configuration.""" + import json + + from claude_agent_sdk import SandboxSettings + + sandbox: SandboxSettings = {"enabled": True} + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(sandbox=sandbox), + ) + + cmd = transport._build_command() + + assert "--settings" in cmd + settings_idx = cmd.index("--settings") + settings_value = cmd[settings_idx + 1] + + parsed = json.loads(settings_value) + assert parsed == {"sandbox": {"enabled": True}} + + def test_sandbox_network_config(self): + """Test sandbox with full network configuration.""" + import json + + from claude_agent_sdk import SandboxSettings + + sandbox: SandboxSettings = { + "enabled": True, + "network": { + "allowUnixSockets": ["/tmp/ssh-agent.sock"], + "allowAllUnixSockets": False, + "allowLocalBinding": True, + "httpProxyPort": 8080, + "socksProxyPort": 8081, + }, + } + + transport = SubprocessCLITransport( + prompt="test", + options=make_options(sandbox=sandbox), + ) + + cmd = transport._build_command() + settings_idx = cmd.index("--settings") + settings_value = cmd[settings_idx + 1] + + parsed = json.loads(settings_value) + network = parsed["sandbox"]["network"] + + assert network["allowUnixSockets"] == ["/tmp/ssh-agent.sock"] + assert network["allowAllUnixSockets"] is False + assert network["allowLocalBinding"] is True + assert network["httpProxyPort"] == 8080 + assert network["socksProxyPort"] == 8081 + + def test_build_command_with_tools_array(self): + """Test building CLI command with tools as array of tool names.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(tools=["Read", "Edit", "Bash"]), + ) + + cmd = transport._build_command() + assert "--tools" in cmd + tools_idx = cmd.index("--tools") + assert cmd[tools_idx + 1] == "Read,Edit,Bash" + + def test_build_command_with_tools_empty_array(self): + """Test building CLI command with tools as empty array (disables all tools).""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(tools=[]), + ) + + cmd = transport._build_command() + assert "--tools" in cmd + tools_idx = cmd.index("--tools") + assert cmd[tools_idx + 1] == "" + + def test_build_command_with_tools_preset(self): + """Test building CLI command with tools preset.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(tools={"type": "preset", "preset": "claude_code"}), + ) + + cmd = transport._build_command() + assert "--tools" in cmd + tools_idx = cmd.index("--tools") + assert cmd[tools_idx + 1] == "default" + + def test_build_command_without_tools(self): + """Test building CLI command without tools option (default None).""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(), + ) + + cmd = transport._build_command() + assert "--tools" not in cmd + + def test_concurrent_writes_are_serialized(self): + """Test that concurrent write() calls are serialized by the lock. + + When parallel subagents invoke MCP tools, they trigger concurrent write() + calls. Without the _write_lock, trio raises BusyResourceError. + + Uses a real subprocess with the same stream setup as production: + process.stdin -> TextSendStream + """ + + async def _test(): + import sys + from subprocess import PIPE + + from anyio.streams.text import TextSendStream + + # Create a real subprocess that consumes stdin (cross-platform) + process = await anyio.open_process( + [sys.executable, "-c", "import sys; sys.stdin.read()"], + stdin=PIPE, + stdout=PIPE, + stderr=PIPE, + ) + + try: + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeAgentOptions(cli_path="/usr/bin/claude"), + ) + + # Same setup as production: TextSendStream wrapping process.stdin + transport._ready = True + transport._process = MagicMock(returncode=None) + transport._stdin_stream = TextSendStream(process.stdin) + + # Spawn concurrent writes - the lock should serialize them + num_writes = 10 + errors: list[Exception] = [] + + async def do_write(i: int): + try: + await transport.write(f'{{"msg": {i}}}\n') + except Exception as e: + errors.append(e) + + async with anyio.create_task_group() as tg: + for i in range(num_writes): + tg.start_soon(do_write, i) + + # All writes should succeed - the lock serializes them + assert len(errors) == 0, f"Got errors: {errors}" + finally: + process.terminate() + await process.wait() + + anyio.run(_test, backend="trio") + + def test_concurrent_writes_fail_without_lock(self): + """Verify that without the lock, concurrent writes cause BusyResourceError. + + Uses a real subprocess with the same stream setup as production. + """ + + async def _test(): + import sys + from contextlib import asynccontextmanager + from subprocess import PIPE + + from anyio.streams.text import TextSendStream + + # Create a real subprocess that consumes stdin (cross-platform) + process = await anyio.open_process( + [sys.executable, "-c", "import sys; sys.stdin.read()"], + stdin=PIPE, + stdout=PIPE, + stderr=PIPE, + ) + + try: + transport = SubprocessCLITransport( + prompt="test", + options=ClaudeAgentOptions(cli_path="/usr/bin/claude"), + ) + + # Same setup as production + transport._ready = True + transport._process = MagicMock(returncode=None) + transport._stdin_stream = TextSendStream(process.stdin) + + # Replace lock with no-op to trigger the race condition + class NoOpLock: + @asynccontextmanager + async def __call__(self): + yield + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + transport._write_lock = NoOpLock() + + # Spawn concurrent writes - should fail without lock + num_writes = 10 + errors: list[Exception] = [] + + async def do_write(i: int): + try: + await transport.write(f'{{"msg": {i}}}\n') + except Exception as e: + errors.append(e) + + async with anyio.create_task_group() as tg: + for i in range(num_writes): + tg.start_soon(do_write, i) + + # Should have gotten errors due to concurrent access + assert len(errors) > 0, ( + "Expected errors from concurrent access, but got none" + ) + + # Check that at least one error mentions the concurrent access + error_strs = [str(e) for e in errors] + assert any("another task" in s for s in error_strs), ( + f"Expected 'another task' error, got: {error_strs}" + ) + finally: + process.terminate() + await process.wait() + + anyio.run(_test, backend="trio") + + def test_build_command_agents_always_via_initialize(self): + """Test that --agents is NEVER passed via CLI. + + Matching TypeScript SDK behavior, agents are always sent via the + initialize request through stdin, regardless of prompt type. + """ + from claude_agent_sdk.types import AgentDefinition + + agents = { + "test-agent": AgentDefinition( + description="A test agent", + prompt="You are a test agent", + ) + } + + # Test with string prompt + transport = SubprocessCLITransport( + prompt="Hello", + options=make_options(agents=agents), + ) + cmd = transport._build_command() + assert "--agents" not in cmd + assert "--input-format" in cmd + assert "stream-json" in cmd + + # Test with async iterable prompt + async def fake_stream(): + yield {"type": "user", "message": {"role": "user", "content": "test"}} + + transport2 = SubprocessCLITransport( + prompt=fake_stream(), + options=make_options(agents=agents), + ) + cmd2 = transport2._build_command() + assert "--agents" not in cmd2 + assert "--input-format" in cmd2 + assert "stream-json" in cmd2 + + def test_build_command_always_uses_streaming(self): + """Test that streaming mode is always used, even for string prompts. + + Matching TypeScript SDK behavior, we always use --input-format stream-json + so that agents and other large configs can be sent via initialize request. + """ + # String prompt should still use streaming + transport = SubprocessCLITransport( + prompt="Hello", + options=make_options(), + ) + cmd = transport._build_command() + assert "--input-format" in cmd + assert "stream-json" in cmd + assert "--print" not in cmd + + def test_build_command_large_agents_work(self): + """Test that large agent definitions work without size limits. + + Since agents are sent via initialize request through stdin, + there are no ARG_MAX or command line length limits. + """ + from claude_agent_sdk.types import AgentDefinition + + # Create a large agent definition (50KB prompt) + large_prompt = "x" * 50000 + agents = { + "large-agent": AgentDefinition( + description="A large agent", + prompt=large_prompt, + ) + } + + transport = SubprocessCLITransport( + prompt="Hello", + options=make_options(agents=agents), + ) + + cmd = transport._build_command() + + # --agents should not be in command (sent via initialize) + assert "--agents" not in cmd + # No @filepath references should exist + cmd_str = " ".join(cmd) + assert "@" not in cmd_str diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_types.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_types.py new file mode 100644 index 00000000..95a88bfa --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/claude-agent-sdk-python/tests/test_types.py @@ -0,0 +1,277 @@ +"""Tests for Claude SDK type definitions.""" + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + NotificationHookInput, + NotificationHookSpecificOutput, + PermissionRequestHookInput, + PermissionRequestHookSpecificOutput, + ResultMessage, + SubagentStartHookInput, + SubagentStartHookSpecificOutput, +) +from claude_agent_sdk.types import ( + PostToolUseHookSpecificOutput, + PreToolUseHookSpecificOutput, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) + + +class TestMessageTypes: + """Test message type creation and validation.""" + + def test_user_message_creation(self): + """Test creating a UserMessage.""" + msg = UserMessage(content="Hello, Claude!") + assert msg.content == "Hello, Claude!" + + def test_assistant_message_with_text(self): + """Test creating an AssistantMessage with text content.""" + text_block = TextBlock(text="Hello, human!") + msg = AssistantMessage(content=[text_block], model="claude-opus-4-1-20250805") + assert len(msg.content) == 1 + assert msg.content[0].text == "Hello, human!" + + def test_assistant_message_with_thinking(self): + """Test creating an AssistantMessage with thinking content.""" + thinking_block = ThinkingBlock(thinking="I'm thinking...", signature="sig-123") + msg = AssistantMessage( + content=[thinking_block], model="claude-opus-4-1-20250805" + ) + assert len(msg.content) == 1 + assert msg.content[0].thinking == "I'm thinking..." + assert msg.content[0].signature == "sig-123" + + def test_tool_use_block(self): + """Test creating a ToolUseBlock.""" + block = ToolUseBlock( + id="tool-123", name="Read", input={"file_path": "/test.txt"} + ) + assert block.id == "tool-123" + assert block.name == "Read" + assert block.input["file_path"] == "/test.txt" + + def test_tool_result_block(self): + """Test creating a ToolResultBlock.""" + block = ToolResultBlock( + tool_use_id="tool-123", content="File contents here", is_error=False + ) + assert block.tool_use_id == "tool-123" + assert block.content == "File contents here" + assert block.is_error is False + + def test_result_message(self): + """Test creating a ResultMessage.""" + msg = ResultMessage( + subtype="success", + duration_ms=1500, + duration_api_ms=1200, + is_error=False, + num_turns=1, + session_id="session-123", + total_cost_usd=0.01, + ) + assert msg.subtype == "success" + assert msg.total_cost_usd == 0.01 + assert msg.session_id == "session-123" + + +class TestOptions: + """Test Options configuration.""" + + def test_default_options(self): + """Test Options with default values.""" + options = ClaudeAgentOptions() + assert options.allowed_tools == [] + assert options.system_prompt is None + assert options.permission_mode is None + assert options.continue_conversation is False + assert options.disallowed_tools == [] + + def test_claude_code_options_with_tools(self): + """Test Options with built-in tools.""" + options = ClaudeAgentOptions( + allowed_tools=["Read", "Write", "Edit"], disallowed_tools=["Bash"] + ) + assert options.allowed_tools == ["Read", "Write", "Edit"] + assert options.disallowed_tools == ["Bash"] + + def test_claude_code_options_with_permission_mode(self): + """Test Options with permission mode.""" + options = ClaudeAgentOptions(permission_mode="bypassPermissions") + assert options.permission_mode == "bypassPermissions" + + options_plan = ClaudeAgentOptions(permission_mode="plan") + assert options_plan.permission_mode == "plan" + + options_default = ClaudeAgentOptions(permission_mode="default") + assert options_default.permission_mode == "default" + + options_accept = ClaudeAgentOptions(permission_mode="acceptEdits") + assert options_accept.permission_mode == "acceptEdits" + + def test_claude_code_options_with_system_prompt_string(self): + """Test Options with system prompt as string.""" + options = ClaudeAgentOptions( + system_prompt="You are a helpful assistant.", + ) + assert options.system_prompt == "You are a helpful assistant." + + def test_claude_code_options_with_system_prompt_preset(self): + """Test Options with system prompt preset.""" + options = ClaudeAgentOptions( + system_prompt={"type": "preset", "preset": "claude_code"}, + ) + assert options.system_prompt == {"type": "preset", "preset": "claude_code"} + + def test_claude_code_options_with_system_prompt_preset_and_append(self): + """Test Options with system prompt preset and append.""" + options = ClaudeAgentOptions( + system_prompt={ + "type": "preset", + "preset": "claude_code", + "append": "Be concise.", + }, + ) + assert options.system_prompt == { + "type": "preset", + "preset": "claude_code", + "append": "Be concise.", + } + + def test_claude_code_options_with_session_continuation(self): + """Test Options with session continuation.""" + options = ClaudeAgentOptions(continue_conversation=True, resume="session-123") + assert options.continue_conversation is True + assert options.resume == "session-123" + + def test_claude_code_options_with_model_specification(self): + """Test Options with model specification.""" + options = ClaudeAgentOptions( + model="claude-sonnet-4-5", permission_prompt_tool_name="CustomTool" + ) + assert options.model == "claude-sonnet-4-5" + assert options.permission_prompt_tool_name == "CustomTool" + + +class TestHookInputTypes: + """Test hook input type definitions.""" + + def test_notification_hook_input(self): + """Test NotificationHookInput construction.""" + hook_input: NotificationHookInput = { + "session_id": "sess-1", + "transcript_path": "/tmp/transcript", + "cwd": "/home/user", + "hook_event_name": "Notification", + "message": "Task completed", + "notification_type": "info", + } + assert hook_input["hook_event_name"] == "Notification" + assert hook_input["message"] == "Task completed" + assert hook_input["notification_type"] == "info" + + def test_notification_hook_input_with_title(self): + """Test NotificationHookInput with optional title.""" + hook_input: NotificationHookInput = { + "session_id": "sess-1", + "transcript_path": "/tmp/transcript", + "cwd": "/home/user", + "hook_event_name": "Notification", + "message": "Task completed", + "notification_type": "info", + "title": "Success", + } + assert hook_input["title"] == "Success" + + def test_subagent_start_hook_input(self): + """Test SubagentStartHookInput construction.""" + hook_input: SubagentStartHookInput = { + "session_id": "sess-1", + "transcript_path": "/tmp/transcript", + "cwd": "/home/user", + "hook_event_name": "SubagentStart", + "agent_id": "agent-42", + "agent_type": "researcher", + } + assert hook_input["hook_event_name"] == "SubagentStart" + assert hook_input["agent_id"] == "agent-42" + assert hook_input["agent_type"] == "researcher" + + def test_permission_request_hook_input(self): + """Test PermissionRequestHookInput construction.""" + hook_input: PermissionRequestHookInput = { + "session_id": "sess-1", + "transcript_path": "/tmp/transcript", + "cwd": "/home/user", + "hook_event_name": "PermissionRequest", + "tool_name": "Bash", + "tool_input": {"command": "ls"}, + } + assert hook_input["hook_event_name"] == "PermissionRequest" + assert hook_input["tool_name"] == "Bash" + assert hook_input["tool_input"] == {"command": "ls"} + + def test_permission_request_hook_input_with_suggestions(self): + """Test PermissionRequestHookInput with optional permission_suggestions.""" + hook_input: PermissionRequestHookInput = { + "session_id": "sess-1", + "transcript_path": "/tmp/transcript", + "cwd": "/home/user", + "hook_event_name": "PermissionRequest", + "tool_name": "Bash", + "tool_input": {"command": "ls"}, + "permission_suggestions": [{"type": "allow", "rule": "Bash(*)"}], + } + assert len(hook_input["permission_suggestions"]) == 1 + + +class TestHookSpecificOutputTypes: + """Test hook-specific output type definitions.""" + + def test_notification_hook_specific_output(self): + """Test NotificationHookSpecificOutput construction.""" + output: NotificationHookSpecificOutput = { + "hookEventName": "Notification", + "additionalContext": "Extra info", + } + assert output["hookEventName"] == "Notification" + assert output["additionalContext"] == "Extra info" + + def test_subagent_start_hook_specific_output(self): + """Test SubagentStartHookSpecificOutput construction.""" + output: SubagentStartHookSpecificOutput = { + "hookEventName": "SubagentStart", + "additionalContext": "Starting subagent for research", + } + assert output["hookEventName"] == "SubagentStart" + + def test_permission_request_hook_specific_output(self): + """Test PermissionRequestHookSpecificOutput construction.""" + output: PermissionRequestHookSpecificOutput = { + "hookEventName": "PermissionRequest", + "decision": {"type": "allow"}, + } + assert output["hookEventName"] == "PermissionRequest" + assert output["decision"] == {"type": "allow"} + + def test_pre_tool_use_output_has_additional_context(self): + """Test PreToolUseHookSpecificOutput includes additionalContext field.""" + output: PreToolUseHookSpecificOutput = { + "hookEventName": "PreToolUse", + "additionalContext": "context for claude", + } + assert output["additionalContext"] == "context for claude" + + def test_post_tool_use_output_has_updated_mcp_tool_output(self): + """Test PostToolUseHookSpecificOutput includes updatedMCPToolOutput field.""" + output: PostToolUseHookSpecificOutput = { + "hookEventName": "PostToolUse", + "updatedMCPToolOutput": {"result": "modified"}, + } + assert output["updatedMCPToolOutput"] == {"result": "modified"} diff --git a/backend/app/one_person_security_dept/claude_agent_sdk_python/runtime.py b/backend/app/one_person_security_dept/claude_agent_sdk_python/runtime.py new file mode 100644 index 00000000..9b543b74 --- /dev/null +++ b/backend/app/one_person_security_dept/claude_agent_sdk_python/runtime.py @@ -0,0 +1,52 @@ +"""Runtime loader for claude-agent-sdk used by Security Dept.""" + +from __future__ import annotations + +import importlib +import sys +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +_VENDORED_REPO_DIR = Path(__file__).resolve().parent / "claude-agent-sdk-python" +_VENDORED_SRC_DIR = _VENDORED_REPO_DIR / "src" + + +@dataclass(frozen=True) +class ClaudeAgentSdkExports: + """Runtime symbols required by Security Dept.""" + + AssistantMessage: type + ResultMessage: type + TextBlock: type + ToolUseBlock: type + ToolResultBlock: type + ClaudeAgentOptions: type + query: Callable[..., AsyncIterator[Any]] + + +def has_vendored_sdk_source() -> bool: + """Return whether vendored SDK source exists in this module.""" + init_file = _VENDORED_SRC_DIR / "claude_agent_sdk" / "__init__.py" + return init_file.exists() and init_file.is_file() + + +def load_claude_agent_sdk() -> ClaudeAgentSdkExports: + """Load SDK symbols, preferring vendored source in this module.""" + if has_vendored_sdk_source(): + vendored_src = str(_VENDORED_SRC_DIR) + if vendored_src not in sys.path: + sys.path.insert(0, vendored_src) + + sdk = importlib.import_module("claude_agent_sdk") + + return ClaudeAgentSdkExports( + AssistantMessage=getattr(sdk, "AssistantMessage"), + ResultMessage=getattr(sdk, "ResultMessage"), + TextBlock=getattr(sdk, "TextBlock"), + ToolUseBlock=getattr(sdk, "ToolUseBlock"), + ToolResultBlock=getattr(sdk, "ToolResultBlock"), + ClaudeAgentOptions=getattr(sdk, "ClaudeAgentOptions"), + query=getattr(sdk, "query"), + ) diff --git a/backend/app/one_person_security_dept/openclaw/.detect-secrets.cfg b/backend/app/one_person_security_dept/openclaw/.detect-secrets.cfg new file mode 100644 index 00000000..38912567 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.detect-secrets.cfg @@ -0,0 +1,30 @@ +# detect-secrets exclusion patterns (regex) +# +# Note: detect-secrets does not read this file by default. If you want these +# applied, wire them into your scan command (e.g. translate to --exclude-files +# / --exclude-lines) or into a baseline's filters_used. + +[exclude-files] +# pnpm lockfiles contain lots of high-entropy package integrity blobs. +pattern = (^|/)pnpm-lock\.yaml$ +# Generated output and vendored assets. +pattern = (^|/)(dist|vendor)/ +# Local config file with allowlist patterns. +pattern = (^|/)\.detect-secrets\.cfg$ + +[exclude-lines] +# Fastlane checks for private key marker; not a real key. +pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\) +# UI label string for Anthropic auth mode. +pattern = case \.apiKeyEnv: "API key \(env var\)" +# CodingKeys mapping uses apiKey literal. +pattern = case apikey = "apiKey" +# Schema labels referencing password fields (not actual secrets). +pattern = "gateway\.remote\.password" +pattern = "gateway\.auth\.password" +# Schema label for talk API key (label text only). +pattern = "talk\.apiKey" +# checking for typeof is not something we care about. +pattern = === "string" +# specific optional-chaining password check that didn't match the line above. +pattern = typeof remote\?\.password === "string" diff --git a/backend/app/one_person_security_dept/openclaw/.dockerignore b/backend/app/one_person_security_dept/openclaw/.dockerignore new file mode 100644 index 00000000..73d00fff --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.dockerignore @@ -0,0 +1,60 @@ +.git +.worktrees +.bun-cache +.bun +.tmp +**/.tmp +.DS_Store +**/.DS_Store +*.png +*.jpg +*.jpeg +*.webp +*.gif +*.mp4 +*.mov +*.wav +*.mp3 +node_modules +**/node_modules +.pnpm-store +**/.pnpm-store +.turbo +**/.turbo +.cache +**/.cache +.next +**/.next +coverage +**/coverage +*.log +tmp +**/tmp + +# build artifacts +dist +**/dist +apps/macos/.build +apps/ios/build +**/*.trace + +# large app trees not needed for CLI build +apps/ +assets/ +Peekaboo/ +Swabble/ +Core/ +Users/ +vendor/ + +# Needed for building the Canvas A2UI bundle during Docker image builds. +# Keep the rest of apps/ and vendor/ excluded to avoid a large build context. +!apps/shared/ +!apps/shared/OpenClawKit/ +!apps/shared/OpenClawKit/Tools/ +!apps/shared/OpenClawKit/Tools/CanvasA2UI/ +!apps/shared/OpenClawKit/Tools/CanvasA2UI/** +!vendor/a2ui/ +!vendor/a2ui/renderers/ +!vendor/a2ui/renderers/lit/ +!vendor/a2ui/renderers/lit/** diff --git a/backend/app/one_person_security_dept/openclaw/.env.example b/backend/app/one_person_security_dept/openclaw/.env.example new file mode 100644 index 00000000..41df435b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.env.example @@ -0,0 +1,80 @@ +# OpenClaw .env example +# +# Quick start: +# 1) Copy this file to `.env` (for local runs from this repo), OR to `~/.openclaw/.env` (for launchd/systemd daemons). +# 2) Fill only the values you use. +# 3) Keep real secrets out of git. +# +# Env-source precedence for environment variables (highest -> lowest): +# process env, ./.env, ~/.openclaw/.env, then openclaw.json `env` block. +# Existing non-empty process env vars are not overridden by dotenv/config env loading. +# Note: direct config keys (for example `gateway.auth.token` or channel tokens in openclaw.json) +# are resolved separately from env loading and often take precedence over env fallbacks. + +# ----------------------------------------------------------------------------- +# Gateway auth + paths +# ----------------------------------------------------------------------------- +# Recommended if the gateway binds beyond loopback. +OPENCLAW_GATEWAY_TOKEN=change-me-to-a-long-random-token +# Example generator: openssl rand -hex 32 + +# Optional alternative auth mode (use token OR password). +# OPENCLAW_GATEWAY_PASSWORD=change-me-to-a-strong-password + +# Optional path overrides (defaults shown for reference). +# OPENCLAW_STATE_DIR=~/.openclaw +# OPENCLAW_CONFIG_PATH=~/.openclaw/openclaw.json +# OPENCLAW_HOME=~ + +# Optional: import missing keys from your login shell profile. +# OPENCLAW_LOAD_SHELL_ENV=1 +# OPENCLAW_SHELL_ENV_TIMEOUT_MS=15000 + +# ----------------------------------------------------------------------------- +# Model provider API keys (set at least one) +# ----------------------------------------------------------------------------- +# OPENAI_API_KEY=sk-... +# ANTHROPIC_API_KEY=sk-ant-... +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=sk-or-... +# OPENCLAW_LIVE_OPENAI_KEY=sk-... +# OPENCLAW_LIVE_ANTHROPIC_KEY=sk-ant-... +# OPENCLAW_LIVE_GEMINI_KEY=... +# OPENAI_API_KEY_1=... +# ANTHROPIC_API_KEY_1=... +# GEMINI_API_KEY_1=... +# GOOGLE_API_KEY=... +# OPENAI_API_KEYS=sk-1,sk-2 +# ANTHROPIC_API_KEYS=sk-ant-1,sk-ant-2 +# GEMINI_API_KEYS=key-1,key-2 + +# Optional additional providers +# ZAI_API_KEY=... +# AI_GATEWAY_API_KEY=... +# MINIMAX_API_KEY=... +# SYNTHETIC_API_KEY=... + +# ----------------------------------------------------------------------------- +# Channels (only set what you enable) +# ----------------------------------------------------------------------------- +# TELEGRAM_BOT_TOKEN=123456:ABCDEF... +# DISCORD_BOT_TOKEN=... +# SLACK_BOT_TOKEN=xoxb-... +# SLACK_APP_TOKEN=xapp-... + +# Optional channel env fallbacks +# MATTERMOST_BOT_TOKEN=... +# MATTERMOST_URL=https://chat.example.com +# ZALO_BOT_TOKEN=... +# OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:... + +# ----------------------------------------------------------------------------- +# Tools + voice/media (optional) +# ----------------------------------------------------------------------------- +# BRAVE_API_KEY=... +# PERPLEXITY_API_KEY=pplx-... +# FIRECRAWL_API_KEY=... + +# ELEVENLABS_API_KEY=... +# XI_API_KEY=... # alias for ElevenLabs +# DEEPGRAM_API_KEY=... diff --git a/backend/app/one_person_security_dept/openclaw/.gitattributes b/backend/app/one_person_security_dept/openclaw/.gitattributes new file mode 100644 index 00000000..54fc4c9b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +CLAUDE.md -text +src/gateway/server-methods/CLAUDE.md -text diff --git a/backend/app/one_person_security_dept/openclaw/.github/FUNDING.yml b/backend/app/one_person_security_dept/openclaw/.github/FUNDING.yml new file mode 100644 index 00000000..082086ea --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ["https://github.com/sponsors/steipete"] diff --git a/backend/app/one_person_security_dept/openclaw/.github/ISSUE_TEMPLATE/bug_report.yml b/backend/app/one_person_security_dept/openclaw/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..927aa707 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,95 @@ +name: Bug report +description: Report a defect or unexpected behavior in OpenClaw. +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for filing this report. Keep it concise, reproducible, and evidence-based. + - type: textarea + id: summary + attributes: + label: Summary + description: One-sentence statement of what is broken. + placeholder: After upgrading to , behavior regressed from . + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: Provide the shortest deterministic repro path. + placeholder: | + 1. Configure channel X. + 2. Send message Y. + 3. Run command Z. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should happen if the bug does not exist. + placeholder: Agent posts a reply in the same thread. + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What happened instead, including user-visible errors. + placeholder: No reply is posted; gateway logs "reply target not found". + validations: + required: true + - type: input + id: version + attributes: + label: OpenClaw version + description: Exact version/build tested. + placeholder: + validations: + required: true + - type: input + id: os + attributes: + label: Operating system + description: OS and version where this occurs. + placeholder: macOS 15.4 / Ubuntu 24.04 / Windows 11 + validations: + required: true + - type: input + id: install_method + attributes: + label: Install method + description: How OpenClaw was installed or launched. + placeholder: npm global / pnpm dev / docker / mac app + - type: textarea + id: logs + attributes: + label: Logs, screenshots, and evidence + description: Include redacted logs/screenshots/recordings that prove the behavior. + render: shell + - type: textarea + id: impact + attributes: + label: Impact and severity + description: | + Explain who is affected, how severe it is, how often it happens, and the practical consequence. + Include: + - Affected users/systems/channels + - Severity (annoying, blocks workflow, data risk, etc.) + - Frequency (always/intermittent/edge case) + - Consequence (missed messages, failed onboarding, extra cost, etc.) + placeholder: | + Affected: Telegram group users on + Severity: High (blocks replies) + Frequency: 100% repro + Consequence: Agents cannot respond in threads + - type: textarea + id: additional_information + attributes: + label: Additional information + description: Add any context that helps triage but does not fit above. + placeholder: Regression started after upgrade from ; temporary workaround is ... diff --git a/backend/app/one_person_security_dept/openclaw/.github/ISSUE_TEMPLATE/config.yml b/backend/app/one_person_security_dept/openclaw/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..4c1b9775 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Onboarding + url: https://discord.gg/clawd + about: "New to OpenClaw? Join Discord for setup guidance in #help." + - name: Support + url: https://discord.gg/clawd + about: "Get help from the OpenClaw community on Discord in #help." diff --git a/backend/app/one_person_security_dept/openclaw/.github/ISSUE_TEMPLATE/feature_request.yml b/backend/app/one_person_security_dept/openclaw/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..a08b4567 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,70 @@ +name: Feature request +description: Propose a new capability or product improvement. +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Help us evaluate this request with concrete use cases and tradeoffs. + - type: textarea + id: summary + attributes: + label: Summary + description: One-line statement of the requested capability. + placeholder: Add per-channel default response prefix. + validations: + required: true + - type: textarea + id: problem + attributes: + label: Problem to solve + description: What user pain this solves and why current behavior is insufficient. + placeholder: Agents cannot distinguish persona context in mixed channels, causing misrouted follow-ups. + validations: + required: true + - type: textarea + id: proposed_solution + attributes: + label: Proposed solution + description: Desired behavior/API/UX with as much specificity as possible. + placeholder: Support channels..responsePrefix with default fallback and account-level override. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Other approaches considered and why they are weaker. + placeholder: Manual prefixing in prompts is inconsistent and hard to enforce. + - type: textarea + id: impact + attributes: + label: Impact + description: | + Explain who is affected, severity/urgency, how often this pain occurs, and practical consequences. + Include: + - Affected users/systems/channels + - Severity (annoying, blocks workflow, etc.) + - Frequency (always/intermittent/edge case) + - Consequence (delays, errors, extra manual work, etc.) + placeholder: | + Affected: Multi-team shared channels + Severity: Medium + Frequency: Daily + Consequence: +20 minutes/day/operator and delayed alerts + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Evidence/examples + description: Prior art, links, screenshots, logs, or metrics. + placeholder: Comparable behavior in X, sample config, and screenshot of current limitation. + - type: textarea + id: additional_information + attributes: + label: Additional information + description: Extra context, constraints, or references not covered above. + placeholder: Must remain backward-compatible with existing config keys. diff --git a/backend/app/one_person_security_dept/openclaw/.github/actionlint.yaml b/backend/app/one_person_security_dept/openclaw/.github/actionlint.yaml new file mode 100644 index 00000000..f02fbddb --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/actionlint.yaml @@ -0,0 +1,22 @@ +# actionlint configuration +# https://github.com/rhysd/actionlint/blob/main/docs/config.md + +self-hosted-runner: + labels: + # Blacksmith CI runners + - blacksmith-8vcpu-ubuntu-2404 + - blacksmith-8vcpu-windows-2025 + - blacksmith-16vcpu-ubuntu-2404 + - blacksmith-16vcpu-windows-2025 + - blacksmith-16vcpu-ubuntu-2404-arm + +# Ignore patterns for known issues +paths: + .github/workflows/**/*.yml: + ignore: + # Ignore shellcheck warnings (we run shellcheck separately) + - "shellcheck reported issue.+" + # Ignore intentional if: false for disabled jobs + - 'constant expression "false" in condition' + # actionlint's built-in runner label allowlist lags Blacksmith additions. + - 'label "blacksmith-16vcpu-[^"]+" is unknown\.' diff --git a/backend/app/one_person_security_dept/openclaw/.github/actions/detect-docs-changes/action.yml b/backend/app/one_person_security_dept/openclaw/.github/actions/detect-docs-changes/action.yml new file mode 100644 index 00000000..853442a7 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/actions/detect-docs-changes/action.yml @@ -0,0 +1,53 @@ +name: Detect docs-only changes +description: > + Outputs docs_only=true when all changed files are under docs/ or are + markdown (.md/.mdx). Fail-safe: if detection fails, outputs false (run + everything). Uses git diff — no API calls, no extra permissions needed. + +outputs: + docs_only: + description: "'true' if all changes are docs/markdown, 'false' otherwise" + value: ${{ steps.check.outputs.docs_only }} + docs_changed: + description: "'true' if any changed file is under docs/ or is markdown" + value: ${{ steps.check.outputs.docs_changed }} + +runs: + using: composite + steps: + - name: Detect docs-only changes + id: check + shell: bash + run: | + if [ "${{ github.event_name }}" = "push" ]; then + BASE="${{ github.event.before }}" + else + # Use the exact base SHA from the event payload — stable regardless + # of base branch movement (avoids origin/ drift). + BASE="${{ github.event.pull_request.base.sha }}" + fi + + # Fail-safe: if we can't diff, assume non-docs (run everything) + CHANGED=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN") + if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + echo "docs_changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Check if any changed file is a doc + DOCS=$(echo "$CHANGED" | grep -E '^docs/|\.md$|\.mdx$' || true) + if [ -n "$DOCS" ]; then + echo "docs_changed=true" >> "$GITHUB_OUTPUT" + else + echo "docs_changed=false" >> "$GITHUB_OUTPUT" + fi + + # Check if all changed files are docs or markdown + NON_DOCS=$(echo "$CHANGED" | grep -vE '^docs/|\.md$|\.mdx$' || true) + if [ -z "$NON_DOCS" ]; then + echo "docs_only=true" >> "$GITHUB_OUTPUT" + echo "Docs-only change detected — skipping heavy jobs" + else + echo "docs_only=false" >> "$GITHUB_OUTPUT" + fi diff --git a/backend/app/one_person_security_dept/openclaw/.github/actions/setup-node-env/action.yml b/backend/app/one_person_security_dept/openclaw/.github/actions/setup-node-env/action.yml new file mode 100644 index 00000000..334cd3c2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/actions/setup-node-env/action.yml @@ -0,0 +1,98 @@ +name: Setup Node environment +description: > + Initialize submodules with retry, install Node 22, pnpm, optionally Bun, + and run pnpm install. Requires actions/checkout to run first. +inputs: + node-version: + description: Node.js version to install. + required: false + default: "22.x" + pnpm-version: + description: pnpm version for corepack. + required: false + default: "10.23.0" + install-bun: + description: Whether to install Bun alongside Node. + required: false + default: "true" + frozen-lockfile: + description: Whether to use --frozen-lockfile for install. + required: false + default: "true" +runs: + using: composite + steps: + - name: Checkout submodules (retry) + shell: bash + run: | + set -euo pipefail + git submodule sync --recursive + for attempt in 1 2 3 4 5; do + if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then + exit 0 + fi + echo "Submodule update failed (attempt $attempt/5). Retrying…" + sleep $((attempt * 10)) + done + exit 1 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ inputs.node-version }} + check-latest: true + + - name: Setup pnpm + cache store + uses: ./.github/actions/setup-pnpm-store-cache + with: + pnpm-version: ${{ inputs.pnpm-version }} + cache-key-suffix: "node22" + + - name: Setup Bun + if: inputs.install-bun == 'true' + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.9+cf6cdbbba" + + - name: Runtime versions + shell: bash + run: | + node -v + npm -v + pnpm -v + if command -v bun &>/dev/null; then bun -v; fi + + - name: Capture node path + shell: bash + run: echo "NODE_BIN=$(dirname "$(node -p "process.execPath")")" >> "$GITHUB_ENV" + + - name: Install dependencies + shell: bash + env: + CI: "true" + FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }} + run: | + set -euo pipefail + export PATH="$NODE_BIN:$PATH" + which node + node -v + pnpm -v + case "$FROZEN_LOCKFILE" in + true) LOCKFILE_FLAG="--frozen-lockfile" ;; + false) LOCKFILE_FLAG="" ;; + *) + echo "::error::Invalid frozen-lockfile input: '$FROZEN_LOCKFILE' (expected true or false)" + exit 2 + ;; + esac + + install_args=( + install + --ignore-scripts=false + --config.engine-strict=false + --config.enable-pre-post-scripts=true + ) + if [ -n "$LOCKFILE_FLAG" ]; then + install_args+=("$LOCKFILE_FLAG") + fi + pnpm "${install_args[@]}" || pnpm "${install_args[@]}" diff --git a/backend/app/one_person_security_dept/openclaw/.github/actions/setup-pnpm-store-cache/action.yml b/backend/app/one_person_security_dept/openclaw/.github/actions/setup-pnpm-store-cache/action.yml new file mode 100644 index 00000000..8e25492a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/actions/setup-pnpm-store-cache/action.yml @@ -0,0 +1,47 @@ +name: Setup pnpm + store cache +description: Prepare pnpm via corepack and restore pnpm store cache. +inputs: + pnpm-version: + description: pnpm version to activate via corepack. + required: false + default: "10.23.0" + cache-key-suffix: + description: Suffix appended to the cache key. + required: false + default: "node22" +runs: + using: composite + steps: + - name: Setup pnpm (corepack retry) + shell: bash + env: + PNPM_VERSION: ${{ inputs.pnpm-version }} + run: | + set -euo pipefail + if [[ ! "$PNPM_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid pnpm-version input: '$PNPM_VERSION'" + exit 2 + fi + corepack enable + for attempt in 1 2 3; do + if corepack prepare "pnpm@$PNPM_VERSION" --activate; then + pnpm -v + exit 0 + fi + echo "corepack prepare failed (attempt $attempt/3). Retrying..." + sleep $((attempt * 10)) + done + exit 1 + + - name: Resolve pnpm store path + id: pnpm-store + shell: bash + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Restore pnpm store cache + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}- diff --git a/backend/app/one_person_security_dept/openclaw/.github/dependabot.yml b/backend/app/one_person_security_dept/openclaw/.github/dependabot.yml new file mode 100644 index 00000000..0a965feb --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/dependabot.yml @@ -0,0 +1,126 @@ +# Dependabot configuration +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 + +registries: + npm-npmjs: + type: npm-registry + url: https://registry.npmjs.org + replaces-base: true + +updates: + # npm dependencies (root) + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + production: + dependency-type: production + update-types: + - minor + - patch + development: + dependency-type: development + update-types: + - minor + - patch + open-pull-requests-limit: 10 + registries: + - npm-npmjs + + # GitHub Actions + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + actions: + patterns: + - "*" + update-types: + - minor + - patch + open-pull-requests-limit: 5 + + # Swift Package Manager - macOS app + - package-ecosystem: swift + directory: /apps/macos + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + swift-deps: + patterns: + - "*" + update-types: + - minor + - patch + open-pull-requests-limit: 5 + + # Swift Package Manager - shared MoltbotKit + - package-ecosystem: swift + directory: /apps/shared/MoltbotKit + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + swift-deps: + patterns: + - "*" + update-types: + - minor + - patch + open-pull-requests-limit: 5 + + # Swift Package Manager - Swabble + - package-ecosystem: swift + directory: /Swabble + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + swift-deps: + patterns: + - "*" + update-types: + - minor + - patch + open-pull-requests-limit: 5 + + # Gradle - Android app + - package-ecosystem: gradle + directory: /apps/android + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + android-deps: + patterns: + - "*" + update-types: + - minor + - patch + open-pull-requests-limit: 5 + + # Docker base images + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + docker-images: + patterns: + - "*" + open-pull-requests-limit: 5 diff --git a/backend/app/one_person_security_dept/openclaw/.github/instructions/copilot.instructions.md b/backend/app/one_person_security_dept/openclaw/.github/instructions/copilot.instructions.md new file mode 100644 index 00000000..8686521c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/instructions/copilot.instructions.md @@ -0,0 +1,64 @@ +# OpenClaw Codebase Patterns + +**Always reuse existing code - no redundancy!** + +## Tech Stack + +- **Runtime**: Node 22+ (Bun also supported for dev/scripts) +- **Language**: TypeScript (ESM, strict mode) +- **Package Manager**: pnpm (keep `pnpm-lock.yaml` in sync) +- **Lint/Format**: Oxlint, Oxfmt (`pnpm check`) +- **Tests**: Vitest with V8 coverage +- **CLI Framework**: Commander + clack/prompts +- **Build**: tsdown (outputs to `dist/`) + +## Anti-Redundancy Rules + +- Avoid files that just re-export from another file. Import directly from the original source. +- If a function already exists, import it - do NOT create a duplicate in another file. +- Before creating any formatter, utility, or helper, search for existing implementations first. + +## Source of Truth Locations + +### Formatting Utilities (`src/infra/`) + +- **Time formatting**: `src\infra\format-time` + +**NEVER create local `formatAge`, `formatDuration`, `formatElapsedTime` functions - import from centralized modules.** + +### Terminal Output (`src/terminal/`) + +- Tables: `src/terminal/table.ts` (`renderTable`) +- Themes/colors: `src/terminal/theme.ts` (`theme.success`, `theme.muted`, etc.) +- Progress: `src/cli/progress.ts` (spinners, progress bars) + +### CLI Patterns + +- CLI option wiring: `src/cli/` +- Commands: `src/commands/` +- Dependency injection via `createDefaultDeps` + +## Import Conventions + +- Use `.js` extension for cross-package imports (ESM) +- Direct imports only - no re-export wrapper files +- Types: `import type { X }` for type-only imports + +## Code Quality + +- TypeScript (ESM), strict typing, avoid `any` +- Keep files under ~700 LOC - extract helpers when larger +- Colocated tests: `*.test.ts` next to source files +- Run `pnpm check` before commits (lint + format) +- Run `pnpm tsgo` for type checking + +## Stack & Commands + +- **Package manager**: pnpm (`pnpm install`) +- **Dev**: `pnpm openclaw ...` or `pnpm dev` +- **Type-check**: `pnpm tsgo` +- **Lint/format**: `pnpm check` +- **Tests**: `pnpm test` +- **Build**: `pnpm build` + +If you are coding together with a human, do NOT use scripts/committer, but git directly and run the above commands manually to ensure quality. diff --git a/backend/app/one_person_security_dept/openclaw/.github/labeler.yml b/backend/app/one_person_security_dept/openclaw/.github/labeler.yml new file mode 100644 index 00000000..78366fb2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/labeler.yml @@ -0,0 +1,254 @@ +"channel: bluebubbles": + - changed-files: + - any-glob-to-any-file: + - "extensions/bluebubbles/**" + - "docs/channels/bluebubbles.md" +"channel: discord": + - changed-files: + - any-glob-to-any-file: + - "src/discord/**" + - "extensions/discord/**" + - "docs/channels/discord.md" +"channel: irc": + - changed-files: + - any-glob-to-any-file: + - "extensions/irc/**" + - "docs/channels/irc.md" +"channel: feishu": + - changed-files: + - any-glob-to-any-file: + - "src/feishu/**" + - "extensions/feishu/**" + - "docs/channels/feishu.md" +"channel: googlechat": + - changed-files: + - any-glob-to-any-file: + - "extensions/googlechat/**" + - "docs/channels/googlechat.md" +"channel: imessage": + - changed-files: + - any-glob-to-any-file: + - "src/imessage/**" + - "extensions/imessage/**" + - "docs/channels/imessage.md" +"channel: line": + - changed-files: + - any-glob-to-any-file: + - "extensions/line/**" + - "docs/channels/line.md" +"channel: matrix": + - changed-files: + - any-glob-to-any-file: + - "extensions/matrix/**" + - "docs/channels/matrix.md" +"channel: mattermost": + - changed-files: + - any-glob-to-any-file: + - "extensions/mattermost/**" + - "docs/channels/mattermost.md" +"channel: msteams": + - changed-files: + - any-glob-to-any-file: + - "extensions/msteams/**" + - "docs/channels/msteams.md" +"channel: nextcloud-talk": + - changed-files: + - any-glob-to-any-file: + - "extensions/nextcloud-talk/**" + - "docs/channels/nextcloud-talk.md" +"channel: nostr": + - changed-files: + - any-glob-to-any-file: + - "extensions/nostr/**" + - "docs/channels/nostr.md" +"channel: signal": + - changed-files: + - any-glob-to-any-file: + - "src/signal/**" + - "extensions/signal/**" + - "docs/channels/signal.md" +"channel: slack": + - changed-files: + - any-glob-to-any-file: + - "src/slack/**" + - "extensions/slack/**" + - "docs/channels/slack.md" +"channel: telegram": + - changed-files: + - any-glob-to-any-file: + - "src/telegram/**" + - "extensions/telegram/**" + - "docs/channels/telegram.md" +"channel: tlon": + - changed-files: + - any-glob-to-any-file: + - "extensions/tlon/**" + - "docs/channels/tlon.md" +"channel: twitch": + - changed-files: + - any-glob-to-any-file: + - "extensions/twitch/**" + - "docs/channels/twitch.md" +"channel: voice-call": + - changed-files: + - any-glob-to-any-file: + - "extensions/voice-call/**" +"channel: whatsapp-web": + - changed-files: + - any-glob-to-any-file: + - "src/web/**" + - "extensions/whatsapp/**" + - "docs/channels/whatsapp.md" +"channel: zalo": + - changed-files: + - any-glob-to-any-file: + - "extensions/zalo/**" + - "docs/channels/zalo.md" +"channel: zalouser": + - changed-files: + - any-glob-to-any-file: + - "extensions/zalouser/**" + - "docs/channels/zalouser.md" + +"app: android": + - changed-files: + - any-glob-to-any-file: + - "apps/android/**" + - "docs/platforms/android.md" +"app: ios": + - changed-files: + - any-glob-to-any-file: + - "apps/ios/**" + - "docs/platforms/ios.md" +"app: macos": + - changed-files: + - any-glob-to-any-file: + - "apps/macos/**" + - "docs/platforms/macos.md" + - "docs/platforms/mac/**" +"app: web-ui": + - changed-files: + - any-glob-to-any-file: + - "ui/**" + - "src/gateway/control-ui.ts" + - "src/gateway/control-ui-shared.ts" + - "src/gateway/protocol/**" + - "src/gateway/server-methods/chat.ts" + - "src/infra/control-ui-assets.ts" + +"gateway": + - changed-files: + - any-glob-to-any-file: + - "src/gateway/**" + - "src/daemon/**" + - "docs/gateway/**" + +"docs": + - changed-files: + - any-glob-to-any-file: + - "docs/**" + - "docs.acp.md" + +"cli": + - changed-files: + - any-glob-to-any-file: + - "src/cli/**" + +"commands": + - changed-files: + - any-glob-to-any-file: + - "src/commands/**" + +"scripts": + - changed-files: + - any-glob-to-any-file: + - "scripts/**" + +"docker": + - changed-files: + - any-glob-to-any-file: + - "Dockerfile" + - "Dockerfile.*" + - "docker-compose.yml" + - "docker-setup.sh" + - ".dockerignore" + - "scripts/**/*docker*" + - "scripts/**/Dockerfile*" + - "scripts/sandbox-*.sh" + - "src/agents/sandbox*.ts" + - "src/commands/sandbox*.ts" + - "src/cli/sandbox-cli.ts" + - "src/docker-setup.test.ts" + - "src/config/**/*sandbox*" + - "docs/cli/sandbox.md" + - "docs/gateway/sandbox*.md" + - "docs/install/docker.md" + - "docs/multi-agent-sandbox-tools.md" + +"agents": + - changed-files: + - any-glob-to-any-file: + - "src/agents/**" + +"security": + - changed-files: + - any-glob-to-any-file: + - "docs/cli/security.md" + - "docs/gateway/security.md" + +"extensions: copilot-proxy": + - changed-files: + - any-glob-to-any-file: + - "extensions/copilot-proxy/**" +"extensions: diagnostics-otel": + - changed-files: + - any-glob-to-any-file: + - "extensions/diagnostics-otel/**" +"extensions: google-antigravity-auth": + - changed-files: + - any-glob-to-any-file: + - "extensions/google-antigravity-auth/**" +"extensions: google-gemini-cli-auth": + - changed-files: + - any-glob-to-any-file: + - "extensions/google-gemini-cli-auth/**" +"extensions: llm-task": + - changed-files: + - any-glob-to-any-file: + - "extensions/llm-task/**" +"extensions: lobster": + - changed-files: + - any-glob-to-any-file: + - "extensions/lobster/**" +"extensions: memory-core": + - changed-files: + - any-glob-to-any-file: + - "extensions/memory-core/**" +"extensions: memory-lancedb": + - changed-files: + - any-glob-to-any-file: + - "extensions/memory-lancedb/**" +"extensions: open-prose": + - changed-files: + - any-glob-to-any-file: + - "extensions/open-prose/**" +"extensions: qwen-portal-auth": + - changed-files: + - any-glob-to-any-file: + - "extensions/qwen-portal-auth/**" +"extensions: device-pair": + - changed-files: + - any-glob-to-any-file: + - "extensions/device-pair/**" +"extensions: minimax-portal-auth": + - changed-files: + - any-glob-to-any-file: + - "extensions/minimax-portal-auth/**" +"extensions: phone-control": + - changed-files: + - any-glob-to-any-file: + - "extensions/phone-control/**" +"extensions: talk-voice": + - changed-files: + - any-glob-to-any-file: + - "extensions/talk-voice/**" diff --git a/backend/app/one_person_security_dept/openclaw/.github/pull_request_template.md b/backend/app/one_person_security_dept/openclaw/.github/pull_request_template.md new file mode 100644 index 00000000..9b0e7f8d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/pull_request_template.md @@ -0,0 +1,108 @@ +## Summary + +Describe the problem and fix in 2–5 bullets: + +- Problem: +- Why it matters: +- What changed: +- What did NOT change (scope boundary): + +## Change Type (select all) + +- [ ] Bug fix +- [ ] Feature +- [ ] Refactor +- [ ] Docs +- [ ] Security hardening +- [ ] Chore/infra + +## Scope (select all touched areas) + +- [ ] Gateway / orchestration +- [ ] Skills / tool execution +- [ ] Auth / tokens +- [ ] Memory / storage +- [ ] Integrations +- [ ] API / contracts +- [ ] UI / DX +- [ ] CI/CD / infra + +## Linked Issue/PR + +- Closes # +- Related # + +## User-visible / Behavior Changes + +List user-visible changes (including defaults/config). +If none, write `None`. + +## Security Impact (required) + +- New permissions/capabilities? (`Yes/No`) +- Secrets/tokens handling changed? (`Yes/No`) +- New/changed network calls? (`Yes/No`) +- Command/tool execution surface changed? (`Yes/No`) +- Data access scope changed? (`Yes/No`) +- If any `Yes`, explain risk + mitigation: + +## Repro + Verification + +### Environment + +- OS: +- Runtime/container: +- Model/provider: +- Integration/channel (if any): +- Relevant config (redacted): + +### Steps + +1. +2. +3. + +### Expected + +- + +### Actual + +- + +## Evidence + +Attach at least one: + +- [ ] Failing test/log before + passing after +- [ ] Trace/log snippets +- [ ] Screenshot/recording +- [ ] Perf numbers (if relevant) + +## Human Verification (required) + +What you personally verified (not just CI), and how: + +- Verified scenarios: +- Edge cases checked: +- What you did **not** verify: + +## Compatibility / Migration + +- Backward compatible? (`Yes/No`) +- Config/env changes? (`Yes/No`) +- Migration needed? (`Yes/No`) +- If yes, exact upgrade steps: + +## Failure Recovery (if this breaks) + +- How to disable/revert this change quickly: +- Files/config to restore: +- Known bad symptoms reviewers should watch for: + +## Risks and Mitigations + +List only real risks for this PR. Add/remove entries as needed. If none, write `None`. + +- Risk: + - Mitigation: diff --git a/backend/app/one_person_security_dept/openclaw/.github/workflows/auto-response.yml b/backend/app/one_person_security_dept/openclaw/.github/workflows/auto-response.yml new file mode 100644 index 00000000..1502456a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/workflows/auto-response.yml @@ -0,0 +1,224 @@ +name: Auto response + +on: + issues: + types: [opened, edited, labeled] + pull_request_target: + types: [labeled] + +permissions: {} + +jobs: + auto-response: + permissions: + issues: write + pull-requests: write + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + id: app-token + with: + app-id: "2729701" + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + - name: Handle labeled items + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + // Labels prefixed with "r:" are auto-response triggers. + const rules = [ + { + label: "r: skill", + close: true, + message: + "Thanks for the contribution! New skills should be published to [Clawhub](https://clawhub.ai) for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.", + }, + { + label: "r: support", + close: true, + message: + "Please use [our support server](https://discord.gg/clawd) and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.openclaw.ai/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.", + }, + { + label: "r: testflight", + close: true, + message: "Not available, build from source.", + }, + { + label: "r: third-party-extension", + close: true, + message: + "Please make this as a third-party plugin that you maintain yourself in your own repo. Docs: https://docs.openclaw.ai/plugin. Feel free to open a PR after to add it to our community plugins page: https://docs.openclaw.ai/plugins/community", + }, + { + label: "r: moltbook", + close: true, + lock: true, + lockReason: "off-topic", + message: + "OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.", + }, + ]; + + const triggerLabel = "trigger-response"; + const target = context.payload.issue ?? context.payload.pull_request; + if (!target) { + return; + } + + const labelSet = new Set( + (target.labels ?? []) + .map((label) => (typeof label === "string" ? label : label?.name)) + .filter((name) => typeof name === "string"), + ); + + const hasTriggerLabel = labelSet.has(triggerLabel); + if (hasTriggerLabel) { + labelSet.delete(triggerLabel); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: target.number, + name: triggerLabel, + }); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + } + + const isLabelEvent = context.payload.action === "labeled"; + if (!hasTriggerLabel && !isLabelEvent) { + return; + } + + const issue = context.payload.issue; + if (issue) { + const title = issue.title ?? ""; + const body = issue.body ?? ""; + const haystack = `${title}\n${body}`.toLowerCase(); + const hasMoltbookLabel = labelSet.has("r: moltbook"); + const hasTestflightLabel = labelSet.has("r: testflight"); + const hasSecurityLabel = labelSet.has("security"); + if (title.toLowerCase().includes("security") && !hasSecurityLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ["security"], + }); + labelSet.add("security"); + } + if (title.toLowerCase().includes("testflight") && !hasTestflightLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ["r: testflight"], + }); + labelSet.add("r: testflight"); + } + if (haystack.includes("moltbook") && !hasMoltbookLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ["r: moltbook"], + }); + labelSet.add("r: moltbook"); + } + } + + const invalidLabel = "invalid"; + const dirtyLabel = "dirty"; + const noisyPrMessage = + "Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch."; + + const pullRequest = context.payload.pull_request; + if (pullRequest) { + if (labelSet.has(dirtyLabel)) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + body: noisyPrMessage, + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + state: "closed", + }); + return; + } + const labelCount = labelSet.size; + if (labelCount > 20) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + body: noisyPrMessage, + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + state: "closed", + }); + return; + } + if (labelSet.has(invalidLabel)) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + state: "closed", + }); + return; + } + } + + if (issue && labelSet.has(invalidLabel)) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: "closed", + state_reason: "not_planned", + }); + return; + } + + const rule = rules.find((item) => labelSet.has(item.label)); + if (!rule) { + return; + } + + const issueNumber = target.number; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: rule.message, + }); + + if (rule.close) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + state: "closed", + }); + } + + if (rule.lock) { + await github.rest.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + lock_reason: rule.lockReason ?? "resolved", + }); + } diff --git a/backend/app/one_person_security_dept/openclaw/.github/workflows/ci.yml b/backend/app/one_person_security_dept/openclaw/.github/workflows/ci.yml new file mode 100644 index 00000000..8de4f388 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/workflows/ci.yml @@ -0,0 +1,802 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + # Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android). + # Lint and format always run. Fail-safe: if detection fails, run everything. + docs-scope: + runs-on: blacksmith-16vcpu-ubuntu-2404 + outputs: + docs_only: ${{ steps.check.outputs.docs_only }} + docs_changed: ${{ steps.check.outputs.docs_changed }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: false + + - name: Detect docs-only changes + id: check + uses: ./.github/actions/detect-docs-changes + + # Detect which heavy areas are touched so PRs can skip unrelated expensive jobs. + # Push to main keeps broad coverage. + changed-scope: + needs: [docs-scope] + if: needs.docs-scope.outputs.docs_only != 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + outputs: + run_node: ${{ steps.scope.outputs.run_node }} + run_macos: ${{ steps.scope.outputs.run_macos }} + run_android: ${{ steps.scope.outputs.run_android }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: false + + - name: Detect changed scopes + id: scope + shell: bash + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "push" ]; then + BASE="${{ github.event.before }}" + else + BASE="${{ github.event.pull_request.base.sha }}" + fi + + CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")" + if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then + # Fail-safe: run broad checks if detection fails. + echo "run_node=true" >> "$GITHUB_OUTPUT" + echo "run_macos=true" >> "$GITHUB_OUTPUT" + echo "run_android=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + run_node=false + run_macos=false + run_android=false + has_non_docs=false + has_non_native_non_docs=false + + while IFS= read -r path; do + [ -z "$path" ] && continue + case "$path" in + docs/*|*.md|*.mdx) + continue + ;; + *) + has_non_docs=true + ;; + esac + + case "$path" in + # Generated protocol models are already covered by protocol:check and + # should not force the full native macOS lane. + apps/macos/Sources/OpenClawProtocol/*|apps/shared/OpenClawKit/Sources/OpenClawProtocol/*) + ;; + apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*) + run_macos=true + ;; + esac + + case "$path" in + apps/android/*|apps/shared/*) + run_android=true + ;; + esac + + case "$path" in + src/*|test/*|extensions/*|packages/*|scripts/*|ui/*|.github/*|openclaw.mjs|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig*.json|vitest*.ts|tsdown.config.ts|.oxlintrc.json|.oxfmtrc.jsonc) + run_node=true + ;; + esac + + case "$path" in + apps/android/*|apps/ios/*|apps/macos/*|apps/shared/*|Swabble/*|appcast.xml) + ;; + *) + has_non_native_non_docs=true + ;; + esac + done <<< "$CHANGED" + + # If there are non-doc files outside native app trees, keep Node checks enabled. + if [ "$run_node" = false ] && [ "$has_non_docs" = true ] && [ "$has_non_native_non_docs" = true ]; then + run_node=true + fi + + echo "run_node=${run_node}" >> "$GITHUB_OUTPUT" + echo "run_macos=${run_macos}" >> "$GITHUB_OUTPUT" + echo "run_android=${run_android}" >> "$GITHUB_OUTPUT" + + # Build dist once for Node-relevant changes and share it with downstream jobs. + build-artifacts: + needs: [docs-scope, changed-scope, check] + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + + - name: Build dist + run: pnpm build + + - name: Upload dist artifact + uses: actions/upload-artifact@v4 + with: + name: dist-build + path: dist/ + retention-days: 1 + + # Validate npm pack contents after build (only on push to main, not PRs). + release-check: + needs: [docs-scope, build-artifacts] + if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + + - name: Download dist artifact + uses: actions/download-artifact@v4 + with: + name: dist-build + path: dist/ + + - name: Check release contents + run: pnpm release:check + + checks: + needs: [docs-scope, changed-scope, check] + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') + runs-on: blacksmith-16vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: + include: + - runtime: node + task: test + command: pnpm canvas:a2ui:bundle && pnpm test + - runtime: node + task: protocol + command: pnpm protocol:check + - runtime: bun + task: test + command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts + steps: + - name: Skip bun lane on push + if: github.event_name == 'push' && matrix.runtime == 'bun' + run: echo "Skipping bun test lane on push events." + + - name: Checkout + if: github.event_name != 'push' || matrix.runtime != 'bun' + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node environment + if: matrix.runtime != 'bun' || github.event_name != 'push' + uses: ./.github/actions/setup-node-env + with: + install-bun: "${{ matrix.runtime == 'bun' }}" + + - name: Configure vitest JSON reports + if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node' + run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV" + + - name: Configure Node test resources + if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node' + run: | + # `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes. + # Default heap limits have been too low on Linux CI (V8 OOM near 4GB). + echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV" + echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV" + + - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) + if: matrix.runtime != 'bun' || github.event_name != 'push' + run: ${{ matrix.command }} + + - name: Summarize slowest tests + if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node' + run: | + node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null + echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md" + + - name: Upload vitest reports + if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node' + uses: actions/upload-artifact@v4 + with: + name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }} + path: | + ${{ env.OPENCLAW_VITEST_REPORT_DIR }} + ${{ runner.temp }}/vitest-slowest.md + + # Types, lint, and format check. + check: + name: "check" + needs: [docs-scope] + if: needs.docs-scope.outputs.docs_only != 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + + - name: Check types and lint and oxfmt + run: pnpm check + + - name: Enforce safe external URL opening policy + run: pnpm lint:ui:no-raw-window-open + + # Report-only dead-code scans. Runs after scope detection and stores machine-readable + # results as artifacts for later triage before we enable hard gates. + # Temporarily disabled in CI while we process initial findings. + deadcode: + name: dead-code report + needs: [docs-scope, changed-scope] + # if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') + if: false + runs-on: blacksmith-16vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: + include: + - tool: knip + command: pnpm deadcode:report:ci:knip + - tool: ts-prune + command: pnpm deadcode:report:ci:ts-prune + - tool: ts-unused-exports + command: pnpm deadcode:report:ci:ts-unused + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + + - name: Run ${{ matrix.tool }} dead-code scan + run: ${{ matrix.command }} + + - name: Upload dead-code results + uses: actions/upload-artifact@v4 + with: + name: dead-code-${{ matrix.tool }}-${{ github.run_id }} + path: .artifacts/deadcode + + # Validate docs (format, lint, broken links) only when docs files changed. + check-docs: + needs: [docs-scope] + if: needs.docs-scope.outputs.docs_changed == 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + + - name: Check docs + run: pnpm check:docs + + skills-python: + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Python tooling + run: | + python -m pip install --upgrade pip + python -m pip install pytest ruff pyyaml + + - name: Lint Python skill scripts + run: python -m ruff check skills + + - name: Test skill Python scripts + run: python -m pytest -q skills + + secrets: + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install pre-commit + run: | + python -m pip install --upgrade pip + python -m pip install pre-commit detect-secrets==1.5.0 + + - name: Detect secrets + run: | + if ! detect-secrets scan --baseline .secrets.baseline; then + echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets" + exit 1 + fi + + - name: Detect committed private keys + run: pre-commit run --all-files detect-private-key + + - name: Audit changed GitHub workflows with zizmor + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "push" ]; then + BASE="${{ github.event.before }}" + else + BASE="${{ github.event.pull_request.base.sha }}" + fi + + mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml') + if [ "${#workflow_files[@]}" -eq 0 ]; then + echo "No workflow changes detected; skipping zizmor." + exit 0 + fi + + pre-commit run zizmor --files "${workflow_files[@]}" + + - name: Audit production dependencies + run: pre-commit run --all-files pnpm-audit-prod + + checks-windows: + needs: [docs-scope, changed-scope, build-artifacts, check] + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') + runs-on: blacksmith-16vcpu-windows-2025 + env: + NODE_OPTIONS: --max-old-space-size=4096 + # Keep total concurrency predictable on the 16 vCPU runner: + # `scripts/test-parallel.mjs` runs some vitest suites in parallel processes. + OPENCLAW_TEST_WORKERS: 2 + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + include: + - runtime: node + task: lint + command: pnpm lint + - runtime: node + task: test + command: pnpm canvas:a2ui:bundle && pnpm test + - runtime: node + task: protocol + command: pnpm protocol:check + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Try to exclude workspace from Windows Defender (best-effort) + shell: pwsh + run: | + $cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue + if (-not $cmd) { + Write-Host "Add-MpPreference not available, skipping Defender exclusions." + exit 0 + } + + try { + # Defender sometimes intercepts process spawning (vitest workers). If this fails + # (eg hardened images), keep going and rely on worker limiting above. + Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop + Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop + Write-Host "Defender exclusions applied." + } catch { + Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)" + } + + - name: Download dist artifact (lint lane) + if: matrix.task == 'lint' + uses: actions/download-artifact@v4 + with: + name: dist-build + path: dist/ + + - name: Verify dist artifact (lint lane) + if: matrix.task == 'lint' + run: | + set -euo pipefail + test -s dist/index.js + test -s dist/plugin-sdk/index.js + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.x + check-latest: true + + - name: Setup pnpm + cache store + uses: ./.github/actions/setup-pnpm-store-cache + with: + pnpm-version: "10.23.0" + cache-key-suffix: "node22" + + - name: Runtime versions + run: | + node -v + npm -v + pnpm -v + + - name: Capture node path + run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV" + + - name: Install dependencies + env: + CI: true + run: | + export PATH="$NODE_BIN:$PATH" + which node + node -v + pnpm -v + pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true + + - name: Configure vitest JSON reports + if: matrix.task == 'test' + run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV" + + - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) + run: ${{ matrix.command }} + + - name: Summarize slowest tests + if: matrix.task == 'test' + run: | + node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null + echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md" + + - name: Upload vitest reports + if: matrix.task == 'test' + uses: actions/upload-artifact@v4 + with: + name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }} + path: | + ${{ env.OPENCLAW_VITEST_REPORT_DIR }} + ${{ runner.temp }}/vitest-slowest.md + + # Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially + # on a single runner. GitHub limits macOS concurrent jobs to 5 per org; + # running 4 separate jobs per PR (as before) starved the queue. One job + # per PR allows 5 PRs to run macOS checks simultaneously. + macos: + needs: [docs-scope, changed-scope, check] + if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true' + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + + # --- Run all checks sequentially (fast gates first) --- + - name: TS tests (macOS) + env: + NODE_OPTIONS: --max-old-space-size=4096 + run: pnpm test + + # --- Xcode/Swift setup --- + - name: Select Xcode 26.1 + run: | + sudo xcode-select -s /Applications/Xcode_26.1.app + xcodebuild -version + + - name: Install XcodeGen / SwiftLint / SwiftFormat + run: brew install xcodegen swiftlint swiftformat + + - name: Show toolchain + run: | + sw_vers + xcodebuild -version + swift --version + + - name: Swift lint + run: | + swiftlint --config .swiftlint.yml + swiftformat --lint apps/macos/Sources --config .swiftformat + + - name: Cache SwiftPM + uses: actions/cache@v4 + with: + path: ~/Library/Caches/org.swift.swiftpm + key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-swiftpm- + + - name: Swift build (release) + run: | + set -euo pipefail + for attempt in 1 2 3; do + if swift build --package-path apps/macos --configuration release; then + exit 0 + fi + echo "swift build failed (attempt $attempt/3). Retrying…" + sleep $((attempt * 20)) + done + exit 1 + + - name: Swift test + run: | + set -euo pipefail + for attempt in 1 2 3; do + if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then + exit 0 + fi + echo "swift test failed (attempt $attempt/3). Retrying…" + sleep $((attempt * 20)) + done + exit 1 + + ios: + if: false # ignore iOS in CI for now + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Select Xcode 26.1 + run: | + sudo xcode-select -s /Applications/Xcode_26.1.app + xcodebuild -version + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Install SwiftLint / SwiftFormat + run: brew install swiftlint swiftformat + + - name: Show toolchain + run: | + sw_vers + xcodebuild -version + swift --version + + - name: Generate iOS project + run: | + cd apps/ios + xcodegen generate + + - name: iOS tests + run: | + set -euo pipefail + RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult" + DEST_ID="$( + python3 - <<'PY' + import json + import subprocess + import sys + import uuid + + def sh(args: list[str]) -> str: + return subprocess.check_output(args, text=True).strip() + + # Prefer an already-created iPhone simulator if it exists. + devices = json.loads(sh(["xcrun", "simctl", "list", "devices", "-j"])) + candidates: list[tuple[str, str]] = [] + for runtime, devs in (devices.get("devices") or {}).items(): + for dev in devs or []: + if not dev.get("isAvailable"): + continue + name = str(dev.get("name") or "") + udid = str(dev.get("udid") or "") + if not udid or not name.startswith("iPhone"): + continue + candidates.append((name, udid)) + + candidates.sort(key=lambda it: (0 if "iPhone 16" in it[0] else 1, it[0])) + if candidates: + print(candidates[0][1]) + sys.exit(0) + + # Otherwise, create one from the newest available iOS runtime. + runtimes = json.loads(sh(["xcrun", "simctl", "list", "runtimes", "-j"])).get("runtimes") or [] + ios = [rt for rt in runtimes if rt.get("platform") == "iOS" and rt.get("isAvailable")] + if not ios: + print("No available iOS runtimes found.", file=sys.stderr) + sys.exit(1) + + def version_key(rt: dict) -> tuple[int, ...]: + parts: list[int] = [] + for p in str(rt.get("version") or "0").split("."): + try: + parts.append(int(p)) + except ValueError: + parts.append(0) + return tuple(parts) + + ios.sort(key=version_key, reverse=True) + runtime = ios[0] + runtime_id = str(runtime.get("identifier") or "") + if not runtime_id: + print("Missing iOS runtime identifier.", file=sys.stderr) + sys.exit(1) + + supported = runtime.get("supportedDeviceTypes") or [] + iphones = [dt for dt in supported if dt.get("productFamily") == "iPhone"] + if not iphones: + print("No iPhone device types for iOS runtime.", file=sys.stderr) + sys.exit(1) + + iphones.sort( + key=lambda dt: ( + 0 if "iPhone 16" in str(dt.get("name") or "") else 1, + str(dt.get("name") or ""), + ) + ) + device_type_id = str(iphones[0].get("identifier") or "") + if not device_type_id: + print("Missing iPhone device type identifier.", file=sys.stderr) + sys.exit(1) + + sim_name = f"CI iPhone {uuid.uuid4().hex[:8]}" + udid = sh(["xcrun", "simctl", "create", sim_name, device_type_id, runtime_id]) + if not udid: + print("Failed to create iPhone simulator.", file=sys.stderr) + sys.exit(1) + print(udid) + PY + )" + echo "Using iOS Simulator id: $DEST_ID" + xcodebuild test \ + -project apps/ios/Clawdis.xcodeproj \ + -scheme Clawdis \ + -destination "platform=iOS Simulator,id=$DEST_ID" \ + -resultBundlePath "$RESULT_BUNDLE_PATH" \ + -enableCodeCoverage YES + + - name: iOS coverage summary + run: | + set -euo pipefail + RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult" + xcrun xccov view --report --only-targets "$RESULT_BUNDLE_PATH" + + - name: iOS coverage gate (43%) + run: | + set -euo pipefail + RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult" + RESULT_BUNDLE_PATH="$RESULT_BUNDLE_PATH" python3 - <<'PY' + import json + import os + import subprocess + import sys + + target_name = "Clawdis.app" + minimum = 0.43 + + report = json.loads( + subprocess.check_output( + ["xcrun", "xccov", "view", "--report", "--json", os.environ["RESULT_BUNDLE_PATH"]], + text=True, + ) + ) + + target_coverage = None + for target in report.get("targets", []): + if target.get("name") == target_name: + target_coverage = float(target["lineCoverage"]) + break + + if target_coverage is None: + print(f"Could not find coverage for target: {target_name}") + sys.exit(1) + + print(f"{target_name} line coverage: {target_coverage * 100:.2f}% (min {minimum * 100:.2f}%)") + if target_coverage + 1e-12 < minimum: + sys.exit(1) + PY + + android: + needs: [docs-scope, changed-scope, check] + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true') + runs-on: blacksmith-16vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: + include: + - task: test + command: ./gradlew --no-daemon :app:testDebugUnitTest + - task: build + command: ./gradlew --no-daemon :app:assembleDebug + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + # setup-android's sdkmanager currently crashes on JDK 21 in CI. + java-version: 17 + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + accept-android-sdk-licenses: false + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: 8.11.1 + + - name: Install Android SDK packages + run: | + yes | sdkmanager --licenses >/dev/null + sdkmanager --install \ + "platform-tools" \ + "platforms;android-36" \ + "build-tools;36.0.0" + + - name: Run Android ${{ matrix.task }} + working-directory: apps/android + run: ${{ matrix.command }} diff --git a/backend/app/one_person_security_dept/openclaw/.github/workflows/docker-release.yml b/backend/app/one_person_security_dept/openclaw/.github/workflows/docker-release.yml new file mode 100644 index 00000000..fc0d97d4 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/workflows/docker-release.yml @@ -0,0 +1,198 @@ +name: Docker Release + +on: + push: + branches: + - main + tags: + - "v*" + paths-ignore: + - "docs/**" + - "**/*.md" + - "**/*.mdx" + - ".agents/**" + - "skills/**" + +concurrency: + group: docker-release-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + # Build amd64 image + build-amd64: + runs-on: blacksmith-16vcpu-ubuntu-2404 + permissions: + packages: write + contents: read + outputs: + image-digest: ${{ steps.build.outputs.digest }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve image tags (amd64) + id: tags + shell: bash + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: | + set -euo pipefail + tags=() + if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + tags+=("${IMAGE}:main-amd64") + fi + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + version="${GITHUB_REF#refs/tags/v}" + tags+=("${IMAGE}:${version}-amd64") + fi + if [[ ${#tags[@]} -eq 0 ]]; then + echo "::error::No amd64 tags resolved for ref ${GITHUB_REF}" + exit 1 + fi + { + echo "value<> "$GITHUB_OUTPUT" + + - name: Build and push amd64 image + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + tags: ${{ steps.tags.outputs.value }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64 + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max + provenance: false + push: true + + # Build arm64 image + build-arm64: + runs-on: blacksmith-16vcpu-ubuntu-2404-arm + permissions: + packages: write + contents: read + outputs: + image-digest: ${{ steps.build.outputs.digest }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve image tags (arm64) + id: tags + shell: bash + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: | + set -euo pipefail + tags=() + if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + tags+=("${IMAGE}:main-arm64") + fi + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + version="${GITHUB_REF#refs/tags/v}" + tags+=("${IMAGE}:${version}-arm64") + fi + if [[ ${#tags[@]} -eq 0 ]]; then + echo "::error::No arm64 tags resolved for ref ${GITHUB_REF}" + exit 1 + fi + { + echo "value<> "$GITHUB_OUTPUT" + + - name: Build and push arm64 image + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/arm64 + tags: ${{ steps.tags.outputs.value }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64 + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max + provenance: false + push: true + + # Create multi-platform manifest + create-manifest: + runs-on: blacksmith-16vcpu-ubuntu-2404 + permissions: + packages: write + contents: read + needs: [build-amd64, build-arm64] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve manifest tags + id: tags + shell: bash + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: | + set -euo pipefail + tags=() + if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + tags+=("${IMAGE}:main") + fi + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + version="${GITHUB_REF#refs/tags/v}" + tags+=("${IMAGE}:${version}") + fi + if [[ ${#tags[@]} -eq 0 ]]; then + echo "::error::No manifest tags resolved for ref ${GITHUB_REF}" + exit 1 + fi + { + echo "value<> "$GITHUB_OUTPUT" + + - name: Create and push manifest + shell: bash + run: | + set -euo pipefail + mapfile -t tags <<< "${{ steps.tags.outputs.value }}" + args=() + for tag in "${tags[@]}"; do + [ -z "$tag" ] && continue + args+=("-t" "$tag") + done + docker buildx imagetools create "${args[@]}" \ + ${{ needs.build-amd64.outputs.image-digest }} \ + ${{ needs.build-arm64.outputs.image-digest }} diff --git a/backend/app/one_person_security_dept/openclaw/.github/workflows/install-smoke.yml b/backend/app/one_person_security_dept/openclaw/.github/workflows/install-smoke.yml new file mode 100644 index 00000000..03e87db8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/workflows/install-smoke.yml @@ -0,0 +1,59 @@ +name: Install Smoke + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + docs-scope: + runs-on: blacksmith-16vcpu-ubuntu-2404 + outputs: + docs_only: ${{ steps.check.outputs.docs_only }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect docs-only changes + id: check + uses: ./.github/actions/detect-docs-changes + + install-smoke: + needs: [docs-scope] + if: needs.docs-scope.outputs.docs_only != 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout CLI + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.x + check-latest: true + + - name: Setup pnpm + cache store + uses: ./.github/actions/setup-pnpm-store-cache + with: + pnpm-version: "10.23.0" + cache-key-suffix: "node22" + + - name: Install pnpm deps (minimal) + run: pnpm install --ignore-scripts --frozen-lockfile + + - name: Run installer docker tests + env: + CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh + CLAWDBOT_INSTALL_CLI_URL: https://openclaw.ai/install-cli.sh + CLAWDBOT_NO_ONBOARD: "1" + CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1" + CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }} + CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS: "1" + run: pnpm test:install:smoke diff --git a/backend/app/one_person_security_dept/openclaw/.github/workflows/labeler.yml b/backend/app/one_person_security_dept/openclaw/.github/workflows/labeler.yml new file mode 100644 index 00000000..9ac44dfa --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/workflows/labeler.yml @@ -0,0 +1,519 @@ +name: Labeler + +on: + pull_request_target: + types: [opened, synchronize, reopened] + issues: + types: [opened] + workflow_dispatch: + inputs: + max_prs: + description: "Maximum number of open PRs to process (0 = all)" + required: false + default: "200" + per_page: + description: "PRs per page (1-100)" + required: false + default: "50" + +permissions: {} + +jobs: + label: + permissions: + contents: read + pull-requests: write + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + id: app-token + with: + app-id: "2729701" + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 + with: + configuration-path: .github/labeler.yml + repo-token: ${{ steps.app-token.outputs.token }} + sync-labels: true + - name: Apply PR size label + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const pullRequest = context.payload.pull_request; + if (!pullRequest) { + return; + } + + const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"]; + const labelColor = "b76e79"; + + for (const label of sizeLabels) { + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label, + }); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label, + color: labelColor, + }); + } + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullRequest.number, + per_page: 100, + }); + + const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]); + const totalChangedLines = files.reduce((total, file) => { + const path = file.filename ?? ""; + if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) { + return total; + } + return total + (file.additions ?? 0) + (file.deletions ?? 0); + }, 0); + + let targetSizeLabel = "size: XL"; + if (totalChangedLines < 50) { + targetSizeLabel = "size: XS"; + } else if (totalChangedLines < 200) { + targetSizeLabel = "size: S"; + } else if (totalChangedLines < 500) { + targetSizeLabel = "size: M"; + } else if (totalChangedLines < 1000) { + targetSizeLabel = "size: L"; + } + + const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + per_page: 100, + }); + + for (const label of currentLabels) { + const name = label.name ?? ""; + if (!sizeLabels.includes(name)) { + continue; + } + if (name === targetSizeLabel) { + continue; + } + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + name, + }); + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + labels: [targetSizeLabel], + }); + - name: Apply maintainer or trusted-contributor label + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const login = context.payload.pull_request?.user?.login; + if (!login) { + return; + } + + const repo = `${context.repo.owner}/${context.repo.repo}`; + const trustedLabel = "trusted-contributor"; + const experiencedLabel = "experienced-contributor"; + const trustedThreshold = 4; + const experiencedThreshold = 10; + + let isMaintainer = false; + try { + const membership = await github.rest.teams.getMembershipForUserInOrg({ + org: context.repo.owner, + team_slug: "maintainer", + username: login, + }); + isMaintainer = membership?.data?.state === "active"; + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + + if (isMaintainer) { + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: context.payload.pull_request.number, + labels: ["maintainer"], + }); + return; + } + + const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`; + let mergedCount = 0; + try { + const merged = await github.rest.search.issuesAndPullRequests({ + q: mergedQuery, + per_page: 1, + }); + mergedCount = merged?.data?.total_count ?? 0; + } catch (error) { + if (error?.status !== 422) { + throw error; + } + core.warning(`Skipping merged search for ${login}; treating as 0.`); + } + + if (mergedCount >= experiencedThreshold) { + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: context.payload.pull_request.number, + labels: [experiencedLabel], + }); + return; + } + + if (mergedCount >= trustedThreshold) { + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: context.payload.pull_request.number, + labels: [trustedLabel], + }); + } + + backfill-pr-labels: + if: github.event_name == 'workflow_dispatch' + permissions: + contents: read + pull-requests: write + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + id: app-token + with: + app-id: "2729701" + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + - name: Backfill PR labels + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const repoFull = `${owner}/${repo}`; + const inputs = context.payload.inputs ?? {}; + const maxPrsInput = inputs.max_prs ?? "200"; + const perPageInput = inputs.per_page ?? "50"; + const parsedMaxPrs = Number.parseInt(maxPrsInput, 10); + const parsedPerPage = Number.parseInt(perPageInput, 10); + const maxPrs = Number.isFinite(parsedMaxPrs) ? parsedMaxPrs : 200; + const perPage = Number.isFinite(parsedPerPage) ? Math.min(100, Math.max(1, parsedPerPage)) : 50; + const processAll = maxPrs <= 0; + const maxCount = processAll ? Number.POSITIVE_INFINITY : Math.max(1, maxPrs); + + const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"]; + const labelColor = "b76e79"; + const trustedLabel = "trusted-contributor"; + const experiencedLabel = "experienced-contributor"; + const trustedThreshold = 4; + const experiencedThreshold = 10; + + const contributorCache = new Map(); + + async function ensureSizeLabels() { + for (const label of sizeLabels) { + try { + await github.rest.issues.getLabel({ + owner, + repo, + name: label, + }); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + await github.rest.issues.createLabel({ + owner, + repo, + name: label, + color: labelColor, + }); + } + } + } + + async function resolveContributorLabel(login) { + if (contributorCache.has(login)) { + return contributorCache.get(login); + } + + let isMaintainer = false; + try { + const membership = await github.rest.teams.getMembershipForUserInOrg({ + org: owner, + team_slug: "maintainer", + username: login, + }); + isMaintainer = membership?.data?.state === "active"; + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + + if (isMaintainer) { + contributorCache.set(login, "maintainer"); + return "maintainer"; + } + + const mergedQuery = `repo:${repoFull} is:pr is:merged author:${login}`; + let mergedCount = 0; + try { + const merged = await github.rest.search.issuesAndPullRequests({ + q: mergedQuery, + per_page: 1, + }); + mergedCount = merged?.data?.total_count ?? 0; + } catch (error) { + if (error?.status !== 422) { + throw error; + } + core.warning(`Skipping merged search for ${login}; treating as 0.`); + } + + let label = null; + if (mergedCount >= experiencedThreshold) { + label = experiencedLabel; + } else if (mergedCount >= trustedThreshold) { + label = trustedLabel; + } + + contributorCache.set(login, label); + return label; + } + + async function applySizeLabel(pullRequest, currentLabels, labelNames) { + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pullRequest.number, + per_page: 100, + }); + + const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]); + const totalChangedLines = files.reduce((total, file) => { + const path = file.filename ?? ""; + if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) { + return total; + } + return total + (file.additions ?? 0) + (file.deletions ?? 0); + }, 0); + + let targetSizeLabel = "size: XL"; + if (totalChangedLines < 50) { + targetSizeLabel = "size: XS"; + } else if (totalChangedLines < 200) { + targetSizeLabel = "size: S"; + } else if (totalChangedLines < 500) { + targetSizeLabel = "size: M"; + } else if (totalChangedLines < 1000) { + targetSizeLabel = "size: L"; + } + + for (const label of currentLabels) { + const name = label.name ?? ""; + if (!sizeLabels.includes(name)) { + continue; + } + if (name === targetSizeLabel) { + continue; + } + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pullRequest.number, + name, + }); + labelNames.delete(name); + } + + if (!labelNames.has(targetSizeLabel)) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pullRequest.number, + labels: [targetSizeLabel], + }); + labelNames.add(targetSizeLabel); + } + } + + async function applyContributorLabel(pullRequest, labelNames) { + const login = pullRequest.user?.login; + if (!login) { + return; + } + + const label = await resolveContributorLabel(login); + if (!label) { + return; + } + + if (labelNames.has(label)) { + return; + } + + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pullRequest.number, + labels: [label], + }); + labelNames.add(label); + } + + await ensureSizeLabels(); + + let page = 1; + let processed = 0; + + while (processed < maxCount) { + const remaining = maxCount - processed; + const pageSize = processAll ? perPage : Math.min(perPage, remaining); + const { data: pullRequests } = await github.rest.pulls.list({ + owner, + repo, + state: "open", + per_page: pageSize, + page, + }); + + if (pullRequests.length === 0) { + break; + } + + for (const pullRequest of pullRequests) { + if (!processAll && processed >= maxCount) { + break; + } + + const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner, + repo, + issue_number: pullRequest.number, + per_page: 100, + }); + + const labelNames = new Set( + currentLabels.map((label) => label.name).filter((name) => typeof name === "string"), + ); + + await applySizeLabel(pullRequest, currentLabels, labelNames); + await applyContributorLabel(pullRequest, labelNames); + + processed += 1; + } + + if (pullRequests.length < pageSize) { + break; + } + + page += 1; + } + + core.info(`Processed ${processed} pull requests.`); + + label-issues: + permissions: + issues: write + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + id: app-token + with: + app-id: "2729701" + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + - name: Apply maintainer or trusted-contributor label + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const login = context.payload.issue?.user?.login; + if (!login) { + return; + } + + const repo = `${context.repo.owner}/${context.repo.repo}`; + const trustedLabel = "trusted-contributor"; + const experiencedLabel = "experienced-contributor"; + const trustedThreshold = 4; + const experiencedThreshold = 10; + + let isMaintainer = false; + try { + const membership = await github.rest.teams.getMembershipForUserInOrg({ + org: context.repo.owner, + team_slug: "maintainer", + username: login, + }); + isMaintainer = membership?.data?.state === "active"; + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + + if (isMaintainer) { + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: context.payload.issue.number, + labels: ["maintainer"], + }); + return; + } + + const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`; + let mergedCount = 0; + try { + const merged = await github.rest.search.issuesAndPullRequests({ + q: mergedQuery, + per_page: 1, + }); + mergedCount = merged?.data?.total_count ?? 0; + } catch (error) { + if (error?.status !== 422) { + throw error; + } + core.warning(`Skipping merged search for ${login}; treating as 0.`); + } + + if (mergedCount >= experiencedThreshold) { + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: context.payload.issue.number, + labels: [experiencedLabel], + }); + return; + } + + if (mergedCount >= trustedThreshold) { + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: context.payload.issue.number, + labels: [trustedLabel], + }); + } diff --git a/backend/app/one_person_security_dept/openclaw/.github/workflows/sandbox-common-smoke.yml b/backend/app/one_person_security_dept/openclaw/.github/workflows/sandbox-common-smoke.yml new file mode 100644 index 00000000..26c0dcc1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/workflows/sandbox-common-smoke.yml @@ -0,0 +1,56 @@ +name: Sandbox Common Smoke + +on: + push: + branches: [main] + paths: + - Dockerfile.sandbox + - Dockerfile.sandbox-common + - scripts/sandbox-common-setup.sh + pull_request: + paths: + - Dockerfile.sandbox + - Dockerfile.sandbox-common + - scripts/sandbox-common-setup.sh + +concurrency: + group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + sandbox-common-smoke: + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Build minimal sandbox base (USER sandbox) + shell: bash + run: | + set -euo pipefail + + docker build -t openclaw-sandbox-smoke-base:bookworm-slim - <<'EOF' + FROM debian:bookworm-slim + RUN useradd --create-home --shell /bin/bash sandbox + USER sandbox + WORKDIR /home/sandbox + EOF + + - name: Build sandbox-common image (root for installs, sandbox at runtime) + shell: bash + run: | + set -euo pipefail + + BASE_IMAGE="openclaw-sandbox-smoke-base:bookworm-slim" \ + TARGET_IMAGE="openclaw-sandbox-common-smoke:bookworm-slim" \ + PACKAGES="ca-certificates" \ + INSTALL_PNPM=0 \ + INSTALL_BUN=0 \ + INSTALL_BREW=0 \ + FINAL_USER=sandbox \ + scripts/sandbox-common-setup.sh + + u="$(docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')" + test "$u" = "sandbox" diff --git a/backend/app/one_person_security_dept/openclaw/.github/workflows/stale.yml b/backend/app/one_person_security_dept/openclaw/.github/workflows/stale.yml new file mode 100644 index 00000000..6248a93d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/workflows/stale.yml @@ -0,0 +1,51 @@ +name: Stale + +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: {} + +jobs: + stale: + permissions: + issues: write + pull-requests: write + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + id: app-token + with: + app-id: "2729701" + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + - name: Mark stale issues and pull requests + uses: actions/stale@v9 + with: + repo-token: ${{ steps.app-token.outputs.token }} + days-before-issue-stale: 7 + days-before-issue-close: 5 + days-before-pr-stale: 5 + days-before-pr-close: 3 + stale-issue-label: stale + stale-pr-label: stale + exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale + exempt-pr-labels: maintainer,no-stale + operations-per-run: 10000 + exempt-all-assignees: true + remove-stale-when-updated: true + stale-issue-message: | + This issue has been automatically marked as stale due to inactivity. + Please add updates or it will be closed. + stale-pr-message: | + This pull request has been automatically marked as stale due to inactivity. + Please add updates or it will be closed. + close-issue-message: | + Closing due to inactivity. + If this is still an issue, please retry on the latest OpenClaw release and share updated details. + If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps. + close-issue-reason: not_planned + close-pr-message: | + Closing due to inactivity. + If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer. + That channel is the escape hatch for high-quality PRs that get auto-closed. diff --git a/backend/app/one_person_security_dept/openclaw/.github/workflows/workflow-sanity.yml b/backend/app/one_person_security_dept/openclaw/.github/workflows/workflow-sanity.yml new file mode 100644 index 00000000..19668e69 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.github/workflows/workflow-sanity.yml @@ -0,0 +1,67 @@ +name: Workflow Sanity + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + no-tabs: + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fail on tabs in workflow files + run: | + python - <<'PY' + from __future__ import annotations + + import pathlib + import sys + + root = pathlib.Path(".github/workflows") + bad: list[str] = [] + for path in sorted(root.rglob("*.yml")): + if b"\t" in path.read_bytes(): + bad.append(str(path)) + + for path in sorted(root.rglob("*.yaml")): + if b"\t" in path.read_bytes(): + bad.append(str(path)) + + if bad: + print("Tabs found in workflow file(s):") + for path in bad: + print(f"- {path}") + sys.exit(1) + PY + + actionlint: + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install actionlint + shell: bash + run: | + set -euo pipefail + ACTIONLINT_VERSION="1.7.11" + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}" + curl -sSfL -o "${archive}" "${base_url}/${archive}" + curl -sSfL -o checksums.txt "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt" + grep " ${archive}\$" checksums.txt | sha256sum -c - + tar -xzf "${archive}" actionlint + sudo install -m 0755 actionlint /usr/local/bin/actionlint + + - name: Lint workflows + run: actionlint + + - name: Disallow direct inputs interpolation in composite run blocks + run: python3 scripts/check-composite-action-input-interpolation.py diff --git a/backend/app/one_person_security_dept/openclaw/.gitignore b/backend/app/one_person_security_dept/openclaw/.gitignore new file mode 100644 index 00000000..b5d3257e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.gitignore @@ -0,0 +1,122 @@ +node_modules +**/node_modules/ +.env +docker-compose.extra.yml +dist +pnpm-lock.yaml +bun.lock +bun.lockb +coverage +__pycache__/ +*.pyc +.tsbuildinfo +.pnpm-store +.worktrees/ +.DS_Store +**/.DS_Store +ui/src/ui/__screenshots__/ +ui/playwright-report/ +ui/test-results/ +packages/dashboard-next/.next/ +packages/dashboard-next/out/ + +# Mise configuration files +mise.toml + +# Android build artifacts +apps/android/.gradle/ +apps/android/app/build/ +apps/android/.cxx/ + +# Bun build artifacts +*.bun-build +apps/macos/.build/ +apps/shared/MoltbotKit/.build/ +apps/shared/OpenClawKit/.build/ +apps/shared/OpenClawKit/Package.resolved +**/ModuleCache/ +bin/ +bin/clawdbot-mac +bin/docs-list +apps/macos/.build-local/ +apps/macos/.swiftpm/ +apps/shared/MoltbotKit/.swiftpm/ +apps/shared/OpenClawKit/.swiftpm/ +Core/ +apps/ios/*.xcodeproj/ +apps/ios/*.xcworkspace/ +apps/ios/.swiftpm/ +apps/ios/.derivedData/ +apps/ios/.local-signing.xcconfig +vendor/ +apps/ios/Clawdbot.xcodeproj/ +apps/ios/Clawdbot.xcodeproj/** +apps/macos/.build/** +**/*.bun-build +apps/ios/*.xcfilelist + +# Vendor build artifacts +vendor/a2ui/renderers/lit/dist/ +src/canvas-host/a2ui/*.bundle.js +src/canvas-host/a2ui/*.map +.bundle.hash + +# fastlane (iOS) +apps/ios/fastlane/README.md +apps/ios/fastlane/report.xml +apps/ios/fastlane/Preview.html +apps/ios/fastlane/screenshots/ +apps/ios/fastlane/test_output/ +apps/ios/fastlane/logs/ +apps/ios/fastlane/.env + +# fastlane build artifacts (local) +apps/ios/*.ipa +apps/ios/*.dSYM.zip + +# provisioning profiles (local) +apps/ios/*.mobileprovision + +# Local untracked files +.local/ +docs/.local/ +IDENTITY.md +USER.md +.tgz +.idea + +# local tooling +.serena/ + +# Agent credentials and memory (NEVER COMMIT) +/memory/ +.agent/*.json +!.agent/workflows/ +/local/ +package-lock.json +.claude/settings.local.json +.agents/ +.agents +.agent/ +skills-lock.json + +# Local iOS signing overrides +apps/ios/LocalSigning.xcconfig + +# Xcode build directories (xcodebuild output) +apps/ios/build/ +apps/shared/OpenClawKit/build/ +Swabble/build/ + +# Generated protocol schema (produced via pnpm protocol:gen) +dist/protocol.schema.json +.ant-colony/ + +# Eclipse +**/.project +**/.classpath +**/.settings/ +**/.gradle/ + +# Synthing +**/.stfolder/ diff --git a/backend/app/one_person_security_dept/openclaw/.mailmap b/backend/app/one_person_security_dept/openclaw/.mailmap new file mode 100644 index 00000000..9190f88b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.mailmap @@ -0,0 +1,13 @@ +# Canonical contributor identity mappings for cherry-picked commits. +bmendonca3 <208517100+bmendonca3@users.noreply.github.com> +hcl <7755017+hclsys@users.noreply.github.com> +Glucksberg <80581902+Glucksberg@users.noreply.github.com> +JackyWay <53031570+JackyWay@users.noreply.github.com> +Marcus Castro <7562095+mcaxtr@users.noreply.github.com> +Marc Gratch <2238658+mgratch@users.noreply.github.com> +Peter Machona <7957943+chilu18@users.noreply.github.com> +Ben Marvell <92585+easternbloc@users.noreply.github.com> +zerone0x <39543393+zerone0x@users.noreply.github.com> +Marco Di Dionisio <3519682+marcodd23@users.noreply.github.com> +mujiannan <46643837+mujiannan@users.noreply.github.com> +Santhanakrishnan <239082898+bitfoundry-ai@users.noreply.github.com> diff --git a/backend/app/one_person_security_dept/openclaw/.markdownlint-cli2.jsonc b/backend/app/one_person_security_dept/openclaw/.markdownlint-cli2.jsonc new file mode 100644 index 00000000..94035711 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.markdownlint-cli2.jsonc @@ -0,0 +1,52 @@ +{ + "globs": ["docs/**/*.md", "docs/**/*.mdx", "README.md"], + "ignores": ["docs/zh-CN/**", "docs/.i18n/**", "docs/reference/templates/**", "**/.local/**"], + "config": { + "default": true, + + "MD013": false, + "MD025": false, + "MD029": false, + + "MD033": { + "allowed_elements": [ + "Note", + "Info", + "Tip", + "Warning", + "Card", + "CardGroup", + "Columns", + "Steps", + "Step", + "Tabs", + "Tab", + "Accordion", + "AccordionGroup", + "CodeGroup", + "Frame", + "Callout", + "ParamField", + "ResponseField", + "RequestExample", + "ResponseExample", + "img", + "a", + "br", + "details", + "summary", + "p", + "strong", + "picture", + "source", + "Tooltip", + "Check", + ], + }, + + "MD036": false, + "MD040": false, + "MD041": false, + "MD046": false, + }, +} diff --git a/backend/app/one_person_security_dept/openclaw/.npmrc b/backend/app/one_person_security_dept/openclaw/.npmrc new file mode 100644 index 00000000..05620061 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.npmrc @@ -0,0 +1 @@ +# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies. diff --git a/backend/app/one_person_security_dept/openclaw/.oxfmtrc.jsonc b/backend/app/one_person_security_dept/openclaw/.oxfmtrc.jsonc new file mode 100644 index 00000000..0a928d5f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.oxfmtrc.jsonc @@ -0,0 +1,26 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "experimentalSortImports": { + "newlinesBetween": false, + }, + "experimentalSortPackageJson": { + "sortScripts": true, + }, + "tabWidth": 2, + "useTabs": false, + "ignorePatterns": [ + "apps/", + "assets/", + "CLAUDE.md", + "docker-compose.yml", + "dist/", + "docs/_layouts/", + "node_modules/", + "patches/", + "pnpm-lock.yaml/", + "src/gateway/server-methods/CLAUDE.md", + "src/auto-reply/reply/export-html/", + "Swabble/", + "vendor/", + ], +} diff --git a/backend/app/one_person_security_dept/openclaw/.oxlintrc.json b/backend/app/one_person_security_dept/openclaw/.oxlintrc.json new file mode 100644 index 00000000..687b5bb5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.oxlintrc.json @@ -0,0 +1,39 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["unicorn", "typescript", "oxc"], + "categories": { + "correctness": "error", + "perf": "error", + "suspicious": "error" + }, + "rules": { + "curly": "error", + "eslint-plugin-unicorn/prefer-array-find": "off", + "eslint/no-await-in-loop": "off", + "eslint/no-new": "off", + "eslint/no-shadow": "off", + "eslint/no-unmodified-loop-condition": "off", + "oxc/no-accumulating-spread": "off", + "oxc/no-async-endpoint-handlers": "off", + "oxc/no-map-spread": "off", + "typescript/no-explicit-any": "error", + "typescript/no-extraneous-class": "off", + "typescript/no-unsafe-type-assertion": "off", + "unicorn/consistent-function-scoping": "off", + "unicorn/require-post-message-target-origin": "off" + }, + "ignorePatterns": [ + "assets/", + "dist/", + "docs/_layouts/", + "extensions/", + "node_modules/", + "patches/", + "pnpm-lock.yaml", + "skills/", + "src/auto-reply/reply/export-html/template.js", + "src/canvas-host/a2ui/a2ui.bundle.js", + "Swabble/", + "vendor/" + ] +} diff --git a/backend/app/one_person_security_dept/openclaw/.pi/extensions/diff.ts b/backend/app/one_person_security_dept/openclaw/.pi/extensions/diff.ts new file mode 100644 index 00000000..037fa240 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.pi/extensions/diff.ts @@ -0,0 +1,195 @@ +/** + * Diff Extension + * + * /diff command shows modified/deleted/new files from git status and opens + * the selected file in VS Code's diff view. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; +import { + Container, + Key, + matchesKey, + type SelectItem, + SelectList, + Text, +} from "@mariozechner/pi-tui"; + +interface FileInfo { + status: string; + statusLabel: string; + file: string; +} + +export default function (pi: ExtensionAPI) { + pi.registerCommand("diff", { + description: "Show git changes and open in VS Code diff view", + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("No UI available", "error"); + return; + } + + // Get changed files from git status + const result = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd }); + + if (result.code !== 0) { + ctx.ui.notify(`git status failed: ${result.stderr}`, "error"); + return; + } + + if (!result.stdout || !result.stdout.trim()) { + ctx.ui.notify("No changes in working tree", "info"); + return; + } + + // Parse git status output + // Format: XY filename (where XY is two-letter status, then space, then filename) + const lines = result.stdout.split("\n"); + const files: FileInfo[] = []; + + for (const line of lines) { + if (line.length < 4) { + continue; + } // Need at least "XY f" + + const status = line.slice(0, 2); + const file = line.slice(2).trimStart(); + + // Translate status codes to short labels + let statusLabel: string; + if (status.includes("M")) { + statusLabel = "M"; + } else if (status.includes("A")) { + statusLabel = "A"; + } else if (status.includes("D")) { + statusLabel = "D"; + } else if (status.includes("?")) { + statusLabel = "?"; + } else if (status.includes("R")) { + statusLabel = "R"; + } else if (status.includes("C")) { + statusLabel = "C"; + } else { + statusLabel = status.trim() || "~"; + } + + files.push({ status: statusLabel, statusLabel, file }); + } + + if (files.length === 0) { + ctx.ui.notify("No changes found", "info"); + return; + } + + const openSelected = async (fileInfo: FileInfo): Promise => { + try { + // Open in VS Code diff view. + // For untracked files, git difftool won't work, so fall back to just opening the file. + if (fileInfo.status === "?") { + await pi.exec("code", ["-g", fileInfo.file], { cwd: ctx.cwd }); + return; + } + + const diffResult = await pi.exec( + "git", + ["difftool", "-y", "--tool=vscode", fileInfo.file], + { + cwd: ctx.cwd, + }, + ); + if (diffResult.code !== 0) { + await pi.exec("code", ["-g", fileInfo.file], { cwd: ctx.cwd }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.ui.notify(`Failed to open ${fileInfo.file}: ${message}`, "error"); + } + }; + + // Show file picker with SelectList + await ctx.ui.custom((tui, theme, _kb, done) => { + const container = new Container(); + + // Top border + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + // Title + container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to diff")), 0, 0)); + + // Build select items with colored status + const items: SelectItem[] = files.map((f) => { + let statusColor: string; + switch (f.status) { + case "M": + statusColor = theme.fg("warning", f.status); + break; + case "A": + statusColor = theme.fg("success", f.status); + break; + case "D": + statusColor = theme.fg("error", f.status); + break; + case "?": + statusColor = theme.fg("muted", f.status); + break; + default: + statusColor = theme.fg("dim", f.status); + } + return { + value: f, + label: `${statusColor} ${f.file}`, + }; + }); + + const visibleRows = Math.min(files.length, 15); + let currentIndex = 0; + + const selectList = new SelectList(items, visibleRows, { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => t, // Keep existing colors + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }); + selectList.onSelect = (item) => { + void openSelected(item.value as FileInfo); + }; + selectList.onCancel = () => done(); + selectList.onSelectionChange = (item) => { + currentIndex = items.indexOf(item); + }; + container.addChild(selectList); + + // Help text + container.addChild( + new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0), + ); + + // Bottom border + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + return { + render: (w) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data) => { + // Add paging with left/right + if (matchesKey(data, Key.left)) { + // Page up - clamp to 0 + currentIndex = Math.max(0, currentIndex - visibleRows); + selectList.setSelectedIndex(currentIndex); + } else if (matchesKey(data, Key.right)) { + // Page down - clamp to last + currentIndex = Math.min(items.length - 1, currentIndex + visibleRows); + selectList.setSelectedIndex(currentIndex); + } else { + selectList.handleInput(data); + } + tui.requestRender(); + }, + }; + }); + }, + }); +} diff --git a/backend/app/one_person_security_dept/openclaw/.pi/extensions/files.ts b/backend/app/one_person_security_dept/openclaw/.pi/extensions/files.ts new file mode 100644 index 00000000..bba2760d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.pi/extensions/files.ts @@ -0,0 +1,194 @@ +/** + * Files Extension + * + * /files command lists all files the model has read/written/edited in the active session branch, + * coalesced by path and sorted newest first. Selecting a file opens it in VS Code. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; +import { + Container, + Key, + matchesKey, + type SelectItem, + SelectList, + Text, +} from "@mariozechner/pi-tui"; + +interface FileEntry { + path: string; + operations: Set<"read" | "write" | "edit">; + lastTimestamp: number; +} + +type FileToolName = "read" | "write" | "edit"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("files", { + description: "Show files read/written/edited in this session", + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("No UI available", "error"); + return; + } + + // Get the current branch (path from leaf to root) + const branch = ctx.sessionManager.getBranch(); + + // First pass: collect tool calls (id -> {path, name}) from assistant messages + const toolCalls = new Map(); + + for (const entry of branch) { + if (entry.type !== "message") { + continue; + } + const msg = entry.message; + + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "toolCall") { + const name = block.name; + if (name === "read" || name === "write" || name === "edit") { + const path = block.arguments?.path; + if (path && typeof path === "string") { + toolCalls.set(block.id, { path, name, timestamp: msg.timestamp }); + } + } + } + } + } + } + + // Second pass: match tool results to get the actual execution timestamp + const fileMap = new Map(); + + for (const entry of branch) { + if (entry.type !== "message") { + continue; + } + const msg = entry.message; + + if (msg.role === "toolResult") { + const toolCall = toolCalls.get(msg.toolCallId); + if (!toolCall) { + continue; + } + + const { path, name } = toolCall; + const timestamp = msg.timestamp; + + const existing = fileMap.get(path); + if (existing) { + existing.operations.add(name); + if (timestamp > existing.lastTimestamp) { + existing.lastTimestamp = timestamp; + } + } else { + fileMap.set(path, { + path, + operations: new Set([name]), + lastTimestamp: timestamp, + }); + } + } + } + + if (fileMap.size === 0) { + ctx.ui.notify("No files read/written/edited in this session", "info"); + return; + } + + // Sort by most recent first + const files = Array.from(fileMap.values()).toSorted( + (a, b) => b.lastTimestamp - a.lastTimestamp, + ); + + const openSelected = async (file: FileEntry): Promise => { + try { + await pi.exec("code", ["-g", file.path], { cwd: ctx.cwd }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.ui.notify(`Failed to open ${file.path}: ${message}`, "error"); + } + }; + + // Show file picker with SelectList + await ctx.ui.custom((tui, theme, _kb, done) => { + const container = new Container(); + + // Top border + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + // Title + container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0)); + + // Build select items with colored operations + const items: SelectItem[] = files.map((f) => { + const ops: string[] = []; + if (f.operations.has("read")) { + ops.push(theme.fg("muted", "R")); + } + if (f.operations.has("write")) { + ops.push(theme.fg("success", "W")); + } + if (f.operations.has("edit")) { + ops.push(theme.fg("warning", "E")); + } + const opsLabel = ops.join(""); + return { + value: f, + label: `${opsLabel} ${f.path}`, + }; + }); + + const visibleRows = Math.min(files.length, 15); + let currentIndex = 0; + + const selectList = new SelectList(items, visibleRows, { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => t, // Keep existing colors + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }); + selectList.onSelect = (item) => { + void openSelected(item.value as FileEntry); + }; + selectList.onCancel = () => done(); + selectList.onSelectionChange = (item) => { + currentIndex = items.indexOf(item); + }; + container.addChild(selectList); + + // Help text + container.addChild( + new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0), + ); + + // Bottom border + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + return { + render: (w) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data) => { + // Add paging with left/right + if (matchesKey(data, Key.left)) { + // Page up - clamp to 0 + currentIndex = Math.max(0, currentIndex - visibleRows); + selectList.setSelectedIndex(currentIndex); + } else if (matchesKey(data, Key.right)) { + // Page down - clamp to last + currentIndex = Math.min(items.length - 1, currentIndex + visibleRows); + selectList.setSelectedIndex(currentIndex); + } else { + selectList.handleInput(data); + } + tui.requestRender(); + }, + }; + }); + }, + }); +} diff --git a/backend/app/one_person_security_dept/openclaw/.pi/extensions/prompt-url-widget.ts b/backend/app/one_person_security_dept/openclaw/.pi/extensions/prompt-url-widget.ts new file mode 100644 index 00000000..2bb56b10 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.pi/extensions/prompt-url-widget.ts @@ -0,0 +1,193 @@ +import { + DynamicBorder, + type ExtensionAPI, + type ExtensionContext, +} from "@mariozechner/pi-coding-agent"; +import { Container, Text } from "@mariozechner/pi-tui"; + +const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im; +const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im; + +type PromptMatch = { + kind: "pr" | "issue"; + url: string; +}; + +type GhMetadata = { + title?: string; + author?: { + login?: string; + name?: string | null; + }; +}; + +function extractPromptMatch(prompt: string): PromptMatch | undefined { + const prMatch = prompt.match(PR_PROMPT_PATTERN); + if (prMatch?.[1]) { + return { kind: "pr", url: prMatch[1].trim() }; + } + + const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN); + if (issueMatch?.[1]) { + return { kind: "issue", url: issueMatch[1].trim() }; + } + + return undefined; +} + +async function fetchGhMetadata( + pi: ExtensionAPI, + kind: PromptMatch["kind"], + url: string, +): Promise { + const args = + kind === "pr" + ? ["pr", "view", url, "--json", "title,author"] + : ["issue", "view", url, "--json", "title,author"]; + + try { + const result = await pi.exec("gh", args); + if (result.code !== 0 || !result.stdout) { + return undefined; + } + return JSON.parse(result.stdout) as GhMetadata; + } catch { + return undefined; + } +} + +function formatAuthor(author?: GhMetadata["author"]): string | undefined { + if (!author) { + return undefined; + } + const name = author.name?.trim(); + const login = author.login?.trim(); + if (name && login) { + return `${name} (@${login})`; + } + if (login) { + return `@${login}`; + } + if (name) { + return name; + } + return undefined; +} + +export default function promptUrlWidgetExtension(pi: ExtensionAPI) { + const setWidget = ( + ctx: ExtensionContext, + match: PromptMatch, + title?: string, + authorText?: string, + ) => { + ctx.ui.setWidget("prompt-url", (_tui, thm) => { + const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url); + const authorLine = authorText ? thm.fg("muted", authorText) : undefined; + const urlLine = thm.fg("dim", match.url); + + const lines = [titleText]; + if (authorLine) { + lines.push(authorLine); + } + lines.push(urlLine); + + const container = new Container(); + container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s))); + container.addChild(new Text(lines.join("\n"), 1, 0)); + return container; + }); + }; + + const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => { + const label = match.kind === "pr" ? "PR" : "Issue"; + const trimmedTitle = title?.trim(); + const fallbackName = `${label}: ${match.url}`; + const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName; + const currentName = pi.getSessionName()?.trim(); + if (!currentName) { + pi.setSessionName(desiredName); + return; + } + if (currentName === match.url || currentName === fallbackName) { + pi.setSessionName(desiredName); + } + }; + + pi.on("before_agent_start", async (event, ctx) => { + if (!ctx.hasUI) { + return; + } + const match = extractPromptMatch(event.prompt); + if (!match) { + return; + } + + setWidget(ctx, match); + applySessionName(ctx, match); + void fetchGhMetadata(pi, match.kind, match.url).then((meta) => { + const title = meta?.title?.trim(); + const authorText = formatAuthor(meta?.author); + setWidget(ctx, match, title, authorText); + applySessionName(ctx, match, title); + }); + }); + + pi.on("session_switch", async (_event, ctx) => { + rebuildFromSession(ctx); + }); + + const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => { + if (!content) { + return ""; + } + if (typeof content === "string") { + return content; + } + return ( + content + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map((block) => block.text) + .join("\n") ?? "" + ); + }; + + const rebuildFromSession = (ctx: ExtensionContext) => { + if (!ctx.hasUI) { + return; + } + + const entries = ctx.sessionManager.getEntries(); + const lastMatch = [...entries].toReversed().find((entry) => { + if (entry.type !== "message" || entry.message.role !== "user") { + return false; + } + const text = getUserText(entry.message.content); + return !!extractPromptMatch(text); + }); + + const content = + lastMatch?.type === "message" && lastMatch.message.role === "user" + ? lastMatch.message.content + : undefined; + const text = getUserText(content); + const match = text ? extractPromptMatch(text) : undefined; + if (!match) { + ctx.ui.setWidget("prompt-url", undefined); + return; + } + + setWidget(ctx, match); + applySessionName(ctx, match); + void fetchGhMetadata(pi, match.kind, match.url).then((meta) => { + const title = meta?.title?.trim(); + const authorText = formatAuthor(meta?.author); + setWidget(ctx, match, title, authorText); + applySessionName(ctx, match, title); + }); + }; + + pi.on("session_start", async (_event, ctx) => { + rebuildFromSession(ctx); + }); +} diff --git a/backend/app/one_person_security_dept/openclaw/.pi/extensions/redraws.ts b/backend/app/one_person_security_dept/openclaw/.pi/extensions/redraws.ts new file mode 100644 index 00000000..6331f5ea --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.pi/extensions/redraws.ts @@ -0,0 +1,26 @@ +/** + * Redraws Extension + * + * Exposes /tui to show TUI redraw stats. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("tui", { + description: "Show TUI stats", + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + return; + } + let redraws = 0; + await ctx.ui.custom((tui, _theme, _keybindings, done) => { + redraws = tui.fullRedraws; + done(undefined); + return new Text("", 0, 0); + }); + ctx.ui.notify(`TUI full redraws: ${redraws}`, "info"); + }, + }); +} diff --git a/backend/app/one_person_security_dept/openclaw/.pi/git/.gitignore b/backend/app/one_person_security_dept/openclaw/.pi/git/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.pi/git/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/backend/app/one_person_security_dept/openclaw/.pi/prompts/cl.md b/backend/app/one_person_security_dept/openclaw/.pi/prompts/cl.md new file mode 100644 index 00000000..6d79ecda --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.pi/prompts/cl.md @@ -0,0 +1,58 @@ +--- +description: Audit changelog entries before release +--- + +Audit changelog entries for all commits since the last release. + +## Process + +1. **Find the last release tag:** + + ```bash + git tag --sort=-version:refname | head -1 + ``` + +2. **List all commits since that tag:** + + ```bash + git log ..HEAD --oneline + ``` + +3. **Read each package's [Unreleased] section:** + - packages/ai/CHANGELOG.md + - packages/tui/CHANGELOG.md + - packages/coding-agent/CHANGELOG.md + +4. **For each commit, check:** + - Skip: changelog updates, doc-only changes, release housekeeping + - Determine which package(s) the commit affects (use `git show --stat`) + - Verify a changelog entry exists in the affected package(s) + - For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))` + +5. **Cross-package duplication rule:** + Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them. + +6. **Add New Features section after changelog fixes:** + - Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`. + - Propose the top new features to the user for confirmation before writing them. + - Link to relevant docs and sections whenever possible. + +7. **Report:** + - List commits with missing entries + - List entries that need cross-package duplication + - Add any missing entries directly + +## Changelog Format Reference + +Sections (in order): + +- `### Breaking Changes` - API changes requiring migration +- `### Added` - New features +- `### Changed` - Changes to existing functionality +- `### Fixed` - Bug fixes +- `### Removed` - Removed features + +Attribution: + +- Internal: `Fixed foo ([#123](https://github.com/badlogic/pi-mono/issues/123))` +- External: `Added bar ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@user](https://github.com/user))` diff --git a/backend/app/one_person_security_dept/openclaw/.pi/prompts/is.md b/backend/app/one_person_security_dept/openclaw/.pi/prompts/is.md new file mode 100644 index 00000000..cc8f603a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.pi/prompts/is.md @@ -0,0 +1,22 @@ +--- +description: Analyze GitHub issues (bugs or feature requests) +--- + +Analyze GitHub issue(s): $ARGUMENTS + +For each issue: + +1. Read the issue in full, including all comments and linked issues/PRs. + +2. **For bugs**: + - Ignore any root cause analysis in the issue (likely wrong) + - Read all related code files in full (no truncation) + - Trace the code path and identify the actual root cause + - Propose a fix + +3. **For feature requests**: + - Read all related code files in full (no truncation) + - Propose the most concise implementation approach + - List affected files and changes needed + +Do NOT implement unless explicitly asked. Analyze and propose only. diff --git a/backend/app/one_person_security_dept/openclaw/.pi/prompts/landpr.md b/backend/app/one_person_security_dept/openclaw/.pi/prompts/landpr.md new file mode 100644 index 00000000..95e4692f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.pi/prompts/landpr.md @@ -0,0 +1,73 @@ +--- +description: Land a PR (merge with proper workflow) +--- + +Input + +- PR: $1 + - If missing: use the most recent PR mentioned in the conversation. + - If ambiguous: ask. + +Do (end-to-end) +Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` with `--rebase` or `--squash`. + +1. Assign PR to self: + - `gh pr edit --add-assignee @me` +2. Repo clean: `git status`. +3. Identify PR meta (author + head branch): + + ```sh + gh pr view --json number,title,author,headRefName,baseRefName,headRepository --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner}' + contrib=$(gh pr view --json author --jq .author.login) + head=$(gh pr view --json headRefName --jq .headRefName) + head_repo_url=$(gh pr view --json headRepository --jq .headRepository.url) + ``` + +4. Fast-forward base: + - `git checkout main` + - `git pull --ff-only` +5. Create temp base branch from main: + - `git checkout -b temp/landpr-` +6. Check out PR branch locally: + - `gh pr checkout ` +7. Rebase PR branch onto temp base: + - `git rebase temp/landpr-` + - Fix conflicts; keep history tidy. +8. Fix + tests + changelog: + - Implement fixes + add/adjust tests + - Update `CHANGELOG.md` and mention `#` + `@$contrib` +9. Decide merge strategy: + - Rebase if we want to preserve commit history + - Squash if we want a single clean commit + - If unclear, ask +10. Full gate (BEFORE commit): + - `pnpm lint && pnpm build && pnpm test` +11. Commit via committer (final merge commit only includes PR # + thanks): + - For the final merge-ready commit: `committer "fix: (#) (thanks @$contrib)" CHANGELOG.md ` + - If you need intermediate fix commits before the final merge commit, keep those messages concise and **omit** PR number/thanks. + - `land_sha=$(git rev-parse HEAD)` +12. Push updated PR branch (rebase => usually needs force): + + ```sh + git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git" + git push --force-with-lease prhead HEAD:$head + ``` + +13. Merge PR (must show MERGED on GitHub): + - Rebase: `gh pr merge --rebase` + - Squash: `gh pr merge --squash` + - Never `gh pr close` (closing is wrong) +14. Sync main: + - `git checkout main` + - `git pull --ff-only` +15. Comment on PR with what we did + SHAs + thanks: + + ```sh + merge_sha=$(gh pr view --json mergeCommit --jq '.mergeCommit.oid') + gh pr comment --body "Landed via temp rebase onto main.\n\n- Gate: pnpm lint && pnpm build && pnpm test\n- Land commit: $land_sha\n- Merge commit: $merge_sha\n\nThanks @$contrib!" + ``` + +16. Verify PR state == MERGED: + - `gh pr view --json state --jq .state` +17. Delete temp branch: + - `git branch -D temp/landpr-` diff --git a/backend/app/one_person_security_dept/openclaw/.pi/prompts/reviewpr.md b/backend/app/one_person_security_dept/openclaw/.pi/prompts/reviewpr.md new file mode 100644 index 00000000..835be806 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.pi/prompts/reviewpr.md @@ -0,0 +1,105 @@ +--- +description: Review a PR thoroughly without merging +--- + +Input + +- PR: $1 + - If missing: use the most recent PR mentioned in the conversation. + - If ambiguous: ask. + +Do (review-only) +Goal: produce a thorough review and a clear recommendation (READY for /landpr vs NEEDS WORK). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command. + +1. Identify PR meta + context + + ```sh + gh pr view --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length}' + ``` + +2. Read the PR description carefully + - Summarize the stated goal, scope, and any "why now?" rationale. + - Call out any missing context: motivation, alternatives considered, rollout/compat notes, risk. + +3. Read the diff thoroughly (prefer full diff) + + ```sh + gh pr diff + # If you need more surrounding context for files: + gh pr checkout # optional; still review-only + git show --stat + ``` + +4. Validate the change is needed / valuable + - What user/customer/dev pain does this solve? + - Is this change the smallest reasonable fix? + - Are we introducing complexity for marginal benefit? + - Are we changing behavior/contract in a way that needs docs or a release note? + +5. Evaluate implementation quality + optimality + - Correctness: edge cases, error handling, null/undefined, concurrency, ordering. + - Design: is the abstraction/architecture appropriate or over/under-engineered? + - Performance: hot paths, allocations, queries, network, N+1s, caching. + - Security/privacy: authz/authn, input validation, secrets, logging PII. + - Backwards compatibility: public APIs, config, migrations. + - Style consistency: formatting, naming, patterns used elsewhere. + +6. Tests & verification + - Identify what's covered by tests (unit/integration/e2e). + - Are there regression tests for the bug fixed / scenario added? + - Missing tests? Call out exact cases that should be added. + - If tests are present, do they actually assert the important behavior (not just snapshots / happy path)? + +7. Follow-up refactors / cleanup suggestions + - Any code that should be simplified before merge? + - Any TODOs that should be tickets vs addressed now? + - Any deprecations, docs, types, or lint rules we should adjust? + +8. Key questions to answer explicitly + - Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR? + - Any blocking concerns (must-fix before merge)? + - Is this PR ready to land, or does it need work? + +9. Output (structured) + Produce a review with these sections: + +A) TL;DR recommendation + +- One of: READY FOR /landpr | NEEDS WORK | NEEDS DISCUSSION +- 1–3 sentence rationale. + +B) What changed + +- Brief bullet summary of the diff/behavioral changes. + +C) What's good + +- Bullets: correctness, simplicity, tests, docs, ergonomics, etc. + +D) Concerns / questions (actionable) + +- Numbered list. +- Mark each item as: + - BLOCKER (must fix before merge) + - IMPORTANT (should fix before merge) + - NIT (optional) +- For each: point to the file/area and propose a concrete fix or alternative. + +E) Tests + +- What exists. +- What's missing (specific scenarios). + +F) Follow-ups (optional) + +- Non-blocking refactors/tickets to open later. + +G) Suggested PR comment (optional) + +- Offer: "Want me to draft a PR comment to the author?" +- If yes, provide a ready-to-paste comment summarizing the above, with clear asks. + +Rules / Guardrails + +- Review only: do not merge (`gh pr merge`), do not push branches, do not edit code. +- If you need clarification, ask questions rather than guessing. diff --git a/backend/app/one_person_security_dept/openclaw/.pre-commit-config.yaml b/backend/app/one_person_security_dept/openclaw/.pre-commit-config.yaml new file mode 100644 index 00000000..30b6363a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.pre-commit-config.yaml @@ -0,0 +1,131 @@ +# Pre-commit hooks for openclaw +# Install: prek install +# Run manually: prek run --all-files +# +# See https://pre-commit.com for more information + +repos: + # Basic file hygiene + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + exclude: '^(docs/|dist/|vendor/|.*\.snap$)' + - id: end-of-file-fixer + exclude: '^(docs/|dist/|vendor/|.*\.snap$)' + - id: check-yaml + args: [--allow-multiple-documents] + - id: check-added-large-files + args: [--maxkb=500] + - id: check-merge-conflict + - id: detect-private-key + exclude: '(^|/)(\.secrets\.baseline$|\.detect-secrets\.cfg$|\.pre-commit-config\.yaml$|apps/ios/fastlane/Fastfile$|.*\.test\.ts$)' + + # Secret detection (same as CI) + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + args: + - --baseline + - .secrets.baseline + - --exclude-files + - '(^|/)(dist/|vendor/|pnpm-lock\.yaml$|\.detect-secrets\.cfg$)' + - --exclude-lines + - 'key_content\.include\?\("BEGIN PRIVATE KEY"\)' + - --exclude-lines + - 'case \.apiKeyEnv: "API key \(env var\)"' + - --exclude-lines + - 'case apikey = "apiKey"' + - --exclude-lines + - '"gateway\.remote\.password"' + - --exclude-lines + - '"gateway\.auth\.password"' + - --exclude-lines + - '"talk\.apiKey"' + - --exclude-lines + - '=== "string"' + - --exclude-lines + - 'typeof remote\?\.password === "string"' + # Shell script linting + - repo: https://github.com/koalaman/shellcheck-precommit + rev: v0.11.0 + hooks: + - id: shellcheck + args: [--severity=error] # Only fail on errors, not warnings/info + # Exclude vendor and scripts with embedded code or known issues + exclude: "^(vendor/|scripts/e2e/)" + + # GitHub Actions linting + - repo: https://github.com/rhysd/actionlint + rev: v1.7.10 + hooks: + - id: actionlint + + # GitHub Actions security audit + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.22.0 + hooks: + - id: zizmor + args: [--persona=regular, --min-severity=medium, --min-confidence=medium] + exclude: "^(vendor/|Swabble/)" + + # Python checks for skills scripts + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.1 + hooks: + - id: ruff + files: "^skills/.*\\.py$" + args: [--config, pyproject.toml] + + - repo: local + hooks: + - id: skills-python-tests + name: skills python tests + entry: pytest -q skills + language: python + additional_dependencies: [pytest>=8, <9] + pass_filenames: false + files: "^skills/.*\\.py$" + + # Project checks (same commands as CI) + - repo: local + hooks: + # pnpm audit --prod --audit-level=high + - id: pnpm-audit-prod + name: pnpm-audit-prod + entry: pnpm audit --prod --audit-level=high + language: system + pass_filenames: false + + # oxlint --type-aware src test + - id: oxlint + name: oxlint + entry: scripts/pre-commit/run-node-tool.sh oxlint --type-aware src test + language: system + pass_filenames: false + types_or: [javascript, jsx, ts, tsx] + + # oxfmt --check src test + - id: oxfmt + name: oxfmt + entry: scripts/pre-commit/run-node-tool.sh oxfmt --check src test + language: system + pass_filenames: false + types_or: [javascript, jsx, ts, tsx] + + # swiftlint (same as CI) + - id: swiftlint + name: swiftlint + entry: swiftlint --config .swiftlint.yml + language: system + pass_filenames: false + types: [swift] + + # swiftformat --lint (same as CI) + - id: swiftformat + name: swiftformat + entry: swiftformat --lint apps/macos/Sources --config .swiftformat + language: system + pass_filenames: false + types: [swift] diff --git a/backend/app/one_person_security_dept/openclaw/.shellcheckrc b/backend/app/one_person_security_dept/openclaw/.shellcheckrc new file mode 100644 index 00000000..515f25a5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.shellcheckrc @@ -0,0 +1,25 @@ +# ShellCheck configuration +# https://www.shellcheck.net/wiki/ + +# Disable common false positives and style suggestions + +# SC2034: Variable appears unused (often exported or used indirectly) +disable=SC2034 + +# SC2155: Declare and assign separately (common idiom, rarely causes issues) +disable=SC2155 + +# SC2295: Expansions inside ${..} need quoting (info-level, rarely causes issues) +disable=SC2295 + +# SC1012: \r is literal (tr -d '\r' works as intended on most systems) +disable=SC1012 + +# SC2026: Word outside quotes (info-level, often intentional) +disable=SC2026 + +# SC2016: Expressions don't expand in single quotes (often intentional in sed/awk) +disable=SC2016 + +# SC2129: Consider using { cmd1; cmd2; } >> file (style preference) +disable=SC2129 diff --git a/backend/app/one_person_security_dept/openclaw/.swiftformat b/backend/app/one_person_security_dept/openclaw/.swiftformat new file mode 100644 index 00000000..fd8c0e63 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.swiftformat @@ -0,0 +1,51 @@ +# SwiftFormat configuration adapted from Peekaboo defaults (Swift 6 friendly) + +--swiftversion 6.2 + +# Self handling +--self insert +--selfrequired + +# Imports / extensions +--importgrouping testable-bottom +--extensionacl on-declarations + +# Indentation +--indent 4 +--indentcase false +--ifdef no-indent +--xcodeindentation enabled + +# Line breaks +--linebreaks lf +--maxwidth 120 + +# Whitespace +--trimwhitespace always +--emptybraces no-space +--nospaceoperators ...,..< +--ranges no-space +--someAny true +--voidtype void + +# Wrapping +--wraparguments before-first +--wrapparameters before-first +--wrapcollections before-first +--closingparen same-line + +# Organization +--organizetypes class,struct,enum,extension +--extensionmark "MARK: - %t + %p" +--marktypes always +--markextensions always +--structthreshold 0 +--enumthreshold 0 + +# Other +--stripunusedargs closure-only +--header ignore +--allman false + +# Exclusions +--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol diff --git a/backend/app/one_person_security_dept/openclaw/.swiftlint.yml b/backend/app/one_person_security_dept/openclaw/.swiftlint.yml new file mode 100644 index 00000000..b5622880 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.swiftlint.yml @@ -0,0 +1,148 @@ +# SwiftLint configuration adapted from Peekaboo defaults (Swift 6 friendly) + +included: + - apps/macos/Sources + +excluded: + - .build + - DerivedData + - "**/.build" + - "**/.swiftpm" + - "**/DerivedData" + - "**/Generated" + - "**/Resources" + - "**/Package.swift" + - "**/Tests/Resources" + - node_modules + - dist + - coverage + - "*.playground" + # Generated (protocol-gen-swift.ts) + - apps/macos/Sources/MoltbotProtocol/GatewayModels.swift + +analyzer_rules: + - unused_declaration + - unused_import + +opt_in_rules: + - array_init + - closure_spacing + - contains_over_first_not_nil + - empty_count + - empty_string + - explicit_init + - fallthrough + - fatal_error_message + - first_where + - joined_default_parameter + - last_where + - literal_expression_end_indentation + - multiline_arguments + - multiline_parameters + - operator_usage_whitespace + - overridden_super_call + - pattern_matching_keywords + - private_outlet + - prohibited_super_call + - redundant_nil_coalescing + - sorted_first_last + - switch_case_alignment + - unneeded_parentheses_in_closure_argument + - vertical_parameter_alignment_on_call + +disabled_rules: + # SwiftFormat handles these + - trailing_whitespace + - trailing_newline + - trailing_comma + - vertical_whitespace + - indentation_width + + # Style exclusions + - explicit_self + - identifier_name + - file_header + - explicit_top_level_acl + - explicit_acl + - explicit_type_interface + - missing_docs + - required_deinit + - prefer_nimble + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - anonymous_argument_in_multiline_closure + - no_extension_access_modifier + - no_grouping_extension + - switch_case_on_newline + - strict_fileprivate + - extension_access_modifier + - convenience_type + - no_magic_numbers + - one_declaration_per_file + - vertical_whitespace_between_cases + - vertical_whitespace_closing_braces + - superfluous_else + - number_separator + - prefixed_toplevel_constant + - opening_brace + - trailing_closure + - contrasted_opening_brace + - sorted_imports + - redundant_type_annotation + - shorthand_optional_binding + - untyped_error_in_catch + - file_name + - todo + +force_cast: warning +force_try: warning + +type_name: + min_length: + warning: 2 + error: 1 + max_length: + warning: 60 + error: 80 + +function_body_length: + warning: 150 + error: 300 + +function_parameter_count: + warning: 7 + error: 10 + +file_length: + warning: 1500 + error: 2500 + ignore_comment_only_lines: true + +type_body_length: + warning: 800 + error: 1200 + +cyclomatic_complexity: + warning: 20 + error: 120 + +large_tuple: + warning: 4 + error: 5 + +nesting: + type_level: + warning: 4 + error: 6 + function_level: + warning: 5 + error: 7 + +line_length: + warning: 120 + error: 250 + ignores_comments: true + ignores_urls: true + +reporter: "xcode" diff --git a/backend/app/one_person_security_dept/openclaw/.vscode/extensions.json b/backend/app/one_person_security_dept/openclaw/.vscode/extensions.json new file mode 100644 index 00000000..99e2f7dd --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["oxc.oxc-vscode"] +} diff --git a/backend/app/one_person_security_dept/openclaw/.vscode/settings.json b/backend/app/one_person_security_dept/openclaw/.vscode/settings.json new file mode 100644 index 00000000..e291954c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/.vscode/settings.json @@ -0,0 +1,22 @@ +{ + "editor.formatOnSave": true, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "[javascript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[json]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "typescript.preferences.importModuleSpecifierEnding": "js", + "typescript.reportStyleChecksAsWarnings": false, + "typescript.updateImportsOnFileMove.enabled": "always", + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.experimental.useTsgo": true +} diff --git a/backend/app/one_person_security_dept/openclaw/AGENTS.md b/backend/app/one_person_security_dept/openclaw/AGENTS.md new file mode 100644 index 00000000..09ed6423 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/AGENTS.md @@ -0,0 +1,255 @@ +# Repository Guidelines + +- Repo: https://github.com/openclaw/openclaw +- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n". +- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption. +- GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL). +- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries. + +## Project Structure & Module Organization + +- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). +- Tests: colocated `*.test.ts`. +- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. +- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. +- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias). +- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`). +- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs). + - Core channel docs: `docs/channels/` + - Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing` + - Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`) +- When adding channels/extensions/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/extension label colors). + +## Docs Linking (Mintlify) + +- Docs are hosted on Mintlify (docs.openclaw.ai). +- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`). +- When working with documentation, read the mintlify skill. +- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`). +- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links. +- When Peter asks for links, reply with full `https://docs.openclaw.ai/...` URLs (not root-relative). +- When you touch docs, end the reply with the `https://docs.openclaw.ai/...` URLs you referenced. +- README (GitHub): keep absolute docs URLs (`https://docs.openclaw.ai/...`) so links work on GitHub. +- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”. + +## Docs i18n (zh-CN) + +- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks. +- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed. +- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated). +- See `docs/.i18n/README.md`. +- The pipeline can be slow/inefficient; if it’s dragging, ping @jospalmbier on Discord instead of hacking around it. + +## exe.dev VM ops (general) + +- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set). +- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops. +- Update: `sudo npm i -g openclaw@latest` (global install needs root on `/usr/lib/node_modules`). +- Config: use `openclaw config set ...`; ensure `gateway.mode=local` is set. +- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix). +- Restart: stop old gateway and run: + `pkill -9 -f openclaw-gateway || true; nohup openclaw gateway run --bind loopback --port 18789 --force > /tmp/openclaw-gateway.log 2>&1 &` +- Verify: `openclaw channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/openclaw-gateway.log`. + +## Build, Test, and Development Commands + +- Runtime baseline: Node **22+** (keep Node + Bun paths working). +- Install deps: `pnpm install` +- If deps are missing (for example `node_modules` missing, `vitest not found`, or `command not found`), run the repo’s package-manager install command (prefer lockfile/README-defined PM), then rerun the exact requested command once. Apply this to test/build/lint/typecheck/dev commands; if retry still fails, report the command and first actionable error. +- Pre-commit hooks: `prek install` (runs same checks as CI) +- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches). +- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun ` / `bunx `. +- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`. +- Node remains supported for running built output (`dist/*`) and production installs. +- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`. +- Type-check/build: `pnpm build` +- TypeScript checks: `pnpm tsgo` +- Lint/format: `pnpm check` +- Format check: `pnpm format` (oxfmt --check) +- Format fix: `pnpm format:fix` (oxfmt --write) +- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage` + +## Coding Style & Naming Conventions + +- Language: TypeScript (ESM). Prefer strict typing; avoid `any`. +- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits. +- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required. +- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck. +- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed. +- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required. +- Add brief code comments for tricky or non-obvious logic. +- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`. +- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability. +- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys. + +## Release Channels (Naming) + +- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`. +- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app). +- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-` and `vYYYY.M.D.beta.N` remain recognized. +- dev: moving head on `main` (no tag; git checkout main). + +## Testing Guidelines + +- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements). +- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`. +- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic. +- Do not set test workers above 16; tried already. +- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs. +- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`. +- Full kit + what’s covered: `docs/testing.md`. +- Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process). +- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one. +- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available. + +## Commit & Pull Request Guidelines + +**Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW. + +- Create commits with `scripts/committer "" `; avoid manual `git add`/`git commit` so staging stays scoped. +- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`). +- Group related changes; avoid bundling unrelated refactors. +- PR submission template (canonical): `.github/pull_request_template.md` +- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/` + +## Shorthand Commands + +- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`. + +## Git Notes + +- If `git branch -d/-D ` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/`. +- Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query. + +## GitHub Search (`gh`) + +- Prefer targeted keyword search before proposing new work or duplicating fixes. +- Use `--repo openclaw/openclaw` + `--match title,body` first; add `--match comments` when triaging follow-up threads. +- PRs: `gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"` +- Issues: `gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"` +- Structured output example: + `gh search issues --repo openclaw/openclaw --match title,body --limit 50 --json number,title,state,url,updatedAt -- "auto update" --jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'` + +## Security & Configuration Tips + +- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out. +- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable. +- Environment variables: see `~/.profile`. +- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. +- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them. + +## GHSA (Repo Advisory) Patch/Publish + +- Before reviewing security advisories, read `SECURITY.md`. +- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/` +- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"` +- Private fork PRs must be closed: + `fork=$(gh api /repos/openclaw/openclaw/security-advisories/ | jq -r .private_fork.full_name)` + `gh pr list -R "$fork" --state open` (must be empty) +- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings) +- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json` +- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls. +- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/ --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint) +- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs +- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing + +## Troubleshooting + +- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`). + +## Agent-Specific Notes + +- Vocabulary: "makeup" = "mac app". +- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`. +- When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`). +- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`. +- When working on a GitHub Issue or PR, print the full URL at the end of the task. +- When answering questions, respond with high-confidence answers only: verify in code; do not guess. +- Never update the Carbon dependency. +- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`). +- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default. +- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars. +- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes. +- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.** +- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the OpenClaw subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`. +- If shared guardrails are available locally, review them; otherwise follow this repo's guidance. +- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code. +- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync. +- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION). +- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release). +- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch. +- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators. +- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`. +- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit. +- Release signing/notary keys are managed outside the repo; follow internal release docs. +- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs). +- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes. +- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks. +- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested. +- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested. +- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session. +- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those. +- Lint/format churn: + - If staged+unstaged diffs are formatting-only, auto-resolve without asking. + - If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation. + - Only ask when changes are semantic (logic/data/behavior). +- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed. +- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant. +- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. +- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed). +- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`. +- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema. +- When asked to open a “session” file, open the Pi session logs under `~/.openclaw/agents//sessions/*.jsonl` (use the `agent=` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there. +- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac. +- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel. +- Voice wake forwarding tips: + - Command template should stay `openclaw-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes. + - launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`. +- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping. +- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step. +- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked. + +## NPM + 1Password (publish/verify) + +- Use the 1password skill; all `op` commands must run inside a fresh tmux session. +- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on). +- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`. +- Publish: `npm publish --access public --otp=""` (run from the package dir). +- Verify without local npmrc side effects: `npm view version --userconfig "$(mktemp)"`. +- Kill the tmux session after publish. + +## Plugin Release Fast Path (no core `openclaw` publish) + +- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list". +- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption: + - `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)` + - `eval "$(op signin --account my.1password.com)"` +- 1Password helpers: + - password used by `npm login`: + `op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'` + - OTP: + `op read 'op://Private/Npmjs/one-time password?attribute=otp'` +- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean): + - compare local plugin `version` to `npm view version` + - only run `npm publish --access public --otp=""` when versions differ + - skip if package is missing on npm or version already matches. +- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested. +- Post-check for each release: + - per-plugin: `npm view @openclaw/ version --userconfig "$(mktemp)"` should be `2026.2.17` + - core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested. + +## Changelog Release Notes + +- When cutting a mac release with beta GitHub prerelease: + - Tag `vYYYY.M.D-beta.N` from the release commit (example: `v2026.2.15-beta.1`). + - Create prerelease with title `openclaw YYYY.M.D-beta.N`. + - Use release notes from `CHANGELOG.md` version section (`Changes` + `Fixes`, no title duplicate). + - Attach at least `OpenClaw-YYYY.M.D.zip` and `OpenClaw-YYYY.M.D.dSYM.zip`; include `.dmg` if available. + +- Keep top version entries in `CHANGELOG.md` sorted by impact: + - `### Changes` first. + - `### Fixes` deduped and ranked with user-facing fixes first. +- Before tagging/publishing, run: + - `node --import tsx scripts/release-check.ts` + - `pnpm release:check` + - `pnpm test:install:smoke` or `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` for non-root smoke path. diff --git a/backend/app/one_person_security_dept/openclaw/CHANGELOG.md b/backend/app/one_person_security_dept/openclaw/CHANGELOG.md new file mode 100644 index 00000000..9eeac161 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/CHANGELOG.md @@ -0,0 +1,2984 @@ +# Changelog + +Docs: https://docs.openclaw.ai + +## 2026.2.25 (Unreleased) + +### Changes + +- Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus. +- Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. + +### Fixes + +- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3. +- Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3. +- Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3. +- Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3. +- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3. +- Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231) +- Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. +- Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. +- Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. +- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck. +- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3. +- Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman. +- Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode. + +## 2026.2.24 + +### Changes + +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms), and treat exact `do not do that` as a stop trigger while preserving strict standalone matching. (#25103) Thanks @steipete and @vincentkoc. +- Android/App UX: ship a native four-step onboarding flow, move post-onboarding into a five-tab shell (Connect, Chat, Voice, Screen, Settings), add a full Connect setup/manual mode screen, and refresh Android chat/settings surfaces for the new navigation model. +- Talk/Gateway config: add provider-agnostic Talk configuration with legacy compatibility, and expose gateway Talk ElevenLabs config metadata for setup/status surfaces. +- Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes). +- Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping `@buape/carbon` pinned. + +### Breaking + +- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages. +- **BREAKING:** Security/Sandbox: block Docker `network: "container:"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting. + +### Fixes + +- Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. +- Security/Routing: fail closed for shared-session cross-channel replies by binding outbound target resolution to the current turn’s source channel metadata (instead of stale session route fallbacks), and wire those turn-source fields through gateway + command delivery planners with regression coverage. (#24571) Thanks @brandonwise. +- Heartbeat routing: prevent heartbeat leakage/spam into Discord and other direct-message destinations by blocking direct-chat heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871) +- Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851) +- Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr. +- Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl. +- Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch. +- Channels/Typing keepalive: refresh channel typing callbacks on a keepalive interval during long replies and clear keepalive timers on idle/cleanup across core + extension dispatcher callsites so typing indicators do not expire mid-inference. (#25886, #25882) Thanks @stakeswky. +- Agents/Model fallback: when a run is currently on a configured fallback model, keep traversing the configured fallback chain instead of collapsing straight to primary-only, preventing dead-end failures when primary stays in cooldown. (#25922, #25912) Thanks @Taskle. +- Gateway/Models: honor explicit `agents.defaults.models` allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in `models.list`, and allow `sessions.patch`/`/model` selection for those refs without false `model not allowed` errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc. +- Control UI/Agents: inherit `agents.defaults.model.fallbacks` in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko. +- Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. +- Discord/Voice reliability: restore runtime DAVE dependency (`@snazzah/davey`), add configurable DAVE join options (`channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance`), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032) +- Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. +- Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr. +- WhatsApp/Web reconnect: treat close status `440` as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson. +- WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with `Reasoning:` before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328) +- Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. +- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. +- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. +- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis. +- Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko. +- Android/Gateway auth: preserve Android gateway auth state across onboarding, use the native client id for operator sessions, retry with shared-token fallback after device-token auth failures, and avoid clearing tokens on transient connect errors. +- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. +- Zalo/Group policy: enforce sender authorization for group messages with `groupPolicy` + `groupAllowFrom` (fallback to `allowFrom`), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting. +- macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001. +- macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl. +- macOS/Voice wake routing: default forwarded voice-wake transcripts to the `webchat` channel (instead of ambiguous `last` routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18. +- macOS/Gateway launch: prefer an available `openclaw` binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18. +- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. +- macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos. +- Windows/Exec shell selection: prefer PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing `&&` command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x. +- Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. +- iMessage/Reasoning safety: harden iMessage echo suppression with outbound `messageId` matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb. +- Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix. +- Providers/Google reasoning: sanitize invalid negative `thinkingBudget` payloads for Gemini 3.1 requests by dropping `-1` budgets and mapping configured reasoning effort to `thinkingLevel`, preventing malformed reasoning payloads on `google-generative-ai`. (#25900) +- Providers/SiliconFlow: normalize `thinking="off"` to `thinking: null` for `Pro/*` model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru. +- Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. +- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. +- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. +- CLI/Memory search: accept `--query ` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky. +- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. +- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. +- Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid `plugins.entries.` writes when ids differ. (#25275) Thanks @zerone0x. +- Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. +- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. +- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. +- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. +- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis. +- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. +- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. +- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. +- Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian. +- Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. +- Sandbox/Config: preserve `dangerouslyAllowReservedContainerTargets` and `dangerouslyAllowExternalBindSources` during sandbox docker config resolution so explicit bind-mount break-glass overrides reach runtime validation. (#25410) Thanks @skyer-jian. +- Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3. +- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. +- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. +- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd. +- Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3. +- Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width `HOOK:...`) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3. +- Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3. +- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting. +- Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3. +- Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting. +- Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. Thanks @v8hid for reporting. +- Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. Thanks @tdjackey for reporting. +- Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so `dmPolicy: "allowlist"` with empty `allowedUserIds` rejects all senders instead of allowing unauthorized dispatch. (#25827) Thanks @bmendonca3 for the contribution and @tdjackey for reporting. +- Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. Thanks @tdjackey for reporting. +- Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. Thanks @tdjackey for reporting. +- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting. +- Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting. +- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting. +- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. +- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis. +- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis. +- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. +- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. +- Gateway/Sessions: preserve `modelProvider` on `sessions.reset` and avoid incorrect provider prefixes for legacy session models. (#25874) Thanks @lbo728. +- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. +- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. +- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. +- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. +- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. +- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. +- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. +- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. +- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. +- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. +- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. +- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. +- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd. +- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. +- CLI/Memory search: accept `--query ` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky. +- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey. + +## 2026.2.23 + +### Changes + +- Providers/Kilo Gateway: add first-class `kilocode` provider support (auth, onboarding, implicit provider detection, model defaults, transcript/cache-ttl handling, and docs), with default model `kilocode/anthropic/claude-opus-4.6`. (#20212) Thanks @jrf0110 and @markijbema. +- Providers/Vercel AI Gateway: accept Claude shorthand model refs (`vercel-ai-gateway/claude-*`) by normalizing to canonical Anthropic-routed model ids. (#23985) Thanks @sallyom, @markbooch, and @vincentkoc. +- Docs/Prompt caching: add a dedicated prompt-caching reference covering `cacheRetention`, per-agent `params` merge precedence, Bedrock/OpenRouter behavior, and cache-ttl + heartbeat tuning. Thanks @svenssonaxel. +- Gateway/HTTP security headers: add optional `gateway.http.securityHeaders.strictTransportSecurity` support to emit `Strict-Transport-Security` for direct HTTPS deployments, with runtime wiring, validation, tests, and hardening docs. +- Sessions/Cron: harden session maintenance with `openclaw sessions cleanup`, per-agent store targeting, disk-budget controls (`session.maintenance.maxDiskBytes` / `highWaterBytes`), and safer transcript/archive cleanup + run-log retention behavior. (#24753) thanks @gumadeiras. +- Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#16616, #18822) Thanks @adshine. +- Media understanding/Video: add a native Moonshot video provider and include Moonshot in auto video key detection, plus refactor video execution to honor `entry/config/provider` baseUrl+header precedence (matching audio behavior). (#12063) Thanks @xiaoyaner0201. +- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed. +- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera. + +### Breaking + +- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically. + +### Fixes + +- Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305. +- Tests/Vitest: tier local parallel worker defaults by host memory, keep gateway serial by default on non-high-memory hosts, and document a low-profile fallback command for memory-constrained land/gate runs to prevent local OOMs. (#24719) Thanks @ngutman. +- WhatsApp/Group policy: fix `groupAllowFrom` sender filtering when `groupPolicy: "allowlist"` is set without explicit `groups` — previously all group messages were blocked even for allowlisted senders. (#24670) +- Agents/Context pruning: extend `cache-ttl` eligibility to Moonshot/Kimi and ZAI/GLM providers (including OpenRouter model refs), so `contextPruning.mode: "cache-ttl"` is no longer silently skipped for those sessions. (#24497) Thanks @lailoo. +- Doctor/Memory: query gateway-side default-agent memory embedding readiness during `openclaw doctor` (instead of inferring from generic gateway health), and warn when the gateway memory probe is unavailable or not ready while keeping `openclaw configure` remediation guidance. (#22327) thanks @therk. +- Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. +- Telegram/Reactions: soft-fail reaction action errors (policy/token/emoji/API), accept snake_case `message_id`, and fallback to inbound message-id context when explicit `messageId` is omitted so DM reactions stay stable without regeneration loops. (#20236, #21001) Thanks @PeterShanxin and @vincentkoc. +- Telegram/Polling: scope persisted polling offsets to bot identity and reuse a single awaited runner-stop path on abort/retry, preventing cross-token offset bleed and overlapping pollers during restart/error recovery. (#10850, #11347) Thanks @talhaorak, @anooprdawar, and @vincentkoc. +- Telegram/Reasoning: when `/reasoning off` is active, suppress reasoning-only delivery segments and block raw fallback resend of suppressed `Reasoning:`/`` text, preventing internal reasoning leakage in legacy sessions while preserving answer delivery. (#24626, #24518) +- Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051. +- Agents/Reasoning: avoid classifying provider reasoning-required errors as context overflows so these failures no longer trigger compaction-style overflow recovery. (#24593) Thanks @vincentkoc. +- Agents/Models: codify `agents.defaults.model` / `agents.defaults.imageModel` config-boundary input as `string | {primary,fallbacks}`, split explicit vs effective model resolution, and fix `models status --agent` source attribution so defaults-inherited agents are labeled as `defaults` while runtime selection still honors defaults fallback. (#24210) thanks @bianbiandashen. +- Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg. +- Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc. +- Agents/Compaction: cancel safeguard compaction when summary generation cannot run (missing model/API key or summarization failure), preserving history instead of truncating to fallback `"Summary unavailable"` text. (#10711) Thanks @DukeDeSouth and @vincentkoc. +- Agents/Tools: make `session_status` read transcript-derived usage mid-turn and tail-read session logs for cache-aware context reporting without full-log scans. (#22387) Thanks @1ucian. +- Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg. +- Agents/Overflow: add Chinese context-overflow pattern detection in `isContextOverflowError` so localized provider errors route through overflow recovery paths. (#22855) Thanks @Clawborn. +- Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc. +- Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316. +- Auto-reply/Inbound metadata: move dynamic inbound `flags` (reply/forward/thread/history) from system metadata to user-context conversation info, preventing turn-by-turn prompt-cache invalidation from flag toggles. (#21785) Thanks @aidiffuser. +- Auto-reply/Sessions: remove auth-key labels from `/new` and `/reset` confirmation messages so session reset notices never expose API key prefixes or env-key labels in chat output. (#24384, #24409) Thanks @Clawborn. +- Slack/Group policy: move Slack account `groupPolicy` defaulting to provider-level schema defaults so multi-account configs inherit top-level `channels.slack.groupPolicy` instead of silently overriding inheritance with per-account `allowlist`. (#17579) Thanks @ZetiMente. +- Providers/Anthropic: skip `context-1m-*` beta injection for OAuth/subscription tokens (`sk-ant-oat-*`) while preserving OAuth-required betas, avoiding Anthropic 401 auth failures when `params.context1m` is enabled. (#10647, #20354) Thanks @ClumsyWizardHands and @dcruver. +- Providers/DashScope: mark DashScope-compatible `openai-completions` endpoints as `supportsDeveloperRole=false` so OpenClaw sends `system` instead of unsupported `developer` role on Qwen/DashScope APIs. (#19130) Thanks @Putzhuawa and @vincentkoc. +- Providers/Bedrock: disable prompt-cache retention for non-Anthropic Bedrock models so Nova/Mistral requests do not send unsupported cache metadata. (#20866) Thanks @pierreeurope. +- Providers/Bedrock: apply Anthropic-Claude cacheRetention defaults and runtime pass-through for `amazon-bedrock/*anthropic.claude*` model refs, while keeping non-Anthropic Bedrock models excluded. (#22303) Thanks @snese. +- Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm. +- Providers/Groq: avoid classifying Groq TPM limit errors as context overflow so throttling paths no longer trigger overflow recovery logic. (#16176) Thanks @dddabtc. +- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc. +- Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras. +- Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn. +- Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263) +- Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc. +- Security/ACP: harden ACP client permission auto-approval to require trusted core tool IDs, ignore untrusted `toolCall.kind` hints, and scope `read` auto-approval to the active working directory so unknown tool names and out-of-scope file reads always prompt. Thanks @nedlir for reporting. +- Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x. +- Security/Skills: harden `skill-creator` packaging by skipping symlink entries and rejecting files whose resolved paths escape the selected skill root. (#24260, #16959) Thanks @CornBrother0x and @vincentkoc. +- Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise. +- Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. +- Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. +- Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc. + +## 2026.2.22 + +### Changes + +- Control UI/Agents: make the Tools panel data-driven from runtime `tools.catalog`, add per-tool provenance labels (`core` / `plugin:` + optional marker), and keep a static fallback list when the runtime catalog is unavailable. +- Web Search/Gemini: add grounded Gemini provider support with provider auto-detection and config/docs updates. (#13075, #13074) Thanks @akoscz. +- Control UI/Cron: add full web cron edit parity (including clone and richer validation/help text), plus all-jobs run history with pagination/search/sort/multi-filter controls and improved cron page layout for cleaner scheduling and failure triage workflows. +- Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc. +- Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence. +- CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting. +- Config/UI: add tag-aware settings filtering and broaden config labels/help copy so fields are easier to discover and understand in the dashboard config screen. +- Channels/Synology Chat: add a native Synology Chat channel plugin with webhook ingress, direct-message routing, outbound send/media support, per-account config, and DM policy controls. (#23012) +- iOS/Talk: prefetch TTS segments and suppress expected speech-cancellation errors for smoother talk playback. (#22833) Thanks @ngutman. +- Memory/FTS: add Spanish and Portuguese stop-word filtering for query expansion in FTS-only search mode, improving conversational recall for both languages. Thanks @vincentkoc. +- Memory/FTS: add Japanese-aware query expansion tokenization and stop-word filtering (including mixed-script terms like ASCII + katakana) for FTS-only search mode. Thanks @vincentkoc. +- Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang. +- Memory/FTS: add Arabic stop-word filtering for query expansion in FTS-only search mode to reduce conversational filler in Arabic memory searches. Thanks @vincentkoc. +- Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior. +- Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path. +- Gateway/Auth: unify call/probe/status/auth credential-source precedence on shared resolver helpers, with table-driven parity coverage across gateway entrypoints. +- Gateway/Auth: refactor gateway credential resolution and websocket auth handshake paths to use shared typed auth contexts, including explicit `auth.deviceToken` support in connect frames and tests. +- Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead. +- Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz. + +### Breaking + +- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers. +- **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`. +- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3. +- **BREAKING:** unify channel preview-streaming config to `channels..streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names. +- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected. + +### Fixes + +- Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc. +- Sessions/Paths: resolve symlinked state-dir aliases during transcript-path validation while preserving safe cross-agent/state-root compatibility for valid `agents//sessions/**` paths. (#18593) Thanks @EpaL and @vincentkoc. +- Agents/Compaction: count auto-compactions only after a non-retry `auto_compaction_end`, keeping session `compactionCount` aligned to completed compactions. +- Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo. +- Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC. +- Agents/Kimi: classify Moonshot `Your request exceeded model token limit` failures as context overflows so auto-compaction and user-facing overflow recovery trigger correctly instead of surfacing raw invalid-request errors. (#9562) Thanks @danilofalcao. +- Providers/Moonshot: mark Kimi K2.5 as image-capable in implicit + onboarding model definitions, and refresh stale explicit provider capability fields (`input`/`reasoning`/context limits) from implicit catalogs so existing configs pick up Moonshot vision support without manual model rewrites. (#13135, #4459) Thanks @manikv12. +- Agents/Transcript: enable consecutive-user turn merging for strict non-OpenAI `openai-completions` providers (for example Moonshot/Kimi), reducing `roles must alternate` ordering failures on OpenAI-compatible endpoints while preserving current OpenRouter/Opencode behavior. (#7693) +- Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman. +- Docker/Setup: precreate `$OPENCLAW_CONFIG_DIR/identity` during `docker-setup.sh` so CLI commands that need device identity (for example `devices list`) avoid `EACCES ... /home/node/.openclaw/identity` failures on restrictive bind mounts. (#23948) Thanks @ackson-beep. +- Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303) +- Slack/Threading: sessions: keep parent-session forking and thread-history context active beyond first turn by removing first-turn-only gates in session init, thread-history fetch, and reply prompt context injection. (#23843, #23090) Thanks @vincentkoc and @Taskle. +- Slack/Threading: respect `replyToMode` when Slack auto-populates top-level `thread_ts`, and ignore inline `replyToId` directive tags when `replyToMode` is `off` so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan. +- Slack/Extension: forward `message read` `threadId` to `readMessages` and use delivery-context `threadId` as outbound `thread_ts` fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan. +- Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via `conversations.open` before calling `files.uploadV2`, which rejects non-channel IDs. `chat.postMessage` tolerates user IDs directly, but `files.uploadV2` → `completeUploadExternal` validates `channel_id` against `^[CGDZ][A-Z0-9]{8,}$`, causing `invalid_arguments` when agents reply with media to DM conversations. +- Webchat/Chat: apply assistant `final` payload messages directly to chat state so sent turns render without waiting for a full history refresh cycle. (#14928) Thanks @BradGroux. +- Webchat/Chat: for out-of-band final events (for example tool-call side runs), append provided final assistant payloads directly instead of forcing a transient history reset. (#11139) Thanks @AkshayNavle. +- Webchat/Performance: reload `chat.history` after final events only when the final payload lacks a renderable assistant message, avoiding expensive full-history refreshes on normal turns. (#20588) Thanks @amzzzzzzz. +- Webchat/Sessions: preserve external session routing metadata when internal `chat.send` turns run under `webchat`, so explicit channel-keyed sessions (for example Telegram) no longer get rewritten to `webchat` and misroute follow-up delivery. (#23258) Thanks @binary64. +- Webchat/Sessions: preserve existing session `label` across `/new` and `/reset` rollovers so reset sessions remain discoverable in session history lists. (#23755) Thanks @ThunderStormer. +- Gateway/Chat UI: strip inline reply/audio directive tags from non-streaming final webchat broadcasts (including `chat.inject`) while preserving empty-string message content when tags are the entire reply. (#23298) Thanks @SidQin-cyber. +- Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces. +- Gateway/Chat UI: sanitize non-streaming final `chat.send`/`chat.inject` payload text with the same envelope/untrusted-context stripping used by `chat.history`, preventing `<<>>` wrapper markup from rendering in Control UI chat. (#24012) Thanks @mittelaltergouda. +- Telegram/Media: send a user-facing Telegram reply when media download fails (non-size errors) instead of silently dropping the message. +- Telegram/Webhook: keep webhook monitors alive until gateway abort signals fire, preventing false channel exits and immediate webhook auto-restart loops. +- Telegram/Polling: retry recoverable setup-time network failures in monitor startup and await runner teardown before retry to avoid overlapping polling sessions. +- Telegram/Polling: clear Telegram webhooks (`deleteWebhook`) before starting long-poll `getUpdates`, including retry handling for transient cleanup failures. +- Telegram/Webhook: add `channels.telegram.webhookPort` config support and pass it through plugin startup wiring to the monitor listener. +- Browser/Extension Relay: refactor the MV3 worker to preserve debugger attachments across relay drops, auto-reconnect with bounded backoff+jitter, persist and rehydrate attached tab state via `chrome.storage.session`, recover from `target_closed` navigation detaches, guard stale socket handlers, enforce per-tab operation locks and per-request timeouts, and add lifecycle keepalive/badge refresh hooks (`alarms`, `webNavigation`). (#15099, #6175, #8468, #9807) +- Browser/Relay: treat extension websocket as connected only when `OPEN`, allow reconnect when a stale `CLOSING/CLOSED` extension socket lingers, and guard stale socket message/close handlers so late events cannot clear active relay state; includes regression coverage for live-duplicate `409` rejection and immediate reconnect-after-close races. (#15099, #18698, #20688) +- Browser/Remote CDP: extend stale-target recovery so `ensureTabAvailable()` now reuses the sole available tab for remote CDP profiles (same behavior as extension profiles) while preserving strict `tab not found` errors when multiple tabs exist; includes remote-profile regression tests. (#15989) +- Gateway/Pairing: treat `operator.admin` as satisfying other `operator.*` scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt. +- Gateway/Pairing: auto-approve loopback `scope-upgrade` pairing requests (including device-token reconnects) so local clients do not disconnect on pairing-required scope elevation. (#23708) Thanks @widingmarcus-cyber. +- Gateway/Scopes: include `operator.read` and `operator.write` in default operator connect scope bundles across CLI, Control UI, and macOS clients so write-scoped announce/sub-agent follow-up calls no longer hit `pairing required` disconnects on loopback gateways. (#22582) thanks @YuzuruS. +- Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07. +- Gateway/Restart: fix restart-loop edge cases by keeping `openclaw.mjs -> dist/entry.js` bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli. +- Gateway/Lock: use optional gateway-port reachability as a primary stale-lock liveness signal (and wire gateway run-loop lock acquisition to the resolved port), reducing false "already running" lockouts after unclean exits. (#23760) Thanks @Operative-001. +- Delivery/Queue: quarantine queue entries immediately on known permanent delivery errors (for example invalid recipients or missing conversation references) by moving them to `failed/` instead of retrying on every restart. (#23794) Thanks @aldoeliacim. +- Cron/Status: split execution outcome (`lastRunStatus`) from delivery outcome (`lastDeliveryStatus`) in persisted cron state, finished events, and run history so failed/unknown announcement delivery is visible without conflating it with run errors. +- Cron/Delivery: route text-only announce jobs with explicit thread/topic targets through direct outbound delivery so forum/thread destinations do not get dropped by intermediary announce turns. (#23841) Thanks @AndrewArto. +- Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman. +- Cron/Run: enforce the same per-job timeout guard for manual `cron.run` executions as timer-driven runs, including abort propagation for isolated agent jobs, so forced runs cannot wedge indefinitely. (#23704) Thanks @tkuehnl. +- Cron/Run: persist the manual-run `runningAtMs` marker before releasing the cron lock so overlapping timer ticks cannot start the same job concurrently. +- Cron/Startup: enforce per-job timeout guards for startup catch-up replay runs so missed isolated jobs cannot hang indefinitely during gateway boot recovery. +- Cron/Main session: honor abort/timeout signals while retrying `wakeMode=now` heartbeat contention loops so main-target cron runs stop promptly instead of waiting through the full busy-retry window. +- Cron/Schedule: for `every` jobs, prefer `lastRunAtMs + everyMs` when still in the future after restarts, then fall back to anchor scheduling for catch-up windows, so NEXT timing matches the last successful cadence. (#22895) Thanks @SidQin-cyber. +- Cron/Service: execute manual `cron.run` jobs outside the cron lock (while still persisting started/finished state atomically) so `cron.list` and `cron.status` remain responsive during long forced runs. (#23628) Thanks @dsgraves. +- Cron/Timer: keep a watchdog recheck timer armed while `onTimer` is actively executing so the scheduler continues polling even if a due-run tick stalls for an extended period. (#23628) Thanks @dsgraves. +- Cron/Run log: clean up settled per-path run-log write queue entries so long-running cron uptime does not retain stale promise bookkeeping in memory. +- Cron/Run log: harden `cron.runs` run-log path resolution by rejecting path-separator `id`/`jobId` inputs and enforcing reads within the per-cron `runs/` directory. +- Cron/Announce: when announce delivery target resolution fails (for example multiple configured channels with no explicit target), skip injecting fallback `Cron (error): ...` into the main session so runs fail cleanly without accidental last-route sends. (#24074) +- Cron/Telegram: validate cron `delivery.to` with shared Telegram target parsing and resolve legacy `@username`/`t.me` targets to numeric IDs at send-time for deterministic delivery target writeback. (#21930) Thanks @kesor. +- Telegram/Targets: normalize unprefixed topic-qualified targets through the shared parse/normalize path so valid `@channel:topic:` and `:topic:` routes are recognized again. (#24166) Thanks @obviyus. +- Cron/Isolation: force fresh session IDs for isolated cron runs so `sessionTarget="isolated"` executions never reuse prior run context. (#23470) Thanks @echoVic. +- Plugins/Install: strip `workspace:*` devDependency entries from copied plugin manifests before `npm install --omit=dev`, preventing `EUNSUPPORTEDPROTOCOL` install failures for npm-published channel plugins (including Feishu and MS Teams). +- Feishu/Plugins: restore bundled Feishu SDK availability for global installs and strip `openclaw: workspace:*` from plugin `devDependencies` during plugin-version sync so npm-installed Feishu plugins do not fail dependency install. (#23611, #23645, #23603) +- Config/Channels: auto-enable built-in channels by writing `channels..enabled=true` (not `plugins.entries.`), and stop adding built-ins to `plugins.allow`, preventing `plugins.entries.telegram: plugin not found` validation failures. +- Config/Channels: when `plugins.allow` is active, auto-enable/enable flows now also allowlist configured built-in channels so `channels..enabled=true` cannot remain blocked by restrictive plugin allowlists. +- Plugins/Discovery: ignore scanned extension backup/disabled directory patterns (for example `.backup-*`, `.bak`, `.disabled*`) and move updater backup directories under `.openclaw-install-backups`, preventing duplicate plugin-id collisions from archived copies. +- Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl. +- Security/Voice Call: harden media stream WebSocket handling against pre-auth idle-connection DoS by adding strict pre-start timeouts, pending/per-IP connection limits, and total connection caps for streaming endpoints. Thanks @jiseoung for reporting. +- Security/Sessions: redact sensitive token patterns from `sessions_history` tool output and surface `contentRedacted` metadata when masking occurs. (#16928) Thanks @aether-ai-agent. +- Security/Exec: stop trusting `PATH`-derived directories for safe-bin allowlist checks, add explicit `tools.exec.safeBinTrustedDirs`, and pin safe-bin shell execution to resolved absolute executable paths to prevent binary-shadowing approval bypasses. Thanks @tdjackey for reporting. +- Security/Elevated: match `tools.elevated.allowFrom` against sender identities only (not recipient `ctx.To`), closing a recipient-token bypass for `/elevated` authorization. Thanks @jiseoung for reporting. +- Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. Thanks @jiseoung for reporting. +- Security/Group policy: harden `channels.*.groups.*.toolsBySender` matching by requiring explicit sender-key types (`id:`, `e164:`, `username:`, `name:`), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. Thanks @jiseoung for reporting. +- Channels/Group policy: fail closed when `groupPolicy: "allowlist"` is set without explicit `groups`, honor account-level `groupPolicy` overrides, and enforce `groupPolicy: "disabled"` as a hard group block. (#22215) Thanks @etereo. +- Telegram/Discord extensions: propagate trusted `mediaLocalRoots` through extension outbound `sendMedia` options so extension direct-send media paths honor agent-scoped local-media allowlists. (#20029, #21903, #23227) +- Agents/Exec: honor explicit agent context when resolving `tools.exec` defaults for runs with opaque/non-agent session keys, so per-agent `host/security/ask` policies are applied consistently. (#11832) +- CLI/Sessions: resolve implicit session-store path templates with the configured default agent ID so named-agent setups do not silently read/write stale `agent:main` session/auth stores. (#22685) Thanks @sene1337. +- Doctor/Security: add an explicit warning that `approvals.exec.enabled=false` disables forwarding only, while enforcement remains driven by host-local `exec-approvals.json` policy. (#15047) +- Sandbox/Docker: default sandbox container user to the workspace owner `uid:gid` when `agents.*.sandbox.docker.user` is unset, fixing non-root gateway file-tool permissions under capability-dropped containers. (#20979) +- Plugins/Media sandbox: propagate trusted `mediaLocalRoots` through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718) +- Agents/Workspace guard: map sandbox container-workdir file-tool paths (for example `/workspace/...` and `file:///workspace/...`) to host workspace roots before workspace-only validation, preventing false `Path escapes sandbox root` rejections for sandbox file tools. (#9560) +- Gateway/Exec approvals: expire approval requests immediately when no approval-capable gateway clients are connected and no forwarding targets are available, avoiding delayed approvals after restarts/offline approver windows. (#22144) +- Security/Exec approvals: when approving wrapper commands with allow-always in allowlist mode, persist inner executable paths for known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) and fail closed (no persisted entry) when wrapper unwrapping is not safe, preventing wrapper-path approval bypasses. Thanks @tdjackey for reporting. +- Node/macOS exec host: default headless macOS node `system.run` to local execution and only route through the companion app when `OPENCLAW_NODE_EXEC_HOST=app` is explicitly set, avoiding companion-app filesystem namespace mismatches during exec. (#23547) +- Sandbox/Media: map container workspace paths (`/workspace/...` and `file:///workspace/...`) back to the host sandbox root for outbound media validation, preventing false deny errors for sandbox-generated local media. (#23083) Thanks @echo931. +- Sandbox/Docker: apply custom bind mounts after workspace mounts and prioritize bind-source resolution on overlapping paths, so explicit workspace binds are no longer ignored. (#22669) Thanks @tasaankaeris. +- Exec approvals/Forwarding: restore Discord text forwarding when component approvals are not configured, and carry request snapshots through resolve events so resolved notices still forward after cache misses/restarts. (#22988) Thanks @bubmiller. +- Control UI/WebSocket: stop and clear the browser gateway client on UI teardown so remounts cannot leave orphan websocket clients that create duplicate active connections. (#23422) Thanks @floatinggball-design. +- Control UI/WebSocket: send a stable per-tab `instanceId` in websocket connect frames so reconnect cycles keep a consistent client identity for diagnostics and presence tracking. (#23616) Thanks @zq58855371-ui. +- Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake. +- Feishu/Commands: in group chats, command authorization now falls back to top-level `channels.feishu.allowFrom` when per-group `allowFrom` is not set, so `/command` no longer gets blocked by an unintended empty allowlist. (#23756) +- Dev tooling: prevent `CLAUDE.md` symlink target regressions by excluding CLAUDE symlink sentinels from `oxfmt` and marking them `-text` in `.gitattributes`, so formatter/EOL normalization cannot reintroduce trailing-newline targets. Thanks @vincentkoc. +- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg. +- Feishu/Media: for inbound video messages that include both `file_key` (video) and `image_key` (thumbnail), prefer `file_key` when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633) +- Hooks/Loader: avoid redundant hook-module recompilation on gateway restart by skipping cache-busting for bundled hooks and using stable file metadata keys (`mtime+size`) for mutable workspace/managed/plugin hook imports. (#16953) Thanks @mudrii. +- Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark `SILENT_REPLY_TOKEN` (`NO_REPLY`) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks. +- Providers/OpenRouter: inject `cache_control` on system prompts for OpenRouter Anthropic models to improve prompt-cache reuse. (#17473) Thanks @rrenamed. +- Installer/Smoke tests: remove legacy `OPENCLAW_USE_GUM` overrides from docker install-smoke runs so tests exercise installer auto TTY detection behavior directly. +- Providers/OpenRouter: allow pass-through OpenRouter and Opencode model IDs in live model filtering so custom routed model IDs are treated as modern refs. (#14312) Thanks @Joly0. +- Providers/OpenRouter: default reasoning to enabled when the selected model advertises `reasoning: true` and no session/directive override is set. (#22513) Thanks @zwffff. +- Providers/OpenRouter: map `/think` levels to `reasoning.effort` in embedded runs while preserving explicit `reasoning.max_tokens` payloads. (#17236) Thanks @robbyczgw-cla. +- Providers/OpenRouter: preserve stored session provider when model IDs are vendor-prefixed (for example, `anthropic/...`) so follow-up turns do not incorrectly route to direct provider APIs. (#22753) Thanks @dndodson. +- Providers/OpenRouter: preserve the required `openrouter/` prefix for OpenRouter-native model IDs during model-ref normalization. (#12942) Thanks @omair445. +- Providers/OpenRouter: pass through provider routing parameters from model params.provider to OpenRouter request payloads for provider selection controls. (#17148) Thanks @carrotRakko. +- Providers/OpenRouter: preserve model allowlist entries containing OpenRouter preset paths (for example `openrouter/@preset/...`) by treating `/model ...@profile` auth-profile parsing as a suffix-only override. (#14120) Thanks @NotMainstream. +- Cron/Auth: propagate auth-profile resolution to isolated cron sessions so provider API keys are resolved the same way as main sessions, fixing 401 errors when using providers configured via auth-profiles. (#20689) Thanks @lailoo. +- Cron/Follow-up: pass resolved `agentDir` through isolated cron and queued follow-up embedded runs so auth/profile lookups stay scoped to the correct agent directory. (#22845) Thanks @seilk. +- Agents/Media: route tool-result `MEDIA:` extraction through shared parser validation so malformed prose like `MEDIA:-prefixed ...` is no longer treated as a local file path (prevents Telegram ENOENT tool-error overrides). (#18780) Thanks @HOYALIM. +- Logging: cap single log-file size with `logging.maxFileBytes` (default 500 MB) and suppress additional writes after cap hit to prevent disk exhaustion from repeated error storms. +- Memory/Remote HTTP: centralize remote memory HTTP calls behind a shared guarded helper (`withRemoteHttpResponse`) so embeddings and batch flows use one request/release path. +- Memory/Embeddings: apply configured remote-base host pinning (`allowedHostnames`) across OpenAI/Voyage/Gemini embedding requests to keep private/self-hosted endpoints working without cross-host drift. (#18198) Thanks @ianpcook. +- Memory/Batch: route OpenAI/Voyage/Gemini batch upload/create/status/download requests through the same guarded HTTP path for consistent SSRF policy enforcement. +- Memory/Index: detect memory source-set changes (for example enabling `sessions` after an existing memory-only index) and trigger a full reindex so existing session transcripts are indexed without requiring `--force`. (#17576) Thanks @TarsAI-Agent. +- Memory/Embeddings: enforce a per-input 8k safety cap before embedding batching and apply a conservative 2k fallback limit for local providers without declared input limits, preventing oversized session/memory chunks from triggering provider context-size failures during sync/indexing. (#6016) Thanks @batumilove. +- Memory/QMD: on Windows, resolve bare `qmd`/`mcporter` command names to npm shim executables (`.cmd`) before spawning, so qmd boot updates and mcporter-backed searches no longer fail with `spawn ... ENOENT` on default npm installs. (#23899) Thanks @arcbuilder-ai. +- Memory/QMD: parse plain-text `qmd collection list --json` output when older qmd builds ignore JSON mode, and retry memory searches once after re-ensuring managed collections when qmd returns `Collection not found ...`. (#23613) Thanks @leozhucn. +- iOS/Watch: normalize watch quick-action notification payloads, support mirrored indexed actions beyond primary/secondary, and fix iOS test-target signing/compile blockers for watch notify coverage. (#23636) Thanks @mbelinky. +- Signal/RPC: guard malformed Signal RPC JSON responses with a clear status-scoped error and add regression coverage for invalid JSON responses. (#22995) Thanks @adhitShet. +- Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early `Cannot read properties of undefined (reading 'trim')` crashes during subagent spawn and wait flows. +- Agents/Workspace: guard `resolveUserPath` against undefined/null input to prevent `Cannot read properties of undefined (reading 'trim')` crashes when workspace paths are missing in embedded runner flows. +- Auth/Profiles: keep active `cooldownUntil`/`disabledUntil` windows immutable across retries so mid-window failures cannot extend recovery indefinitely; only recompute a backoff window after the previous deadline has expired. This resolves cron/inbound retry loops that could trap gateways until manual `usageStats` cleanup. (#23516, #23536) Thanks @arosstale. +- Channels/Security: fail closed on missing provider group policy config by defaulting runtime group policy to `allowlist` (instead of inheriting `channels.defaults.groupPolicy`) when `channels.` is absent across message channels, and align runtime + security warnings/docs to the same fallback behavior (Slack, Discord, iMessage, Telegram, WhatsApp, Signal, LINE, Matrix, Mattermost, Google Chat, IRC, Nextcloud Talk, Feishu, and Zalo user flows; plus Discord message/native-command paths). (#23367) Thanks @bmendonca3. +- Gateway/Onboarding: harden remote gateway onboarding defaults and guidance by defaulting discovered direct URLs to `wss://`, rejecting insecure non-loopback `ws://` targets in onboarding validation, and expanding remote-security remediation messaging across gateway client/call/doctor flows. (#23476) Thanks @bmendonca3. +- CLI/Sessions: pass the configured sessions directory when resolving transcript paths in `agentCommand`, so custom `session.store` locations resume sessions reliably. Thanks @davidrudduck. +- Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started `signal-cli` is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn. +- Channels/Dedupe: centralize plugin dedupe primitives in plugin SDK (memory + persistent), move Feishu inbound dedupe to a namespace-scoped persistent store, and reuse shared dedupe cache logic for Zalo webhook replay + Tlon processed-message tracking to reduce duplicate handling during reconnect/replay paths. (#23377) Thanks @SidQin-cyber. +- Channels/Delivery: remove hardcoded WhatsApp delivery fallbacks; require explicit/session channel context or auto-pick the sole configured channel when unambiguous. (#23357) Thanks @lbo728. +- ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early `gateway not connected` request races. (#23390) Thanks @janckerchen. +- Gateway/Auth: preserve `OPENCLAW_GATEWAY_PASSWORD` env override precedence for remote gateway call credentials after shared resolver refactors, preventing stale configured remote passwords from overriding runtime secret rotation. +- Gateway/Auth: preserve shared-token `gateway token mismatch` auth errors when `auth.token` fallback device-token checks fail, and reserve `device token mismatch` guidance for explicit `auth.deviceToken` failures. +- Gateway/Tools: when agent tools pass an allowlisted `gatewayUrl` override, resolve local override tokens from env/config fallback but keep remote overrides strict to `gateway.remote.token`, preventing local token leakage to remote targets. +- Gateway/Client: keep cached device-auth tokens on `device token mismatch` closes when the client used explicit shared token/password credentials, avoiding accidental pairing-token churn during explicit-auth failures. +- Node host/Exec: keep strict Windows allowlist behavior for `cmd.exe /c` shell-wrapper runs, and return explicit approval guidance when blocked (`SYSTEM_RUN_DENIED: allowlist miss`). +- Control UI: show pairing-required guidance (commands + mobile tokenized URL reminder) when the dashboard disconnects with `1008 pairing required`. +- Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`). +- Security/Audit: make `gateway.real_ip_fallback_enabled` severity conditional for loopback trusted-proxy setups (warn for loopback-only `trustedProxies`, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3. +- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. Thanks @tdjackey for reporting. +- Security/Exec env: block `SHELLOPTS`/`PS4` in host exec env sanitizers and restrict shell-wrapper (`bash|sh|zsh ... -c/-lc`) request env overrides to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`) on both node host and macOS companion paths, preventing xtrace prompt command-substitution allowlist bypasses. Thanks @tdjackey for reporting. +- WhatsApp/Security: enforce `allowFrom` for direct-message outbound targets in all send modes (including `mode: "explicit"`), preventing sends to non-allowlisted numbers. (#20108) Thanks @zahlmann. +- Security/Exec approvals: fail closed on shell line continuations (`\\\n`/`\\\r\n`) and treat shell-wrapper execution as approval-required in allowlist mode, preventing `$\\` newline command-substitution bypasses. Thanks @tdjackey for reporting. +- Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including `gateway.controlUi.dangerouslyDisableDeviceAuth=true`) and point operators to `openclaw security audit`. +- Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. Thanks @aether-ai-agent for reporting. +- Security/Exec approvals: treat `env` and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. Thanks @tdjackey for reporting. +- Security/Exec approvals: require explicit safe-bin profiles for `tools.exec.safeBins` entries in allowlist mode (remove generic safe-bin profile fallback), and add `tools.exec.safeBinProfiles` for safe custom binaries so unprofiled interpreter-style entries cannot be treated as stdin-safe. Thanks @tdjackey for reporting. +- Security/Channels: harden Slack external menu token handling by switching to CSPRNG tokens, validating token shape, requiring user identity for external option lookups, and avoiding fabricated timestamp `trigger_id` fallbacks; also switch Tlon Urbit channel IDs to CSPRNG UUIDs, centralize secure ID/token generation via shared infra helpers, and add a guardrail test to block new runtime `Date.now()+Math.random()` token/id patterns. +- Security/Hooks transforms: enforce symlink-safe containment for webhook transform module paths (including `hooks.transformsDir` and `hooks.mappings[].transform.module`) by resolving existing-path ancestors via realpath before import, while preserving in-root symlink support; add regression coverage for both escape and allow cases. Thanks @aether-ai-agent for reporting. +- Telegram/WSL2: disable `autoSelectFamily` by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync `/proc/version` probes on fetch/send paths. (#21916) Thanks @MizukiMachine. +- Telegram/Network: default Node 22+ DNS result ordering to `ipv4first` for Telegram fetch paths and add `OPENCLAW_TELEGRAM_DNS_RESULT_ORDER`/`channels.telegram.network.dnsResultOrder` overrides to reduce IPv6-path fetch failures. (#5405) Thanks @Glucksberg. +- Telegram/Forward bursts: coalesce forwarded text+media updates through a dedicated forward lane debounce window that works with default inbound debounce config, while keeping forwarded control commands immediate. (#19476) thanks @napetrov. +- Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus. +- Telegram/Replies: scope messaging-tool text/media dedupe to same-target sends only, so cross-target tool sends can no longer silently suppress Telegram final replies. +- Telegram/Replies: normalize `file://` and local-path media variants during messaging dedupe so equivalent media paths do not produce duplicate Telegram replies. +- Telegram/Replies: extract forwarded-origin context from unified reply targets (`reply_to_message` and `external_reply`) so forward+comment metadata is preserved across partial reply shapes. (#9720) thanks @mcaxtr. +- Telegram/Polling: persist a safe update-offset watermark bounded by pending updates so crash/restart cannot skip queued lower `update_id` updates after out-of-order completion. (#23284) thanks @frankekn. +- Telegram/Polling: force-restart stuck runner instances when recoverable unhandled network rejections escape the polling task path, so polling resumes instead of silently stalling. (#19721) Thanks @jg-noncelogic. +- Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia. +- Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13. +- Slack/Queue routing: preserve string `thread_ts` values through collect-mode queue drain and DM `deliveryContext` updates so threaded follow-ups do not leak to the main channel when Slack thread IDs are strings. (#11934) Thanks @sandieman2 and @vincentkoc. +- Telegram/Native commands: set `ctx.Provider="telegram"` for native slash-command context so elevated gate checks resolve provider correctly (fixes `provider (ctx.Provider)` failures in `/elevated` flows). (#23748) Thanks @serhii12. +- Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester. +- Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr. +- Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged `memory.qmd.paths` and `memory.qmd.scope.rules` no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai. +- Gateway/Config reload: retry short-lived missing config snapshots during reload before skipping, preventing atomic-write unlink windows from triggering restart loops. (#23343) Thanks @lbo728. +- Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear `invalid cron schedule: expr is required` error instead of crashing with `undefined.trim` failures and auto-disable churn. (#23223) Thanks @asimons81. +- Memory/QMD: migrate legacy unscoped collection bindings (for example `memory-root`) to per-agent scoped names (for example `memory-root-main`) during startup when safe, so QMD-backed `memory_search` no longer fails with `Collection not found` after upgrades. (#23228, #20727) Thanks @JLDynamics and @AaronFaby. +- Memory/QMD: normalize Han-script BM25 search queries before invoking `qmd search` so mixed CJK+Latin prompts no longer return empty results due to tokenizer mismatch. (#23426) Thanks @LunaLee0130. +- TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends. +- TUI/RTL: isolate right-to-left script lines (Arabic/Hebrew ranges) with Unicode bidi isolation marks in TUI text sanitization so RTL assistant output no longer renders in reversed visual order in terminal chat panes. (#21936) Thanks @Asm3r96. +- TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness. +- TUI/Input: arm Ctrl+C exit timing when clearing non-empty composer text and add a SIGINT fallback path so double Ctrl+C exits remain responsive during active runs instead of requiring an extra press or appearing stuck. (#23407) Thanks @tinybluedev. +- Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane. +- Agents/Google: sanitize non-base64 `thought_signature`/`thoughtSignature` values from assistant replay transcripts for native Google Gemini requests while preserving valid signatures and tool-call order. (#23457) Thanks @echoVic. +- Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry. +- Agents/Mistral: sanitize tool-call IDs in the embedded agent loop and generate strict provider-safe pending tool-call IDs, preventing Mistral strict9 `HTTP 400` failures on tool continuations. (#23698) Thanks @echoVic. +- Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson. +- Agents/Replies: emit a default completion acknowledgement (`✅ Done.`) only for direct/private tool-only completions with no final assistant text, while suppressing synthetic acknowledgements for channel/group sessions and runs that already delivered output via messaging tools. (#22834) Thanks @Oldshue. +- Agents/Subagents: honor `tools.subagents.tools.alsoAllow` and explicit subagent `allow` entries when resolving built-in subagent deny defaults, so explicitly granted tools (for example `sessions_send`) are no longer blocked unless re-denied in `tools.subagents.tools.deny`. (#23359) Thanks @goren-beehero. +- Agents/Subagents: make announce call timeouts configurable via `agents.defaults.subagents.announceTimeoutMs` and restore a 60s default to prevent false timeout failures on slower announce paths. (#22719) Thanks @Valadon. +- Agents/Diagnostics: include resolved lifecycle error text in `embedded run agent end` warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize. +- Agents/Auth profiles: resolve `agentCommand` session scope before choosing `agentDir`/workspace so resumed runs no longer read auth from `agents/main/agent` when the resolved session belongs to a different/default agent (for example `agent:exec:*` sessions). (#24016) Thanks @abersonFAC. +- Agents/Auth profiles: skip auth-profile cooldown writes for timeout failures in embedded runner rotation so model/network timeouts do not poison same-provider fallback model selection while still allowing in-turn account rotation. (#22622) Thanks @vageeshkumar. +- Plugins/Hooks: run legacy `before_agent_start` once per agent turn and reuse that result across model-resolve and prompt-build compatibility paths, preventing duplicate hook side effects (for example duplicate external API calls). (#23289) Thanks @ksato8710. +- Models/Config: default missing Anthropic provider/model `api` fields to `anthropic-messages` during config validation so custom relay model entries are preserved instead of being dropped by runtime model registry validation. (#23332) Thanks @bigbigmonkey123. +- Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit `scopes`, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81. +- Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07. +- Infra/Network: classify undici `TypeError: fetch failed` as transient in unhandled-rejection detection even when nested causes are unclassified, preventing avoidable gateway crash loops on flaky networks. (#14345) Thanks @Unayung. +- Telegram/Retry: classify undici `TypeError: fetch failed` as recoverable in both polling and send retry paths so transient fetch failures no longer fail fast. (#16699) thanks @Glucksberg. +- Docs/Telegram: correct Node 22+ network defaults (`autoSelectFamily`, `dnsResultOrder`) and clarify Telegram setup does not use positional `openclaw channels login telegram`. (#23609) Thanks @ryanbastic. +- BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines. +- BlueBubbles/Private API cache: treat unknown (`null`) private-API cache status as disabled for send/attachment/reply flows to avoid stale-cache 500s, and log a warning when reply/effect features are requested while capability is unknown. (#23459) Thanks @echoVic. +- BlueBubbles/Webhooks: accept inbound/reaction webhook payloads when BlueBubbles omits `handle` but provides DM `chatGuid`, and harden payload extraction for array/string-wrapped message bodies so valid webhook events no longer get rejected as unparseable. (#23275) Thanks @toph31. +- Security/Audit: add `openclaw security audit` finding `gateway.nodes.allow_commands_dangerous` for risky `gateway.nodes.allowCommands` overrides, with severity upgraded to critical on remote gateway exposure. +- Gateway/Control plane: reduce cross-client write limiter contention by adding `connId` fallback keying when device ID and client IP are both unavailable. +- Security/Config: block prototype-key traversal during config merge patch and legacy migration merge helpers (`__proto__`, `constructor`, `prototype`) to prevent prototype pollution during config mutation flows. (#22968) Thanks @Clawborn. +- Security/Shell env: validate login-shell executable paths for shell-env fallback (`/etc/shells` + trusted prefixes), block `SHELL`/`HOME`/`ZDOTDIR` in config env ingestion before fallback execution, and sanitize fallback shell exec env to pin `HOME` to the real user home while dropping `ZDOTDIR` and other dangerous startup vars. Thanks @tdjackey for reporting. +- Network/SSRF: enable `autoSelectFamily` on pinned undici dispatchers (with attempt timeout) so IPv6-unreachable environments can quickly fall back to IPv4 for guarded fetch paths. (#19950) Thanks @ENAwareness. +- Security/Config: make parsed chat allowlist checks fail closed when `allowFrom` is empty, restoring expected DM/pairing gating. +- Security/Exec: in non-default setups that manually add `sort` to `tools.exec.safeBins`, block `sort --compress-program` so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting. +- Security/Exec approvals: when users choose `allow-always` for shell-wrapper commands (for example `/bin/zsh -lc ...`), persist allowlist patterns for the inner executable(s) instead of the wrapper shell binary, preventing accidental broad shell allowlisting in moderate mode. (#23276) Thanks @xrom2863. +- Security/Exec: fail closed when `tools.exec.host=sandbox` is configured/requested but sandbox runtime is unavailable. (#23398) Thanks @bmendonca3. +- Security/macOS app beta: enforce path-only `system.run` allowlist matching (drop basename matches like `echo`), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. Thanks @tdjackey for reporting. +- Security/Agents: auto-generate and persist a dedicated `commands.ownerDisplaySecret` when `commands.ownerDisplay=hash`, remove gateway token fallback from owner-ID prompt hashing across CLI and embedded agent runners, and centralize owner-display secret resolution in one shared helper. Thanks @aether-ai-agent for reporting. +- Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), centralize range checks into a single CIDR policy table, and reuse one shared host/IP classifier across literal + DNS checks to reduce classifier drift. Thanks @princeeismond-dot for reporting. +- Security/SSRF: block RFC2544 benchmarking range (`198.18.0.0/15`) across direct and embedded-IP paths, and normalize IPv6 dotted-quad transition literals (for example `::127.0.0.1`, `64:ff9b::8.8.8.8`) in shared IP parsing/classification. +- Security/Archive: block zip symlink escapes during archive extraction. +- Security/Media sandbox: keep tmp media allowance for absolute tmp paths only and enforce symlink-escape checks before sandbox-validated reads, preventing tmp symlink exfiltration and relative `../` sandbox escapes when sandboxes live under tmp. (#17892) Thanks @dashed. +- Browser/Upload: accept canonical in-root upload paths when the configured uploads directory is a symlink alias (for example `/tmp` -> `/private/tmp` on macOS), so browser upload validation no longer rejects valid files during client->server revalidation. (#23300, #23222, #22848) Thanks @bgaither4, @parkerati, and @Nabsku. +- Security/Discord: add `openclaw security audit` warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel `users`, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported, and canonicalize resolved Discord allowlist names to IDs at runtime without rewriting config files. Thanks @tdjackey for reporting. +- Security/Gateway: block node-role connections when device identity metadata is missing. +- Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. Thanks @tdjackey for reporting. +- Media/Understanding: preserve `application/pdf` MIME classification during text-like file heuristics so PDF uploads use PDF extraction paths instead of being inlined as raw text. (#23191) Thanks @claudeplay2026-byte. +- Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. Thanks @tdjackey for reporting. +- Security/Gateway avatars: block symlink traversal during local avatar `data:` URL resolution by enforcing realpath containment and file-identity checks before reads. Thanks @tdjackey for reporting. +- Security/Control UI: centralize avatar URL/path validation across gateway/config helpers and enforce a 2 MB max size for local agent avatar files before `/avatar` resolution, reducing oversized-avatar memory risk without changing supported avatar formats. +- Security/Control UI avatars: harden `/avatar/:agentId` local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. Thanks @tdjackey for reporting. +- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. Thanks @tdjackey for reporting. +- Security/MSTeams media: route attachment auth-retry and Graph SharePoint download redirects through shared `safeFetch` so each hop is validated with allowlist + DNS/IP checks across the full redirect chain. (#23598) Thanks @Asm3r96 and @lewiswigmore. +- Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3. +- Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs. +- CI/Tests: fix TypeScript case-table typing and lint assertion regressions so `pnpm check` passes again after Synology Chat landing. (#23012) Thanks @druide67. +- Security/Browser relay: harden extension relay auth token handling for `/extension` and `/cdp` pathways. +- Cron: persist `delivered` state in cron job records so delivery failures remain visible in status and logs. (#19174) Thanks @simonemacario. +- Config/Doctor: only repair the OAuth credentials directory when affected channels are configured, avoiding fresh-install noise. +- Config/Channels: whitelist `channels.modelByChannel` in config validation and exclude it from plugin auto-enable channel detection so model overrides no longer trigger `unknown channel id` validation errors or bogus `modelByChannel` plugin enables. (#23412) Thanks @ProspectOre. +- Config/Bindings: allow optional `bindings[].comment` in strict config validation so annotated binding entries no longer fail load. (#23458) Thanks @echoVic. +- Usage/Pricing: correct MiniMax M2.5 pricing defaults to fix inflated cost reporting. (#22755) Thanks @miloudbelarebia. +- Gateway/Daemon: verify gateway health after daemon restart. +- Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam. + +## 2026.2.21 + +### Changes + +- Models/Google: add Gemini 3.1 support (`google/gemini-3.1-pro-preview`). +- Providers/Onboarding: add Volcano Engine (Doubao) and BytePlus providers/models (including coding variants), wire onboarding auth choices for interactive + non-interactive flows, and align docs to `volcengine-api-key`. (#7967) Thanks @funmore123. +- Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin. +- Channels: allow per-channel model overrides via `channels.modelByChannel` and note them in /status. Thanks @thewilloftheshadow. +- Telegram/Streaming: simplify preview streaming config to `channels.telegram.streaming` (boolean), auto-map legacy `streamMode` values, and remove block-vs-partial preview branching. (#22012) thanks @obviyus. +- Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it. +- Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow. +- Discord/Voice: add voice channel join/leave/status via `/vc`, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow. +- Discord: add configurable ephemeral defaults for slash-command responses. (#16563) Thanks @wei. +- Discord: support updating forum `available_tags` via channel edit actions for forum tag management. (#12070) Thanks @xiaoyaner0201. +- Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow. +- Discord/Subagents: add thread-bound subagent sessions on Discord with per-thread focus/list controls and thread-bound continuation routing for spawned helper agents. (#21805) Thanks @onutc. +- iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky. +- iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky. +- iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky. +- Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant. +- MSTeams: dedupe sent-message cache storage by removing duplicate per-message Set storage and using timestamps Map keys as the single membership source. (#22514) Thanks @TaKO8Ki. +- Agents/Subagents: default subagent spawn depth now uses shared `maxSpawnDepth=2`, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204. +- Security/Agents: make owner-ID obfuscation use a dedicated HMAC secret from configuration (`ownerDisplaySecret`) and update hashing behavior so obfuscation is decoupled from gateway token handling for improved control. (#7343) Thanks @vincentkoc. +- Security/Infra: switch gateway lock and tool-call synthetic IDs from SHA-1 to SHA-256 with unchanged truncation length to strengthen hash basis while keeping deterministic behavior and lock key format. (#7343) Thanks @vincentkoc. +- Dependencies/Tooling: add non-blocking dead-code scans in CI via Knip/ts-prune/ts-unused-exports to surface unused dependencies and exports earlier. (#22468) Thanks @vincentkoc. +- Dependencies/Unused Dependencies: remove or scope unused root and extension deps (`@larksuiteoapi/node-sdk`, `signal-utils`, `ollama`, `lit`, `@lit/context`, `@lit-labs/signals`, `@microsoft/agents-hosting-express`, `@microsoft/agents-hosting-extensions-teams`, and plugin-local `openclaw` devDeps in `extensions/open-prose`, `extensions/lobster`, and `extensions/llm-task`). (#22471, #22495) Thanks @vincentkoc. +- Dependencies/A2UI: harden dependency resolution after root cleanup (resolve `lit`, `@lit/context`, `@lit-labs/signals`, and `signal-utils` from workspace/root) and simplify bundling fallback behavior, including `pnpm dlx rolldown` compatibility. (#22481, #22507) Thanks @vincentkoc. + +### Fixes + +- Agents/Bootstrap: skip malformed bootstrap files with missing/invalid paths instead of crashing agent sessions; hooks using `filePath` (or non-string `path`) are skipped with a warning. (#22693, #22698) Thanks @arosstale. +- Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit `retry_limit` error payload when retries never converge, preventing unbounded internal retry cycles (`GHSA-76m6-pj3w-v7mf`). +- Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless `getUpdates` conflict loops. +- Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files. +- Providers/OAuth: harden Qwen and Chutes refresh handling by validating refresh response expiry values and preserving prior refresh tokens when providers return empty refresh token fields, with regression coverage for empty-token responses. +- Models/Kimi-Coding: add missing implicit provider template for `kimi-coding` with correct `anthropic-messages` API type and base URL, fixing 403 errors when using Kimi for Coding. (#22409) +- Auto-reply/Tools: forward `senderIsOwner` through embedded queued/followup runner params so owner-only tools remain available for authorized senders. (#22296) thanks @hcoj. +- Discord: restore model picker back navigation when a provider is missing and document the Discord picker flow. (#21458) Thanks @pejmanjohn and @thewilloftheshadow. +- Memory/QMD: respect per-agent `memorySearch.enabled=false` during gateway QMD startup initialization, split multi-collection QMD searches into per-collection queries (`search`/`vsearch`/`query`) to avoid sparse-term drops, prefer collection-hinted doc resolution to avoid stale-hash collisions, retry boot updates on transient lock/timeout failures, skip `qmd embed` in BM25-only `search` mode (including `memory index --force`), and serialize embed runs globally with failure backoff to prevent CPU storms on multi-agent hosts. (#20581, #21590, #20513, #20001, #21266, #21583, #20346, #19493) Thanks @danielrevivo, @zanderkrause, @sunyan034-cmd, @tilleulenspiegel, @dae-oss, @adamlongcreativellc, @jonathanadams96, and @kiliansitel. +- Memory/Builtin: prevent automatic sync races with manager shutdown by skipping post-close sync starts and waiting for in-flight sync before closing SQLite, so `onSearch`/`onSessionStart` no longer fail with `database is not open` in ephemeral CLI flows. (#20556, #7464) Thanks @FuzzyTG and @henrybottter. +- Providers/Copilot: drop persisted assistant `thinking` blocks for Claude models (while preserving turn structure/tool blocks) so follow-up requests no longer fail on invalid `thinkingSignature` payloads. (#19459) Thanks @jackheuberger. +- Providers/Copilot: add `claude-sonnet-4.6` and `claude-sonnet-4.5` to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn. +- Auto-reply/WebChat: avoid defaulting inbound runtime channel labels to unrelated providers (for example `whatsapp`) for webchat sessions so channel-specific formatting guidance stays accurate. (#21534) Thanks @lbo728. +- Status: include persisted `cacheRead`/`cacheWrite` in session summaries so compact `/status` output consistently shows cache hit percentages from real session data. +- Sessions/Usage: persist `totalTokens` from `promptTokens` snapshots even when providers omit structured usage payloads, so session history/status no longer regress to `unknown` token utilization for otherwise successful runs. (#21819) Thanks @zymclaw. +- Heartbeat/Cron: restore interval heartbeat behavior so missing `HEARTBEAT.md` no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths. +- WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured `allowFrom` recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats. +- Heartbeat/Active hours: constrain active-hours `24` sentinel parsing to `24:00` in time validation so invalid values like `24:30` are rejected early. (#21410) thanks @adhitShet. +- Heartbeat: treat `activeHours` windows with identical `start`/`end` times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet. +- CLI/Pairing: default `pairing list` and `pairing approve` to the sole available pairing channel when omitted, so TUI-only setups can recover from `pairing required` without guessing channel arguments. (#21527) Thanks @losts1. +- TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return `pairing required`, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux. +- TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer. +- TUI/Models: scope `models.list` to the configured model allowlist (`agents.defaults.models`) so `/model` picker no longer floods with unrelated catalog entries by default. (#18816) Thanks @fwends. +- TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when `showOk` is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton. +- TUI/History: cap chat-log component growth and prune stale render nodes/references so large default history loads no longer overflow render recursion with `RangeError: Maximum call stack size exceeded`. (#18068) Thanks @JaniJegoroff. +- Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr. +- Memory/Tools: return explicit `unavailable` warnings/actions from `memory_search` when embedding/provider failures occur (including quota exhaustion), so disabled memory does not look like an empty recall result. (#21894) Thanks @XBS9. +- Session/Startup: require the `/new` and `/reset` greeting path to run Session Startup file-reading instructions before responding, so daily memory startup context is not skipped on fresh-session greetings. (#22338) Thanks @armstrong-pv. +- Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii. +- Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0. +- Slack: pass `recipient_team_id` / `recipient_user_id` through Slack native streaming calls so `chat.startStream`/`appendStream`/`stopStream` work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012. +- CLI/Config: add canonical `--strict-json` parsing for `config set` and keep `--json` as a legacy alias to reduce help/behavior drift. (#21332) thanks @adhitShet. +- CLI/Config: preserve explicitly unset config paths in persisted JSON after writes so `openclaw config unset ` no longer re-introduces defaulted keys (for example `commands.ownerDisplay`) through schema normalization. (#22984) Thanks @aronchick. +- CLI: keep `openclaw -v` as a root-only version alias so subcommand `-v, --verbose` flags (for example ACP/hooks/skills) are no longer intercepted globally. (#21303) thanks @adhitShet. +- Memory: return empty snippets when `memory_get`/QMD read files that have not been created yet, and harden memory indexing/session helpers against ENOENT races so missing Markdown no longer crashes tools. (#20680) Thanks @pahdo. +- Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii. +- Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal `` tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus. +- Telegram/Streaming: restore 30-char first-preview debounce and scope `NO_REPLY` prefix suppression to partial sentinel fragments so normal `No...` text is not filtered. (#22613) thanks @obviyus. +- Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow. +- Telegram/Status reactions: keep lifecycle reactions active when available-reactions lookup fails by falling back to unrestricted variant selection instead of suppressing reaction updates. (#22380) thanks @obviyus. +- Discord/Events: await `DiscordMessageListener` message handlers so regular `MESSAGE_CREATE` traffic is processed through queue ordering/timeout flow instead of fire-and-forget drops. (#22396) Thanks @sIlENtbuffER. +- Discord/Streaming: apply `replyToMode: first` only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report. +- Discord/Components: map DM channel targets back to user-scoped component sessions so button/select interactions stay in the main DM session. Thanks @thewilloftheshadow. +- Discord/Allowlist: lazy-load guild lists when resolving Discord user allowlists so ID-only entries resolve even if guild fetch fails. (#20208) Thanks @zhangjunmengyang. +- Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow. +- Discord: ingest inbound stickers as media so sticker-only messages and forwarded stickers are visible to agents. Thanks @thewilloftheshadow. +- Auto-reply/Runner: emit `onAgentRunStart` only after agent lifecycle or tool activity begins (and only once per run), so fallback preflight errors no longer mark runs as started. (#21165) Thanks @shakkernerd. +- Auto-reply/Tool results: serialize tool-result delivery and keep the delivery chain progressing after individual failures so concurrent tool outputs preserve user-visible ordering. (#21231) thanks @ahdernasr. +- Auto-reply/Prompt caching: restore prefix-cache stability by keeping inbound system metadata session-stable and moving per-message IDs (`message_id`, `message_id_full`, `reply_to_id`, `sender_id`) into untrusted conversation context. (#20597) Thanks @anisoptera. +- iOS/Watch: add actionable watch approval/reject controls and quick-reply actions so watch-originated approvals and responses can be sent directly from notification flows. (#21996) Thanks @mbelinky. +- iOS/Watch: refresh iOS and watch app icon assets with the lobster icon set to keep phone/watch branding aligned. (#21997) Thanks @mbelinky. +- CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate `/v1` paths during setup checks. (#21336) Thanks @17jmumford. +- iOS/Gateway/Tools: prefer uniquely connected node matches when duplicate display names exist, surface actionable `nodes invoke` pairing-required guidance with request IDs, and refresh active iOS gateway registration after location-capability setting changes so capability updates apply immediately. (#22120) thanks @mbelinky. +- Gateway/Auth: require `gateway.trustedProxies` to include a loopback proxy address when `auth.mode="trusted-proxy"` and `bind="loopback"`, preventing same-host proxy misconfiguration from silently blocking auth. (#22082, follow-up to #20097) thanks @mbelinky. +- Gateway/Auth: allow trusted-proxy mode with loopback bind for same-host reverse-proxy deployments, while still requiring configured `gateway.trustedProxies`. (#20097) thanks @xinhuagu. +- Gateway/Auth: allow authenticated clients across roles/scopes to call `health` while preserving role and scope enforcement for non-health methods. (#19699) thanks @Nachx639. +- Gateway/Hooks: include transform export name in hook-transform cache keys so distinct exports from the same module do not reuse the wrong cached transform function. (#13855) thanks @mcaxtr. +- Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr. +- Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek. +- Gateway/Pairing: clear persisted paired-device state when the gateway client closes with `device token mismatch` (`1008`) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky. +- Gateway/Config: allow `gateway.customBindHost` in strict config validation when `gateway.bind="custom"` so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420. +- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant. +- Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd. +- Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman. +- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg. +- Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204. +- Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow. +- Agents/Tool display: fix exec cwd suffix inference so `pushd ... && popd ... && ` does not keep stale `(in )` context in summaries. (#21925) Thanks @Lukavyi. +- Agents/Google: flatten residual nested `anyOf`/`oneOf` unions in Gemini tool-schema cleanup so Cloud Code Assist no longer rejects unsupported union keywords that survive earlier simplification. (#22825) Thanks @Oceanswave. +- Tools/web_search: handle xAI Responses API payloads that emit top-level `output_text` blocks (without a `message` wrapper) so Grok web_search no longer returns `No response` for those results. (#20508) Thanks @echoVic. +- Agents/Failover: treat non-default override runs as direct fallback-to-configured-primary (skip configured fallback chain), normalize default-model detection for provider casing/whitespace, and add regression coverage for override/auth error paths. (#18820) Thanks @Glucksberg. +- Docker/Build: include `ownerDisplay` in `CommandsSchema` object-level defaults so Docker `pnpm build` no longer fails with `TS2769` during plugin SDK d.ts generation. (#22558) Thanks @obviyus. +- Docker/Browser: install Playwright Chromium into `/home/node/.cache/ms-playwright` and set `node:node` ownership so browser binaries are available to the runtime user in browser-enabled images. (#22585) thanks @obviyus. +- Hooks/Session memory: trigger bundled `session-memory` persistence on both `/new` and `/reset` so reset flows no longer skip markdown transcript capture before archival. (#21382) Thanks @mofesolapaul. +- Dependencies/Agents: bump embedded Pi SDK packages (`@mariozechner/pi-agent-core`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, `@mariozechner/pi-tui`) to `0.54.0`. (#21578) Thanks @Takhoffman. +- Config/Agents: expose Pi compaction tuning values `agents.defaults.compaction.reserveTokens` and `agents.defaults.compaction.keepRecentTokens` in config schema/types and apply them in embedded Pi runner settings overrides with floor enforcement via `reserveTokensFloor`. (#21568) Thanks @Takhoffman. +- Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek. +- Docker: run build steps as the `node` user and use `COPY --chown` to avoid recursive ownership changes, trimming image size and layer churn. Thanks @huntharo. +- Config/Memory: restore schema help/label metadata for hybrid `mmr` and `temporalDecay` settings so configuration surfaces show correct names and guidance. (#18786) Thanks @rodrigouroz. +- Skills/SonosCLI: add troubleshooting guidance for `sonos discover` failures on macOS direct mode (`sendto: no route to host`) and sandbox network restrictions (`bind: operation not permitted`). (#21316) Thanks @huntharo. +- macOS/Build: default release packaging to `BUNDLE_ID=ai.openclaw.mac` in `scripts/package-mac-dist.sh`, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit. +- Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson. +- Anthropic/Agents: preserve required pi-ai default OAuth beta headers when `context1m` injects `anthropic-beta`, preventing 401 auth failures for `sk-ant-oat-*` tokens. (#19789, fixes #19769) Thanks @minupla. +- Security/Exec: block unquoted heredoc body expansion tokens in shell allowlist analysis, reject unterminated heredocs, and require explicit approval for allowlisted heredoc execution on gateway hosts to prevent heredoc substitution allowlist bypass. Thanks @torturado for reporting. +- macOS/Security: evaluate `system.run` allowlists per shell segment in macOS node runtime and companion exec host (including chained shell operators), fail closed on shell/process substitution parsing, and require explicit approval on unsafe parse cases to prevent allowlist bypass via `rawCommand` chaining. Thanks @tdjackey for reporting. +- WhatsApp/Security: enforce allowlist JID authorization for reaction actions so authenticated callers cannot target non-allowlisted chats by forging `chatJid` + valid `messageId` pairs. Thanks @aether-ai-agent for reporting. +- ACP/Security: escape control and delimiter characters in ACP `resource_link` title/URI metadata before prompt interpolation to prevent metadata-driven prompt injection through resource links. Thanks @aether-ai-agent for reporting. +- TTS/Security: make model-driven provider switching opt-in by default (`messages.tts.modelOverrides.allowProvider=false` unless explicitly enabled), while keeping voice/style overrides available, to reduce prompt-injection-driven provider hops and unexpected TTS cost escalation. Thanks @aether-ai-agent for reporting. +- Security/Agents: keep overflow compaction retry budgeting global across tool-result truncation recovery so successful truncation cannot reset the overflow retry counter and amplify retry/cost cycles. Thanks @aether-ai-agent for reporting. +- BlueBubbles/Security: require webhook token authentication for all BlueBubbles webhook requests (including loopback/proxied setups), removing passwordless webhook fallback behavior. Thanks @zpbrent. +- iOS/Security: force `https://` for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky. +- Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow. +- Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) Thanks @coygeek and @Vasco0x4 for reporting. +- Gateway/Security: scope tokenless Tailscale forwarded-header auth to Control UI websocket auth only, so HTTP gateway routes still require token/password even on trusted hosts. Thanks @zpbrent for reporting. +- Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow. +- Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow. +- Security/Commands: block prototype-key injection in runtime `/debug` overrides and require own-property checks for gated command flags (`bash`, `config`, `debug`) so inherited prototype values cannot enable privileged commands. Thanks @tdjackey for reporting. +- Security/Browser: block non-network browser navigation protocols (including `file:`, `data:`, and `javascript:`) while preserving `about:blank`, preventing local file reads via browser tool navigation. Thanks @q1uf3ng for reporting. +- Security/Exec: block shell startup-file env injection (`BASH_ENV`, `ENV`, `BASH_FUNC_*`, `LD_*`, `DYLD_*`) across config env ingestion, node-host inherited environment sanitization, and macOS exec host runtime to prevent pre-command execution from attacker-controlled environment variables. Thanks @tdjackey. +- Security/Exec (Windows): canonicalize `cmd.exe /c` command text across validation, approval binding, and audit/event rendering to prevent trailing-argument approval mismatches in `system.run`. Thanks @tdjackey for reporting. +- Security/Gateway/Hooks: block `__proto__`, `constructor`, and `prototype` traversal in webhook template path resolution to prevent prototype-chain payload data leakage in `messageTemplate` rendering. (#22213) Thanks @SleuthCo. +- Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc. +- Security/OpenClawKit/UI: strip inbound metadata blocks from user messages in TUI rendering while preserving user-authored content. (#22345) Thanks @kansodata, @vincentkoc. +- Security/OpenClawKit/UI: prevent inbound metadata leaks and reply-tag streaming artifacts in TUI rendering by stripping untrusted metadata prefixes at display boundaries. (#22346) Thanks @akramcodez, @vincentkoc. +- Security/Agents: restrict local MEDIA tool attachments to core tools and the OpenClaw temp root to prevent untrusted MCP tool file exfiltration. Thanks @NucleiAv and @thewilloftheshadow. +- Security/Net: strip sensitive headers (`Authorization`, `Proxy-Authorization`, `Cookie`, `Cookie2`) on cross-origin redirects in `fetchWithSsrFGuard` to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm. +- Security/Systemd: reject CR/LF in systemd unit environment values and fix argument escaping so generated units cannot be injected with extra directives. Thanks @thewilloftheshadow. +- Security/Tools: add per-wrapper random IDs to untrusted-content markers from `wrapExternalContent`/`wrapWebContent`, preventing marker spoofing from escaping content boundaries. (#19009) Thanks @Whoaa512. +- Shared/Security: reject insecure deep links that use `ws://` non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky. +- macOS/Security: reject non-loopback `ws://` remote gateway URLs in macOS remote config to block insecure plaintext websocket endpoints. (#21971) Thanks @mbelinky. +- Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky. +- Security/Dependencies: bump transitive `hono` usage to `4.11.10` to incorporate timing-safe authentication comparison hardening for `basicAuth`/`bearerAuth` (`GHSA-gq3j-xvxp-8hrf`). Thanks @vincentkoc. +- Security/Gateway: parse `X-Forwarded-For` with trust-preserving semantics when requests come from configured trusted proxies, preventing proxy-chain spoofing from influencing client IP classification and rate-limit identity. Thanks @AnthonyDiSanti and @vincentkoc. +- Security/Sandbox: remove default `--no-sandbox` for the browser container entrypoint, add explicit opt-in via `OPENCLAW_BROWSER_NO_SANDBOX` / `CLAWDBOT_BROWSER_NO_SANDBOX`, and add security-audit checks for stale/missing sandbox browser Docker hash labels. Thanks @TerminalsandCoffee and @vincentkoc. +- Security/Sandbox Browser: require VNC password auth for noVNC observer sessions in the sandbox browser entrypoint, plumb per-container noVNC passwords from runtime, and emit short-lived noVNC observer token URLs while keeping loopback-only host port publishing. Thanks @TerminalsandCoffee for reporting. +- Security/Sandbox Browser: default browser sandbox containers to a dedicated Docker network (`openclaw-sandbox-browser`), add optional CDP ingress source-range restrictions, auto-create missing dedicated networks, and warn in `openclaw security --audit` when browser sandboxing runs on bridge without source-range limits. Thanks @TerminalsandCoffee for reporting. + +## 2026.2.19 + +### Changes + +- iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky. +- iOS/Gateway: wake disconnected iOS nodes via APNs before `nodes.invoke` and auto-reconnect gateway sessions on silent push wake to reduce invoke failures while the app is backgrounded. (#20332) Thanks @mbelinky. +- Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky. +- iOS/APNs: add push registration and notification-signing configuration for node delivery. (#20308) Thanks @mbelinky. +- Gateway/APNs: add a push-test pipeline for APNs delivery validation in gateway flows. (#20307) Thanks @mbelinky. +- Security/Audit: add `gateway.http.no_auth` findings when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable, with loopback warning and remote-exposure critical severity, plus regression coverage and docs updates. +- Skills: harden coding-agent skill guidance by removing shell-command examples that interpolate untrusted issue text directly into command strings. +- Dev tooling: align `oxfmt` local/CI formatting behavior. (#12579) Thanks @vincentkoc. + +### Fixes + +- Security: strip hidden text from `web_fetch` extracted content to prevent indirect prompt injection, covering CSS-hidden elements, class-based hiding (sr-only, d-none, etc.), invisible Unicode, color:transparent, offscreen transforms, and non-content tags. (#8027, #21074) Thanks @hydro13 for the fix and @LucasAIBuilder for reporting. +- Agents/Streaming: keep assistant partial streaming active during reasoning streams, handle native `thinking_*` stream events consistently, dedupe mixed reasoning-end signals, and clear stale mutating tool errors after same-target retry success. (#20635) Thanks @obviyus. +- iOS/Chat: use a dedicated iOS chat session key for ChatSheet routing to avoid cross-client session collisions with main-session traffic. (#21139) thanks @mbelinky. +- iOS/Chat: auto-resync chat history after reconnect sequence gaps, clear stale pending runs, and avoid dead-end manual refresh errors after transient disconnects. (#21135) thanks @mbelinky. +- UI/Usage: reload usage data immediately when timezone changes so Local/UTC toggles apply the selected date range without requiring a manual refresh. (#17774) +- iOS/Screen: move `WKWebView` lifecycle ownership into `ScreenWebView` coordinator and explicit attach/detach flow to reduce gesture/lifecycle crash risk (`__NSArrayM insertObject:atIndex:` paths) during screen tab updates. (#20366) Thanks @ngutman. +- iOS/Onboarding: prevent pairing-status flicker during auto-resume by keeping resumed state transitions stable. (#20310) Thanks @mbelinky. +- iOS/Onboarding: stabilize pairing and reconnect behavior by resetting stale pairing request state on manual retry, disconnecting both operator and node gateways on operator failure, and avoiding duplicate pairing loops from operator transport identity attachment. (#20056) Thanks @mbelinky. +- iOS/Signing: restore local auto-selected signing-team overrides during iOS project generation by wiring `.local-signing.xcconfig` into the active signing config and emitting `OPENCLAW_DEVELOPMENT_TEAM` in local signing setup. (#19993) Thanks @ngutman. +- Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. +- Telegram: keep media-group processing resilient by skipping recoverable per-item download failures while still failing loud on non-recoverable media errors. (#20598) thanks @mcaxtr. +- Telegram/Agents: gate exec/bash tool-failure warnings behind verbose mode so default Telegram replies stay clean while verbose sessions still surface diagnostics. (#20560) Thanks @obviyus. +- Telegram/Cron/Heartbeat: honor explicit Telegram topic targets in cron and heartbeat delivery (`:topic:`) so scheduled sends land in the configured topic instead of the last active thread. (#19367) Thanks @Lukavyi. +- Telegram/DM routing: prevent DM inbound origin metadata from leaking into main-session `lastRoute` updates and normalize DM `lastRoute.to` to provider-prefixed `telegram:`. (#19491) thanks @guirguispierre. +- Gateway/Daemon: forward `TMPDIR` into installed service environments so macOS LaunchAgent gateway runs can open SQLite temp/journal files reliably instead of failing with `SQLITE_CANTOPEN`. (#20512) Thanks @Clawborn. +- Agents/Billing: include the active model that produced a billing error in user-facing billing messages (for example, `OpenAI (gpt-5.3)`) across payload, failover, and lifecycle error paths, so users can identify exactly which key needs credits. (#20510) Thanks @echoVic. +- Gateway/TUI: honor `agents.defaults.blockStreamingDefault` for `chat.send` by removing the hardcoded block-streaming disable override, so replies can use configured block-mode delivery. (#19693) Thanks @neipor. +- UI/Sessions: accept the canonical main session-key alias in Chat UI flows so main-session routing stays consistent. (#20311) Thanks @mbelinky. +- OpenClawKit/Protocol: preserve JSON boolean literals (`true`/`false`) when bridging through `AnyCodable` so Apple client RPC params no longer re-encode booleans as `1`/`0`. Thanks @mbelinky. +- Commands/Doctor: skip embedding-provider warnings when `memory.backend` is `qmd`, because QMD manages embeddings internally and does not require `memorySearch` providers. (#17263) Thanks @miloudbelarebia. +- Canvas/A2UI: improve bundled-asset resolution and empty-state handling so UI fallbacks render reliably. (#20312) Thanks @mbelinky. +- Commands/Doctor: avoid rewriting invalid configs with new `gateway.auth.token` defaults during repair and only write when real config changes are detected, preventing accidental token duplication and backup churn. +- Gateway/Auth: default unresolved gateway auth to token mode with startup auto-generation/persistence of `gateway.auth.token`, while allowing explicit `gateway.auth.mode: "none"` for intentional open loopback setups. (#20686) thanks @gumadeiras. +- Channels/Matrix: fix mention detection for `formatted_body` Matrix-to links by handling matrix.to mention formats consistently. (#16941) Thanks @zerone0x. +- Heartbeat/Cron: skip interval heartbeats when `HEARTBEAT.md` is missing or empty and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos. +- Browser/Relay: reuse an already-running extension relay when the relay port is occupied by another OpenClaw process, while still failing on non-relay port collisions to avoid masking unrelated listeners. (#20035) Thanks @mbelinky. +- Scripts: update clawdock helper command support to include `docker-compose.extra.yml` where available. (#17094) Thanks @zerone0x. +- Lobster/Config: remove Lobster executable-path overrides (`lobsterPath`), require PATH-based execution, and add focused Windows wrapper-resolution tests to keep shell-free behavior stable. +- Gateway/WebChat: block `sessions.patch` and `sessions.delete` for WebChat clients so session-store mutations stay restricted to non-WebChat operator flows. Thanks @allsmog for reporting. +- Gateway: clarify launchctl GUI domain bootstrap failure on macOS. (#13795) Thanks @vincentkoc. +- Lobster/CI: fix flaky test Windows cmd shim script resolution. (#20833) Thanks @vincentkoc. +- Browser/Relay: require gateway-token auth on both `/extension` and `/cdp`, and align Chrome extension setup to use a single `gateway.auth.token` input for relay authentication. Thanks @tdjackey for reporting. +- Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr. +- Protocol/Apple: regenerate Swift gateway models for `push.test` so `pnpm protocol:check` stays green on main. Thanks @mbelinky. +- Sandbox/Registry: serialize container and browser registry writes with shared file locks and atomic replacement to prevent lost updates and delete rollback races from desyncing `sandbox list`, `prune`, and `recreate --all`. Thanks @kexinoh. +- OTEL/diagnostics-otel: complete OpenTelemetry v2 API migration. (#12897) Thanks @vincentkoc. +- Cron/Webhooks: protect cron webhook POST delivery with SSRF-guarded outbound fetch (`fetchWithSsrFGuard`) to block private/metadata destinations before request dispatch. Thanks @Adam55A-code. +- Security/Voice Call: harden `voice-call` telephony TTS override merging by blocking unsafe deep-merge keys (`__proto__`, `prototype`, `constructor`) and add regression coverage for top-level and nested prototype-pollution payloads. +- Security/Windows Daemon: harden Scheduled Task `gateway.cmd` generation by quoting cmd metacharacter arguments, escaping `%`/`!` expansions, and rejecting CR/LF in arguments, descriptions, and environment assignments (`set "KEY=VALUE"`), preventing command injection in Windows daemon startup scripts. Thanks @tdjackey for reporting. +- Security/Gateway/Canvas: replace shared-IP fallback auth with node-scoped session capability URLs for `/__openclaw__/canvas/*` and `/__openclaw__/a2ui/*`, fail closed when trusted-proxy requests omit forwarded client headers, and add IPv6/proxy-header regression coverage. Thanks @aether-ai-agent for reporting. +- Security/Net: enforce strict dotted-decimal IPv4 literals in SSRF checks and fail closed on unsupported legacy forms (octal/hex/short/packed, for example `0177.0.0.1`, `127.1`, `2130706433`) before DNS lookup. +- Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting. +- Security/ACP+Exec: add `openclaw acp --token-file/--password-file` secret-file support (with inline secret flag warnings), redact ACP working-directory prefixes to `~` home-relative paths, constrain exec script preflight file inspection to the effective `workdir` boundary, and add security-audit warnings when `tools.exec.host="sandbox"` is configured while sandbox mode is off. +- Security/Plugins/Hooks: enforce runtime/package path containment with realpath checks so `openclaw.extensions`, `openclaw.hooks`, and hook handler modules cannot escape their trusted roots via traversal or symlinks. +- Security/Discord: centralize trusted sender checks for moderation actions in message-action dispatch, share moderation command parsing across handlers, and clarify permission helpers with explicit any/all semantics. +- Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage. +- Security/ACP: bound ACP prompt text payloads to 2 MiB before gateway forwarding, account for join separator bytes during pre-concatenation size checks, and avoid stale active-run session state when oversized prompts are rejected. Thanks @aether-ai-agent for reporting. +- Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift. +- Security/Plugins: harden plugin discovery by blocking unsafe candidates (root escapes, world-writable paths, suspicious ownership), add startup warnings when `plugins.allow` is empty with discoverable non-bundled plugins, and warn on loaded plugins without install/load-path provenance. +- Security/Gateway: rate-limit control-plane write RPCs (`config.apply`, `config.patch`, `update.run`) to 3 requests per minute per `deviceId+clientIp`, add restart single-flight coalescing plus a 30-second restart cooldown, and log actor/device/ip with changed-path audit details for config/update-triggered restarts. +- Security/Webhooks: harden Feishu and Zalo webhook ingress with webhook-mode token preconditions, loopback-default Feishu bind host, JSON content-type enforcement, per-path rate limiting, replay dedupe for Zalo events, constant-time Zalo secret comparison, and anomaly status counters. +- Security/Plugins: for the next npm release, clarify plugin trust boundary and keep `runtime.system.runCommandWithTimeout` available by default for trusted in-process plugins. Thanks @markmusson for reporting. +- Security/Skills: for the next npm release, reject symlinks during skill packaging to prevent external file inclusion in distributed `.skill` archives. Thanks @aether-ai-agent for reporting. +- Security/Gateway: fail startup when `hooks.token` matches `gateway.auth.token` so hooks and gateway token reuse is rejected at boot. (#20813) Thanks @coygeek. +- Security/Network: block plaintext `ws://` connections to non-loopback hosts and require secure websocket transport elsewhere. (#20803) Thanks @jscaldwell55. +- Security/Config: parse frontmatter YAML using the YAML 1.2 core schema to avoid implicit coercion of `on`/`off`-style values. (#20857) Thanks @davidrudduck. +- Security/Discord: escape backticks in exec-approval embed content to prevent markdown formatting injection via command text. (#20854) Thanks @davidrudduck. +- Security/Agents: replace shell-based `execSync` usage with `execFileSync` in command lookup helpers to eliminate shell argument interpolation risk. (#20655) Thanks @mahanandhi. +- Security/Media: use `crypto.randomBytes()` for temp file names and set owner-only permissions for TTS temp files. (#20654) Thanks @mahanandhi. +- Security/Gateway: set baseline security headers (`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`) on gateway HTTP responses. (#10526) Thanks @abdelsfane. +- Security/iMessage: harden remote attachment SSH/SCP handling by requiring strict host-key verification, validating `channels.imessage.remoteHost` as `host`/`user@host`, and rejecting unsafe host tokens from config or auto-detection. Thanks @allsmog for reporting. +- Security/Feishu: prevent path traversal in Feishu inbound media temp-file writes by replacing key-derived temp filenames with UUID-based names. Thanks @allsmog for reporting. +- Security/Feishu: escape mention regex metacharacters in `stripBotMention` so crafted mention metadata cannot trigger regex injection or ReDoS during inbound message parsing. (#20916) Thanks @orlyjamie for the fix and @allsmog for reporting. +- LINE/Security: harden inbound media temp-file naming by using UUID-based temp paths for downloaded media instead of external message IDs. (#20792) Thanks @mbelinky. +- Security/Media: harden local media ingestion against TOCTOU/symlink swap attacks by pinning reads to a single file descriptor with symlink rejection and inode/device verification in `saveMediaSource`. Thanks @dorjoos for reporting. +- Security/Lobster (Windows): for the next npm release, remove shell-based fallback when launching Lobster wrappers (`.cmd`/`.bat`) and switch to explicit argv execution with wrapper entrypoint resolution, preventing command injection while preserving Windows wrapper compatibility. Thanks @allsmog for reporting. +- Security/Exec: require `tools.exec.safeBins` binaries to resolve from trusted bin directories (system defaults plus gateway startup `PATH`) so PATH-hijacked trojan binaries cannot bypass allowlist checks. Thanks @jackhax for reporting. +- Security/Exec: remove file-existence oracle behavior from `tools.exec.safeBins` by using deterministic argv-only stdin-safe validation and blocking file-oriented flags (for example `sort -o`, `jq -f`, `grep -f`) so allow/deny results no longer disclose host file presence. Thanks @nedlir for reporting. +- Security/Browser: route browser URL navigation through one SSRF-guarded validation path for tab-open/CDP-target/Playwright navigation flows and block private/metadata destinations by default (configurable via `browser.ssrfPolicy`). Thanks @dorjoos for reporting. +- Security/Exec: for the next npm release, harden safe-bin stdin-only enforcement by blocking output/recursive flags (`sort -o/--output`, grep recursion) and tightening default safe bins to remove `sort`/`grep`, preventing safe-bin allowlist bypass for file writes/recursive reads. Thanks @nedlir for reporting. +- Security/Exec: block grep safe-bin positional operand bypass by setting grep positional budget to zero, so `-e/--regexp` cannot smuggle bare filename reads (for example `.env`) via ambiguous positionals; safe-bin grep patterns must come from `-e/--regexp`. Thanks @athuljayaram for reporting. +- Security/Gateway/Agents: remove implicit admin scopes from agent tool gateway calls by classifying methods to least-privilege operator scopes, and enforce owner-only tooling (`cron`, `gateway`, `whatsapp_login`) through centralized tool-policy wrappers plus tool metadata to prevent non-owner DM privilege escalation. Ships in the next npm release. Thanks @Adam55A-code for reporting. +- Security/Gateway: centralize gateway method-scope authorization and default non-CLI gateway callers to least-privilege method scopes, with explicit CLI scope handling, full core-handler scope classification coverage, and regression guards to prevent scope drift. +- Security/Net: block SSRF bypass via NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), and Teredo (`2001:0000::/32`) IPv6 transition addresses, and fail closed on IPv6 parse errors. Thanks @jackhax. +- Security/OTEL: sanitize OTLP endpoint URL resolution. (#13791) Thanks @vincentkoc. +- Security: patch Dependabot security issues in pnpm lock. (#20832) Thanks @vincentkoc. +- Security: migrate request dependencies to `@cypress/request`. (#20836) Thanks @vincentkoc. + +## 2026.2.17 + +### Changes + +- Agents/Anthropic: add opt-in 1M context beta header support for Opus/Sonnet via model `params.context1m: true` (maps to `anthropic-beta: context-1m-2025-08-07`). +- Agents/Models: support Anthropic Sonnet 4.6 (`anthropic/claude-sonnet-4-6`) across aliases/defaults with forward-compat fallback when upstream catalogs still only expose Sonnet 4.5. +- Commands/Subagents: add `/subagents spawn` for deterministic subagent activation from chat commands. (#18218) Thanks @JoshuaLelon. +- Agents/Subagents: add an accepted response note for `sessions_spawn` explaining polling subagents are disabled for one-off calls. Thanks @tyler6204. +- Agents/Subagents: prefix spawned subagent task messages with context to preserve source information in downstream handling. Thanks @tyler6204. +- iOS/Share: add an iOS share extension that forwards shared URL/text/image content directly to gateway `agent.request`, with delivery-route fallback and optional receipt acknowledgements. (#19424) Thanks @mbelinky. +- iOS/Talk: add a `Background Listening` toggle that keeps Talk Mode active while the app is backgrounded (off by default for battery safety). Thanks @zeulewan. +- iOS/Talk: add a `Voice Directive Hint` toggle for Talk Mode prompts so users can disable ElevenLabs voice-switching instructions to save tokens when not needed. (#18250) Thanks @zeulewan. +- iOS/Talk: harden barge-in behavior by disabling interrupt-on-speech when output route is built-in speaker/receiver, reducing false interruptions from local TTS bleed-through. Thanks @zeulewan. +- Slack: add native single-message text streaming with Slack `chat.startStream`/`appendStream`/`stopStream`; keep reply threading aligned with `replyToMode`, default streaming to enabled, and fall back to normal delivery when streaming fails. (#9972) Thanks @natedenh. +- Slack: add configurable streaming modes for draft previews. (#18555) Thanks @Solvely-Colin. +- Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus. +- Telegram: surface user message reactions as system events, with configurable `channels.telegram.reactionNotifications` scope. (#10075) Thanks @Glucksberg. +- iMessage: support `replyToId` on outbound text/media sends and normalize leading `[[reply_to:]]` tags so replies target the intended iMessage. Thanks @tyler6204. +- Tool Display/Web UI: add intent-first tool detail views and exec summaries. (#18592) Thanks @xdLawless2. +- Discord: expose native `/exec` command options (host/security/ask/node) so Discord slash commands get autocomplete and structured inputs. Thanks @thewilloftheshadow. +- Discord: allow reusable interactive components with `components.reusable=true` so buttons, selects, and forms can be used multiple times before expiring. Thanks @thewilloftheshadow. +- Discord: add per-button `allowedUsers` allowlist for interactive components to restrict who can click buttons. Thanks @thewilloftheshadow. +- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal. +- Cron/CLI: add deterministic default stagger for recurring top-of-hour cron schedules (including 6-field seconds cron), auto-migrate existing jobs to persisted `schedule.staggerMs`, and add `openclaw cron add/edit --stagger ` plus `--exact` overrides for per-job timing control. +- Cron: log per-run model/provider usage telemetry in cron run logs/webhooks and add a local usage report script for aggregating token usage by job. (#18172) Thanks @HankAndTheCrew. +- Tools/Web: add URL allowlists for `web_search` and `web_fetch`. (#18584) Thanks @smartprogrammer93. +- Browser: add `extraArgs` config for custom Chrome launch arguments. (#18443) Thanks @JayMishra-source. +- Voice Call: pre-cache inbound greeting TTS for faster first playback. (#18447) Thanks @JayMishra-source. +- Skills: compact skill file `` paths in the system prompt by replacing home-directory prefixes with `~`, and add targeted compaction tests for prompt serialization behavior. (#14776) Thanks @bitfish3. +- Skills: refine skill-description routing boundaries with explicit "Use when"/"NOT for" guidance for coding-agent/github/weather, and clarify PTY/browser fallback wording. (#14577) Thanks @DylanWoodAkers. +- Auto-reply/Prompts: include trusted inbound `message_id` in conversation metadata payloads for downstream targeting workflows. Thanks @tyler6204. +- Auto-reply: include `sender_id` in trusted inbound metadata so moderation workflows can target the sender without relying on untrusted text. (#18303) Thanks @crimeacs. +- UI/Sessions: avoid duplicating typed session prefixes in display names (for example `Subagent Subagent ...`). Thanks @tyler6204. +- Agents/Z.AI: enable `tool_stream` by default for real-time tool call streaming, with opt-out via `params.tool_stream: false`. (#18173) Thanks @tianxiao1430-jpg. +- Plugins: add `before_agent_start` model/provider overrides before resolution. (#18568) Thanks @natefikru. +- Mattermost: add emoji reaction actions plus reaction event notifications, including an explicit boolean `remove` flag to avoid accidental removals. (#18608) Thanks @echo931. +- Memory/Search: add FTS fallback plus query expansion for memory search. (#18304) Thanks @irchelper. +- Agents/Models: support per-model `thinkingDefault` overrides in model config. (#18152) Thanks @wu-tian807. +- Agents: enable `llms.txt` discovery in default behavior. (#18158) Thanks @yolo-maxi. +- Extensions/Auth: add OpenAI Codex CLI auth provider integration. (#18009) Thanks @jiteshdhamaniya. +- Feishu: add Bitable create-app/create-field tools for automation workflows. (#17963) Thanks @gaowanqi08141999. +- Docker: add optional `OPENCLAW_INSTALL_BROWSER` build arg to preinstall Chromium + Xvfb in the Docker image, avoiding runtime Playwright installs. (#18449) + +### Fixes + +- Agents/Antigravity: preserve unsigned Claude thinking blocks as plain text instead of dropping them during transcript sanitization, preventing reasoning context loss while avoiding `thinking.signature` request rejections. +- Agents/Google: clean tool JSON Schemas for `google-antigravity` the same as `google-gemini-cli` before Cloud Code Assist requests, preventing Claude tool calls from failing with `patternProperties` 400 errors. (#19860) +- Tests/Telegram: add regression coverage for command-menu sync that asserts all `setMyCommands` entries are Telegram-safe and hyphen-normalized across native/custom/plugin command sources. (#19703) Thanks @obviyus. +- Agents/Image: collapse resize diagnostics to one line per image and include visible pixel/byte size details in the log message for faster triage. +- Auth/Cooldowns: clear all usage stats fields (`disabledUntil`, `disabledReason`, `failureCounts`) in `clearAuthProfileCooldown` so manual cooldown resets fully recover billing-disabled profiles without requiring direct file edits. (#19211) Thanks @nabbilkhan. +- Agents/Subagents: preemptively guard accumulated tool-result context before model calls by truncating oversized outputs and compacting oldest tool-result messages to avoid context-window overflow crashes. Thanks @tyler6204. +- Agents/Subagents/CLI: fail `sessions_spawn` when subagent model patching is rejected, allow subagent model patch defaults from `subagents.model`, and keep `sessions list`/`status` model reporting aligned to runtime model resolution. (#18660) Thanks @robbyczgw-cla. +- Agents/Subagents: add explicit subagent guidance to recover from `[compacted: tool output removed to free context]` / `[truncated: output exceeded context limit]` markers by re-reading with smaller chunks instead of full-file `cat`. Thanks @tyler6204. +- Agents/Tools: make `read` auto-page across chunks (when no explicit `limit` is provided) and scale its per-call output budget from model `contextWindow`, so larger contexts can read more before context guards kick in. Thanks @tyler6204. +- Agents/Tools: strip duplicated `read` truncation payloads from tool-result `details` and make pre-call context guarding account for heavy tool-result metadata, so repeated `read` calls no longer bypass compaction and overflow model context windows. Thanks @tyler6204. +- Reply threading: keep reply context sticky across streamed/split chunks and preserve `replyToId` on all chunk sends across shared and channel-specific delivery paths (including iMessage, BlueBubbles, Telegram, Discord, and Matrix), so follow-up bubbles stay attached to the same referenced message. Thanks @tyler6204. +- Gateway/Agent: defer transient lifecycle `error` snapshots with a short grace window so `agent.wait` does not resolve early during retry/failover. Thanks @tyler6204. +- Gateway/Presence: centralize presence snapshot broadcasts and unify runtime version precedence (`OPENCLAW_VERSION` > `OPENCLAW_SERVICE_VERSION` > `npm_package_version`) so self-presence and websocket `hello-ok` report consistent versions. +- Hooks/Automation: bridge outbound/inbound message lifecycle into internal hook events (`message:received`, `message:sent`) with session-key correlation guards, while keeping per-payload success/error reporting accurate for chunked and best-effort deliveries. (PR #9387) +- Media understanding: honor `agents.defaults.imageModel` during auto-discovery so implicit image analysis uses configured primary/fallback image models. (PR #7607) +- iOS/Onboarding: stop auth Step 3 retry-loop churn by pausing reconnect attempts on unauthorized/missing-token gateway errors and keeping auth/pairing issue state sticky during manual retry. (#19153) Thanks @mbelinky. +- Voice-call: auto-end calls when media streams disconnect to prevent stuck active calls. (#18435) Thanks @JayMishra-source. +- Voice call/Gateway: prevent overlapping closed-loop turn races with per-call turn locking, route transcript dedupe via source-aware fingerprints with strict cache eviction bounds, and harden `voicecall latency` stats for large logs without spread-operator stack overflow. (#19140) Thanks @mbelinky. +- iOS/Chat: route ChatSheet RPCs through the operator session instead of the node session to avoid node-role authorization failures for `chat.history`, `chat.send`, and `sessions.list`. (#19320) Thanks @mbelinky. +- macOS/Update: correct the Sparkle appcast version for 2026.2.15 so updates are offered again. (#18201) +- Gateway/Auth: clear stale device-auth tokens after device token mismatch errors so re-paired clients can re-auth. (#18201) +- Telegram: enable DM voice-note transcription with CLI fallback handling. (#18564) Thanks @thhuang. +- Telegram/Polls: restore Telegram poll action wiring in channel handlers. (#18122) Thanks @akyourowngames. +- WebChat: strip reply/audio directive tags from rendered chat output. (#18093) Thanks @aldoeliacim. +- Discord: honor configured HTTP proxy for app-id and allowlist REST resolution. (#17958) Thanks @k2009. +- BlueBubbles: add fallback path to recover outbound `message_id` from `fromMe` webhooks when platform message IDs are missing. Thanks @tyler6204. +- BlueBubbles: match outbound message-id fallback recovery by chat identifier as well as account context. Thanks @tyler6204. +- BlueBubbles: include sender identifier in untrusted conversation metadata for conversation info payloads. Thanks @tyler6204. +- Security/Exec: fix the OC-09 credential-theft path via environment-variable injection. (#18048) Thanks @aether-ai-agent. +- Security/Config: confine `$include` resolution to the top-level config directory, harden traversal/symlink checks with cross-platform-safe path containment, and add doctor hints for invalid escaped include paths. (#18652) Thanks @aether-ai-agent. +- Security/Net: block SSRF bypass via ISATAP embedded IPv4 transition addresses and centralize hostname/IP blocking checks across URL safety validators. Thanks @zpbrent for reporting. +- Providers: improve error messaging for unconfigured local `ollama`/`vllm` providers. (#18183) Thanks @arosstale. +- TTS: surface all provider errors instead of only the last error in aggregated failures. (#17964) Thanks @ikari-pl. +- CLI/Doctor/Configure: skip gateway auth checks for loopback-only setups. (#18407) Thanks @sggolakiya. +- CLI/Doctor: reconcile gateway service-token drift after re-pair flows. (#18525) Thanks @norunners. +- Process/Windows: disable detached spawn in exec runs to prevent empty command output. (#18067) Thanks @arosstale. +- Process: gracefully terminate process trees with SIGTERM before SIGKILL. (#18626) Thanks @sauerdaniel. +- Sessions/Windows: use atomic session-store writes to prevent context loss on Windows. (#18347) Thanks @twcwinston. +- Agents/Image: validate base64 image payloads before provider submission. (#18263) Thanks @sriram369. +- Models CLI: validate catalog entries in `openclaw models set`. (#18129) Thanks @carrotRakko. +- Usage: isolate last-turn totals in token usage reporting to avoid mixed-turn totals. (#18052) Thanks @arosstale. +- Cron: resolve `accountId` from agent bindings in isolated sessions. (#17996) Thanks @simonemacario. +- Gateway/HTTP: preserve unbracketed IPv6 `Host` headers when normalizing requests. (#18061) Thanks @Clawborn. +- Sandbox: fix workspace-directory orphaning during SHA-1 -> SHA-256 slug migration. (#18523) Thanks @yinghaosang. +- Ollama/Qwen: handle Qwen 3 reasoning field format in Ollama responses. (#18631) Thanks @mr-sk. +- OpenAI/Transcripts: always drop orphaned reasoning blocks from transcript repair. (#18632) Thanks @TySabs. +- Fix types in all tests. Typecheck the whole repository. +- Gateway/Channels: wire `gateway.channelHealthCheckMinutes` into strict config validation, treat implicit account status as managed for health checks, and harden channel auto-restart flow (preserve restart-attempt caps across crash loops, propagate enabled/configured runtime flags, and stop pending restart backoff after manual stop). Thanks @steipete. +- Gateway/WebChat: hard-cap `chat.history` oversized payloads by truncating high-cost fields and replacing over-budget entries with placeholders, so history fetches stay within configured byte limits and avoid chat UI freezes. (#18505) +- UI/Usage: replace lingering undefined `var(--text-muted)` usage with `var(--muted)` in usage date-range and chart styles to keep muted text visible across themes. (#17975) Thanks @jogelin. +- UI/Usage: preserve selected-range totals when timeline data is downsampled by bucket-aggregating timeseries points (instead of dropping intermediate points), so filtered tokens/cost stay accurate. (#17959) Thanks @jogelin. +- UI/Sessions: refresh the sessions table only after successful deletes and preserve delete errors on cancel/failure paths, so deleted sessions disappear automatically without masking delete failures. (#18507) +- Scripts/UI/Windows: fix `pnpm ui:*` spawn `EINVAL` failures by restoring shell-backed launch for `.cmd`/`.bat` runners, narrowing shell usage to launcher types that require it, and rejecting unsafe forwarded shell metacharacters in UI script args. (#18594) +- Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088) +- Auto-reply/Sessions: prevent stale thread ID leakage into non-thread sessions so replies stay in the main DM after topic interactions. (#18528) Thanks @j2h4u. +- Slack: restrict forwarded-attachment ingestion to explicit shared-message attachments and skip non-Slack forwarded `image_url` fetches, preventing non-forward attachment unfurls from polluting inbound agent context while preserving forwarded message handling. +- Feishu: detect bot mentions in post messages with embedded docs when `message.mentions` is empty. (#18074) Thanks @popomore. +- Agents/Sessions: align session lock watchdog hold windows with run and compaction timeout budgets (plus grace), preventing valid long-running turns from being force-unlocked mid-run while still recovering hung lock owners. (#18060) +- Cron: preserve default model fallbacks for cron agent runs when only `model.primary` is overridden, so failover still follows configured fallbacks unless explicitly cleared with `fallbacks: []`. (#18210) Thanks @mahsumaktas. +- Cron: route text-only announce output through the main session announce flow via runSubagentAnnounceFlow so cron text-only output remains visible to the initiating session. Thanks @tyler6204. +- Cron: treat `timeoutSeconds: 0` as no-timeout (not clamped to 1), ensuring long-running cron runs are not prematurely terminated. Thanks @tyler6204. +- Cron announce injection now targets the session determined by delivery config (`to` + channel) instead of defaulting to the current session. Thanks @tyler6204. +- Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07. +- Cron/Webhooks: reuse existing session IDs for webhook/cron runs when the session key is stable and still fresh, preserving conversation history. (#18031) Thanks @Operative-001. +- Cron: prevent spin loops when cron jobs complete within the scheduled second by advancing the next run and enforcing a minimum refire gap. (#18073) Thanks @widingmarcus-cyber. +- OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky. +- iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky and @Marvae. +- iOS/Gateway: stabilize connect/discovery state handling, add onboarding reset recovery in Settings, and fix iOS gateway-controller coverage for command-surface and last-connection persistence behavior. (#18164) Thanks @mbelinky. +- iOS/Talk: harden mobile talk config handling by ignoring redacted/env-placeholder API keys, support secure local keychain override, improve accessibility motion/contrast behavior in status UI, and tighten ATS to local-network allowance. (#18163) Thanks @mbelinky. +- iOS/Location: restore the significant location monitor implementation (service hooks + protocol surface + ATS key alignment) after merge drift so iOS builds compile again. (#18260) Thanks @ngutman. +- iOS/Signing: auto-select local Apple Development team during iOS project generation/build, prefer the canonical OpenClaw team when available, and support local per-machine signing overrides without committing team IDs. (#18421) Thanks @ngutman. +- Discord/Telegram: make per-account message action gates effective for both action listing and execution, and preserve top-level gate restrictions when account overrides only specify a subset of `actions` keys (account key -> base key -> default fallback). (#18494) +- Telegram: keep DM-topic replies and draft previews in the originating private-chat topic by preserving positive `message_thread_id` values for DM threads. (#18586) Thanks @sebslight. +- Telegram: preserve private-chat topic `message_thread_id` on outbound sends (message/sticker/poll), keep thread-not-found retry fallback, and avoid masking `chat not found` routing errors. (#18993) Thanks @obviyus. +- Discord: prevent duplicate media delivery when the model uses the `message send` tool with media, by skipping media extraction from messaging tool results since the tool already sent the message directly. (#18270) +- Discord: route `audioAsVoice` auto-replies through the voice message API so opt-in audio renders as voice messages. (#18041) Thanks @zerone0x. +- Discord: skip auto-thread creation in forum/media/voice/stage channels and keep group session last-route metadata fresh to avoid invalid thread API errors and lost follow-up sends. (#18098) Thanks @Clawborn. +- Discord/Commands: normalize `commands.allowFrom` entries with `user:`/`discord:`/`pk:` prefixes and `<@id>` mentions so command authorization matches Discord allowlist behavior. (#18042) +- Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang. +- Telegram: prevent streaming final replies from being overwritten by later final/error payloads, and suppress fallback tool-error warnings when a recovered assistant answer already exists after tool calls. (#17883) Thanks @Marvae and @obviyus. +- Telegram: debounce the first draft-stream preview update (30-char threshold) and finalize short responses by editing the stop-time preview message, improving first push notifications and avoiding duplicate final sends. (#18148) Thanks @Marvae. +- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk. +- Telegram: keep `streamMode: "partial"` draft previews in a single message across assistant-message/reasoning boundaries, preventing duplicate preview bubbles during partial-mode tool-call turns. (#18956) Thanks @obviyus. +- Telegram: normalize native command names for Telegram menu registration (`-` -> `_`) to avoid `BOT_COMMAND_INVALID` command-menu wipeouts, and log failed command syncs instead of silently swallowing them. (#19257) Thanks @akramcodez. +- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus. +- Telegram: ignore `` placeholder lines when extracting `MEDIA:` tool-result paths, preventing false local-file reads and dropped replies. (#18510) Thanks @yinghaosang. +- Telegram: skip retries when inbound media `getFile` fails with Telegram's 20MB limit and continue processing message text, avoiding dropped messages for oversized attachments. (#18531) Thanks @brandonwise. +- Telegram: clear stored polling offsets when bot tokens change or accounts are deleted, preventing stale offsets after token rotations. (#18233) +- Telegram: enable `autoSelectFamily` by default on Node.js 22+ so IPv4 fallback works on broken IPv6 networks. (#18272) Thanks @nacho9900. +- Auto-reply/TTS: keep tool-result media delivery enabled in group chats and native command sessions (while still suppressing tool summary text) so `NO_REPLY` follow-ups do not drop successful TTS audio. (#17991) Thanks @zerone0x. +- Agents/Tools: deliver tool-result media even when verbose tool output is off so media attachments are not dropped. (#16679) +- Discord: optimize reaction notification handling to skip unnecessary message fetches in `off`/`all`/`allowlist` modes, streamline reaction routing, and improve reaction emoji formatting. (#18248) Thanks @thewilloftheshadow and @victorGPT. +- CLI/Pairing: make `openclaw qr --remote` prefer `gateway.remote.url` over tailscale/public URL resolution and register the `openclaw clawbot qr` legacy alias path. (#18091) +- CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky. +- CLI: fix parent/subcommand option collisions across gateway, daemon, update, ACP, and browser command flows, while preserving legacy `browser set headers --json ` compatibility. +- CLI/Doctor: ensure `openclaw doctor --fix --non-interactive --yes` exits promptly after completion so one-shot automation no longer hangs. (#18502) +- CLI/Doctor: auto-repair `dmPolicy="open"` configs missing wildcard allowlists and write channel-correct repair paths (including `channels.googlechat.dm.allowFrom`) so `openclaw doctor --fix` no longer leaves Google Chat configs invalid after attempted repair. (#18544) +- CLI/Doctor: detect gateway service token drift when the gateway token is only provided via environment variables, keeping service repairs aligned after token rotation. +- Gateway/Update: prevent restart crash loops after failed self-updates by restarting only on successful updates, stopping early on failed install/build steps, and running `openclaw doctor --fix` during updates to sanitize config. (#18131) Thanks @RamiNoodle733. +- Gateway/Update: preserve update.run restart delivery context so post-update status replies route back to the initiating channel/thread. (#18267) Thanks @yinghaosang. +- CLI/Update: run a standalone restart helper after updates, honoring service-name overrides and reporting restart initiation separately from confirmed restarts. (#18050) +- CLI/Daemon: warn when a gateway restart sees a stale service token so users can reinstall with `openclaw gateway install --force`, and skip drift warnings for non-gateway service restarts. (#18018) +- CLI/Daemon: prefer the active version-manager Node when installing daemons and include macOS version-manager bin directories in the service PATH so launchd services resolve user-managed runtimes. +- CLI/Status: fix `openclaw status --all` token summaries for bot-token-only channels so Mattermost/Zalo no longer show a bot+app warning. (#18527) Thanks @echo931. +- CLI/Configure: make the `/model picker` allowlist prompt searchable with tokenized matching in `openclaw configure` so users can filter huge model lists by typing terms like `gpt-5.2 openai/`. (#19010) Thanks @bjesuiter. +- CLI/Message: preserve `--components` JSON payloads in `openclaw message send` so Discord component payloads are no longer dropped. (#18222) Thanks @saurabhchopade. +- Voice Call: add an optional stale call reaper (`staleCallReaperSeconds`) to end stuck calls when enabled. (#18437) +- Auto-reply/Subagents: propagate group context (`groupId`, `groupChannel`, `space`) when spawning via `/subagents spawn`, matching tool-triggered subagent spawn behavior. +- Subagents: route nested announce results back to the parent session after the parent run ends, falling back only when the parent session is deleted. (#18043) Thanks @tyler6204. +- Subagents: cap announce retry loops with max attempts and expiry to prevent infinite retry spam after deferred announces. (#18444) +- Agents/Tools/exec: add a preflight guard that detects likely shell env var injection (e.g. `$DM_JSON`, `$TMPDIR`) in Python/Node scripts before execution, preventing recurring cron failures and wasted tokens when models emit mixed shell+language source. (#12836) +- Agents/Tools/exec: treat normal non-zero exit codes as completed and append the exit code to tool output to avoid false tool-failure warnings. (#18425) +- Agents/Tools: make loop detection progress-aware and phased by hard-blocking known `process(action=poll|log)` no-progress loops, warning on generic identical-call repeats, warning + no-progress-blocking ping-pong alternation loops (10/20), coalescing repeated warning spam into threshold buckets (including canonical ping-pong pairs), adding a global circuit breaker at 30 no-progress repeats, and emitting structured diagnostic `tool.loop` warning/error events for loop actions. (#16808) Thanks @akramcodez and @beca-oc. +- Agents/Hooks: preserve the `before_tool_call` wrapped-marker across abort-signal tool wrapping so the hook runs once per tool call in normal agent sessions. (#16852) Thanks @sreuter. +- Agents/Tests: add `before_message_write` persistence regression coverage for block/mutate behavior (including synthetic tool-result flushes) and thrown-hook fallback persistence. (#18197) Thanks @shakkernerd +- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus. +- Agents/Image tool: replace Anthropic-incompatible union schema with explicit `image` (single) and `images` (multi) parameters, keeping tool schemas `anyOf`/`oneOf`/`allOf`-free while preserving multi-image analysis support. (#18551, #18566) Thanks @aldoeliacim. +- Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost. +- Agents/Failover: classify provider abort stop-reason errors (`Unhandled stop reason: abort`, `stop reason: abort`, `reason: abort`) as timeout-class failures so configured model fallback chains trigger instead of surfacing raw abort failures. (#18618) Thanks @sauerdaniel. +- Models/CLI: sync auth-profiles credentials into agent `auth.json` before registry availability checks so `openclaw models list --all` reports auth correctly for API-key/token providers, normalize provider-id aliases when bridging credentials, and skip expired token mirrors. (#18610, #18615) +- Agents/Context: raise default total bootstrap prompt cap from `24000` to `150000` chars (keeping `bootstrapMaxChars` at `20000`), include total-cap visibility in `/context`, and mark truncation from injected-vs-raw sizes so total-cap clipping is reflected accurately. +- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96. +- Cron: preserve per-job schedule-error isolation in post-run maintenance recompute so malformed sibling jobs no longer abort persistence of successful runs. (#17852) Thanks @pierreeurope. +- Gateway/Config: prevent `config.patch` object-array merges from falling back to full-array replacement when some patch entries lack `id`, so partial `agents.list` updates no longer drop unrelated agents. (#17989) Thanks @stakeswky. +- Gateway/Auth: trim whitespace around trusted proxy entries before matching so configured proxies with stray spaces still authorize. (#18084) Thanks @Clawborn. +- Config/Discord: require string IDs in Discord allowlists, keep onboarding inputs string-only, and add doctor repair for numeric entries. (#18220) Thanks @thewilloftheshadow. +- Security/Sessions: create new session transcript JSONL files with user-only (`0o600`) permissions and extend `openclaw security audit --fix` to remediate existing transcript file permissions. +- Sessions/Maintenance: archive transcripts when pruning stale sessions, clean expired media in subdirectories, and purge `.deleted` transcript archives after the prune window to prevent disk leaks. (#18538) +- Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten. +- Heartbeat: allow suppressing tool error warning payloads during heartbeat runs via a new heartbeat config flag. (#18497) Thanks @thewilloftheshadow. +- Heartbeat: include sender metadata (From/To/Provider) in heartbeat prompts so model context matches the delivery target. (#18532) Thanks @dinakars777. +- Heartbeat/Telegram: strip configured `responsePrefix` before heartbeat ack detection (with boundary-safe matching) so prefixed `HEARTBEAT_OK` replies are correctly suppressed instead of leaking into DMs. (#18602) + +## 2026.2.15 + +### Changes + +- Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow. +- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow. +- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread. +- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204. +- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x. +- Telegram: add `channel_post` inbound support for channel-based bot-to-bot wake/trigger flows, with channel allowlist gating and message/media batching parity. +- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal. +- Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow. +- Memory: add MMR (Maximal Marginal Relevance) re-ranking for hybrid search diversity. Configurable via `memorySearch.query.hybrid.mmr`. Thanks @rodrigouroz. +- Memory: add opt-in temporal decay for hybrid search scoring, with configurable half-life via `memorySearch.query.hybrid.temporalDecay`. Thanks @rodrigouroz. + +### Fixes + +- Discord: send initial content when creating non-forum threads so `thread-create` content is delivered. (#18117) Thanks @zerone0x. +- Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh. +- Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent. +- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent. +- Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh. +- Gateway/Security: redact sensitive session/path details from `status` responses for non-admin clients; full details remain available to `operator.admin`. (#8590) Thanks @fr33d3m0n. +- Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (`allowInsecureAuth` / `dangerouslyDisableDeviceAuth`) when device identity is unavailable, preventing false `missing scope` failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird. +- LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann. +- Skills/Security: restrict `download` installer `targetDir` to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code. +- Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly. +- Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168. +- Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving `passwordFile` path exemptions, preventing accidental redaction of non-secret config values like `maxTokens` and IRC password-file paths. (#16042) Thanks @akramcodez. +- Dev tooling: harden git `pre-commit` hook against option injection from malicious filenames (for example `--force`), preventing accidental staging of ignored files. Thanks @mrthankyou. +- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz. +- Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n. +- Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz. +- Gateway/Commands: keep webchat command authorization on the internal `webchat` context instead of inferring another provider from channel allowlists, fixing dropped `/new`/`/status` commands in Control UI when channel allowlists are configured. (#7189) Thanks @karlisbergmanis-lv. +- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code. +- Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent. +- Agents/Sandbox: clarify system prompt path guidance so sandbox `bash/exec` uses container paths (for example `/workspace`) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot. +- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07. +- Agents/Context: derive `lookupContextTokens()` from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07. +- Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07. +- Memory/FTS: make `buildFtsQuery` Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471. +- Auto-reply/Compaction: resolve `memory/YYYY-MM-DD.md` placeholders with timezone-aware runtime dates and append a `Current time:` line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07. +- Auth/Cooldowns: auto-expire stale auth profile cooldowns when `cooldownUntil` or `disabledUntil` timestamps have passed, and reset `errorCount` so the next transient failure does not immediately escalate to a disproportionately long cooldown. Handles `cooldownUntil` and `disabledUntil` independently. (#3604) Thanks @nabbilkhan. +- Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07. +- Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204. +- Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone. +- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber. +- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model. +- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus. +- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx. +- Telegram: replace inbound `` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023. +- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang. +- Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus. +- Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd. +- Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme. +- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu. +- Discord: skip text-based exec approval forwarding in favor of Discord's component-based approval UI. Thanks @thewilloftheshadow. +- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras. +- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96. +- Gateway/Memory: initialize QMD startup sync for every configured agent (not just the default agent), so `memory.qmd.update.onBoot` is effective across multi-agent setups. (#17663) Thanks @HenryLoenwind. +- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie. +- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz. +- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come. +- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane. +- TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07. +- TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry. +- CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07. + +## 2026.2.14 + +### Changes + +- Telegram: add poll sending via `openclaw message poll` (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla. +- Slack/Discord: add `dmPolicy` + `allowFrom` config aliases for DM access control; legacy `dm.policy` + `dm.allowFrom` keys remain supported and `openclaw doctor --fix` can migrate them. +- Discord: allow exec approval prompts to target channels or both DM+channel via `channels.discord.execApprovals.target`. (#16051) Thanks @leonnardo. +- Sandbox: add `sandbox.browser.binds` to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak. +- Discord: add debug logging for message routing decisions to improve `--debug` tracing. (#16202) Thanks @jayleekr. +- Agents: add optional `messages.suppressToolErrors` config to hide non-mutating tool-failure warnings from user-facing chat while still surfacing mutating failures. (#16620) Thanks @vai-oro. + +### Fixes + +- CLI/Installation: fix Docker installation hangs on macOS. (#12972) Thanks @vincentkoc. +- Models: fix antigravity opus 4.6 availability follow-up. (#12845) Thanks @vincentkoc. +- Security/Sessions/Telegram: restrict session tool targeting by default to the current session tree (`tools.sessions.visibility`, default `tree`) with sandbox clamping, and pass configured per-account Telegram webhook secrets in webhook mode when no explicit override is provided. Thanks @aether-ai-agent. +- CLI/Plugins: ensure `openclaw message send` exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang. +- CLI/Plugins: run registered plugin `gateway_stop` hooks before `openclaw message` exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras. +- WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr. +- Telegram: when `channels.telegram.commands.native` is `false`, exclude plugin commands from `setMyCommands` menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg. +- LINE: return 200 OK for Developers Console "Verify" requests (`{"events":[]}`) without `X-Line-Signature`, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale. +- Cron: deliver text-only output directly when `delivery.to` is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow. +- Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla. +- Media: accept `MEDIA:`-prefixed paths (lenient whitespace) when loading outbound media to prevent `ENOENT` for tool-returned local media paths. (#13107) Thanks @mcaxtr. +- Media understanding: treat binary `application/vnd.*`/zip/octet-stream attachments as non-text (while keeping vendor `+json`/`+xml` text-eligible) so Office/ZIP files are not inlined into prompt body text. (#16513) Thanks @rmramsey32. +- Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1. +- Auto-reply/Block streaming: strip leading whitespace from streamed block replies so messages starting with blank lines no longer deliver visible leading empty lines. (#16422) Thanks @mcinteerj. +- Auto-reply/Queue: keep queued followups and overflow summaries when drain attempts fail, then retry delivery instead of dropping messages on transient errors. (#16771) Thanks @mmhzlrj. +- Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541) +- Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit `workspaceDir`. (#16722) +- BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x. +- CLI: fix lazy core command registration so top-level maintenance commands (`doctor`, `dashboard`, `reset`, `uninstall`) resolve correctly instead of exposing a non-functional `maintenance` placeholder command. +- CLI/Dashboard: when `gateway.bind=lan`, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev. +- TUI/Gateway: resolve local gateway target URL from `gateway.bind` mode (tailnet/lan) instead of hardcoded localhost so `openclaw tui` connects when gateway is non-loopback. (#16299) Thanks @cortexuvula. +- TUI: honor explicit `--session ` in `openclaw tui` even when `session.scope` is `global`, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu. +- TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla. +- TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73. +- TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75. +- TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe. +- TUI: harden render-time sanitizer for narrow terminals by chunking moderately long unbroken tokens and adding fast-path sanitization guards to reduce overhead on normal text. (#5355) Thanks @tingxueren. +- TUI: render assistant body text in terminal default foreground (instead of fixed light ANSI color) so contrast remains readable on light themes such as Solarized Light. (#16750) Thanks @paymog. +- TUI/Hooks: pass explicit reset reason (`new` vs `reset`) through `sessions.reset` and emit internal command hooks for gateway-triggered resets so `/new` hook workflows fire in TUI/webchat. +- Gateway/Agent: route bare `/new` and `/reset` through `sessions.reset` before running the fresh-session greeting prompt, so reset commands clear the current session in-place instead of falling through to normal agent runs. (#16732) Thanks @kdotndot and @vignesh07. +- Cron: prevent `cron list`/`cron status` from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x. +- Cron: repair missing/corrupt `nextRunAtMs` for the updated job without globally recomputing unrelated due jobs during `cron update`. (#15750) +- Cron: treat persisted jobs with missing `enabled` as enabled by default across update/list/timer due-path checks, and add regression coverage for missing-`enabled` store records. (#15433) Thanks @eternauta1337. +- Cron: skip missed-job replay on startup for jobs interrupted mid-run (stale `runningAtMs` markers), preventing restart loops for self-restarting jobs such as update tasks. (#16694) Thanks @sbmilburn. +- Heartbeat/Cron: treat cron-tagged queued system events as cron reminders even on interval wakes, so isolated cron announce summaries no longer run under the default heartbeat prompt. (#14947) Thanks @archedark-ada and @vignesh07. +- Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as `guild=dm`. Thanks @thewilloftheshadow. +- Discord: treat empty per-guild `channels: {}` config maps as no channel allowlist (not deny-all), so `groupPolicy: "open"` guilds without explicit channel entries continue to receive messages. (#16714) Thanks @xqliu. +- Models/CLI: guard `models status` string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev. +- Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace. +- Gateway/Config: make `config.patch` merge object arrays by `id` (for example `agents.list`) instead of replacing the whole array, so partial agent updates do not silently delete unrelated agents. (#6766) Thanks @lightclient. +- Webchat/Prompts: stop injecting direct-chat `conversation_label` into inbound untrusted metadata context blocks, preventing internal label noise from leaking into visible chat replies. (#16556) Thanks @nberardi. +- Auto-reply/Prompts: include trusted inbound `message_id`, `chat_id`, `reply_to_id`, and optional `message_id_full` metadata fields so action tools (for example reactions) can target the triggering message without relying on user text. (#17662) Thanks @MaikiMolto. +- Gateway/Sessions: abort active embedded runs and clear queued session work before `sessions.reset`, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn. +- Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla. +- Agents: add a safety timeout around embedded `session.compact()` to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev. +- Agents/Tools: make required-parameter validation errors list missing fields and instruct: "Supply correct parameters before retrying," reducing repeated invalid tool-call loops (for example `read({})`). (#14729) +- Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including `session_status` model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader. +- Agents/Process/Bootstrap: preserve unbounded `process log` offset-only pagination (default tail applies only when both `offset` and `limit` are omitted) and enforce strict `bootstrapTotalMaxChars` budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman. +- Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing `BOOTSTRAP.md` once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras. +- Agents/Workspace: create `BOOTSTRAP.md` when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla. +- Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli. +- Agents: treat empty-stream provider failures (`request ended without sending any chunks`) as timeout-class failover signals, enabling auth-profile rotation/fallback and showing a friendly timeout message instead of raw provider errors. (#10210) Thanks @zenchantlive. +- Agents: treat `read` tool `file_path` arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73. +- Agents/Transcript: drop malformed tool-call blocks with blank required fields (`id`/`name` or missing `input`/`arguments`) during session transcript repair to prevent persistent tool-call corruption on future turns. (#15485) Thanks @mike-zachariades. +- Tools/Write/Edit: normalize structured text-block arguments for `content`/`oldText`/`newText` before filesystem edits, preventing JSON-like file corruption and false “exact text not found” misses from block-form params. (#16778) Thanks @danielpipernz. +- Ollama/Agents: avoid forcing `` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @Glucksberg. +- Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238. +- Agents/Process: supervise PTY/child process lifecycles with explicit ownership, cancellation, timeouts, and deterministic cleanup, preventing Codex/Pi PTY sessions from dying or stalling on resume. (#14257) Thanks @onutc. +- Skills: watch `SKILL.md` only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard. +- Memory/QMD: make `memory status` read-only by skipping QMD boot update/embed side effects for status-only manager checks. +- Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors. +- Memory/Builtin: keep `memory status` dirty reporting stable across invocations by deriving status-only manager dirty state from persisted index metadata instead of process-start defaults. (#10863) Thanks @BarryYangi. +- Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological `qmd` command output. +- Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks. +- Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency. +- Memory/QMD: pass result limits to `search`/`vsearch` commands so QMD can cap results earlier. +- Memory/QMD: avoid reading full markdown files when a `from/lines` window is requested in QMD reads. +- Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn. +- Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`. +- Memory/QMD: treat prefixed `no results found` marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui. +- Memory/QMD: avoid multi-collection `query` ranking corruption by running one `qmd query -c ` per managed collection and merging by best score (also used for `search`/`vsearch` fallback-to-query). (#16740) Thanks @volarian-vai. +- Memory/QMD: rebind managed collections when existing collection metadata drifts (including sessions name-only listings), preventing non-default agents from reusing another agent's `sessions` collection path. (#17194) Thanks @jonathanadams96. +- Memory/QMD: make `openclaw memory index` verify and print the active QMD index file path/size, and fail when QMD leaves a missing or zero-byte index artifact after an update. (#16775) Thanks @Shunamxiao. +- Memory/QMD: detect null-byte `ENOTDIR` update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms. +- Memory/QMD/Security: add `rawKeyPrefix` support for QMD scope rules and preserve legacy `keyPrefix: "agent:..."` matching, preventing scoped deny bypass when operators match agent-prefixed session keys. +- Memory/Builtin: narrow memory watcher targets to markdown globs and ignore dependency/venv directories to reduce file-descriptor pressure during memory sync startup. (#11721) Thanks @rex05ai. +- Security/Memory-LanceDB: treat recalled memories as untrusted context (escape injected memory text + explicit non-instruction framing), skip likely prompt-injection payloads during auto-capture, and restrict auto-capture to user messages to reduce memory-poisoning risk. (#12524) Thanks @davidschmid24. +- Security/Memory-LanceDB: require explicit `autoCapture: true` opt-in (default is now disabled) to prevent automatic PII capture unless operators intentionally enable it. (#12552) Thanks @fr33d3m0n. +- Diagnostics/Memory: prune stale diagnostic session state entries and cap tracked session states to prevent unbounded in-memory growth on long-running gateways. (#5136) Thanks @coygeek and @vignesh07. +- Gateway/Memory: clean up `agentRunSeq` tracking on run completion/abort and enforce maintenance-time cap pruning to prevent unbounded sequence-map growth over long uptimes. (#6036) Thanks @coygeek and @vignesh07. +- Auto-reply/Memory: bound `ABORT_MEMORY` growth by evicting oldest entries and deleting reset (`false`) flags so abort state tracking cannot grow unbounded over long uptimes. (#6629) Thanks @coygeek and @vignesh07. +- Slack/Memory: bound thread-starter cache growth with TTL + max-size pruning to prevent long-running Slack gateways from accumulating unbounded thread cache state. (#5258) Thanks @coygeek and @vignesh07. +- Outbound/Memory: bound directory cache growth with max-size eviction and proactive TTL pruning to prevent long-running gateways from accumulating unbounded directory entries. (#5140) Thanks @coygeek and @vignesh07. +- Skills/Memory: remove disconnected nodes from remote-skills cache to prevent stale node metadata from accumulating over long uptimes. (#6760) Thanks @coygeek. +- Sandbox/Tools: make sandbox file tools bind-mount aware (including absolute container paths) and enforce read-only bind semantics for writes. (#16379) Thanks @tasaankaeris. +- Sandbox/Prompts: show the sandbox container workdir as the prompt working directory and clarify host-path usage for file tools, preventing host-path `exec` failures in sandbox sessions. (#16790) Thanks @carrotRakko. +- Media/Security: allow local media reads from OpenClaw state `workspace/` and `sandboxes/` roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji. +- Media/Security: harden local media allowlist bypasses by requiring an explicit `readFile` override when callers mark paths as validated, and reject filesystem-root `localRoots` entries. (#16739) +- Media/Security: allow outbound local media reads from the active agent workspace (including `workspace-`) via agent-scoped local roots, avoiding broad global allowlisting of all per-agent workspaces. (#17136) Thanks @MisterGuy420. +- Outbound/Media: thread explicit `agentId` through core `sendMessage` direct-delivery path so agent-scoped local media roots apply even when mirror metadata is absent. (#17268) Thanks @gumadeiras. +- Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files. +- Security/BlueBubbles: require explicit `mediaLocalRoots` allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky. +- Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password. +- Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek. +- Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky. +- Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret. +- Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc. +- Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root. +- Security/Hooks: restrict hook transform modules to `~/.openclaw/hooks/transforms` (prevents path traversal/escape module loads via config). Config note: `hooks.transformsDir` must now be within that directory. Thanks @akhmittra. +- Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery). +- Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc. +- Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc. +- Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc. +- Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson. +- Security/Slack: compute command authorization for DM slash commands even when `dmPolicy=open`, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth. +- Security/Pairing: scope pairing allowlist writes/reads to channel accounts (for example `telegram:yy`), and propagate account-aware pairing approvals so multi-account channels do not share a single per-channel pairing allowFrom store. (#17631) Thanks @crazytan. +- Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc. +- Security/Google Chat: deprecate `users/` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc. +- Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc. +- Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject `@username` principals), auto-resolve `@username` to IDs in `openclaw doctor --fix` (when possible), and warn in `openclaw security audit` when legacy configs contain usernames. Thanks @vincentkoc. +- Telegram/Security: reject Telegram webhook startup when `webhookSecret` is missing or empty (prevents unauthenticated webhook request forgery). Thanks @yueyueL. +- Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text). +- Telegram: set webhook callback timeout handling to `onTimeout: "return"` (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington. +- Signal: preserve case-sensitive `group:` target IDs during normalization so mixed-case group IDs no longer fail with `Group not found`. (#16748) Thanks @repfigit. +- Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent. +- Security/Agents: enforce workspace-root path bounds for `apply_patch` in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec. +- Security/Agents: enforce symlink-escape checks for `apply_patch` delete hunks under `workspaceOnly`, while still allowing deleting the symlink itself. Thanks @p80n-sec. +- Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent. +- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins. +- Scripts/Security: validate GitHub logins and avoid shell invocation in `scripts/update-clawtributors.ts` to prevent command injection via malicious commit records. Thanks @scanleale. +- Security: fix Chutes manual OAuth login state validation by requiring the full redirect URL (reject code-only pastes) (thanks @aether-ai-agent). +- Security/Gateway: harden tool-supplied `gatewayUrl` overrides by restricting them to loopback or the configured `gateway.remote.url`. Thanks @p80n-sec. +- Security/Gateway: block `system.execApprovals.*` via `node.invoke` (use `exec.approvals.node.*` instead). Thanks @christos-eth. +- Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc. +- Security/Gateway: stop returning raw resolved config values in `skills.status` requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek. +- Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL. +- Security/Browser: harden browser control file upload + download helpers to prevent path traversal / local file disclosure. Thanks @1seal. +- Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc. +- Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth. +- Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth. +- Security/Exec: harden PATH handling by disabling project-local `node_modules/.bin` bootstrapping by default, disallowing node-host `PATH` overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra. +- Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: `channels.tlon.allowPrivateNetwork`). Thanks @p80n-sec. +- Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without `telnyx.publicKey` are now rejected unless `skipSignatureVerification` is enabled. Thanks @p80n-sec. +- Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec. +- Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek. + +## 2026.2.13 + +### Changes + +- Install: add optional Podman-based setup: `setup-podman.sh` for one-time host setup (openclaw user, image, launch script, systemd quadlet), `run-openclaw-podman.sh launch` / `launch setup`; systemd Quadlet unit for openclaw user service; docs for rootless container, openclaw user (subuid/subgid), and quadlet (troubleshooting). (#16273) Thanks @DarwinsBuddy. +- Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou. +- Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw. +- Slack/Plugins: add thread-ownership outbound gating via `message_sending` hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper. +- Agents: add synthetic catalog support for `hf:zai-org/GLM-5`. (#15867) Thanks @battman21. +- Skills: remove duplicate `local-places` Google Places skill/proxy and keep `goplaces` as the single supported Google Places path. +- Agents: add pre-prompt context diagnostics (`messages`, `systemPromptChars`, `promptChars`, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg. +- Onboarding/Providers: add first-class Hugging Face Inference provider support (provider wiring, onboarding auth choice/API key flow, and default-model selection), and preserve Hugging Face auth intent in auth-choice remapping (`tokenProvider=huggingface` with `authChoice=apiKey`) while skipping env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp. +- Onboarding/Providers: add `minimax-api-key-cn` auth choice for the MiniMax China API endpoint. (#15191) Thanks @liuy. + +### Breaking + +- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly. + +### Fixes + +- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline. +- Docs/Hooks: update hooks documentation URLs to the new `/automation/hooks` location. (#16165) Thanks @nicholascyh. +- Security/Audit: warn when `gateway.tools.allow` re-enables default-denied tools over HTTP `POST /tools/invoke`, since this can increase RCE blast radius if the gateway is reachable. +- Security/Plugins/Hooks: harden npm-based installs by restricting specs to registry packages only, passing `--ignore-scripts` to `npm pack`, and cleaning up temp install directories. +- Security/Sessions: preserve inter-session input provenance for routed prompts so delegated/internal sessions are not treated as direct external user instructions. Thanks @anbecker. +- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale. +- Agents: strip leading empty lines from `sanitizeUserFacingText` output and normalize whitespace-only outputs to empty text. (#16158) Thanks @mcinteerj. +- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y. +- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow. +- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u. +- Auto-reply/Threading: honor explicit `[[reply_to_*]]` tags even when `replyToMode` is `off`. (#16174) Thanks @aldoeliacim. +- Plugins/Threading: rename `allowTagsWhenOff` to `allowExplicitReplyTagsWhenOff` and keep the old key as a deprecated alias for compatibility. (#16189) +- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr. +- Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale. +- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow. +- Web UI: add `img` to DOMPurify allowed tags and `src`/`alt` to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo. +- Telegram/Matrix: treat MP3 and M4A (including `audio/mp4`) as voice-compatible for `asVoice` routing, and keep WAV/AAC falling back to regular audio sends. (#15438) Thanks @azade-c. +- WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending `"file"`. (#15594) Thanks @TsekaLuk. +- Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21. +- Telegram: scope skill commands to the resolved agent for default accounts so `setMyCommands` no longer triggers `BOT_COMMANDS_TOO_MUCH` when multiple agents are configured. (#15599) +- Discord: avoid misrouting numeric guild allowlist entries to `/channels/` by prefixing guild-only inputs with `guild:` during resolution. (#12326) Thanks @headswim. +- Memory/QMD: default `memory.qmd.searchMode` to `search` for faster CPU-only recall and always scope `search`/`vsearch` requests to managed collections (auto-falling back to `query` when required). (#16047) Thanks @togotago. +- Memory/LanceDB: add configurable `captureMaxChars` for auto-capture while keeping the legacy 500-char default. (#16641) Thanks @ciberponk. +- MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin. +- Media: classify `text/*` MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale. +- Inbound/Web UI: preserve literal `\n` sequences when normalizing inbound text so Windows paths like `C:\\Work\\nxxx\\README.md` are not corrupted. (#11547) Thanks @mcaxtr. +- TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk. +- Providers/MiniMax: switch implicit MiniMax API-key provider from `openai-completions` to `anthropic-messages` with the correct Anthropic-compatible base URL, fixing `invalid role: developer (2013)` errors on MiniMax M2.5. (#15275) Thanks @lailoo. +- Ollama/Agents: use resolved model/provider base URLs for native `/api/chat` streaming (including aliased providers), normalize `/v1` endpoints, and forward abort + `maxTokens` stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98. +- OpenAI Codex/Spark: implement end-to-end `gpt-5.3-codex-spark` support across fallback/thinking/model resolution and `models list` forward-compat visibility. (#14990, #15174) Thanks @L-U-C-K-Y, @loiie45e. +- Agents/Codex: allow `gpt-5.3-codex-spark` in forward-compat fallback, live model filtering, and thinking presets, and fix model-picker recognition for spark. (#14990) Thanks @L-U-C-K-Y. +- Models/Codex: resolve configured `openai-codex/gpt-5.3-codex-spark` through forward-compat fallback during `models list`, so it is not incorrectly tagged as missing when runtime resolution succeeds. (#15174) Thanks @loiie45e. +- OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into `pi` `auth.json` so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e. +- Auth/OpenAI Codex: share OAuth login handling across onboarding and `models auth login --provider openai-codex`, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20. +- Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng. +- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly (including Docker TTY installs that would otherwise hang). (#12972) Thanks @vincentkoc. +- Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k. +- macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR. +- Mattermost (plugin): retry websocket monitor connections with exponential backoff and abort-aware teardown so transient connect failures no longer permanently stop monitoring. (#14962) Thanks @mcaxtr. +- Discord/Agents: apply channel/group `historyLimit` during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238. +- Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr. +- Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug. +- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug. +- Heartbeat: allow explicit wake (`wake`) and hook wake (`hook:*`) reasons to run even when `HEARTBEAT.md` is effectively empty so queued system events are processed. (#14527) Thanks @arosstale. +- Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish. +- Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew. +- Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman. +- Sessions: archive previous transcript files on `/new` and `/reset` session resets (including gateway `sessions.reset`) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr. +- Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic. +- Gateway/Routing: speed up hot paths for session listing (derived titles + previews), WS broadcast, and binding resolution. +- Gateway/Sessions: cache derived title + last-message transcript reads to speed up repeated sessions list refreshes. +- CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid `source <(openclaw completion ...)` corruption. (#15481) Thanks @arosstale. +- CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle. +- CLI: speed up startup by lazily registering core commands (keeps rich `--help` while reducing cold-start overhead). +- Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent. +- Security/ACP: prompt for non-read/search permission requests in ACP clients (reduces silent tool approval risk). Thanks @aether-ai-agent. +- Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo. +- Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS. +- Security/Browser: constrain `POST /trace/stop`, `POST /wait/download`, and `POST /download` output paths to OpenClaw temp roots and reject traversal/escape paths. +- Security/Browser: sanitize download `suggestedFilename` to keep implicit `wait/download` paths within the downloads root. Thanks @1seal. +- Security/Browser: confine `POST /hooks/file-chooser` upload paths to an OpenClaw temp uploads root and reject traversal/escape paths. Thanks @1seal. +- Security/Browser: require auth for the sandbox browser bridge server (protects `/profiles`, `/tabs`, CDP URLs, and other control endpoints). Thanks @jackhax. +- Security: bind local helper servers to loopback and fail closed on non-loopback OAuth callback hosts (reduces localhost/LAN attack surface). +- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane. +- Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane. +- Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow. +- Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective `gateway.nodes.denyCommands` entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability. +- Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr. +- Security/Onboarding: clarify multi-user DM isolation remediation with explicit `openclaw config set session.dmScope ...` commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin. +- Security/Gateway: bind node `system.run` approval overrides to gateway exec-approval records (runId-bound), preventing approval-bypass via `node.invoke` param injection. Thanks @222n5. +- Agents/Nodes: harden node exec approval decision handling in the `nodes` tool run path by failing closed on unexpected approval decisions, and add regression coverage for approval-required retry/deny/timeout flows. (#4726) Thanks @rmorse. +- Android/Nodes: harden `app.update` by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93. +- Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo. +- Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr. +- Config: preserve `${VAR}` env references when writing config files so `openclaw config set/apply/patch` does not persist secrets to disk. Thanks @thewilloftheshadow. +- Config: remove a cross-request env-snapshot race in config writes by carrying read-time env context into write calls per request, preserving `${VAR}` refs safely under concurrent gateway config mutations. (#11560) Thanks @akoscz. +- Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers. +- Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293. +- Config: accept `$schema` key in config file so JSON Schema editor tooling works without validation errors. (#14998) +- Gateway/Tools Invoke: sanitize `/tools/invoke` execution failures while preserving `400` for tool input errors and returning `500` for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck. +- Gateway/Hooks: preserve `408` for hook request-body timeout responses while keeping bounded auth-failure cache eviction behavior, with timeout-status regression coverage. (#15848) Thanks @AI-Reviewer-QS. +- Plugins/Hooks: fire `before_tool_call` hook exactly once per tool invocation in embedded runs by removing duplicate dispatch paths while preserving parameter mutation semantics. (#15635) Thanks @lailoo. +- Agents/Transcript policy: sanitize OpenAI/Codex tool-call ids during transcript policy normalization to prevent invalid tool-call identifiers from propagating into session history. (#15279) Thanks @divisonofficer. +- Agents/Image tool: cap image-analysis completion `maxTokens` by model capability (`min(4096, model.maxTokens)`) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1. +- Agents/Compaction: centralize exec default resolution in the shared tool factory so per-agent `tools.exec` overrides (host/security/ask/node and related defaults) persist across compaction retries. (#15833) Thanks @napetrov. +- Gateway/Agents: stop injecting a phantom `main` agent into gateway agent listings when `agents.list` explicitly excludes it. (#11450) Thanks @arosstale. +- Process/Exec: avoid shell execution for `.exe` commands on Windows so env overrides work reliably in `runCommandWithTimeout`. Thanks @thewilloftheshadow. +- Daemon/Windows: preserve literal backslashes in `gateway.cmd` command parsing so drive and UNC paths are not corrupted in runtime checks and doctor entrypoint comparisons. (#15642) Thanks @arosstale. +- Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive. +- Voice Call: route webhook runtime event handling through shared manager event logic so rejected inbound hangups are idempotent in production, with regression tests for duplicate reject events and provider-call-ID remapping parity. (#15892) Thanks @dcantu96. +- Cron: add regression coverage for announce-mode isolated jobs so runs that already report `delivered: true` do not enqueue duplicate main-session relays, including delivery configs where `mode` is omitted and defaults to announce. (#15737) Thanks @brandonwise. +- Cron: honor `deleteAfterRun` in isolated announce delivery by mapping it to subagent announce cleanup mode, so cron run sessions configured for deletion are removed after completion. (#15368) Thanks @arosstale. +- Web tools/web_fetch: prefer `text/markdown` responses for Cloudflare Markdown for Agents, add `cf-markdown` extraction for markdown bodies, and redact fetched URLs in `x-markdown-tokens` debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42. +- Tools/web_search: support `freshness` for the Perplexity provider by mapping `pd`/`pw`/`pm`/`py` to Perplexity `search_recency_filter` values and including freshness in the Perplexity cache key. (#15343) Thanks @echoVic. +- Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner. +- Memory: switch default local embedding model to the QAT `embeddinggemma-300m-qat-Q8_0` variant for better quality at the same footprint. (#15429) Thanks @azade-c. +- Docs/Discord: expand quick setup and clarify guild workspace guidance. (#20088) Thanks @pejmanjohn, @thewilloftheshadow. +- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad. +- Security/Pairing: generate 256-bit base64url device and node pairing tokens and use byte-safe constant-time verification to avoid token-compare edge-case failures. (#16535) Thanks @FaizanKolega, @gumadeiras. + +## 2026.2.12 + +### Changes + +- CLI/Plugins: add `openclaw plugins uninstall ` with `--dry-run`, `--force`, and `--keep-files` options, including safe uninstall path handling and plugin uninstall docs. (#5985) Thanks @JustasMonkev. +- CLI: add `openclaw logs --local-time` to display log timestamps in local timezone. (#13818) Thanks @xialonglee. +- Telegram: render blockquotes as native `
` tags instead of stripping them. (#14608) +- Telegram: expose `/compact` in the native command menu. (#10352) Thanks @akramcodez. +- Discord: add role-based allowlists and role-based agent routing. (#10650) Thanks @Minidoracat. +- Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino. + +### Breaking + +- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting. + +### Fixes + +- Gateway/OpenResponses: harden URL-based `input_file`/`input_image` handling with explicit SSRF deny policy, hostname allowlists (`files.urlAllowlist` / `images.urlAllowlist`), per-request URL input caps (`maxUrlParts`), blocked-fetch audit logging, and regression coverage/docs updates. +- Sessions: guard `withSessionStoreLock` against undefined `storePath` to prevent `path.dirname` crash. (#14717) +- Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek. +- Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc. +- Security/Audit: add hook session-routing hardening checks (`hooks.defaultSessionKey`, `hooks.allowRequestSessionKey`, and prefix allowlists), and warn when HTTP API endpoints allow explicit session-key routing. +- Security/Sandbox: confine mirrored skill sync destinations to the sandbox `skills/` root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal. +- Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip `toolResult.details` from model-facing transcript/compaction inputs to reduce prompt-injection replay risk. +- Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (`429` + `Retry-After`). Thanks @akhmittra. +- Security/Browser: require auth for loopback browser control HTTP routes, auto-generate `gateway.auth.token` when browser control starts without auth, and add a security-audit check for unauthenticated browser control. Thanks @tcusolle. +- Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra. +- Sessions: preserve `verboseLevel`, `thinkingLevel`/`reasoningLevel`, and `ttsAuto` overrides across `/new` and `/reset` session resets. (#10787) Thanks @mcaxtr. +- Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini. +- Logging/CLI: use local timezone timestamps for console prefixing, and include `±HH:MM` offsets when using `openclaw logs --local-time` to avoid ambiguity. (#14771) Thanks @0xRaini. +- Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini. +- Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery. +- Gateway: prevent `undefined`/missing token in auth config. (#13809) Thanks @asklee-klawd. +- Configure/Gateway: reject literal `"undefined"`/`"null"` token input and validate gateway password prompt values to avoid invalid password-mode configs. (#13767) Thanks @omair445. +- Gateway: handle async `EPIPE` on stdout/stderr during shutdown. (#13414) Thanks @keshav55. +- Gateway/Control UI: resolve missing dashboard assets when `openclaw` is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica. +- Gateway/Control UI: keep partial assistant output visible when runs are aborted, and persist aborted partials to session transcripts for follow-up context. +- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini. +- Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon. +- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro. +- Cron: re-arm timers when `onTimer` fires while a job is still executing. (#14233) Thanks @tomron87. +- Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu. +- Cron: prevent duplicate announce-mode isolated cron deliveries, and keep main-session fallback active when best-effort structured delivery attempts fail to send any message. (#15739) Thanks @widingmarcus-cyber. +- Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic. +- Cron: prevent one-shot `at` jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo. +- Heartbeat: prevent scheduler stalls on unexpected run errors and avoid immediate rerun loops after `requests-in-flight` skips. (#14901) Thanks @joeykrug. +- Cron: honor stored session model overrides for isolated-agent runs while preserving `hooks.gmail.model` precedence for Gmail hook sessions. (#14983) Thanks @shtse8. +- Logging/Browser: fall back to `os.tmpdir()/openclaw` for default log, browser trace, and browser download temp paths when `/tmp/openclaw` is unavailable. +- WhatsApp: convert Markdown bold/strikethrough to WhatsApp formatting. (#14285) Thanks @Raikan10. +- WhatsApp: allow media-only sends and normalize leading blank payloads. (#14408) Thanks @karimnaguib. +- WhatsApp: default MIME type for voice messages when Baileys omits it. (#14444) Thanks @mcaxtr. +- Telegram: handle no-text message in model picker editMessageText. (#14397) Thanks @0xRaini. +- Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini. +- BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek. +- Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de. +- Slack: honor `limit` for `emoji-list` actions across core and extension adapters, with capped emoji-list responses in the Slack action handler. (#4293) Thanks @mcaxtr. +- Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker. +- Slack: include thread reply metadata in inbound message footer context (`thread_ts`, `parent_user_id`) while keeping top-level `thread_ts == ts` events unthreaded. (#14625) Thanks @bennewton999. +- Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins. +- Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr. +- Discord: treat Administrator as full permissions in channel permission checks. Thanks @thewilloftheshadow. +- Discord: respect replyToMode in threads. (#11062) Thanks @cordx56. +- Discord: add optional gateway proxy support for WebSocket connections via `channels.discord.proxy`. (#10400) Thanks @winter-loo, @thewilloftheshadow. +- Browser: add Chrome launch flag `--disable-blink-features=AutomationControlled` to reduce `navigator.webdriver` automation detection issues on reCAPTCHA-protected sites. (#10735) Thanks @Milofax. +- Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn. +- Signal: render mention placeholders as `@uuid`/`@phone` so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason. +- Agents/Reminders: guard reminder promises by appending a note when no `cron.add` succeeded in the turn, so users know nothing was scheduled. (#18588) Thanks @vignesh07. +- Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar. +- Onboarding/Providers: add Z.AI endpoint-specific auth choices (`zai-coding-global`, `zai-coding-cn`, `zai-global`, `zai-cn`) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28. +- Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include `minimax-m2.5` in modern model filtering. (#14865) Thanks @adao-max. +- Ollama: use configured `models.providers.ollama.baseUrl` for model discovery and normalize `/v1` endpoints to the native Ollama API root. (#14131) Thanks @shtse8. +- Voice Call: pass Twilio stream auth token via `` instead of query string. (#14029) Thanks @mcwigglesmcgee. +- Config/Models: allow full `models.providers.*.models[*].compat` keys used by `openai-completions` (`thinkingFormat`, `supportsStrictMode`, and streaming/tool-result compatibility flags) so valid provider overrides no longer fail strict config validation. (#11063) Thanks @ikari-pl. +- Feishu: pass `Buffer` directly to the Feishu SDK upload APIs instead of `Readable.from(...)` to avoid form-data upload failures. (#10345) Thanks @youngerstyle. +- Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf. +- Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat. +- Feishu: add streaming card replies via Card Kit API and preserve `renderMode=auto` fallback behavior for plain-text responses. (#10379) Thanks @xzq-xu. +- Feishu DocX: preserve top-level converted block order using `firstLevelBlockIds` when writing/appending documents. (#13994) Thanks @Cynosure159. +- Feishu plugin packaging: remove `workspace:*` `openclaw` dependency from `extensions/feishu` and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015. +- CLI/Wizard: exit with code 1 when `configure`, `agents add`, or interactive `onboard` wizards are canceled, so `set -e` automation stops correctly. (#14156) Thanks @0xRaini. +- Media: strip `MEDIA:` lines with local paths instead of leaking as visible text. (#14399) Thanks @0xRaini. +- Config/Cron: exclude `maxTokens` from config redaction and honor `deleteAfterRun` on skipped cron jobs. (#13342) Thanks @niceysam. +- Config: ignore `meta` field changes in config file watcher. (#13460) Thanks @brandonwise. +- Daemon: suppress `EPIPE` error when restarting LaunchAgent. (#14343) Thanks @0xRaini. +- Antigravity: add opus 4.6 forward-compat model and bypass thinking signature sanitization. (#14218) Thanks @jg-noncelogic. +- Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26. +- Agents: prevent double compaction caused by cache TTL bypassing guard. (#13514) Thanks @taw0002. +- Agents: use last API call's cache tokens for context display instead of accumulated sum. (#13805) Thanks @akari-musubi. +- Agents: keep followup-runner session `totalTokens` aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8. +- Hooks/Plugins: wire 9 previously unwired plugin lifecycle hooks into core runtime paths (session, compaction, gateway, and outbound message hooks). (#14882) Thanks @shtse8. +- Hooks/Tools: dispatch `before_tool_call` and `after_tool_call` hooks from both tool execution paths with rebased conflict fixes. (#15012) Thanks @Patrick-Barletta, @Takhoffman. +- Hooks: replace loader `console.*` output with subsystem logger messages so hook loading errors/warnings route through standard logging. (#11029) Thanks @shadril238. +- Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct. +- Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale. +- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik. +- Update/Daemon: fix post-update restart compatibility by generating `dist/cli/daemon-cli.js` with alias-aware exports from hashed daemon bundles, preventing `registerDaemonCli` import failures during `openclaw update`. + +## 2026.2.9 + +### Added + +- Commands: add `commands.allowFrom` config for separate command authorization, allowing operators to restrict slash commands to specific users while keeping chat open to others. (#12430) Thanks @thewilloftheshadow. +- Docker: add ClawDock shell helpers for Docker workflows. (#12817) Thanks @Olshansk. +- Gateway: periodic channel health monitor auto-restarts stuck, crashed, or silently-stopped channels. Configurable via `gateway.channelHealthCheckMinutes` (default: 5, set to 0 to disable). (#7053, #4302) +- iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky. +- Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204. +- Channels: IRC first-class channel support. (#11482) Thanks @vignesh07. +- Plugins: device pairing + phone control plugins (Telegram `/pair`, iOS/Android node controls). (#11755) Thanks @mbelinky. +- Tools: add Grok (xAI) as a `web_search` provider. (#12419) Thanks @tmchow. +- Gateway: add agent management RPC methods for the web UI (`agents.create`, `agents.update`, `agents.delete`). (#11045) Thanks @advaitpaliwal. +- Gateway: stream thinking events to WS clients and broadcast tool events independent of verbose level. (#10568) Thanks @nk1tz. +- Web UI: show a Compaction divider in chat history. (#11341) Thanks @Takhoffman. +- Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman. +- Agents: auto-select `zai/glm-4.6v` for image understanding when ZAI is primary provider. (#10267) Thanks @liuy. +- Paths: add `OPENCLAW_HOME` for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight. +- Onboarding: add Custom Provider flow for OpenAI and Anthropic-compatible endpoints. (#11106) Thanks @MackDing. +- Hooks: route webhook agent runs to specific `agentId`s, add `hooks.allowedAgentIds` controls, and fall back to default agent when unknown IDs are provided. (#13672) Thanks @BillChirico. + +### Fixes + +- Cron: prevent one-shot `at` jobs from re-firing on gateway restart when previously skipped or errored. (#13845) +- Discord: add exec approval cleanup option to delete DMs after approval/denial/timeout. (#13205) Thanks @thewilloftheshadow. +- Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @Glucksberg, @gumadeiras. +- CI: Implement pipeline and workflow order. Thanks @quotentiroler. +- WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez. +- Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov. +- Security/Telegram: breaking default-behavior change — standalone canvas host + Telegram webhook listeners now bind loopback (`127.0.0.1`) instead of `0.0.0.0`; set `channels.telegram.webhookHost` when external ingress is required. (#13184) Thanks @davidrudduck. +- Telegram: recover proactive sends when stale topic thread IDs are used by retrying without `message_thread_id`. (#11620) +- Discord: auto-create forum/media thread posts on send, with chunked follow-up replies and media handling for forum sends. (#12380) Thanks @magendary, @thewilloftheshadow. +- Discord: cap gateway reconnect attempts to avoid infinite retry loops. (#12230) Thanks @Yida-Dev. +- Telegram: render markdown spoilers with `` HTML tags. (#11543) Thanks @ezhikkk. +- Telegram: truncate command registration to 100 entries to avoid `BOT_COMMANDS_TOO_MUCH` failures on startup. (#12356) Thanks @arosstale. +- Telegram: match DM `allowFrom` against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai. +- Pairing/Telegram: include the actual pairing code in approve commands, route Telegram pairing replies through the shared pairing message builder, and add regression checks to prevent `` placeholder drift. +- Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual). +- Onboarding/Providers: add LiteLLM provider onboarding and preserve custom LiteLLM proxy base URLs while enforcing API-key auth mode. (#12823) Thanks @ryan-crabbe. +- Docker: make `docker-setup.sh` compatible with macOS Bash 3.2 and empty extra mounts. (#9441) Thanks @mateusz-michalik. +- Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials. +- Agents: strip reasoning tags and downgraded tool markers from messaging tool and streaming output to prevent leakage. (#11053, #13453) Thanks @liebertar, @meaadore1221-afk, @gumadeiras. +- Browser: prevent stuck `act:evaluate` from wedging the browser tool, and make cancellation stop waiting promptly. (#13498) Thanks @onutc. +- Security/Gateway: default-deny missing connect `scopes` (no implicit `operator.admin`). +- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh. +- Web UI: coerce Form Editor values to schema types before `config.set` and `config.apply`, preventing numeric and boolean fields from being serialized as strings. (#13468) Thanks @mcaxtr. +- Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow. +- Tools/web_search: fix Grok response parsing for xAI Responses API output blocks. (#13049) Thanks @ereid7. +- Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey. +- Model failover: treat HTTP 400 errors as failover-eligible, enabling automatic model fallback. (#1879) Thanks @orenyomtov. +- Errors: prevent false positive context overflow detection when conversation mentions "context overflow" topic. (#2078) Thanks @sbking. +- Errors: avoid rewriting/swallowing normal assistant replies that mention error keywords by scoping `sanitizeUserFacingText` rewrites to error-context. (#12988) Thanks @Takhoffman. +- Config: re-hydrate state-dir `.env` during runtime config loads so `${VAR}` substitutions remain resolvable. (#12748) Thanks @rodrigouroz. +- Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session `parentId` chain so agents can remember again. (#12283) Thanks @Takhoffman. +- Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman. +- Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204. +- Subagents/compaction: stabilize announce timing and preserve compaction metrics across retries. (#11664) Thanks @tyler6204. +- Subagents: report timeout-aborted runs as timed out instead of completed successfully in parent-session announcements. (#13996) Thanks @dario-github. +- Cron: share isolated announce flow and harden scheduling/delivery reliability. (#11641) Thanks @tyler6204. +- Cron tool: recover flat params when LLM omits the `job` wrapper for add requests. (#12124) Thanks @tyler6204. +- Gateway/CLI: when `gateway.bind=lan`, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6. +- CLI: make `openclaw plugins list` output scannable by hoisting source roots and shortening bundled/global/workspace plugin paths. +- Hooks: fix bundled hooks broken since 2026.2.2 (tsdown migration). (#9295) Thanks @patrickshao. +- Security/Plugins: install plugin and hook dependencies with `--ignore-scripts` to prevent lifecycle script execution. +- Routing: refresh bindings per message by loading config at route resolution so binding changes apply without restart. (#11372) Thanks @juanpablodlc. +- Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight. +- Config: clamp `maxTokens` to `contextWindow` to prevent invalid model configs. (#5516) Thanks @lailoo. +- Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @LatencyTDH. +- Thinking: honor `/think off` for reasoning-capable models. (#9564) Thanks @liuy. +- Discord: support forum/media thread-create starter messages, wire `message thread create --message`, and harden routing. (#10062) Thanks @jarvis89757. +- Discord: download attachments from forwarded messages. (#17049) Thanks @pip-nomel, @thewilloftheshadow. +- Paths: structurally resolve `OPENCLAW_HOME`-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr. +- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj. +- Memory: disable async batch embeddings by default for memory indexing (opt-in via `agents.defaults.memorySearch.remote.batch.enabled`). (#13069) Thanks @mcinteerj. +- Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204. +- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07. +- Memory/QMD: initialize QMD backend on gateway startup so background update timers restart after process reloads. (#10797) Thanks @vignesh07. +- Config/Memory: auto-migrate legacy top-level `memorySearch` settings into `agents.defaults.memorySearch`. (#11278, #9143) Thanks @vignesh07. +- Memory/QMD: treat plain-text `No results found` output from QMD as an empty result instead of throwing invalid JSON errors. (#9824) +- Memory/QMD: add `memory.qmd.searchMode` to choose `query`, `search`, or `vsearch` recall mode. (#9967, #10084) +- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. +- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy. +- Doctor/State dir: suppress repeated legacy migration warnings only for valid symlink mirrors, while keeping warnings for empty or invalid legacy trees. (#11709) Thanks @gumadeiras. +- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras. +- macOS: honor Nix-managed defaults suite (`ai.openclaw.mac`) for nixMode to prevent onboarding from reappearing after bundle-id churn. (#12205) Thanks @joshp123. +- Matrix: add multi-account support via `channels.matrix.accounts`; use per-account config for dm policy, allowFrom, groups, and other settings; serialize account startup to avoid race condition. (#7286, #3165, #3085) Thanks @emonty. + +## 2026.2.6 + +### Changes + +- Cron: default `wakeMode` is now `"now"` for new jobs (was `"next-heartbeat"`). (#10776) Thanks @tyler6204. +- Cron: `cron run` defaults to force execution; use `--due` to restrict to due-only. (#10776) Thanks @tyler6204. +- Models: support Anthropic Opus 4.6 and OpenAI Codex gpt-5.3-codex (forward-compat fallbacks). (#9853, #10720, #9995) Thanks @TinyTb, @calvin-hpnet, @tyler6204. +- Providers: add xAI (Grok) support. (#9885) Thanks @grp06. +- Providers: add Baidu Qianfan support. (#8868) Thanks @ide-rea. +- Web UI: add token usage dashboard. (#10072) Thanks @Takhoffman. +- Web UI: add RTL auto-direction support for Hebrew/Arabic text in chat composer and rendered messages. (#11498) Thanks @dirbalak. +- Memory: native Voyage AI support. (#7078) Thanks @mcinteerj. +- Sessions: cap sessions_history payloads to reduce context overflow. (#10000) Thanks @gut-puncture. +- CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617. +- CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr. +- Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids. + +### Added + +- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204. +- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204. +- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204. + +### Fixes + +- TTS: add missing OpenAI voices (ballad, cedar, juniper, marin, verse) to the allowlist so they are recognized instead of silently falling back to Edge TTS. (#2393) +- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204. +- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204. +- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj. +- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07. +- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. +- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi. +- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek. +- Cron: fix scheduling and reminder delivery regressions; harden next-run recompute + timer re-arming + legacy schedule fields. (#9733, #9823, #9948, #9932) Thanks @tyler6204, @pycckuu, @j2h4u, @fujiwara-tofu-shop. +- Update: harden Control UI asset handling in update flow. (#10146) Thanks @gumadeiras. +- Security: add skill/plugin code safety scanner; redact credentials from config.get gateway responses. (#9806, #9858) Thanks @abdelsfane. +- Exec approvals: coerce bare string allowlist entries to objects. (#9903) Thanks @mcaxtr. +- Slack: add mention stripPatterns for /new and /reset. (#9971) Thanks @ironbyte-rgb. +- Chrome extension: fix bundled path resolution. (#8914) Thanks @kelvinCB. +- Compaction/errors: allow multiple compaction retries on context overflow; show clear billing errors. (#8928, #8391) Thanks @Glucksberg. + +## 2026.2.3 + +### Changes + +- Telegram: remove last `@ts-nocheck` from `bot-handlers.ts`, use Grammy types directly, deduplicate `StickerMetadata`. Zero `@ts-nocheck` remaining in `src/telegram/`. (#9206) +- Telegram: remove `@ts-nocheck` from `bot-message.ts`, type deps via `Omit`, widen `allMedia` to `TelegramMediaRef[]`. (#9180) +- Telegram: remove `@ts-nocheck` from `bot.ts`, fix duplicate `bot.catch` error handler (Grammy overrides), remove dead reaction `message_thread_id` routing, harden sticker cache guard. (#9077) +- Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan. +- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz. +- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov. +- Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123. +- Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii. +- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config. +- Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs. +- Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and `atMs` inputs. +- Cron: delete one-shot jobs after success by default; add `--keep-after-run` for CLI. +- Cron: suppress messaging tools during announce delivery so summaries post consistently. +- Cron: avoid duplicate deliveries when isolated runs send messages directly. + +### Fixes + +- Control UI: add hardened fallback for asset resolution in global npm installs. (#4855) Thanks @anapivirtua. +- Update: remove dead restore control-ui step that failed on gitignored dist/ output. +- Update: avoid wiping prebuilt Control UI assets during dev auto-builds (`tsdown --no-clean`), run update doctor via `openclaw.mjs`, and auto-restore missing UI assets after doctor. (#10146) Thanks @gumadeiras. +- Models: add forward-compat fallback for `openai-codex/gpt-5.3-codex` when model registry hasn't discovered it yet. (#9989) Thanks @w1kke. +- Auto-reply/Docs: normalize `extra-high` (and spaced variants) to `xhigh` for Codex thinking levels, and align Codex 5.3 FAQ examples. (#9976) Thanks @slonce70. +- Compaction: remove orphaned `tool_result` messages during history pruning to prevent session corruption from aborted tool calls. (#9868, fixes #9769, #9724, #9672) +- Telegram: pass `parentPeer` for forum topic binding inheritance so group-level bindings apply to all topics within the group. (#9789, fixes #9545, #9351) +- CLI: pass `--disable-warning=ExperimentalWarning` as a Node CLI option when respawning (avoid disallowed `NODE_OPTIONS` usage; fixes npm pack). (#9691) Thanks @18-RAJAT. +- CLI: resolve bundled Chrome extension assets by walking up to the nearest assets directory; add resolver and clipboard tests. (#8914) Thanks @kelvinCB. +- Tests: stabilize Windows ACL coverage with deterministic os.userInfo mocking. (#9335) Thanks @M00N7682. +- Exec approvals: coerce bare string allowlist entries to objects to prevent allowlist corruption. (#9903, fixes #9790) Thanks @mcaxtr. +- Exec approvals: ensure two-phase approval registration/decision flow works reliably by validating `twoPhase` requests and exposing `waitDecision` as an approvals-scoped gateway method. (#3357, fixes #2402) Thanks @ramin-shirali. +- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411. +- TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras. +- Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard. +- Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo. +- Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman. +- Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo. +- Web UI: apply button styling to the new-messages indicator. +- Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua. +- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin. +- Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier. +- Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier. +- Security: gate `whatsapp_login` tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier. +- Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass. +- Voice call: add regression coverage for anonymous inbound caller IDs with allowlist policy. (#8104) Thanks @victormier. +- Cron: accept epoch timestamps and 0ms durations in CLI `--at` parsing. +- Cron: reload store data when the store file is recreated or mtime changes. +- Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204. +- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg. +- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety. +- Discord: enforce DM allowlists for agent components (buttons/select menus), honoring pairing store approvals and tag matches. (#11254) Thanks @thedudeabidesai. + +## 2026.2.2-3 + +### Fixes + +- Update: ship legacy daemon-cli shim for pre-tsdown update imports (fixes daemon restart after npm update). + +## 2026.2.2-2 + +### Changes + +- Docs: promote BlueBubbles as the recommended iMessage integration; mark imsg channel as legacy. (#8415) Thanks @tyler6204. + +### Fixes + +- CLI status: resolve build-info from bundled dist output (fixes "unknown" commit in npm builds). + +## 2026.2.2-1 + +### Fixes + +- CLI status: fall back to build-info for version detection (fixes "unknown" in beta builds). Thanks @gumadeira. + +## 2026.2.2 + +### Changes + +- Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn). +- Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs. +- Subagents: discourage direct messaging tool use unless a specific external recipient is requested. +- Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07. +- Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman. +- Config: allow setting a default subagent thinking level via `agents.defaults.subagents.thinking` (and per-agent `agents.list[].subagents.thinking`). (#7372) Thanks @tyler6204. +- Docs: zh-CN translations seed + polish, pipeline guidance, nav/landing updates, and typo fixes. (#8202, #6995, #6619, #7242, #7303, #7415) Thanks @AaronWander, @taiyi747, @Explorer1092, @rendaoyuan, @joshp123, @lailoo. +- Docs: add zh-CN i18n guardrails to avoid editing generated translations. (#8416) Thanks @joshp123. + +### Fixes + +- Docs: finish renaming the QMD memory docs to reference the OpenClaw state dir. +- Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed). +- Onboarding: drop completion prompt now handled by install/update. +- TUI: block onboarding output while TUI is active and restore terminal state on exit. +- CLI: cache shell completion scripts in state dir and source cached files in profiles. +- Zsh completion: escape option descriptions to avoid invalid option errors. +- Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode. +- fix(agents): validate AbortSignal instances before calling AbortSignal.any() (#7277) (thanks @Elarwei001) +- fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz) +- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23. +- Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji. +- Security: enforce access-group gating for Slack slash commands when channel type lookup fails. +- Security: require validated shared-secret auth before skipping device identity on gateway connect. Thanks @simecek. +- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs). +- Security/Gateway: require `operator.approvals` for in-chat `/approve` when invoked from gateway clients. Thanks @yueyueL. +- Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek. +- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow. +- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly. +- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek) +- Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed); completion prompt now handled by install/update. +- CLI/Zsh completion: cache scripts in state dir and escape option descriptions to avoid invalid option errors. +- fix(ui): resolve Control UI asset path correctly. +- fix(ui): refresh agent files after external edits. +- Tests: stub SSRF DNS pinning in web auto-reply + Gemini video coverage. (#6619) Thanks @joshp123. + +## 2026.2.1 + +### Changes + +- Docs: onboarding/install/i18n/exec-approvals/Control UI/exe.dev/cacheRetention updates + misc nav/typos. (#3050, #3461, #4064, #4675, #4729, #4763, #5003, #5402, #5446, #5474, #5663, #5689, #5694, #5967, #6270, #6300, #6311, #6416, #6487, #6550, #6789) +- Telegram: use shared pairing store. (#6127) Thanks @obviyus. +- Agents: add OpenRouter app attribution headers. Thanks @alexanderatallah. +- Agents: add system prompt safety guardrails. (#5445) Thanks @joshp123. +- Agents: update pi-ai to 0.50.9 and rename cacheControlTtl -> cacheRetention (with back-compat mapping). +- Agents: extend CreateAgentSessionOptions with systemPrompt/skills/contextFiles. +- Agents: add tool policy conformance snapshot (no runtime behavior change). (#6011) +- Auth: update MiniMax OAuth hint + portal auth note copy. +- Discord: inherit thread parent bindings for routing. (#3892) Thanks @aerolalit. +- Gateway: inject timestamps into agent and chat.send messages. (#3705) Thanks @conroywhitney, @CashWilliams. +- Gateway: require TLS 1.3 minimum for TLS listeners. (#5970) Thanks @loganaden. +- Web UI: refine chat layout + extend session active duration. +- CI: add formal conformance + alias consistency checks. (#5723, #5807) + +### Fixes + +- Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning). +- Updates: clean stale global install rename dirs and extend gateway update timeouts to avoid npm ENOTEMPTY failures. +- Security/Plugins/Hooks: validate install paths and reject traversal-like names (prevents path traversal outside the state dir). Thanks @logicx24. +- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys. +- Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus. +- Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014) +- Streaming: stabilize partial streaming filters. +- Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation. +- Tools: align tool execute adapters/signatures (legacy + parameter order + arg normalization). +- Tools: treat "\*" tool allowlist entries as valid to avoid spurious unknown-entry warnings. +- Skills: update session-logs paths from .clawdbot to .openclaw. (#4502) +- Slack: harden media fetch limits and Slack file URL validation. (#6639) Thanks @davidiach. +- Lint: satisfy curly rule after import sorting. (#6310) +- Process: resolve Windows `spawn()` failures for npm-family CLIs by appending `.cmd` when needed. (#5815) Thanks @thejhinvirtuoso. +- Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow. +- Tlon: add timeout to SSE client fetch calls (CWE-400). (#5926) +- Memory search: L2-normalize local embedding vectors to fix semantic search. (#5332) +- Agents: align embedded runner + typings with pi-coding-agent API updates (pi 0.51.0). +- Agents: ensure OpenRouter attribution headers apply in the embedded runner. +- Agents: cap context window resolution for compaction safeguard. (#6187) Thanks @iamEvanYT. +- System prompt: resolve overrides and hint using session_status for current date/time. (#1897, #1928, #2108, #3677) +- Agents: fix Pi prompt template argument syntax. (#6543) +- Subagents: fix announce failover race (always emit lifecycle end; timeout=0 means no-timeout). (#6621) +- Teams: gate media auth retries. +- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus. +- Onboarding: friendlier Windows onboarding message. (#6242) Thanks @shanselman. +- TUI: prevent crash when searching with digits in the model selector. +- Agents: wire before_tool_call plugin hook into tool execution. (#6570, #6660) Thanks @ryancnelson. +- Browser: secure Chrome extension relay CDP sessions. +- Docker: use container port for gateway command instead of host port. (#5110) Thanks @mise42. +- Docker: start gateway CMD by default for container deployments. (#6635) Thanks @kaizen403. +- fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07. +- Security: sanitize WhatsApp accountId to prevent path traversal. (#4610) +- Security: restrict MEDIA path extraction to prevent LFI. (#4930) +- Security: validate message-tool filePath/path against sandbox root. (#6398) +- Security: block LD*/DYLD* env overrides for host exec. (#4896) Thanks @HassanFleyah. +- Security: harden web tool content wrapping + file parsing safeguards. (#4058) Thanks @VACInc. +- Security: enforce Twitch `allowFrom` allowlist gating (deny non-allowlisted senders). Thanks @MegaManSec. + +## 2026.1.31 + +### Changes + +- Docs: onboarding/install/i18n/exec-approvals/Control UI/exe.dev/cacheRetention updates + misc nav/typos. (#3050, #3461, #4064, #4675, #4729, #4763, #5003, #5402, #5446, #5474, #5663, #5689, #5694, #5967, #6270, #6300, #6311, #6416, #6487, #6550, #6789) +- Telegram: use shared pairing store. (#6127) Thanks @obviyus. +- Agents: add OpenRouter app attribution headers. Thanks @alexanderatallah. +- Agents: add system prompt safety guardrails. (#5445) Thanks @joshp123. +- Agents: update pi-ai to 0.50.9 and rename cacheControlTtl -> cacheRetention (with back-compat mapping). +- Agents: extend CreateAgentSessionOptions with systemPrompt/skills/contextFiles. +- Agents: add tool policy conformance snapshot (no runtime behavior change). (#6011) +- Auth: update MiniMax OAuth hint + portal auth note copy. +- Discord: inherit thread parent bindings for routing. (#3892) Thanks @aerolalit. +- Gateway: inject timestamps into agent and chat.send messages. (#3705) Thanks @conroywhitney, @CashWilliams. +- Gateway: require TLS 1.3 minimum for TLS listeners. (#5970) Thanks @loganaden. +- Web UI: refine chat layout + extend session active duration. +- CI: add formal conformance + alias consistency checks. (#5723, #5807) + +### Fixes + +- Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning). +- Updates: clean stale global install rename dirs and extend gateway update timeouts to avoid npm ENOTEMPTY failures. +- Plugins: validate plugin/hook install paths and reject traversal-like names. +- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys. +- Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus. +- Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014) +- Streaming: stabilize partial streaming filters. +- Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation. +- Tools: align tool execute adapters/signatures (legacy + parameter order + arg normalization). +- Tools: treat `"*"` tool allowlist entries as valid to avoid spurious unknown-entry warnings. +- Skills: update session-logs paths from .clawdbot to .openclaw. (#4502) +- Slack: harden media fetch limits and Slack file URL validation. (#6639) Thanks @davidiach. +- Lint: satisfy curly rule after import sorting. (#6310) +- Process: resolve Windows `spawn()` failures for npm-family CLIs by appending `.cmd` when needed. (#5815) Thanks @thejhinvirtuoso. +- Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow. +- Tlon: add timeout to SSE client fetch calls (CWE-400). (#5926) +- Memory search: L2-normalize local embedding vectors to fix semantic search. (#5332) +- Agents: align embedded runner + typings with pi-coding-agent API updates (pi 0.51.0). +- Agents: ensure OpenRouter attribution headers apply in the embedded runner. +- Agents: cap context window resolution for compaction safeguard. (#6187) Thanks @iamEvanYT. +- System prompt: resolve overrides and hint using session_status for current date/time. (#1897, #1928, #2108, #3677) +- Agents: fix Pi prompt template argument syntax. (#6543) +- Subagents: fix announce failover race (always emit lifecycle end; timeout=0 means no-timeout). (#6621) +- Teams: gate media auth retries. +- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus. +- Onboarding: friendlier Windows onboarding message. (#6242) Thanks @shanselman. +- TUI: prevent crash when searching with digits in the model selector. +- Agents: wire before_tool_call plugin hook into tool execution. (#6570, #6660) Thanks @ryancnelson. +- Browser: secure Chrome extension relay CDP sessions. +- Docker: use container port for gateway command instead of host port. (#5110) Thanks @mise42. +- Docker: start gateway CMD by default for container deployments. (#6635) Thanks @kaizen403. +- fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07. +- Security: sanitize WhatsApp accountId to prevent path traversal. (#4610) +- Security: restrict MEDIA path extraction to prevent LFI. (#4930) +- Security: validate message-tool filePath/path against sandbox root. (#6398) +- Security: block LD*/DYLD* env overrides for host exec. (#4896) Thanks @HassanFleyah. +- Security: harden web tool content wrapping + file parsing safeguards. (#4058) Thanks @VACInc. +- Security: enforce Twitch `allowFrom` allowlist gating (deny non-allowlisted senders). Thanks @MegaManSec. + +## 2026.1.30 + +### Changes + +- CLI: add `completion` command (Zsh/Bash/PowerShell/Fish) and auto-setup during postinstall/onboarding. +- CLI: add per-agent `models status` (`--agent` filter). (#4780) Thanks @jlowin. +- Agents: add Kimi K2.5 to the synthetic model catalog. (#4407) Thanks @manikv12. +- Auth: switch Kimi Coding to built-in provider; normalize OAuth profile email. +- Auth: add MiniMax OAuth plugin + onboarding option. (#4521) Thanks @Maosghoul. +- Agents: update pi SDK/API usage and dependencies. +- Web UI: refresh sessions after chat commands and improve session display names. +- Build: move TypeScript builds to `tsdown` + `tsgo` (faster builds, CI typechecks), update tsconfig target, and clean up lint rules. +- Build: align npm tar override and bin metadata so the `openclaw` CLI entrypoint is preserved in npm publishes. +- Docs: add pi/pi-dev docs and update OpenClaw branding + install links. +- Docker E2E: stabilize gateway readiness, plugin installs/manifests, and cleanup/doctor switch entrypoint checks. + +### Fixes + +- Security: restrict local path extraction in media parser to prevent LFI. (#4880) +- Gateway: prevent token defaults from becoming the literal "undefined". (#4873) Thanks @Hisleren. +- Control UI: fix assets resolution for npm global installs. (#4909) Thanks @YuriNachos. +- macOS: avoid stderr pipe backpressure in gateway discovery. (#3304) Thanks @abhijeet117. +- Telegram: normalize account token lookup for non-normalized IDs. (#5055) Thanks @jasonsschin. +- Telegram: preserve delivery thread fallback and fix threadId handling in delivery context. +- Telegram: fix HTML nesting for overlapping styles/links. (#4578) Thanks @ThanhNguyxn. +- Telegram: accept numeric messageId/chatId in react actions. (#4533) Thanks @Ayush10. +- Telegram: honor per-account proxy dispatcher via undici fetch. (#4456) Thanks @spiceoogway. +- Telegram: scope skill commands to bound agent per bot. (#4360) Thanks @robhparker. +- BlueBubbles: debounce by messageId to preserve attachments in text+image messages. (#4984) +- Routing: prefer requesterOrigin over stale session entries for sub-agent announce delivery. (#4957) +- Extensions: restore embedded extension discovery typings. +- CLI: fix `tui:dev` port resolution. +- LINE: fix status command TypeError. (#4651) +- OAuth: skip expired-token warnings when refresh tokens are still valid. (#4593) +- Build: skip redundant UI install step in Dockerfile. (#4584) Thanks @obviyus. + +## 2026.1.29 + +### Changes + +- Rebrand: rename the npm package/CLI to `openclaw`, add a `openclaw` compatibility shim, and move extensions to the `@openclaw/*` scope. +- Onboarding: strengthen security warning copy for beta + access control expectations. +- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub. +- Config: auto-migrate legacy state/config paths and keep config resolution consistent across legacy filenames. +- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. +- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248) +- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. +- Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7. +- Browser: route browser control via gateway/node; remove standalone browser control command and control URL config. +- Browser: route `browser.request` via node proxies when available; honor proxy timeouts; derive browser ports from `gateway.port`. +- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev. +- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra. +- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon. +- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco. +- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma. +- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21. +- Telegram: support quote replies for message tool and inbound context. (#2900) Thanks @aduk059. +- Telegram: add sticker receive/send with vision caching. (#2629) Thanks @longjos. +- Telegram: send sticker pixels to vision models. (#2650) +- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc. +- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro. +- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999. +- Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk. +- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a. +- Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt. +- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47. +- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr. +- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) +- Memory Search: allow extra paths for memory indexing (ignores symlinks). (#3600) Thanks @kira-ariaki. +- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204. +- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger. +- Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev. +- Routing: add per-account DM session scope and document multi-account isolation. (#3095) Thanks @jarvis-sam. +- Routing: precompile session key regexes. (#1697) Thanks @Ray0907. +- CLI: use Node's module compile cache for faster startup. (#2808) Thanks @pi0. +- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla. +- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein. +- macOS: finish OpenClaw app rename for macOS sources, bundle identifiers, and shared kit paths. (#2844) Thanks @fal3. +- Branding: update launchd labels, mobile bundle IDs, and logging subsystems to bot.molt (legacy bundle ID migrations). Thanks @thewilloftheshadow. +- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk). +- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. +- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn. +- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg. +- Build: bundle A2UI assets during build and stop tracking generated bundles. (#2455) Thanks @0oAstro. +- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi. +- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918. +- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst. +- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido. +- Docs: add migration guide for moving to a new machine. (#2381) +- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN. +- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. +- Docs: add Render deployment guide. (#1975) Thanks @anurag. +- Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou. +- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto. +- Docs: add Oracle Cloud (OCI) platform guide + cross-links. (#2333) Thanks @hirefrank. +- Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto. +- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev. +- Docs: add LINE channel guide. Thanks @thewilloftheshadow. +- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD. +- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99. +- Docs: update exe.dev install instructions. (#https://github.com/openclaw/openclaw/pull/3047) Thanks @zackerthescar. + +### Breaking + +- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). + +### Fixes + +- Skills: update session-logs paths to use ~/.openclaw. (#4502) Thanks @bonald. +- Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796) +- Mentions: honor mentionPatterns even when explicit mentions are present. (#3303) Thanks @HirokiKobayashi-R. +- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald. +- Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald. +- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma. +- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94. +- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355. +- Telegram: include AccountId in native command context for multi-agent routing. (#2942) Thanks @Chloe-VP. +- Telegram: handle video note attachments in media extraction. (#2905) Thanks @mylukin. +- TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys. +- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee. +- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101. +- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops. +- Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky. +- Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow. +- Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow. +- Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow. +- Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb. +- Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent. +- Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang. +- Providers: update Moonshot Kimi model references to kimi-k2.5. (#2762) Thanks @MarvinCui. +- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg. +- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg. +- Security: pin npm overrides to keep tar@7.5.4 for install toolchains. +- Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez. +- CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo. +- CLI: avoid prompting for gateway runtime under the spinner. (#2874) +- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. +- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. +- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481. +- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. +- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai. +- Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24. +- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. +- Telegram: ignore non-forum group message_thread_id while preserving DM thread sessions. (#2731) Thanks @dylanneve1. +- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. +- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne. +- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. +- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. +- Media: fix text attachment MIME misclassification with CSV/TSV inference and UTF-16 detection; add XML attribute escaping for file output. (#3628) Thanks @frankekn. +- Build: align memory-core peer dependency with lockfile. +- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie. +- Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng. +- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93. +- Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0. +- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed). +- Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present. +- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags. + +## 2026.1.24-3 + +### Fixes + +- Slack: fix image downloads failing due to missing Authorization header on cross-origin redirects. (#1936) Thanks @sanderhelgesen. +- Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie. +- Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie. +- CLI: resume claude-cli sessions and stream CLI replies to TUI clients. (#1921) Thanks @rmorse. + +## 2026.1.24-2 + +### Fixes + +- Packaging: include dist/link-understanding output in npm tarball (fixes missing apply.js import on install). + +## 2026.1.24-1 + +### Fixes + +- Packaging: include dist/shared output in npm tarball (fixes missing reasoning-tags import on install). + +## 2026.1.24 + +### Highlights + +- Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.openclaw.ai/providers/ollama https://docs.openclaw.ai/providers/venice +- Channels: LINE plugin (Messaging API) with rich replies + quick replies. (#1630) Thanks @plum-dawg. +- TTS: Edge fallback (keyless) + `/tts` auto modes. (#1668, #1667) Thanks @steipete, @sebslight. https://docs.openclaw.ai/tts +- Exec approvals: approve in-chat via `/approve` across all channels (including plugins). (#1621) Thanks @czekaj. https://docs.openclaw.ai/tools/exec-approvals https://docs.openclaw.ai/tools/slash-commands +- Telegram: DM topics as separate sessions + outbound link preview toggle. (#1597, #1700) Thanks @rohannagpal, @zerone0x. https://docs.openclaw.ai/channels/telegram + +### Changes + +- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg. +- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.openclaw.ai/tts +- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.openclaw.ai/tts +- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal. +- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.openclaw.ai/channels/telegram +- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.openclaw.ai/tools/web +- UI: refresh Control UI dashboard design system (colors, icons, typography). (#1745, #1786) Thanks @EnzeD, @mousberg. +- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.openclaw.ai/tools/exec-approvals https://docs.openclaw.ai/tools/slash-commands +- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg. +- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.openclaw.ai/diagnostics/flags +- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround). +- Docs: add verbose installer troubleshooting guidance. +- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua. +- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.openclaw.ai/bedrock +- Docs: update Fly.io guide notes. +- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido. + +### Fixes + +- Web UI: fix config/debug layout overflow, scrolling, and code block sizing. (#1715) Thanks @saipreetham589. +- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent. +- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg. +- Web UI: hide internal `message_id` hints in chat bubbles. +- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete. +- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47. +- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.openclaw.ai/channels/bluebubbles +- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. +- iMessage: normalize chat_id/chat_guid/chat_identifier prefixes case-insensitively and keep service-prefixed handles stable. (#1708) Thanks @aaronn. +- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev. +- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.openclaw.ai/channels/signal +- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338. +- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639) +- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt. +- Telegram: fall back to text when voice notes are blocked by privacy settings. (#1725) Thanks @foeken. +- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634) +- Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido. +- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy. +- Google Chat: normalize space targets without double `spaces/` prefix. +- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz. +- Agents: use the active auth profile for auto-compaction recovery. +- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204. +- Models: default missing custom provider fields so minimal configs are accepted. +- Messaging: keep newline chunking safe for fenced markdown blocks across channels. +- Messaging: treat newline chunking as paragraph-aware (blank-line splits) to keep lists and headings together. (#1726) Thanks @tyler6204. +- TUI: reload history after gateway reconnect to restore session state. (#1663) +- Heartbeat: normalize target identifiers for consistent routing. +- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco. +- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19. +- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep. +- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671) +- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. +- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690) +- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work. +- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676) +- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman. +- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal. +- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal. +- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal. + +## 2026.1.23-1 + +### Fixes + +- Packaging: include dist/tts output in npm tarball (fixes missing dist/tts/tts.js). + +## 2026.1.23 + +### Highlights + +- TTS: move Telegram TTS into core + enable model-driven TTS tags by default for expressive audio replies. (#1559) Thanks @Glucksberg. https://docs.openclaw.ai/tts +- Gateway: add `/tools/invoke` HTTP endpoint for direct tool calls (auth + tool policy enforced). (#1575) Thanks @vignesh07. https://docs.openclaw.ai/gateway/tools-invoke-http-api +- Heartbeat: per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer. https://docs.openclaw.ai/gateway/heartbeat +- Deploy: add Fly.io deployment support + guide. (#1570) https://docs.openclaw.ai/platforms/fly +- Channels: add Tlon/Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. https://docs.openclaw.ai/channels/tlon + +### Changes + +- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt. https://docs.openclaw.ai/multi-agent-sandbox-tools +- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3. https://docs.openclaw.ai/bedrock +- CLI: add `openclaw system` for system events + heartbeat controls; remove standalone `wake`. (commit 71203829d) https://docs.openclaw.ai/cli/system +- CLI: add live auth probes to `openclaw models status` for per-profile verification. (commit 40181afde) https://docs.openclaw.ai/cli/models +- CLI: restart the gateway by default after `openclaw update`; add `--no-restart` to skip it. (commit 2c85b1b40) +- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). (commit c3cb26f7c) +- Plugins: add optional `llm-task` JSON-only tool for workflows. (#1498) Thanks @vignesh07. https://docs.openclaw.ai/tools/llm-task +- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. +- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits. (commit 66eec295b) +- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman. +- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc. https://docs.openclaw.ai/automation/cron-vs-heartbeat +- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc. https://docs.openclaw.ai/gateway/heartbeat + +### Fixes + +- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518) +- Heartbeat: accept plugin channel ids for heartbeat target validation + UI hints. +- Messaging/Sessions: mirror outbound sends into target session keys (threads + dmScope), create session entries on send, and normalize session key casing. (#1520, commit 4b6cdd1d3) +- Sessions: reject array-backed session stores to prevent silent wipes. (#1469) +- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete. +- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo. +- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman. +- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts). (commit 5662a9cdf) +- Daemon: use platform PATH delimiters when building minimal service paths. (commit a4e57d3ac) +- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. +- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies. +- Docker: update gateway command in docker-compose and Hetzner guide. (#1514) +- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops). (commit 8ea8801d0) +- Agents: ignore IDENTITY.md template placeholders when parsing identity. (#1556) +- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4. +- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies. +- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566) +- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) +- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (commit 084002998) +- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu. +- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. +- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. (commit f70ac0c7c) +- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp). (commit d905ca0e0) +- Telegram: render markdown in media captions. (#1478) +- MS Teams: remove `.default` suffix from Graph scopes and Bot Framework probe scopes. (#1507, #1574) Thanks @Evizero. +- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160) +- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. (commit 69f645c66) +- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast. +- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank. (commit d57cb2e1a) +- TUI: forward unknown slash commands, include Gateway commands in autocomplete, and render slash replies as system output. (commit 1af227b61, commit 8195497ce, commit 6fba598ea) +- CLI: auth probe output polish (table output, inline errors, reduced noise, and wrap fixes in `openclaw models status`). (commit da3f2b489, commit 00ae21bed, commit 31e59cd58, commit f7dc27f2d, commit 438e782f8, commit 886752217, commit aabe0bed3, commit 81535d512, commit c63144ab1) +- Media: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206) +- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. +- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest. + +## 2026.1.22 + +### Changes + +- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer. +- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren. +- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu. +- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell. +- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link. + +### Fixes + +- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell. +- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky. +- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla. +- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer. +- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x. +- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok. +- Gateway: stop the service before uninstalling and fail if it remains loaded. +- Agents: surface concrete API error details instead of generic AI service errors. +- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484) +- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj. +- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider. +- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c. +- Agents: make tool summaries more readable and only show optional params when set. +- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal. +- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl. +- CLI: prefer `~` for home paths in output. +- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic. +- Agents: centralize transcript sanitization in the runner; keep tags and error turns intact. +- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik. +- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff. +- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x. +- Slack: reduce WebClient retries to avoid duplicate sends. (#1481) +- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz. +- Discord: honor accountId across message actions and cron deliveries. (#1492) Thanks @svkozak. +- macOS: prefer linked channels in gateway summary to avoid false “not linked” status. +- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483) + +## 2026.1.21-2 + +### Fixes + +- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.openclaw.ai/cli/agents https://docs.openclaw.ai/web/control-ui +- Slack: remove deprecated `filetype` field from `files.uploadV2` to eliminate API warnings. (#1447) + +## 2026.1.21 + +### Changes + +- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.openclaw.ai/tools/lobster +- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.openclaw.ai/tools/lobster +- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker. +- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output. +- CLI: exec approvals mutations render tables instead of raw JSON. +- Exec approvals: support wildcard agent allowlists (`*`) across all agents. +- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing. +- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. +- CLI: flatten node service commands under `openclaw node` and remove `service node` docs. +- CLI: move gateway service commands under `openclaw gateway` and add `gateway probe` for reachability. +- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot. +- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. +- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla. +- CLI: add `openclaw update wizard` for interactive channel selection and restart prompts. https://docs.openclaw.ai/cli/update +- Signal: add typing indicators and DM read receipts via signal-cli. +- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. +- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead). +- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.openclaw.ai/gateway/troubleshooting +- Docs: add /model allowlist troubleshooting note. (#1405) +- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky. + +### Breaking + +- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.openclaw.ai/web/control-ui#insecure-http +- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. + +### Fixes + +- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman. +- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380) +- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early. +- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x. +- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell. +- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. +- macOS: exec approvals now respect wildcard agent allowlists (`*`). +- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-. +- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock). +- UI: remove the chat stop button and keep the composer aligned to the bottom edge. +- Typing: start instant typing indicators at run start so DMs and mentions show immediately. +- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5. +- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen. +- Model picker: list the full catalog when no model allowlist is configured. +- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo. +- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204. +- Infra: preserve fetch helper methods when wrapping abort signals. (#1387) +- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc. + +## 2026.1.20 + +### Changes + +- Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.openclaw.ai/web/control-ui +- Control UI: drop the legacy list view. (#1345) https://docs.openclaw.ai/web/control-ui +- TUI: add syntax highlighting for code blocks. (#1200) https://docs.openclaw.ai/tui +- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.openclaw.ai/tui +- TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.openclaw.ai/tui +- TUI: add input history (up/down) for submitted messages. (#1348) https://docs.openclaw.ai/tui +- ACP: add `openclaw acp` for IDE integrations. https://docs.openclaw.ai/cli/acp +- ACP: add `openclaw acp client` interactive harness for debugging. https://docs.openclaw.ai/cli/acp +- Skills: add download installs with OS-filtered options. https://docs.openclaw.ai/tools/skills +- Skills: add the local sherpa-onnx-tts skill. https://docs.openclaw.ai/tools/skills +- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.openclaw.ai/concepts/memory +- Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.openclaw.ai/concepts/memory +- Memory: add OpenAI batch indexing for embeddings when configured. https://docs.openclaw.ai/concepts/memory +- Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.openclaw.ai/concepts/memory +- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.openclaw.ai/concepts/memory +- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.openclaw.ai/concepts/memory +- Memory: add `--verbose` logging for memory status + batch indexing details. https://docs.openclaw.ai/concepts/memory +- Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.openclaw.ai/concepts/memory +- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.openclaw.ai/tools/browser +- Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.openclaw.ai/channels/nostr +- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.openclaw.ai/channels/matrix +- Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.openclaw.ai/channels/slack +- Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.openclaw.ai/channels/telegram +- Discord: fall back to `/skill` when native command limits are exceeded. (#1287) +- Discord: expose `/skill` globally. (#1287) +- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.openclaw.ai/plugins/zalouser +- Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.openclaw.ai/plugins/manifest +- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.openclaw.ai/plugins/manifest +- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.openclaw.ai/plugins/manifest +- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.openclaw.ai/web/control-ui +- Agents/UI: add agent avatar support in identity config, IDENTITY.md, and the Control UI. (#1329) https://docs.openclaw.ai/gateway/configuration +- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.openclaw.ai/plugins/agent-tools +- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.openclaw.ai/channels/bluebubbles +- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader. +- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.openclaw.ai/channels/zalo +- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.openclaw.ai/plugins/zalouser +- Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.openclaw.ai/plugins/agent-tools +- Plugins: auto-enable bundled channel/provider plugins when configuration is present. +- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `openclaw update`. +- Plugins: share npm plugin update logic between `openclaw update` and `openclaw plugins update`. + +- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229) +- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) +- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.openclaw.ai/reference/api-usage-costs +- Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.openclaw.ai/cli/security +- Exec: add host/security/ask routing for gateway + node exec. https://docs.openclaw.ai/tools/exec +- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). https://docs.openclaw.ai/tools/exec +- Exec approvals: migrate approvals to `~/.openclaw/exec-approvals.json` with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.openclaw.ai/tools/exec-approvals +- Nodes: add headless node host (`openclaw node start`) for `system.run`/`system.which`. https://docs.openclaw.ai/cli/node +- Nodes: add node daemon service install/status/start/stop/restart. https://docs.openclaw.ai/cli/node +- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins. +- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.openclaw.ai/concepts/session +- Sessions: allow `sessions_spawn` to override thinking level for sub-agent runs. https://docs.openclaw.ai/tools/subagents +- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.openclaw.ai/concepts/groups +- Models: add Qwen Portal OAuth provider support. (#1120) https://docs.openclaw.ai/providers/qwen +- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.openclaw.ai/start/onboarding +- Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.openclaw.ai/start/onboarding +- Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.openclaw.ai/platforms/android +- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.openclaw.ai/bedrock +- Docs: clarify WhatsApp voice notes. https://docs.openclaw.ai/channels/whatsapp +- Docs: clarify Windows WSL portproxy LAN access notes. https://docs.openclaw.ai/platforms/windows +- Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.openclaw.ai/tools/browser-login +- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt. +- Agents: clarify node_modules read-only guidance in agent instructions. +- Config: stamp last-touched metadata on write and warn if the config is newer than the running build. +- macOS: hide usage section when usage is unavailable instead of showing provider errors. +- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming. +- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects. +- Android: remove legacy bridge transport code now that nodes use the gateway protocol. +- Android: bump okhttp + dnsjava to satisfy lint dependency checks. +- Build: update workspace + core/plugin deps. +- Build: use tsgo for dev/watch builds by default (opt out with `OPENCLAW_TS_COMPILER=tsc`). +- Repo: remove the Peekaboo git submodule now that the SPM release is used. +- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release. +- macOS: stop syncing Peekaboo in postinstall. +- Swabble: use the tagged Commander Swift package release. + +### Breaking + +- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `openclaw doctor --fix` to repair, then update plugins (`openclaw plugins update`) if you use any. + +### Fixes + +- Discovery: shorten Bonjour DNS-SD service type to `_moltbot-gw._tcp` and update discovery clients/docs. +- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry. +- Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244) +- Diagnostics: gate heartbeat/webhook logging. (#1244) +- Gateway: strip inbound envelope headers from chat history messages to keep clients clean. +- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance. +- Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354) +- Gateway: clarify connect/validation errors for gateway params. (#1347) +- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) +- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner. +- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed. +- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285) +- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) +- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058) +- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137) +- Agents: sanitize oversized image payloads before send and surface image-dimension errors. +- Sessions: fall back to session labels when listing display names. (#1124) +- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084) +- Config: log invalid config issues once per run and keep invalid-config errors stackless. +- Config: allow Perplexity as a web_search provider in config validation. (#1230) +- Config: allow custom fields under `skills.entries..config` for skill credentials/config. (#1226) +- Doctor: clarify plugin auto-enable hint text in the startup banner. +- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169) +- Docs: make docs:list fail fast with a clear error if the docs directory is missing. +- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) +- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context. +- CLI: preserve cron delivery settings when editing message payloads. (#1322) +- CLI: keep `openclaw logs` output resilient to broken pipes while preserving progress output. +- CLI: avoid duplicating --profile/--dev flags when formatting commands. +- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) +- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) +- CLI: skip runner rebuilds when dist is fresh. (#1231) +- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output. +- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301) +- Status: show both usage windows with reset hints when usage data is available. (#1101) +- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) +- UI: preserve ordered list numbering in chat markdown. (#1341) +- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) +- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) +- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) +- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) +- TUI: align custom editor initialization with the latest pi-tui API. (#1298) +- TUI: show generic empty-state text for searchable pickers. (#1201) +- TUI: highlight model search matches and stabilize search ordering. +- Configure: hide OpenRouter auto routing model from the model picker. (#1182) +- Memory: show total file counts + scan issues in `openclaw memory status`. +- Memory: fall back to non-batch embeddings after repeated batch failures. +- Memory: apply OpenAI batch defaults even without explicit remote config. +- Memory: index atomically so failed reindex preserves the previous memory database. (#1151) +- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) +- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing. +- Memory: parallelize embedding indexing with rate-limit retries. +- Memory: split overly long lines to keep embeddings under token limits. +- Memory: skip empty chunks to avoid invalid embedding inputs. +- Memory: split embedding batches to avoid OpenAI token limits during indexing. +- Memory: probe sqlite-vec availability in `openclaw memory status`. +- Exec approvals: enforce allowlist when ask is off. +- Exec approvals: prefer raw command for node approvals/events. +- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries. +- Tools: return a companion-app-required message when node exec is requested with no paired node. +- Tools: return a companion-app-required message when `system.run` is requested without a supporting node. +- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny). +- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) +- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304) +- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) +- Discord: make resolve warnings avoid raw JSON payloads on rate limits. +- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295) +- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners. +- Discord: only emit slow listener warnings after 30s. +- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) +- Telegram: honor pairing allowlists for native slash commands. +- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) +- Slack: resolve Bolt import interop for Bun + Node. (#1191) +- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). +- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) +- Browser: register AI snapshot refs for act commands. (#1282) +- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864) +- Anthropic: default API prompt caching to 1h with configurable TTL override. +- Anthropic: ignore TTL for OAuth. +- Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138) +- Auth profiles: user pins stay locked. (#1138) +- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) +- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts. +- Tests: stabilize plugin SDK resolution and embedded agent timeouts. +- Windows: install gateway scheduled task as the current user. +- Windows: show friendly guidance instead of failing on access denied. +- macOS: load menu session previews asynchronously so items populate while the menu is open. +- macOS: use label colors for session preview text so previews render in menu subviews. +- macOS: suppress usage error text in the menubar cost view. +- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) +- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105) +- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006) +- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) + +Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x. + +## 2026.1.16-2 + +### Changes + +- CLI: stamp build commit into dist metadata so banners show the commit in npm installs. +- CLI: close memory manager after memory commands to avoid hanging processes. (#1127) — thanks @NicholasSpisak. + +## 2026.1.16-1 + +### Highlights + +- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.openclaw.ai/hooks +- Media: add inbound media understanding (image/audio/video) with provider + CLI fallbacks. https://docs.openclaw.ai/nodes/media-understanding +- Plugins: add Zalo Personal plugin (`@openclaw/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. https://docs.openclaw.ai/plugins/zalouser +- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. https://docs.openclaw.ai/providers/vercel-ai-gateway +- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.openclaw.ai/concepts/session +- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.openclaw.ai/tools/web + +### Breaking + +- **BREAKING:** `openclaw message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan. +- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow. +- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`. +- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups. +- **BREAKING:** `openclaw hooks` is now `openclaw webhooks`; hooks live under `openclaw hooks`. https://docs.openclaw.ai/cli/webhooks +- **BREAKING:** `openclaw plugins install ` now copies into `~/.openclaw/extensions` (use `--link` to keep path-based loading). + +### Changes + +- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO. +- Plugins: add bundled Antigravity + Gemini CLI OAuth + Copilot Proxy provider plugins. (#1066) — thanks @ItzR3NO. +- Tools: improve `web_fetch` extraction using Readability (with fallback). +- Tools: add Firecrawl fallback for `web_fetch` when configured. +- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites. +- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails. +- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`. +- Tools: add `exec` PTY support for interactive sessions. https://docs.openclaw.ai/tools/exec +- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions. +- Tools: add `process submit` helper to send CR for PTY sessions. +- Tools: respond to PTY cursor position queries to unblock interactive TUIs. +- Tools: include tool outputs in verbose mode and expand verbose tool feedback. +- Skills: update coding-agent guidance to prefer PTY-enabled exec runs and simplify tmux usage. +- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos. +- Status: trim `/status` to current-provider usage only and drop the OAuth/token block. +- Directory: unify `openclaw directory` across channels and plugin channels. +- UI: allow deleting sessions from the Control UI. +- Memory: add sqlite-vec vector acceleration with CLI status details. +- Memory: add experimental session transcript indexing for memory_search (opt-in via memorySearch.experimental.sessionMemory + sources). +- Skills: add user-invocable skill commands and expanded skill command registration. +- Telegram: default reaction level to minimal and enable reaction notifications by default. +- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2. +- iMessage: add remote attachment support for VM/SSH deployments. +- Messages: refresh live directory cache results when resolving targets. +- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo. +- Messages: avoid redundant sender envelopes for iMessage + Signal group chats. (#1080) — thanks @tyler6204. +- Media: normalize Deepgram audio upload bytes for fetch compatibility. +- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup. +- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs. +- Config: support env var substitution in config values. (#1044) — thanks @sebslight. +- Health: add per-agent session summaries and account-level health details, and allow selective probes. (#1047) — thanks @gumadeiras. +- Hooks: add hook pack installs (npm/path/zip/tar) with `openclaw.hooks` manifests and `openclaw hooks install/update`. +- Plugins: add zip installs and `--link` to avoid copying local paths. + +### Fixes + +- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash. +- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels. +- Tools: include provider/session context in elevated exec denial errors. +- Tools: normalize exec tool alias naming in tool error logs. +- Logging: reuse shared ANSI stripping to keep console capture lint-clean. +- Logging: prefix nested agent output with session/run/channel context. +- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z. +- Telegram: split long captions into follow-up messages. +- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm. +- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt. +- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058) +- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058) +- Sessions: preserve overrides on `/new` reset. +- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4. +- Memory: avoid gateway crash when embeddings return 429/insufficient_quota (disable tool + surface error). (#1004) +- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing. +- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields. +- Build: allow `@lydell/node-pty` builds on supported platforms. +- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea. +- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes. +- Messages: honor message tool channel when deduping sends. +- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059) +- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600). +- Sessions: repair orphaned user turns before embedded prompts. +- Sessions: hard-stop `sessions.delete` cleanup. +- Channels: treat replies to the bot as implicit mentions across supported channels. +- Channels: normalize object-format capabilities in channel capability parsing. +- Security: default-deny slash/control commands unless a channel computed `CommandAuthorized` (fixes accidental “open” behavior), and ensure WhatsApp + Zalo plugin channels gate inline `/…` tokens correctly. https://docs.openclaw.ai/gateway/security +- Security: redact sensitive text in gateway WS logs. +- Tools: cap pending `exec` process output to avoid unbounded buffers. +- CLI: speed up `openclaw sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids. +- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm. +- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm. +- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4. +- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message. +- WhatsApp: include `linked` field in `describeAccount`. +- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions. +- Agents: hide the image tool when the primary model already supports images. +- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends. +- Auth: inherit/merge sub-agent auth profiles from the main agent. +- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi. +- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel. +- iMessage: avoid RPC restart loops. +- OpenAI image-gen: handle URL + `b64_json` responses and remove deprecated `response_format` (use URL downloads). +- CLI: auto-update global installs when installed via a package manager. +- Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras. +- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr. +- Security: bump `tar` to 7.5.3. +- Models: align ZAI thinking toggles. +- iMessage/Signal: include sender metadata for non-queued group messages. (#1059) +- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact. +- Skills: fix skills watcher ignored list typing (tsc). + +## 2026.1.15 + +### Highlights + +- Plugins: add provider auth registry + `openclaw models auth login` for plugin-driven OAuth/API key flows. +- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors). +- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf. +- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs). + +### Breaking + +- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702) +- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`. +- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow. + +### Changes + +- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow. +- CLI: set process titles to `openclaw-` for clearer process listings. +- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware). +- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups. +- Telegram: default reaction notifications to own. +- Tools: improve `web_fetch` extraction using Readability (with fallback). +- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf. +- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007. +- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee. +- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker. +- TUI: show provider/model labels for the active session and default model. +- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example. +- UI: show gateway auth guidance + doc link on unauthorized Control UI connections. +- UI: add session deletion action in Control UI sessions list. (#1017) — thanks @Szpadel. +- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `openclaw security audit`. +- Apps: store node auth tokens encrypted (Keychain/SecurePrefs). +- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts. +- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter. +- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24). +- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields. +- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`). +- Docs: add Date & Time guide and update prompt/timezone configuration docs. +- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc. +- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev. +- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `openclaw models status`, and update docs. +- CLI: add `--json` output for `openclaw daemon` lifecycle/install commands. +- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors. +- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`. +- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors. +- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer. +- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`. +- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm. +- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi. +- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino. +- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow. +- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE. + +### Fixes + +- Messages: make `/stop` clear queued followups and pending session lane work for a hard abort. +- Messages: make `/stop` abort active sub-agent runs spawned from the requester session and report how many were stopped. +- WhatsApp: report linked status consistently in channel status. (#1050) — thanks @YuriNachos. +- Sessions: keep per-session overrides when `/new` resets compaction counters. (#1050) — thanks @YuriNachos. +- Skills: allow OpenAI image-gen helper to handle URL or base64 responses. (#1050) — thanks @YuriNachos. +- WhatsApp: default response prefix only for self-chat, using identity name when set. +- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel. +- iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops. +- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg. +- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg. +- Fix: make `openclaw update` auto-update global installs when installed via a package manager. +- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. +- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr. +- Fix: persist `gateway.mode=local` after selecting Local run mode in `openclaw configure`, even if no other sections are chosen. +- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter. +- Agents: avoid false positives when logging unsupported Google tool schema keywords. +- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm. +- Status: restore usage summary line for current provider when no OAuth profiles exist. +- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4. +- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204. +- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639. +- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads. +- Fix: honor message tool channel for duplicate suppression (prefer `NO_REPLY` after `message` tool sends). (#1053) — thanks @sashcatanzarite. +- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904) +- Browser: extension mode recovers when only one tab is attached (stale targetId fallback). +- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page). +- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake). +- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c. +- Fix: sanitize user-facing error text + strip `` tags across reply pipelines. (#975) — thanks @ThomsenDrake. +- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba. +- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash. +- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998) + +## 2026.1.14-1 + +### Highlights + +- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure. +- Browser control: Chrome extension relay takeover mode + remote browser control support. +- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba. +- Security: expanded `openclaw security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy. + +### Changes + +- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics. +- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors. +- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging. +- Security: expand `openclaw security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths. +- Security: add `SECURITY.md` reporting policy. +- Channels: add Matrix plugin (external) with docs + onboarding hooks. +- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba. +- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. +- Docs: expand gateway security hardening guidance and incident response checklist. +- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf. +- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia. +- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill. +- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips. +- Browser: add Chrome extension relay takeover mode (toolbar button), plus `openclaw browser extension install/path` and remote browser control (standalone server + token auth). + +### Fixes + +- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204. +- Browser: add tests for snapshot labels/efficient query params and labeled image responses. +- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors. +- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06. +- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. +- Agents: harden Antigravity Claude history/tool-call sanitization. (#968) — thanks @rdev. +- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4. +- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. +- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. +- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman. +- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06. +- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer. +- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer. +- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. +- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”). +- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies. +- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves). + +## 2026.1.14 + +### Changes + +- Usage: add MiniMax coding plan usage tracking. +- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR. +- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915) +- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko. +- Config: add `channels..configWrites` gating for channel-initiated config writes; migrate Slack channel IDs. + +### Fixes + +- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. +- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor. +- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank. +- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states. +- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`. +- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.openclaw-dev`). + +#### Agents / Auth / Tools / Sandbox + +- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams. +- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994. +- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli. +- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. +- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4. +- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. +- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. +- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06. +- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer. +- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors. + +#### macOS / Apps + +- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4. +- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75. +- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. +- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917) +- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi. +- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions. +- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis. +- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. +- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman. +- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko. +- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose. +- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4. +- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman. +- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester. +- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee. + +## 2026.1.13 + +### Fixes + +- Postinstall: treat already-applied pnpm patches as no-ops to avoid npm/bun install failures. +- Packaging: pin `@mariozechner/pi-ai` to 0.45.7 and refresh patched dependency to match npm resolution. + +## 2026.1.12-2 + +### Fixes + +- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`). +- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4. +- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien. + +## 2026.1.12-1 + +### Fixes + +- Packaging: include `dist/channels/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/channels/registry.js`). + +## 2026.1.12 + +### Highlights + +- **BREAKING:** rename chat “providers” (Slack/Telegram/WhatsApp/…) to **channels** across CLI/RPC/config; legacy config keys auto-migrate on load (and are written back as `channels.*`). +- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback. +- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI). +- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs). (#811) — thanks @siraht; (#818) — thanks @mickahouan. +- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI). +- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks. (#700) — thanks @thewilloftheshadow; (#583) — thanks @mitschabaude-bot. + +### New & Improved + +- Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI. (#819) — thanks @mukhtharcm. +- Memory: new `openclaw memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.openclaw/memory/{agentId}.sqlite` with watch-on-by-default. +- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config. +- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer. +- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider. +- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06. +- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `openclaw dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior. +- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4. +- Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs). (#802) — thanks @zknicker. +- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool. +- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal. + +### Installer + +- Install: run `openclaw doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected. + +### Fixes + +- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds. +- Tools: apply global tool allow/deny even when agent-specific tool policy is set. +- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ. +- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax. +- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg. +- Agents: auto-recover from compaction context overflow by resetting the session and retrying; propagate overflow details from embedded runs so callers can recover. +- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing. (#809) — thanks @latitudeki5223. +- Onboarding/Auth: honor `CLAWDBOT_AGENT_DIR` / `PI_CODING_AGENT_DIR` when writing auth profiles (MiniMax). (#829) — thanks @roshanasingh4. +- Anthropic: handle `overloaded_error` with a friendly message and failover classification. (#832) — thanks @danielz1z. +- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors. (#804) — thanks @ThomsenDrake. +- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution. (#793) — thanks @hsrvc; (#450, #447) — thanks @thewilloftheshadow. +- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles. (#810) — thanks @mcinteerj. +- System events: include local timestamps when events are injected into prompts. (#245) — thanks @thewilloftheshadow. +- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping. +- Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool. (#800, #807) — thanks @davidguttman; (#744) — thanks @thewilloftheshadow. +- Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics. (#727, #739) — thanks @thewilloftheshadow; (#821) — thanks @gumadeiras; (#779) — thanks @azade-c. +- Slack: accept slash commands with or without leading `/` for custom command configs. (#798) — thanks @thewilloftheshadow. +- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params. (#205, #252) — thanks @thewilloftheshadow. +- Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `openclaw doctor --non-interactive` during updates. (#781) — thanks @ronyrus. +- Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom. (#764) — thanks @mukhtharcm; (#794) — thanks @roshanasingh4; (#217) — thanks @thewilloftheshadow. +- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow. +- Connections UI: polish multi-account account cards. (#816) — thanks @steipete. + +### Maintenance + +- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai. +- Testing: update Vitest + browser-playwright to 4.0.17. +- Docs: add Amazon Bedrock provider notes and link from models/FAQ. + +## 2026.1.11 + +### Highlights + +- Plugins are now first-class: loader + CLI management, plus the new Voice Call plugin. +- Config: modular `$include` support for split config files. (#731) — thanks @pasogott. +- Agents/Pi: reserve compaction headroom so pre-compaction memory writes can run before auto-compaction. +- Agents: automatic pre-compaction memory flush turn to store durable memories before compaction. + +### Changes + +- CLI/Onboarding: simplify MiniMax auth choice to a single M2.1 option. +- CLI: configure section selection now loops until Continue. +- Docs: explain MiniMax vs MiniMax Lightning (speed vs cost) and restore LM Studio example. +- Docs: add Cerebras GLM 4.6/4.7 config example (OpenAI-compatible endpoint). +- Onboarding/CLI: group model/auth choice by provider and label Z.AI as GLM 4.7. +- Onboarding/Docs: add Moonshot AI (Kimi K2) auth choice + config example. +- CLI/Onboarding: prompt to reuse detected API keys for Moonshot/MiniMax/Z.AI/Gemini/Anthropic/OpenCode. +- Auto-reply: add compact `/model` picker (models + available providers) and show provider endpoints in `/model status`. +- Control UI: add Config tab model presets (MiniMax M2.1, GLM 4.7, Kimi) for one-click setup. +- Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints). +- Plugins: add `openclaw plugins install` (path/tgz/npm), plus `list|info|enable|disable|doctor` UX. +- Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests. +- Docs: add plugins doc + cross-links from tools/skills/gateway config. +- Docs: add beginner-friendly plugin quick start + expand Voice Call plugin docs. +- Tests: add Docker plugin loader + tgz-install smoke test. +- Tests: extend Docker plugin E2E to cover installing from local folders (`plugins.load.paths`) and `file:` npm specs. +- Tests: add coverage for pre-compaction memory flush settings. +- Tests: modernize live model smoke selection for current releases and enforce tools/images/thinking-high coverage. (#769) — thanks @steipete. +- Agents/Tools: add `apply_patch` tool for multi-file edits (experimental; gated by tools.exec.applyPatch; OpenAI-only). +- Agents/Tools: rename the bash tool to exec (config alias maintained). (#748) — thanks @myfunc. +- Agents: add pre-compaction memory flush config (`agents.defaults.compaction.*`) with a soft threshold + system prompt. +- Config: add `$include` directive for modular config files. (#731) — thanks @pasogott. +- Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr. +- macOS: prompt to install the global `openclaw` CLI when missing in local mode; install via `openclaw.ai/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime. +- Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell. +- Cron/CLI: add `--model` flag to cron add/edit commands. (#711) — thanks @mjrussell. +- Cron/CLI: trim model overrides on cron edits and document main-session guidance. (#711) — thanks @mjrussell. +- Skills: bundle `skill-creator` to guide creating and packaging skills. +- Providers: add per-DM history limit overrides (`dmHistoryLimit`) with provider-level config. (#728) — thanks @pkrmf. +- Discord: expose channel/category management actions in the message tool. (#730) — thanks @NicholasSpisak. +- Docs: rename README “macOS app” section to “Apps”. (#733) — thanks @AbhisekBasu1. +- Gateway: require `client.id` in WebSocket connect params; use `client.instanceId` for presence de-dupe; update docs/tests. +- macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present. + +### Installer + +- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests. +- Postinstall: skip pnpm patch fallback when the new patcher is active. +- Installer tests: add root+non-root docker smokes, CI workflow to fetch openclaw.ai scripts and run install sh/cli with onboarding skipped. +- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git. +- Installer UX: add `install.sh --help` with flags/env and git install hint. +- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm). + +### Fixes + +- Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias). +- Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete. +- CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete. +- Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes. +- Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test. +- Gateway: tighten gateway listener detection. +- Control UI: hide onboarding chat when configured and guard the mobile chat sidebar overlay. +- Auth: read Codex keychain credentials and make the lookup platform-aware. +- macOS/Release: avoid bundling dist artifacts in relay builds and generate appcasts from zip-only sources. +- Doctor: surface plugin diagnostics in the report. +- Plugins: treat `plugins.load.paths` directory entries as package roots when they contain `package.json` + `openclaw.extensions`; load plugin packages from config dirs; extract archives without system tar. +- Config: expand `~` in `CLAWDBOT_CONFIG_PATH` and common path-like config fields (including `plugins.load.paths`); guard invalid `$include` paths. (#731) — thanks @pasogott. +- Agents: stop pre-creating session transcripts so first user messages persist in JSONL history. +- Agents: skip pre-compaction memory flush when the session workspace is read-only. +- Auto-reply: ignore inline `/status` directives unless the message is directive-only. +- Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo. +- Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight. +- Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc. +- Auto-reply: treat whitespace-only sender ids as missing for command authorization (WhatsApp self-chat). (#766) — thanks @steipete. +- Heartbeat: refresh prompt text for updated defaults. +- Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc. +- Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka. +- CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z. +- Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44. +- Agents: preserve reasoning items on tool-only turns. +- Agents/Subagents: wait for completion before announcing, align wait timeout with run timeout, and make announce prompts more emphatic. +- Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson. +- Agents/Tools: preserve action enums when flattening tool schemas. (#708) — thanks @xMikeMickelson. +- Gateway/Agents: canonicalize main session aliases for store writes and add regression coverage. (#709) — thanks @xMikeMickelson. +- Agents: reset sessions and retry when auto-compaction overflows instead of crashing the gateway. +- Providers/Telegram: normalize command mentions for consistent parsing. (#729) — thanks @obviyus. +- Providers: skip DM history limit handling for non-DM sessions. (#728) — thanks @pkrmf. +- Sandbox: fix non-main mode incorrectly sandboxing the main DM session and align `/status` runtime reporting with effective sandbox state. +- Sandbox/Gateway: treat `agent::main` as a main-session alias when `session.mainKey` is customized (backwards compatible). +- Auto-reply: fast-path allowlisted slash commands (inline `/help`/`/commands`/`/status`/`/whoami` stripped before model). + +## 2026.1.10 + +### Highlights + +- CLI: `openclaw status` now table-based + shows OS/update/gateway/daemon/agents/sessions; `status --all` adds a full read-only debug report (tables, log tails, Tailscale summary, and scan progress via OSC-9 + spinner). +- CLI Backends: add Codex CLI fallback with resume support (text output) and JSONL parsing for new runs, plus a live CLI resume probe. +- CLI: add `openclaw update` (safe-ish git checkout update) + `--update` shorthand. (#673) — thanks @fm1randa. +- Gateway: add OpenAI-compatible `/v1/chat/completions` HTTP endpoint (auth, SSE streaming, per-agent routing). (#680). + +### Changes + +- Onboarding/Models: add first-class Z.AI (GLM) auth choice (`zai-api-key`) + `--zai-api-key` flag. +- CLI/Onboarding: add OpenRouter API key auth option in configure/onboard. (#703) — thanks @mteam88. +- Agents: add human-delay pacing between block replies (modes: off/natural/custom, per-agent configurable). (#446) — thanks @tony-freedomology. +- Agents/Browser: add `browser.target` (sandbox/host/custom) with sandbox host-control gating via `agents.defaults.sandbox.browser.allowHostControl`, allowlists for custom control URLs/hosts/ports, and expand browser tool docs (remote control, profiles, internals). +- Onboarding/Models: add catalog-backed default model picker to onboarding + configure. (#611) — thanks @jonasjancarik. +- Agents/OpenCode Zen: update fallback models + defaults, keep legacy alias mappings. (#669) — thanks @magimetal. +- CLI: add `openclaw reset` and `openclaw uninstall` flows (interactive + non-interactive) plus docker cleanup smoke test. +- Providers: move provider wiring to a plugin architecture. (#661). +- Providers: unify group history context wrappers across providers with per-provider/per-account `historyLimit` overrides (fallback to `messages.groupChat.historyLimit`). Set `0` to disable. (#672). +- Gateway/Heartbeat: optionally deliver heartbeat `Reasoning:` output (`agents.defaults.heartbeat.includeReasoning`). (#690) +- Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo. + +### Fixes + +- Auto-reply: suppress draft/typing streaming for `NO_REPLY` (silent system ops) so it doesn’t leak partial output. +- CLI/Status: expand tables to full terminal width; clarify provider setup vs runtime warnings; richer per-provider detail; token previews in `status` while keeping `status --all` redacted; add troubleshooting link footer; keep log tails pasteable; show gateway auth used when reachable; surface provider runtime errors (Signal/iMessage/Slack); harden `tailscale status --json` parsing; make `status --all` scan progress determinate; and replace the footer with a 3-line “Next steps” recommendation (share/debug/probe). +- CLI/Gateway: clarify that `openclaw gateway status` reports RPC health (connect + RPC) and shows RPC failures separately from connect failures. +- CLI/Update: gate progress spinner on stdout TTY and align clean-check step label. (#701) — thanks @bjesuiter. +- Telegram: add `/whoami` + `/id` commands to reveal sender id for allowlists; allow `@username` and prefixed ids in `allowFrom` prompts (with stability warning). +- Heartbeat: strip markup-wrapped `HEARTBEAT_OK` so acks don’t leak to external providers (e.g., Telegram). +- Control UI: stop auto-writing `telegram.groups["*"]` and warn/confirm before enabling wildcard groups. +- WhatsApp: send ack reactions only for handled messages and ignore legacy `messages.ackReaction` (doctor copies to `whatsapp.ackReaction`). (#629) — thanks @pasogott. +- Sandbox/Skills: mirror skills into sandbox workspaces for read-only mounts so SKILL.md stays accessible. +- Terminal/Table: ANSI-safe wrapping to prevent table clipping/color loss; add regression coverage. +- Docker: allow optional apt packages during image build and document the build arg. (#697) — thanks @gabriel-trigo. +- Gateway/Heartbeat: deliver reasoning even when the main heartbeat reply is `HEARTBEAT_OK`. (#694) — thanks @antons. +- Agents/Pi: inject config `temperature`/`maxTokens` into streaming without replacing the session streamFn; cover with live maxTokens probe. (#732) — thanks @peschee. +- macOS: clear unsigned launchd overrides on signed restarts and warn via doctor when attach-only/disable markers are set. (#695) — thanks @jeffersonwarrior. +- Agents: enforce single-writer session locks and drop orphan tool results to prevent tool-call ID failures (MiniMax/Anthropic-compatible APIs). +- Docs: make `openclaw status` the first diagnostic step, clarify `status --deep` behavior, and document `/whoami` + `/id`. +- Docs/Testing: clarify live tool+image probes and how to list your testable `provider/model` ids. +- Tests/Live: make gateway bash+read probes resilient to provider formatting while still validating real tool calls. +- WhatsApp: detect @lid mentions in groups using authDir reverse mapping + resolve self JID E.164 for mention gating. (#692) — thanks @peschee. +- Gateway/Auth: default to token auth on loopback during onboarding, add doctor token generation flow, and tighten audio transcription config to Whisper-only. +- Providers: dedupe inbound messages across providers to avoid duplicate LLM runs on redeliveries/reconnects. (#689) — thanks @adam91holt. +- Agents: strip ``/`` tags from hidden reasoning output and cover tag variants in tests. (#688) — thanks @theglove44. +- macOS: save model picker selections as normalized provider/model IDs and keep manual entries aligned. (#683) — thanks @benithors. +- Agents: recognize "usage limit" errors as rate limits for failover. (#687) — thanks @evalexpr. +- CLI: avoid success message when daemon restart is skipped. (#685) — thanks @carlulsoe. +- Commands: disable `/config` + `/debug` by default; gate via `commands.config`/`commands.debug` and hide from native registration/help output. +- Agents/System: clarify that sub-agents remain sandboxed and cannot use elevated host access. +- Gateway: disable the OpenAI-compatible `/v1/chat/completions` endpoint by default; enable via `gateway.http.endpoints.chatCompletions.enabled=true`. +- macOS: stabilize bridge tunnels, guard invoke senders on disconnect, and drain stdout/stderr to avoid deadlocks. (#676) — thanks @ngutman. +- Agents/System: clarify sandboxed runtime in system prompt and surface elevated availability when sandboxed. +- Auto-reply: prefer `RawBody` for command/directive parsing (WhatsApp + Discord) and prevent fallback runs from clobbering concurrent session updates. (#643) — thanks @mcinteerj. +- WhatsApp: fix group reactions by preserving message IDs and sender JIDs in history; normalize participant phone numbers to JIDs in outbound reactions. (#640) — thanks @mcinteerj. +- WhatsApp: expose group participant IDs to the model so reactions can target the right sender. +- Cron: `wakeMode: "now"` waits for heartbeat completion (and retries when the main lane is busy). (#666) — thanks @roshanasingh4. +- Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 “required following item”) and replay reasoning items in Responses/Codex Responses history for tool-call-only turns. +- Sandbox: add `openclaw sandbox explain` (effective policy inspector + fix-it keys); improve “sandbox jail” tool-policy/elevated errors with actionable config key paths; link to docs. +- Hooks/Gmail: keep Tailscale serve path at `/` while preserving the public path. (#668) — thanks @antons. +- Hooks/Gmail: allow Tailscale target URLs to preserve internal serve paths. +- Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage. +- Auth: throttle external CLI credential syncs (Claude/Codex), reduce Keychain reads, and skip sync when cached credentials are still fresh. +- CLI: respect `CLAWDBOT_STATE_DIR` for node pairing + voice wake settings storage. (#664) — thanks @azade-c. +- Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage. +- Gateway/Control UI: make `chat.send` non-blocking, wire Stop to `chat.abort`, and treat `/stop` as an out-of-band abort. (#653) +- Gateway/Control UI: allow `chat.abort` without `runId` (abort active runs), suppress post-abort chat streaming, and prune stuck chat runs. (#653) +- Gateway/Control UI: sniff image attachments for chat.send, drop non-images, and log mismatches. (#670) — thanks @cristip73. +- macOS: force `restart-mac.sh --sign` to require identities and keep bundled Node signed for relay verification. (#580) — thanks @jeffersonwarrior. +- Gateway/Agent: accept image attachments on `agent` (multimodal message) and add live gateway image probe (`CLAWDBOT_LIVE_GATEWAY_IMAGE_PROBE=1`). +- CLI: `openclaw sessions` now includes `elev:*` + `usage:*` flags in the table output. +- CLI/Pairing: accept positional provider for `pairing list|approve` (npm-run compatible); update docs/bot hints. +- Branding: normalize legacy casing/branding to “OpenClaw” (CLI, status, docs). +- Auto-reply: fix native `/model` not updating the actual chat session (Telegram/Slack/Discord). (#646) +- Doctor: offer to run `openclaw update` first on git installs (keeps doctor output aligned with latest). +- Doctor: avoid false legacy workspace warning when install dir is `~/openclaw`. (#660) +- iMessage: fix reasoning persistence across DMs; avoid partial/duplicate replies when reasoning is enabled. (#655) — thanks @antons. +- Models/Auth: allow MiniMax API configs without `models.providers.minimax.apiKey` (auth profiles / `MINIMAX_API_KEY`). (#656) — thanks @mneves75. +- Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan. +- Agents: harden Cloud Code Assist tool ID sanitization (toolUse/toolCall/toolResult) and scrub extra JSON Schema constraints. (#665) — thanks @sebslight. +- Agents: sanitize tool results + Cloud Code Assist tool IDs at context-build time (prevents mid-run strict-provider request rejects). +- Agents/Tools: resolve workspace-relative Read/Write/Edit paths; align bash default cwd. (#642) — thanks @mukhtharcm. +- Discord: include forwarded message snapshots in agent session context. (#667) — thanks @rubyrunsstuff. +- Telegram: add `telegram.draftChunk` to tune draft streaming chunking for `streamMode: "block"`. (#667) — thanks @rubyrunsstuff. +- Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults. +- iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski. +- Auth: read Codex CLI keychain tokens on macOS before falling back to `~/.codex/auth.json`, preventing stale refresh tokens from breaking gateway live tests. +- Security/Exec approvals: reject shell command substitution (`$()` and backticks) inside double quotes to prevent exec allowlist bypass when exec allowlist mode is explicitly enabled (the default configuration does not use this mode). Thanks @simecek. +- iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks). +- Telegram: serialize media-group processing to avoid missed albums under load. +- Signal: handle `dataMessage.reaction` events (signal-cli SSE) to avoid broken attachment errors. (#637) — thanks @neist. +- Docs: showcase entries for ParentPay, R2 Upload, iOS TestFlight, and Oura Health. (#650) — thanks @henrino3. +- Agents: repair session transcripts by dropping duplicate tool results across the whole history (unblocks Anthropic-compatible APIs after retries). +- Tests/Live: reset the gateway session between model runs to avoid cross-provider transcript incompatibilities (notably OpenAI Responses reasoning replay rules). + +## 2026.1.9 + +### Highlights + +- Microsoft Teams provider: polling, attachments, outbound CLI send, per-channel policy. +- Models/Auth expansion: OpenCode Zen + MiniMax API onboarding; token auth profiles + auth order; OAuth health in doctor/status. +- CLI/Gateway UX: message subcommands, gateway discover/status/SSH, /config + /debug, sandbox CLI. +- Provider reliability sweep: WhatsApp contact cards/targets, Telegram audio-as-voice + streaming, Signal reactions, Slack threading, Discord stability. +- Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting. +- Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX. + +### Breaking + +- CLI: `openclaw message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured. +- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`. + +### New Features and Changes + +- Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff. +- Models/Auth: setup-token + token auth profiles; `openclaw models auth order {get,set,clear}`; per-agent auth candidates in `/model status`; OAuth expiry checks in doctor/status. +- Agent/System: claude-cli runner; `session_status` tool (and sandbox allow); adaptive context pruning default; system prompt messaging guidance + no auto self-update; eligible skills list injection; sub-agent context trimmed. +- Commands: `/commands` list; `/models` alias; `/usage` alias; `/debug` runtime overrides + effective config view; `/config` chat updates + `/config get`; `config --section`. +- CLI/Gateway: unified message tool + message subcommands; gateway discover (local + wide-area DNS-SD) with JSON/timeout; gateway status human-readable + JSON + SSH loopback; wide-area records include gatewayPort/sshPort/cliPath + tailnet DNS fallback. +- CLI UX: logs output modes (pretty/plain/JSONL) + colorized health/daemon output; global `--no-color`; lobster palette in onboarding/config. +- Dev ergonomics: gateway `--dev/--reset` + dev profile auto-config; C-3PO dev templates; dev gateway/TUI helper scripts. +- Sandbox/Workspace: sandbox list/recreate commands; sync skills into sandbox workspace; sandbox browser auto-start. +- Config/Onboarding: inline env vars; OpenAI API key flow to shared `~/.openclaw/.env`; Opus 4.5 default prompt for Anthropic auth; QuickStart auto-install gateway (Node-only) + provider picker tweaks + skip-systemd flags; TUI bootstrap prompt (`tui --message`); remove Bun runtime choice. +- Providers: Microsoft Teams provider (polling, attachments, outbound sends, requireMention, config reload/DM policy). (#404) — thanks @onutc +- Providers: WhatsApp broadcast groups for multi-agent replies (#547) — thanks @pasogott; inbound media size cap configurable (#505) — thanks @koala73; identity-based message prefixes (#578) — thanks @p6l-richard. +- Providers: Telegram inline keyboard buttons + callback payload routing (#491) — thanks @azade-c; cron topic delivery targets (#474/#478) — thanks @mitschabaude-bot, @nachoiacovino; `[[audio_as_voice]]` tag support (#490) — thanks @jarvis-medmatic. +- Providers: Signal reactions + notifications with allowlist support. +- Status/Usage: /status cost reporting + `/cost` lines; auth profile snippet; provider usage windows. +- Control UI: mobile responsiveness (#558) — thanks @carlulsoe; queued messages + Enter-to-send (#527) — thanks @YuriNachos; session links (#471) — thanks @HazAT; reasoning view; skill install feedback (#445) — thanks @pkrmf; chat layout refresh (#475) — thanks @rahthakor; docs link + new session button; drop explicit `ui:install`. +- TUI: agent picker + agents list RPC; improved status line. +- Doctor/Daemon: audit/repair flows, permissions checks, supervisor config audits; provider status probes + warnings for Discord intents and Telegram privacy; last activity timestamps; gateway restart guidance. +- Docs: Hetzner Docker VPS guide + cross-links (#556/#592) — thanks @Iamadig; Ansible guide (#545) — thanks @pasogott; provider troubleshooting index; hook parameter expansion (#532) — thanks @mcinteerj; model allowlist notes; OAuth deep dive; showcase refresh. +- Apps/Branding: refreshed iOS/Android/macOS icons (#521) — thanks @fishfisher. + +### Fixes + +- Packaging: include MS Teams send module in npm tarball. +- Sandbox/Browser: auto-start CDP endpoint; proxy CDP out of container for attachOnly; relax Bun fetch typing; align sandbox list output with config images. +- Agents/Runtime: gate heartbeat prompt to default sessions; /stop aborts between tool calls; require explicit system-event session keys; guard small context windows; fix model fallback stringification; sessions_spawn inherits provider; failover on billing/credits; respect auth cooldown ordering; restore Anthropic OAuth tool dispatch + tool-name bypass; avoid OpenAI invalid reasoning replay; harden Gmail hook model defaults. +- Agent history/schema: strip/skip empty assistant/error blocks to prevent session corruption/Claude 400s; scrub unsupported JSON Schema keywords + sanitize tool call IDs for Cloud Code Assist; simplify Gemini-compatible tool/session schemas; require raw for config.apply. +- Auto-reply/Streaming: default audioAsVoice false; preserve audio_as_voice propagation + buffer audio blocks + guard voice notes; block reply ordering (timeout) + forced-block fence-safe; avoid chunk splits inside parentheses + fence-close breaks + invalid UTF-16 truncation; preserve inline directive spacing + allow whitespace in reply tags; filter NO_REPLY prefixes + normalize routed replies; suppress leakage with separate Reasoning; block streaming defaults (off by default, minChars/idle tuning) + coalesced blocks; dedupe followup queue; restore explicit responsePrefix default. +- Status/Commands: provider prefix in /status model display; usage filtering + provider mapping; auth label + usage snapshots (claude-cli fallback + optional claude.ai); show Verbose/Elevated only when enabled; compact usage/cost line + restore emoji-rich status; /status in directive-only + multi-directive handling; mention-bypass elevated handling; surface provider usage errors; wire /usage to /status; restore hidden gateway-daemon alias; fallback /model list when catalog unavailable. +- WhatsApp: vCard/contact cards (prefer FN, include numbers, show all contacts, keep summary counts, better empty summaries); preserve group JIDs + normalize targets; resolve @lid mappings/JIDs (Baileys/auth-dir) + inbound mapping; route queued replies to sender; improve web listener errors + remove provider name from errors; record outbound activity account id; fix web media fetch errors; broadcast group history consistency. +- Telegram: keep streamMode draft-only; long-poll conflict retries + update dedupe; grammY fetch mismatch fixes + restrict native fetch to Bun; suppress getUpdates stack traces; include user id in pairing; audio_as_voice handling fixes. +- Discord/Slack: thread context helpers + forum thread starters; avoid category parent overrides; gateway reconnect logs + HELLO timeout + stop provider after reconnect exhaustion; DM recipient parsing for numeric IDs; remove incorrect limited warning; reply threading + mrkdwn edge cases; remove ack reactions after reply; gateway debug event visibility. +- Signal: reaction handling safety; own-reaction matching (uuid+phone); UUID-only senders accepted; ignore reaction-only messages. +- MS Teams: download image attachments reliably; fix top-level replies; stop on shutdown + honor chunk limits; normalize poll providers/deps; pairing label fixes. +- iMessage: isolate group-ish threads by chat_id. +- Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle MoltbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling. +- Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off. +- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag. + +### Maintenance + +- Dependencies: bump pi-\* stack to 0.42.2. +- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj. +- Build: Docker build cache layer (#605) — thanks @zknicker. + +- Auth: enable OAuth token refresh for Claude Code CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt). + +## 2026.1.8 + +### Highlights + +- Security: DMs locked down by default across providers; pairing-first + allowlist guidance. +- Sandbox: per-agent scope defaults + workspace access controls; tool/session isolation tuned. +- Agent loop: compaction, pruning, streaming, and error handling hardened. +- Providers: Telegram/WhatsApp/Discord/Slack reliability, threading, reactions, media, and retries improved. +- Control UI: logs tab, streaming stability, focus mode, and large-output rendering fixes. +- CLI/Gateway/Doctor: daemon/logs/status, auth migration, and diagnostics significantly expanded. + +### Breaking + +- **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack. + - Previously, if you didn’t configure an allowlist, your bot could be **open to anyone** (especially discoverable Telegram bots). + - New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`). + - To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`). + - Approve requests via `openclaw pairing list ` + `openclaw pairing approve `. +- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation. +- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only). +- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup. +- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context. +- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior. +- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides). +- CLI: remove `update`, `gateway-daemon`, `gateway {install|uninstall|start|stop|restart|daemon status|wake|send|agent}`, and `telegram` commands; move `login/logout` to `providers login/logout` (top-level aliases hidden); use `daemon` for service control, `send`/`agent`/`wake` for RPC, and `nodes canvas` for canvas ops. + +### Fixes + +- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints. +- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking. +- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification. +- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram. +- **Gateway/CLI UX:** `openclaw logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification. +- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth. +- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes. +- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install. +- **Docs:** new FAQ/ClawHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs. + +### Maintenance + +- Skills additions (Himalaya email, CodexBar, 1Password). +- Dependency refreshes (pi-\* stack, Slack SDK, discord-api-types, file-type, zod, Biome, Vite). + +## 2026.1.5 + +### Highlights + +- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support. +- Agent tools: new `image` tool routed to the image model (when configured). +- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`). +- Docs: document built-in model shorthands + precedence (user config wins). +- Bun: optional local install/build workflow without maintaining a Bun lockfile (see `docs/bun.md`). + +### Fixes + +- Control UI: render Markdown in tool result cards. +- Control UI: prevent overlapping action buttons in Discord guild rules on narrow layouts. +- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids +- Cron tool uses `id` for update/remove/run/runs (aligns with gateway params). (#180) — thanks @adamgall +- Control UI: chat view uses page scroll with sticky header/sidebar and fixed composer (no inner scroll frame). +- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639 +- macOS: make generated gateway protocol models `Sendable` for Swift 6 strict concurrency. (#195) — thanks @andranik-sahakyan +- macOS: bundle QR code renderer modules so DMG gateway boot doesn't crash on missing qrcode-terminal vendor files. +- macOS: parse JSON5 config safely to avoid wiping user settings when comments are present. +- WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj +- WhatsApp: mark offline history sync messages as read without auto-reply. (#193) — thanks @mcinteerj +- Discord: avoid duplicate replies when a provider emits late streaming `text_end` events (OpenAI/GPT). +- CLI: use tailnet IP for local gateway calls when bind is tailnet/auto (fixes #176). +- Env: load global `$OPENCLAW_STATE_DIR/.env` (`~/.openclaw/.env`) as a fallback after CWD `.env`. +- Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env). +- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas). +- Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`). +- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed). +- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off. +- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events. +- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler. +- CLI: run `openclaw agent` via the Gateway by default; use `--local` to force embedded mode. diff --git a/backend/app/one_person_security_dept/openclaw/CLAUDE.md b/backend/app/one_person_security_dept/openclaw/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/backend/app/one_person_security_dept/openclaw/CONTRIBUTING.md b/backend/app/one_person_security_dept/openclaw/CONTRIBUTING.md new file mode 100644 index 00000000..1386bc48 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/CONTRIBUTING.md @@ -0,0 +1,150 @@ +# Contributing to OpenClaw + +Welcome to the lobster tank! 🦞 + +## Quick Links + +- **GitHub:** https://github.com/openclaw/openclaw +- **Vision:** [`VISION.md`](VISION.md) +- **Discord:** https://discord.gg/qkhbAGHRBT +- **X/Twitter:** [@steipete](https://x.com/steipete) / [@openclaw](https://x.com/openclaw) + +## Maintainers + +- **Peter Steinberger** - Benevolent Dictator + - GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete) + +- **Shadow** - Discord subsystem, Discord admin, Clawhub, all community moderation + - GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed) + +- **Vignesh** - Memory (QMD), formal modeling, TUI, IRC, and Lobster + - GitHub: [@vignesh07](https://github.com/vignesh07) · X: [@\_vgnsh](https://x.com/_vgnsh) + +- **Jos** - Telegram, API, Nix mode + - GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes) + +- **Ayaan Zaidi** - Telegram subsystem, iOS app + - GitHub: [@obviyus](https://github.com/obviyus) · X: [@0bviyus](https://x.com/0bviyus) + +- **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app + - GitHub: [@tyler6204](https://github.com/tyler6204) · X: [@tyleryust](https://x.com/tyleryust) + +- **Mariano Belinky** - iOS app, Security + - GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad) + +- **Vincent Koc** - Agents, Telemetry, Hooks, Security + - GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc) + +- **Val Alexander** - UI/UX, Docs, and Agent DevX + - GitHub: [@BunsDev](https://github.com/BunsDev) · X: [@BunsDev](https://x.com/BunsDev) + +- **Seb Slight** - Docs, Agent Reliability, Runtime Hardening + - GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig) + +- **Christoph Nakazawa** - JS Infra + - GitHub: [@cpojer](https://github.com/cpojer) · X: [@cnakazawa](https://x.com/cnakazawa) + +- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI + - GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras) + +- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams + - GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz) + +## How to Contribute + +1. **Bugs & small fixes** → Open a PR! +2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first +3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) + +## Before You PR + +- Test locally with your OpenClaw instance +- Run tests: `pnpm build && pnpm check && pnpm test` +- Ensure CI checks pass +- Keep PRs focused (one thing per PR; do not mix unrelated concerns) +- Describe what & why + +## Control UI Decorators + +The Control UI uses Lit with **legacy** decorators (current Rollup parsing does not support +`accessor` fields required for standard decorators). When adding reactive fields, keep the +legacy style: + +```ts +@state() foo = "bar"; +@property({ type: Number }) count = 0; +``` + +The root `tsconfig.json` is configured for legacy decorators (`experimentalDecorators: true`) +with `useDefineForClassFields: false`. Avoid flipping these unless you are also updating the UI +build tooling to support standard decorators. + +## AI/Vibe-Coded PRs Welcome! 🤖 + +Built with Codex, Claude, or other AI tools? **Awesome - just mark it!** + +Please include in your PR: + +- [ ] Mark as AI-assisted in the PR title or description +- [ ] Note the degree of testing (untested / lightly tested / fully tested) +- [ ] Include prompts or session logs if possible (super helpful!) +- [ ] Confirm you understand what the code does + +AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. + +## Current Focus & Roadmap 🗺 + +We are currently prioritizing: + +- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram). +- **UX**: Improving the onboarding wizard and error messages. +- **Skills**: For skill contributions, head to [ClawHub](https://clawhub.ai/) — the community hub for OpenClaw skills. +- **Performance**: Optimizing token usage and compaction logic. + +Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels! + +## Maintainers + +We're selectively expanding the maintainer team. +If you're an experienced contributor who wants to help shape OpenClaw's direction — whether through code, docs, or community — we'd like to hear from you. + +Being a maintainer is a responsibility, not an honorary title. We expect active, consistent involvement — triaging issues, reviewing PRs, and helping move the project forward. + +Still interested? Email contributing@openclaw.ai with: + +- Links to your PRs on OpenClaw (if you don't have any, start there first) +- Links to open source projects you maintain or actively contribute to +- Your GitHub, Discord, and X/Twitter handles +- A brief intro: background, experience, and areas of interest +- Languages you speak and where you're based +- How much time you can realistically commit + +We welcome people across all skill sets — engineering, documentation, community management, and more. +We review every human-only-written application carefully and add maintainers slowly and deliberately. +Please allow a few weeks for a response. + +## Report a Vulnerability + +We take security reports seriously. Report vulnerabilities directly to the repository where the issue lives: + +- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw) +- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos) +- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios) +- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android) +- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub) +- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust) + +For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it. + +### Required in Reports + +1. **Title** +2. **Severity Assessment** +3. **Impact** +4. **Affected Component** +5. **Technical Reproduction** +6. **Demonstrated Impact** +7. **Environment** +8. **Remediation Advice** + +Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues. diff --git a/backend/app/one_person_security_dept/openclaw/Dockerfile b/backend/app/one_person_security_dept/openclaw/Dockerfile new file mode 100644 index 00000000..255340cb --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Dockerfile @@ -0,0 +1,65 @@ +FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935 + +# Install Bun (required for build scripts) +RUN curl -fsSL https://bun.sh/install | bash +ENV PATH="/root/.bun/bin:${PATH}" + +RUN corepack enable + +WORKDIR /app +RUN chown node:node /app + +ARG OPENCLAW_DOCKER_APT_PACKAGES="" +RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ + apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ + fi + +COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY --chown=node:node ui/package.json ./ui/package.json +COPY --chown=node:node patches ./patches +COPY --chown=node:node scripts ./scripts + +USER node +RUN pnpm install --frozen-lockfile + +# Optionally install Chromium and Xvfb for browser automation. +# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ... +# Adds ~300MB but eliminates the 60-90s Playwright install on every container start. +# Must run after pnpm install so playwright-core is available in node_modules. +USER root +ARG OPENCLAW_INSTALL_BROWSER="" +RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ + apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \ + mkdir -p /home/node/.cache/ms-playwright && \ + PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \ + node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \ + chown -R node:node /home/node/.cache/ms-playwright && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ + fi + +USER node +COPY --chown=node:node . . +RUN pnpm build +# Force pnpm for UI build (Bun may fail on ARM/Synology architectures) +ENV OPENCLAW_PREFER_PNPM=1 +RUN pnpm ui:build + +ENV NODE_ENV=production + +# Security hardening: Run as non-root user +# The node:22-bookworm image includes a 'node' user (uid 1000) +# This reduces the attack surface by preventing container escape via root privileges +USER node + +# Start gateway server with default config. +# Binds to loopback (127.0.0.1) by default for security. +# +# For container platforms requiring external health checks: +# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var +# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"] +CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"] diff --git a/backend/app/one_person_security_dept/openclaw/Dockerfile.sandbox b/backend/app/one_person_security_dept/openclaw/Dockerfile.sandbox new file mode 100644 index 00000000..a463d4a1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Dockerfile.sandbox @@ -0,0 +1,20 @@ +FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + jq \ + python3 \ + ripgrep \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --create-home --shell /bin/bash sandbox +USER sandbox +WORKDIR /home/sandbox + +CMD ["sleep", "infinity"] diff --git a/backend/app/one_person_security_dept/openclaw/Dockerfile.sandbox-browser b/backend/app/one_person_security_dept/openclaw/Dockerfile.sandbox-browser new file mode 100644 index 00000000..ec9faf71 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Dockerfile.sandbox-browser @@ -0,0 +1,32 @@ +FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + chromium \ + curl \ + fonts-liberation \ + fonts-noto-color-emoji \ + git \ + jq \ + novnc \ + python3 \ + socat \ + websockify \ + x11vnc \ + xvfb \ + && rm -rf /var/lib/apt/lists/* + +COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser +RUN chmod +x /usr/local/bin/openclaw-sandbox-browser + +RUN useradd --create-home --shell /bin/bash sandbox +USER sandbox +WORKDIR /home/sandbox + +EXPOSE 9222 5900 6080 + +CMD ["openclaw-sandbox-browser"] diff --git a/backend/app/one_person_security_dept/openclaw/Dockerfile.sandbox-common b/backend/app/one_person_security_dept/openclaw/Dockerfile.sandbox-common new file mode 100644 index 00000000..71f80070 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Dockerfile.sandbox-common @@ -0,0 +1,45 @@ +ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim +FROM ${BASE_IMAGE} + +USER root + +ENV DEBIAN_FRONTEND=noninteractive + +ARG PACKAGES="curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file" +ARG INSTALL_PNPM=1 +ARG INSTALL_BUN=1 +ARG BUN_INSTALL_DIR=/opt/bun +ARG INSTALL_BREW=1 +ARG BREW_INSTALL_DIR=/home/linuxbrew/.linuxbrew +ARG FINAL_USER=sandbox + +ENV BUN_INSTALL=${BUN_INSTALL_DIR} +ENV HOMEBREW_PREFIX=${BREW_INSTALL_DIR} +ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar +ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew +ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH} + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ${PACKAGES} \ + && rm -rf /var/lib/apt/lists/* + +RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi + +RUN if [ "${INSTALL_BUN}" = "1" ]; then \ + curl -fsSL https://bun.sh/install | bash; \ + ln -sf "${BUN_INSTALL_DIR}/bin/bun" /usr/local/bin/bun; \ +fi + +RUN if [ "${INSTALL_BREW}" = "1" ]; then \ + if ! id -u linuxbrew >/dev/null 2>&1; then useradd -m -s /bin/bash linuxbrew; fi; \ + mkdir -p "${BREW_INSTALL_DIR}"; \ + chown -R linuxbrew:linuxbrew "$(dirname "${BREW_INSTALL_DIR}")"; \ + su - linuxbrew -c "NONINTERACTIVE=1 CI=1 /bin/bash -c '$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'"; \ + if [ ! -e "${BREW_INSTALL_DIR}/Library" ]; then ln -s "${BREW_INSTALL_DIR}/Homebrew/Library" "${BREW_INSTALL_DIR}/Library"; fi; \ + if [ ! -x "${BREW_INSTALL_DIR}/bin/brew" ]; then echo \"brew install failed\"; exit 1; fi; \ + ln -sf "${BREW_INSTALL_DIR}/bin/brew" /usr/local/bin/brew; \ +fi + +# Default is sandbox, but allow BASE_IMAGE overrides to select another final user. +USER ${FINAL_USER} + diff --git a/backend/app/one_person_security_dept/openclaw/LICENSE b/backend/app/one_person_security_dept/openclaw/LICENSE new file mode 100644 index 00000000..f7b52669 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Peter Steinberger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/app/one_person_security_dept/openclaw/PR_STATUS.md b/backend/app/one_person_security_dept/openclaw/PR_STATUS.md new file mode 100644 index 00000000..1887eca2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/PR_STATUS.md @@ -0,0 +1,78 @@ +# OpenClaw PR Submission Status + +> Auto-maintained by agent team. Last updated: 2026-02-22 + +## PR Plan Overview + +All PRs target upstream `openclaw/openclaw` via fork `kevinWangSheng/openclaw`. +Each PR follows [CONTRIBUTING.md](./CONTRIBUTING.md) and uses the [PR template](./.github/PULL_REQUEST_TEMPLATE.md). + +## Duplicate Check + +Before submission, each PR was cross-referenced against: + +- 100+ open upstream PRs (as of 2026-02-22) +- 50 recently merged PRs +- 50+ open issues + +No overlap found with existing PRs. + +## PR Status Table + +| # | Branch | Title | Type | Status | PR URL | +| --- | -------------------------------------- | --------------------------------------------------------------------------- | -------- | --------------- | --------------------------------------------------------- | +| 1 | `security/redos-safe-regex` | fix(security): add ReDoS protection for user-controlled regex patterns | Security | CI Pass | [#23670](https://github.com/openclaw/openclaw/pull/23670) | +| 2 | `security/session-slug-crypto-random` | fix(security): use crypto.randomInt for session slug generation | Security | CI Pass | [#23671](https://github.com/openclaw/openclaw/pull/23671) | +| 3 | `fix/json-parse-crash-guard` | fix(resilience): guard JSON.parse of external process output with try-catch | Bug fix | CI Pass | [#23672](https://github.com/openclaw/openclaw/pull/23672) | +| 4 | `refactor/console-to-subsystem-logger` | refactor(logging): migrate remaining console calls to subsystem logger | Refactor | CI Pass | [#23669](https://github.com/openclaw/openclaw/pull/23669) | +| 5 | `fix/sanitize-rpc-error-messages` | fix(security): sanitize RPC error messages in signal and imessage clients | Security | CI Pass | [#23724](https://github.com/openclaw/openclaw/pull/23724) | +| 6 | `fix/download-stream-cleanup` | fix(resilience): destroy write streams on download errors | Bug fix | CI Pass | [#23726](https://github.com/openclaw/openclaw/pull/23726) | +| 7 | `fix/telegram-status-reaction-cleanup` | fix(telegram): clear done reaction when removeAckAfterReply is true | Bug fix | CI Pass | [#23728](https://github.com/openclaw/openclaw/pull/23728) | +| 8 | `fix/session-cache-eviction` | fix(memory): add max size eviction to session manager cache | Bug fix | CI Pass (17/17) | [#23744](https://github.com/openclaw/openclaw/pull/23744) | +| 9 | `fix/fetch-missing-timeout` | fix(resilience): add timeout to unguarded fetch calls in browser subsystem | Bug fix | CI Pass (18/18) | [#23745](https://github.com/openclaw/openclaw/pull/23745) | +| 10 | `fix/skills-download-partial-cleanup` | fix(resilience): clean up partial file on skill download failure | Bug fix | CI Pass (19/19) | [#24141](https://github.com/openclaw/openclaw/pull/24141) | +| 11 | `fix/extension-relay-stop-cleanup` | fix(browser): flush pending extension timers on relay stop | Bug fix | CI Pass (20/20) | [#24142](https://github.com/openclaw/openclaw/pull/24142) | + +## Isolation Rules + +- Each agent works on a separate git worktree branch +- No two agents modify the same file +- File ownership: + - PR 1: `src/infra/exec-approval-forwarder.ts`, `src/discord/monitor/exec-approvals.ts` + - PR 2: `src/agents/session-slug.ts` + - PR 3: `src/infra/bonjour-discovery.ts`, `src/infra/outbound/delivery-queue.ts` + - PR 4: `src/infra/tailscale.ts`, `src/node-host/runner.ts` + - PR 5: `src/signal/client.ts`, `src/imessage/client.ts` + - PR 6: `src/media/store.ts`, `src/commands/signal-install.ts` + - PR 7: `src/telegram/bot-message-dispatch.ts` + - PR 8: `src/agents/pi-embedded-runner/session-manager-cache.ts` + - PR 9: `src/cli/nodes-camera.ts`, `src/browser/pw-session.ts` + - PR 10: `src/agents/skills-install-download.ts` + - PR 11: `src/browser/extension-relay.ts` + +## Verification Results + +### Batch 1 (PRs 1-4) — All CI Green + +- PR 1: 17 tests pass, check/build/tests all green +- PR 2: 3 tests pass, check/build/tests all green +- PR 3: 45 tests pass (3 new), check/build/tests all green +- PR 4: 12 tests pass, check/build/tests all green + +### Batch 2 (PRs 5-7) — CI Running + +- PR 5: 3 signal tests pass, check pass, awaiting full test suite +- PR 6: 38 tests pass (20 media + 18 signal-install), check pass, awaiting full suite +- PR 7: 47 tests pass (3 new), check pass, awaiting full suite + +### Batch 3 (PRs 8-9) — All CI Green + +- PR 8 & 9: Initially failed due to pre-existing upstream TS errors + Windows flaky test. Fixed by rebasing onto latest upstream/main and removing `yieldMs: 10` from flaky sandbox test. +- PR 8: 17/17 pass, check/build/tests/windows all green +- PR 9: 18/18 pass, check/build/tests/windows all green + +### Batch 4 (PRs 10-11) — All CI Green + +- PR 10 & 11: Initially failed Windows flaky test (`yieldMs: 10` race). Fixed by removing `yieldMs: 10` from flaky sandbox test (same fix as PRs 8-9). +- PR 10: 19/19 pass, check/build/tests/windows all green +- PR 11: 20/20 pass, check/build/tests/windows all green diff --git a/backend/app/one_person_security_dept/openclaw/README.md b/backend/app/one_person_security_dept/openclaw/README.md new file mode 100644 index 00000000..1dcad2b7 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/README.md @@ -0,0 +1,555 @@ +# 🦞 OpenClaw — Personal AI Assistant + +

+ + + OpenClaw + +

+ +

+ EXFOLIATE! EXFOLIATE! +

+ +

+ CI status + GitHub release + Discord + MIT License +

+ +**OpenClaw** is a _personal AI assistant_ you run on your own devices. +It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. + +If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. + +[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) + +Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal. +The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**. +Works with npm, pnpm, or bun. +New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started) + +## Sponsors + +| OpenAI | Blacksmith | +| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| [![OpenAI](docs/assets/sponsors/openai.svg)](https://openai.com/) | [![Blacksmith](docs/assets/sponsors/blacksmith.svg)](https://blacksmith.sh/) | + +**Subscriptions (OAuth):** + +- **[OpenAI](https://openai.com/)** (ChatGPT/Codex) + +Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding). + +## Models (selection + auth) + +- Models config + CLI: [Models](https://docs.openclaw.ai/concepts/models) +- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.openclaw.ai/concepts/model-failover) + +## Install (recommended) + +Runtime: **Node ≥22**. + +```bash +npm install -g openclaw@latest +# or: pnpm add -g openclaw@latest + +openclaw onboard --install-daemon +``` + +The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running. + +## Quick start (TL;DR) + +Runtime: **Node ≥22**. + +Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started) + +```bash +openclaw onboard --install-daemon + +openclaw gateway --port 18789 --verbose + +# Send a message +openclaw message send --to +1234567890 --message "Hello from OpenClaw" + +# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat) +openclaw agent --message "Ship checklist" --thinking high +``` + +Upgrading? [Updating guide](https://docs.openclaw.ai/install/updating) (and run `openclaw doctor`). + +## Development channels + +- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-`), npm dist-tag `latest`. +- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing). +- **dev**: moving head of `main`, npm dist-tag `dev` (when published). + +Switch channels (git + npm): `openclaw update --channel stable|beta|dev`. +Details: [Development channels](https://docs.openclaw.ai/install/development-channels). + +## From source (development) + +Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly. + +```bash +git clone https://github.com/openclaw/openclaw.git +cd openclaw + +pnpm install +pnpm ui:build # auto-installs UI deps on first run +pnpm build + +pnpm openclaw onboard --install-daemon + +# Dev loop (auto-reload on TS changes) +pnpm gateway:watch +``` + +Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary. + +## Security defaults (DM access) + +OpenClaw connects to real messaging surfaces. Treat inbound DMs as **untrusted input**. + +Full security guide: [Security](https://docs.openclaw.ai/gateway/security) + +Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack: + +- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dmPolicy="pairing"` / `channels.slack.dmPolicy="pairing"`; legacy: `channels.discord.dm.policy`, `channels.slack.dm.policy`): unknown senders receive a short pairing code and the bot does not process their message. +- Approve with: `openclaw pairing approve ` (then the sender is added to a local allowlist store). +- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`). + +Run `openclaw doctor` to surface risky/misconfigured DM policies. + +## Highlights + +- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events. +- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android. +- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions). +- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs. +- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui). +- **[First-class tools](https://docs.openclaw.ai/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions. +- **[Companion apps](https://docs.openclaw.ai/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes). +- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills. + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=openclaw/openclaw&type=date&legend=top-left)](https://www.star-history.com/#openclaw/openclaw&type=date&legend=top-left) + +## Everything we built so far + +### Core platform + +- [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui). +- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor). +- [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming. +- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/channels/groups). +- [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio). + +### Channels + +- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat). +- [Group routing](https://docs.openclaw.ai/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels). + +### Apps + nodes + +- [macOS app](https://docs.openclaw.ai/platforms/macos): menu bar control plane, [Voice Wake](https://docs.openclaw.ai/nodes/voicewake)/PTT, [Talk Mode](https://docs.openclaw.ai/nodes/talk) overlay, [WebChat](https://docs.openclaw.ai/web/webchat), debug tools, [remote gateway](https://docs.openclaw.ai/gateway/remote) control. +- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour pairing. +- [Android node](https://docs.openclaw.ai/platforms/android): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, optional SMS. +- [macOS node mode](https://docs.openclaw.ai/nodes): system.run/notify + canvas/camera exposure. + +### Tools + automation + +- [Browser control](https://docs.openclaw.ai/tools/browser): dedicated openclaw Chrome/Chromium, snapshots, actions, uploads, profiles. +- [Canvas](https://docs.openclaw.ai/platforms/mac/canvas): [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot. +- [Nodes](https://docs.openclaw.ai/nodes): camera snap/clip, screen record, [location.get](https://docs.openclaw.ai/nodes/location-command), notifications. +- [Cron + wakeups](https://docs.openclaw.ai/automation/cron-jobs); [webhooks](https://docs.openclaw.ai/automation/webhook); [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub). +- [Skills platform](https://docs.openclaw.ai/tools/skills): bundled, managed, and workspace skills with install gating + UI. + +### Runtime + safety + +- [Channel routing](https://docs.openclaw.ai/channels/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming). +- [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking). +- [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning). +- [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting). + +### Ops + packaging + +- [Control UI](https://docs.openclaw.ai/web) + [WebChat](https://docs.openclaw.ai/web/webchat) served directly from the Gateway. +- [Tailscale Serve/Funnel](https://docs.openclaw.ai/gateway/tailscale) or [SSH tunnels](https://docs.openclaw.ai/gateway/remote) with token/password auth. +- [Nix mode](https://docs.openclaw.ai/install/nix) for declarative config; [Docker](https://docs.openclaw.ai/install/docker)-based installs. +- [Doctor](https://docs.openclaw.ai/gateway/doctor) migrations, [logging](https://docs.openclaw.ai/logging). + +## How it works (short) + +``` +WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat + │ + ▼ +┌───────────────────────────────┐ +│ Gateway │ +│ (control plane) │ +│ ws://127.0.0.1:18789 │ +└──────────────┬────────────────┘ + │ + ├─ Pi agent (RPC) + ├─ CLI (openclaw …) + ├─ WebChat UI + ├─ macOS app + └─ iOS / Android nodes +``` + +## Key subsystems + +- **[Gateway WebSocket network](https://docs.openclaw.ai/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.openclaw.ai/gateway)). +- **[Tailscale exposure](https://docs.openclaw.ai/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.openclaw.ai/gateway/remote)). +- **[Browser control](https://docs.openclaw.ai/tools/browser)** — openclaw‑managed Chrome/Chromium with CDP control. +- **[Canvas + A2UI](https://docs.openclaw.ai/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui)). +- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always‑on speech and continuous conversation. +- **[Nodes](https://docs.openclaw.ai/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`. + +## Tailscale access (Gateway dashboard) + +OpenClaw can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`: + +- `off`: no Tailscale automation (default). +- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default). +- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth). + +Notes: + +- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (OpenClaw enforces this). +- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`. +- Funnel refuses to start unless `gateway.auth.mode: "password"` is set. +- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown. + +Details: [Tailscale guide](https://docs.openclaw.ai/gateway/tailscale) · [Web surfaces](https://docs.openclaw.ai/web) + +## Remote Gateway (Linux is great) + +It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed. + +- **Gateway host** runs the exec tool and channel connections by default. +- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`. + In short: exec runs where the Gateway lives; device actions run where the device lives. + +Details: [Remote access](https://docs.openclaw.ai/gateway/remote) · [Nodes](https://docs.openclaw.ai/nodes) · [Security](https://docs.openclaw.ai/gateway/security) + +## macOS permissions via the Gateway protocol + +The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`: + +- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`). +- `system.notify` posts a user notification and fails if notifications are denied. +- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status. + +Elevated bash (host permissions) is separate from macOS TCC: + +- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted. +- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`. + +Details: [Nodes](https://docs.openclaw.ai/nodes) · [macOS app](https://docs.openclaw.ai/platforms/macos) · [Gateway protocol](https://docs.openclaw.ai/concepts/architecture) + +## Agent to Agent (sessions\_\* tools) + +- Use these to coordinate work across sessions without jumping between chat surfaces. +- `sessions_list` — discover active sessions (agents) and their metadata. +- `sessions_history` — fetch transcript logs for a session. +- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`). + +Details: [Session tools](https://docs.openclaw.ai/concepts/session-tool) + +## Skills registry (ClawHub) + +ClawHub is a minimal skill registry. With ClawHub enabled, the agent can search for skills automatically and pull in new ones as needed. + +[ClawHub](https://clawhub.com) + +## Chat commands + +Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only): + +- `/status` — compact session status (model + tokens, cost when available) +- `/new` or `/reset` — reset the session +- `/compact` — compact session context (summary) +- `/think ` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only) +- `/verbose on|off` +- `/usage off|tokens|full` — per-response usage footer +- `/restart` — restart the gateway (owner-only in groups) +- `/activation mention|always` — group activation toggle (groups only) + +## Apps (optional) + +The Gateway alone delivers a great experience. All apps are optional and add extra features. + +If you plan to build/run companion apps, follow the platform runbooks below. + +### macOS (OpenClaw.app) (optional) + +- Menu bar control for the Gateway and health. +- Voice Wake + push-to-talk overlay. +- WebChat + debug tools. +- Remote gateway control over SSH. + +Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`). + +### iOS node (optional) + +- Pairs as a node via the Bridge. +- Voice trigger forwarding + Canvas surface. +- Controlled via `openclaw nodes …`. + +Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios). + +### Android node (optional) + +- Pairs via the same Bridge + pairing flow as iOS. +- Exposes Canvas, Camera, and Screen capture commands. +- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android). + +## Agent workspace + skills + +- Workspace root: `~/.openclaw/workspace` (configurable via `agents.defaults.workspace`). +- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`. +- Skills: `~/.openclaw/workspace/skills//SKILL.md`. + +## Configuration + +Minimal `~/.openclaw/openclaw.json` (model + defaults): + +```json5 +{ + agent: { + model: "anthropic/claude-opus-4-6", + }, +} +``` + +[Full configuration reference (all keys + examples).](https://docs.openclaw.ai/gateway/configuration) + +## Security model (important) + +- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you. +- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions. +- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`. + +Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker + sandboxing](https://docs.openclaw.ai/install/docker) · [Sandbox config](https://docs.openclaw.ai/gateway/configuration) + +### [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) + +- Link the device: `pnpm openclaw channels login` (stores creds in `~/.openclaw/credentials`). +- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`. +- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all. + +### [Telegram](https://docs.openclaw.ai/channels/telegram) + +- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins). +- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` as needed. + +```json5 +{ + channels: { + telegram: { + botToken: "123456:ABCDEF", + }, + }, +} +``` + +### [Slack](https://docs.openclaw.ai/channels/slack) + +- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`). + +### [Discord](https://docs.openclaw.ai/channels/discord) + +- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins). +- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed. + +```json5 +{ + channels: { + discord: { + token: "1234abcd", + }, + }, +} +``` + +### [Signal](https://docs.openclaw.ai/channels/signal) + +- Requires `signal-cli` and a `channels.signal` config section. + +### [BlueBubbles (iMessage)](https://docs.openclaw.ai/channels/bluebubbles) + +- **Recommended** iMessage integration. +- Configure `channels.bluebubbles.serverUrl` + `channels.bluebubbles.password` and a webhook (`channels.bluebubbles.webhookPath`). +- The BlueBubbles server runs on macOS; the Gateway can run on macOS or elsewhere. + +### [iMessage (legacy)](https://docs.openclaw.ai/channels/imessage) + +- Legacy macOS-only integration via `imsg` (Messages must be signed in). +- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all. + +### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) + +- Configure a Teams app + Bot Framework, then add a `msteams` config section. +- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`. + +### [WebChat](https://docs.openclaw.ai/web/webchat) + +- Uses the Gateway WebSocket; no separate WebChat port/config. + +Browser control (optional): + +```json5 +{ + browser: { + enabled: true, + color: "#FF4500", + }, +} +``` + +## Docs + +Use these when you’re past the onboarding flow and want the deeper reference. + +- [Start with the docs index for navigation and “what’s where.”](https://docs.openclaw.ai) +- [Read the architecture overview for the gateway + protocol model.](https://docs.openclaw.ai/concepts/architecture) +- [Use the full configuration reference when you need every key and example.](https://docs.openclaw.ai/gateway/configuration) +- [Run the Gateway by the book with the operational runbook.](https://docs.openclaw.ai/gateway) +- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.openclaw.ai/web) +- [Understand remote access over SSH tunnels or tailnets.](https://docs.openclaw.ai/gateway/remote) +- [Follow the onboarding wizard flow for a guided setup.](https://docs.openclaw.ai/start/wizard) +- [Wire external triggers via the webhook surface.](https://docs.openclaw.ai/automation/webhook) +- [Set up Gmail Pub/Sub triggers.](https://docs.openclaw.ai/automation/gmail-pubsub) +- [Learn the macOS menu bar companion details.](https://docs.openclaw.ai/platforms/mac/menu-bar) +- [Platform guides: Windows (WSL2)](https://docs.openclaw.ai/platforms/windows), [Linux](https://docs.openclaw.ai/platforms/linux), [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android) +- [Debug common failures with the troubleshooting guide.](https://docs.openclaw.ai/channels/troubleshooting) +- [Review security guidance before exposing anything.](https://docs.openclaw.ai/gateway/security) + +## Advanced docs (discovery + control) + +- [Discovery + transports](https://docs.openclaw.ai/gateway/discovery) +- [Bonjour/mDNS](https://docs.openclaw.ai/gateway/bonjour) +- [Gateway pairing](https://docs.openclaw.ai/gateway/pairing) +- [Remote gateway README](https://docs.openclaw.ai/gateway/remote-gateway-readme) +- [Control UI](https://docs.openclaw.ai/web/control-ui) +- [Dashboard](https://docs.openclaw.ai/web/dashboard) + +## Operations & troubleshooting + +- [Health checks](https://docs.openclaw.ai/gateway/health) +- [Gateway lock](https://docs.openclaw.ai/gateway/gateway-lock) +- [Background process](https://docs.openclaw.ai/gateway/background-process) +- [Browser troubleshooting (Linux)](https://docs.openclaw.ai/tools/browser-linux-troubleshooting) +- [Logging](https://docs.openclaw.ai/logging) + +## Deep dives + +- [Agent loop](https://docs.openclaw.ai/concepts/agent-loop) +- [Presence](https://docs.openclaw.ai/concepts/presence) +- [TypeBox schemas](https://docs.openclaw.ai/concepts/typebox) +- [RPC adapters](https://docs.openclaw.ai/reference/rpc) +- [Queue](https://docs.openclaw.ai/concepts/queue) + +## Workspace & skills + +- [Skills config](https://docs.openclaw.ai/tools/skills-config) +- [Default AGENTS](https://docs.openclaw.ai/reference/AGENTS.default) +- [Templates: AGENTS](https://docs.openclaw.ai/reference/templates/AGENTS) +- [Templates: BOOTSTRAP](https://docs.openclaw.ai/reference/templates/BOOTSTRAP) +- [Templates: IDENTITY](https://docs.openclaw.ai/reference/templates/IDENTITY) +- [Templates: SOUL](https://docs.openclaw.ai/reference/templates/SOUL) +- [Templates: TOOLS](https://docs.openclaw.ai/reference/templates/TOOLS) +- [Templates: USER](https://docs.openclaw.ai/reference/templates/USER) + +## Platform internals + +- [macOS dev setup](https://docs.openclaw.ai/platforms/mac/dev-setup) +- [macOS menu bar](https://docs.openclaw.ai/platforms/mac/menu-bar) +- [macOS voice wake](https://docs.openclaw.ai/platforms/mac/voicewake) +- [iOS node](https://docs.openclaw.ai/platforms/ios) +- [Android node](https://docs.openclaw.ai/platforms/android) +- [Windows (WSL2)](https://docs.openclaw.ai/platforms/windows) +- [Linux app](https://docs.openclaw.ai/platforms/linux) + +## Email hooks (Gmail) + +- [docs.openclaw.ai/gmail-pubsub](https://docs.openclaw.ai/automation/gmail-pubsub) + +## Molty + +OpenClaw was built for **Molty**, a space lobster AI assistant. 🦞 +by Peter Steinberger and the community. + +- [openclaw.ai](https://openclaw.ai) +- [soul.md](https://soul.md) +- [steipete.me](https://steipete.me) +- [@openclaw](https://x.com/openclaw) + +## Community + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs. +AI/vibe-coded PRs welcome! 🤖 + +Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for +[pi-mono](https://github.com/badlogic/pi-mono). +Special thanks to Adam Doppelt for lobster.bot. + +Thanks to all clawtributors: + +

+ steipete sktbrd cpojer joshp123 Mariano Belinky Takhoffman sebslight tyler6204 quotentiroler Verite Igiraneza + gumadeiras bohdanpodvirnyi vincentkoc iHildy jaydenfyi Glucksberg joaohlisboa rodrigouroz mneves75 BunsDev + MatthieuBizien MaudeBot vignesh07 smartprogrammer93 advaitpaliwal HenryLoenwind rahthakor vrknetha abdelsfane radek-paclt + joshavant christianklotz mudrii zerone0x ranausmanai Tobias Bischoff heyhudson czekaj ethanpalm yinghaosang + nabbilkhan mukhtharcm aether-ai-agent coygeek Mrseenz maxsumrall xadenryan VACInc juanpablodlc conroywhitney + Harald Buerbaumer akoscz Bridgerz hsrvc magimetal openclaw-bot meaningfool JustasM Phineas1500 ENCHIGO + Hiren Patel NicholasSpisak claude jonisjongithub theonejvo abhisekbasu1 Ryan Haines Blakeshannon jamesgroat Marvae + arosstale shakkernerd gejifeng divanoli ryan-crabbe nyanjou Sam Padilla dantelex SocialNerd42069 solstead + natefikru daveonkels LeftX Yida-Dev Masataka Shinohara Lewis riccardogiorato lc0rp adam91holt mousberg + BillChirico shadril238 CharlieGreenman hougangdev Mars orlyjamie McRolly NWANGWU LI SHANXIN Simone Macario durenzidu + JustYannicc Minidoracat magendary Jessy LANGE mteam88 brandonwise hirefrank M00N7682 dbhurley Eng. Juan Combetto + Harrington-bot TSavo Lalit Singh julianengel Jay Caldwell Kirill Shchetynin nachx639 bradleypriest TsekaLuk benithors + Shailesh thewilloftheshadow jackheuberger loiie45e El-Fitz benostein pvtclawn 0xRaini ruypang xinhuagu + Taylor Asplund adhitShet Paul van Oorschot sreekaransrinath buddyh gupsammy AI-Reviewer-QS Stefan Galescu WalterSumbon nachoiacovino + rodbland2021 Vasanth Rao Naik Sabavat fagemx petter-b omair445 dorukardahan leszekszpunar Clawborn davidrudduck scald + Igor Markelov rrenamed Parker Todd Brooks AnonO6 Tanwa Arpornthip andranik-sahakyan davidguttman sleontenko denysvitali Tom Ron + popomore Patrick Barletta shayan919293 不做了睡大觉 Luis Conde Harry Cui Kepler SidQin-cyber Lucky Michael Lee sircrumpet + peschee dakshaymehta davidiach nonggia.liang seheepeak obviyus danielwanwx osolmaz minupla misterdas + Shuai-DaiDai dominicnunez lploc94 sfo2001 lutr0 dirbalak cathrynlavery Joly0 kiranjd niceysam + danielz1z Iranb carrotRakko Oceanswave cdorsey AdeboyeDN j2h4u Alg0rix Skyler Miao peetzweg/ + TideFinder CornBrother0x DukeDeSouth emanuelst bsormagec Diaspar4u evanotero Nate OscarMinjarez webvijayi + garnetlyx miloudbelarebia Jeremiah Lowin liebertar Max rhuanssauro joshrad-dev adityashaw2 CashWilliams taw0002 + asklee-klawd h0tp-ftw constansino mcaxtr onutc ryan unisone artuskg Solvely-Colin pahdo + Kimitaka Watanabe Lilo Rajat Joshi Yuting Lin Neo wu-tian807 ngutman crimeacs manuelhettich mcinteerj + bjesuiter Manik Vahsith alexgleason Nicholas Stephen Brian King justinhuangcode mahanandhi andreesg connorshea dinakars777 + Flash-LHR JINNYEONG KIM Protocol Zero kyleok Limitless grp06 robbyczgw-cla slonce70 JayMishra-source ide-rea + lailoo badlogic echoVic amitbiswal007 azade-c John Rood dddabtc Jonathan Works roshanasingh4 tosh-hamburg + dlauer ezhikkk Shivam Kumar Raut Mykyta Bozhenko YuriNachos Josh Phillips ThomsenDrake Wangnov akramcodez jadilson12 + Whoaa512 clawdinator[bot] emonty kaizen403 chriseidhof Lukavyi wangai-studio ysqander aj47 google-labs-jules[bot] + hyf0-agent Jeremy Mumford Kenny Lee superman32432432 widingmarcus-cyber DylanWoodAkers antons austinm911 boris721 damoahdominic + dan-dr doodlewind GHesericsu HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf + Randy Torres sumleo Yeom-JinHo akyourowngames aldoeliacim Dithilli dougvk erikpr1994 fal3 jonasjancarik + koala73 mitschabaude-bot mkbehr Oren shtse8 sibbl thesomewhatyou zats chrisrodz frankekn + gabriel-trigo ghsmc iamadig ibrahimq21 irtiq7 jeann2013 jogelin Jonathan D. Rhyne (DJ-D) Justin Ling kelvinCB + manmal Matthew MattQ Milofax mitsuhiko neist pejmanjohn ProspectOre rmorse rubyrunsstuff + rybnikov santiagomed Steve (OpenClaw) suminhthanh svkozak wes-davis 24601 AkashKobal ameno- awkoy + battman21 BinHPdev bonald dashed dawondyifraw dguido Django Navarro evalexpr henrino3 humanwritten + hyojin joeykrug larlyssa liuy Mark Liu natedenh odysseus0 pcty-nextgen-service-account pi0 Syhids + tmchow uli-will-code aaronveklabs andreabadesso BinaryMuse cash-echo-bot CJWTRUST cordx56 danballance Elarwei001 + EnzeD erik-agens Evizero fcatuhe gildo Grynn huntharo hydro13 itsjaydesu ivanrvpereira + jverdi kentaro loeclos longmaba MarvinCui MisterGuy420 mjrussell odnxe optimikelabs oswalpalash + p6l-richard philipp-spiess RamiNoodle733 Raymond Berger Rob Axelsen sauerdaniel SleuthCo T5-AndyML TaKO8Ki thejhinvirtuoso + travisp yudshj zknicker 0oAstro 8BlT Abdul535 abhaymundhara aduk059 afurm aisling404 + akari-musubi Alex-Alaniz alexanderatallah alexstyl andrewting19 araa47 Asleep123 Ayush10 bennewton999 bguidolim + caelum0x championswimmer Chloe-VP dario-github DarwinsBuddy David-Marsh-Photo dcantu96 dndodson dvrshil dxd5001 + dylanneve1 EmberCF ephraimm ereid7 eternauta1337 foeken gtsifrikas HazAT iamEvanYT ikari-pl + kesor knocte MackDing nobrainer-tech Noctivoro Olshansk Pratham Dubey Raikan10 SecondThread Swader + testingabc321 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou carlulsoe hrdwdmrbl hugobarauna jayhickey jiulingyun + kitze latitudeki5223 loukotal minghinmatthewlam MSch odrobnik rafaelreis-r ratulsarna reeltimeapps rhjoh + ronak-guliani snopoke thesash timkrase +

diff --git a/backend/app/one_person_security_dept/openclaw/SECURITY.md b/backend/app/one_person_security_dept/openclaw/SECURITY.md new file mode 100644 index 00000000..eb42a335 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/SECURITY.md @@ -0,0 +1,261 @@ +# Security Policy + +If you believe you've found a security issue in OpenClaw, please report it privately. + +## Reporting + +Report vulnerabilities directly to the repository where the issue lives: + +- **Core CLI and gateway** — [openclaw/openclaw](https://github.com/openclaw/openclaw) +- **macOS desktop app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/macos) +- **iOS app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/ios) +- **Android app** — [openclaw/openclaw](https://github.com/openclaw/openclaw) (apps/android) +- **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub) +- **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust) + +For issues that don't fit a specific repo, or if you're unsure, email **[security@openclaw.ai](mailto:security@openclaw.ai)** and we'll route it. + +For full reporting instructions see our [Trust page](https://trust.openclaw.ai). + +### Required in Reports + +1. **Title** +2. **Severity Assessment** +3. **Impact** +4. **Affected Component** +5. **Technical Reproduction** +6. **Demonstrated Impact** +7. **Environment** +8. **Remediation Advice** + +Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues. + +### Report Acceptance Gate (Triage Fast Path) + +For fastest triage, include all of the following: + +- Exact vulnerable path (`file`, function, and line range) on a current revision. +- Tested version details (OpenClaw version and/or commit SHA). +- Reproducible PoC against latest `main` or latest released version. +- Demonstrated impact tied to OpenClaw's documented trust boundaries. +- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services). +- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config. +- Scope check explaining why the report is **not** covered by the Out of Scope section below. + +Reports that miss these requirements may be closed as `invalid` or `no-action`. + +### Common False-Positive Patterns + +These are frequently reported but are typically closed with no code change: + +- Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope). +- Operator-intended local features (for example TUI local `!` shell) presented as remote injection. +- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass. +- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it. +- Reports that assume per-user multi-tenant authorization on a shared gateway host/config. +- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass. +- Missing HSTS findings on default local/loopback deployments. +- Slack webhook signature findings when HTTP mode already uses signing-secret verification. +- Discord inbound webhook signature findings for paths not used by this repo's Discord integration. +- Scanner-only claims against stale/nonexistent paths, or claims without a working repro. + +### Duplicate Report Handling + +- Search existing advisories before filing. +- Include likely duplicate GHSA IDs in your report when applicable. +- Maintainers may close lower-quality/later duplicates in favor of the earliest high-quality canonical report. + +## Security & Trust + +**Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) is Security & Trust at OpenClaw. Jamieson is the founder of [Dvuln](https://dvuln.com) and brings extensive experience in offensive security, penetration testing, and security program development. + +## Bug Bounties + +OpenClaw is a labor of love. There is no bug bounty program and no budget for paid reports. Please still disclose responsibly so we can fix issues quickly. +The best way to help the project right now is by sending PRs. + +## Maintainers: GHSA Updates via CLI + +When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (or newer). Without it, some fields (notably CVSS) may not persist even if the request returns 200. + +## Operator Trust Model (Important) + +OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary. + +- Authenticated Gateway callers are treated as trusted operators for that gateway instance. +- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries. +- If one operator can view data from another operator on the same gateway, that is expected in this trust model. +- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary. +- Recommended mode: one user per machine/host (or VPS), one gateway for that user, and one or more agents inside that gateway. +- If multiple users need OpenClaw, use one VPS (or host/OS user boundary) per user. +- For advanced setups, multiple gateways on one machine are possible, but only with strict isolation and are not the recommended default. +- Exec behavior is host-first by default: `agents.defaults.sandbox.mode` defaults to `off`. +- `tools.exec.host` defaults to `sandbox` as a routing preference, but if sandbox runtime is not active for the session, exec runs on the gateway host. +- Implicit exec calls (no explicit host in the tool call) follow the same behavior. +- This is expected in OpenClaw's one-user trusted-operator model. If you need isolation, enable sandbox mode (`non-main`/`all`) and keep strict tool policy. + +## Trusted Plugin Concept (Core) + +Plugins/extensions are part of OpenClaw's trusted computing base for a gateway. + +- Installing or enabling a plugin grants it the same trust level as local code running on that gateway host. +- Plugin behavior such as reading env/files or running host commands is expected inside this trust boundary. +- Security reports must show a boundary bypass (for example unauthenticated plugin load, allowlist/policy bypass, or sandbox/path-safety bypass), not only malicious behavior from a trusted-installed plugin. + +## Out of Scope + +- Public Internet Exposure +- Using OpenClaw in ways that the docs recommend not to +- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads) +- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass) +- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`) +- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary +- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior). +- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design) +- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses. +- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact +- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass. + +## Deployment Assumptions + +OpenClaw security guidance assumes: + +- The host where OpenClaw runs is within a trusted OS/admin boundary. +- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator. +- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary. +- Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries. +- Multiple gateway instances can run on one machine, but the recommended model is clean per-user isolation (prefer one host/VPS per user). + +## One-User Trust Model (Personal Assistant) + +OpenClaw's security model is "personal assistant" (one trusted operator, potentially many agents), not "shared multi-tenant bus." + +- If multiple people can message the same tool-enabled agent (for example a shared Slack workspace), they can all steer that agent within its granted permissions. +- Session or memory scoping reduces context bleed, but does **not** create per-user host authorization boundaries. +- For mixed-trust or adversarial users, isolate by OS user/host/gateway and use separate credentials per boundary. +- A company-shared agent can be a valid setup when users are in the same trust boundary and the agent is strictly business-only. +- For company-shared setups, use a dedicated machine/VM/container and dedicated accounts; avoid mixing personal data on that runtime. +- If that host/browser profile is logged into personal accounts (for example Apple/Google/personal password manager), you have collapsed the boundary and increased personal-data exposure risk. + +## Agent and Model Assumptions + +- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior. +- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals. +- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries. + +## Gateway and Node trust concept + +OpenClaw separates routing from execution, but both remain inside the same operator trust boundary: + +- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway. +- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node. +- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary. +- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary. + +## Workspace Memory Trust Boundary + +`MEMORY.md` and `memory/*.md` are plain workspace files and are treated as trusted local operator state. + +- If someone can edit workspace memory files, they already crossed the trusted operator boundary. +- Memory search indexing/recall over those files is expected behavior, not a sandbox/security boundary. +- Example report pattern considered out of scope: "attacker writes malicious content into `memory/*.md`, then `memory_search` returns it." +- If you need isolation between mutually untrusted users, split by OS user or host and run separate gateways. + +## Plugin Trust Boundary + +Plugins/extensions are loaded **in-process** with the Gateway and are treated as trusted code. + +- Plugins can execute with the same OS privileges as the OpenClaw process. +- Runtime helpers (for example `runtime.system.runCommandWithTimeout`) are convenience APIs, not a sandbox boundary. +- Only install plugins you trust, and prefer `plugins.allow` to pin explicit trusted plugin ids. + +## Temp Folder Boundary (Media/Sandbox) + +OpenClaw uses a dedicated temp root for local media handoff and sandbox-adjacent temp artifacts: + +- Preferred temp root: `/tmp/openclaw` (when available and safe on the host). +- Fallback temp root: `os.tmpdir()/openclaw` (or `openclaw-` on multi-user hosts). + +Security boundary notes: + +- Sandbox media validation allows absolute temp paths only under the OpenClaw-managed temp root. +- Arbitrary host tmp paths are not treated as trusted media roots. +- Plugin/extension code should use OpenClaw temp helpers (`resolvePreferredOpenClawTmpDir`, `buildRandomTempFilePath`, `withTempDownloadPath`) rather than raw `os.tmpdir()` defaults when handling media files. +- Enforcement reference points: + - temp root resolver: `src/infra/tmp-openclaw-dir.ts` + - SDK temp helpers: `src/plugin-sdk/temp-path.ts` + - messaging/channel tmp guardrail: `scripts/check-no-random-messaging-tmp.mjs` + +## Operational Guidance + +For threat model + hardening guidance (including `openclaw security audit --deep` and `--fix`), see: + +- `https://docs.openclaw.ai/gateway/security` + +### Tool filesystem hardening + +- `tools.exec.applyPatch.workspaceOnly: true` (recommended): keeps `apply_patch` writes/deletes within the configured workspace directory. +- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory. +- Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution. + +### Web Interface Safety + +OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for **local use only**. + +- Recommended: keep the Gateway **loopback-only** (`127.0.0.1` / `::1`). + - Config: `gateway.bind="loopback"` (default). + - CLI: `openclaw gateway run --bind loopback`. +- `gateway.controlUi.dangerouslyDisableDeviceAuth` is intended for localhost-only break-glass use. + - OpenClaw keeps deployment flexibility by design and does not hard-forbid non-local setups. + - Non-local and other risky configurations are surfaced by `openclaw security audit` as dangerous findings. + - This operator-selected tradeoff is by design and not, by itself, a security vulnerability. +- Canvas host note: network-visible canvas is **intentional** for trusted node scenarios (LAN/tailnet). + - Expected setup: non-loopback bind + Gateway auth (token/password/trusted-proxy) + firewall/tailnet controls. + - Expected routes: `/__openclaw__/canvas/`, `/__openclaw__/a2ui/`. + - This deployment model alone is not a security vulnerability. +- Do **not** expose it to the public internet (no direct bind to `0.0.0.0`, no public reverse proxy). It is not hardened for public exposure. +- If you need remote access, prefer an SSH tunnel or Tailscale serve/funnel (so the Gateway still binds to loopback), plus strong Gateway auth. +- The Gateway HTTP surface includes the canvas host (`/__openclaw__/canvas/`, `/__openclaw__/a2ui/`). Treat canvas content as sensitive/untrusted and avoid exposing it beyond loopback unless you understand the risk. + +## Runtime Requirements + +### Node.js Version + +OpenClaw requires **Node.js 22.12.0 or later** (LTS). This version includes important security patches: + +- CVE-2025-59466: async_hooks DoS vulnerability +- CVE-2026-21636: Permission model bypass vulnerability + +Verify your Node.js version: + +```bash +node --version # Should be v22.12.0 or later +``` + +### Docker Security + +When running OpenClaw in Docker: + +1. The official image runs as a non-root user (`node`) for reduced attack surface +2. Use `--read-only` flag when possible for additional filesystem protection +3. Limit container capabilities with `--cap-drop=ALL` + +Example secure Docker run: + +```bash +docker run --read-only --cap-drop=ALL \ + -v openclaw-data:/app/data \ + openclaw/openclaw:latest +``` + +## Security Scanning + +This project uses `detect-secrets` for automated secret detection in CI/CD. +See `.detect-secrets.cfg` for configuration and `.secrets.baseline` for the baseline. + +Run locally: + +```bash +pip install detect-secrets==1.5.0 +detect-secrets scan --baseline .secrets.baseline +``` diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/.github/workflows/ci.yml b/backend/app/one_person_security_dept/openclaw/Swabble/.github/workflows/ci.yml new file mode 100644 index 00000000..aff600f6 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build-and-test: + runs-on: macos-latest + defaults: + run: + shell: bash + working-directory: swabble + steps: + - name: Checkout swabble + uses: actions/checkout@v4 + with: + path: swabble + + - name: Select Xcode 26.1 (prefer 26.1.1) + run: | + set -euo pipefail + # pick the newest installed 26.1.x, fallback to newest 26.x + CANDIDATE="$(ls -d /Applications/Xcode_26.1*.app 2>/dev/null | sort -V | tail -1 || true)" + if [[ -z "$CANDIDATE" ]]; then + CANDIDATE="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)" + fi + if [[ -z "$CANDIDATE" ]]; then + echo "No Xcode 26.x found on runner" >&2 + exit 1 + fi + echo "Selecting $CANDIDATE" + sudo xcode-select -s "$CANDIDATE" + xcodebuild -version + + - name: Show Swift version + run: swift --version + + - name: Install tooling + run: | + brew update + brew install swiftlint swiftformat + + - name: Format check + run: | + ./scripts/format.sh + git diff --exit-code + + - name: Lint + run: ./scripts/lint.sh + + - name: Test + run: swift test --parallel diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/.gitignore b/backend/app/one_person_security_dept/openclaw/Swabble/.gitignore new file mode 100644 index 00000000..e988a5b2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/.gitignore @@ -0,0 +1,33 @@ +# macOS +.DS_Store + +# SwiftPM / Build +/.build +/.swiftpm +/DerivedData +xcuserdata/ +*.xcuserstate + +# Editors +/.vscode +.idea/ + +# Xcode artifacts +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Playgrounds +*.xcplayground +playground.xcworkspace +timeline.xctimeline + +# Carthage +Carthage/Build/ + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/.swiftformat b/backend/app/one_person_security_dept/openclaw/Swabble/.swiftformat new file mode 100644 index 00000000..2686269a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/.swiftformat @@ -0,0 +1,8 @@ +--swiftversion 6.2 +--indent 4 +--maxwidth 120 +--wraparguments before-first +--wrapcollections before-first +--stripunusedargs closure-only +--self remove +--header "" diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/.swiftlint.yml b/backend/app/one_person_security_dept/openclaw/Swabble/.swiftlint.yml new file mode 100644 index 00000000..f63ff5db --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/.swiftlint.yml @@ -0,0 +1,43 @@ +# SwiftLint for swabble +included: + - Sources +excluded: + - .build + - DerivedData + - "**/.swiftpm" + - "**/.build" + - "**/DerivedData" + - "**/.DS_Store" +opt_in_rules: + - array_init + - closure_spacing + - explicit_init + - fatal_error_message + - first_where + - joined_default_parameter + - last_where + - literal_expression_end_indentation + - multiline_arguments + - multiline_parameters + - operator_usage_whitespace + - redundant_nil_coalescing + - sorted_first_last + - switch_case_alignment + - vertical_parameter_alignment_on_call + - vertical_whitespace_opening_braces + - vertical_whitespace_closing_braces + +disabled_rules: + - trailing_whitespace + - trailing_newline + - indentation_width + - identifier_name + - explicit_self + - file_header + - todo + +line_length: + warning: 140 + error: 180 + +reporter: "xcode" diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/CHANGELOG.md b/backend/app/one_person_security_dept/openclaw/Swabble/CHANGELOG.md new file mode 100644 index 00000000..e8f2ad60 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 0.2.0 — 2025-12-23 + +### Highlights +- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection). +- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only. + +### Changes +- CLI wake-word matching/stripping routed through `SwabbleKit` helpers. +- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability. diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/LICENSE b/backend/app/one_person_security_dept/openclaw/Swabble/LICENSE new file mode 100644 index 00000000..f7b52669 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Peter Steinberger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Package.resolved b/backend/app/one_person_security_dept/openclaw/Swabble/Package.resolved new file mode 100644 index 00000000..f52a51fb --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Package.resolved @@ -0,0 +1,69 @@ +{ + "originHash" : "24a723309d7a0039d3df3051106f77ac1ed7068a02508e3a6804e41d757e6c72", + "pins" : [ + { + "identity" : "commander", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/Commander.git", + "state" : { + "revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce", + "version" : "0.2.1" + } + }, + { + "identity" : "elevenlabskit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/ElevenLabsKit", + "state" : { + "revision" : "7e3c948d8340abe3977014f3de020edf221e9269", + "version" : "0.1.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swift-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-testing", + "state" : { + "revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211", + "version" : "0.99.0" + } + }, + { + "identity" : "swiftui-math", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swiftui-math", + "state" : { + "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", + "version" : "0.1.0" + } + }, + { + "identity" : "textual", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/textual", + "state" : { + "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38", + "version" : "0.3.1" + } + } + ], + "version" : 3 +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Package.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Package.swift new file mode 100644 index 00000000..9f5a0003 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Package.swift @@ -0,0 +1,55 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "swabble", + platforms: [ + .macOS(.v15), + .iOS(.v17), + ], + products: [ + .library(name: "Swabble", targets: ["Swabble"]), + .library(name: "SwabbleKit", targets: ["SwabbleKit"]), + .executable(name: "swabble", targets: ["SwabbleCLI"]), + ], + dependencies: [ + .package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"), + .package(url: "https://github.com/apple/swift-testing", from: "0.99.0"), + ], + targets: [ + .target( + name: "Swabble", + path: "Sources/SwabbleCore", + swiftSettings: []), + .target( + name: "SwabbleKit", + path: "Sources/SwabbleKit", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .executableTarget( + name: "SwabbleCLI", + dependencies: [ + "Swabble", + "SwabbleKit", + .product(name: "Commander", package: "Commander"), + ], + path: "Sources/swabble"), + .testTarget( + name: "SwabbleKitTests", + dependencies: [ + "SwabbleKit", + .product(name: "Testing", package: "swift-testing"), + ], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("SwiftTesting"), + ]), + .testTarget( + name: "swabbleTests", + dependencies: [ + "Swabble", + .product(name: "Testing", package: "swift-testing"), + ]), + ], + swiftLanguageModes: [.v6]) diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/README.md b/backend/app/one_person_security_dept/openclaw/Swabble/README.md new file mode 100644 index 00000000..bf6dc3dc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/README.md @@ -0,0 +1,111 @@ +# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26) + +swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps. + +- **Local-only**: Speech.framework on-device models; zero network usage. +- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass. +- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments). +- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout. +- **Services**: launchd helper stubs for start/stop/install. +- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits). + +## Quick start +```bash +# Install deps +brew install swiftformat swiftlint + +# Build +swift build + +# Write default config (~/.config/swabble/config.json) +swift run swabble setup + +# Run foreground daemon +swift run swabble serve + +# Test your hook +swift run swabble test-hook "hello world" + +# Transcribe a file to SRT +swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt +``` + +## Use as a library +Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product: + +```swift +// Package.swift +dependencies: [ + .package(url: "https://github.com/steipete/swabble.git", branch: "main"), +], +targets: [ + .target(name: "MyApp", dependencies: [ + .product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+) + .product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+) + ]), +] +``` + +## CLI +- `serve` — foreground loop (mic → wake → hook) +- `transcribe ` — offline transcription (txt|srt) +- `test-hook "text"` — invoke configured hook +- `mic list|set ` — enumerate/select input device +- `setup` — write default config JSON +- `doctor` — check Speech auth & device availability +- `health` — prints `ok` +- `tail-log` — last 10 transcripts +- `status` — show wake state + recent transcripts +- `service install|uninstall|status` — user launchd plist (stub: prints launchctl commands) +- `start|stop|restart` — placeholders until full launchd wiring + +All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `--log-level`), plus `--config` where applicable. + +## Config +`~/.config/swabble/config.json` (auto-created by `setup`): +```json +{ + "audio": {"deviceName": "", "deviceIndex": -1, "sampleRate": 16000, "channels": 1}, + "wake": {"enabled": true, "word": "clawd", "aliases": ["claude"]}, + "hook": { + "command": "", + "args": [], + "prefix": "Voice swabble from ${hostname}: ", + "cooldownSeconds": 1, + "minCharacters": 24, + "timeoutSeconds": 5, + "env": {} + }, + "logging": {"level": "info", "format": "text"}, + "transcripts": {"enabled": true, "maxEntries": 50}, + "speech": {"localeIdentifier": "en_US", "etiquetteReplacements": false} +} +``` + +- Config path override: `--config /path/to/config.json` on relevant commands. +- Transcripts persist to `~/Library/Application Support/swabble/transcripts.log`. + +## Hook protocol +When a wake-gated transcript passes min_chars & cooldown, swabble runs: +``` + "" +``` +Environment variables: +- `SWABBLE_TEXT` — stripped transcript (wake word removed) +- `SWABBLE_PREFIX` — rendered prefix (hostname substituted) +- plus any `hook.env` key/values + +## Speech pipeline +- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module. +- Requests volatile + final results; the CLI uses text-only wake gating today. +- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs. + +## Development +- Format: `./scripts/format.sh` (uses local `.swiftformat`) +- Lint: `./scripts/lint.sh` (uses local `.swiftlint.yml`) +- Tests: `swift test` (uses swift-testing package) + +## Roadmap +- launchd control (load/bootout, PID + status socket) +- JSON logging + PII redaction toggle +- Stronger wake-word detection and control socket status/health diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Config/Config.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Config/Config.swift new file mode 100644 index 00000000..4dc9d466 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Config/Config.swift @@ -0,0 +1,77 @@ +import Foundation + +public struct SwabbleConfig: Codable, Sendable { + public struct Audio: Codable, Sendable { + public var deviceName: String = "" + public var deviceIndex: Int = -1 + public var sampleRate: Double = 16000 + public var channels: Int = 1 + } + + public struct Wake: Codable, Sendable { + public var enabled: Bool = true + public var word: String = "clawd" + public var aliases: [String] = ["claude"] + } + + public struct Hook: Codable, Sendable { + public var command: String = "" + public var args: [String] = [] + public var prefix: String = "Voice swabble from ${hostname}: " + public var cooldownSeconds: Double = 1 + public var minCharacters: Int = 24 + public var timeoutSeconds: Double = 5 + public var env: [String: String] = [:] + } + + public struct Logging: Codable, Sendable { + public var level: String = "info" + public var format: String = "text" // text|json placeholder + } + + public struct Transcripts: Codable, Sendable { + public var enabled: Bool = true + public var maxEntries: Int = 50 + } + + public struct Speech: Codable, Sendable { + public var localeIdentifier: String = Locale.current.identifier + public var etiquetteReplacements: Bool = false + } + + public var audio = Audio() + public var wake = Wake() + public var hook = Hook() + public var logging = Logging() + public var transcripts = Transcripts() + public var speech = Speech() + + public static let defaultPath = FileManager.default + .homeDirectoryForCurrentUser + .appendingPathComponent(".config/swabble/config.json") + + public init() {} +} + +public enum ConfigError: Error { + case missingConfig +} + +public enum ConfigLoader { + public static func load(at path: URL?) throws -> SwabbleConfig { + let url = path ?? SwabbleConfig.defaultPath + if !FileManager.default.fileExists(atPath: url.path) { + throw ConfigError.missingConfig + } + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(SwabbleConfig.self, from: data) + } + + public static func save(_ config: SwabbleConfig, at path: URL?) throws { + let url = path ?? SwabbleConfig.defaultPath + let dir = url.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(config) + try data.write(to: url) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Hooks/HookExecutor.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Hooks/HookExecutor.swift new file mode 100644 index 00000000..dd59c43b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Hooks/HookExecutor.swift @@ -0,0 +1,75 @@ +import Foundation + +public struct HookJob: Sendable { + public let text: String + public let timestamp: Date + + public init(text: String, timestamp: Date) { + self.text = text + self.timestamp = timestamp + } +} + +public actor HookExecutor { + private let config: SwabbleConfig + private var lastRun: Date? + private let hostname: String + + public init(config: SwabbleConfig) { + self.config = config + hostname = Host.current().localizedName ?? "host" + } + + public func shouldRun() -> Bool { + guard config.hook.cooldownSeconds > 0 else { return true } + if let lastRun, Date().timeIntervalSince(lastRun) < config.hook.cooldownSeconds { + return false + } + return true + } + + public func run(job: HookJob) async throws { + guard shouldRun() else { return } + guard !config.hook.command.isEmpty else { throw NSError( + domain: "Hook", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) } + + let prefix = config.hook.prefix.replacingOccurrences(of: "${hostname}", with: hostname) + let payload = prefix + job.text + + let process = Process() + process.executableURL = URL(fileURLWithPath: config.hook.command) + process.arguments = config.hook.args + [payload] + + var env = ProcessInfo.processInfo.environment + env["SWABBLE_TEXT"] = job.text + env["SWABBLE_PREFIX"] = prefix + for (k, v) in config.hook.env { + env[k] = v + } + process.environment = env + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + + let timeoutNanos = UInt64(max(config.hook.timeoutSeconds, 0.1) * 1_000_000_000) + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + process.waitUntilExit() + } + group.addTask { + try await Task.sleep(nanoseconds: timeoutNanos) + if process.isRunning { + process.terminate() + } + } + try await group.next() + group.cancelAll() + } + lastRun = Date() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Speech/BufferConverter.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Speech/BufferConverter.swift new file mode 100644 index 00000000..e6d7dc99 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Speech/BufferConverter.swift @@ -0,0 +1,50 @@ +@preconcurrency import AVFoundation +import Foundation + +final class BufferConverter { + private final class Box: @unchecked Sendable { var value: T; init(_ value: T) { self.value = value } } + enum ConverterError: Swift.Error { + case failedToCreateConverter + case failedToCreateConversionBuffer + case conversionFailed(NSError?) + } + + private var converter: AVAudioConverter? + + func convert(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> AVAudioPCMBuffer { + let inputFormat = buffer.format + if inputFormat == format { + return buffer + } + if converter == nil || converter?.outputFormat != format { + converter = AVAudioConverter(from: inputFormat, to: format) + converter?.primeMethod = .none + } + guard let converter else { throw ConverterError.failedToCreateConverter } + + let sampleRateRatio = converter.outputFormat.sampleRate / converter.inputFormat.sampleRate + let scaledInputFrameLength = Double(buffer.frameLength) * sampleRateRatio + let frameCapacity = AVAudioFrameCount(scaledInputFrameLength.rounded(.up)) + guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity) + else { + throw ConverterError.failedToCreateConversionBuffer + } + + var nsError: NSError? + let consumed = Box(false) + let inputBuffer = buffer + let status = converter.convert(to: conversionBuffer, error: &nsError) { _, statusPtr in + if consumed.value { + statusPtr.pointee = .noDataNow + return nil + } + consumed.value = true + statusPtr.pointee = .haveData + return inputBuffer + } + if status == .error { + throw ConverterError.conversionFailed(nsError) + } + return conversionBuffer + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Speech/SpeechPipeline.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Speech/SpeechPipeline.swift new file mode 100644 index 00000000..014b174d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Speech/SpeechPipeline.swift @@ -0,0 +1,114 @@ +import AVFoundation +import Foundation +import Speech + +@available(macOS 26.0, iOS 26.0, *) +public struct SpeechSegment: Sendable { + public let text: String + public let isFinal: Bool +} + +@available(macOS 26.0, iOS 26.0, *) +public enum SpeechPipelineError: Error { + case authorizationDenied + case analyzerFormatUnavailable + case transcriberUnavailable +} + +/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline. +@available(macOS 26.0, iOS 26.0, *) +public actor SpeechPipeline { + private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer } + + private var engine = AVAudioEngine() + private var transcriber: SpeechTranscriber? + private var analyzer: SpeechAnalyzer? + private var inputContinuation: AsyncStream.Continuation? + private var resultTask: Task? + private let converter = BufferConverter() + + public init() {} + + public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream { + let auth = await requestAuthorizationIfNeeded() + guard auth == .authorized else { throw SpeechPipelineError.authorizationDenied } + + let transcriberModule = SpeechTranscriber( + locale: Locale(identifier: localeIdentifier), + transcriptionOptions: etiquette ? [.etiquetteReplacements] : [], + reportingOptions: [.volatileResults], + attributeOptions: []) + transcriber = transcriberModule + + guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule]) + else { + throw SpeechPipelineError.analyzerFormatUnavailable + } + + analyzer = SpeechAnalyzer(modules: [transcriberModule]) + let (stream, continuation) = AsyncStream.makeStream() + inputContinuation = continuation + + let inputNode = engine.inputNode + let inputFormat = inputNode.outputFormat(forBus: 0) + inputNode.removeTap(onBus: 0) + inputNode.installTap(onBus: 0, bufferSize: 2048, format: inputFormat) { [weak self] buffer, _ in + guard let self else { return } + let boxed = UnsafeBuffer(buffer: buffer) + Task { await self.handleBuffer(boxed.buffer, targetFormat: analyzerFormat) } + } + + engine.prepare() + try engine.start() + try await analyzer?.start(inputSequence: stream) + + guard let transcriberForStream = transcriber else { + throw SpeechPipelineError.transcriberUnavailable + } + + return AsyncStream { continuation in + self.resultTask = Task { + do { + for try await result in transcriberForStream.results { + let seg = SpeechSegment(text: String(result.text.characters), isFinal: result.isFinal) + continuation.yield(seg) + } + } catch { + // swallow errors and finish + } + continuation.finish() + } + continuation.onTermination = { _ in + Task { await self.stop() } + } + } + } + + public func stop() async { + resultTask?.cancel() + inputContinuation?.finish() + engine.inputNode.removeTap(onBus: 0) + engine.stop() + try? await analyzer?.finalizeAndFinishThroughEndOfInput() + } + + private func handleBuffer(_ buffer: AVAudioPCMBuffer, targetFormat: AVAudioFormat) async { + do { + let converted = try converter.convert(buffer, to: targetFormat) + let input = AnalyzerInput(buffer: converted) + inputContinuation?.yield(input) + } catch { + // drop on conversion failure + } + } + + private func requestAuthorizationIfNeeded() async -> SFSpeechRecognizerAuthorizationStatus { + let current = SFSpeechRecognizer.authorizationStatus() + guard current == .notDetermined else { return current } + return await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Support/AttributedString+Sentences.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Support/AttributedString+Sentences.swift new file mode 100644 index 00000000..e2de6fdf --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Support/AttributedString+Sentences.swift @@ -0,0 +1,62 @@ +import CoreMedia +import Foundation +import NaturalLanguage + +extension AttributedString { + public func sentences(maxLength: Int? = nil) -> [AttributedString] { + let tokenizer = NLTokenizer(unit: .sentence) + let string = String(characters) + tokenizer.string = string + let sentenceRanges = tokenizer.tokens(for: string.startIndex.. maxLength else { + return [sentenceRange] + } + + let wordTokenizer = NLTokenizer(unit: .word) + wordTokenizer.string = string + var wordRanges = wordTokenizer.tokens(for: sentenceStringRange).map { + AttributedString.Index($0.lowerBound, within: self)! + ..< + AttributedString.Index($0.upperBound, within: self)! + } + guard !wordRanges.isEmpty else { return [sentenceRange] } + wordRanges[0] = sentenceRange.lowerBound..] = [] + for wordRange in wordRanges { + if let lastRange = ranges.last, + self[lastRange].characters.count + self[wordRange].characters.count <= maxLength { + ranges[ranges.count - 1] = lastRange.lowerBound.. Bool { lhs.rank < rhs.rank } +} + +public struct Logger: Sendable { + public let level: LogLevel + + public init(level: LogLevel) { self.level = level } + + public func log(_ level: LogLevel, _ message: String) { + guard level >= self.level else { return } + let ts = ISO8601DateFormatter().string(from: Date()) + print("[\(level.rawValue.uppercased())] \(ts) | \(message)") + } + + public func trace(_ msg: String) { log(.trace, msg) } + public func debug(_ msg: String) { log(.debug, msg) } + public func info(_ msg: String) { log(.info, msg) } + public func warn(_ msg: String) { log(.warn, msg) } + public func error(_ msg: String) { log(.error, msg) } +} + +extension LogLevel { + public init?(configValue: String) { + self.init(rawValue: configValue.lowercased()) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Support/OutputFormat.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Support/OutputFormat.swift new file mode 100644 index 00000000..84047c72 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Support/OutputFormat.swift @@ -0,0 +1,45 @@ +import CoreMedia +import Foundation + +public enum OutputFormat: String { + case txt + case srt + + public var needsAudioTimeRange: Bool { + switch self { + case .srt: true + default: false + } + } + + public func text(for transcript: AttributedString, maxLength: Int) -> String { + switch self { + case .txt: + return String(transcript.characters) + case .srt: + func format(_ timeInterval: TimeInterval) -> String { + let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000) + let s = Int(timeInterval) % 60 + let m = (Int(timeInterval) / 60) % 60 + let h = Int(timeInterval) / 60 / 60 + return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms) + } + + return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> ( + CMTimeRange, + String)? in + guard let timeRange = sentence.audioTimeRange else { return nil } + return (timeRange, String(sentence.characters)) + }.enumerated().map { index, run in + let (timeRange, text) = run + return """ + + \(index + 1) + \(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds)) + \(text.trimmingCharacters(in: .whitespacesAndNewlines)) + + """ + }.joined().trimmingCharacters(in: .whitespacesAndNewlines) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Support/TranscriptsStore.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Support/TranscriptsStore.swift new file mode 100644 index 00000000..4f91d052 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleCore/Support/TranscriptsStore.swift @@ -0,0 +1,45 @@ +import Foundation + +public actor TranscriptsStore { + public static let shared = TranscriptsStore() + + private var entries: [String] = [] + private let limit = 100 + private let fileURL: URL + + public init() { + let dir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/swabble", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + fileURL = dir.appendingPathComponent("transcripts.log") + if let data = try? Data(contentsOf: fileURL), + let text = String(data: data, encoding: .utf8) { + entries = text.split(separator: "\n").map(String.init).suffix(limit) + } + } + + public func append(text: String) { + entries.append(text) + if entries.count > limit { + entries.removeFirst(entries.count - limit) + } + let body = entries.joined(separator: "\n") + try? body.write(to: fileURL, atomically: false, encoding: .utf8) + } + + public func latest() -> [String] { entries } +} + +extension String { + private func appendLine(to url: URL) throws { + let data = (self + "\n").data(using: .utf8) ?? Data() + if FileManager.default.fileExists(atPath: url.path) { + let handle = try FileHandle(forWritingTo: url) + try handle.seekToEnd() + try handle.write(contentsOf: data) + try handle.close() + } else { + try data.write(to: url) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleKit/WakeWordGate.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleKit/WakeWordGate.swift new file mode 100644 index 00000000..27c952a8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/SwabbleKit/WakeWordGate.swift @@ -0,0 +1,197 @@ +import Foundation + +public struct WakeWordSegment: Sendable, Equatable { + public let text: String + public let start: TimeInterval + public let duration: TimeInterval + public let range: Range? + + public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range? = nil) { + self.text = text + self.start = start + self.duration = duration + self.range = range + } + + public var end: TimeInterval { start + duration } +} + +public struct WakeWordGateConfig: Sendable, Equatable { + public var triggers: [String] + public var minPostTriggerGap: TimeInterval + public var minCommandLength: Int + + public init( + triggers: [String], + minPostTriggerGap: TimeInterval = 0.45, + minCommandLength: Int = 1) { + self.triggers = triggers + self.minPostTriggerGap = minPostTriggerGap + self.minCommandLength = minCommandLength + } +} + +public struct WakeWordGateMatch: Sendable, Equatable { + public let triggerEndTime: TimeInterval + public let postGap: TimeInterval + public let command: String + + public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) { + self.triggerEndTime = triggerEndTime + self.postGap = postGap + self.command = command + } +} + +public enum WakeWordGate { + private struct Token { + let normalized: String + let start: TimeInterval + let end: TimeInterval + let range: Range? + let text: String + } + + private struct TriggerTokens { + let tokens: [String] + } + + private struct MatchCandidate { + let index: Int + let triggerEnd: TimeInterval + let gap: TimeInterval + } + + public static func match( + transcript: String, + segments: [WakeWordSegment], + config: WakeWordGateConfig) + -> WakeWordGateMatch? { + let triggerTokens = normalizeTriggers(config.triggers) + guard !triggerTokens.isEmpty else { return nil } + + let tokens = normalizeSegments(segments) + guard !tokens.isEmpty else { return nil } + + var best: MatchCandidate? + + for trigger in triggerTokens { + let count = trigger.tokens.count + guard count > 0, tokens.count > count else { continue } + for i in 0...(tokens.count - count - 1) { + let matched = (0..= config.minCommandLength else { return nil } + return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command) + } + + public static func commandText( + transcript: String, + segments: [WakeWordSegment], + triggerEndTime: TimeInterval) + -> String { + let threshold = triggerEndTime + 0.001 + for segment in segments where segment.start >= threshold { + if normalizeToken(segment.text).isEmpty { continue } + if let range = segment.range { + let slice = transcript[range.lowerBound...] + return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation) + } + break + } + + let text = segments + .filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty } + .map(\.text) + .joined(separator: " ") + return text.trimmingCharacters(in: Self.whitespaceAndPunctuation) + } + + public static func matchesTextOnly(text: String, triggers: [String]) -> Bool { + guard !text.isEmpty else { return false } + let normalized = text.lowercased() + for trigger in triggers { + let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased() + if token.isEmpty { continue } + if normalized.contains(token) { return true } + } + return false + } + + public static func stripWake(text: String, triggers: [String]) -> String { + var out = text + for trigger in triggers { + let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation) + guard !token.isEmpty else { continue } + out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive]) + } + return out.trimmingCharacters(in: whitespaceAndPunctuation) + } + + private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] { + var output: [TriggerTokens] = [] + for trigger in triggers { + let tokens = trigger + .split(whereSeparator: { $0.isWhitespace }) + .map { normalizeToken(String($0)) } + .filter { !$0.isEmpty } + if tokens.isEmpty { continue } + output.append(TriggerTokens(tokens: tokens)) + } + return output + } + + private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] { + segments.compactMap { segment in + let normalized = normalizeToken(segment.text) + guard !normalized.isEmpty else { return nil } + return Token( + normalized: normalized, + start: segment.start, + end: segment.end, + range: segment.range, + text: segment.text) + } + } + + private static func normalizeToken(_ token: String) -> String { + token + .trimmingCharacters(in: whitespaceAndPunctuation) + .lowercased() + } + + private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines + .union(.punctuationCharacters) +} + +#if canImport(Speech) +import Speech + +public enum WakeWordSpeechSegments { + public static func from(transcription: SFTranscription, transcript: String) -> [WakeWordSegment] { + transcription.segments.map { segment in + let range = Range(segment.substringRange, in: transcript) + return WakeWordSegment( + text: segment.substring, + start: segment.timestamp, + duration: segment.duration, + range: range) + } + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/CLI/CLIRegistry.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/CLI/CLIRegistry.swift new file mode 100644 index 00000000..c47a9864 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/CLI/CLIRegistry.swift @@ -0,0 +1,71 @@ +import Commander +import Foundation + +@available(macOS 26.0, *) +@MainActor +enum CLIRegistry { + static var descriptors: [CommandDescriptor] { + let serveDesc = descriptor(for: ServeCommand.self) + let transcribeDesc = descriptor(for: TranscribeCommand.self) + let testHookDesc = descriptor(for: TestHookCommand.self) + let micList = descriptor(for: MicList.self) + let micSet = descriptor(for: MicSet.self) + let micRoot = CommandDescriptor( + name: "mic", + abstract: "Microphone management", + discussion: nil, + signature: CommandSignature(), + subcommands: [micList, micSet]) + let serviceRoot = CommandDescriptor( + name: "service", + abstract: "launchd helper", + discussion: nil, + signature: CommandSignature(), + subcommands: [ + descriptor(for: ServiceInstall.self), + descriptor(for: ServiceUninstall.self), + descriptor(for: ServiceStatus.self) + ]) + let doctorDesc = descriptor(for: DoctorCommand.self) + let setupDesc = descriptor(for: SetupCommand.self) + let healthDesc = descriptor(for: HealthCommand.self) + let tailLogDesc = descriptor(for: TailLogCommand.self) + let startDesc = descriptor(for: StartCommand.self) + let stopDesc = descriptor(for: StopCommand.self) + let restartDesc = descriptor(for: RestartCommand.self) + let statusDesc = descriptor(for: StatusCommand.self) + + let rootSignature = CommandSignature().withStandardRuntimeFlags() + let root = CommandDescriptor( + name: "swabble", + abstract: "Speech hook daemon", + discussion: "Local wake-word → SpeechTranscriber → hook", + signature: rootSignature, + subcommands: [ + serveDesc, + transcribeDesc, + testHookDesc, + micRoot, + serviceRoot, + doctorDesc, + setupDesc, + healthDesc, + tailLogDesc, + startDesc, + stopDesc, + restartDesc, + statusDesc + ]) + return [root] + } + + private static func descriptor(for type: any ParsableCommand.Type) -> CommandDescriptor { + let sig = CommandSignature.describe(type.init()).withStandardRuntimeFlags() + return CommandDescriptor( + name: type.commandDescription.commandName ?? "", + abstract: type.commandDescription.abstract, + discussion: type.commandDescription.discussion, + signature: sig, + subcommands: []) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/DoctorCommand.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/DoctorCommand.swift new file mode 100644 index 00000000..ec6c84ad --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/DoctorCommand.swift @@ -0,0 +1,37 @@ +import Commander +import Foundation +import Speech +import Swabble + +@MainActor +struct DoctorCommand: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription(commandName: "doctor", abstract: "Check Speech permission and config") + } + + @Option(name: .long("config"), help: "Path to config JSON") var configPath: String? + + init() {} + init(parsed: ParsedValues) { + self.init() + if let cfg = parsed.options["config"]?.last { configPath = cfg } + } + + mutating func run() async throws { + let auth = await SFSpeechRecognizer.authorizationStatus() + print("Speech auth: \(auth)") + do { + _ = try ConfigLoader.load(at: configURL) + print("Config: OK") + } catch { + print("Config missing or invalid; run setup") + } + let session = AVCaptureDevice.DiscoverySession( + deviceTypes: [.microphone, .external], + mediaType: .audio, + position: .unspecified) + print("Mics found: \(session.devices.count)") + } + + private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/HealthCommand.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/HealthCommand.swift new file mode 100644 index 00000000..b3db4528 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/HealthCommand.swift @@ -0,0 +1,16 @@ +import Commander +import Foundation + +@MainActor +struct HealthCommand: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription(commandName: "health", abstract: "Health probe") + } + + init() {} + init(parsed: ParsedValues) {} + + mutating func run() async throws { + print("ok") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/MicCommands.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/MicCommands.swift new file mode 100644 index 00000000..6430c86d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/MicCommands.swift @@ -0,0 +1,62 @@ +import AVFoundation +import Commander +import Foundation +import Swabble + +@MainActor +struct MicCommand: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription( + commandName: "mic", + abstract: "Microphone management", + subcommands: [MicList.self, MicSet.self]) + } +} + +@MainActor +struct MicList: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription(commandName: "list", abstract: "List input devices") + } + + init() {} + init(parsed: ParsedValues) {} + + mutating func run() async throws { + let session = AVCaptureDevice.DiscoverySession( + deviceTypes: [.microphone, .external], + mediaType: .audio, + position: .unspecified) + let devices = session.devices + if devices.isEmpty { print("no audio inputs found"); return } + for (idx, device) in devices.enumerated() { + print("[\(idx)] \(device.localizedName)") + } + } +} + +@MainActor +struct MicSet: ParsableCommand { + @Argument(help: "Device index from list") var index: Int = 0 + @Option(name: .long("config"), help: "Path to config JSON") var configPath: String? + + static var commandDescription: CommandDescription { + CommandDescription(commandName: "set", abstract: "Set default input device index") + } + + init() {} + init(parsed: ParsedValues) { + self.init() + if let value = parsed.positional.first, let intVal = Int(value) { index = intVal } + if let cfg = parsed.options["config"]?.last { configPath = cfg } + } + + mutating func run() async throws { + var cfg = try ConfigLoader.load(at: configURL) + cfg.audio.deviceIndex = index + try ConfigLoader.save(cfg, at: configURL) + print("saved device index \(index)") + } + + private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/ServeCommand.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/ServeCommand.swift new file mode 100644 index 00000000..705ecf41 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/ServeCommand.swift @@ -0,0 +1,81 @@ +import Commander +import Foundation +import Swabble +import SwabbleKit + +@available(macOS 26.0, *) +@MainActor +struct ServeCommand: ParsableCommand { + @Option(name: .long("config"), help: "Path to config JSON") var configPath: String? + @Flag(name: .long("no-wake"), help: "Disable wake word") var noWake: Bool = false + + static var commandDescription: CommandDescription { + CommandDescription( + commandName: "serve", + abstract: "Run swabble in the foreground") + } + + init() {} + + init(parsed: ParsedValues) { + self.init() + if parsed.flags.contains("noWake") { noWake = true } + if let cfg = parsed.options["config"]?.last { configPath = cfg } + } + + mutating func run() async throws { + var cfg: SwabbleConfig + do { + cfg = try ConfigLoader.load(at: configURL) + } catch { + cfg = SwabbleConfig() + try ConfigLoader.save(cfg, at: configURL) + } + if noWake { + cfg.wake.enabled = false + } + + let logger = Logger(level: LogLevel(configValue: cfg.logging.level) ?? .info) + logger.info("swabble serve starting (wake: \(cfg.wake.enabled ? cfg.wake.word : "disabled"))") + let pipeline = SpeechPipeline() + do { + let stream = try await pipeline.start( + localeIdentifier: cfg.speech.localeIdentifier, + etiquette: cfg.speech.etiquetteReplacements) + for await seg in stream { + if cfg.wake.enabled { + guard Self.matchesWake(text: seg.text, cfg: cfg) else { continue } + } + let stripped = Self.stripWake(text: seg.text, cfg: cfg) + let job = HookJob(text: stripped, timestamp: Date()) + let executor = HookExecutor(config: cfg) + try await executor.run(job: job) + if cfg.transcripts.enabled { + await TranscriptsStore.shared.append(text: stripped) + } + if seg.isFinal { + logger.info("final: \(stripped)") + } else { + logger.debug("partial: \(stripped)") + } + } + } catch { + logger.error("serve error: \(error)") + throw error + } + } + + private var configURL: URL? { + configPath.map { URL(fileURLWithPath: $0) } + } + + private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool { + let triggers = [cfg.wake.word] + cfg.wake.aliases + return WakeWordGate.matchesTextOnly(text: text, triggers: triggers) + } + + private static func stripWake(text: String, cfg: SwabbleConfig) -> String { + let triggers = [cfg.wake.word] + cfg.wake.aliases + return WakeWordGate.stripWake(text: text, triggers: triggers) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/ServiceCommands.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/ServiceCommands.swift new file mode 100644 index 00000000..8690e956 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/ServiceCommands.swift @@ -0,0 +1,77 @@ +import Commander +import Foundation + +@MainActor +struct ServiceRootCommand: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription( + commandName: "service", + abstract: "Manage launchd agent", + subcommands: [ServiceInstall.self, ServiceUninstall.self, ServiceStatus.self]) + } +} + +private enum LaunchdHelper { + static let label = "com.swabble.agent" + + static var plistURL: URL { + FileManager.default + .homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents/\(label).plist") + } + + static func writePlist(executable: String) throws { + let plist: [String: Any] = [ + "Label": label, + "ProgramArguments": [executable, "serve"], + "RunAtLoad": true, + "KeepAlive": true + ] + let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try data.write(to: plistURL) + } + + static func removePlist() throws { + try? FileManager.default.removeItem(at: plistURL) + } +} + +@MainActor +struct ServiceInstall: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription(commandName: "install", abstract: "Install user launch agent") + } + + mutating func run() async throws { + let exe = CommandLine.arguments.first ?? "/usr/local/bin/swabble" + try LaunchdHelper.writePlist(executable: exe) + print("launchctl load -w \(LaunchdHelper.plistURL.path)") + } +} + +@MainActor +struct ServiceUninstall: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription(commandName: "uninstall", abstract: "Remove launch agent") + } + + mutating func run() async throws { + try LaunchdHelper.removePlist() + print("launchctl bootout gui/$(id -u)/\(LaunchdHelper.label)") + } +} + +@MainActor +struct ServiceStatus: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription(commandName: "status", abstract: "Show launch agent status") + } + + mutating func run() async throws { + if FileManager.default.fileExists(atPath: LaunchdHelper.plistURL.path) { + print("plist present at \(LaunchdHelper.plistURL.path)") + } else { + print("launchd plist not installed") + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/SetupCommand.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/SetupCommand.swift new file mode 100644 index 00000000..469de233 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/SetupCommand.swift @@ -0,0 +1,26 @@ +import Commander +import Foundation +import Swabble + +@MainActor +struct SetupCommand: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription(commandName: "setup", abstract: "Write default config") + } + + @Option(name: .long("config"), help: "Path to config JSON") var configPath: String? + + init() {} + init(parsed: ParsedValues) { + self.init() + if let cfg = parsed.options["config"]?.last { configPath = cfg } + } + + mutating func run() async throws { + let cfg = SwabbleConfig() + try ConfigLoader.save(cfg, at: configURL) + print("wrote config to \(configURL?.path ?? SwabbleConfig.defaultPath.path)") + } + + private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/StartStopCommands.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/StartStopCommands.swift new file mode 100644 index 00000000..641cd923 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/StartStopCommands.swift @@ -0,0 +1,35 @@ +import Commander +import Foundation + +@MainActor +struct StartCommand: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription(commandName: "start", abstract: "Start swabble (foreground placeholder)") + } + + mutating func run() async throws { + print("start: launchd helper not implemented; run 'swabble serve' instead") + } +} + +@MainActor +struct StopCommand: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription(commandName: "stop", abstract: "Stop swabble (placeholder)") + } + + mutating func run() async throws { + print("stop: launchd helper not implemented yet") + } +} + +@MainActor +struct RestartCommand: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription(commandName: "restart", abstract: "Restart swabble (placeholder)") + } + + mutating func run() async throws { + print("restart: launchd helper not implemented yet") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/StatusCommand.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/StatusCommand.swift new file mode 100644 index 00000000..19db1611 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/StatusCommand.swift @@ -0,0 +1,34 @@ +import Commander +import Foundation +import Swabble + +@MainActor +struct StatusCommand: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription(commandName: "status", abstract: "Show daemon state") + } + + @Option(name: .long("config"), help: "Path to config JSON") var configPath: String? + + init() {} + init(parsed: ParsedValues) { + self.init() + if let cfg = parsed.options["config"]?.last { configPath = cfg } + } + + mutating func run() async throws { + let cfg = try? ConfigLoader.load(at: configURL) + let wake = cfg?.wake.word ?? "clawd" + let wakeEnabled = cfg?.wake.enabled ?? false + let latest = await TranscriptsStore.shared.latest().suffix(3) + print("wake: \(wakeEnabled ? wake : "disabled")") + if latest.isEmpty { + print("transcripts: (none yet)") + } else { + print("last transcripts:") + latest.forEach { print("- \($0)") } + } + } + + private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/TailLogCommand.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/TailLogCommand.swift new file mode 100644 index 00000000..451ed37d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/TailLogCommand.swift @@ -0,0 +1,20 @@ +import Commander +import Foundation +import Swabble + +@MainActor +struct TailLogCommand: ParsableCommand { + static var commandDescription: CommandDescription { + CommandDescription(commandName: "tail-log", abstract: "Tail recent transcripts") + } + + init() {} + init(parsed: ParsedValues) {} + + mutating func run() async throws { + let latest = await TranscriptsStore.shared.latest() + for line in latest.suffix(10) { + print(line) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/TestHookCommand.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/TestHookCommand.swift new file mode 100644 index 00000000..226776ce --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/TestHookCommand.swift @@ -0,0 +1,30 @@ +import Commander +import Foundation +import Swabble + +@MainActor +struct TestHookCommand: ParsableCommand { + @Argument(help: "Text to send to hook") var text: String + @Option(name: .long("config"), help: "Path to config JSON") var configPath: String? + + static var commandDescription: CommandDescription { + CommandDescription(commandName: "test-hook", abstract: "Invoke the configured hook with text") + } + + init() {} + + init(parsed: ParsedValues) { + self.init() + if let positional = parsed.positional.first { text = positional } + if let cfg = parsed.options["config"]?.last { configPath = cfg } + } + + mutating func run() async throws { + let cfg = try ConfigLoader.load(at: configURL) + let executor = HookExecutor(config: cfg) + try await executor.run(job: HookJob(text: text, timestamp: Date())) + print("hook invoked") + } + + private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/TranscribeCommand.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/TranscribeCommand.swift new file mode 100644 index 00000000..1bedca3f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/Commands/TranscribeCommand.swift @@ -0,0 +1,61 @@ +import AVFoundation +import Commander +import Foundation +import Speech +import Swabble + +@MainActor +struct TranscribeCommand: ParsableCommand { + @Argument(help: "Path to audio/video file") var inputFile: String = "" + @Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current + .identifier + @Flag(help: "Censor etiquette-sensitive content") var censor: Bool = false + @Option(name: .long("output"), help: "Output file path") var outputFile: String? + @Option(name: .long("format"), help: "Output format txt|srt") var format: String = "txt" + @Option(name: .long("max-length"), help: "Max sentence length for srt") var maxLength: Int = 40 + + static var commandDescription: CommandDescription { + CommandDescription( + commandName: "transcribe", + abstract: "Transcribe a media file locally") + } + + init() {} + + init(parsed: ParsedValues) { + self.init() + if let positional = parsed.positional.first { inputFile = positional } + if let loc = parsed.options["locale"]?.last { locale = loc } + if parsed.flags.contains("censor") { censor = true } + if let out = parsed.options["output"]?.last { outputFile = out } + if let fmt = parsed.options["format"]?.last { format = fmt } + if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { maxLength = intVal } + } + + mutating func run() async throws { + let fileURL = URL(fileURLWithPath: inputFile) + let audioFile = try AVAudioFile(forReading: fileURL) + + let outputFormat = OutputFormat(rawValue: format) ?? .txt + + let transcriber = SpeechTranscriber( + locale: Locale(identifier: locale), + transcriptionOptions: censor ? [.etiquetteReplacements] : [], + reportingOptions: [], + attributeOptions: outputFormat.needsAudioTimeRange ? [.audioTimeRange] : []) + let analyzer = SpeechAnalyzer(modules: [transcriber]) + try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true) + + var transcript: AttributedString = "" + for try await result in transcriber.results { + transcript += result.text + } + + let output = outputFormat.text(for: transcript, maxLength: maxLength) + if let path = outputFile { + try output.write(to: URL(fileURLWithPath: path), atomically: false, encoding: .utf8) + } else { + print(output) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/main.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/main.swift new file mode 100644 index 00000000..a534c68d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Sources/swabble/main.swift @@ -0,0 +1,151 @@ +import Commander +import Foundation + +@available(macOS 26.0, *) +@MainActor +private func runCLI() async -> Int32 { + do { + let descriptors = CLIRegistry.descriptors + let program = Program(descriptors: descriptors) + let invocation = try program.resolve(argv: CommandLine.arguments) + try await dispatch(invocation: invocation) + return 0 + } catch { + fputs("error: \(error)\n", stderr) + return 1 + } +} + +@available(macOS 26.0, *) +@MainActor +private func dispatch(invocation: CommandInvocation) async throws { + let parsed = invocation.parsedValues + let path = invocation.path + guard let first = path.first else { throw CommanderProgramError.missingCommand } + + switch first { + case "swabble": + try await dispatchSwabble(parsed: parsed, path: path) + default: + throw CommanderProgramError.unknownCommand(first) + } +} + +@available(macOS 26.0, *) +@MainActor +private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws { + let sub = try subcommand(path, index: 1, command: "swabble") + switch sub { + case "mic": + try await dispatchMic(parsed: parsed, path: path) + case "service": + try await dispatchService(path: path) + default: + let handlers = swabbleHandlers(parsed: parsed) + guard let handler = handlers[sub] else { + throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub) + } + try await handler() + } +} + +@available(macOS 26.0, *) +@MainActor +private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] { + [ + "serve": { + var cmd = ServeCommand(parsed: parsed) + try await cmd.run() + }, + "transcribe": { + var cmd = TranscribeCommand(parsed: parsed) + try await cmd.run() + }, + "test-hook": { + var cmd = TestHookCommand(parsed: parsed) + try await cmd.run() + }, + "doctor": { + var cmd = DoctorCommand(parsed: parsed) + try await cmd.run() + }, + "setup": { + var cmd = SetupCommand(parsed: parsed) + try await cmd.run() + }, + "health": { + var cmd = HealthCommand(parsed: parsed) + try await cmd.run() + }, + "tail-log": { + var cmd = TailLogCommand(parsed: parsed) + try await cmd.run() + }, + "start": { + var cmd = StartCommand() + try await cmd.run() + }, + "stop": { + var cmd = StopCommand() + try await cmd.run() + }, + "restart": { + var cmd = RestartCommand() + try await cmd.run() + }, + "status": { + var cmd = StatusCommand() + try await cmd.run() + } + ] +} + +@available(macOS 26.0, *) +@MainActor +private func dispatchMic(parsed: ParsedValues, path: [String]) async throws { + let micSub = try subcommand(path, index: 2, command: "mic") + switch micSub { + case "list": + var cmd = MicList(parsed: parsed) + try await cmd.run() + case "set": + var cmd = MicSet(parsed: parsed) + try await cmd.run() + default: + throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub) + } +} + +@available(macOS 26.0, *) +@MainActor +private func dispatchService(path: [String]) async throws { + let svcSub = try subcommand(path, index: 2, command: "service") + switch svcSub { + case "install": + var cmd = ServiceInstall() + try await cmd.run() + case "uninstall": + var cmd = ServiceUninstall() + try await cmd.run() + case "status": + var cmd = ServiceStatus() + try await cmd.run() + default: + throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub) + } +} + +private func subcommand(_ path: [String], index: Int, command: String) throws -> String { + guard path.count > index else { + throw CommanderProgramError.missingSubcommand(command: command) + } + return path[index] +} + +if #available(macOS 26.0, *) { + let exitCode = await runCLI() + exit(exitCode) +} else { + fputs("error: swabble requires macOS 26 or newer\n", stderr) + exit(1) +} diff --git a/backend/app/one_person_security_dept/openclaw/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift b/backend/app/one_person_security_dept/openclaw/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift new file mode 100644 index 00000000..5cc283c3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift @@ -0,0 +1,63 @@ +import Foundation +import SwabbleKit +import Testing + +@Suite struct WakeWordGateTests { + @Test func matchRequiresGapAfterTrigger() { + let transcript = "hey clawd do thing" + let segments = makeSegments( + transcript: transcript, + words: [ + ("hey", 0.0, 0.1), + ("clawd", 0.2, 0.1), + ("do", 0.35, 0.1), + ("thing", 0.5, 0.1), + ]) + let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3) + #expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil) + } + + @Test func matchAllowsGapAndExtractsCommand() { + let transcript = "hey clawd do thing" + let segments = makeSegments( + transcript: transcript, + words: [ + ("hey", 0.0, 0.1), + ("clawd", 0.2, 0.1), + ("do", 0.9, 0.1), + ("thing", 1.1, 0.1), + ]) + let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3) + let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config) + #expect(match?.command == "do thing") + } + + @Test func matchHandlesMultiWordTriggers() { + let transcript = "hey clawd do it" + let segments = makeSegments( + transcript: transcript, + words: [ + ("hey", 0.0, 0.1), + ("clawd", 0.2, 0.1), + ("do", 0.8, 0.1), + ("it", 1.0, 0.1), + ]) + let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3) + let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config) + #expect(match?.command == "do it") + } +} + +private func makeSegments( + transcript: String, + words: [(String, TimeInterval, TimeInterval)]) +-> [WakeWordSegment] { + var searchStart = transcript.startIndex + var output: [WakeWordSegment] = [] + for (word, start, duration) in words { + let range = transcript.range(of: word, range: searchStart../dev/null; then + echo "swiftlint not installed" >&2 + exit 1 +fi +swiftlint --config "$CONFIG" diff --git a/backend/app/one_person_security_dept/openclaw/VISION.md b/backend/app/one_person_security_dept/openclaw/VISION.md new file mode 100644 index 00000000..4ff70189 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/VISION.md @@ -0,0 +1,110 @@ +## OpenClaw Vision + +OpenClaw is the AI that actually does things. +It runs on your devices, in your channels, with your rules. + +This document explains the current state and direction of the project. +We are still early, so iteration is fast. +Project overview and developer docs: [`README.md`](README.md) +Contribution guide: [`CONTRIBUTING.md`](CONTRIBUTING.md) + +OpenClaw started as a personal playground to learn AI and build something genuinely useful: +an assistant that can run real tasks on a real computer. +It evolved through several names and shells: Warelay -> Clawdbot -> Moltbot -> OpenClaw. + +The goal: a personal assistant that is easy to use, supports a wide range of platforms, and respects privacy and security. + +The current focus is: + +Priority: + +- Security and safe defaults +- Bug fixes and stability +- Setup reliability and first-run UX + +Next priorities: + +- Supporting all major model providers +- Improving support for major messaging channels (and adding a few high-demand ones) +- Performance and test infrastructure +- Better computer-use and agent harness capabilities +- Ergonomics across CLI and web frontend +- Companion apps on macOS, iOS, Android, Windows, and Linux + +Contribution rules: + +- One PR = one issue/topic. Do not bundle multiple unrelated fixes/features. +- PRs over ~5,000 changed lines are reviewed only in exceptional circumstances. +- Do not open large batches of tiny PRs at once; each PR has review cost. +- For very small related fixes, grouping into one focused PR is encouraged. + +## Security + +Security in OpenClaw is a deliberate tradeoff: strong defaults without killing capability. +The goal is to stay powerful for real work while making risky paths explicit and operator-controlled. + +Canonical security policy and reporting: + +- [`SECURITY.md`](SECURITY.md) + +We prioritize secure defaults, but also expose clear knobs for trusted high-power workflows. + +## Plugins & Memory + +OpenClaw has an extensive plugin API. +Core stays lean; optional capability should usually ship as plugins. + +Preferred plugin path is npm package distribution plus local extension loading for development. +If you build a plugin, host and maintain it in your own repository. +The bar for adding optional plugins to core is intentionally high. +Plugin docs: [`docs/tools/plugin.md`](docs/tools/plugin.md) +Community plugin listing + PR bar: https://docs.openclaw.ai/plugins/community + +Memory is a special plugin slot where only one memory plugin can be active at a time. +Today we ship multiple memory options; over time we plan to converge on one recommended default path. + +### Skills + +We still ship some bundled skills for baseline UX. +New skills should be published to ClawHub first (`clawhub.ai`), not added to core by default. +Core skill additions should be rare and require a strong product or security reason. + +### MCP Support + +OpenClaw supports MCP through `mcporter`: https://github.com/steipete/mcporter + +This keeps MCP integration flexible and decoupled from core runtime: + +- add or change MCP servers without restarting the gateway +- keep core tool/context surface lean +- reduce MCP churn impact on core stability and security + +For now, we prefer this bridge model over building first-class MCP runtime into core. +If there is an MCP server or feature `mcporter` does not support yet, please open an issue there. + +### Setup + +OpenClaw is currently terminal-first by design. +This keeps setup explicit: users see docs, auth, permissions, and security posture up front. + +Long term, we want easier onboarding flows as hardening matures. +We do not want convenience wrappers that hide critical security decisions from users. + +### Why TypeScript? + +OpenClaw is primarily an orchestration system: prompts, tools, protocols, and integrations. +TypeScript was chosen to keep OpenClaw hackable by default. +It is widely known, fast to iterate in, and easy to read, modify, and extend. + +## What We Will Not Merge (For Now) + +- New core skills when they can live on ClawHub +- Full-doc translation sets for all docs (deferred; we plan AI-generated translations later) +- Commercial service integrations that do not clearly fit the model-provider category +- Wrapper channels around already supported channels without a clear capability or security gap +- First-class MCP runtime in core when `mcporter` already provides the integration path +- Agent-hierarchy frameworks (manager-of-managers / nested planner trees) as a default architecture +- Heavy orchestration layers that duplicate existing agent and tool infrastructure + +This list is a roadmap guardrail, not a law of physics. +Strong user demand and strong technical rationale can change it. diff --git a/backend/app/one_person_security_dept/openclaw/appcast.xml b/backend/app/one_person_security_dept/openclaw/appcast.xml new file mode 100644 index 00000000..902d6097 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/appcast.xml @@ -0,0 +1,314 @@ + + + + OpenClaw + + 2026.2.14 + Sun, 15 Feb 2026 04:24:34 +0100 + https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml + 202602140 + 2026.2.14 + 15.0 + OpenClaw 2026.2.14 +

Changes

+
    +
  • Telegram: add poll sending via openclaw message poll (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla.
  • +
  • Slack/Discord: add dmPolicy + allowFrom config aliases for DM access control; legacy dm.policy + dm.allowFrom keys remain supported and openclaw doctor --fix can migrate them.
  • +
  • Discord: allow exec approval prompts to target channels or both DM+channel via channels.discord.execApprovals.target. (#16051) Thanks @leonnardo.
  • +
  • Sandbox: add sandbox.browser.binds to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.
  • +
  • Discord: add debug logging for message routing decisions to improve --debug tracing. (#16202) Thanks @jayleekr.
  • +
+

Fixes

+
    +
  • CLI/Plugins: ensure openclaw message send exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.
  • +
  • CLI/Plugins: run registered plugin gateway_stop hooks before openclaw message exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.
  • +
  • WhatsApp: honor per-account dmPolicy overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
  • +
  • Telegram: when channels.telegram.commands.native is false, exclude plugin commands from setMyCommands menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg.
  • +
  • LINE: return 200 OK for Developers Console "Verify" requests ({"events":[]}) without X-Line-Signature, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale.
  • +
  • Cron: deliver text-only output directly when delivery.to is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow.
  • +
  • Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.
  • +
  • Media: accept MEDIA:-prefixed paths (lenient whitespace) when loading outbound media to prevent ENOENT for tool-returned local media paths. (#13107) Thanks @mcaxtr.
  • +
  • Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1.
  • +
  • Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541)
  • +
  • Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit workspaceDir. (#16722)
  • +
  • BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.
  • +
  • CLI: fix lazy core command registration so top-level maintenance commands (doctor, dashboard, reset, uninstall) resolve correctly instead of exposing a non-functional maintenance placeholder command.
  • +
  • CLI/Dashboard: when gateway.bind=lan, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev.
  • +
  • TUI/Gateway: resolve local gateway target URL from gateway.bind mode (tailnet/lan) instead of hardcoded localhost so openclaw tui connects when gateway is non-loopback. (#16299) Thanks @cortexuvula.
  • +
  • TUI: honor explicit --session in openclaw tui even when session.scope is global, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu.
  • +
  • TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla.
  • +
  • TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds.
  • +
  • TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73.
  • +
  • TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75.
  • +
  • TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe.
  • +
  • TUI: harden render-time sanitizer for narrow terminals by chunking moderately long unbroken tokens and adding fast-path sanitization guards to reduce overhead on normal text. (#5355) Thanks @tingxueren.
  • +
  • TUI: render assistant body text in terminal default foreground (instead of fixed light ANSI color) so contrast remains readable on light themes such as Solarized Light. (#16750) Thanks @paymog.
  • +
  • TUI/Hooks: pass explicit reset reason (new vs reset) through sessions.reset and emit internal command hooks for gateway-triggered resets so /new hook workflows fire in TUI/webchat.
  • +
  • Cron: prevent cron list/cron status from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x.
  • +
  • Cron: repair missing/corrupt nextRunAtMs for the updated job without globally recomputing unrelated due jobs during cron update. (#15750)
  • +
  • Cron: skip missed-job replay on startup for jobs interrupted mid-run (stale runningAtMs markers), preventing restart loops for self-restarting jobs such as update tasks. (#16694) Thanks @sbmilburn.
  • +
  • Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as guild=dm. Thanks @thewilloftheshadow.
  • +
  • Discord: treat empty per-guild channels: {} config maps as no channel allowlist (not deny-all), so groupPolicy: "open" guilds without explicit channel entries continue to receive messages. (#16714) Thanks @xqliu.
  • +
  • Models/CLI: guard models status string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev.
  • +
  • Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace.
  • +
  • Gateway/Sessions: abort active embedded runs and clear queued session work before sessions.reset, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.
  • +
  • Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
  • +
  • Agents: add a safety timeout around embedded session.compact() to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.
  • +
  • Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including session_status model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.
  • +
  • Agents/Process/Bootstrap: preserve unbounded process log offset-only pagination (default tail applies only when both offset and limit are omitted) and enforce strict bootstrapTotalMaxChars budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.
  • +
  • Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing BOOTSTRAP.md once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras.
  • +
  • Agents/Workspace: create BOOTSTRAP.md when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla.
  • +
  • Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli.
  • +
  • Agents: treat empty-stream provider failures (request ended without sending any chunks) as timeout-class failover signals, enabling auth-profile rotation/fallback and showing a friendly timeout message instead of raw provider errors. (#10210) Thanks @zenchantlive.
  • +
  • Agents: treat read tool file_path arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73.
  • +
  • Ollama/Agents: avoid forcing tag enforcement for Ollama models, which could suppress all output as (no output). (#16191) Thanks @Glucksberg.
  • +
  • Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
  • +
  • Skills: watch SKILL.md only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.
  • +
  • Memory/QMD: make memory status read-only by skipping QMD boot update/embed side effects for status-only manager checks.
  • +
  • Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors.
  • +
  • Memory/Builtin: keep memory status dirty reporting stable across invocations by deriving status-only manager dirty state from persisted index metadata instead of process-start defaults. (#10863) Thanks @BarryYangi.
  • +
  • Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological qmd command output.
  • +
  • Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks.
  • +
  • Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency.
  • +
  • Memory/QMD: pass result limits to search/vsearch commands so QMD can cap results earlier.
  • +
  • Memory/QMD: avoid reading full markdown files when a from/lines window is requested in QMD reads.
  • +
  • Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn.
  • +
  • Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy stdout.
  • +
  • Memory/QMD: treat prefixed no results found marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.
  • +
  • Memory/QMD: avoid multi-collection query ranking corruption by running one qmd query -c per managed collection and merging by best score (also used for search/vsearch fallback-to-query). (#16740) Thanks @volarian-vai.
  • +
  • Memory/QMD: detect null-byte ENOTDIR update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms.
  • +
  • Memory/QMD/Security: add rawKeyPrefix support for QMD scope rules and preserve legacy keyPrefix: "agent:..." matching, preventing scoped deny bypass when operators match agent-prefixed session keys.
  • +
  • Memory/Builtin: narrow memory watcher targets to markdown globs and ignore dependency/venv directories to reduce file-descriptor pressure during memory sync startup. (#11721) Thanks @rex05ai.
  • +
  • Security/Memory-LanceDB: treat recalled memories as untrusted context (escape injected memory text + explicit non-instruction framing), skip likely prompt-injection payloads during auto-capture, and restrict auto-capture to user messages to reduce memory-poisoning risk. (#12524) Thanks @davidschmid24.
  • +
  • Security/Memory-LanceDB: require explicit autoCapture: true opt-in (default is now disabled) to prevent automatic PII capture unless operators intentionally enable it. (#12552) Thanks @fr33d3m0n.
  • +
  • Diagnostics/Memory: prune stale diagnostic session state entries and cap tracked session states to prevent unbounded in-memory growth on long-running gateways. (#5136) Thanks @coygeek and @vignesh07.
  • +
  • Gateway/Memory: clean up agentRunSeq tracking on run completion/abort and enforce maintenance-time cap pruning to prevent unbounded sequence-map growth over long uptimes. (#6036) Thanks @coygeek and @vignesh07.
  • +
  • Auto-reply/Memory: bound ABORT_MEMORY growth by evicting oldest entries and deleting reset (false) flags so abort state tracking cannot grow unbounded over long uptimes. (#6629) Thanks @coygeek and @vignesh07.
  • +
  • Slack/Memory: bound thread-starter cache growth with TTL + max-size pruning to prevent long-running Slack gateways from accumulating unbounded thread cache state. (#5258) Thanks @coygeek and @vignesh07.
  • +
  • Outbound/Memory: bound directory cache growth with max-size eviction and proactive TTL pruning to prevent long-running gateways from accumulating unbounded directory entries. (#5140) Thanks @coygeek and @vignesh07.
  • +
  • Skills/Memory: remove disconnected nodes from remote-skills cache to prevent stale node metadata from accumulating over long uptimes. (#6760) Thanks @coygeek.
  • +
  • Sandbox/Tools: make sandbox file tools bind-mount aware (including absolute container paths) and enforce read-only bind semantics for writes. (#16379) Thanks @tasaankaeris.
  • +
  • Media/Security: allow local media reads from OpenClaw state workspace/ and sandboxes/ roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji.
  • +
  • Media/Security: harden local media allowlist bypasses by requiring an explicit readFile override when callers mark paths as validated, and reject filesystem-root localRoots entries. (#16739)
  • +
  • Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files.
  • +
  • Security/BlueBubbles: require explicit mediaLocalRoots allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.
  • +
  • Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.
  • +
  • Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek.
  • +
  • Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
  • +
  • Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.
  • +
  • Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.
  • +
  • Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.
  • +
  • Security/Hooks: restrict hook transform modules to ~/.openclaw/hooks/transforms (prevents path traversal/escape module loads via config). Config note: hooks.transformsDir must now be within that directory. Thanks @akhmittra.
  • +
  • Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).
  • +
  • Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.
  • +
  • Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc.
  • +
  • Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.
  • +
  • Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.
  • +
  • Security/Slack: compute command authorization for DM slash commands even when dmPolicy=open, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth.
  • +
  • Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc.
  • +
  • Security/Google Chat: deprecate users/ allowlists (treat users/... as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.
  • +
  • Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.
  • +
  • Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject @username principals), auto-resolve @username to IDs in openclaw doctor --fix (when possible), and warn in openclaw security audit when legacy configs contain usernames. Thanks @vincentkoc.
  • +
  • Telegram/Security: reject Telegram webhook startup when webhookSecret is missing or empty (prevents unauthenticated webhook request forgery). Thanks @yueyueL.
  • +
  • Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text).
  • +
  • Telegram: set webhook callback timeout handling to onTimeout: "return" (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington.
  • +
  • Signal: preserve case-sensitive group: target IDs during normalization so mixed-case group IDs no longer fail with Group not found. (#16748) Thanks @repfigit.
  • +
  • Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
  • +
  • Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.
  • +
  • Security/Agents: enforce workspace-root path bounds for apply_patch in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec.
  • +
  • Security/Agents: enforce symlink-escape checks for apply_patch delete hunks under workspaceOnly, while still allowing deleting the symlink itself. Thanks @p80n-sec.
  • +
  • Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.
  • +
  • macOS: hard-limit unkeyed openclaw://agent deep links and ignore deliver / to / channel unless a valid unattended key is provided. Thanks @Cillian-Collins.
  • +
  • Scripts/Security: validate GitHub logins and avoid shell invocation in scripts/update-clawtributors.ts to prevent command injection via malicious commit records. Thanks @scanleale.
  • +
  • Security: fix Chutes manual OAuth login state validation by requiring the full redirect URL (reject code-only pastes) (thanks @aether-ai-agent).
  • +
  • Security/Gateway: harden tool-supplied gatewayUrl overrides by restricting them to loopback or the configured gateway.remote.url. Thanks @p80n-sec.
  • +
  • Security/Gateway: block system.execApprovals.* via node.invoke (use exec.approvals.node.* instead). Thanks @christos-eth.
  • +
  • Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc.
  • +
  • Security/Gateway: stop returning raw resolved config values in skills.status requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek.
  • +
  • Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL.
  • +
  • Security/Browser: harden browser control file upload + download helpers to prevent path traversal / local file disclosure. Thanks @1seal.
  • +
  • Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.
  • +
  • Security/Node Host: enforce system.run rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.
  • +
  • Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.
  • +
  • Security/Exec: harden PATH handling by disabling project-local node_modules/.bin bootstrapping by default, disallowing node-host PATH overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra.
  • +
  • Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: channels.tlon.allowPrivateNetwork). Thanks @p80n-sec.
  • +
  • Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without telnyx.publicKey are now rejected unless skipSignatureVerification is enabled. Thanks @p80n-sec.
  • +
  • Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec.
  • +
  • Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.
  • +
+

View full changelog

+]]>
+ +
+ + 2026.2.15 + Mon, 16 Feb 2026 05:04:34 +0100 + https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml + 202602150 + 2026.2.15 + 15.0 + OpenClaw 2026.2.15 +

Changes

+
    +
  • Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.
  • +
  • Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
  • +
  • Plugins: expose llm_input and llm_output hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
  • +
  • Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set agents.defaults.subagents.maxSpawnDepth: 2 to allow sub-agents to spawn their own children. Includes maxChildrenPerAgent limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
  • +
  • Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
  • +
  • Cron/Gateway: add finished-run webhook delivery toggle (notify) and dedicated webhook auth token support (cron.webhookToken) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
  • +
  • Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.
  • +
+

Fixes

+
    +
  • Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.
  • +
  • Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.
  • +
  • Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
  • +
  • Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.
  • +
  • Gateway/Security: redact sensitive session/path details from status responses for non-admin clients; full details remain available to operator.admin. (#8590) Thanks @fr33d3m0n.
  • +
  • Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (allowInsecureAuth / dangerouslyDisableDeviceAuth) when device identity is unavailable, preventing false missing scope failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.
  • +
  • LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann.
  • +
  • Skills/Security: restrict download installer targetDir to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.
  • +
  • Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.
  • +
  • Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
  • +
  • Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving passwordFile path exemptions, preventing accidental redaction of non-secret config values like maxTokens and IRC password-file paths. (#16042) Thanks @akramcodez.
  • +
  • Dev tooling: harden git pre-commit hook against option injection from malicious filenames (for example --force), preventing accidental staging of ignored files. Thanks @mrthankyou.
  • +
  • Gateway/Agent: reject malformed agent:-prefixed session keys (for example, agent:main) in agent and agent.identity.get instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
  • +
  • Gateway/Chat: harden chat.send inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
  • +
  • Gateway/Send: return an actionable error when send targets internal-only webchat, guiding callers to use chat.send or a deliverable channel. (#15703) Thanks @rodrigouroz.
  • +
  • Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing script-src 'self'. Thanks @Adam55A-code.
  • +
  • Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.
  • +
  • Agents/Sandbox: clarify system prompt path guidance so sandbox bash/exec uses container paths (for example /workspace) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.
  • +
  • Agents/Context: apply configured model contextWindow overrides after provider discovery so lookupContextTokens() honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
  • +
  • Agents/Context: derive lookupContextTokens() from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.
  • +
  • Agents/OpenAI: force store=true for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.
  • +
  • Memory/FTS: make buildFtsQuery Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.
  • +
  • Auto-reply/Compaction: resolve memory/YYYY-MM-DD.md placeholders with timezone-aware runtime dates and append a Current time: line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.
  • +
  • Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.
  • +
  • Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.
  • +
  • Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.
  • +
  • Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
  • +
  • Subagents/Models: preserve agents.defaults.model.fallbacks when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
  • +
  • Telegram: omit message_thread_id for DM sends/draft previews and keep forum-topic handling (id=1 general omitted, non-general kept), preventing DM failures with 400 Bad Request: message thread not found. (#10942) Thanks @garnetlyx.
  • +
  • Telegram: replace inbound placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
  • +
  • Telegram: retry inbound media getFile calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
  • +
  • Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
  • +
  • Discord: preserve channel session continuity when runtime payloads omit message.channelId by falling back to event/raw channel_id values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as sessionKey=unknown. (#17622) Thanks @shakkernerd.
  • +
  • Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with _2 suffixes. (#17365) Thanks @seewhyme.
  • +
  • Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
  • +
  • Web UI/Agents: hide BOOTSTRAP.md in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
  • +
  • Auto-reply/WhatsApp/TUI/Web: when a final assistant message is NO_REPLY and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show NO_REPLY placeholders. (#7010) Thanks @Morrowind-Xie.
  • +
  • Cron: infer payload.kind="agentTurn" for model-only cron.update payload patches, so partial agent-turn updates do not fail validation when kind is omitted. (#15664) Thanks @rodrigouroz.
  • +
  • TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.
  • +
  • TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.
  • +
  • TUI: suppress false (no output) placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.
  • +
  • TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.
  • +
  • CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.
  • +
+

View full changelog

+]]>
+ +
+ + 2026.2.24 + Wed, 25 Feb 2026 02:59:30 +0000 + https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml + 14728 + 2026.2.24 + 15.0 + OpenClaw 2026.2.24 +

Changes

+
    +
  • Auto-reply/Abort shortcuts: expand standalone stop phrases (stop openclaw, stop action, stop run, stop agent, please stop, and related variants), accept trailing punctuation (for example STOP OPENCLAW!!!), add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms), and treat exact do not do that as a stop trigger while preserving strict standalone matching. (#25103) Thanks @steipete and @vincentkoc.
  • +
  • Android/App UX: ship a native four-step onboarding flow, move post-onboarding into a five-tab shell (Connect, Chat, Voice, Screen, Settings), add a full Connect setup/manual mode screen, and refresh Android chat/settings surfaces for the new navigation model.
  • +
  • Talk/Gateway config: add provider-agnostic Talk configuration with legacy compatibility, and expose gateway Talk ElevenLabs config metadata for setup/status surfaces.
  • +
  • Security/Audit: add security.trust_model.multi_user_heuristic to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (sandbox.mode="all", workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).
  • +
  • Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping @buape/carbon pinned.
  • +
+

Breaking

+
    +
  • BREAKING: Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example user:, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.
  • +
  • BREAKING: Security/Sandbox: block Docker network: "container:" namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true (break-glass). Thanks @tdjackey for reporting.
  • +
+

Fixes

+
    +
  • Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (channel/to/thread) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner.
  • +
  • Security/Routing: fail closed for shared-session cross-channel replies by binding outbound target resolution to the current turn’s source channel metadata (instead of stale session route fallbacks), and wire those turn-source fields through gateway + command delivery planners with regression coverage. (#24571) Thanks @brandonwise.
  • +
  • Heartbeat routing: prevent heartbeat leakage/spam into Discord and other direct-message destinations by blocking direct-chat heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871)
  • +
  • Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from last to none (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851)
  • +
  • Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr.
  • +
  • Cron/Heartbeat delivery: stop inheriting cached session lastThreadId for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl.
  • +
  • Messaging tool dedupe: treat originating channel metadata as authoritative for same-target message.send suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so delivery-mirror transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch.
  • +
  • Channels/Typing keepalive: refresh channel typing callbacks on a keepalive interval during long replies and clear keepalive timers on idle/cleanup across core + extension dispatcher callsites so typing indicators do not expire mid-inference. (#25886, #25882) Thanks @stakeswky.
  • +
  • Agents/Model fallback: when a run is currently on a configured fallback model, keep traversing the configured fallback chain instead of collapsing straight to primary-only, preventing dead-end failures when primary stays in cooldown. (#25922, #25912) Thanks @Taskle.
  • +
  • Gateway/Models: honor explicit agents.defaults.models allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in models.list, and allow sessions.patch//model selection for those refs without false model not allowed errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc.
  • +
  • Control UI/Agents: inherit agents.defaults.model.fallbacks in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko.
  • +
  • Automation/Subagent/Cron reliability: honor ANNOUNCE_SKIP in sessions_spawn completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include cron in the coding tool profile so /tools/invoke can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky.
  • +
  • Discord/Voice reliability: restore runtime DAVE dependency (@snazzah/davey), add configurable DAVE join options (channels.discord.voice.daveEncryption and channels.discord.voice.decryptionFailureTolerance), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032)
  • +
  • Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all block payloads), fixing missing Discord replies in channels.discord.streaming=block mode. (#25839, #25836, #25792) Thanks @pewallin.
  • +
  • Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire messages.statusReactions.{emojis,timing} into Discord reaction lifecycle control, and compact model-picker custom_id keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr.
  • +
  • WhatsApp/Web reconnect: treat close status 440 as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson.
  • +
  • WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with Reasoning: before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328)
  • +
  • Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.
  • +
  • Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg.
  • +
  • Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.
  • +
  • Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram autoSelectFamily decisions so outbound fetch calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis.
  • +
  • Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko.
  • +
  • Android/Gateway auth: preserve Android gateway auth state across onboarding, use the native client id for operator sessions, retry with shared-token fallback after device-token auth failures, and avoid clearing tokens on transient connect errors.
  • +
  • Slack/DM routing: treat D* channel IDs as direct messages even when Slack sends an incorrect channel_type, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr.
  • +
  • Zalo/Group policy: enforce sender authorization for group messages with groupPolicy + groupAllowFrom (fallback to allowFrom), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting.
  • +
  • macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001.
  • +
  • macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl.
  • +
  • macOS/Voice wake routing: default forwarded voice-wake transcripts to the webchat channel (instead of ambiguous last routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18.
  • +
  • macOS/Gateway launch: prefer an available openclaw binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18.
  • +
  • macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
  • +
  • macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos.
  • +
  • Windows/Exec shell selection: prefer PowerShell 7 (pwsh) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing && command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x.
  • +
  • Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 dev=0 stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false Local media path is not safe to read drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng.
  • +
  • iMessage/Reasoning safety: harden iMessage echo suppression with outbound messageId matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb.
  • +
  • Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix.
  • +
  • Providers/Google reasoning: sanitize invalid negative thinkingBudget payloads for Gemini 3.1 requests by dropping -1 budgets and mapping configured reasoning effort to thinkingLevel, preventing malformed reasoning payloads on google-generative-ai. (#25900)
  • +
  • Providers/SiliconFlow: normalize thinking="off" to thinking: null for Pro/* model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru.
  • +
  • Models/Bedrock auth: normalize additional Bedrock provider aliases (bedrock, aws-bedrock, aws_bedrock, amazon bedrock) to canonical amazon-bedrock, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13.
  • +
  • Models/Providers: preserve explicit user reasoning overrides when merging provider model config with built-in catalog metadata, so reasoning: false is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728.
  • +
  • Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false pairing required failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
  • +
  • CLI/Memory search: accept --query for openclaw memory search (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky.
  • +
  • CLI/Doctor: correct stale recovery hints to use valid commands (openclaw gateway status --deep and openclaw configure --section model). (#24485) Thanks @chilu18.
  • +
  • Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr.
  • +
  • Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid plugins.entries. writes when ids differ. (#25275) Thanks @zerone0x.
  • +
  • Config/Plugins: treat stale removed google-antigravity-auth plugin references as compatibility warnings (not hard validation errors) across plugins.entries, plugins.allow, plugins.deny, and plugins.slots.memory, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18.
  • +
  • Config/Meta: accept numeric meta.lastTouchedAt timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write Date.now() values. (#25491) Thanks @mcaxtr.
  • +
  • Usage accounting: parse Moonshot/Kimi cached_tokens fields (including prompt_tokens_details.cached_tokens) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.
  • +
  • Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
  • +
  • Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit status/code/http 402 detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis.
  • +
  • Sessions/Tool-result guard: avoid generating synthetic toolResult entries for assistant turns that ended with stopReason: "aborted" or "error", preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell.
  • +
  • Auto-reply/Reset hooks: guarantee native /new and /reset flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18.
  • +
  • Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi.
  • +
  • Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian.
  • +
  • Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not ; joins) to avoid POSIX sh do; syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility.
  • +
  • Sandbox/Config: preserve dangerouslyAllowReservedContainerTargets and dangerouslyAllowExternalBindSources during sandbox docker config resolution so explicit bind-mount break-glass overrides reach runtime validation. (#25410) Thanks @skyer-jian.
  • +
  • Gateway/Security: enforce gateway auth for the exact /api/channels plugin root path (plus /api/channels/ descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3.
  • +
  • Exec approvals: treat bare allowlist * as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
  • +
  • iOS/Signing: improve scripts/ios-team-id.sh for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode xcodebuild output directories (apps/ios/build, apps/shared/OpenClawKit/build, Swabble/build). (#22773) Thanks @brianleach.
  • +
  • Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
  • +
  • Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (LD_*, DYLD_*, SSLKEYLOGFILE, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3.
  • +
  • Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width HOOK:...) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3.
  • +
  • Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3.
  • +
  • Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host os.tmpdir() trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting.
  • +
  • Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3.
  • +
  • Security/Message actions: enforce local media root checks for sendAttachment and setGroupIcon when sandboxRoot is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting.
  • +
  • Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. Thanks @v8hid for reporting.
  • +
  • Security/Workspace FS: normalize @-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. Thanks @tdjackey for reporting.
  • +
  • Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so dmPolicy: "allowlist" with empty allowedUserIds rejects all senders instead of allowing unauthorized dispatch. (#25827) Thanks @bmendonca3 for the contribution and @tdjackey for reporting.
  • +
  • Security/Native images: enforce tools.fs.workspaceOnly for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. Thanks @tdjackey for reporting.
  • +
  • Security/Exec approvals: bind system.run command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only rawCommand mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. Thanks @tdjackey for reporting.
  • +
  • Security/Exec companion host: forward canonical system.run display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting.
  • +
  • Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested /usr/bin/env chains cannot bypass shell-wrapper approval gating in allowlist + ask=on-miss mode. Thanks @tdjackey for reporting.
  • +
  • Security/Exec: limit default safe-bin trusted directories to immutable system paths (/bin, /usr/bin) and require explicit opt-in (tools.exec.safeBinTrustedDirs) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured safeBins resolve outside trusted dirs. Thanks @tdjackey for reporting.
  • +
  • Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.
  • +
+

View full changelog

+]]>
+ +
+
+
\ No newline at end of file diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/.gitignore b/backend/app/one_person_security_dept/openclaw/apps/android/.gitignore new file mode 100644 index 00000000..68bfc099 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +**/build/ +local.properties +.idea/ +**/*.iml diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/README.md b/backend/app/one_person_security_dept/openclaw/apps/android/README.md new file mode 100644 index 00000000..5e4d3235 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/README.md @@ -0,0 +1,72 @@ +## OpenClaw Android App + +Status: **extremely alpha**. The app is actively being rebuilt from the ground up. + +### Rebuild Checklist + +- [x] New 4-step onboarding flow +- [x] Connect tab with `Setup Code` + `Manual` modes +- [x] Encrypted persistence for gateway setup/auth state +- [x] Chat UI restyled +- [x] Settings UI restyled and de-duplicated (gateway controls moved to Connect) +- [ ] QR code scanning in onboarding +- [ ] Performance improvements +- [ ] Streaming support in chat UI +- [ ] Request camera/location and other permissions in onboarding/settings flow +- [ ] Push notifications for gateway/chat status updates +- [ ] Security hardening (biometric lock, token handling, safer defaults) +- [ ] Voice tab full functionality +- [ ] Screen tab full functionality +- [ ] Full end-to-end QA and release hardening + +## Open in Android Studio + +- Open the folder `apps/android`. + +## Build / Run + +```bash +cd apps/android +./gradlew :app:assembleDebug +./gradlew :app:installDebug +./gradlew :app:testDebugUnitTest +``` + +`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset. + +## Connect / Pair + +1) Start the gateway (on your main machine): + +```bash +pnpm openclaw gateway --port 18789 --verbose +``` + +2) In the Android app: + +- Open the **Connect** tab. +- Use **Setup Code** or **Manual** mode to connect. + +3) Approve pairing (on the gateway machine): + +```bash +openclaw nodes pending +openclaw nodes approve +``` + +More details: `docs/platforms/android.md`. + +## Permissions + +- Discovery: + - Android 13+ (`API 33+`): `NEARBY_WIFI_DEVICES` + - Android 12 and below: `ACCESS_FINE_LOCATION` (required for NSD scanning) +- Foreground service notification (Android 13+): `POST_NOTIFICATIONS` +- Camera: + - `CAMERA` for `camera.snap` and `camera.clip` + - `RECORD_AUDIO` for `camera.clip` when `includeAudio=true` + +## Contributions + +This Android app is currently being rebuilt. +Maintainer: @obviyus. For issues/questions/contributions, please open an issue or reach out on Discord. diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/THIRD_PARTY_LICENSES/MANROPE_OFL.txt b/backend/app/one_person_security_dept/openclaw/apps/android/THIRD_PARTY_LICENSES/MANROPE_OFL.txt new file mode 100644 index 00000000..472064af --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/THIRD_PARTY_LICENSES/MANROPE_OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/build.gradle.kts b/backend/app/one_person_security_dept/openclaw/apps/android/app/build.gradle.kts new file mode 100644 index 00000000..ffe7d1d7 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/build.gradle.kts @@ -0,0 +1,154 @@ +import com.android.build.api.variant.impl.VariantOutputImpl + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "ai.openclaw.android" + compileSdk = 36 + + sourceSets { + getByName("main") { + assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources") + } + } + + defaultConfig { + applicationId = "ai.openclaw.android" + minSdk = 31 + targetSdk = 36 + versionCode = 202602250 + versionName = "2026.2.25" + ndk { + // Support all major ABIs — native libs are tiny (~47 KB per ABI) + abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + debug { + isMinifyEnabled = false + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources { + excludes += setOf( + "/META-INF/{AL2.0,LGPL2.1}", + "/META-INF/*.version", + "/META-INF/LICENSE*.txt", + "DebugProbesKt.bin", + "kotlin-tooling-metadata.json", + ) + } + } + + lint { + disable += setOf( + "GradleDependency", + "IconLauncherShape", + "NewerVersionAvailable", + ) + warningsAsErrors = true + } + + testOptions { + unitTests.isIncludeAndroidResources = true + } +} + +androidComponents { + onVariants { variant -> + variant.outputs + .filterIsInstance() + .forEach { output -> + val versionName = output.versionName.orNull ?: "0" + val buildType = variant.buildType + + val outputFileName = "openclaw-${versionName}-${buildType}.apk" + output.outputFileName = outputFileName + } + } +} +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + allWarningsAsErrors.set(true) + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2026.02.00") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.core:core-ktx:1.17.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") + implementation("androidx.activity:activity-compose:1.12.2") + implementation("androidx.webkit:webkit:1.15.0") + + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + // material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used. + // R8 will tree-shake unused icons when minify is enabled on release builds. + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.navigation:navigation-compose:2.9.7") + + debugImplementation("androidx.compose.ui:ui-tooling") + + // Material Components (XML theme + resources) + implementation("com.google.android.material:material:1.13.0") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") + + implementation("androidx.security:security-crypto:1.1.0") + implementation("androidx.exifinterface:exifinterface:1.4.2") + implementation("com.squareup.okhttp3:okhttp:5.3.2") + implementation("org.bouncycastle:bcprov-jdk18on:1.83") + implementation("org.commonmark:commonmark:0.27.1") + implementation("org.commonmark:commonmark-ext-autolink:0.27.1") + implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.27.1") + implementation("org.commonmark:commonmark-ext-gfm-tables:0.27.1") + implementation("org.commonmark:commonmark-ext-task-list-items:0.27.1") + + // CameraX (for node.invoke camera.* parity) + implementation("androidx.camera:camera-core:1.5.2") + implementation("androidx.camera:camera-camera2:1.5.2") + implementation("androidx.camera:camera-lifecycle:1.5.2") + implementation("androidx.camera:camera-video:1.5.2") + implementation("androidx.camera:camera-view:1.5.2") + + // Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains. + implementation("dnsjava:dnsjava:3.6.4") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3") + testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3") + testImplementation("org.robolectric:robolectric:4.16.1") + testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2") +} + +tasks.withType().configureEach { + useJUnitPlatform() +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/proguard-rules.pro b/backend/app/one_person_security_dept/openclaw/apps/android/app/proguard-rules.pro new file mode 100644 index 00000000..d73c7971 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/proguard-rules.pro @@ -0,0 +1,28 @@ +# ── App classes ─────────────────────────────────────────────────── +-keep class ai.openclaw.android.** { *; } + +# ── Bouncy Castle ───────────────────────────────────────────────── +-keep class org.bouncycastle.** { *; } +-dontwarn org.bouncycastle.** + +# ── CameraX ─────────────────────────────────────────────────────── +-keep class androidx.camera.** { *; } + +# ── kotlinx.serialization ──────────────────────────────────────── +-keep class kotlinx.serialization.** { *; } +-keepclassmembers class * { + @kotlinx.serialization.Serializable *; +} +-keepattributes *Annotation*, InnerClasses + +# ── OkHttp ──────────────────────────────────────────────────────── +-dontwarn okhttp3.** +-dontwarn okio.** +-keep class okhttp3.internal.platform.** { *; } + +# ── Misc suppressions ──────────────────────────────────────────── +-dontwarn com.sun.jna.** +-dontwarn javax.naming.** +-dontwarn lombok.Generated +-dontwarn org.slf4j.impl.StaticLoggerBinder +-dontwarn sun.net.spi.nameservice.NameServiceDescriptor diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/AndroidManifest.xml b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..facdbf30 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/CameraHudState.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/CameraHudState.kt new file mode 100644 index 00000000..636c31bd --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/CameraHudState.kt @@ -0,0 +1,14 @@ +package ai.openclaw.android + +enum class CameraHudKind { + Photo, + Recording, + Success, + Error, +} + +data class CameraHudState( + val token: Long, + val kind: CameraHudKind, + val message: String, +) diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/DeviceNames.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/DeviceNames.kt new file mode 100644 index 00000000..3c44a3bb --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/DeviceNames.kt @@ -0,0 +1,26 @@ +package ai.openclaw.android + +import android.content.Context +import android.os.Build +import android.provider.Settings + +object DeviceNames { + fun bestDefaultNodeName(context: Context): String { + val deviceName = + runCatching { + Settings.Global.getString(context.contentResolver, "device_name") + } + .getOrNull() + ?.trim() + .orEmpty() + + if (deviceName.isNotEmpty()) return deviceName + + val model = + listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() }) + .joinToString(" ") + .trim() + + return model.ifEmpty { "Android Node" } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/InstallResultReceiver.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/InstallResultReceiver.kt new file mode 100644 index 00000000..ffb21258 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/InstallResultReceiver.kt @@ -0,0 +1,33 @@ +package ai.openclaw.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.util.Log + +class InstallResultReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + // System needs user confirmation — launch the confirmation activity + @Suppress("DEPRECATION") + val confirmIntent = intent.getParcelableExtra(Intent.EXTRA_INTENT) + if (confirmIntent != null) { + confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(confirmIntent) + Log.w("openclaw", "app.update: user confirmation requested, launching install dialog") + } + } + PackageInstaller.STATUS_SUCCESS -> { + Log.w("openclaw", "app.update: install SUCCESS") + } + else -> { + Log.e("openclaw", "app.update: install FAILED status=$status message=$message") + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/LocationMode.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/LocationMode.kt new file mode 100644 index 00000000..eb9c8442 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/LocationMode.kt @@ -0,0 +1,15 @@ +package ai.openclaw.android + +enum class LocationMode(val rawValue: String) { + Off("off"), + WhileUsing("whileUsing"), + Always("always"), + ; + + companion object { + fun fromRawValue(raw: String?): LocationMode { + val normalized = raw?.trim()?.lowercase() + return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt new file mode 100644 index 00000000..cafe0958 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/MainActivity.kt @@ -0,0 +1,91 @@ +package ai.openclaw.android + +import android.content.pm.ApplicationInfo +import android.os.Bundle +import android.view.WindowManager +import android.webkit.WebView +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import ai.openclaw.android.ui.RootScreen +import ai.openclaw.android.ui.OpenClawTheme +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + private val viewModel: MainViewModel by viewModels() + private lateinit var permissionRequester: PermissionRequester + private lateinit var screenCaptureRequester: ScreenCaptureRequester + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + WebView.setWebContentsDebuggingEnabled(isDebuggable) + applyImmersiveMode() + NodeForegroundService.start(this) + permissionRequester = PermissionRequester(this) + screenCaptureRequester = ScreenCaptureRequester(this) + viewModel.camera.attachLifecycleOwner(this) + viewModel.camera.attachPermissionRequester(permissionRequester) + viewModel.sms.attachPermissionRequester(permissionRequester) + viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester) + viewModel.screenRecorder.attachPermissionRequester(permissionRequester) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.preventSleep.collect { enabled -> + if (enabled) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } + } + + setContent { + OpenClawTheme { + Surface(modifier = Modifier) { + RootScreen(viewModel = viewModel) + } + } + } + } + + override fun onResume() { + super.onResume() + applyImmersiveMode() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + applyImmersiveMode() + } + } + + override fun onStart() { + super.onStart() + viewModel.setForeground(true) + } + + override fun onStop() { + viewModel.setForeground(false) + super.onStop() + } + + private fun applyImmersiveMode() { + WindowCompat.setDecorFitsSystemWindows(window, false) + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsetsCompat.Type.systemBars()) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt new file mode 100644 index 00000000..7076f09a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/MainViewModel.kt @@ -0,0 +1,210 @@ +package ai.openclaw.android + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import ai.openclaw.android.gateway.GatewayEndpoint +import ai.openclaw.android.chat.OutgoingAttachment +import ai.openclaw.android.node.CameraCaptureManager +import ai.openclaw.android.node.CanvasController +import ai.openclaw.android.node.ScreenRecordManager +import ai.openclaw.android.node.SmsManager +import kotlinx.coroutines.flow.StateFlow + +class MainViewModel(app: Application) : AndroidViewModel(app) { + private val runtime: NodeRuntime = (app as NodeApp).runtime + + val canvas: CanvasController = runtime.canvas + val canvasCurrentUrl: StateFlow = runtime.canvas.currentUrl + val canvasA2uiHydrated: StateFlow = runtime.canvasA2uiHydrated + val canvasRehydratePending: StateFlow = runtime.canvasRehydratePending + val canvasRehydrateErrorText: StateFlow = runtime.canvasRehydrateErrorText + val camera: CameraCaptureManager = runtime.camera + val screenRecorder: ScreenRecordManager = runtime.screenRecorder + val sms: SmsManager = runtime.sms + + val gateways: StateFlow> = runtime.gateways + val discoveryStatusText: StateFlow = runtime.discoveryStatusText + + val isConnected: StateFlow = runtime.isConnected + val isNodeConnected: StateFlow = runtime.nodeConnected + val statusText: StateFlow = runtime.statusText + val serverName: StateFlow = runtime.serverName + val remoteAddress: StateFlow = runtime.remoteAddress + val pendingGatewayTrust: StateFlow = runtime.pendingGatewayTrust + val isForeground: StateFlow = runtime.isForeground + val seamColorArgb: StateFlow = runtime.seamColorArgb + val mainSessionKey: StateFlow = runtime.mainSessionKey + + val cameraHud: StateFlow = runtime.cameraHud + val cameraFlashToken: StateFlow = runtime.cameraFlashToken + val screenRecordActive: StateFlow = runtime.screenRecordActive + + val instanceId: StateFlow = runtime.instanceId + val displayName: StateFlow = runtime.displayName + val cameraEnabled: StateFlow = runtime.cameraEnabled + val locationMode: StateFlow = runtime.locationMode + val locationPreciseEnabled: StateFlow = runtime.locationPreciseEnabled + val preventSleep: StateFlow = runtime.preventSleep + val wakeWords: StateFlow> = runtime.wakeWords + val voiceWakeMode: StateFlow = runtime.voiceWakeMode + val voiceWakeStatusText: StateFlow = runtime.voiceWakeStatusText + val voiceWakeIsListening: StateFlow = runtime.voiceWakeIsListening + val talkEnabled: StateFlow = runtime.talkEnabled + val talkStatusText: StateFlow = runtime.talkStatusText + val talkIsListening: StateFlow = runtime.talkIsListening + val talkIsSpeaking: StateFlow = runtime.talkIsSpeaking + val manualEnabled: StateFlow = runtime.manualEnabled + val manualHost: StateFlow = runtime.manualHost + val manualPort: StateFlow = runtime.manualPort + val manualTls: StateFlow = runtime.manualTls + val gatewayToken: StateFlow = runtime.gatewayToken + val onboardingCompleted: StateFlow = runtime.onboardingCompleted + val canvasDebugStatusEnabled: StateFlow = runtime.canvasDebugStatusEnabled + + val chatSessionKey: StateFlow = runtime.chatSessionKey + val chatSessionId: StateFlow = runtime.chatSessionId + val chatMessages = runtime.chatMessages + val chatError: StateFlow = runtime.chatError + val chatHealthOk: StateFlow = runtime.chatHealthOk + val chatThinkingLevel: StateFlow = runtime.chatThinkingLevel + val chatStreamingAssistantText: StateFlow = runtime.chatStreamingAssistantText + val chatPendingToolCalls = runtime.chatPendingToolCalls + val chatSessions = runtime.chatSessions + val pendingRunCount: StateFlow = runtime.pendingRunCount + + fun setForeground(value: Boolean) { + runtime.setForeground(value) + } + + fun setDisplayName(value: String) { + runtime.setDisplayName(value) + } + + fun setCameraEnabled(value: Boolean) { + runtime.setCameraEnabled(value) + } + + fun setLocationMode(mode: LocationMode) { + runtime.setLocationMode(mode) + } + + fun setLocationPreciseEnabled(value: Boolean) { + runtime.setLocationPreciseEnabled(value) + } + + fun setPreventSleep(value: Boolean) { + runtime.setPreventSleep(value) + } + + fun setManualEnabled(value: Boolean) { + runtime.setManualEnabled(value) + } + + fun setManualHost(value: String) { + runtime.setManualHost(value) + } + + fun setManualPort(value: Int) { + runtime.setManualPort(value) + } + + fun setManualTls(value: Boolean) { + runtime.setManualTls(value) + } + + fun setGatewayToken(value: String) { + runtime.setGatewayToken(value) + } + + fun setGatewayPassword(value: String) { + runtime.setGatewayPassword(value) + } + + fun setOnboardingCompleted(value: Boolean) { + runtime.setOnboardingCompleted(value) + } + + fun setCanvasDebugStatusEnabled(value: Boolean) { + runtime.setCanvasDebugStatusEnabled(value) + } + + fun setWakeWords(words: List) { + runtime.setWakeWords(words) + } + + fun resetWakeWordsDefaults() { + runtime.resetWakeWordsDefaults() + } + + fun setVoiceWakeMode(mode: VoiceWakeMode) { + runtime.setVoiceWakeMode(mode) + } + + fun setTalkEnabled(enabled: Boolean) { + runtime.setTalkEnabled(enabled) + } + + fun logGatewayDebugSnapshot(source: String = "manual") { + runtime.logGatewayDebugSnapshot(source) + } + + fun refreshGatewayConnection() { + runtime.refreshGatewayConnection() + } + + fun connect(endpoint: GatewayEndpoint) { + runtime.connect(endpoint) + } + + fun connectManual() { + runtime.connectManual() + } + + fun disconnect() { + runtime.disconnect() + } + + fun acceptGatewayTrustPrompt() { + runtime.acceptGatewayTrustPrompt() + } + + fun declineGatewayTrustPrompt() { + runtime.declineGatewayTrustPrompt() + } + + fun handleCanvasA2UIActionFromWebView(payloadJson: String) { + runtime.handleCanvasA2UIActionFromWebView(payloadJson) + } + + fun requestCanvasRehydrate(source: String = "screen_tab") { + runtime.requestCanvasRehydrate(source = source, force = true) + } + + fun loadChat(sessionKey: String) { + runtime.loadChat(sessionKey) + } + + fun refreshChat() { + runtime.refreshChat() + } + + fun refreshChatSessions(limit: Int? = null) { + runtime.refreshChatSessions(limit = limit) + } + + fun setChatThinkingLevel(level: String) { + runtime.setChatThinkingLevel(level) + } + + fun switchChatSession(sessionKey: String) { + runtime.switchChatSession(sessionKey) + } + + fun abortChat() { + runtime.abortChat() + } + + fun sendChat(message: String, thinking: String, attachments: List) { + runtime.sendChat(message = message, thinking = thinking, attachments = attachments) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt new file mode 100644 index 00000000..2be9ee71 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/NodeApp.kt @@ -0,0 +1,37 @@ +package ai.openclaw.android + +import android.app.Application +import android.os.StrictMode +import android.util.Log +import java.security.Security + +class NodeApp : Application() { + val runtime: NodeRuntime by lazy { NodeRuntime(this) } + + override fun onCreate() { + super.onCreate() + // Register Bouncy Castle as highest-priority provider for Ed25519 support + try { + val bcProvider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider") + .getDeclaredConstructor().newInstance() as java.security.Provider + Security.removeProvider("BC") + Security.insertProviderAt(bcProvider, 1) + } catch (it: Throwable) { + Log.e("NodeApp", "Failed to register Bouncy Castle provider", it) + } + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectAll() + .penaltyLog() + .build(), + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectAll() + .penaltyLog() + .build(), + ) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt new file mode 100644 index 00000000..ee7c8e00 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/NodeForegroundService.kt @@ -0,0 +1,180 @@ +package ai.openclaw.android + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.app.PendingIntent +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +class NodeForegroundService : Service() { + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var notificationJob: Job? = null + private var lastRequiresMic = false + private var didStartForeground = false + + override fun onCreate() { + super.onCreate() + ensureChannel() + val initial = buildNotification(title = "OpenClaw Node", text = "Starting…") + startForegroundWithTypes(notification = initial, requiresMic = false) + + val runtime = (application as NodeApp).runtime + notificationJob = + scope.launch { + combine( + runtime.statusText, + runtime.serverName, + runtime.isConnected, + runtime.voiceWakeMode, + runtime.voiceWakeIsListening, + ) { status, server, connected, voiceMode, voiceListening -> + Quint(status, server, connected, voiceMode, voiceListening) + }.collect { (status, server, connected, voiceMode, voiceListening) -> + val title = if (connected) "OpenClaw Node · Connected" else "OpenClaw Node" + val voiceSuffix = + if (voiceMode == VoiceWakeMode.Always) { + if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused" + } else { + "" + } + val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix + + val requiresMic = + voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission() + startForegroundWithTypes( + notification = buildNotification(title = title, text = text), + requiresMic = requiresMic, + ) + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + (application as NodeApp).runtime.disconnect() + stopSelf() + return START_NOT_STICKY + } + } + // Keep running; connection is managed by NodeRuntime (auto-reconnect + manual). + return START_STICKY + } + + override fun onDestroy() { + notificationJob?.cancel() + scope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?) = null + + private fun ensureChannel() { + val mgr = getSystemService(NotificationManager::class.java) + val channel = + NotificationChannel( + CHANNEL_ID, + "Connection", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "OpenClaw node connection status" + setShowBadge(false) + } + mgr.createNotificationChannel(channel) + } + + private fun buildNotification(title: String, text: String): Notification { + val launchIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val launchPending = + PendingIntent.getActivity( + this, + 1, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP) + val stopPending = + PendingIntent.getService( + this, + 2, + stopIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(launchPending) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .addAction(0, "Disconnect", stopPending) + .build() + } + + private fun updateNotification(notification: Notification) { + val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + mgr.notify(NOTIFICATION_ID, notification) + } + + private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) { + if (didStartForeground && requiresMic == lastRequiresMic) { + updateNotification(notification) + return + } + + lastRequiresMic = requiresMic + val types = + if (requiresMic) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } + startForeground(NOTIFICATION_ID, notification, types) + didStartForeground = true + } + + private fun hasRecordAudioPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) + } + + companion object { + private const val CHANNEL_ID = "connection" + private const val NOTIFICATION_ID = 1 + + private const val ACTION_STOP = "ai.openclaw.android.action.STOP" + + fun start(context: Context) { + val intent = Intent(context, NodeForegroundService::class.java) + context.startForegroundService(intent) + } + + fun stop(context: Context) { + val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP) + context.startService(intent) + } + } +} + +private data class Quint(val first: A, val second: B, val third: C, val fourth: D, val fifth: E) diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt new file mode 100644 index 00000000..3e804ec8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt @@ -0,0 +1,845 @@ +package ai.openclaw.android + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.SystemClock +import android.util.Log +import androidx.core.content.ContextCompat +import ai.openclaw.android.chat.ChatController +import ai.openclaw.android.chat.ChatMessage +import ai.openclaw.android.chat.ChatPendingToolCall +import ai.openclaw.android.chat.ChatSessionEntry +import ai.openclaw.android.chat.OutgoingAttachment +import ai.openclaw.android.gateway.DeviceAuthStore +import ai.openclaw.android.gateway.DeviceIdentityStore +import ai.openclaw.android.gateway.GatewayDiscovery +import ai.openclaw.android.gateway.GatewayEndpoint +import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.android.gateway.probeGatewayTlsFingerprint +import ai.openclaw.android.node.* +import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction +import ai.openclaw.android.voice.TalkModeManager +import ai.openclaw.android.voice.VoiceWakeManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import java.util.concurrent.atomic.AtomicLong + +class NodeRuntime(context: Context) { + private val appContext = context.applicationContext + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + val prefs = SecurePrefs(appContext) + private val deviceAuthStore = DeviceAuthStore(prefs) + val canvas = CanvasController() + val camera = CameraCaptureManager(appContext) + val location = LocationCaptureManager(appContext) + val screenRecorder = ScreenRecordManager(appContext) + val sms = SmsManager(appContext) + private val json = Json { ignoreUnknownKeys = true } + + private val externalAudioCaptureActive = MutableStateFlow(false) + + private val voiceWake: VoiceWakeManager by lazy { + VoiceWakeManager( + context = appContext, + scope = scope, + onCommand = { command -> + nodeSession.sendNodeEvent( + event = "agent.request", + payloadJson = + buildJsonObject { + put("message", JsonPrimitive(command)) + put("sessionKey", JsonPrimitive(resolveMainSessionKey())) + put("thinking", JsonPrimitive(chatThinkingLevel.value)) + put("deliver", JsonPrimitive(false)) + }.toString(), + ) + }, + ) + } + + val voiceWakeIsListening: StateFlow + get() = voiceWake.isListening + + val voiceWakeStatusText: StateFlow + get() = voiceWake.statusText + + val talkStatusText: StateFlow + get() = talkMode.statusText + + val talkIsListening: StateFlow + get() = talkMode.isListening + + val talkIsSpeaking: StateFlow + get() = talkMode.isSpeaking + + private val discovery = GatewayDiscovery(appContext, scope = scope) + val gateways: StateFlow> = discovery.gateways + val discoveryStatusText: StateFlow = discovery.statusText + + private val identityStore = DeviceIdentityStore(appContext) + private var connectedEndpoint: GatewayEndpoint? = null + + private val cameraHandler: CameraHandler = CameraHandler( + appContext = appContext, + camera = camera, + prefs = prefs, + connectedEndpoint = { connectedEndpoint }, + externalAudioCaptureActive = externalAudioCaptureActive, + showCameraHud = ::showCameraHud, + triggerCameraFlash = ::triggerCameraFlash, + invokeErrorFromThrowable = { invokeErrorFromThrowable(it) }, + ) + + private val debugHandler: DebugHandler = DebugHandler( + appContext = appContext, + identityStore = identityStore, + ) + + private val appUpdateHandler: AppUpdateHandler = AppUpdateHandler( + appContext = appContext, + connectedEndpoint = { connectedEndpoint }, + ) + + private val locationHandler: LocationHandler = LocationHandler( + appContext = appContext, + location = location, + json = json, + isForeground = { _isForeground.value }, + locationMode = { locationMode.value }, + locationPreciseEnabled = { locationPreciseEnabled.value }, + ) + + private val screenHandler: ScreenHandler = ScreenHandler( + screenRecorder = screenRecorder, + setScreenRecordActive = { _screenRecordActive.value = it }, + invokeErrorFromThrowable = { invokeErrorFromThrowable(it) }, + ) + + private val smsHandlerImpl: SmsHandler = SmsHandler( + sms = sms, + ) + + private val a2uiHandler: A2UIHandler = A2UIHandler( + canvas = canvas, + json = json, + getNodeCanvasHostUrl = { nodeSession.currentCanvasHostUrl() }, + getOperatorCanvasHostUrl = { operatorSession.currentCanvasHostUrl() }, + ) + + private val connectionManager: ConnectionManager = ConnectionManager( + prefs = prefs, + cameraEnabled = { cameraEnabled.value }, + locationMode = { locationMode.value }, + voiceWakeMode = { voiceWakeMode.value }, + smsAvailable = { sms.canSendSms() }, + hasRecordAudioPermission = { hasRecordAudioPermission() }, + manualTls = { manualTls.value }, + ) + + private val invokeDispatcher: InvokeDispatcher = InvokeDispatcher( + canvas = canvas, + cameraHandler = cameraHandler, + locationHandler = locationHandler, + screenHandler = screenHandler, + smsHandler = smsHandlerImpl, + a2uiHandler = a2uiHandler, + debugHandler = debugHandler, + appUpdateHandler = appUpdateHandler, + isForeground = { _isForeground.value }, + cameraEnabled = { cameraEnabled.value }, + locationEnabled = { locationMode.value != LocationMode.Off }, + onCanvasA2uiPush = { + _canvasA2uiHydrated.value = true + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + }, + onCanvasA2uiReset = { _canvasA2uiHydrated.value = false }, + ) + + private lateinit var gatewayEventHandler: GatewayEventHandler + + data class GatewayTrustPrompt( + val endpoint: GatewayEndpoint, + val fingerprintSha256: String, + ) + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + private val _nodeConnected = MutableStateFlow(false) + val nodeConnected: StateFlow = _nodeConnected.asStateFlow() + + private val _statusText = MutableStateFlow("Offline") + val statusText: StateFlow = _statusText.asStateFlow() + + private val _pendingGatewayTrust = MutableStateFlow(null) + val pendingGatewayTrust: StateFlow = _pendingGatewayTrust.asStateFlow() + + private val _mainSessionKey = MutableStateFlow("main") + val mainSessionKey: StateFlow = _mainSessionKey.asStateFlow() + + private val cameraHudSeq = AtomicLong(0) + private val _cameraHud = MutableStateFlow(null) + val cameraHud: StateFlow = _cameraHud.asStateFlow() + + private val _cameraFlashToken = MutableStateFlow(0L) + val cameraFlashToken: StateFlow = _cameraFlashToken.asStateFlow() + + private val _screenRecordActive = MutableStateFlow(false) + val screenRecordActive: StateFlow = _screenRecordActive.asStateFlow() + + private val _canvasA2uiHydrated = MutableStateFlow(false) + val canvasA2uiHydrated: StateFlow = _canvasA2uiHydrated.asStateFlow() + private val _canvasRehydratePending = MutableStateFlow(false) + val canvasRehydratePending: StateFlow = _canvasRehydratePending.asStateFlow() + private val _canvasRehydrateErrorText = MutableStateFlow(null) + val canvasRehydrateErrorText: StateFlow = _canvasRehydrateErrorText.asStateFlow() + + private val _serverName = MutableStateFlow(null) + val serverName: StateFlow = _serverName.asStateFlow() + + private val _remoteAddress = MutableStateFlow(null) + val remoteAddress: StateFlow = _remoteAddress.asStateFlow() + + private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB) + val seamColorArgb: StateFlow = _seamColorArgb.asStateFlow() + + private val _isForeground = MutableStateFlow(true) + val isForeground: StateFlow = _isForeground.asStateFlow() + + private var lastAutoA2uiUrl: String? = null + private var didAutoRequestCanvasRehydrate = false + private val canvasRehydrateSeq = AtomicLong(0) + private var operatorConnected = false + private var operatorStatusText: String = "Offline" + private var nodeStatusText: String = "Offline" + + private val operatorSession = + GatewaySession( + scope = scope, + identityStore = identityStore, + deviceAuthStore = deviceAuthStore, + onConnected = { name, remote, mainSessionKey -> + operatorConnected = true + operatorStatusText = "Connected" + _serverName.value = name + _remoteAddress.value = remote + _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB + applyMainSessionKey(mainSessionKey) + updateStatus() + scope.launch { refreshBrandingFromGateway() } + scope.launch { gatewayEventHandler.refreshWakeWordsFromGateway() } + }, + onDisconnected = { message -> + operatorConnected = false + operatorStatusText = message + _serverName.value = null + _remoteAddress.value = null + _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB + if (!isCanonicalMainSessionKey(_mainSessionKey.value)) { + _mainSessionKey.value = "main" + } + val mainKey = resolveMainSessionKey() + talkMode.setMainSessionKey(mainKey) + chat.applyMainSessionKey(mainKey) + chat.onDisconnected(message) + updateStatus() + }, + onEvent = { event, payloadJson -> + handleGatewayEvent(event, payloadJson) + }, + ) + + private val nodeSession = + GatewaySession( + scope = scope, + identityStore = identityStore, + deviceAuthStore = deviceAuthStore, + onConnected = { _, _, _ -> + _nodeConnected.value = true + nodeStatusText = "Connected" + didAutoRequestCanvasRehydrate = false + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + updateStatus() + maybeNavigateToA2uiOnConnect() + requestCanvasRehydrate(source = "node_connect", force = false) + }, + onDisconnected = { message -> + _nodeConnected.value = false + nodeStatusText = message + didAutoRequestCanvasRehydrate = false + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + updateStatus() + showLocalCanvasOnDisconnect() + }, + onEvent = { _, _ -> }, + onInvoke = { req -> + invokeDispatcher.handleInvoke(req.command, req.paramsJson) + }, + onTlsFingerprint = { stableId, fingerprint -> + prefs.saveGatewayTlsFingerprint(stableId, fingerprint) + }, + ) + + private val chat: ChatController = + ChatController( + scope = scope, + session = operatorSession, + json = json, + supportsChatSubscribe = false, + ) + private val talkMode: TalkModeManager by lazy { + TalkModeManager( + context = appContext, + scope = scope, + session = operatorSession, + supportsChatSubscribe = false, + isConnected = { operatorConnected }, + ) + } + + private fun applyMainSessionKey(candidate: String?) { + val trimmed = normalizeMainKey(candidate) ?: return + if (isCanonicalMainSessionKey(_mainSessionKey.value)) return + if (_mainSessionKey.value == trimmed) return + _mainSessionKey.value = trimmed + talkMode.setMainSessionKey(trimmed) + chat.applyMainSessionKey(trimmed) + } + + private fun updateStatus() { + _isConnected.value = operatorConnected + _statusText.value = + when { + operatorConnected && _nodeConnected.value -> "Connected" + operatorConnected && !_nodeConnected.value -> "Connected (node offline)" + !operatorConnected && _nodeConnected.value -> "Connected (operator offline)" + operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText + else -> nodeStatusText + } + } + + private fun resolveMainSessionKey(): String { + val trimmed = _mainSessionKey.value.trim() + return if (trimmed.isEmpty()) "main" else trimmed + } + + private fun maybeNavigateToA2uiOnConnect() { + val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: return + val current = canvas.currentUrl()?.trim().orEmpty() + if (current.isEmpty() || current == lastAutoA2uiUrl) { + lastAutoA2uiUrl = a2uiUrl + canvas.navigate(a2uiUrl) + } + } + + private fun showLocalCanvasOnDisconnect() { + lastAutoA2uiUrl = null + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + canvas.navigate("") + } + + fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) { + scope.launch { + if (!_nodeConnected.value) { + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = "Node offline. Reconnect and retry." + return@launch + } + if (!force && didAutoRequestCanvasRehydrate) return@launch + didAutoRequestCanvasRehydrate = true + val requestId = canvasRehydrateSeq.incrementAndGet() + _canvasRehydratePending.value = true + _canvasRehydrateErrorText.value = null + + val sessionKey = resolveMainSessionKey() + val prompt = + "Restore canvas now for session=$sessionKey source=$source. " + + "If existing A2UI state exists, replay it immediately. " + + "If not, create and render a compact mobile-friendly dashboard in Canvas." + val sent = + nodeSession.sendNodeEvent( + event = "agent.request", + payloadJson = + buildJsonObject { + put("message", JsonPrimitive(prompt)) + put("sessionKey", JsonPrimitive(sessionKey)) + put("thinking", JsonPrimitive("low")) + put("deliver", JsonPrimitive(false)) + }.toString(), + ) + if (!sent) { + if (!force) { + didAutoRequestCanvasRehydrate = false + } + if (canvasRehydrateSeq.get() == requestId) { + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = "Failed to request restore. Tap to retry." + } + Log.w("OpenClawCanvas", "canvas rehydrate request failed ($source): transport unavailable") + return@launch + } + scope.launch { + delay(20_000) + if (canvasRehydrateSeq.get() != requestId) return@launch + if (!_canvasRehydratePending.value) return@launch + if (_canvasA2uiHydrated.value) return@launch + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = "No canvas update yet. Tap to retry." + } + } + } + + val instanceId: StateFlow = prefs.instanceId + val displayName: StateFlow = prefs.displayName + val cameraEnabled: StateFlow = prefs.cameraEnabled + val locationMode: StateFlow = prefs.locationMode + val locationPreciseEnabled: StateFlow = prefs.locationPreciseEnabled + val preventSleep: StateFlow = prefs.preventSleep + val wakeWords: StateFlow> = prefs.wakeWords + val voiceWakeMode: StateFlow = prefs.voiceWakeMode + val talkEnabled: StateFlow = prefs.talkEnabled + val manualEnabled: StateFlow = prefs.manualEnabled + val manualHost: StateFlow = prefs.manualHost + val manualPort: StateFlow = prefs.manualPort + val manualTls: StateFlow = prefs.manualTls + val gatewayToken: StateFlow = prefs.gatewayToken + val onboardingCompleted: StateFlow = prefs.onboardingCompleted + fun setGatewayToken(value: String) = prefs.setGatewayToken(value) + fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value) + fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value) + val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId + val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled + + private var didAutoConnect = false + + val chatSessionKey: StateFlow = chat.sessionKey + val chatSessionId: StateFlow = chat.sessionId + val chatMessages: StateFlow> = chat.messages + val chatError: StateFlow = chat.errorText + val chatHealthOk: StateFlow = chat.healthOk + val chatThinkingLevel: StateFlow = chat.thinkingLevel + val chatStreamingAssistantText: StateFlow = chat.streamingAssistantText + val chatPendingToolCalls: StateFlow> = chat.pendingToolCalls + val chatSessions: StateFlow> = chat.sessions + val pendingRunCount: StateFlow = chat.pendingRunCount + + init { + gatewayEventHandler = GatewayEventHandler( + scope = scope, + prefs = prefs, + json = json, + operatorSession = operatorSession, + isConnected = { _isConnected.value }, + ) + + scope.launch { + combine( + voiceWakeMode, + isForeground, + externalAudioCaptureActive, + wakeWords, + ) { mode, foreground, externalAudio, words -> + Quad(mode, foreground, externalAudio, words) + }.distinctUntilChanged() + .collect { (mode, foreground, externalAudio, words) -> + voiceWake.setTriggerWords(words) + + val shouldListen = + when (mode) { + VoiceWakeMode.Off -> false + VoiceWakeMode.Foreground -> foreground + VoiceWakeMode.Always -> true + } && !externalAudio + + if (!shouldListen) { + voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused") + return@collect + } + + if (!hasRecordAudioPermission()) { + voiceWake.stop(statusText = "Microphone permission required") + return@collect + } + + voiceWake.start() + } + } + + scope.launch { + talkEnabled.collect { enabled -> + talkMode.setEnabled(enabled) + externalAudioCaptureActive.value = enabled + } + } + + scope.launch(Dispatchers.Default) { + gateways.collect { list -> + if (list.isNotEmpty()) { + // Security: don't let an unauthenticated discovery feed continuously steer autoconnect. + // UX parity with iOS: only set once when unset. + if (lastDiscoveredStableId.value.trim().isEmpty()) { + prefs.setLastDiscoveredStableId(list.first().stableId) + } + } + + if (didAutoConnect) return@collect + if (_isConnected.value) return@collect + + if (manualEnabled.value) { + val host = manualHost.value.trim() + val port = manualPort.value + if (host.isNotEmpty() && port in 1..65535) { + // Security: autoconnect only to previously trusted gateways (stored TLS pin). + if (!manualTls.value) return@collect + val stableId = GatewayEndpoint.manual(host = host, port = port).stableId + val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty() + if (storedFingerprint.isEmpty()) return@collect + + didAutoConnect = true + connect(GatewayEndpoint.manual(host = host, port = port)) + } + return@collect + } + + val targetStableId = lastDiscoveredStableId.value.trim() + if (targetStableId.isEmpty()) return@collect + val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect + + // Security: autoconnect only to previously trusted gateways (stored TLS pin). + val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty() + if (storedFingerprint.isEmpty()) return@collect + + didAutoConnect = true + connect(target) + } + } + + scope.launch { + combine( + canvasDebugStatusEnabled, + statusText, + serverName, + remoteAddress, + ) { debugEnabled, status, server, remote -> + Quad(debugEnabled, status, server, remote) + }.distinctUntilChanged() + .collect { (debugEnabled, status, server, remote) -> + canvas.setDebugStatusEnabled(debugEnabled) + if (!debugEnabled) return@collect + canvas.setDebugStatus(status, server ?: remote) + } + } + } + + fun setForeground(value: Boolean) { + _isForeground.value = value + } + + fun setDisplayName(value: String) { + prefs.setDisplayName(value) + } + + fun setCameraEnabled(value: Boolean) { + prefs.setCameraEnabled(value) + } + + fun setLocationMode(mode: LocationMode) { + prefs.setLocationMode(mode) + } + + fun setLocationPreciseEnabled(value: Boolean) { + prefs.setLocationPreciseEnabled(value) + } + + fun setPreventSleep(value: Boolean) { + prefs.setPreventSleep(value) + } + + fun setManualEnabled(value: Boolean) { + prefs.setManualEnabled(value) + } + + fun setManualHost(value: String) { + prefs.setManualHost(value) + } + + fun setManualPort(value: Int) { + prefs.setManualPort(value) + } + + fun setManualTls(value: Boolean) { + prefs.setManualTls(value) + } + + fun setCanvasDebugStatusEnabled(value: Boolean) { + prefs.setCanvasDebugStatusEnabled(value) + } + + fun setWakeWords(words: List) { + prefs.setWakeWords(words) + gatewayEventHandler.scheduleWakeWordsSyncIfNeeded() + } + + fun resetWakeWordsDefaults() { + setWakeWords(SecurePrefs.defaultWakeWords) + } + + fun setVoiceWakeMode(mode: VoiceWakeMode) { + prefs.setVoiceWakeMode(mode) + } + + fun setTalkEnabled(value: Boolean) { + prefs.setTalkEnabled(value) + } + + fun logGatewayDebugSnapshot(source: String = "manual") { + val flowToken = gatewayToken.value.trim() + val loadedToken = prefs.loadGatewayToken().orEmpty() + Log.i( + "OpenClawGatewayDebug", + "source=$source manualEnabled=${manualEnabled.value} host=${manualHost.value} port=${manualPort.value} tls=${manualTls.value} flowTokenLen=${flowToken.length} loadTokenLen=${loadedToken.length} connected=${isConnected.value} status=${statusText.value}", + ) + } + + fun refreshGatewayConnection() { + val endpoint = connectedEndpoint ?: return + val token = prefs.loadGatewayToken() + val password = prefs.loadGatewayPassword() + val tls = connectionManager.resolveTlsParams(endpoint) + operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls) + nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls) + operatorSession.reconnect() + nodeSession.reconnect() + } + + fun connect(endpoint: GatewayEndpoint) { + val tls = connectionManager.resolveTlsParams(endpoint) + if (tls?.required == true && tls.expectedFingerprint.isNullOrBlank()) { + // First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect. + _statusText.value = "Verify gateway TLS fingerprint…" + scope.launch { + val fp = probeGatewayTlsFingerprint(endpoint.host, endpoint.port) ?: run { + _statusText.value = "Failed: can't read TLS fingerprint" + return@launch + } + _pendingGatewayTrust.value = GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp) + } + return + } + + connectedEndpoint = endpoint + operatorStatusText = "Connecting…" + nodeStatusText = "Connecting…" + updateStatus() + val token = prefs.loadGatewayToken() + val password = prefs.loadGatewayPassword() + operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls) + nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls) + } + + fun acceptGatewayTrustPrompt() { + val prompt = _pendingGatewayTrust.value ?: return + _pendingGatewayTrust.value = null + prefs.saveGatewayTlsFingerprint(prompt.endpoint.stableId, prompt.fingerprintSha256) + connect(prompt.endpoint) + } + + fun declineGatewayTrustPrompt() { + _pendingGatewayTrust.value = null + _statusText.value = "Offline" + } + + private fun hasRecordAudioPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) + } + + fun connectManual() { + val host = manualHost.value.trim() + val port = manualPort.value + if (host.isEmpty() || port <= 0 || port > 65535) { + _statusText.value = "Failed: invalid manual host/port" + return + } + connect(GatewayEndpoint.manual(host = host, port = port)) + } + + fun disconnect() { + connectedEndpoint = null + _pendingGatewayTrust.value = null + operatorSession.disconnect() + nodeSession.disconnect() + } + + fun handleCanvasA2UIActionFromWebView(payloadJson: String) { + scope.launch { + val trimmed = payloadJson.trim() + if (trimmed.isEmpty()) return@launch + + val root = + try { + json.parseToJsonElement(trimmed).asObjectOrNull() ?: return@launch + } catch (_: Throwable) { + return@launch + } + + val userActionObj = (root["userAction"] as? JsonObject) ?: root + val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { + java.util.UUID.randomUUID().toString() + } + val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch + + val surfaceId = + (userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" } + val sourceComponentId = + (userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" } + val contextJson = (userActionObj["context"] as? JsonObject)?.toString() + + val sessionKey = resolveMainSessionKey() + val message = + OpenClawCanvasA2UIAction.formatAgentMessage( + actionName = name, + sessionKey = sessionKey, + surfaceId = surfaceId, + sourceComponentId = sourceComponentId, + host = displayName.value, + instanceId = instanceId.value.lowercase(), + contextJson = contextJson, + ) + + val connected = _nodeConnected.value + var error: String? = null + if (connected) { + val sent = + nodeSession.sendNodeEvent( + event = "agent.request", + payloadJson = + buildJsonObject { + put("message", JsonPrimitive(message)) + put("sessionKey", JsonPrimitive(sessionKey)) + put("thinking", JsonPrimitive("low")) + put("deliver", JsonPrimitive(false)) + put("key", JsonPrimitive(actionId)) + }.toString(), + ) + if (!sent) { + error = "send failed" + } + } else { + error = "gateway not connected" + } + + try { + canvas.eval( + OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus( + actionId = actionId, + ok = connected && error == null, + error = error, + ), + ) + } catch (_: Throwable) { + // ignore + } + } + } + + fun loadChat(sessionKey: String) { + val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() } + chat.load(key) + } + + fun refreshChat() { + chat.refresh() + } + + fun refreshChatSessions(limit: Int? = null) { + chat.refreshSessions(limit = limit) + } + + fun setChatThinkingLevel(level: String) { + chat.setThinkingLevel(level) + } + + fun switchChatSession(sessionKey: String) { + chat.switchSession(sessionKey) + } + + fun abortChat() { + chat.abort() + } + + fun sendChat(message: String, thinking: String, attachments: List) { + chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments) + } + + private fun handleGatewayEvent(event: String, payloadJson: String?) { + if (event == "voicewake.changed") { + gatewayEventHandler.handleVoiceWakeChangedEvent(payloadJson) + return + } + + talkMode.handleGatewayEvent(event, payloadJson) + chat.handleGatewayEvent(event, payloadJson) + } + + private suspend fun refreshBrandingFromGateway() { + if (!_isConnected.value) return + try { + val res = operatorSession.request("config.get", "{}") + val root = json.parseToJsonElement(res).asObjectOrNull() + val config = root?.get("config").asObjectOrNull() + val ui = config?.get("ui").asObjectOrNull() + val raw = ui?.get("seamColor").asStringOrNull()?.trim() + val sessionCfg = config?.get("session").asObjectOrNull() + val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) + applyMainSessionKey(mainKey) + + val parsed = parseHexColorArgb(raw) + _seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB + } catch (_: Throwable) { + // ignore + } + } + + private fun triggerCameraFlash() { + // Token is used as a pulse trigger; value doesn't matter as long as it changes. + _cameraFlashToken.value = SystemClock.elapsedRealtimeNanos() + } + + private fun showCameraHud(message: String, kind: CameraHudKind, autoHideMs: Long? = null) { + val token = cameraHudSeq.incrementAndGet() + _cameraHud.value = CameraHudState(token = token, kind = kind, message = message) + + if (autoHideMs != null && autoHideMs > 0) { + scope.launch { + delay(autoHideMs) + if (_cameraHud.value?.token == token) _cameraHud.value = null + } + } + } + +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/PermissionRequester.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/PermissionRequester.kt new file mode 100644 index 00000000..0ee267b5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/PermissionRequester.kt @@ -0,0 +1,133 @@ +package ai.openclaw.android + +import android.content.pm.PackageManager +import android.content.Intent +import android.Manifest +import android.net.Uri +import android.provider.Settings +import androidx.appcompat.app.AlertDialog +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.core.app.ActivityCompat +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +class PermissionRequester(private val activity: ComponentActivity) { + private val mutex = Mutex() + private var pending: CompletableDeferred>? = null + + private val launcher: ActivityResultLauncher> = + activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + val p = pending + pending = null + p?.complete(result) + } + + suspend fun requestIfMissing( + permissions: List, + timeoutMs: Long = 20_000, + ): Map = + mutex.withLock { + val missing = + permissions.filter { perm -> + ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED + } + if (missing.isEmpty()) { + return permissions.associateWith { true } + } + + val needsRationale = + missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) } + if (needsRationale) { + val proceed = showRationaleDialog(missing) + if (!proceed) { + return permissions.associateWith { perm -> + ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED + } + } + } + + val deferred = CompletableDeferred>() + pending = deferred + withContext(Dispatchers.Main) { + launcher.launch(missing.toTypedArray()) + } + + val result = + withContext(Dispatchers.Default) { + kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() } + } + + // Merge: if something was already granted, treat it as granted even if launcher omitted it. + val merged = + permissions.associateWith { perm -> + val nowGranted = + ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED + result[perm] == true || nowGranted + } + + val denied = + merged.filterValues { !it }.keys.filter { + !ActivityCompat.shouldShowRequestPermissionRationale(activity, it) + } + if (denied.isNotEmpty()) { + showSettingsDialog(denied) + } + + return merged + } + + private suspend fun showRationaleDialog(permissions: List): Boolean = + withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + AlertDialog.Builder(activity) + .setTitle("Permission required") + .setMessage(buildRationaleMessage(permissions)) + .setPositiveButton("Continue") { _, _ -> cont.resume(true) } + .setNegativeButton("Not now") { _, _ -> cont.resume(false) } + .setOnCancelListener { cont.resume(false) } + .show() + } + } + + private fun showSettingsDialog(permissions: List) { + AlertDialog.Builder(activity) + .setTitle("Enable permission in Settings") + .setMessage(buildSettingsMessage(permissions)) + .setPositiveButton("Open Settings") { _, _ -> + val intent = + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", activity.packageName, null), + ) + activity.startActivity(intent) + } + .setNegativeButton("Cancel", null) + .show() + } + + private fun buildRationaleMessage(permissions: List): String { + val labels = permissions.map { permissionLabel(it) } + return "OpenClaw needs ${labels.joinToString(", ")} permissions to continue." + } + + private fun buildSettingsMessage(permissions: List): String { + val labels = permissions.map { permissionLabel(it) } + return "Please enable ${labels.joinToString(", ")} in Android Settings to continue." + } + + private fun permissionLabel(permission: String): String = + when (permission) { + Manifest.permission.CAMERA -> "Camera" + Manifest.permission.RECORD_AUDIO -> "Microphone" + Manifest.permission.SEND_SMS -> "SMS" + else -> permission + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ScreenCaptureRequester.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ScreenCaptureRequester.kt new file mode 100644 index 00000000..c215103b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ScreenCaptureRequester.kt @@ -0,0 +1,65 @@ +package ai.openclaw.android + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.media.projection.MediaProjectionManager +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +class ScreenCaptureRequester(private val activity: ComponentActivity) { + data class CaptureResult(val resultCode: Int, val data: Intent) + + private val mutex = Mutex() + private var pending: CompletableDeferred? = null + + private val launcher: ActivityResultLauncher = + activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val p = pending + pending = null + val data = result.data + if (result.resultCode == Activity.RESULT_OK && data != null) { + p?.complete(CaptureResult(result.resultCode, data)) + } else { + p?.complete(null) + } + } + + suspend fun requestCapture(timeoutMs: Long = 20_000): CaptureResult? = + mutex.withLock { + val proceed = showRationaleDialog() + if (!proceed) return null + + val mgr = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + val intent = mgr.createScreenCaptureIntent() + + val deferred = CompletableDeferred() + pending = deferred + withContext(Dispatchers.Main) { launcher.launch(intent) } + + withContext(Dispatchers.Default) { withTimeout(timeoutMs) { deferred.await() } } + } + + private suspend fun showRationaleDialog(): Boolean = + withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + AlertDialog.Builder(activity) + .setTitle("Screen recording required") + .setMessage("OpenClaw needs to record the screen for this command.") + .setPositiveButton("Continue") { _, _ -> cont.resume(true) } + .setNegativeButton("Not now") { _, _ -> cont.resume(false) } + .setOnCancelListener { cont.resume(false) } + .show() + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt new file mode 100644 index 00000000..f03e2b56 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/SecurePrefs.kt @@ -0,0 +1,299 @@ +@file:Suppress("DEPRECATION") + +package ai.openclaw.android + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import java.util.UUID + +class SecurePrefs(context: Context) { + companion object { + val defaultWakeWords: List = listOf("openclaw", "claude") + private const val displayNameKey = "node.displayName" + private const val voiceWakeModeKey = "voiceWake.mode" + } + + private val appContext = context.applicationContext + private val json = Json { ignoreUnknownKeys = true } + + private val masterKey = + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs: SharedPreferences by lazy { + createPrefs(appContext, "openclaw.node.secure") + } + + private val _instanceId = MutableStateFlow(loadOrCreateInstanceId()) + val instanceId: StateFlow = _instanceId + + private val _displayName = + MutableStateFlow(loadOrMigrateDisplayName(context = context)) + val displayName: StateFlow = _displayName + + private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true)) + val cameraEnabled: StateFlow = _cameraEnabled + + private val _locationMode = + MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off"))) + val locationMode: StateFlow = _locationMode + + private val _locationPreciseEnabled = + MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true)) + val locationPreciseEnabled: StateFlow = _locationPreciseEnabled + + private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true)) + val preventSleep: StateFlow = _preventSleep + + private val _manualEnabled = + MutableStateFlow(prefs.getBoolean("gateway.manual.enabled", false)) + val manualEnabled: StateFlow = _manualEnabled + + private val _manualHost = + MutableStateFlow(prefs.getString("gateway.manual.host", "") ?: "") + val manualHost: StateFlow = _manualHost + + private val _manualPort = + MutableStateFlow(prefs.getInt("gateway.manual.port", 18789)) + val manualPort: StateFlow = _manualPort + + private val _manualTls = + MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true)) + val manualTls: StateFlow = _manualTls + + private val _gatewayToken = + MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "") + val gatewayToken: StateFlow = _gatewayToken + + private val _onboardingCompleted = + MutableStateFlow(prefs.getBoolean("onboarding.completed", false)) + val onboardingCompleted: StateFlow = _onboardingCompleted + + private val _lastDiscoveredStableId = + MutableStateFlow( + prefs.getString("gateway.lastDiscoveredStableID", "") ?: "", + ) + val lastDiscoveredStableId: StateFlow = _lastDiscoveredStableId + + private val _canvasDebugStatusEnabled = + MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false)) + val canvasDebugStatusEnabled: StateFlow = _canvasDebugStatusEnabled + + private val _wakeWords = MutableStateFlow(loadWakeWords()) + val wakeWords: StateFlow> = _wakeWords + + private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode()) + val voiceWakeMode: StateFlow = _voiceWakeMode + + private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false)) + val talkEnabled: StateFlow = _talkEnabled + + fun setLastDiscoveredStableId(value: String) { + val trimmed = value.trim() + prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) } + _lastDiscoveredStableId.value = trimmed + } + + fun setDisplayName(value: String) { + val trimmed = value.trim() + prefs.edit { putString(displayNameKey, trimmed) } + _displayName.value = trimmed + } + + fun setCameraEnabled(value: Boolean) { + prefs.edit { putBoolean("camera.enabled", value) } + _cameraEnabled.value = value + } + + fun setLocationMode(mode: LocationMode) { + prefs.edit { putString("location.enabledMode", mode.rawValue) } + _locationMode.value = mode + } + + fun setLocationPreciseEnabled(value: Boolean) { + prefs.edit { putBoolean("location.preciseEnabled", value) } + _locationPreciseEnabled.value = value + } + + fun setPreventSleep(value: Boolean) { + prefs.edit { putBoolean("screen.preventSleep", value) } + _preventSleep.value = value + } + + fun setManualEnabled(value: Boolean) { + prefs.edit { putBoolean("gateway.manual.enabled", value) } + _manualEnabled.value = value + } + + fun setManualHost(value: String) { + val trimmed = value.trim() + prefs.edit { putString("gateway.manual.host", trimmed) } + _manualHost.value = trimmed + } + + fun setManualPort(value: Int) { + prefs.edit { putInt("gateway.manual.port", value) } + _manualPort.value = value + } + + fun setManualTls(value: Boolean) { + prefs.edit { putBoolean("gateway.manual.tls", value) } + _manualTls.value = value + } + + fun setGatewayToken(value: String) { + val trimmed = value.trim() + prefs.edit(commit = true) { putString("gateway.manual.token", trimmed) } + _gatewayToken.value = trimmed + } + + fun setGatewayPassword(value: String) { + saveGatewayPassword(value) + } + + fun setOnboardingCompleted(value: Boolean) { + prefs.edit { putBoolean("onboarding.completed", value) } + _onboardingCompleted.value = value + } + + fun setCanvasDebugStatusEnabled(value: Boolean) { + prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } + _canvasDebugStatusEnabled.value = value + } + + fun loadGatewayToken(): String? { + val manual = _gatewayToken.value.trim() + if (manual.isNotEmpty()) return manual + val key = "gateway.token.${_instanceId.value}" + val stored = prefs.getString(key, null)?.trim() + return stored?.takeIf { it.isNotEmpty() } + } + + fun saveGatewayToken(token: String) { + val key = "gateway.token.${_instanceId.value}" + prefs.edit { putString(key, token.trim()) } + } + + fun loadGatewayPassword(): String? { + val key = "gateway.password.${_instanceId.value}" + val stored = prefs.getString(key, null)?.trim() + return stored?.takeIf { it.isNotEmpty() } + } + + fun saveGatewayPassword(password: String) { + val key = "gateway.password.${_instanceId.value}" + prefs.edit { putString(key, password.trim()) } + } + + fun loadGatewayTlsFingerprint(stableId: String): String? { + val key = "gateway.tls.$stableId" + return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } + } + + fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) { + val key = "gateway.tls.$stableId" + prefs.edit { putString(key, fingerprint.trim()) } + } + + fun getString(key: String): String? { + return prefs.getString(key, null) + } + + fun putString(key: String, value: String) { + prefs.edit { putString(key, value) } + } + + fun remove(key: String) { + prefs.edit { remove(key) } + } + + private fun createPrefs(context: Context, name: String): SharedPreferences { + return EncryptedSharedPreferences.create( + context, + name, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + private fun loadOrCreateInstanceId(): String { + val existing = prefs.getString("node.instanceId", null)?.trim() + if (!existing.isNullOrBlank()) return existing + val fresh = UUID.randomUUID().toString() + prefs.edit { putString("node.instanceId", fresh) } + return fresh + } + + private fun loadOrMigrateDisplayName(context: Context): String { + val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty() + if (existing.isNotEmpty() && existing != "Android Node") return existing + + val candidate = DeviceNames.bestDefaultNodeName(context).trim() + val resolved = candidate.ifEmpty { "Android Node" } + + prefs.edit { putString(displayNameKey, resolved) } + return resolved + } + + fun setWakeWords(words: List) { + val sanitized = WakeWords.sanitize(words, defaultWakeWords) + val encoded = + JsonArray(sanitized.map { JsonPrimitive(it) }).toString() + prefs.edit { putString("voiceWake.triggerWords", encoded) } + _wakeWords.value = sanitized + } + + fun setVoiceWakeMode(mode: VoiceWakeMode) { + prefs.edit { putString(voiceWakeModeKey, mode.rawValue) } + _voiceWakeMode.value = mode + } + + fun setTalkEnabled(value: Boolean) { + prefs.edit { putBoolean("talk.enabled", value) } + _talkEnabled.value = value + } + + private fun loadVoiceWakeMode(): VoiceWakeMode { + val raw = prefs.getString(voiceWakeModeKey, null) + val resolved = VoiceWakeMode.fromRawValue(raw) + + // Default ON (foreground) when unset. + if (raw.isNullOrBlank()) { + prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) } + } + + return resolved + } + + private fun loadWakeWords(): List { + val raw = prefs.getString("voiceWake.triggerWords", null)?.trim() + if (raw.isNullOrEmpty()) return defaultWakeWords + return try { + val element = json.parseToJsonElement(raw) + val array = element as? JsonArray ?: return defaultWakeWords + val decoded = + array.mapNotNull { item -> + when (item) { + is JsonNull -> null + is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() } + else -> null + } + } + WakeWords.sanitize(decoded, defaultWakeWords) + } catch (_: Throwable) { + defaultWakeWords + } + } + +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/SessionKey.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/SessionKey.kt new file mode 100644 index 00000000..8148a170 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/SessionKey.kt @@ -0,0 +1,13 @@ +package ai.openclaw.android + +internal fun normalizeMainKey(raw: String?): String { + val trimmed = raw?.trim() + return if (!trimmed.isNullOrEmpty()) trimmed else "main" +} + +internal fun isCanonicalMainSessionKey(raw: String?): Boolean { + val trimmed = raw?.trim().orEmpty() + if (trimmed.isEmpty()) return false + if (trimmed == "global") return true + return trimmed.startsWith("agent:") +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/VoiceWakeMode.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/VoiceWakeMode.kt new file mode 100644 index 00000000..75c2fe34 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/VoiceWakeMode.kt @@ -0,0 +1,14 @@ +package ai.openclaw.android + +enum class VoiceWakeMode(val rawValue: String) { + Off("off"), + Foreground("foreground"), + Always("always"), + ; + + companion object { + fun fromRawValue(raw: String?): VoiceWakeMode { + return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/WakeWords.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/WakeWords.kt new file mode 100644 index 00000000..b64cb1dd --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/WakeWords.kt @@ -0,0 +1,21 @@ +package ai.openclaw.android + +object WakeWords { + const val maxWords: Int = 32 + const val maxWordLength: Int = 64 + + fun parseCommaSeparated(input: String): List { + return input.split(",").map { it.trim() }.filter { it.isNotEmpty() } + } + + fun parseIfChanged(input: String, current: List): List? { + val parsed = parseCommaSeparated(input) + return if (parsed == current) null else parsed + } + + fun sanitize(words: List, defaults: List): List { + val cleaned = + words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) } + return cleaned.ifEmpty { defaults } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt new file mode 100644 index 00000000..335f3b0d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatController.kt @@ -0,0 +1,540 @@ +package ai.openclaw.android.chat + +import ai.openclaw.android.gateway.GatewaySession +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +class ChatController( + private val scope: CoroutineScope, + private val session: GatewaySession, + private val json: Json, + private val supportsChatSubscribe: Boolean, +) { + private val _sessionKey = MutableStateFlow("main") + val sessionKey: StateFlow = _sessionKey.asStateFlow() + + private val _sessionId = MutableStateFlow(null) + val sessionId: StateFlow = _sessionId.asStateFlow() + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + private val _errorText = MutableStateFlow(null) + val errorText: StateFlow = _errorText.asStateFlow() + + private val _healthOk = MutableStateFlow(false) + val healthOk: StateFlow = _healthOk.asStateFlow() + + private val _thinkingLevel = MutableStateFlow("off") + val thinkingLevel: StateFlow = _thinkingLevel.asStateFlow() + + private val _pendingRunCount = MutableStateFlow(0) + val pendingRunCount: StateFlow = _pendingRunCount.asStateFlow() + + private val _streamingAssistantText = MutableStateFlow(null) + val streamingAssistantText: StateFlow = _streamingAssistantText.asStateFlow() + + private val pendingToolCallsById = ConcurrentHashMap() + private val _pendingToolCalls = MutableStateFlow>(emptyList()) + val pendingToolCalls: StateFlow> = _pendingToolCalls.asStateFlow() + + private val _sessions = MutableStateFlow>(emptyList()) + val sessions: StateFlow> = _sessions.asStateFlow() + + private val pendingRuns = mutableSetOf() + private val pendingRunTimeoutJobs = ConcurrentHashMap() + private val pendingRunTimeoutMs = 120_000L + + private var lastHealthPollAtMs: Long? = null + + fun onDisconnected(message: String) { + _healthOk.value = false + // Not an error; keep connection status in the UI pill. + _errorText.value = null + clearPendingRuns() + pendingToolCallsById.clear() + publishPendingToolCalls() + _streamingAssistantText.value = null + _sessionId.value = null + } + + fun load(sessionKey: String) { + val key = sessionKey.trim().ifEmpty { "main" } + _sessionKey.value = key + scope.launch { bootstrap(forceHealth = true) } + } + + fun applyMainSessionKey(mainSessionKey: String) { + val trimmed = mainSessionKey.trim() + if (trimmed.isEmpty()) return + if (_sessionKey.value == trimmed) return + if (_sessionKey.value != "main") return + _sessionKey.value = trimmed + scope.launch { bootstrap(forceHealth = true) } + } + + fun refresh() { + scope.launch { bootstrap(forceHealth = true) } + } + + fun refreshSessions(limit: Int? = null) { + scope.launch { fetchSessions(limit = limit) } + } + + fun setThinkingLevel(thinkingLevel: String) { + val normalized = normalizeThinking(thinkingLevel) + if (normalized == _thinkingLevel.value) return + _thinkingLevel.value = normalized + } + + fun switchSession(sessionKey: String) { + val key = sessionKey.trim() + if (key.isEmpty()) return + if (key == _sessionKey.value) return + _sessionKey.value = key + scope.launch { bootstrap(forceHealth = true) } + } + + fun sendMessage( + message: String, + thinkingLevel: String, + attachments: List, + ) { + val trimmed = message.trim() + if (trimmed.isEmpty() && attachments.isEmpty()) return + if (!_healthOk.value) { + _errorText.value = "Gateway health not OK; cannot send" + return + } + + val runId = UUID.randomUUID().toString() + val text = if (trimmed.isEmpty() && attachments.isNotEmpty()) "See attached." else trimmed + val sessionKey = _sessionKey.value + val thinking = normalizeThinking(thinkingLevel) + + // Optimistic user message. + val userContent = + buildList { + add(ChatMessageContent(type = "text", text = text)) + for (att in attachments) { + add( + ChatMessageContent( + type = att.type, + mimeType = att.mimeType, + fileName = att.fileName, + base64 = att.base64, + ), + ) + } + } + _messages.value = + _messages.value + + ChatMessage( + id = UUID.randomUUID().toString(), + role = "user", + content = userContent, + timestampMs = System.currentTimeMillis(), + ) + + armPendingRunTimeout(runId) + synchronized(pendingRuns) { + pendingRuns.add(runId) + _pendingRunCount.value = pendingRuns.size + } + + _errorText.value = null + _streamingAssistantText.value = null + pendingToolCallsById.clear() + publishPendingToolCalls() + + scope.launch { + try { + val params = + buildJsonObject { + put("sessionKey", JsonPrimitive(sessionKey)) + put("message", JsonPrimitive(text)) + put("thinking", JsonPrimitive(thinking)) + put("timeoutMs", JsonPrimitive(30_000)) + put("idempotencyKey", JsonPrimitive(runId)) + if (attachments.isNotEmpty()) { + put( + "attachments", + JsonArray( + attachments.map { att -> + buildJsonObject { + put("type", JsonPrimitive(att.type)) + put("mimeType", JsonPrimitive(att.mimeType)) + put("fileName", JsonPrimitive(att.fileName)) + put("content", JsonPrimitive(att.base64)) + } + }, + ), + ) + } + } + val res = session.request("chat.send", params.toString()) + val actualRunId = parseRunId(res) ?: runId + if (actualRunId != runId) { + clearPendingRun(runId) + armPendingRunTimeout(actualRunId) + synchronized(pendingRuns) { + pendingRuns.add(actualRunId) + _pendingRunCount.value = pendingRuns.size + } + } + } catch (err: Throwable) { + clearPendingRun(runId) + _errorText.value = err.message + } + } + } + + fun abort() { + val runIds = + synchronized(pendingRuns) { + pendingRuns.toList() + } + if (runIds.isEmpty()) return + scope.launch { + for (runId in runIds) { + try { + val params = + buildJsonObject { + put("sessionKey", JsonPrimitive(_sessionKey.value)) + put("runId", JsonPrimitive(runId)) + } + session.request("chat.abort", params.toString()) + } catch (_: Throwable) { + // best-effort + } + } + } + } + + fun handleGatewayEvent(event: String, payloadJson: String?) { + when (event) { + "tick" -> { + scope.launch { pollHealthIfNeeded(force = false) } + } + "health" -> { + // If we receive a health snapshot, the gateway is reachable. + _healthOk.value = true + } + "seqGap" -> { + _errorText.value = "Event stream interrupted; try refreshing." + clearPendingRuns() + } + "chat" -> { + if (payloadJson.isNullOrBlank()) return + handleChatEvent(payloadJson) + } + "agent" -> { + if (payloadJson.isNullOrBlank()) return + handleAgentEvent(payloadJson) + } + } + } + + private suspend fun bootstrap(forceHealth: Boolean) { + _errorText.value = null + _healthOk.value = false + clearPendingRuns() + pendingToolCallsById.clear() + publishPendingToolCalls() + _streamingAssistantText.value = null + _sessionId.value = null + + val key = _sessionKey.value + try { + if (supportsChatSubscribe) { + session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") + } + + val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") + val history = parseHistory(historyJson, sessionKey = key) + _messages.value = history.messages + _sessionId.value = history.sessionId + history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } + + pollHealthIfNeeded(force = forceHealth) + fetchSessions(limit = 50) + } catch (err: Throwable) { + _errorText.value = err.message + } + } + + private suspend fun fetchSessions(limit: Int?) { + try { + val params = + buildJsonObject { + put("includeGlobal", JsonPrimitive(true)) + put("includeUnknown", JsonPrimitive(false)) + if (limit != null && limit > 0) put("limit", JsonPrimitive(limit)) + } + val res = session.request("sessions.list", params.toString()) + _sessions.value = parseSessions(res) + } catch (_: Throwable) { + // best-effort + } + } + + private suspend fun pollHealthIfNeeded(force: Boolean) { + val now = System.currentTimeMillis() + val last = lastHealthPollAtMs + if (!force && last != null && now - last < 10_000) return + lastHealthPollAtMs = now + try { + session.request("health", null) + _healthOk.value = true + } catch (_: Throwable) { + _healthOk.value = false + } + } + + private fun handleChatEvent(payloadJson: String) { + val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return + val sessionKey = payload["sessionKey"].asStringOrNull()?.trim() + if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return + + val runId = payload["runId"].asStringOrNull() + if (runId != null) { + val isPending = + synchronized(pendingRuns) { + pendingRuns.contains(runId) + } + if (!isPending) return + } + + val state = payload["state"].asStringOrNull() + when (state) { + "delta" -> { + val text = parseAssistantDeltaText(payload) + if (!text.isNullOrEmpty()) { + _streamingAssistantText.value = text + } + } + "final", "aborted", "error" -> { + if (state == "error") { + _errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed" + } + if (runId != null) clearPendingRun(runId) else clearPendingRuns() + pendingToolCallsById.clear() + publishPendingToolCalls() + _streamingAssistantText.value = null + scope.launch { + try { + val historyJson = + session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""") + val history = parseHistory(historyJson, sessionKey = _sessionKey.value) + _messages.value = history.messages + _sessionId.value = history.sessionId + history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } + } catch (_: Throwable) { + // best-effort + } + } + } + } + } + + private fun handleAgentEvent(payloadJson: String) { + val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return + val sessionKey = payload["sessionKey"].asStringOrNull()?.trim() + if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return + + val stream = payload["stream"].asStringOrNull() + val data = payload["data"].asObjectOrNull() + + when (stream) { + "assistant" -> { + val text = data?.get("text")?.asStringOrNull() + if (!text.isNullOrEmpty()) { + _streamingAssistantText.value = text + } + } + "tool" -> { + val phase = data?.get("phase")?.asStringOrNull() + val name = data?.get("name")?.asStringOrNull() + val toolCallId = data?.get("toolCallId")?.asStringOrNull() + if (phase.isNullOrEmpty() || name.isNullOrEmpty() || toolCallId.isNullOrEmpty()) return + + val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis() + if (phase == "start") { + val args = data?.get("args").asObjectOrNull() + pendingToolCallsById[toolCallId] = + ChatPendingToolCall( + toolCallId = toolCallId, + name = name, + args = args, + startedAtMs = ts, + isError = null, + ) + publishPendingToolCalls() + } else if (phase == "result") { + pendingToolCallsById.remove(toolCallId) + publishPendingToolCalls() + } + } + "error" -> { + _errorText.value = "Event stream interrupted; try refreshing." + clearPendingRuns() + pendingToolCallsById.clear() + publishPendingToolCalls() + _streamingAssistantText.value = null + } + } + } + + private fun parseAssistantDeltaText(payload: JsonObject): String? { + val message = payload["message"].asObjectOrNull() ?: return null + if (message["role"].asStringOrNull() != "assistant") return null + val content = message["content"].asArrayOrNull() ?: return null + for (item in content) { + val obj = item.asObjectOrNull() ?: continue + if (obj["type"].asStringOrNull() != "text") continue + val text = obj["text"].asStringOrNull() + if (!text.isNullOrEmpty()) { + return text + } + } + return null + } + + private fun publishPendingToolCalls() { + _pendingToolCalls.value = + pendingToolCallsById.values.sortedBy { it.startedAtMs } + } + + private fun armPendingRunTimeout(runId: String) { + pendingRunTimeoutJobs[runId]?.cancel() + pendingRunTimeoutJobs[runId] = + scope.launch { + delay(pendingRunTimeoutMs) + val stillPending = + synchronized(pendingRuns) { + pendingRuns.contains(runId) + } + if (!stillPending) return@launch + clearPendingRun(runId) + _errorText.value = "Timed out waiting for a reply; try again or refresh." + } + } + + private fun clearPendingRun(runId: String) { + pendingRunTimeoutJobs.remove(runId)?.cancel() + synchronized(pendingRuns) { + pendingRuns.remove(runId) + _pendingRunCount.value = pendingRuns.size + } + } + + private fun clearPendingRuns() { + for ((_, job) in pendingRunTimeoutJobs) { + job.cancel() + } + pendingRunTimeoutJobs.clear() + synchronized(pendingRuns) { + pendingRuns.clear() + _pendingRunCount.value = 0 + } + } + + private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory { + val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList()) + val sid = root["sessionId"].asStringOrNull() + val thinkingLevel = root["thinkingLevel"].asStringOrNull() + val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList()) + + val messages = + array.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val role = obj["role"].asStringOrNull() ?: return@mapNotNull null + val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseMessageContent) ?: emptyList() + val ts = obj["timestamp"].asLongOrNull() + ChatMessage( + id = UUID.randomUUID().toString(), + role = role, + content = content, + timestampMs = ts, + ) + } + + return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages) + } + + private fun parseMessageContent(el: JsonElement): ChatMessageContent? { + val obj = el.asObjectOrNull() ?: return null + val type = obj["type"].asStringOrNull() ?: "text" + return if (type == "text") { + ChatMessageContent(type = "text", text = obj["text"].asStringOrNull()) + } else { + ChatMessageContent( + type = type, + mimeType = obj["mimeType"].asStringOrNull(), + fileName = obj["fileName"].asStringOrNull(), + base64 = obj["content"].asStringOrNull(), + ) + } + } + + private fun parseSessions(jsonString: String): List { + val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList() + val sessions = root["sessions"].asArrayOrNull() ?: return emptyList() + return sessions.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val key = obj["key"].asStringOrNull()?.trim().orEmpty() + if (key.isEmpty()) return@mapNotNull null + val updatedAt = obj["updatedAt"].asLongOrNull() + val displayName = obj["displayName"].asStringOrNull()?.trim() + ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName) + } + } + + private fun parseRunId(resJson: String): String? { + return try { + json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull() + } catch (_: Throwable) { + null + } + } + + private fun normalizeThinking(raw: String): String { + return when (raw.trim().lowercase()) { + "low" -> "low" + "medium" -> "medium" + "high" -> "high" + else -> "off" + } + } +} + +private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject + +private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray + +private fun JsonElement?.asStringOrNull(): String? = + when (this) { + is JsonNull -> null + is JsonPrimitive -> content + else -> null + } + +private fun JsonElement?.asLongOrNull(): Long? = + when (this) { + is JsonPrimitive -> content.toLongOrNull() + else -> null + } diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatModels.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatModels.kt new file mode 100644 index 00000000..dd17a8c1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/chat/ChatModels.kt @@ -0,0 +1,44 @@ +package ai.openclaw.android.chat + +data class ChatMessage( + val id: String, + val role: String, + val content: List, + val timestampMs: Long?, +) + +data class ChatMessageContent( + val type: String = "text", + val text: String? = null, + val mimeType: String? = null, + val fileName: String? = null, + val base64: String? = null, +) + +data class ChatPendingToolCall( + val toolCallId: String, + val name: String, + val args: kotlinx.serialization.json.JsonObject? = null, + val startedAtMs: Long, + val isError: Boolean? = null, +) + +data class ChatSessionEntry( + val key: String, + val updatedAtMs: Long?, + val displayName: String? = null, +) + +data class ChatHistory( + val sessionKey: String, + val sessionId: String?, + val thinkingLevel: String?, + val messages: List, +) + +data class OutgoingAttachment( + val type: String, + val mimeType: String, + val fileName: String, + val base64: String, +) diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/BonjourEscapes.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/BonjourEscapes.kt new file mode 100644 index 00000000..1606df79 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/BonjourEscapes.kt @@ -0,0 +1,35 @@ +package ai.openclaw.android.gateway + +object BonjourEscapes { + fun decode(input: String): String { + if (input.isEmpty()) return input + + val bytes = mutableListOf() + var i = 0 + while (i < input.length) { + if (input[i] == '\\' && i + 3 < input.length) { + val d0 = input[i + 1] + val d1 = input[i + 2] + val d2 = input[i + 3] + if (d0.isDigit() && d1.isDigit() && d2.isDigit()) { + val value = + ((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code) + if (value in 0..255) { + bytes.add(value.toByte()) + i += 4 + continue + } + } + } + + val codePoint = Character.codePointAt(input, i) + val charBytes = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8) + for (b in charBytes) { + bytes.add(b) + } + i += Character.charCount(codePoint) + } + + return String(bytes.toByteArray(), Charsets.UTF_8) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt new file mode 100644 index 00000000..810e029f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceAuthStore.kt @@ -0,0 +1,26 @@ +package ai.openclaw.android.gateway + +import ai.openclaw.android.SecurePrefs + +class DeviceAuthStore(private val prefs: SecurePrefs) { + fun loadToken(deviceId: String, role: String): String? { + val key = tokenKey(deviceId, role) + return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } + } + + fun saveToken(deviceId: String, role: String, token: String) { + val key = tokenKey(deviceId, role) + prefs.putString(key, token.trim()) + } + + fun clearToken(deviceId: String, role: String) { + val key = tokenKey(deviceId, role) + prefs.remove(key) + } + + private fun tokenKey(deviceId: String, role: String): String { + val normalizedDevice = deviceId.trim().lowercase() + val normalizedRole = role.trim().lowercase() + return "gateway.deviceToken.$normalizedDevice.$normalizedRole" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt new file mode 100644 index 00000000..ff651c6c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/DeviceIdentityStore.kt @@ -0,0 +1,182 @@ +package ai.openclaw.android.gateway + +import android.content.Context +import android.util.Base64 +import java.io.File +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class DeviceIdentity( + val deviceId: String, + val publicKeyRawBase64: String, + val privateKeyPkcs8Base64: String, + val createdAtMs: Long, +) + +class DeviceIdentityStore(context: Context) { + private val json = Json { ignoreUnknownKeys = true } + private val identityFile = File(context.filesDir, "openclaw/identity/device.json") + + @Synchronized + fun loadOrCreate(): DeviceIdentity { + val existing = load() + if (existing != null) { + val derived = deriveDeviceId(existing.publicKeyRawBase64) + if (derived != null && derived != existing.deviceId) { + val updated = existing.copy(deviceId = derived) + save(updated) + return updated + } + return existing + } + val fresh = generate() + save(fresh) + return fresh + } + + fun signPayload(payload: String, identity: DeviceIdentity): String? { + return try { + // Use BC lightweight API directly — JCA provider registration is broken by R8 + val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT) + val pkInfo = org.bouncycastle.asn1.pkcs.PrivateKeyInfo.getInstance(privateKeyBytes) + val parsed = pkInfo.parsePrivateKey() + val rawPrivate = org.bouncycastle.asn1.DEROctetString.getInstance(parsed).octets + val privateKey = org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters(rawPrivate, 0) + val signer = org.bouncycastle.crypto.signers.Ed25519Signer() + signer.init(true, privateKey) + val payloadBytes = payload.toByteArray(Charsets.UTF_8) + signer.update(payloadBytes, 0, payloadBytes.size) + base64UrlEncode(signer.generateSignature()) + } catch (e: Throwable) { + android.util.Log.e("DeviceAuth", "signPayload FAILED: ${e.javaClass.simpleName}: ${e.message}", e) + null + } + } + + fun verifySelfSignature(payload: String, signatureBase64Url: String, identity: DeviceIdentity): Boolean { + return try { + val rawPublicKey = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT) + val pubKey = org.bouncycastle.crypto.params.Ed25519PublicKeyParameters(rawPublicKey, 0) + val sigBytes = base64UrlDecode(signatureBase64Url) + val verifier = org.bouncycastle.crypto.signers.Ed25519Signer() + verifier.init(false, pubKey) + val payloadBytes = payload.toByteArray(Charsets.UTF_8) + verifier.update(payloadBytes, 0, payloadBytes.size) + verifier.verifySignature(sigBytes) + } catch (e: Throwable) { + android.util.Log.e("DeviceAuth", "self-verify exception: ${e.message}", e) + false + } + } + + private fun base64UrlDecode(input: String): ByteArray { + val normalized = input.replace('-', '+').replace('_', '/') + val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4) + return Base64.decode(padded, Base64.DEFAULT) + } + + fun publicKeyBase64Url(identity: DeviceIdentity): String? { + return try { + val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT) + base64UrlEncode(raw) + } catch (_: Throwable) { + null + } + } + + private fun load(): DeviceIdentity? { + return readIdentity(identityFile) + } + + private fun readIdentity(file: File): DeviceIdentity? { + return try { + if (!file.exists()) return null + val raw = file.readText(Charsets.UTF_8) + val decoded = json.decodeFromString(DeviceIdentity.serializer(), raw) + if (decoded.deviceId.isBlank() || + decoded.publicKeyRawBase64.isBlank() || + decoded.privateKeyPkcs8Base64.isBlank() + ) { + null + } else { + decoded + } + } catch (_: Throwable) { + null + } + } + + private fun save(identity: DeviceIdentity) { + try { + identityFile.parentFile?.mkdirs() + val encoded = json.encodeToString(DeviceIdentity.serializer(), identity) + identityFile.writeText(encoded, Charsets.UTF_8) + } catch (_: Throwable) { + // best-effort only + } + } + + private fun generate(): DeviceIdentity { + // Use BC lightweight API directly to avoid JCA provider issues with R8 + val kpGen = org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator() + kpGen.init(org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters(java.security.SecureRandom())) + val kp = kpGen.generateKeyPair() + val pubKey = kp.public as org.bouncycastle.crypto.params.Ed25519PublicKeyParameters + val privKey = kp.private as org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters + val rawPublic = pubKey.encoded // 32 bytes + val deviceId = sha256Hex(rawPublic) + // Encode private key as PKCS8 for storage + val privKeyInfo = org.bouncycastle.crypto.util.PrivateKeyInfoFactory.createPrivateKeyInfo(privKey) + val pkcs8Bytes = privKeyInfo.encoded + return DeviceIdentity( + deviceId = deviceId, + publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP), + privateKeyPkcs8Base64 = Base64.encodeToString(pkcs8Bytes, Base64.NO_WRAP), + createdAtMs = System.currentTimeMillis(), + ) + } + + private fun deriveDeviceId(publicKeyRawBase64: String): String? { + return try { + val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT) + sha256Hex(raw) + } catch (_: Throwable) { + null + } + } + + private fun stripSpkiPrefix(spki: ByteArray): ByteArray { + if (spki.size == ED25519_SPKI_PREFIX.size + 32 && + spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX) + ) { + return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size) + } + return spki + } + + private fun sha256Hex(data: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(data) + val out = StringBuilder(digest.size * 2) + for (byte in digest) { + out.append(String.format("%02x", byte)) + } + return out.toString() + } + + private fun base64UrlEncode(data: ByteArray): String { + return Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + } + + companion object { + private val ED25519_SPKI_PREFIX = + byteArrayOf( + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayDiscovery.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayDiscovery.kt new file mode 100644 index 00000000..2ad8ec0c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayDiscovery.kt @@ -0,0 +1,521 @@ +package ai.openclaw.android.gateway + +import android.content.Context +import android.net.ConnectivityManager +import android.net.DnsResolver +import android.net.NetworkCapabilities +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.CancellationSignal +import android.util.Log +import java.io.IOException +import java.net.InetSocketAddress +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import org.xbill.DNS.AAAARecord +import org.xbill.DNS.ARecord +import org.xbill.DNS.DClass +import org.xbill.DNS.ExtendedResolver +import org.xbill.DNS.Message +import org.xbill.DNS.Name +import org.xbill.DNS.PTRRecord +import org.xbill.DNS.Record +import org.xbill.DNS.Rcode +import org.xbill.DNS.Resolver +import org.xbill.DNS.SRVRecord +import org.xbill.DNS.Section +import org.xbill.DNS.SimpleResolver +import org.xbill.DNS.TextParseException +import org.xbill.DNS.TXTRecord +import org.xbill.DNS.Type +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Suppress("DEPRECATION") +class GatewayDiscovery( + context: Context, + private val scope: CoroutineScope, +) { + private val nsd = context.getSystemService(NsdManager::class.java) + private val connectivity = context.getSystemService(ConnectivityManager::class.java) + private val dns = DnsResolver.getInstance() + private val serviceType = "_openclaw-gw._tcp." + private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN") + private val logTag = "OpenClaw/GatewayDiscovery" + + private val localById = ConcurrentHashMap() + private val unicastById = ConcurrentHashMap() + private val _gateways = MutableStateFlow>(emptyList()) + val gateways: StateFlow> = _gateways.asStateFlow() + + private val _statusText = MutableStateFlow("Searching…") + val statusText: StateFlow = _statusText.asStateFlow() + + private var unicastJob: Job? = null + private val dnsExecutor: Executor = Executors.newCachedThreadPool() + + @Volatile private var lastWideAreaRcode: Int? = null + @Volatile private var lastWideAreaCount: Int = 0 + + private val discoveryListener = + object : NsdManager.DiscoveryListener { + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {} + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {} + override fun onDiscoveryStarted(serviceType: String) {} + override fun onDiscoveryStopped(serviceType: String) {} + + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + if (serviceInfo.serviceType != this@GatewayDiscovery.serviceType) return + resolve(serviceInfo) + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + val serviceName = BonjourEscapes.decode(serviceInfo.serviceName) + val id = stableId(serviceName, "local.") + localById.remove(id) + publish() + } + } + + init { + startLocalDiscovery() + if (!wideAreaDomain.isNullOrBlank()) { + startUnicastDiscovery(wideAreaDomain) + } + } + + private fun startLocalDiscovery() { + try { + nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener) + } catch (_: Throwable) { + // ignore (best-effort) + } + } + + private fun stopLocalDiscovery() { + try { + nsd.stopServiceDiscovery(discoveryListener) + } catch (_: Throwable) { + // ignore (best-effort) + } + } + + private fun startUnicastDiscovery(domain: String) { + unicastJob = + scope.launch(Dispatchers.IO) { + while (true) { + try { + refreshUnicast(domain) + } catch (_: Throwable) { + // ignore (best-effort) + } + delay(5000) + } + } + } + + private fun resolve(serviceInfo: NsdServiceInfo) { + nsd.resolveService( + serviceInfo, + object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {} + + override fun onServiceResolved(resolved: NsdServiceInfo) { + val host = resolved.host?.hostAddress ?: return + val port = resolved.port + if (port <= 0) return + + val rawServiceName = resolved.serviceName + val serviceName = BonjourEscapes.decode(rawServiceName) + val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName) + val lanHost = txt(resolved, "lanHost") + val tailnetDns = txt(resolved, "tailnetDns") + val gatewayPort = txtInt(resolved, "gatewayPort") + val canvasPort = txtInt(resolved, "canvasPort") + val tlsEnabled = txtBool(resolved, "gatewayTls") + val tlsFingerprint = txt(resolved, "gatewayTlsSha256") + val id = stableId(serviceName, "local.") + localById[id] = + GatewayEndpoint( + stableId = id, + name = displayName, + host = host, + port = port, + lanHost = lanHost, + tailnetDns = tailnetDns, + gatewayPort = gatewayPort, + canvasPort = canvasPort, + tlsEnabled = tlsEnabled, + tlsFingerprintSha256 = tlsFingerprint, + ) + publish() + } + }, + ) + } + + private fun publish() { + _gateways.value = + (localById.values + unicastById.values).sortedBy { it.name.lowercase() } + _statusText.value = buildStatusText() + } + + private fun buildStatusText(): String { + val localCount = localById.size + val wideRcode = lastWideAreaRcode + val wideCount = lastWideAreaCount + + val wide = + when (wideRcode) { + null -> "Wide: ?" + Rcode.NOERROR -> "Wide: $wideCount" + Rcode.NXDOMAIN -> "Wide: NXDOMAIN" + else -> "Wide: ${Rcode.string(wideRcode)}" + } + + return when { + localCount == 0 && wideRcode == null -> "Searching for gateways…" + localCount == 0 -> "$wide" + else -> "Local: $localCount • $wide" + } + } + + private fun stableId(serviceName: String, domain: String): String { + return "${serviceType}|${domain}|${normalizeName(serviceName)}" + } + + private fun normalizeName(raw: String): String { + return raw.trim().split(Regex("\\s+")).joinToString(" ") + } + + private fun txt(info: NsdServiceInfo, key: String): String? { + val bytes = info.attributes[key] ?: return null + return try { + String(bytes, Charsets.UTF_8).trim().ifEmpty { null } + } catch (_: Throwable) { + null + } + } + + private fun txtInt(info: NsdServiceInfo, key: String): Int? { + return txt(info, key)?.toIntOrNull() + } + + private fun txtBool(info: NsdServiceInfo, key: String): Boolean { + val raw = txt(info, key)?.trim()?.lowercase() ?: return false + return raw == "1" || raw == "true" || raw == "yes" + } + + private suspend fun refreshUnicast(domain: String) { + val ptrName = "${serviceType}${domain}" + val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return + val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord } + + val next = LinkedHashMap() + for (ptr in ptrRecords) { + val instanceFqdn = ptr.target.toString() + val srv = + recordByName(ptrMsg, instanceFqdn, Type.SRV) as? SRVRecord + ?: run { + val msg = lookupUnicastMessage(instanceFqdn, Type.SRV) ?: return@run null + recordByName(msg, instanceFqdn, Type.SRV) as? SRVRecord + } + ?: continue + val port = srv.port + if (port <= 0) continue + + val targetFqdn = srv.target.toString() + val host = + resolveHostFromMessage(ptrMsg, targetFqdn) + ?: resolveHostFromMessage(lookupUnicastMessage(instanceFqdn, Type.SRV), targetFqdn) + ?: resolveHostUnicast(targetFqdn) + ?: continue + + val txtFromPtr = + recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)] + .orEmpty() + .mapNotNull { it as? TXTRecord } + val txt = + if (txtFromPtr.isNotEmpty()) { + txtFromPtr + } else { + val msg = lookupUnicastMessage(instanceFqdn, Type.TXT) + records(msg, Section.ANSWER).mapNotNull { it as? TXTRecord } + } + val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain)) + val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName) + val lanHost = txtValue(txt, "lanHost") + val tailnetDns = txtValue(txt, "tailnetDns") + val gatewayPort = txtIntValue(txt, "gatewayPort") + val canvasPort = txtIntValue(txt, "canvasPort") + val tlsEnabled = txtBoolValue(txt, "gatewayTls") + val tlsFingerprint = txtValue(txt, "gatewayTlsSha256") + val id = stableId(instanceName, domain) + next[id] = + GatewayEndpoint( + stableId = id, + name = displayName, + host = host, + port = port, + lanHost = lanHost, + tailnetDns = tailnetDns, + gatewayPort = gatewayPort, + canvasPort = canvasPort, + tlsEnabled = tlsEnabled, + tlsFingerprintSha256 = tlsFingerprint, + ) + } + + unicastById.clear() + unicastById.putAll(next) + lastWideAreaRcode = ptrMsg.header.rcode + lastWideAreaCount = next.size + publish() + + if (next.isEmpty()) { + Log.d( + logTag, + "wide-area discovery: 0 results for $ptrName (rcode=${Rcode.string(ptrMsg.header.rcode)})", + ) + } + } + + private fun decodeInstanceName(instanceFqdn: String, domain: String): String { + val suffix = "${serviceType}${domain}" + val withoutSuffix = + if (instanceFqdn.endsWith(suffix)) { + instanceFqdn.removeSuffix(suffix) + } else { + instanceFqdn.substringBefore(serviceType) + } + return normalizeName(stripTrailingDot(withoutSuffix)) + } + + private fun stripTrailingDot(raw: String): String { + return raw.removeSuffix(".") + } + + private suspend fun lookupUnicastMessage(name: String, type: Int): Message? { + val query = + try { + Message.newQuery( + org.xbill.DNS.Record.newRecord( + Name.fromString(name), + type, + DClass.IN, + ), + ) + } catch (_: TextParseException) { + return null + } + + val system = queryViaSystemDns(query) + if (records(system, Section.ANSWER).any { it.type == type }) return system + + val direct = createDirectResolver() ?: return system + return try { + val msg = direct.send(query) + if (records(msg, Section.ANSWER).any { it.type == type }) msg else system + } catch (_: Throwable) { + system + } + } + + private suspend fun queryViaSystemDns(query: Message): Message? { + val network = preferredDnsNetwork() + val bytes = + try { + rawQuery(network, query.toWire()) + } catch (_: Throwable) { + return null + } + + return try { + Message(bytes) + } catch (_: IOException) { + null + } + } + + private fun records(msg: Message?, section: Int): List { + return msg?.getSectionArray(section)?.toList() ?: emptyList() + } + + private fun keyName(raw: String): String { + return raw.trim().lowercase() + } + + private fun recordsByName(msg: Message, section: Int): Map> { + val next = LinkedHashMap>() + for (r in records(msg, section)) { + val name = r.name?.toString() ?: continue + next.getOrPut(keyName(name)) { mutableListOf() }.add(r) + } + return next + } + + private fun recordByName(msg: Message, fqdn: String, type: Int): Record? { + val key = keyName(fqdn) + val byNameAnswer = recordsByName(msg, Section.ANSWER) + val fromAnswer = byNameAnswer[key].orEmpty().firstOrNull { it.type == type } + if (fromAnswer != null) return fromAnswer + + val byNameAdditional = recordsByName(msg, Section.ADDITIONAL) + return byNameAdditional[key].orEmpty().firstOrNull { it.type == type } + } + + private fun resolveHostFromMessage(msg: Message?, hostname: String): String? { + val m = msg ?: return null + val key = keyName(hostname) + val additional = recordsByName(m, Section.ADDITIONAL)[key].orEmpty() + val a = additional.mapNotNull { it as? ARecord }.mapNotNull { it.address?.hostAddress } + val aaaa = additional.mapNotNull { it as? AAAARecord }.mapNotNull { it.address?.hostAddress } + return a.firstOrNull() ?: aaaa.firstOrNull() + } + + private fun preferredDnsNetwork(): android.net.Network? { + val cm = connectivity ?: return null + + // Prefer VPN (Tailscale) when present; otherwise use the active network. + cm.allNetworks.firstOrNull { n -> + val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false + caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + }?.let { return it } + + return cm.activeNetwork + } + + private fun createDirectResolver(): Resolver? { + val cm = connectivity ?: return null + + val candidateNetworks = + buildList { + cm.allNetworks + .firstOrNull { n -> + val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false + caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + }?.let(::add) + cm.activeNetwork?.let(::add) + }.distinct() + + val servers = + candidateNetworks + .asSequence() + .flatMap { n -> + cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence() + } + .distinctBy { it.hostAddress ?: it.toString() } + .toList() + if (servers.isEmpty()) return null + + return try { + val resolvers = + servers.mapNotNull { addr -> + try { + SimpleResolver().apply { + setAddress(InetSocketAddress(addr, 53)) + setTimeout(3) + } + } catch (_: Throwable) { + null + } + } + if (resolvers.isEmpty()) return null + ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(3) } + } catch (_: Throwable) { + null + } + } + + private suspend fun rawQuery(network: android.net.Network?, wireQuery: ByteArray): ByteArray = + suspendCancellableCoroutine { cont -> + val signal = CancellationSignal() + cont.invokeOnCancellation { signal.cancel() } + + dns.rawQuery( + network, + wireQuery, + DnsResolver.FLAG_EMPTY, + dnsExecutor, + signal, + object : DnsResolver.Callback { + override fun onAnswer(answer: ByteArray, rcode: Int) { + cont.resume(answer) + } + + override fun onError(error: DnsResolver.DnsException) { + cont.resumeWithException(error) + } + }, + ) + } + + private fun txtValue(records: List, key: String): String? { + val prefix = "$key=" + for (r in records) { + val strings: List = + try { + r.strings.mapNotNull { it as? String } + } catch (_: Throwable) { + emptyList() + } + for (s in strings) { + val trimmed = decodeDnsTxtString(s).trim() + if (trimmed.startsWith(prefix)) { + return trimmed.removePrefix(prefix).trim().ifEmpty { null } + } + } + } + return null + } + + private fun txtIntValue(records: List, key: String): Int? { + return txtValue(records, key)?.toIntOrNull() + } + + private fun txtBoolValue(records: List, key: String): Boolean { + val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false + return raw == "1" || raw == "true" || raw == "yes" + } + + private fun decodeDnsTxtString(raw: String): String { + // dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes. + // Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible. + val bytes = raw.toByteArray(Charsets.ISO_8859_1) + val decoder = + Charsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + return try { + decoder.decode(ByteBuffer.wrap(bytes)).toString() + } catch (_: Throwable) { + raw + } + } + + private suspend fun resolveHostUnicast(hostname: String): String? { + val a = + records(lookupUnicastMessage(hostname, Type.A), Section.ANSWER) + .mapNotNull { it as? ARecord } + .mapNotNull { it.address?.hostAddress } + val aaaa = + records(lookupUnicastMessage(hostname, Type.AAAA), Section.ANSWER) + .mapNotNull { it as? AAAARecord } + .mapNotNull { it.address?.hostAddress } + + return a.firstOrNull() ?: aaaa.firstOrNull() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayEndpoint.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayEndpoint.kt new file mode 100644 index 00000000..9a301060 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayEndpoint.kt @@ -0,0 +1,26 @@ +package ai.openclaw.android.gateway + +data class GatewayEndpoint( + val stableId: String, + val name: String, + val host: String, + val port: Int, + val lanHost: String? = null, + val tailnetDns: String? = null, + val gatewayPort: Int? = null, + val canvasPort: Int? = null, + val tlsEnabled: Boolean = false, + val tlsFingerprintSha256: String? = null, +) { + companion object { + fun manual(host: String, port: Int): GatewayEndpoint = + GatewayEndpoint( + stableId = "manual|${host.lowercase()}|$port", + name = "$host:$port", + host = host, + port = port, + tlsEnabled = false, + tlsFingerprintSha256 = null, + ) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayProtocol.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayProtocol.kt new file mode 100644 index 00000000..da8fa4c6 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayProtocol.kt @@ -0,0 +1,3 @@ +package ai.openclaw.android.gateway + +const val GATEWAY_PROTOCOL_VERSION = 3 diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt new file mode 100644 index 00000000..4e210de8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -0,0 +1,732 @@ +package ai.openclaw.android.gateway + +import android.util.Log +import java.util.Locale +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener + +data class GatewayClientInfo( + val id: String, + val displayName: String?, + val version: String, + val platform: String, + val mode: String, + val instanceId: String?, + val deviceFamily: String?, + val modelIdentifier: String?, +) + +data class GatewayConnectOptions( + val role: String, + val scopes: List, + val caps: List, + val commands: List, + val permissions: Map, + val client: GatewayClientInfo, + val userAgent: String? = null, +) + +class GatewaySession( + private val scope: CoroutineScope, + private val identityStore: DeviceIdentityStore, + private val deviceAuthStore: DeviceAuthStore, + private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit, + private val onDisconnected: (message: String) -> Unit, + private val onEvent: (event: String, payloadJson: String?) -> Unit, + private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null, + private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null, +) { + data class InvokeRequest( + val id: String, + val nodeId: String, + val command: String, + val paramsJson: String?, + val timeoutMs: Long?, + ) + + data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) { + companion object { + fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null) + fun error(code: String, message: String) = + InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message)) + } + } + + data class ErrorShape(val code: String, val message: String) + + private val json = Json { ignoreUnknownKeys = true } + private val writeLock = Mutex() + private val pending = ConcurrentHashMap>() + + @Volatile private var canvasHostUrl: String? = null + @Volatile private var mainSessionKey: String? = null + + private data class DesiredConnection( + val endpoint: GatewayEndpoint, + val token: String?, + val password: String?, + val options: GatewayConnectOptions, + val tls: GatewayTlsParams?, + ) + + private var desired: DesiredConnection? = null + private var job: Job? = null + @Volatile private var currentConnection: Connection? = null + + fun connect( + endpoint: GatewayEndpoint, + token: String?, + password: String?, + options: GatewayConnectOptions, + tls: GatewayTlsParams? = null, + ) { + desired = DesiredConnection(endpoint, token, password, options, tls) + if (job == null) { + job = scope.launch(Dispatchers.IO) { runLoop() } + } + } + + fun disconnect() { + desired = null + currentConnection?.closeQuietly() + scope.launch(Dispatchers.IO) { + job?.cancelAndJoin() + job = null + canvasHostUrl = null + mainSessionKey = null + onDisconnected("Offline") + } + } + + fun reconnect() { + currentConnection?.closeQuietly() + } + + fun currentCanvasHostUrl(): String? = canvasHostUrl + fun currentMainSessionKey(): String? = mainSessionKey + + suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean { + val conn = currentConnection ?: return false + val parsedPayload = payloadJson?.let { parseJsonOrNull(it) } + val params = + buildJsonObject { + put("event", JsonPrimitive(event)) + if (parsedPayload != null) { + put("payload", parsedPayload) + } else if (payloadJson != null) { + put("payloadJSON", JsonPrimitive(payloadJson)) + } else { + put("payloadJSON", JsonNull) + } + } + try { + conn.request("node.event", params, timeoutMs = 8_000) + return true + } catch (err: Throwable) { + Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}") + return false + } + } + + suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String { + val conn = currentConnection ?: throw IllegalStateException("not connected") + val params = + if (paramsJson.isNullOrBlank()) { + null + } else { + json.parseToJsonElement(paramsJson) + } + val res = conn.request(method, params, timeoutMs) + if (res.ok) return res.payloadJson ?: "" + val err = res.error + throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}") + } + + private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) + + private inner class Connection( + private val endpoint: GatewayEndpoint, + private val token: String?, + private val password: String?, + private val options: GatewayConnectOptions, + private val tls: GatewayTlsParams?, + ) { + private val connectDeferred = CompletableDeferred() + private val closedDeferred = CompletableDeferred() + private val isClosed = AtomicBoolean(false) + private val connectNonceDeferred = CompletableDeferred() + private val client: OkHttpClient = buildClient() + private var socket: WebSocket? = null + private val loggerTag = "OpenClawGateway" + + val remoteAddress: String = + if (endpoint.host.contains(":")) { + "[${endpoint.host}]:${endpoint.port}" + } else { + "${endpoint.host}:${endpoint.port}" + } + + suspend fun connect() { + val scheme = if (tls != null) "wss" else "ws" + val url = "$scheme://${endpoint.host}:${endpoint.port}" + val httpScheme = if (tls != null) "https" else "http" + val origin = "$httpScheme://${endpoint.host}:${endpoint.port}" + val request = Request.Builder().url(url).header("Origin", origin).build() + socket = client.newWebSocket(request, Listener()) + try { + connectDeferred.await() + } catch (err: Throwable) { + throw err + } + } + + suspend fun request(method: String, params: JsonElement?, timeoutMs: Long): RpcResponse { + val id = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + pending[id] = deferred + val frame = + buildJsonObject { + put("type", JsonPrimitive("req")) + put("id", JsonPrimitive(id)) + put("method", JsonPrimitive(method)) + if (params != null) put("params", params) + } + sendJson(frame) + return try { + withTimeout(timeoutMs) { deferred.await() } + } catch (err: TimeoutCancellationException) { + pending.remove(id) + throw IllegalStateException("request timeout") + } + } + + suspend fun sendJson(obj: JsonObject) { + val jsonString = obj.toString() + writeLock.withLock { + socket?.send(jsonString) + } + } + + suspend fun awaitClose() = closedDeferred.await() + + fun closeQuietly() { + if (isClosed.compareAndSet(false, true)) { + socket?.close(1000, "bye") + socket = null + closedDeferred.complete(Unit) + } + } + + private fun buildClient(): OkHttpClient { + val builder = OkHttpClient.Builder() + .writeTimeout(60, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(0, java.util.concurrent.TimeUnit.SECONDS) + .pingInterval(30, java.util.concurrent.TimeUnit.SECONDS) + val tlsConfig = buildGatewayTlsConfig(tls) { fingerprint -> + onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint) + } + if (tlsConfig != null) { + builder.sslSocketFactory(tlsConfig.sslSocketFactory, tlsConfig.trustManager) + builder.hostnameVerifier(tlsConfig.hostnameVerifier) + } + return builder.build() + } + + private inner class Listener : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + scope.launch { + try { + val nonce = awaitConnectNonce() + sendConnect(nonce) + } catch (err: Throwable) { + connectDeferred.completeExceptionally(err) + closeQuietly() + } + } + } + + override fun onMessage(webSocket: WebSocket, text: String) { + scope.launch { handleMessage(text) } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + if (!connectDeferred.isCompleted) { + connectDeferred.completeExceptionally(t) + } + if (isClosed.compareAndSet(false, true)) { + failPending() + closedDeferred.complete(Unit) + onDisconnected("Gateway error: ${t.message ?: t::class.java.simpleName}") + } + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + if (!connectDeferred.isCompleted) { + connectDeferred.completeExceptionally(IllegalStateException("Gateway closed: $reason")) + } + if (isClosed.compareAndSet(false, true)) { + failPending() + closedDeferred.complete(Unit) + onDisconnected("Gateway closed: $reason") + } + } + } + + private suspend fun sendConnect(connectNonce: String) { + val identity = identityStore.loadOrCreate() + val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) + val trimmedToken = token?.trim().orEmpty() + val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken + val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim()) + var res = request("connect", payload, timeoutMs = 8_000) + if (!res.ok) { + val msg = res.error?.message ?: "connect failed" + val hasStoredToken = !storedToken.isNullOrBlank() + val canRetryWithShared = hasStoredToken && trimmedToken.isNotBlank() + if (canRetryWithShared) { + val sharedPayload = buildConnectParams(identity, connectNonce, trimmedToken, password?.trim()) + val sharedRes = request("connect", sharedPayload, timeoutMs = 8_000) + if (!sharedRes.ok) { + val retryMsg = sharedRes.error?.message ?: msg + throw IllegalStateException(retryMsg) + } + // Stored device token was bypassed successfully; clear stale token for future connects. + deviceAuthStore.clearToken(identity.deviceId, options.role) + res = sharedRes + } else { + throw IllegalStateException(msg) + } + } + handleConnectSuccess(res, identity.deviceId) + connectDeferred.complete(Unit) + } + + private fun handleConnectSuccess(res: RpcResponse, deviceId: String) { + val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") + val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") + val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() + val authObj = obj["auth"].asObjectOrNull() + val deviceToken = authObj?.get("deviceToken").asStringOrNull() + val authRole = authObj?.get("role").asStringOrNull() ?: options.role + if (!deviceToken.isNullOrBlank()) { + deviceAuthStore.saveToken(deviceId, authRole, deviceToken) + } + val rawCanvas = obj["canvasHostUrl"].asStringOrNull() + canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null) + val sessionDefaults = + obj["snapshot"].asObjectOrNull() + ?.get("sessionDefaults").asObjectOrNull() + mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull() + onConnected(serverName, remoteAddress, mainSessionKey) + } + + private fun buildConnectParams( + identity: DeviceIdentity, + connectNonce: String, + authToken: String, + authPassword: String?, + ): JsonObject { + val client = options.client + val locale = Locale.getDefault().toLanguageTag() + val clientObj = + buildJsonObject { + put("id", JsonPrimitive(client.id)) + client.displayName?.let { put("displayName", JsonPrimitive(it)) } + put("version", JsonPrimitive(client.version)) + put("platform", JsonPrimitive(client.platform)) + put("mode", JsonPrimitive(client.mode)) + client.instanceId?.let { put("instanceId", JsonPrimitive(it)) } + client.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) } + client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } + } + + val password = authPassword?.trim().orEmpty() + val authJson = + when { + authToken.isNotEmpty() -> + buildJsonObject { + put("token", JsonPrimitive(authToken)) + } + password.isNotEmpty() -> + buildJsonObject { + put("password", JsonPrimitive(password)) + } + else -> null + } + + val signedAtMs = System.currentTimeMillis() + val payload = + buildDeviceAuthPayload( + deviceId = identity.deviceId, + clientId = client.id, + clientMode = client.mode, + role = options.role, + scopes = options.scopes, + signedAtMs = signedAtMs, + token = if (authToken.isNotEmpty()) authToken else null, + nonce = connectNonce, + ) + val signature = identityStore.signPayload(payload, identity) + val publicKey = identityStore.publicKeyBase64Url(identity) + val deviceJson = + if (!signature.isNullOrBlank() && !publicKey.isNullOrBlank()) { + buildJsonObject { + put("id", JsonPrimitive(identity.deviceId)) + put("publicKey", JsonPrimitive(publicKey)) + put("signature", JsonPrimitive(signature)) + put("signedAt", JsonPrimitive(signedAtMs)) + put("nonce", JsonPrimitive(connectNonce)) + } + } else { + null + } + + return buildJsonObject { + put("minProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION)) + put("maxProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION)) + put("client", clientObj) + if (options.caps.isNotEmpty()) put("caps", JsonArray(options.caps.map(::JsonPrimitive))) + if (options.commands.isNotEmpty()) put("commands", JsonArray(options.commands.map(::JsonPrimitive))) + if (options.permissions.isNotEmpty()) { + put( + "permissions", + buildJsonObject { + options.permissions.forEach { (key, value) -> + put(key, JsonPrimitive(value)) + } + }, + ) + } + put("role", JsonPrimitive(options.role)) + if (options.scopes.isNotEmpty()) put("scopes", JsonArray(options.scopes.map(::JsonPrimitive))) + authJson?.let { put("auth", it) } + deviceJson?.let { put("device", it) } + put("locale", JsonPrimitive(locale)) + options.userAgent?.trim()?.takeIf { it.isNotEmpty() }?.let { + put("userAgent", JsonPrimitive(it)) + } + } + } + + private suspend fun handleMessage(text: String) { + val frame = json.parseToJsonElement(text).asObjectOrNull() ?: return + when (frame["type"].asStringOrNull()) { + "res" -> handleResponse(frame) + "event" -> handleEvent(frame) + } + } + + private fun handleResponse(frame: JsonObject) { + val id = frame["id"].asStringOrNull() ?: return + val ok = frame["ok"].asBooleanOrNull() ?: false + val payloadJson = frame["payload"]?.let { payload -> payload.toString() } + val error = + frame["error"]?.asObjectOrNull()?.let { obj -> + val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE" + val msg = obj["message"].asStringOrNull() ?: "request failed" + ErrorShape(code, msg) + } + pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error)) + } + + private fun handleEvent(frame: JsonObject) { + val event = frame["event"].asStringOrNull() ?: return + val payloadJson = + frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull() + if (event == "connect.challenge") { + val nonce = extractConnectNonce(payloadJson) + if (!connectNonceDeferred.isCompleted && !nonce.isNullOrBlank()) { + connectNonceDeferred.complete(nonce.trim()) + } + return + } + if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) { + handleInvokeEvent(payloadJson) + return + } + onEvent(event, payloadJson) + } + + private suspend fun awaitConnectNonce(): String { + return try { + withTimeout(2_000) { connectNonceDeferred.await() } + } catch (err: Throwable) { + throw IllegalStateException("connect challenge timeout", err) + } + } + + private fun extractConnectNonce(payloadJson: String?): String? { + if (payloadJson.isNullOrBlank()) return null + val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null + return obj["nonce"].asStringOrNull() + } + + private fun handleInvokeEvent(payloadJson: String) { + val payload = + try { + json.parseToJsonElement(payloadJson).asObjectOrNull() + } catch (_: Throwable) { + null + } ?: return + val id = payload["id"].asStringOrNull() ?: return + val nodeId = payload["nodeId"].asStringOrNull() ?: return + val command = payload["command"].asStringOrNull() ?: return + val params = + payload["paramsJSON"].asStringOrNull() + ?: payload["params"]?.let { value -> if (value is JsonNull) null else value.toString() } + val timeoutMs = payload["timeoutMs"].asLongOrNull() + scope.launch { + val result = + try { + onInvoke?.invoke(InvokeRequest(id, nodeId, command, params, timeoutMs)) + ?: InvokeResult.error("UNAVAILABLE", "invoke handler missing") + } catch (err: Throwable) { + invokeErrorFromThrowable(err) + } + sendInvokeResult(id, nodeId, result) + } + } + + private suspend fun sendInvokeResult(id: String, nodeId: String, result: InvokeResult) { + val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) } + val params = + buildJsonObject { + put("id", JsonPrimitive(id)) + put("nodeId", JsonPrimitive(nodeId)) + put("ok", JsonPrimitive(result.ok)) + if (parsedPayload != null) { + put("payload", parsedPayload) + } else if (result.payloadJson != null) { + put("payloadJSON", JsonPrimitive(result.payloadJson)) + } + result.error?.let { err -> + put( + "error", + buildJsonObject { + put("code", JsonPrimitive(err.code)) + put("message", JsonPrimitive(err.message)) + }, + ) + } + } + try { + request("node.invoke.result", params, timeoutMs = 15_000) + } catch (err: Throwable) { + Log.w(loggerTag, "node.invoke.result failed: ${err.message ?: err::class.java.simpleName}") + } + } + + private fun invokeErrorFromThrowable(err: Throwable): InvokeResult { + val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName + val parts = msg.split(":", limit = 2) + if (parts.size == 2) { + val code = parts[0].trim() + val rest = parts[1].trim() + if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) { + return InvokeResult.error(code = code, message = rest.ifEmpty { msg }) + } + } + return InvokeResult.error(code = "UNAVAILABLE", message = msg) + } + + private fun failPending() { + for ((_, waiter) in pending) { + waiter.cancel() + } + pending.clear() + } + } + + private suspend fun runLoop() { + var attempt = 0 + while (scope.isActive) { + val target = desired + if (target == null) { + currentConnection?.closeQuietly() + currentConnection = null + delay(250) + continue + } + + try { + onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…") + connectOnce(target) + attempt = 0 + } catch (err: Throwable) { + attempt += 1 + onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}") + val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong()) + delay(sleepMs) + } + } + } + + private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) { + val conn = Connection(target.endpoint, target.token, target.password, target.options, target.tls) + currentConnection = conn + try { + conn.connect() + conn.awaitClose() + } finally { + currentConnection = null + canvasHostUrl = null + mainSessionKey = null + } + } + + private fun buildDeviceAuthPayload( + deviceId: String, + clientId: String, + clientMode: String, + role: String, + scopes: List, + signedAtMs: Long, + token: String?, + nonce: String, + ): String { + val scopeString = scopes.joinToString(",") + val authToken = token.orEmpty() + val parts = + mutableListOf( + "v2", + deviceId, + clientId, + clientMode, + role, + scopeString, + signedAtMs.toString(), + authToken, + nonce, + ) + return parts.joinToString("|") + } + + private fun normalizeCanvasHostUrl( + raw: String?, + endpoint: GatewayEndpoint, + isTlsConnection: Boolean, + ): String? { + val trimmed = raw?.trim().orEmpty() + val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() } + val host = parsed?.host?.trim().orEmpty() + val port = parsed?.port ?: -1 + val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" } + val suffix = buildUrlSuffix(parsed) + + // If raw URL is a non-loopback address and this connection uses TLS, + // normalize scheme/port to the endpoint we actually connected to. + if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackHost(host)) { + val needsTlsRewrite = + isTlsConnection && + ( + !scheme.equals("https", ignoreCase = true) || + (port > 0 && port != endpoint.port) || + (port <= 0 && endpoint.port != 443) + ) + if (needsTlsRewrite) { + return buildCanvasUrl(host = host, scheme = "https", port = endpoint.port, suffix = suffix) + } + return trimmed + } + + val fallbackHost = + endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() } + ?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() } + ?: endpoint.host.trim() + if (fallbackHost.isEmpty()) return trimmed.ifBlank { null } + + // For TLS connections, use the connected endpoint's scheme/port instead of raw canvas metadata. + val fallbackScheme = if (isTlsConnection) "https" else scheme + // For TLS, always use the connected endpoint port. + val fallbackPort = if (isTlsConnection) endpoint.port else (endpoint.canvasPort ?: endpoint.port) + return buildCanvasUrl(host = fallbackHost, scheme = fallbackScheme, port = fallbackPort, suffix = suffix) + } + + private fun buildCanvasUrl(host: String, scheme: String, port: Int, suffix: String): String { + val loweredScheme = scheme.lowercase() + val formattedHost = if (host.contains(":")) "[${host}]" else host + val portSuffix = if ((loweredScheme == "https" && port == 443) || (loweredScheme == "http" && port == 80)) "" else ":$port" + return "$loweredScheme://$formattedHost$portSuffix$suffix" + } + + private fun buildUrlSuffix(uri: java.net.URI?): String { + if (uri == null) return "" + val path = uri.rawPath?.takeIf { it.isNotBlank() } ?: "" + val query = uri.rawQuery?.takeIf { it.isNotBlank() }?.let { "?$it" } ?: "" + val fragment = uri.rawFragment?.takeIf { it.isNotBlank() }?.let { "#$it" } ?: "" + return "$path$query$fragment" + } + + private fun isLoopbackHost(raw: String?): Boolean { + val host = raw?.trim()?.lowercase().orEmpty() + if (host.isEmpty()) return false + if (host == "localhost") return true + if (host == "::1") return true + if (host == "0.0.0.0" || host == "::") return true + return host.startsWith("127.") + } +} + +private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject + +private fun JsonElement?.asStringOrNull(): String? = + when (this) { + is JsonNull -> null + is JsonPrimitive -> content + else -> null + } + +private fun JsonElement?.asBooleanOrNull(): Boolean? = + when (this) { + is JsonPrimitive -> { + val c = content.trim() + when { + c.equals("true", ignoreCase = true) -> true + c.equals("false", ignoreCase = true) -> false + else -> null + } + } + else -> null + } + +private fun JsonElement?.asLongOrNull(): Long? = + when (this) { + is JsonPrimitive -> content.toLongOrNull() + else -> null + } + +private fun parseJsonOrNull(payload: String): JsonElement? { + val trimmed = payload.trim() + if (trimmed.isEmpty()) return null + return try { + Json.parseToJsonElement(trimmed) + } catch (_: Throwable) { + null + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt new file mode 100644 index 00000000..0726c94f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewayTls.kt @@ -0,0 +1,159 @@ +package ai.openclaw.android.gateway + +import android.annotation.SuppressLint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.InetSocketAddress +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import java.util.Locale +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLParameters +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.SNIHostName +import javax.net.ssl.SSLSocket +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +data class GatewayTlsParams( + val required: Boolean, + val expectedFingerprint: String?, + val allowTOFU: Boolean, + val stableId: String, +) + +data class GatewayTlsConfig( + val sslSocketFactory: SSLSocketFactory, + val trustManager: X509TrustManager, + val hostnameVerifier: HostnameVerifier, +) + +fun buildGatewayTlsConfig( + params: GatewayTlsParams?, + onStore: ((String) -> Unit)? = null, +): GatewayTlsConfig? { + if (params == null) return null + val expected = params.expectedFingerprint?.let(::normalizeFingerprint) + val defaultTrust = defaultTrustManager() + @SuppressLint("CustomX509TrustManager") + val trustManager = + object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) { + defaultTrust.checkClientTrusted(chain, authType) + } + + override fun checkServerTrusted(chain: Array, authType: String) { + if (chain.isEmpty()) throw CertificateException("empty certificate chain") + val fingerprint = sha256Hex(chain[0].encoded) + if (expected != null) { + if (fingerprint != expected) { + throw CertificateException("gateway TLS fingerprint mismatch") + } + return + } + if (params.allowTOFU) { + onStore?.invoke(fingerprint) + return + } + defaultTrust.checkServerTrusted(chain, authType) + } + + override fun getAcceptedIssuers(): Array = defaultTrust.acceptedIssuers + } + + val context = SSLContext.getInstance("TLS") + context.init(null, arrayOf(trustManager), SecureRandom()) + val verifier = + if (expected != null || params.allowTOFU) { + // When pinning, we intentionally ignore hostname mismatch (service discovery often yields IPs). + HostnameVerifier { _, _ -> true } + } else { + HttpsURLConnection.getDefaultHostnameVerifier() + } + return GatewayTlsConfig( + sslSocketFactory = context.socketFactory, + trustManager = trustManager, + hostnameVerifier = verifier, + ) +} + +suspend fun probeGatewayTlsFingerprint( + host: String, + port: Int, + timeoutMs: Int = 3_000, +): String? { + val trimmedHost = host.trim() + if (trimmedHost.isEmpty()) return null + if (port !in 1..65535) return null + + return withContext(Dispatchers.IO) { + val trustAll = + @SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager") + object : X509TrustManager { + @SuppressLint("TrustAllX509TrustManager") + override fun checkClientTrusted(chain: Array, authType: String) {} + @SuppressLint("TrustAllX509TrustManager") + override fun checkServerTrusted(chain: Array, authType: String) {} + override fun getAcceptedIssuers(): Array = emptyArray() + } + + val context = SSLContext.getInstance("TLS") + context.init(null, arrayOf(trustAll), SecureRandom()) + + val socket = (context.socketFactory.createSocket() as SSLSocket) + try { + socket.soTimeout = timeoutMs + socket.connect(InetSocketAddress(trimmedHost, port), timeoutMs) + + // Best-effort SNI for hostnames (avoid crashing on IP literals). + try { + if (trimmedHost.any { it.isLetter() }) { + val params = SSLParameters() + params.serverNames = listOf(SNIHostName(trimmedHost)) + socket.sslParameters = params + } + } catch (_: Throwable) { + // ignore + } + + socket.startHandshake() + val cert = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return@withContext null + sha256Hex(cert.encoded) + } catch (_: Throwable) { + null + } finally { + try { + socket.close() + } catch (_: Throwable) { + // ignore + } + } + } +} + +private fun defaultTrustManager(): X509TrustManager { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as java.security.KeyStore?) + val trust = + factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager + return trust ?: throw IllegalStateException("No default X509TrustManager found") +} + +private fun sha256Hex(data: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(data) + val out = StringBuilder(digest.size * 2) + for (byte in digest) { + out.append(String.format(Locale.US, "%02x", byte)) + } + return out.toString() +} + +private fun normalizeFingerprint(raw: String): String { + val stripped = raw.trim() + .replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "") + return stripped.lowercase(Locale.US).filter { it in '0'..'9' || it in 'a'..'f' } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/A2UIHandler.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/A2UIHandler.kt new file mode 100644 index 00000000..4e7ee32b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/A2UIHandler.kt @@ -0,0 +1,146 @@ +package ai.openclaw.android.node + +import ai.openclaw.android.gateway.GatewaySession +import kotlinx.coroutines.delay +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +class A2UIHandler( + private val canvas: CanvasController, + private val json: Json, + private val getNodeCanvasHostUrl: () -> String?, + private val getOperatorCanvasHostUrl: () -> String?, +) { + fun resolveA2uiHostUrl(): String? { + val nodeRaw = getNodeCanvasHostUrl()?.trim().orEmpty() + val operatorRaw = getOperatorCanvasHostUrl()?.trim().orEmpty() + val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw + if (raw.isBlank()) return null + val base = raw.trimEnd('/') + return "${base}/__openclaw__/a2ui/?platform=android" + } + + suspend fun ensureA2uiReady(a2uiUrl: String): Boolean { + try { + val already = canvas.eval(a2uiReadyCheckJS) + if (already == "true") return true + } catch (_: Throwable) { + // ignore + } + + canvas.navigate(a2uiUrl) + repeat(50) { + try { + val ready = canvas.eval(a2uiReadyCheckJS) + if (ready == "true") return true + } catch (_: Throwable) { + // ignore + } + delay(120) + } + return false + } + + fun decodeA2uiMessages(command: String, paramsJson: String?): String { + val raw = paramsJson?.trim().orEmpty() + if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required") + + val obj = + json.parseToJsonElement(raw) as? JsonObject + ?: throw IllegalArgumentException("INVALID_REQUEST: expected object params") + + val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty() + val hasMessagesArray = obj["messages"] is JsonArray + + if (command == "canvas.a2ui.pushJSONL" || (!hasMessagesArray && jsonlField.isNotBlank())) { + val jsonl = jsonlField + if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required") + val messages = + jsonl + .lineSequence() + .map { it.trim() } + .filter { it.isNotBlank() } + .mapIndexed { idx, line -> + val el = json.parseToJsonElement(line) + val msg = + el as? JsonObject + ?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object") + validateA2uiV0_8(msg, idx + 1) + msg + } + .toList() + return JsonArray(messages).toString() + } + + val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required") + val out = + arr.mapIndexed { idx, el -> + val msg = + el as? JsonObject + ?: throw IllegalArgumentException("A2UI messages[${idx}]: expected a JSON object") + validateA2uiV0_8(msg, idx + 1) + msg + } + return JsonArray(out).toString() + } + + private fun validateA2uiV0_8(msg: JsonObject, lineNumber: Int) { + if (msg.containsKey("createSurface")) { + throw IllegalArgumentException( + "A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.", + ) + } + val allowed = setOf("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface") + val matched = msg.keys.filter { allowed.contains(it) } + if (matched.size != 1) { + val found = msg.keys.sorted().joinToString(", ") + throw IllegalArgumentException( + "A2UI JSONL line $lineNumber: expected exactly one of ${allowed.sorted().joinToString(", ")}; found: $found", + ) + } + } + + companion object { + const val a2uiReadyCheckJS: String = + """ + (() => { + try { + const host = globalThis.openclawA2UI; + return !!host && typeof host.applyMessages === 'function'; + } catch (_) { + return false; + } + })() + """ + + const val a2uiResetJS: String = + """ + (() => { + try { + const host = globalThis.openclawA2UI; + if (!host) return { ok: false, error: "missing openclawA2UI" }; + return host.reset(); + } catch (e) { + return { ok: false, error: String(e?.message ?? e) }; + } + })() + """ + + fun a2uiApplyMessagesJS(messagesJson: String): String { + return """ + (() => { + try { + const host = globalThis.openclawA2UI; + if (!host) return { ok: false, error: "missing openclawA2UI" }; + const messages = $messagesJson; + return host.applyMessages(messages); + } catch (e) { + return { ok: false, error: String(e?.message ?? e) }; + } + })() + """.trimIndent() + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt new file mode 100644 index 00000000..e54c846c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/AppUpdateHandler.kt @@ -0,0 +1,295 @@ +package ai.openclaw.android.node + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import ai.openclaw.android.InstallResultReceiver +import ai.openclaw.android.MainActivity +import ai.openclaw.android.gateway.GatewayEndpoint +import ai.openclaw.android.gateway.GatewaySession +import java.io.File +import java.net.URI +import java.security.MessageDigest +import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +private val SHA256_HEX = Regex("^[a-fA-F0-9]{64}$") + +internal data class AppUpdateRequest( + val url: String, + val expectedSha256: String, +) + +internal fun parseAppUpdateRequest(paramsJson: String?, connectedHost: String?): AppUpdateRequest { + val params = + try { + paramsJson?.let { Json.parseToJsonElement(it).jsonObject } + } catch (_: Throwable) { + throw IllegalArgumentException("params must be valid JSON") + } ?: throw IllegalArgumentException("missing 'url' parameter") + + val urlRaw = + params["url"]?.jsonPrimitive?.content?.trim().orEmpty() + .ifEmpty { throw IllegalArgumentException("missing 'url' parameter") } + val sha256Raw = + params["sha256"]?.jsonPrimitive?.content?.trim().orEmpty() + .ifEmpty { throw IllegalArgumentException("missing 'sha256' parameter") } + if (!SHA256_HEX.matches(sha256Raw)) { + throw IllegalArgumentException("invalid 'sha256' parameter (expected 64 hex chars)") + } + + val uri = + try { + URI(urlRaw) + } catch (_: Throwable) { + throw IllegalArgumentException("invalid 'url' parameter") + } + val scheme = uri.scheme?.lowercase(Locale.US).orEmpty() + if (scheme != "https") { + throw IllegalArgumentException("url must use https") + } + if (!uri.userInfo.isNullOrBlank()) { + throw IllegalArgumentException("url must not include credentials") + } + val host = uri.host?.lowercase(Locale.US) ?: throw IllegalArgumentException("url host required") + val connectedHostNormalized = connectedHost?.trim()?.lowercase(Locale.US).orEmpty() + if (connectedHostNormalized.isNotEmpty() && host != connectedHostNormalized) { + throw IllegalArgumentException("url host must match connected gateway host") + } + + return AppUpdateRequest( + url = uri.toASCIIString(), + expectedSha256 = sha256Raw.lowercase(Locale.US), + ) +} + +internal fun sha256Hex(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + if (read == 0) continue + digest.update(buffer, 0, read) + } + } + val out = StringBuilder(64) + for (byte in digest.digest()) { + out.append(String.format(Locale.US, "%02x", byte)) + } + return out.toString() +} + +class AppUpdateHandler( + private val appContext: Context, + private val connectedEndpoint: () -> GatewayEndpoint?, +) { + + fun handleUpdate(paramsJson: String?): GatewaySession.InvokeResult { + try { + val updateRequest = + try { + parseAppUpdateRequest(paramsJson, connectedEndpoint()?.host) + } catch (err: IllegalArgumentException) { + return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: ${err.message ?: "invalid app.update params"}", + ) + } + val url = updateRequest.url + val expectedSha256 = updateRequest.expectedSha256 + + android.util.Log.w("openclaw", "app.update: downloading from $url") + + val notifId = 9001 + val channelId = "app_update" + val notifManager = appContext.getSystemService(android.content.Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + + // Create notification channel (required for Android 8+) + val channel = android.app.NotificationChannel(channelId, "App Updates", android.app.NotificationManager.IMPORTANCE_LOW) + notifManager.createNotificationChannel(channel) + + // PendingIntent to open the app when notification is tapped + val launchIntent = Intent(appContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val launchPi = PendingIntent.getActivity(appContext, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + // Launch download async so the invoke returns immediately + CoroutineScope(Dispatchers.IO).launch { + try { + val cacheDir = java.io.File(appContext.cacheDir, "updates") + cacheDir.mkdirs() + val file = java.io.File(cacheDir, "update.apk") + if (file.exists()) file.delete() + + // Show initial progress notification + fun buildProgressNotif(progress: Int, max: Int, text: String): android.app.Notification { + return android.app.Notification.Builder(appContext, channelId) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle("OpenClaw Update") + .setContentText(text) + .setProgress(max, progress, max == 0) + + .setContentIntent(launchPi) + .setOngoing(true) + .build() + } + notifManager.notify(notifId, buildProgressNotif(0, 0, "Connecting...")) + + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(300, java.util.concurrent.TimeUnit.SECONDS) + .build() + val request = okhttp3.Request.Builder().url(url).build() + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + notifManager.cancel(notifId) + notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle("Update Failed") + + .setContentIntent(launchPi) + .setContentText("HTTP ${response.code}") + .build()) + return@launch + } + + val contentLength = response.body?.contentLength() ?: -1L + val body = response.body ?: run { + notifManager.cancel(notifId) + return@launch + } + + // Download with progress tracking + var totalBytes = 0L + var lastNotifUpdate = 0L + body.byteStream().use { input -> + file.outputStream().use { output -> + val buffer = ByteArray(8192) + while (true) { + val bytesRead = input.read(buffer) + if (bytesRead == -1) break + output.write(buffer, 0, bytesRead) + totalBytes += bytesRead + + // Update notification at most every 500ms + val now = System.currentTimeMillis() + if (now - lastNotifUpdate > 500) { + lastNotifUpdate = now + if (contentLength > 0) { + val pct = ((totalBytes * 100) / contentLength).toInt() + val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0) + val totalMb = String.format(Locale.US, "%.1f", contentLength / 1048576.0) + notifManager.notify(notifId, buildProgressNotif(pct, 100, "$mb / $totalMb MB ($pct%)")) + } else { + val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0) + notifManager.notify(notifId, buildProgressNotif(0, 0, "${mb} MB downloaded")) + } + } + } + } + } + + android.util.Log.w("openclaw", "app.update: downloaded ${file.length()} bytes") + val actualSha256 = sha256Hex(file) + if (actualSha256 != expectedSha256) { + android.util.Log.e( + "openclaw", + "app.update: sha256 mismatch expected=$expectedSha256 actual=$actualSha256", + ) + file.delete() + notifManager.cancel(notifId) + notifManager.notify( + notifId, + android.app.Notification.Builder(appContext, channelId) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle("Update Failed") + .setContentIntent(launchPi) + .setContentText("SHA-256 mismatch") + .build(), + ) + return@launch + } + + // Verify file is a valid APK (basic check: ZIP magic bytes) + val magic = file.inputStream().use { it.read().toByte() to it.read().toByte() } + if (magic.first != 0x50.toByte() || magic.second != 0x4B.toByte()) { + android.util.Log.e("openclaw", "app.update: invalid APK (bad magic: ${magic.first}, ${magic.second})") + file.delete() + notifManager.cancel(notifId) + notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle("Update Failed") + + .setContentIntent(launchPi) + .setContentText("Downloaded file is not a valid APK") + .build()) + return@launch + } + + // Use PackageInstaller session API — works from background on API 34+ + // The system handles showing the install confirmation dialog + notifManager.cancel(notifId) + notifManager.notify( + notifId, + android.app.Notification.Builder(appContext, channelId) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentTitle("Installing Update...") + .setContentIntent(launchPi) + .setContentText("${String.format(Locale.US, "%.1f", totalBytes / 1048576.0)} MB downloaded") + .build(), + ) + + val installer = appContext.packageManager.packageInstaller + val params = android.content.pm.PackageInstaller.SessionParams( + android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL + ) + params.setSize(file.length()) + val sessionId = installer.createSession(params) + val session = installer.openSession(sessionId) + session.openWrite("openclaw-update.apk", 0, file.length()).use { out -> + file.inputStream().use { inp -> inp.copyTo(out) } + session.fsync(out) + } + // Commit with FLAG_MUTABLE PendingIntent — system requires mutable for PackageInstaller status + val callbackIntent = android.content.Intent(appContext, InstallResultReceiver::class.java) + val pi = android.app.PendingIntent.getBroadcast( + appContext, sessionId, callbackIntent, + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE + ) + session.commit(pi.intentSender) + android.util.Log.w("openclaw", "app.update: PackageInstaller session committed, waiting for user confirmation") + } catch (err: Throwable) { + android.util.Log.e("openclaw", "app.update: async error", err) + notifManager.cancel(notifId) + notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle("Update Failed") + + .setContentIntent(launchPi) + .setContentText(err.message ?: "Unknown error") + .build()) + } + } + + // Return immediately — download happens in background + return GatewaySession.InvokeResult.ok(buildJsonObject { + put("status", "downloading") + put("url", url) + put("sha256", expectedSha256) + }.toString()) + } catch (err: Throwable) { + android.util.Log.e("openclaw", "app.update: error", err) + return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "update failed") + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt new file mode 100644 index 00000000..65bac915 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt @@ -0,0 +1,364 @@ +package ai.openclaw.android.node + +import android.Manifest +import android.content.Context +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.util.Base64 +import android.content.pm.PackageManager +import androidx.exifinterface.media.ExifInterface +import androidx.lifecycle.LifecycleOwner +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.FallbackStrategy +import androidx.camera.video.Quality +import androidx.camera.video.QualitySelector +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture +import androidx.camera.video.VideoRecordEvent +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.checkSelfPermission +import androidx.core.graphics.scale +import ai.openclaw.android.PermissionRequester +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.concurrent.Executor +import kotlin.math.roundToInt +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class CameraCaptureManager(private val context: Context) { + data class Payload(val payloadJson: String) + data class FilePayload(val file: File, val durationMs: Long, val hasAudio: Boolean) + + @Volatile private var lifecycleOwner: LifecycleOwner? = null + @Volatile private var permissionRequester: PermissionRequester? = null + + fun attachLifecycleOwner(owner: LifecycleOwner) { + lifecycleOwner = owner + } + + fun attachPermissionRequester(requester: PermissionRequester) { + permissionRequester = requester + } + + private suspend fun ensureCameraPermission() { + val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + if (granted) return + + val requester = permissionRequester + ?: throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission") + val results = requester.requestIfMissing(listOf(Manifest.permission.CAMERA)) + if (results[Manifest.permission.CAMERA] != true) { + throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission") + } + } + + private suspend fun ensureMicPermission() { + val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + if (granted) return + + val requester = permissionRequester + ?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") + val results = requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO)) + if (results[Manifest.permission.RECORD_AUDIO] != true) { + throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") + } + } + + suspend fun snap(paramsJson: String?): Payload = + withContext(Dispatchers.Main) { + ensureCameraPermission() + val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready") + val facing = parseFacing(paramsJson) ?: "front" + val quality = (parseQuality(paramsJson) ?: 0.5).coerceIn(0.1, 1.0) + val maxWidth = parseMaxWidth(paramsJson) ?: 800 + + val provider = context.cameraProvider() + val capture = ImageCapture.Builder().build() + val selector = + if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA + + provider.unbindAll() + provider.bindToLifecycle(owner, selector, capture) + + val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor()) + val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + ?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image") + val rotated = rotateBitmapByExif(decoded, orientation) + val scaled = + if (maxWidth > 0 && rotated.width > maxWidth) { + val h = + (rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble())) + .toInt() + .coerceAtLeast(1) + rotated.scale(maxWidth, h) + } else { + rotated + } + + val maxPayloadBytes = 5 * 1024 * 1024 + // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit). + val maxEncodedBytes = (maxPayloadBytes / 4) * 3 + val result = + JpegSizeLimiter.compressToLimit( + initialWidth = scaled.width, + initialHeight = scaled.height, + startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100), + maxBytes = maxEncodedBytes, + encode = { width, height, q -> + val bitmap = + if (width == scaled.width && height == scaled.height) { + scaled + } else { + scaled.scale(width, height) + } + val out = ByteArrayOutputStream() + if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) { + if (bitmap !== scaled) bitmap.recycle() + throw IllegalStateException("UNAVAILABLE: failed to encode JPEG") + } + if (bitmap !== scaled) { + bitmap.recycle() + } + out.toByteArray() + }, + ) + val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP) + Payload( + """{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""", + ) + } + + @SuppressLint("MissingPermission") + suspend fun clip(paramsJson: String?): FilePayload = + withContext(Dispatchers.Main) { + ensureCameraPermission() + val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready") + val facing = parseFacing(paramsJson) ?: "front" + val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 60_000) + val includeAudio = parseIncludeAudio(paramsJson) ?: true + if (includeAudio) ensureMicPermission() + + android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio") + + val provider = context.cameraProvider() + android.util.Log.w("CameraCaptureManager", "clip: got camera provider") + + // Use LOWEST quality for smallest files over WebSocket + val recorder = Recorder.Builder() + .setQualitySelector( + QualitySelector.from(Quality.LOWEST, FallbackStrategy.lowerQualityOrHigherThan(Quality.LOWEST)) + ) + .build() + val videoCapture = VideoCapture.withOutput(recorder) + val selector = + if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA + + // CameraX requires a Preview use case for the camera to start producing frames; + // without it, the encoder may get no data (ERROR_NO_VALID_DATA). + val preview = androidx.camera.core.Preview.Builder().build() + // Provide a dummy SurfaceTexture so the preview pipeline activates + val surfaceTexture = android.graphics.SurfaceTexture(0) + surfaceTexture.setDefaultBufferSize(640, 480) + preview.setSurfaceProvider { request -> + val surface = android.view.Surface(surfaceTexture) + request.provideSurface(surface, context.mainExecutor()) { result -> + surface.release() + surfaceTexture.release() + } + } + + provider.unbindAll() + android.util.Log.w("CameraCaptureManager", "clip: binding preview + videoCapture to lifecycle") + val camera = provider.bindToLifecycle(owner, selector, preview, videoCapture) + android.util.Log.w("CameraCaptureManager", "clip: bound, cameraInfo=${camera.cameraInfo}") + + // Give camera pipeline time to initialize before recording + android.util.Log.w("CameraCaptureManager", "clip: warming up camera 1.5s...") + kotlinx.coroutines.delay(1_500) + + val file = File.createTempFile("openclaw-clip-", ".mp4") + val outputOptions = FileOutputOptions.Builder(file).build() + + val finalized = kotlinx.coroutines.CompletableDeferred() + android.util.Log.w("CameraCaptureManager", "clip: starting recording to ${file.absolutePath}") + val recording: Recording = + videoCapture.output + .prepareRecording(context, outputOptions) + .apply { + if (includeAudio) withAudioEnabled() + } + .start(context.mainExecutor()) { event -> + android.util.Log.w("CameraCaptureManager", "clip: event ${event.javaClass.simpleName}") + if (event is VideoRecordEvent.Status) { + android.util.Log.w("CameraCaptureManager", "clip: recording status update") + } + if (event is VideoRecordEvent.Finalize) { + android.util.Log.w("CameraCaptureManager", "clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}") + finalized.complete(event) + } + } + + android.util.Log.w("CameraCaptureManager", "clip: recording started, delaying ${durationMs}ms") + try { + kotlinx.coroutines.delay(durationMs.toLong()) + } finally { + android.util.Log.w("CameraCaptureManager", "clip: stopping recording") + recording.stop() + } + + val finalizeEvent = + try { + withTimeout(15_000) { finalized.await() } + } catch (err: Throwable) { + android.util.Log.e("CameraCaptureManager", "clip: finalize timed out", err) + withContext(Dispatchers.IO) { file.delete() } + provider.unbindAll() + throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out") + } + if (finalizeEvent.hasError()) { + android.util.Log.e("CameraCaptureManager", "clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}", finalizeEvent.cause) + // Check file size for debugging + val fileSize = withContext(Dispatchers.IO) { if (file.exists()) file.length() else -1 } + android.util.Log.e("CameraCaptureManager", "clip: file exists=${file.exists()} size=$fileSize") + withContext(Dispatchers.IO) { file.delete() } + provider.unbindAll() + throw IllegalStateException("UNAVAILABLE: camera clip failed (error=${finalizeEvent.error})") + } + + val fileSize = withContext(Dispatchers.IO) { file.length() } + android.util.Log.w("CameraCaptureManager", "clip: SUCCESS file size=$fileSize") + + provider.unbindAll() + + FilePayload(file = file, durationMs = durationMs.toLong(), hasAudio = includeAudio) + } + + private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap { + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f) + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.postRotate(90f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.postRotate(-90f) + matrix.postScale(-1f, 1f) + } + else -> return bitmap + } + val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + if (rotated !== bitmap) { + bitmap.recycle() + } + return rotated + } + + private fun parseFacing(paramsJson: String?): String? = + when { + paramsJson?.contains("\"front\"") == true -> "front" + paramsJson?.contains("\"back\"") == true -> "back" + else -> null + } + + private fun parseQuality(paramsJson: String?): Double? = + parseNumber(paramsJson, key = "quality")?.toDoubleOrNull() + + private fun parseMaxWidth(paramsJson: String?): Int? = + parseNumber(paramsJson, key = "maxWidth")?.toIntOrNull() + + private fun parseDurationMs(paramsJson: String?): Int? = + parseNumber(paramsJson, key = "durationMs")?.toIntOrNull() + + private fun parseIncludeAudio(paramsJson: String?): Boolean? { + val raw = paramsJson ?: return null + val key = "\"includeAudio\"" + val idx = raw.indexOf(key) + if (idx < 0) return null + val colon = raw.indexOf(':', idx + key.length) + if (colon < 0) return null + val tail = raw.substring(colon + 1).trimStart() + return when { + tail.startsWith("true") -> true + tail.startsWith("false") -> false + else -> null + } + } + + private fun parseNumber(paramsJson: String?, key: String): String? { + val raw = paramsJson ?: return null + val needle = "\"$key\"" + val idx = raw.indexOf(needle) + if (idx < 0) return null + val colon = raw.indexOf(':', idx + needle.length) + if (colon < 0) return null + val tail = raw.substring(colon + 1).trimStart() + return tail.takeWhile { it.isDigit() || it == '.' } + } + + private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this) +} + +private suspend fun Context.cameraProvider(): ProcessCameraProvider = + suspendCancellableCoroutine { cont -> + val future = ProcessCameraProvider.getInstance(this) + future.addListener( + { + try { + cont.resume(future.get()) + } catch (e: Exception) { + cont.resumeWithException(e) + } + }, + ContextCompat.getMainExecutor(this), + ) + } + +/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */ +private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair = + suspendCancellableCoroutine { cont -> + val file = File.createTempFile("openclaw-snap-", ".jpg") + val options = ImageCapture.OutputFileOptions.Builder(file).build() + takePicture( + options, + executor, + object : ImageCapture.OnImageSavedCallback { + override fun onError(exception: ImageCaptureException) { + file.delete() + cont.resumeWithException(exception) + } + + override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + try { + val exif = ExifInterface(file.absolutePath) + val orientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + val bytes = file.readBytes() + cont.resume(Pair(bytes, orientation)) + } catch (e: Exception) { + cont.resumeWithException(e) + } finally { + file.delete() + } + } + }, + ) + } diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/CameraHandler.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/CameraHandler.kt new file mode 100644 index 00000000..658c117f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/CameraHandler.kt @@ -0,0 +1,157 @@ +package ai.openclaw.android.node + +import android.content.Context +import ai.openclaw.android.CameraHudKind +import ai.openclaw.android.BuildConfig +import ai.openclaw.android.SecurePrefs +import ai.openclaw.android.gateway.GatewayEndpoint +import ai.openclaw.android.gateway.GatewaySession +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.asRequestBody + +class CameraHandler( + private val appContext: Context, + private val camera: CameraCaptureManager, + private val prefs: SecurePrefs, + private val connectedEndpoint: () -> GatewayEndpoint?, + private val externalAudioCaptureActive: MutableStateFlow, + private val showCameraHud: (message: String, kind: CameraHudKind, autoHideMs: Long?) -> Unit, + private val triggerCameraFlash: () -> Unit, + private val invokeErrorFromThrowable: (err: Throwable) -> Pair, +) { + + suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult { + val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null + fun camLog(msg: String) { + if (!BuildConfig.DEBUG) return + val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date()) + logFile?.appendText("[$ts] $msg\n") + android.util.Log.w("openclaw", "camera.snap: $msg") + } + try { + logFile?.writeText("") // clear + camLog("starting, params=$paramsJson") + camLog("calling showCameraHud") + showCameraHud("Taking photo…", CameraHudKind.Photo, null) + camLog("calling triggerCameraFlash") + triggerCameraFlash() + val res = + try { + camLog("calling camera.snap()") + val r = camera.snap(paramsJson) + camLog("success, payload size=${r.payloadJson.length}") + r + } catch (err: Throwable) { + camLog("inner error: ${err::class.java.simpleName}: ${err.message}") + camLog("stack: ${err.stackTraceToString().take(2000)}") + val (code, message) = invokeErrorFromThrowable(err) + showCameraHud(message, CameraHudKind.Error, 2200) + return GatewaySession.InvokeResult.error(code = code, message = message) + } + camLog("returning result") + showCameraHud("Photo captured", CameraHudKind.Success, 1600) + return GatewaySession.InvokeResult.ok(res.payloadJson) + } catch (err: Throwable) { + camLog("outer error: ${err::class.java.simpleName}: ${err.message}") + camLog("stack: ${err.stackTraceToString().take(2000)}") + return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera snap failed") + } + } + + suspend fun handleClip(paramsJson: String?): GatewaySession.InvokeResult { + val clipLogFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null + fun clipLog(msg: String) { + if (!BuildConfig.DEBUG) return + val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date()) + clipLogFile?.appendText("[CLIP $ts] $msg\n") + android.util.Log.w("openclaw", "camera.clip: $msg") + } + val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false + if (includeAudio) externalAudioCaptureActive.value = true + try { + clipLogFile?.writeText("") // clear + clipLog("starting, params=$paramsJson includeAudio=$includeAudio") + clipLog("calling showCameraHud") + showCameraHud("Recording…", CameraHudKind.Recording, null) + val filePayload = + try { + clipLog("calling camera.clip()") + val r = camera.clip(paramsJson) + clipLog("success, file size=${r.file.length()}") + r + } catch (err: Throwable) { + clipLog("inner error: ${err::class.java.simpleName}: ${err.message}") + clipLog("stack: ${err.stackTraceToString().take(2000)}") + val (code, message) = invokeErrorFromThrowable(err) + showCameraHud(message, CameraHudKind.Error, 2400) + return GatewaySession.InvokeResult.error(code = code, message = message) + } + // Upload file via HTTP instead of base64 through WebSocket + clipLog("uploading via HTTP...") + val uploadUrl = try { + withContext(Dispatchers.IO) { + val ep = connectedEndpoint() + val gatewayHost = if (ep != null) { + val isHttps = ep.tlsEnabled || ep.port == 443 + if (!isHttps) { + clipLog("refusing to upload over plain HTTP — bearer token would be exposed; falling back to base64") + throw Exception("HTTPS required for upload (bearer token protection)") + } + if (ep.port == 443) "https://${ep.host}" else "https://${ep.host}:${ep.port}" + } else { + clipLog("error: no gateway endpoint connected, cannot upload") + throw Exception("no gateway endpoint connected") + } + val token = prefs.loadGatewayToken() ?: "" + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(120, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .build() + val body = filePayload.file.asRequestBody("video/mp4".toMediaType()) + val req = okhttp3.Request.Builder() + .url("$gatewayHost/upload/clip.mp4") + .put(body) + .header("Authorization", "Bearer $token") + .build() + clipLog("uploading ${filePayload.file.length()} bytes to $gatewayHost/upload/clip.mp4") + val resp = client.newCall(req).execute() + val respBody = resp.body?.string() ?: "" + clipLog("upload response: ${resp.code} $respBody") + filePayload.file.delete() + if (!resp.isSuccessful) throw Exception("upload failed: HTTP ${resp.code}") + // Parse URL from response + val urlMatch = Regex("\"url\":\"([^\"]+)\"").find(respBody) + urlMatch?.groupValues?.get(1) ?: throw Exception("no url in response: $respBody") + } + } catch (err: Throwable) { + clipLog("upload failed: ${err.message}, falling back to base64") + // Fallback to base64 if upload fails + val bytes = withContext(Dispatchers.IO) { + val b = filePayload.file.readBytes() + filePayload.file.delete() + b + } + val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) + showCameraHud("Clip captured", CameraHudKind.Success, 1800) + return GatewaySession.InvokeResult.ok( + """{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}""" + ) + } + clipLog("returning URL result: $uploadUrl") + showCameraHud("Clip captured", CameraHudKind.Success, 1800) + return GatewaySession.InvokeResult.ok( + """{"format":"mp4","url":"$uploadUrl","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}""" + ) + } catch (err: Throwable) { + clipLog("outer error: ${err::class.java.simpleName}: ${err.message}") + clipLog("stack: ${err.stackTraceToString().take(2000)}") + return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera clip failed") + } finally { + if (includeAudio) externalAudioCaptureActive.value = false + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt new file mode 100644 index 00000000..d0747ee3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt @@ -0,0 +1,276 @@ +package ai.openclaw.android.node + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Looper +import android.util.Log +import android.webkit.WebView +import androidx.core.graphics.createBitmap +import androidx.core.graphics.scale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.ByteArrayOutputStream +import android.util.Base64 +import org.json.JSONObject +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import ai.openclaw.android.BuildConfig +import kotlin.coroutines.resume + +class CanvasController { + enum class SnapshotFormat(val rawValue: String) { + Png("png"), + Jpeg("jpeg"), + } + + @Volatile private var webView: WebView? = null + @Volatile private var url: String? = null + @Volatile private var debugStatusEnabled: Boolean = false + @Volatile private var debugStatusTitle: String? = null + @Volatile private var debugStatusSubtitle: String? = null + private val _currentUrl = MutableStateFlow(null) + val currentUrl: StateFlow = _currentUrl.asStateFlow() + + private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html" + + private fun clampJpegQuality(quality: Double?): Int { + val q = (quality ?: 0.82).coerceIn(0.1, 1.0) + return (q * 100.0).toInt().coerceIn(1, 100) + } + + fun attach(webView: WebView) { + this.webView = webView + reload() + applyDebugStatus() + } + + fun detach(webView: WebView) { + if (this.webView === webView) { + this.webView = null + } + } + + fun navigate(url: String) { + val trimmed = url.trim() + this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed + _currentUrl.value = this.url + reload() + } + + fun currentUrl(): String? = url + + fun isDefaultCanvas(): Boolean = url == null + + fun setDebugStatusEnabled(enabled: Boolean) { + debugStatusEnabled = enabled + applyDebugStatus() + } + + fun setDebugStatus(title: String?, subtitle: String?) { + debugStatusTitle = title + debugStatusSubtitle = subtitle + applyDebugStatus() + } + + fun onPageFinished() { + applyDebugStatus() + } + + private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) { + val wv = webView ?: return + if (Looper.myLooper() == Looper.getMainLooper()) { + block(wv) + } else { + wv.post { block(wv) } + } + } + + private fun reload() { + val currentUrl = url + withWebViewOnMain { wv -> + if (currentUrl == null) { + if (BuildConfig.DEBUG) { + Log.d("OpenClawCanvas", "load scaffold: $scaffoldAssetUrl") + } + wv.loadUrl(scaffoldAssetUrl) + } else { + if (BuildConfig.DEBUG) { + Log.d("OpenClawCanvas", "load url: $currentUrl") + } + wv.loadUrl(currentUrl) + } + } + } + + private fun applyDebugStatus() { + val enabled = debugStatusEnabled + val title = debugStatusTitle + val subtitle = debugStatusSubtitle + withWebViewOnMain { wv -> + val titleJs = title?.let { JSONObject.quote(it) } ?: "null" + val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null" + val js = """ + (() => { + try { + const api = globalThis.__openclaw; + if (!api) return; + if (typeof api.setDebugStatusEnabled === 'function') { + api.setDebugStatusEnabled(${if (enabled) "true" else "false"}); + } + if (!${if (enabled) "true" else "false"}) return; + if (typeof api.setStatus === 'function') { + api.setStatus($titleJs, $subtitleJs); + } + } catch (_) {} + })(); + """.trimIndent() + wv.evaluateJavascript(js, null) + } + } + + suspend fun eval(javaScript: String): String = + withContext(Dispatchers.Main) { + val wv = webView ?: throw IllegalStateException("no webview") + suspendCancellableCoroutine { cont -> + wv.evaluateJavascript(javaScript) { result -> + cont.resume(result ?: "") + } + } + } + + suspend fun snapshotPngBase64(maxWidth: Int?): String = + withContext(Dispatchers.Main) { + val wv = webView ?: throw IllegalStateException("no webview") + val bmp = wv.captureBitmap() + val scaled = + if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) { + val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1) + bmp.scale(maxWidth, h) + } else { + bmp + } + + val out = ByteArrayOutputStream() + scaled.compress(Bitmap.CompressFormat.PNG, 100, out) + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) + } + + suspend fun snapshotBase64(format: SnapshotFormat, quality: Double?, maxWidth: Int?): String = + withContext(Dispatchers.Main) { + val wv = webView ?: throw IllegalStateException("no webview") + val bmp = wv.captureBitmap() + val scaled = + if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) { + val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1) + bmp.scale(maxWidth, h) + } else { + bmp + } + + val out = ByteArrayOutputStream() + val (compressFormat, compressQuality) = + when (format) { + SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100 + SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality) + } + scaled.compress(compressFormat, compressQuality, out) + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) + } + + private suspend fun WebView.captureBitmap(): Bitmap = + suspendCancellableCoroutine { cont -> + val width = width.coerceAtLeast(1) + val height = height.coerceAtLeast(1) + val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888) + + // WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable + // cross-version snapshot for this lightweight "canvas" use-case. + draw(Canvas(bitmap)) + cont.resume(bitmap) + } + + companion object { + data class SnapshotParams(val format: SnapshotFormat, val quality: Double?, val maxWidth: Int?) + + fun parseNavigateUrl(paramsJson: String?): String { + val obj = parseParamsObject(paramsJson) ?: return "" + return obj.string("url").trim() + } + + fun parseEvalJs(paramsJson: String?): String? { + val obj = parseParamsObject(paramsJson) ?: return null + val js = obj.string("javaScript").trim() + return js.takeIf { it.isNotBlank() } + } + + fun parseSnapshotMaxWidth(paramsJson: String?): Int? { + val obj = parseParamsObject(paramsJson) ?: return null + if (!obj.containsKey("maxWidth")) return null + val width = obj.int("maxWidth") ?: 0 + return width.takeIf { it > 0 } + } + + fun parseSnapshotFormat(paramsJson: String?): SnapshotFormat { + val obj = parseParamsObject(paramsJson) ?: return SnapshotFormat.Jpeg + val raw = obj.string("format").trim().lowercase() + return when (raw) { + "png" -> SnapshotFormat.Png + "jpeg", "jpg" -> SnapshotFormat.Jpeg + "" -> SnapshotFormat.Jpeg + else -> SnapshotFormat.Jpeg + } + } + + fun parseSnapshotQuality(paramsJson: String?): Double? { + val obj = parseParamsObject(paramsJson) ?: return null + if (!obj.containsKey("quality")) return null + val q = obj.double("quality") ?: Double.NaN + if (!q.isFinite()) return null + return q.coerceIn(0.1, 1.0) + } + + fun parseSnapshotParams(paramsJson: String?): SnapshotParams { + return SnapshotParams( + format = parseSnapshotFormat(paramsJson), + quality = parseSnapshotQuality(paramsJson), + maxWidth = parseSnapshotMaxWidth(paramsJson), + ) + } + + private val json = Json { ignoreUnknownKeys = true } + + private fun parseParamsObject(paramsJson: String?): JsonObject? { + val raw = paramsJson?.trim().orEmpty() + if (raw.isEmpty()) return null + return try { + json.parseToJsonElement(raw).asObjectOrNull() + } catch (_: Throwable) { + null + } + } + + private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject + + private fun JsonObject.string(key: String): String { + val prim = this[key] as? JsonPrimitive ?: return "" + val raw = prim.content + return raw.takeIf { it != "null" }.orEmpty() + } + + private fun JsonObject.int(key: String): Int? { + val prim = this[key] as? JsonPrimitive ?: return null + return prim.content.toIntOrNull() + } + + private fun JsonObject.double(key: String): Double? { + val prim = this[key] as? JsonPrimitive ?: return null + return prim.content.toDoubleOrNull() + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt new file mode 100644 index 00000000..9b449fc8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt @@ -0,0 +1,188 @@ +package ai.openclaw.android.node + +import android.os.Build +import ai.openclaw.android.BuildConfig +import ai.openclaw.android.SecurePrefs +import ai.openclaw.android.gateway.GatewayClientInfo +import ai.openclaw.android.gateway.GatewayConnectOptions +import ai.openclaw.android.gateway.GatewayEndpoint +import ai.openclaw.android.gateway.GatewayTlsParams +import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand +import ai.openclaw.android.protocol.OpenClawCanvasCommand +import ai.openclaw.android.protocol.OpenClawCameraCommand +import ai.openclaw.android.protocol.OpenClawLocationCommand +import ai.openclaw.android.protocol.OpenClawScreenCommand +import ai.openclaw.android.protocol.OpenClawSmsCommand +import ai.openclaw.android.protocol.OpenClawCapability +import ai.openclaw.android.LocationMode +import ai.openclaw.android.VoiceWakeMode + +class ConnectionManager( + private val prefs: SecurePrefs, + private val cameraEnabled: () -> Boolean, + private val locationMode: () -> LocationMode, + private val voiceWakeMode: () -> VoiceWakeMode, + private val smsAvailable: () -> Boolean, + private val hasRecordAudioPermission: () -> Boolean, + private val manualTls: () -> Boolean, +) { + companion object { + internal fun resolveTlsParamsForEndpoint( + endpoint: GatewayEndpoint, + storedFingerprint: String?, + manualTlsEnabled: Boolean, + ): GatewayTlsParams? { + val stableId = endpoint.stableId + val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() } + val isManual = stableId.startsWith("manual|") + + if (isManual) { + if (!manualTlsEnabled) return null + if (!stored.isNullOrBlank()) { + return GatewayTlsParams( + required = true, + expectedFingerprint = stored, + allowTOFU = false, + stableId = stableId, + ) + } + return GatewayTlsParams( + required = true, + expectedFingerprint = null, + allowTOFU = false, + stableId = stableId, + ) + } + + // Prefer stored pins. Never let discovery-provided TXT override a stored fingerprint. + if (!stored.isNullOrBlank()) { + return GatewayTlsParams( + required = true, + expectedFingerprint = stored, + allowTOFU = false, + stableId = stableId, + ) + } + + val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank() + if (hinted) { + // TXT is unauthenticated. Do not treat the advertised fingerprint as authoritative. + return GatewayTlsParams( + required = true, + expectedFingerprint = null, + allowTOFU = false, + stableId = stableId, + ) + } + + return null + } + } + + fun buildInvokeCommands(): List = + buildList { + add(OpenClawCanvasCommand.Present.rawValue) + add(OpenClawCanvasCommand.Hide.rawValue) + add(OpenClawCanvasCommand.Navigate.rawValue) + add(OpenClawCanvasCommand.Eval.rawValue) + add(OpenClawCanvasCommand.Snapshot.rawValue) + add(OpenClawCanvasA2UICommand.Push.rawValue) + add(OpenClawCanvasA2UICommand.PushJSONL.rawValue) + add(OpenClawCanvasA2UICommand.Reset.rawValue) + add(OpenClawScreenCommand.Record.rawValue) + if (cameraEnabled()) { + add(OpenClawCameraCommand.Snap.rawValue) + add(OpenClawCameraCommand.Clip.rawValue) + } + if (locationMode() != LocationMode.Off) { + add(OpenClawLocationCommand.Get.rawValue) + } + if (smsAvailable()) { + add(OpenClawSmsCommand.Send.rawValue) + } + if (BuildConfig.DEBUG) { + add("debug.logs") + add("debug.ed25519") + } + add("app.update") + } + + fun buildCapabilities(): List = + buildList { + add(OpenClawCapability.Canvas.rawValue) + add(OpenClawCapability.Screen.rawValue) + if (cameraEnabled()) add(OpenClawCapability.Camera.rawValue) + if (smsAvailable()) add(OpenClawCapability.Sms.rawValue) + if (voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission()) { + add(OpenClawCapability.VoiceWake.rawValue) + } + if (locationMode() != LocationMode.Off) { + add(OpenClawCapability.Location.rawValue) + } + } + + fun resolvedVersionName(): String { + val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" } + return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) { + "$versionName-dev" + } else { + versionName + } + } + + fun resolveModelIdentifier(): String? { + return listOfNotNull(Build.MANUFACTURER, Build.MODEL) + .joinToString(" ") + .trim() + .ifEmpty { null } + } + + fun buildUserAgent(): String { + val version = resolvedVersionName() + val release = Build.VERSION.RELEASE?.trim().orEmpty() + val releaseLabel = if (release.isEmpty()) "unknown" else release + return "OpenClawAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})" + } + + fun buildClientInfo(clientId: String, clientMode: String): GatewayClientInfo { + return GatewayClientInfo( + id = clientId, + displayName = prefs.displayName.value, + version = resolvedVersionName(), + platform = "android", + mode = clientMode, + instanceId = prefs.instanceId.value, + deviceFamily = "Android", + modelIdentifier = resolveModelIdentifier(), + ) + } + + fun buildNodeConnectOptions(): GatewayConnectOptions { + return GatewayConnectOptions( + role = "node", + scopes = emptyList(), + caps = buildCapabilities(), + commands = buildInvokeCommands(), + permissions = emptyMap(), + client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"), + userAgent = buildUserAgent(), + ) + } + + fun buildOperatorConnectOptions(): GatewayConnectOptions { + return GatewayConnectOptions( + role = "operator", + scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"), + caps = emptyList(), + commands = emptyList(), + permissions = emptyMap(), + client = buildClientInfo(clientId = "openclaw-android", clientMode = "ui"), + userAgent = buildUserAgent(), + ) + } + + fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? { + val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId) + return resolveTlsParamsForEndpoint(endpoint, storedFingerprint = stored, manualTlsEnabled = manualTls()) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/DebugHandler.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/DebugHandler.kt new file mode 100644 index 00000000..49502bd3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/DebugHandler.kt @@ -0,0 +1,117 @@ +package ai.openclaw.android.node + +import android.content.Context +import ai.openclaw.android.BuildConfig +import ai.openclaw.android.gateway.DeviceIdentityStore +import ai.openclaw.android.gateway.GatewaySession +import kotlinx.serialization.json.JsonPrimitive + +class DebugHandler( + private val appContext: Context, + private val identityStore: DeviceIdentityStore, +) { + + fun handleEd25519(): GatewaySession.InvokeResult { + if (!BuildConfig.DEBUG) { + return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds") + } + // Self-test Ed25519 signing and return diagnostic info + try { + val identity = identityStore.loadOrCreate() + val testPayload = "test|${identity.deviceId}|${System.currentTimeMillis()}" + val results = mutableListOf() + results.add("deviceId: ${identity.deviceId}") + results.add("publicKeyRawBase64: ${identity.publicKeyRawBase64.take(20)}...") + results.add("privateKeyPkcs8Base64: ${identity.privateKeyPkcs8Base64.take(20)}...") + + // Test publicKeyBase64Url + val pubKeyUrl = identityStore.publicKeyBase64Url(identity) + results.add("publicKeyBase64Url: ${pubKeyUrl ?: "NULL (FAILED)"}") + + // Test signing + val signature = identityStore.signPayload(testPayload, identity) + results.add("signPayload: ${if (signature != null) "${signature.take(20)}... (OK)" else "NULL (FAILED)"}") + + // Test self-verify + if (signature != null) { + val verifyOk = identityStore.verifySelfSignature(testPayload, signature, identity) + results.add("verifySelfSignature: $verifyOk") + } + + // Check available providers + val providers = java.security.Security.getProviders() + val ed25519Providers = providers.filter { p -> + p.services.any { s -> s.algorithm.contains("Ed25519", ignoreCase = true) } + } + results.add("Ed25519 providers: ${ed25519Providers.map { "${it.name} v${it.version}" }}") + results.add("Provider order: ${providers.take(5).map { it.name }}") + + // Test KeyFactory directly + try { + val kf = java.security.KeyFactory.getInstance("Ed25519") + results.add("KeyFactory.Ed25519: ${kf.provider.name} (OK)") + } catch (e: Throwable) { + results.add("KeyFactory.Ed25519: FAILED - ${e.javaClass.simpleName}: ${e.message}") + } + + // Test Signature directly + try { + val sig = java.security.Signature.getInstance("Ed25519") + results.add("Signature.Ed25519: ${sig.provider.name} (OK)") + } catch (e: Throwable) { + results.add("Signature.Ed25519: FAILED - ${e.javaClass.simpleName}: ${e.message}") + } + + return GatewaySession.InvokeResult.ok("""{"diagnostics":"${results.joinToString("\\n").replace("\"", "\\\"")}"}"""") + } catch (e: Throwable) { + return GatewaySession.InvokeResult.error(code = "ED25519_TEST_FAILED", message = "${e.javaClass.simpleName}: ${e.message}\n${e.stackTraceToString().take(500)}") + } + } + + fun handleLogs(): GatewaySession.InvokeResult { + if (!BuildConfig.DEBUG) { + return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds") + } + val pid = android.os.Process.myPid() + val rt = Runtime.getRuntime() + val info = "v6 pid=$pid thread=${Thread.currentThread().name} free=${rt.freeMemory()/1024}K total=${rt.totalMemory()/1024}K max=${rt.maxMemory()/1024}K uptime=${android.os.SystemClock.elapsedRealtime()/1000}s sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.MODEL}\n" + // Run logcat on current dispatcher thread (no withContext) with file redirect + val logResult = try { + val tmpFile = java.io.File(appContext.cacheDir, "debug_logs.txt") + if (tmpFile.exists()) tmpFile.delete() + val pb = ProcessBuilder("logcat", "-d", "-t", "200", "--pid=$pid") + pb.redirectOutput(tmpFile) + pb.redirectErrorStream(true) + val proc = pb.start() + val finished = proc.waitFor(4, java.util.concurrent.TimeUnit.SECONDS) + if (!finished) proc.destroyForcibly() + val raw = if (tmpFile.exists() && tmpFile.length() > 0) { + tmpFile.readText().take(128000) + } else { + "(no output, finished=$finished, exists=${tmpFile.exists()})" + } + tmpFile.delete() + val spamPatterns = listOf("setRequestedFrameRate", "I View :", "BLASTBufferQueue", "VRI[Pop-Up", + "InsetsController:", "VRI[MainActivity", "InsetsSource:", "handleResized", "ProfileInstaller", + "I VRI[", "onStateChanged: host=", "D StrictMode:", "E StrictMode:", "ImeFocusController", + "InputTransport", "IncorrectContextUseViolation") + val sb = StringBuilder() + for (line in raw.lineSequence()) { + if (line.isBlank()) continue + if (spamPatterns.any { line.contains(it) }) continue + if (sb.length + line.length > 16000) { sb.append("\n(truncated)"); break } + if (sb.isNotEmpty()) sb.append('\n') + sb.append(line) + } + sb.toString().ifEmpty { "(all ${raw.lines().size} lines filtered as spam)" } + } catch (e: Throwable) { + "(logcat error: ${e::class.java.simpleName}: ${e.message})" + } + // Also include camera debug log if it exists + val camLogFile = java.io.File(appContext.cacheDir, "camera_debug.log") + val camLog = if (camLogFile.exists() && camLogFile.length() > 0) { + "\n--- camera_debug.log ---\n" + camLogFile.readText().take(4000) + } else "" + return GatewaySession.InvokeResult.ok("""{"logs":${JsonPrimitive(info + logResult + camLog)}}""") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/GatewayEventHandler.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/GatewayEventHandler.kt new file mode 100644 index 00000000..9c0514d8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/GatewayEventHandler.kt @@ -0,0 +1,71 @@ +package ai.openclaw.android.node + +import ai.openclaw.android.SecurePrefs +import ai.openclaw.android.gateway.GatewaySession +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray + +class GatewayEventHandler( + private val scope: CoroutineScope, + private val prefs: SecurePrefs, + private val json: Json, + private val operatorSession: GatewaySession, + private val isConnected: () -> Boolean, +) { + private var suppressWakeWordsSync = false + private var wakeWordsSyncJob: Job? = null + + fun applyWakeWordsFromGateway(words: List) { + suppressWakeWordsSync = true + prefs.setWakeWords(words) + suppressWakeWordsSync = false + } + + fun scheduleWakeWordsSyncIfNeeded() { + if (suppressWakeWordsSync) return + if (!isConnected()) return + + val snapshot = prefs.wakeWords.value + wakeWordsSyncJob?.cancel() + wakeWordsSyncJob = + scope.launch { + delay(650) + val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() } + val params = """{"triggers":[$jsonList]}""" + try { + operatorSession.request("voicewake.set", params) + } catch (_: Throwable) { + // ignore + } + } + } + + suspend fun refreshWakeWordsFromGateway() { + if (!isConnected()) return + try { + val res = operatorSession.request("voicewake.get", "{}") + val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return + val array = payload["triggers"] as? JsonArray ?: return + val triggers = array.mapNotNull { it.asStringOrNull() } + applyWakeWordsFromGateway(triggers) + } catch (_: Throwable) { + // ignore + } + } + + fun handleVoiceWakeChangedEvent(payloadJson: String?) { + if (payloadJson.isNullOrBlank()) return + try { + val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return + val array = payload["triggers"] as? JsonArray ?: return + val triggers = array.mapNotNull { it.asStringOrNull() } + applyWakeWordsFromGateway(triggers) + } catch (_: Throwable) { + // ignore + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt new file mode 100644 index 00000000..91e9da8a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt @@ -0,0 +1,180 @@ +package ai.openclaw.android.node + +import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand +import ai.openclaw.android.protocol.OpenClawCanvasCommand +import ai.openclaw.android.protocol.OpenClawCameraCommand +import ai.openclaw.android.protocol.OpenClawLocationCommand +import ai.openclaw.android.protocol.OpenClawScreenCommand +import ai.openclaw.android.protocol.OpenClawSmsCommand + +class InvokeDispatcher( + private val canvas: CanvasController, + private val cameraHandler: CameraHandler, + private val locationHandler: LocationHandler, + private val screenHandler: ScreenHandler, + private val smsHandler: SmsHandler, + private val a2uiHandler: A2UIHandler, + private val debugHandler: DebugHandler, + private val appUpdateHandler: AppUpdateHandler, + private val isForeground: () -> Boolean, + private val cameraEnabled: () -> Boolean, + private val locationEnabled: () -> Boolean, + private val onCanvasA2uiPush: () -> Unit, + private val onCanvasA2uiReset: () -> Unit, +) { + suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult { + // Check foreground requirement for canvas/camera/screen commands + if ( + command.startsWith(OpenClawCanvasCommand.NamespacePrefix) || + command.startsWith(OpenClawCanvasA2UICommand.NamespacePrefix) || + command.startsWith(OpenClawCameraCommand.NamespacePrefix) || + command.startsWith(OpenClawScreenCommand.NamespacePrefix) + ) { + if (!isForeground()) { + return GatewaySession.InvokeResult.error( + code = "NODE_BACKGROUND_UNAVAILABLE", + message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", + ) + } + } + + // Check camera enabled + if (command.startsWith(OpenClawCameraCommand.NamespacePrefix) && !cameraEnabled()) { + return GatewaySession.InvokeResult.error( + code = "CAMERA_DISABLED", + message = "CAMERA_DISABLED: enable Camera in Settings", + ) + } + + // Check location enabled + if (command.startsWith(OpenClawLocationCommand.NamespacePrefix) && !locationEnabled()) { + return GatewaySession.InvokeResult.error( + code = "LOCATION_DISABLED", + message = "LOCATION_DISABLED: enable Location in Settings", + ) + } + + return when (command) { + // Canvas commands + OpenClawCanvasCommand.Present.rawValue -> { + val url = CanvasController.parseNavigateUrl(paramsJson) + canvas.navigate(url) + GatewaySession.InvokeResult.ok(null) + } + OpenClawCanvasCommand.Hide.rawValue -> GatewaySession.InvokeResult.ok(null) + OpenClawCanvasCommand.Navigate.rawValue -> { + val url = CanvasController.parseNavigateUrl(paramsJson) + canvas.navigate(url) + GatewaySession.InvokeResult.ok(null) + } + OpenClawCanvasCommand.Eval.rawValue -> { + val js = + CanvasController.parseEvalJs(paramsJson) + ?: return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: javaScript required", + ) + val result = + try { + canvas.eval(js) + } catch (err: Throwable) { + return GatewaySession.InvokeResult.error( + code = "NODE_BACKGROUND_UNAVAILABLE", + message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", + ) + } + GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""") + } + OpenClawCanvasCommand.Snapshot.rawValue -> { + val snapshotParams = CanvasController.parseSnapshotParams(paramsJson) + val base64 = + try { + canvas.snapshotBase64( + format = snapshotParams.format, + quality = snapshotParams.quality, + maxWidth = snapshotParams.maxWidth, + ) + } catch (err: Throwable) { + return GatewaySession.InvokeResult.error( + code = "NODE_BACKGROUND_UNAVAILABLE", + message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable", + ) + } + GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""") + } + + // A2UI commands + OpenClawCanvasA2UICommand.Reset.rawValue -> { + val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() + ?: return GatewaySession.InvokeResult.error( + code = "A2UI_HOST_NOT_CONFIGURED", + message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", + ) + val ready = a2uiHandler.ensureA2uiReady(a2uiUrl) + if (!ready) { + return GatewaySession.InvokeResult.error( + code = "A2UI_HOST_UNAVAILABLE", + message = "A2UI host not reachable", + ) + } + val res = canvas.eval(A2UIHandler.a2uiResetJS) + onCanvasA2uiReset() + GatewaySession.InvokeResult.ok(res) + } + OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> { + val messages = + try { + a2uiHandler.decodeA2uiMessages(command, paramsJson) + } catch (err: Throwable) { + return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = err.message ?: "invalid A2UI payload" + ) + } + val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() + ?: return GatewaySession.InvokeResult.error( + code = "A2UI_HOST_NOT_CONFIGURED", + message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", + ) + val ready = a2uiHandler.ensureA2uiReady(a2uiUrl) + if (!ready) { + return GatewaySession.InvokeResult.error( + code = "A2UI_HOST_UNAVAILABLE", + message = "A2UI host not reachable", + ) + } + val js = A2UIHandler.a2uiApplyMessagesJS(messages) + val res = canvas.eval(js) + onCanvasA2uiPush() + GatewaySession.InvokeResult.ok(res) + } + + // Camera commands + OpenClawCameraCommand.Snap.rawValue -> cameraHandler.handleSnap(paramsJson) + OpenClawCameraCommand.Clip.rawValue -> cameraHandler.handleClip(paramsJson) + + // Location command + OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson) + + // Screen command + OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson) + + // SMS command + OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson) + + // Debug commands + "debug.ed25519" -> debugHandler.handleEd25519() + "debug.logs" -> debugHandler.handleLogs() + + // App update + "app.update" -> appUpdateHandler.handleUpdate(paramsJson) + + else -> + GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: unknown command", + ) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/JpegSizeLimiter.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/JpegSizeLimiter.kt new file mode 100644 index 00000000..d6018467 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/JpegSizeLimiter.kt @@ -0,0 +1,61 @@ +package ai.openclaw.android.node + +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +internal data class JpegSizeLimiterResult( + val bytes: ByteArray, + val width: Int, + val height: Int, + val quality: Int, +) + +internal object JpegSizeLimiter { + fun compressToLimit( + initialWidth: Int, + initialHeight: Int, + startQuality: Int, + maxBytes: Int, + minQuality: Int = 20, + minSize: Int = 256, + scaleStep: Double = 0.85, + maxScaleAttempts: Int = 6, + maxQualityAttempts: Int = 6, + encode: (width: Int, height: Int, quality: Int) -> ByteArray, + ): JpegSizeLimiterResult { + require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" } + require(maxBytes > 0) { "Invalid maxBytes" } + + var width = initialWidth + var height = initialHeight + val clampedStartQuality = startQuality.coerceIn(minQuality, 100) + var best = JpegSizeLimiterResult(bytes = encode(width, height, clampedStartQuality), width = width, height = height, quality = clampedStartQuality) + if (best.bytes.size <= maxBytes) return best + + repeat(maxScaleAttempts) { + var quality = clampedStartQuality + repeat(maxQualityAttempts) { + val bytes = encode(width, height, quality) + best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality) + if (bytes.size <= maxBytes) return best + if (quality <= minQuality) return@repeat + quality = max(minQuality, (quality * 0.75).roundToInt()) + } + + val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0) + val nextScale = max(scaleStep, minScale) + val nextWidth = max(minSize, (width * nextScale).roundToInt()) + val nextHeight = max(minSize, (height * nextScale).roundToInt()) + if (nextWidth == width && nextHeight == height) return@repeat + width = min(nextWidth, width) + height = min(nextHeight, height) + } + + if (best.bytes.size > maxBytes) { + throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes") + } + + return best + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/LocationCaptureManager.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/LocationCaptureManager.kt new file mode 100644 index 00000000..87762e87 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/LocationCaptureManager.kt @@ -0,0 +1,117 @@ +package ai.openclaw.android.node + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager +import android.os.CancellationSignal +import androidx.core.content.ContextCompat +import java.time.Instant +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +class LocationCaptureManager(private val context: Context) { + data class Payload(val payloadJson: String) + + suspend fun getLocation( + desiredProviders: List, + maxAgeMs: Long?, + timeoutMs: Long, + isPrecise: Boolean, + ): Payload = + withContext(Dispatchers.Main) { + val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER) && + !manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + ) { + throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled") + } + + val cached = bestLastKnown(manager, desiredProviders, maxAgeMs) + val location = + cached ?: requestCurrent(manager, desiredProviders, timeoutMs) + + val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(location.time)) + val source = location.provider + val altitudeMeters = if (location.hasAltitude()) location.altitude else null + val speedMps = if (location.hasSpeed()) location.speed.toDouble() else null + val headingDeg = if (location.hasBearing()) location.bearing.toDouble() else null + Payload( + buildString { + append("{\"lat\":") + append(location.latitude) + append(",\"lon\":") + append(location.longitude) + append(",\"accuracyMeters\":") + append(location.accuracy.toDouble()) + if (altitudeMeters != null) append(",\"altitudeMeters\":").append(altitudeMeters) + if (speedMps != null) append(",\"speedMps\":").append(speedMps) + if (headingDeg != null) append(",\"headingDeg\":").append(headingDeg) + append(",\"timestamp\":\"").append(timestamp).append('"') + append(",\"isPrecise\":").append(isPrecise) + append(",\"source\":\"").append(source).append('"') + append('}') + }, + ) + } + + private fun bestLastKnown( + manager: LocationManager, + providers: List, + maxAgeMs: Long?, + ): Location? { + val fineOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + val coarseOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!fineOk && !coarseOk) { + throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission") + } + val now = System.currentTimeMillis() + val candidates = + providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) } + val freshest = candidates.maxByOrNull { it.time } ?: return null + if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null + return freshest + } + + private suspend fun requestCurrent( + manager: LocationManager, + providers: List, + timeoutMs: Long, + ): Location { + val fineOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + val coarseOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!fineOk && !coarseOk) { + throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission") + } + val resolved = + providers.firstOrNull { manager.isProviderEnabled(it) } + ?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available") + return withTimeout(timeoutMs.coerceAtLeast(1)) { + suspendCancellableCoroutine { cont -> + val signal = CancellationSignal() + cont.invokeOnCancellation { signal.cancel() } + manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location -> + if (location != null) { + cont.resume(location) + } else { + cont.resumeWithException(IllegalStateException("LOCATION_UNAVAILABLE: no fix")) + } + } + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/LocationHandler.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/LocationHandler.kt new file mode 100644 index 00000000..c3f292f9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/LocationHandler.kt @@ -0,0 +1,116 @@ +package ai.openclaw.android.node + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.LocationManager +import androidx.core.content.ContextCompat +import ai.openclaw.android.LocationMode +import ai.openclaw.android.gateway.GatewaySession +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +class LocationHandler( + private val appContext: Context, + private val location: LocationCaptureManager, + private val json: Json, + private val isForeground: () -> Boolean, + private val locationMode: () -> LocationMode, + private val locationPreciseEnabled: () -> Boolean, +) { + fun hasFineLocationPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + ) + } + + fun hasCoarseLocationPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + ) + } + + fun hasBackgroundLocationPermission(): Boolean { + return ( + ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == + PackageManager.PERMISSION_GRANTED + ) + } + + suspend fun handleLocationGet(paramsJson: String?): GatewaySession.InvokeResult { + val mode = locationMode() + if (!isForeground() && mode != LocationMode.Always) { + return GatewaySession.InvokeResult.error( + code = "LOCATION_BACKGROUND_UNAVAILABLE", + message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always", + ) + } + if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) { + return GatewaySession.InvokeResult.error( + code = "LOCATION_PERMISSION_REQUIRED", + message = "LOCATION_PERMISSION_REQUIRED: grant Location permission", + ) + } + if (!isForeground() && mode == LocationMode.Always && !hasBackgroundLocationPermission()) { + return GatewaySession.InvokeResult.error( + code = "LOCATION_PERMISSION_REQUIRED", + message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings", + ) + } + val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson) + val preciseEnabled = locationPreciseEnabled() + val accuracy = + when (desiredAccuracy) { + "precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" + "coarse" -> "coarse" + else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" + } + val providers = + when (accuracy) { + "precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) + "coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) + else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER) + } + try { + val payload = + location.getLocation( + desiredProviders = providers, + maxAgeMs = maxAgeMs, + timeoutMs = timeoutMs, + isPrecise = accuracy == "precise", + ) + return GatewaySession.InvokeResult.ok(payload.payloadJson) + } catch (err: TimeoutCancellationException) { + return GatewaySession.InvokeResult.error( + code = "LOCATION_TIMEOUT", + message = "LOCATION_TIMEOUT: no fix in time", + ) + } catch (err: Throwable) { + val message = err.message ?: "LOCATION_UNAVAILABLE: no fix" + return GatewaySession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message) + } + } + + private fun parseLocationParams(paramsJson: String?): Triple { + if (paramsJson.isNullOrBlank()) { + return Triple(null, 10_000L, null) + } + val root = + try { + json.parseToJsonElement(paramsJson).asObjectOrNull() + } catch (_: Throwable) { + null + } + val maxAgeMs = (root?.get("maxAgeMs") as? JsonPrimitive)?.content?.toLongOrNull() + val timeoutMs = + (root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L) + ?: 10_000L + val desiredAccuracy = + (root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase() + return Triple(maxAgeMs, timeoutMs, desiredAccuracy) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt new file mode 100644 index 00000000..8ba5ad27 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt @@ -0,0 +1,57 @@ +package ai.openclaw.android.node + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A + +data class Quad(val first: A, val second: B, val third: C, val fourth: D) + +fun String.toJsonString(): String { + val escaped = + this.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + return "\"$escaped\"" +} + +fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject + +fun JsonElement?.asStringOrNull(): String? = + when (this) { + is JsonNull -> null + is JsonPrimitive -> content + else -> null + } + +fun parseHexColorArgb(raw: String?): Long? { + val trimmed = raw?.trim().orEmpty() + if (trimmed.isEmpty()) return null + val hex = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed + if (hex.length != 6) return null + val rgb = hex.toLongOrNull(16) ?: return null + return 0xFF000000L or rgb +} + +fun invokeErrorFromThrowable(err: Throwable): Pair { + val raw = (err.message ?: "").trim() + if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: error" + + val idx = raw.indexOf(':') + if (idx <= 0) return "UNAVAILABLE" to raw + val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" } + val message = raw.substring(idx + 1).trim().ifEmpty { raw } + return code to "$code: $message" +} + +fun normalizeMainKey(raw: String?): String? { + val trimmed = raw?.trim().orEmpty() + return if (trimmed.isEmpty()) null else trimmed +} + +fun isCanonicalMainSessionKey(key: String): Boolean { + return key == "main" +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenHandler.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenHandler.kt new file mode 100644 index 00000000..c63d73f5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenHandler.kt @@ -0,0 +1,25 @@ +package ai.openclaw.android.node + +import ai.openclaw.android.gateway.GatewaySession + +class ScreenHandler( + private val screenRecorder: ScreenRecordManager, + private val setScreenRecordActive: (Boolean) -> Unit, + private val invokeErrorFromThrowable: (Throwable) -> Pair, +) { + suspend fun handleScreenRecord(paramsJson: String?): GatewaySession.InvokeResult { + setScreenRecordActive(true) + try { + val res = + try { + screenRecorder.record(paramsJson) + } catch (err: Throwable) { + val (code, message) = invokeErrorFromThrowable(err) + return GatewaySession.InvokeResult.error(code = code, message = message) + } + return GatewaySession.InvokeResult.ok(res.payloadJson) + } finally { + setScreenRecordActive(false) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenRecordManager.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenRecordManager.kt new file mode 100644 index 00000000..337a9538 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenRecordManager.kt @@ -0,0 +1,199 @@ +package ai.openclaw.android.node + +import android.content.Context +import android.hardware.display.DisplayManager +import android.media.MediaRecorder +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.util.Base64 +import ai.openclaw.android.ScreenCaptureRequester +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.math.roundToInt + +class ScreenRecordManager(private val context: Context) { + data class Payload(val payloadJson: String) + + @Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null + @Volatile private var permissionRequester: ai.openclaw.android.PermissionRequester? = null + + fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) { + screenCaptureRequester = requester + } + + fun attachPermissionRequester(requester: ai.openclaw.android.PermissionRequester) { + permissionRequester = requester + } + + suspend fun record(paramsJson: String?): Payload = + withContext(Dispatchers.Default) { + val requester = + screenCaptureRequester + ?: throw IllegalStateException( + "SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission", + ) + + val durationMs = (parseDurationMs(paramsJson) ?: 10_000).coerceIn(250, 60_000) + val fps = (parseFps(paramsJson) ?: 10.0).coerceIn(1.0, 60.0) + val fpsInt = fps.roundToInt().coerceIn(1, 60) + val screenIndex = parseScreenIndex(paramsJson) + val includeAudio = parseIncludeAudio(paramsJson) ?: true + val format = parseString(paramsJson, key = "format") + if (format != null && format.lowercase() != "mp4") { + throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4") + } + if (screenIndex != null && screenIndex != 0) { + throw IllegalArgumentException("INVALID_REQUEST: screenIndex must be 0 on Android") + } + + val capture = requester.requestCapture() + ?: throw IllegalStateException( + "SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission", + ) + + val mgr = + context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + val projection = mgr.getMediaProjection(capture.resultCode, capture.data) + ?: throw IllegalStateException("UNAVAILABLE: screen capture unavailable") + + val metrics = context.resources.displayMetrics + val width = metrics.widthPixels + val height = metrics.heightPixels + val densityDpi = metrics.densityDpi + + val file = File.createTempFile("openclaw-screen-", ".mp4") + if (includeAudio) ensureMicPermission() + + val recorder = createMediaRecorder() + var virtualDisplay: android.hardware.display.VirtualDisplay? = null + try { + if (includeAudio) { + recorder.setAudioSource(MediaRecorder.AudioSource.MIC) + } + recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE) + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264) + if (includeAudio) { + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + recorder.setAudioChannels(1) + recorder.setAudioSamplingRate(44_100) + recorder.setAudioEncodingBitRate(96_000) + } + recorder.setVideoSize(width, height) + recorder.setVideoFrameRate(fpsInt) + recorder.setVideoEncodingBitRate(estimateBitrate(width, height, fpsInt)) + recorder.setOutputFile(file.absolutePath) + recorder.prepare() + + val surface = recorder.surface + virtualDisplay = + projection.createVirtualDisplay( + "openclaw-screen", + width, + height, + densityDpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + surface, + null, + null, + ) + + recorder.start() + delay(durationMs.toLong()) + } finally { + try { + recorder.stop() + } catch (_: Throwable) { + // ignore + } + recorder.reset() + recorder.release() + virtualDisplay?.release() + projection.stop() + } + + val bytes = withContext(Dispatchers.IO) { file.readBytes() } + file.delete() + val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) + Payload( + """{"format":"mp4","base64":"$base64","durationMs":$durationMs,"fps":$fpsInt,"screenIndex":0,"hasAudio":$includeAudio}""", + ) + } + + private fun createMediaRecorder(): MediaRecorder = MediaRecorder(context) + + private suspend fun ensureMicPermission() { + val granted = + androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.RECORD_AUDIO, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (granted) return + + val requester = + permissionRequester + ?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") + val results = requester.requestIfMissing(listOf(android.Manifest.permission.RECORD_AUDIO)) + if (results[android.Manifest.permission.RECORD_AUDIO] != true) { + throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") + } + } + + private fun parseDurationMs(paramsJson: String?): Int? = + parseNumber(paramsJson, key = "durationMs")?.toIntOrNull() + + private fun parseFps(paramsJson: String?): Double? = + parseNumber(paramsJson, key = "fps")?.toDoubleOrNull() + + private fun parseScreenIndex(paramsJson: String?): Int? = + parseNumber(paramsJson, key = "screenIndex")?.toIntOrNull() + + private fun parseIncludeAudio(paramsJson: String?): Boolean? { + val raw = paramsJson ?: return null + val key = "\"includeAudio\"" + val idx = raw.indexOf(key) + if (idx < 0) return null + val colon = raw.indexOf(':', idx + key.length) + if (colon < 0) return null + val tail = raw.substring(colon + 1).trimStart() + return when { + tail.startsWith("true") -> true + tail.startsWith("false") -> false + else -> null + } + } + + private fun parseNumber(paramsJson: String?, key: String): String? { + val raw = paramsJson ?: return null + val needle = "\"$key\"" + val idx = raw.indexOf(needle) + if (idx < 0) return null + val colon = raw.indexOf(':', idx + needle.length) + if (colon < 0) return null + val tail = raw.substring(colon + 1).trimStart() + return tail.takeWhile { it.isDigit() || it == '.' || it == '-' } + } + + private fun parseString(paramsJson: String?, key: String): String? { + val raw = paramsJson ?: return null + val needle = "\"$key\"" + val idx = raw.indexOf(needle) + if (idx < 0) return null + val colon = raw.indexOf(':', idx + needle.length) + if (colon < 0) return null + val tail = raw.substring(colon + 1).trimStart() + if (!tail.startsWith('\"')) return null + val rest = tail.drop(1) + val end = rest.indexOf('\"') + if (end < 0) return null + return rest.substring(0, end) + } + + private fun estimateBitrate(width: Int, height: Int, fps: Int): Int { + val pixels = width.toLong() * height.toLong() + val raw = (pixels * fps.toLong() * 2L).toInt() + return raw.coerceIn(1_000_000, 12_000_000) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/SmsHandler.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/SmsHandler.kt new file mode 100644 index 00000000..30b77810 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/SmsHandler.kt @@ -0,0 +1,19 @@ +package ai.openclaw.android.node + +import ai.openclaw.android.gateway.GatewaySession + +class SmsHandler( + private val sms: SmsManager, +) { + suspend fun handleSmsSend(paramsJson: String?): GatewaySession.InvokeResult { + val res = sms.send(paramsJson) + if (res.ok) { + return GatewaySession.InvokeResult.ok(res.payloadJson) + } else { + val error = res.error ?: "SMS_SEND_FAILED" + val idx = error.indexOf(':') + val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED" + return GatewaySession.InvokeResult.error(code = code, message = error) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/SmsManager.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/SmsManager.kt new file mode 100644 index 00000000..d727bfd2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/node/SmsManager.kt @@ -0,0 +1,230 @@ +package ai.openclaw.android.node + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.telephony.SmsManager as AndroidSmsManager +import androidx.core.content.ContextCompat +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.encodeToString +import ai.openclaw.android.PermissionRequester + +/** + * Sends SMS messages via the Android SMS API. + * Requires SEND_SMS permission to be granted. + */ +class SmsManager(private val context: Context) { + + private val json = JsonConfig + @Volatile private var permissionRequester: PermissionRequester? = null + + data class SendResult( + val ok: Boolean, + val to: String, + val message: String?, + val error: String? = null, + val payloadJson: String, + ) + + internal data class ParsedParams( + val to: String, + val message: String, + ) + + internal sealed class ParseResult { + data class Ok(val params: ParsedParams) : ParseResult() + data class Error( + val error: String, + val to: String = "", + val message: String? = null, + ) : ParseResult() + } + + internal data class SendPlan( + val parts: List, + val useMultipart: Boolean, + ) + + companion object { + internal val JsonConfig = Json { ignoreUnknownKeys = true } + + internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult { + val params = paramsJson?.trim().orEmpty() + if (params.isEmpty()) { + return ParseResult.Error(error = "INVALID_REQUEST: paramsJSON required") + } + + val obj = try { + json.parseToJsonElement(params).jsonObject + } catch (_: Throwable) { + null + } + + if (obj == null) { + return ParseResult.Error(error = "INVALID_REQUEST: expected JSON object") + } + + val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty() + val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty() + + if (to.isEmpty()) { + return ParseResult.Error( + error = "INVALID_REQUEST: 'to' phone number required", + message = message, + ) + } + + if (message.isEmpty()) { + return ParseResult.Error( + error = "INVALID_REQUEST: 'message' text required", + to = to, + ) + } + + return ParseResult.Ok(ParsedParams(to = to, message = message)) + } + + internal fun buildSendPlan( + message: String, + divider: (String) -> List, + ): SendPlan { + val parts = divider(message).ifEmpty { listOf(message) } + return SendPlan(parts = parts, useMultipart = parts.size > 1) + } + + internal fun buildPayloadJson( + json: Json = JsonConfig, + ok: Boolean, + to: String, + error: String?, + ): String { + val payload = + mutableMapOf( + "ok" to JsonPrimitive(ok), + "to" to JsonPrimitive(to), + ) + if (!ok) { + payload["error"] = JsonPrimitive(error ?: "SMS_SEND_FAILED") + } + return json.encodeToString(JsonObject.serializer(), JsonObject(payload)) + } + } + + fun hasSmsPermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.SEND_SMS + ) == PackageManager.PERMISSION_GRANTED + } + + fun canSendSms(): Boolean { + return hasSmsPermission() && hasTelephonyFeature() + } + + fun hasTelephonyFeature(): Boolean { + return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + } + + fun attachPermissionRequester(requester: PermissionRequester) { + permissionRequester = requester + } + + /** + * Send an SMS message. + * + * @param paramsJson JSON with "to" (phone number) and "message" (text) fields + * @return SendResult indicating success or failure + */ + suspend fun send(paramsJson: String?): SendResult { + if (!hasTelephonyFeature()) { + return errorResult( + error = "SMS_UNAVAILABLE: telephony not available", + ) + } + + if (!ensureSmsPermission()) { + return errorResult( + error = "SMS_PERMISSION_REQUIRED: grant SMS permission", + ) + } + + val parseResult = parseParams(paramsJson, json) + if (parseResult is ParseResult.Error) { + return errorResult( + error = parseResult.error, + to = parseResult.to, + message = parseResult.message, + ) + } + val params = (parseResult as ParseResult.Ok).params + + return try { + val smsManager = context.getSystemService(AndroidSmsManager::class.java) + ?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available") + + val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) } + if (plan.useMultipart) { + smsManager.sendMultipartTextMessage( + params.to, // destination + null, // service center (null = default) + ArrayList(plan.parts), // message parts + null, // sent intents + null, // delivery intents + ) + } else { + smsManager.sendTextMessage( + params.to, // destination + null, // service center (null = default) + params.message,// message + null, // sent intent + null, // delivery intent + ) + } + + okResult(to = params.to, message = params.message) + } catch (e: SecurityException) { + errorResult( + error = "SMS_PERMISSION_REQUIRED: ${e.message}", + to = params.to, + message = params.message, + ) + } catch (e: Throwable) { + errorResult( + error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}", + to = params.to, + message = params.message, + ) + } + } + + private suspend fun ensureSmsPermission(): Boolean { + if (hasSmsPermission()) return true + val requester = permissionRequester ?: return false + val results = requester.requestIfMissing(listOf(Manifest.permission.SEND_SMS)) + return results[Manifest.permission.SEND_SMS] == true + } + + private fun okResult(to: String, message: String): SendResult { + return SendResult( + ok = true, + to = to, + message = message, + error = null, + payloadJson = buildPayloadJson(json = json, ok = true, to = to, error = null), + ) + } + + private fun errorResult(error: String, to: String = "", message: String? = null): SendResult { + return SendResult( + ok = false, + to = to, + message = message, + error = error, + payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error), + ) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIAction.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIAction.kt new file mode 100644 index 00000000..7e1a5bf1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIAction.kt @@ -0,0 +1,66 @@ +package ai.openclaw.android.protocol + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +object OpenClawCanvasA2UIAction { + fun extractActionName(userAction: JsonObject): String? { + val name = + (userAction["name"] as? JsonPrimitive) + ?.content + ?.trim() + .orEmpty() + if (name.isNotEmpty()) return name + val action = + (userAction["action"] as? JsonPrimitive) + ?.content + ?.trim() + .orEmpty() + return action.ifEmpty { null } + } + + fun sanitizeTagValue(value: String): String { + val trimmed = value.trim().ifEmpty { "-" } + val normalized = trimmed.replace(" ", "_") + val out = StringBuilder(normalized.length) + for (c in normalized) { + val ok = + c.isLetterOrDigit() || + c == '_' || + c == '-' || + c == '.' || + c == ':' + out.append(if (ok) c else '_') + } + return out.toString() + } + + fun formatAgentMessage( + actionName: String, + sessionKey: String, + surfaceId: String, + sourceComponentId: String, + host: String, + instanceId: String, + contextJson: String?, + ): String { + val ctxSuffix = contextJson?.takeIf { it.isNotBlank() }?.let { " ctx=$it" }.orEmpty() + return listOf( + "CANVAS_A2UI", + "action=${sanitizeTagValue(actionName)}", + "session=${sanitizeTagValue(sessionKey)}", + "surface=${sanitizeTagValue(surfaceId)}", + "component=${sanitizeTagValue(sourceComponentId)}", + "host=${sanitizeTagValue(host)}", + "instance=${sanitizeTagValue(instanceId)}$ctxSuffix", + "default=update_canvas", + ).joinToString(separator = " ") + } + + fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String { + val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"") + val okLiteral = if (ok) "true" else "false" + val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"") + return "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt new file mode 100644 index 00000000..ccca40c4 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt @@ -0,0 +1,71 @@ +package ai.openclaw.android.protocol + +enum class OpenClawCapability(val rawValue: String) { + Canvas("canvas"), + Camera("camera"), + Screen("screen"), + Sms("sms"), + VoiceWake("voiceWake"), + Location("location"), +} + +enum class OpenClawCanvasCommand(val rawValue: String) { + Present("canvas.present"), + Hide("canvas.hide"), + Navigate("canvas.navigate"), + Eval("canvas.eval"), + Snapshot("canvas.snapshot"), + ; + + companion object { + const val NamespacePrefix: String = "canvas." + } +} + +enum class OpenClawCanvasA2UICommand(val rawValue: String) { + Push("canvas.a2ui.push"), + PushJSONL("canvas.a2ui.pushJSONL"), + Reset("canvas.a2ui.reset"), + ; + + companion object { + const val NamespacePrefix: String = "canvas.a2ui." + } +} + +enum class OpenClawCameraCommand(val rawValue: String) { + Snap("camera.snap"), + Clip("camera.clip"), + ; + + companion object { + const val NamespacePrefix: String = "camera." + } +} + +enum class OpenClawScreenCommand(val rawValue: String) { + Record("screen.record"), + ; + + companion object { + const val NamespacePrefix: String = "screen." + } +} + +enum class OpenClawSmsCommand(val rawValue: String) { + Send("sms.send"), + ; + + companion object { + const val NamespacePrefix: String = "sms." + } +} + +enum class OpenClawLocationCommand(val rawValue: String) { + Get("location.get"), + ; + + companion object { + const val NamespacePrefix: String = "location." + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/tools/ToolDisplay.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/tools/ToolDisplay.kt new file mode 100644 index 00000000..1c556176 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/tools/ToolDisplay.kt @@ -0,0 +1,222 @@ +package ai.openclaw.android.tools + +import android.content.Context +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +@Serializable +private data class ToolDisplayActionSpec( + val label: String? = null, + val detailKeys: List? = null, +) + +@Serializable +private data class ToolDisplaySpec( + val emoji: String? = null, + val title: String? = null, + val label: String? = null, + val detailKeys: List? = null, + val actions: Map? = null, +) + +@Serializable +private data class ToolDisplayConfig( + val version: Int? = null, + val fallback: ToolDisplaySpec? = null, + val tools: Map? = null, +) + +data class ToolDisplaySummary( + val name: String, + val emoji: String, + val title: String, + val label: String, + val verb: String?, + val detail: String?, +) { + val detailLine: String? + get() { + val parts = mutableListOf() + if (!verb.isNullOrBlank()) parts.add(verb) + if (!detail.isNullOrBlank()) parts.add(detail) + return if (parts.isEmpty()) null else parts.joinToString(" · ") + } + + val summaryLine: String + get() = if (detailLine != null) "${emoji} ${label}: ${detailLine}" else "${emoji} ${label}" +} + +object ToolDisplayRegistry { + private const val CONFIG_ASSET = "tool-display.json" + + private val json = Json { ignoreUnknownKeys = true } + @Volatile private var cachedConfig: ToolDisplayConfig? = null + + fun resolve( + context: Context, + name: String?, + args: JsonObject?, + meta: String? = null, + ): ToolDisplaySummary { + val trimmedName = name?.trim().orEmpty().ifEmpty { "tool" } + val key = trimmedName.lowercase() + val config = loadConfig(context) + val spec = config.tools?.get(key) + val fallback = config.fallback + + val emoji = spec?.emoji ?: fallback?.emoji ?: "🧩" + val title = spec?.title ?: titleFromName(trimmedName) + val label = spec?.label ?: trimmedName + + val actionRaw = args?.get("action")?.asStringOrNull()?.trim() + val action = actionRaw?.takeIf { it.isNotEmpty() } + val actionSpec = action?.let { spec?.actions?.get(it) } + val verb = normalizeVerb(actionSpec?.label ?: action) + + var detail: String? = null + if (key == "read") { + detail = readDetail(args) + } else if (key == "write" || key == "edit" || key == "attach") { + detail = pathDetail(args) + } + + val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList() + if (detail == null) { + detail = firstValue(args, detailKeys) + } + + if (detail == null) { + detail = meta + } + + if (detail != null) { + detail = shortenHomeInString(detail) + } + + return ToolDisplaySummary( + name = trimmedName, + emoji = emoji, + title = title, + label = label, + verb = verb, + detail = detail, + ) + } + + private fun loadConfig(context: Context): ToolDisplayConfig { + val existing = cachedConfig + if (existing != null) return existing + return try { + val jsonString = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() } + val decoded = json.decodeFromString(ToolDisplayConfig.serializer(), jsonString) + cachedConfig = decoded + decoded + } catch (_: Throwable) { + val fallback = ToolDisplayConfig() + cachedConfig = fallback + fallback + } + } + + private fun titleFromName(name: String): String { + val cleaned = name.replace("_", " ").trim() + if (cleaned.isEmpty()) return "Tool" + return cleaned + .split(Regex("\\s+")) + .joinToString(" ") { part -> + val upper = part.uppercase() + if (part.length <= 2 && part == upper) part + else upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1) + } + } + + private fun normalizeVerb(value: String?): String? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isEmpty()) return null + return trimmed.replace("_", " ") + } + + private fun readDetail(args: JsonObject?): String? { + val path = args?.get("path")?.asStringOrNull() ?: return null + val offset = args["offset"].asNumberOrNull() + val limit = args["limit"].asNumberOrNull() + return if (offset != null && limit != null) { + val end = offset + limit + "${path}:${offset.toInt()}-${end.toInt()}" + } else { + path + } + } + + private fun pathDetail(args: JsonObject?): String? { + return args?.get("path")?.asStringOrNull() + } + + private fun firstValue(args: JsonObject?, keys: List): String? { + for (key in keys) { + val value = valueForPath(args, key) + val rendered = renderValue(value) + if (!rendered.isNullOrBlank()) return rendered + } + return null + } + + private fun valueForPath(args: JsonObject?, path: String): JsonElement? { + var current: JsonElement? = args + for (segment in path.split(".")) { + if (segment.isBlank()) return null + val obj = current as? JsonObject ?: return null + current = obj[segment] + } + return current + } + + private fun renderValue(value: JsonElement?): String? { + if (value == null) return null + if (value is JsonPrimitive) { + if (value.isString) { + val trimmed = value.contentOrNull?.trim().orEmpty() + if (trimmed.isEmpty()) return null + val firstLine = trimmed.lineSequence().firstOrNull()?.trim().orEmpty() + if (firstLine.isEmpty()) return null + return if (firstLine.length > 160) "${firstLine.take(157)}…" else firstLine + } + val raw = value.contentOrNull?.trim().orEmpty() + raw.toBooleanStrictOrNull()?.let { return it.toString() } + raw.toLongOrNull()?.let { return it.toString() } + raw.toDoubleOrNull()?.let { return it.toString() } + } + if (value is JsonArray) { + val items = value.mapNotNull { renderValue(it) } + if (items.isEmpty()) return null + val preview = items.take(3).joinToString(", ") + return if (items.size > 3) "${preview}…" else preview + } + return null + } + + private fun shortenHomeInString(value: String): String { + val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() } + ?: System.getenv("HOME")?.takeIf { it.isNotBlank() } + if (home.isNullOrEmpty()) return value + return value.replace(home, "~") + .replace(Regex("/Users/[^/]+"), "~") + .replace(Regex("/home/[^/]+"), "~") + } + + private fun JsonElement?.asStringOrNull(): String? { + val primitive = this as? JsonPrimitive ?: return null + return if (primitive.isString) primitive.contentOrNull else primitive.toString() + } + + private fun JsonElement?.asNumberOrNull(): Double? { + val primitive = this as? JsonPrimitive ?: return null + val raw = primitive.contentOrNull ?: return null + return raw.toDoubleOrNull() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/CameraHudOverlay.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/CameraHudOverlay.kt new file mode 100644 index 00000000..21043d73 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/CameraHudOverlay.kt @@ -0,0 +1,44 @@ +package ai.openclaw.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.delay + +@Composable +fun CameraFlashOverlay( + token: Long, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + CameraFlash(token = token) + } +} + +@Composable +private fun CameraFlash(token: Long) { + var alpha by remember { mutableFloatStateOf(0f) } + LaunchedEffect(token) { + if (token == 0L) return@LaunchedEffect + alpha = 0.85f + delay(110) + alpha = 0f + } + + Box( + modifier = + Modifier + .fillMaxSize() + .alpha(alpha) + .background(Color.White), + ) +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt new file mode 100644 index 00000000..f733d154 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/CanvasScreen.kt @@ -0,0 +1,150 @@ +package ai.openclaw.android.ui + +import android.annotation.SuppressLint +import android.util.Log +import android.view.View +import android.webkit.ConsoleMessage +import android.webkit.JavascriptInterface +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature +import ai.openclaw.android.MainViewModel + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) { + val context = LocalContext.current + val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 + val webViewRef = remember { mutableStateOf(null) } + + DisposableEffect(viewModel) { + onDispose { + val webView = webViewRef.value ?: return@onDispose + viewModel.canvas.detach(webView) + webView.removeJavascriptInterface(CanvasA2UIActionBridge.interfaceName) + webView.stopLoading() + webView.destroy() + webViewRef.value = null + } + } + + AndroidView( + modifier = modifier, + factory = { + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + settings.useWideViewPort = false + settings.loadWithOverviewMode = false + settings.builtInZoomControls = false + settings.displayZoomControls = false + settings.setSupportZoom(false) + if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) + } else { + disableForceDarkIfSupported(settings) + } + if (isDebuggable) { + Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}") + } + isScrollContainer = true + overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS + isVerticalScrollBarEnabled = true + isHorizontalScrollBarEnabled = true + webViewClient = + object : WebViewClient() { + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + if (!isDebuggable || !request.isForMainFrame) return + Log.e("OpenClawWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}") + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + if (!isDebuggable || !request.isForMainFrame) return + Log.e( + "OpenClawWebView", + "onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}", + ) + } + + override fun onPageFinished(view: WebView, url: String?) { + if (isDebuggable) { + Log.d("OpenClawWebView", "onPageFinished: $url") + } + viewModel.canvas.onPageFinished() + } + + override fun onRenderProcessGone( + view: WebView, + detail: android.webkit.RenderProcessGoneDetail, + ): Boolean { + if (isDebuggable) { + Log.e( + "OpenClawWebView", + "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", + ) + } + return true + } + } + webChromeClient = + object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + if (!isDebuggable) return false + val msg = consoleMessage ?: return false + Log.d( + "OpenClawWebView", + "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", + ) + return false + } + } + + val bridge = CanvasA2UIActionBridge { payload -> viewModel.handleCanvasA2UIActionFromWebView(payload) } + addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName) + viewModel.canvas.attach(this) + webViewRef.value = this + } + }, + ) +} + +private fun disableForceDarkIfSupported(settings: WebSettings) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return + @Suppress("DEPRECATION") + WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) +} + +private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) { + @JavascriptInterface + fun postMessage(payload: String?) { + val msg = payload?.trim().orEmpty() + if (msg.isEmpty()) return + onMessage(msg) + } + + companion object { + const val interfaceName: String = "openclawCanvasA2UIAction" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/ChatSheet.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/ChatSheet.kt new file mode 100644 index 00000000..85f20364 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/ChatSheet.kt @@ -0,0 +1,10 @@ +package ai.openclaw.android.ui + +import androidx.compose.runtime.Composable +import ai.openclaw.android.MainViewModel +import ai.openclaw.android.ui.chat.ChatSheetContent + +@Composable +fun ChatSheet(viewModel: MainViewModel) { + ChatSheetContent(viewModel = viewModel) +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt new file mode 100644 index 00000000..9f7cf221 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/ConnectTabScreen.kt @@ -0,0 +1,497 @@ +package ai.openclaw.android.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import ai.openclaw.android.MainViewModel + +private enum class ConnectInputMode { + SetupCode, + Manual, +} + +@Composable +fun ConnectTabScreen(viewModel: MainViewModel) { + val statusText by viewModel.statusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val remoteAddress by viewModel.remoteAddress.collectAsState() + val manualHost by viewModel.manualHost.collectAsState() + val manualPort by viewModel.manualPort.collectAsState() + val manualTls by viewModel.manualTls.collectAsState() + val manualEnabled by viewModel.manualEnabled.collectAsState() + val gatewayToken by viewModel.gatewayToken.collectAsState() + val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() + + var advancedOpen by rememberSaveable { mutableStateOf(false) } + var inputMode by + remember(manualEnabled, manualHost, gatewayToken) { + mutableStateOf( + if (manualEnabled || manualHost.isNotBlank() || gatewayToken.trim().isNotEmpty()) { + ConnectInputMode.Manual + } else { + ConnectInputMode.SetupCode + }, + ) + } + var setupCode by rememberSaveable { mutableStateOf("") } + var manualHostInput by rememberSaveable { mutableStateOf(manualHost.ifBlank { "10.0.2.2" }) } + var manualPortInput by rememberSaveable { mutableStateOf(manualPort.toString()) } + var manualTlsInput by rememberSaveable { mutableStateOf(manualTls) } + var passwordInput by rememberSaveable { mutableStateOf("") } + var validationText by rememberSaveable { mutableStateOf(null) } + + if (pendingTrust != null) { + val prompt = pendingTrust!! + AlertDialog( + onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, + title = { Text("Trust this gateway?") }, + text = { + Text( + "First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}", + style = mobileCallout, + ) + }, + confirmButton = { + TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { + Text("Trust and continue") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { + Text("Cancel") + } + }, + ) + } + + val setupResolvedEndpoint = remember(setupCode) { decodeGatewaySetupCode(setupCode)?.url?.let { parseGatewayEndpoint(it)?.displayUrl } } + val manualResolvedEndpoint = remember(manualHostInput, manualPortInput, manualTlsInput) { + composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput)?.let { parseGatewayEndpoint(it)?.displayUrl } + } + + val activeEndpoint = + remember(isConnected, remoteAddress, setupResolvedEndpoint, manualResolvedEndpoint, inputMode) { + when { + isConnected && !remoteAddress.isNullOrBlank() -> remoteAddress!! + inputMode == ConnectInputMode.SetupCode -> setupResolvedEndpoint ?: "Not set" + else -> manualResolvedEndpoint ?: "Not set" + } + } + + val primaryLabel = if (isConnected) "Disconnect Gateway" else "Connect Gateway" + + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Connection Control", style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent) + Text("Gateway Connection", style = mobileTitle1, color = mobileText) + Text( + "One primary action. Open advanced controls only when needed.", + style = mobileCallout, + color = mobileTextSecondary, + ) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Active endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText) + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Gateway state", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(statusText, style = mobileBody, color = mobileText) + } + } + + Button( + onClick = { + if (isConnected) { + viewModel.disconnect() + validationText = null + return@Button + } + + val config = + resolveGatewayConnectConfig( + useSetupCode = inputMode == ConnectInputMode.SetupCode, + setupCode = setupCode, + manualHost = manualHostInput, + manualPort = manualPortInput, + manualTls = manualTlsInput, + fallbackToken = gatewayToken, + fallbackPassword = passwordInput, + ) + + if (config == null) { + validationText = + if (inputMode == ConnectInputMode.SetupCode) { + "Paste a valid setup code to connect." + } else { + "Enter a valid manual host and port to connect." + } + return@Button + } + + validationText = null + viewModel.setManualEnabled(true) + viewModel.setManualHost(config.host) + viewModel.setManualPort(config.port) + viewModel.setManualTls(config.tls) + if (config.token.isNotBlank()) { + viewModel.setGatewayToken(config.token) + } + viewModel.setGatewayPassword(config.password) + viewModel.connectManual() + }, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (isConnected) mobileDanger else mobileAccent, + contentColor = Color.White, + ), + ) { + Text(primaryLabel, style = mobileHeadline.copy(fontWeight = FontWeight.Bold)) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = mobileSurface, + border = BorderStroke(1.dp, mobileBorder), + onClick = { advancedOpen = !advancedOpen }, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Advanced controls", style = mobileHeadline, color = mobileText) + Text("Setup code, endpoint, TLS, token, password, onboarding.", style = mobileCaption1, color = mobileTextSecondary) + } + Icon( + imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (advancedOpen) "Collapse advanced controls" else "Expand advanced controls", + tint = mobileTextSecondary, + ) + } + } + + AnimatedVisibility(visible = advancedOpen) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = Color.White, + border = BorderStroke(1.dp, mobileBorder), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Connection method", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MethodChip( + label = "Setup Code", + active = inputMode == ConnectInputMode.SetupCode, + onClick = { inputMode = ConnectInputMode.SetupCode }, + ) + MethodChip( + label = "Manual", + active = inputMode == ConnectInputMode.Manual, + onClick = { inputMode = ConnectInputMode.Manual }, + ) + } + + Text("Run these on the gateway host:", style = mobileCallout, color = mobileTextSecondary) + CommandBlock("openclaw qr --setup-code-only") + CommandBlock("openclaw qr --json") + + if (inputMode == ConnectInputMode.SetupCode) { + Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = setupCode, + onValueChange = { + setupCode = it + validationText = null + }, + placeholder = { Text("Paste setup code", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 5, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + if (!setupResolvedEndpoint.isNullOrBlank()) { + EndpointPreview(endpoint = setupResolvedEndpoint) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + QuickFillChip( + label = "Android Emulator", + onClick = { + manualHostInput = "10.0.2.2" + manualPortInput = "18789" + manualTlsInput = false + validationText = null + }, + ) + QuickFillChip( + label = "Localhost", + onClick = { + manualHostInput = "127.0.0.1" + manualPortInput = "18789" + manualTlsInput = false + validationText = null + }, + ) + } + + Text("Host", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = manualHostInput, + onValueChange = { + manualHostInput = it + validationText = null + }, + placeholder = { Text("10.0.2.2", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + textStyle = mobileBody.copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + Text("Port", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = manualPortInput, + onValueChange = { + manualPortInput = it + validationText = null + }, + placeholder = { Text("18789", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = mobileBody.copy(fontFamily = FontFamily.Monospace, color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Use TLS", style = mobileHeadline, color = mobileText) + Text("Switch to secure websocket (`wss`).", style = mobileCallout, color = mobileTextSecondary) + } + Switch( + checked = manualTlsInput, + onCheckedChange = { + manualTlsInput = it + validationText = null + }, + colors = + SwitchDefaults.colors( + checkedTrackColor = mobileAccent, + uncheckedTrackColor = mobileBorderStrong, + checkedThumbColor = Color.White, + uncheckedThumbColor = Color.White, + ), + ) + } + + Text("Token (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = gatewayToken, + onValueChange = { viewModel.setGatewayToken(it) }, + placeholder = { Text("token", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = mobileBody.copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + Text("Password (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + OutlinedTextField( + value = passwordInput, + onValueChange = { passwordInput = it }, + placeholder = { Text("password", style = mobileBody, color = mobileTextTertiary) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = mobileBody.copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = outlinedColors(), + ) + + if (!manualResolvedEndpoint.isNullOrBlank()) { + EndpointPreview(endpoint = manualResolvedEndpoint) + } + } + + HorizontalDivider(color = mobileBorder) + + Text( + "Debug snapshot: mode=${if (inputMode == ConnectInputMode.SetupCode) "setup" else "manual"}, manualEnabled=$manualEnabled, tokenLen=${gatewayToken.trim().length}", + style = mobileCaption1, + color = mobileTextSecondary, + ) + TextButton(onClick = { viewModel.logGatewayDebugSnapshot(source = "connect_tab") }) { + Text("Log gateway debug snapshot", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent) + } + + TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) { + Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent) + } + } + } + } + + if (!validationText.isNullOrBlank()) { + Text(validationText!!, style = mobileCaption1, color = mobileWarning) + } + } +} + +@Composable +private fun MethodChip(label: String, active: Boolean, onClick: () -> Unit) { + Button( + onClick = onClick, + modifier = Modifier.height(40.dp), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (active) mobileAccent else mobileSurface, + contentColor = if (active) Color.White else mobileText, + ), + border = BorderStroke(1.dp, if (active) Color(0xFF184DAF) else mobileBorderStrong), + ) { + Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold)) + } +} + +@Composable +private fun QuickFillChip(label: String, onClick: () -> Unit) { + Button( + onClick = onClick, + shape = RoundedCornerShape(999.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = mobileAccentSoft, + contentColor = mobileAccent, + ), + elevation = null, + ) { + Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold)) + } +} + +@Composable +private fun CommandBlock(command: String) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = mobileCodeBg, + border = BorderStroke(1.dp, Color(0xFF2B2E35)), + ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.width(3.dp).height(42.dp).background(Color(0xFF3FC97A))) + Text( + text = command, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = mobileCallout.copy(fontFamily = FontFamily.Monospace), + color = mobileCodeText, + ) + } + } +} + +@Composable +private fun EndpointPreview(endpoint: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HorizontalDivider(color = mobileBorder) + Text("Resolved endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + Text(endpoint, style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileText) + HorizontalDivider(color = mobileBorder) + } +} + +@Composable +private fun outlinedColors() = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = mobileSurface, + unfocusedContainerColor = mobileSurface, + focusedBorderColor = mobileAccent, + unfocusedBorderColor = mobileBorder, + focusedTextColor = mobileText, + unfocusedTextColor = mobileText, + cursorColor = mobileAccent, + ) diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt new file mode 100644 index 00000000..5036c629 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/GatewayConfigResolver.kt @@ -0,0 +1,115 @@ +package ai.openclaw.android.ui + +import android.util.Base64 +import androidx.core.net.toUri +import java.util.Locale +import org.json.JSONObject + +internal data class GatewayEndpointConfig( + val host: String, + val port: Int, + val tls: Boolean, + val displayUrl: String, +) + +internal data class GatewaySetupCode( + val url: String, + val token: String?, + val password: String?, +) + +internal data class GatewayConnectConfig( + val host: String, + val port: Int, + val tls: Boolean, + val token: String, + val password: String, +) + +internal fun resolveGatewayConnectConfig( + useSetupCode: Boolean, + setupCode: String, + manualHost: String, + manualPort: String, + manualTls: Boolean, + fallbackToken: String, + fallbackPassword: String, +): GatewayConnectConfig? { + if (useSetupCode) { + val setup = decodeGatewaySetupCode(setupCode) ?: return null + val parsed = parseGatewayEndpoint(setup.url) ?: return null + return GatewayConnectConfig( + host = parsed.host, + port = parsed.port, + tls = parsed.tls, + token = setup.token ?: fallbackToken.trim(), + password = setup.password ?: fallbackPassword.trim(), + ) + } + + val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) ?: return null + val parsed = parseGatewayEndpoint(manualUrl) ?: return null + return GatewayConnectConfig( + host = parsed.host, + port = parsed.port, + tls = parsed.tls, + token = fallbackToken.trim(), + password = fallbackPassword.trim(), + ) +} + +internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { + val raw = rawInput.trim() + if (raw.isEmpty()) return null + + val normalized = if (raw.contains("://")) raw else "https://$raw" + val uri = normalized.toUri() + val host = uri.host?.trim().orEmpty() + if (host.isEmpty()) return null + + val scheme = uri.scheme?.trim()?.lowercase(Locale.US).orEmpty() + val tls = + when (scheme) { + "ws", "http" -> false + "wss", "https" -> true + else -> true + } + val port = uri.port.takeIf { it in 1..65535 } ?: 18789 + val displayUrl = "${if (tls) "https" else "http"}://$host:$port" + + return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl) +} + +internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? { + val trimmed = rawInput.trim() + if (trimmed.isEmpty()) return null + + val padded = + trimmed + .replace('-', '+') + .replace('_', '/') + .let { normalized -> + val remainder = normalized.length % 4 + if (remainder == 0) normalized else normalized + "=".repeat(4 - remainder) + } + + return try { + val decoded = String(Base64.decode(padded, Base64.DEFAULT), Charsets.UTF_8) + val obj = JSONObject(decoded) + val url = obj.optString("url").trim() + if (url.isEmpty()) return null + val token = obj.optString("token").trim().ifEmpty { null } + val password = obj.optString("password").trim().ifEmpty { null } + GatewaySetupCode(url = url, token = token, password = password) + } catch (_: Throwable) { + null + } +} + +internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? { + val host = hostInput.trim() + val port = portInput.trim().toIntOrNull() ?: return null + if (host.isEmpty() || port !in 1..65535) return null + val scheme = if (tls) "https" else "http" + return "$scheme://$host:$port" +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt new file mode 100644 index 00000000..eb4f9577 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/MobileUiTokens.kt @@ -0,0 +1,106 @@ +package ai.openclaw.android.ui + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import ai.openclaw.android.R + +internal val mobileBackgroundGradient = + Brush.verticalGradient( + listOf( + Color(0xFFFFFFFF), + Color(0xFFF7F8FA), + Color(0xFFEFF1F5), + ), + ) + +internal val mobileSurface = Color(0xFFF6F7FA) +internal val mobileSurfaceStrong = Color(0xFFECEEF3) +internal val mobileBorder = Color(0xFFE5E7EC) +internal val mobileBorderStrong = Color(0xFFD6DAE2) +internal val mobileText = Color(0xFF17181C) +internal val mobileTextSecondary = Color(0xFF5D6472) +internal val mobileTextTertiary = Color(0xFF99A0AE) +internal val mobileAccent = Color(0xFF1D5DD8) +internal val mobileAccentSoft = Color(0xFFECF3FF) +internal val mobileSuccess = Color(0xFF2F8C5A) +internal val mobileSuccessSoft = Color(0xFFEEF9F3) +internal val mobileWarning = Color(0xFFC8841A) +internal val mobileWarningSoft = Color(0xFFFFF8EC) +internal val mobileDanger = Color(0xFFD04B4B) +internal val mobileDangerSoft = Color(0xFFFFF2F2) +internal val mobileCodeBg = Color(0xFF15171B) +internal val mobileCodeText = Color(0xFFE8EAEE) + +internal val mobileFontFamily = + FontFamily( + Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal), + Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium), + Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold), + Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold), + ) + +internal val mobileTitle1 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 30.sp, + letterSpacing = (-0.5).sp, + ) + +internal val mobileTitle2 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 26.sp, + letterSpacing = (-0.3).sp, + ) + +internal val mobileHeadline = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = (-0.1).sp, + ) + +internal val mobileBody = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 22.sp, + ) + +internal val mobileCallout = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + ) + +internal val mobileCaption1 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.2.sp, + ) + +internal val mobileCaption2 = + TextStyle( + fontFamily = mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 14.sp, + letterSpacing = 0.4.sp, + ) diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt new file mode 100644 index 00000000..8c732d9c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt @@ -0,0 +1,1120 @@ +package ai.openclaw.android.ui + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import ai.openclaw.android.LocationMode +import ai.openclaw.android.MainViewModel +import ai.openclaw.android.R + +private enum class OnboardingStep(val index: Int, val label: String) { + Welcome(1, "Welcome"), + Gateway(2, "Gateway"), + Permissions(3, "Permissions"), + FinalCheck(4, "Connect"), +} + +private enum class GatewayInputMode { + SetupCode, + Manual, +} + +private val onboardingBackgroundGradient = + listOf( + Color(0xFFFFFFFF), + Color(0xFFF7F8FA), + Color(0xFFEFF1F5), + ) +private val onboardingSurface = Color(0xFFF6F7FA) +private val onboardingBorder = Color(0xFFE5E7EC) +private val onboardingBorderStrong = Color(0xFFD6DAE2) +private val onboardingText = Color(0xFF17181C) +private val onboardingTextSecondary = Color(0xFF4D5563) +private val onboardingTextTertiary = Color(0xFF8A92A2) +private val onboardingAccent = Color(0xFF1D5DD8) +private val onboardingAccentSoft = Color(0xFFECF3FF) +private val onboardingSuccess = Color(0xFF2F8C5A) +private val onboardingWarning = Color(0xFFC8841A) +private val onboardingCommandBg = Color(0xFF15171B) +private val onboardingCommandBorder = Color(0xFF2B2E35) +private val onboardingCommandAccent = Color(0xFF3FC97A) +private val onboardingCommandText = Color(0xFFE8EAEE) + +private val onboardingFontFamily = + FontFamily( + Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal), + Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium), + Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold), + Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold), + ) + +private val onboardingDisplayStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + lineHeight = 40.sp, + letterSpacing = (-0.8).sp, + ) + +private val onboardingTitle1Style = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 30.sp, + letterSpacing = (-0.5).sp, + ) + +private val onboardingHeadlineStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + letterSpacing = (-0.1).sp, + ) + +private val onboardingBodyStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 22.sp, + ) + +private val onboardingCalloutStyle = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + ) + +private val onboardingCaption1Style = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.2.sp, + ) + +private val onboardingCaption2Style = + TextStyle( + fontFamily = onboardingFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 14.sp, + letterSpacing = 0.4.sp, + ) + +@Composable +fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { + val context = androidx.compose.ui.platform.LocalContext.current + val statusText by viewModel.statusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val serverName by viewModel.serverName.collectAsState() + val remoteAddress by viewModel.remoteAddress.collectAsState() + val persistedGatewayToken by viewModel.gatewayToken.collectAsState() + val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() + + var step by rememberSaveable { mutableStateOf(OnboardingStep.Welcome) } + var setupCode by rememberSaveable { mutableStateOf("") } + var gatewayUrl by rememberSaveable { mutableStateOf("") } + var gatewayPassword by rememberSaveable { mutableStateOf("") } + var gatewayInputMode by rememberSaveable { mutableStateOf(GatewayInputMode.SetupCode) } + var manualHost by rememberSaveable { mutableStateOf("10.0.2.2") } + var manualPort by rememberSaveable { mutableStateOf("18789") } + var manualTls by rememberSaveable { mutableStateOf(false) } + var gatewayError by rememberSaveable { mutableStateOf(null) } + var attemptedConnect by rememberSaveable { mutableStateOf(false) } + + var enableDiscovery by rememberSaveable { mutableStateOf(true) } + var enableNotifications by rememberSaveable { mutableStateOf(true) } + var enableMicrophone by rememberSaveable { mutableStateOf(false) } + var enableCamera by rememberSaveable { mutableStateOf(false) } + var enableSms by rememberSaveable { mutableStateOf(false) } + + val smsAvailable = + remember(context) { + context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + } + + val selectedPermissions = + remember( + context, + enableDiscovery, + enableNotifications, + enableMicrophone, + enableCamera, + enableSms, + smsAvailable, + ) { + val requested = mutableListOf() + if (enableDiscovery) { + requested += if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION + } + if (enableNotifications && Build.VERSION.SDK_INT >= 33) requested += Manifest.permission.POST_NOTIFICATIONS + if (enableMicrophone) requested += Manifest.permission.RECORD_AUDIO + if (enableCamera) requested += Manifest.permission.CAMERA + if (enableSms && smsAvailable) requested += Manifest.permission.SEND_SMS + requested.filterNot { isPermissionGranted(context, it) } + } + + val enabledPermissionSummary = + remember(enableDiscovery, enableNotifications, enableMicrophone, enableCamera, enableSms, smsAvailable) { + val enabled = mutableListOf() + if (enableDiscovery) enabled += "Gateway discovery" + if (Build.VERSION.SDK_INT >= 33 && enableNotifications) enabled += "Notifications" + if (enableMicrophone) enabled += "Microphone" + if (enableCamera) enabled += "Camera" + if (smsAvailable && enableSms) enabled += "SMS" + if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ") + } + + val permissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + step = OnboardingStep.FinalCheck + } + + if (pendingTrust != null) { + val prompt = pendingTrust!! + AlertDialog( + onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, + title = { Text("Trust this gateway?") }, + text = { + Text( + "First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}", + ) + }, + confirmButton = { + TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { + Text("Trust and continue") + } + }, + dismissButton = { + TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { + Text("Cancel") + } + }, + ) + } + + Box( + modifier = + modifier + .fillMaxSize() + .background(Brush.verticalGradient(onboardingBackgroundGradient)), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .imePadding() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)) + .navigationBarsPadding() + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Column( + modifier = Modifier.padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "FIRST RUN", + style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp), + color = onboardingAccent, + ) + Text( + "OpenClaw\nMobile Setup", + style = onboardingDisplayStyle.copy(lineHeight = 38.sp), + color = onboardingText, + ) + Text( + "Step ${step.index} of 4", + style = onboardingCaption1Style, + color = onboardingAccent, + ) + } + StepRailWrap(current = step) + + when (step) { + OnboardingStep.Welcome -> WelcomeStep() + OnboardingStep.Gateway -> + GatewayStep( + inputMode = gatewayInputMode, + setupCode = setupCode, + manualHost = manualHost, + manualPort = manualPort, + manualTls = manualTls, + gatewayToken = persistedGatewayToken, + gatewayPassword = gatewayPassword, + gatewayError = gatewayError, + onInputModeChange = { + gatewayInputMode = it + gatewayError = null + }, + onSetupCodeChange = { + setupCode = it + gatewayError = null + }, + onManualHostChange = { + manualHost = it + gatewayError = null + }, + onManualPortChange = { + manualPort = it + gatewayError = null + }, + onManualTlsChange = { manualTls = it }, + onTokenChange = viewModel::setGatewayToken, + onPasswordChange = { gatewayPassword = it }, + ) + OnboardingStep.Permissions -> + PermissionsStep( + enableDiscovery = enableDiscovery, + enableNotifications = enableNotifications, + enableMicrophone = enableMicrophone, + enableCamera = enableCamera, + enableSms = enableSms, + smsAvailable = smsAvailable, + context = context, + onDiscoveryChange = { enableDiscovery = it }, + onNotificationsChange = { enableNotifications = it }, + onMicrophoneChange = { enableMicrophone = it }, + onCameraChange = { enableCamera = it }, + onSmsChange = { enableSms = it }, + ) + OnboardingStep.FinalCheck -> + FinalStep( + parsedGateway = parseGatewayEndpoint(gatewayUrl), + statusText = statusText, + isConnected = isConnected, + serverName = serverName, + remoteAddress = remoteAddress, + attemptedConnect = attemptedConnect, + enabledPermissions = enabledPermissionSummary, + methodLabel = if (gatewayInputMode == GatewayInputMode.SetupCode) "Setup Code" else "Manual", + ) + } + } + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val backEnabled = step != OnboardingStep.Welcome + Surface( + modifier = Modifier.size(52.dp), + shape = RoundedCornerShape(14.dp), + color = onboardingSurface, + border = androidx.compose.foundation.BorderStroke(1.dp, if (backEnabled) onboardingBorderStrong else onboardingBorder), + ) { + IconButton( + onClick = { + step = + when (step) { + OnboardingStep.Welcome -> OnboardingStep.Welcome + OnboardingStep.Gateway -> OnboardingStep.Welcome + OnboardingStep.Permissions -> OnboardingStep.Gateway + OnboardingStep.FinalCheck -> OnboardingStep.Permissions + } + }, + enabled = backEnabled, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = if (backEnabled) onboardingTextSecondary else onboardingTextTertiary, + ) + } + } + + when (step) { + OnboardingStep.Welcome -> { + Button( + onClick = { step = OnboardingStep.Gateway }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + OnboardingStep.Gateway -> { + Button( + onClick = { + if (gatewayInputMode == GatewayInputMode.SetupCode) { + val parsedSetup = decodeGatewaySetupCode(setupCode) + if (parsedSetup == null) { + gatewayError = "Invalid setup code." + return@Button + } + val parsedGateway = parseGatewayEndpoint(parsedSetup.url) + if (parsedGateway == null) { + gatewayError = "Setup code has invalid gateway URL." + return@Button + } + gatewayUrl = parsedSetup.url + parsedSetup.token?.let { viewModel.setGatewayToken(it) } + gatewayPassword = parsedSetup.password.orEmpty() + } else { + val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) + val parsedGateway = manualUrl?.let(::parseGatewayEndpoint) + if (parsedGateway == null) { + gatewayError = "Manual endpoint is invalid." + return@Button + } + gatewayUrl = parsedGateway.displayUrl + } + step = OnboardingStep.Permissions + }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + OnboardingStep.Permissions -> { + Button( + onClick = { + viewModel.setCameraEnabled(enableCamera) + viewModel.setLocationMode(if (enableDiscovery) LocationMode.WhileUsing else LocationMode.Off) + if (selectedPermissions.isEmpty()) { + step = OnboardingStep.FinalCheck + } else { + permissionLauncher.launch(selectedPermissions.toTypedArray()) + } + }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + OnboardingStep.FinalCheck -> { + if (isConnected) { + Button( + onClick = { viewModel.setOnboardingCompleted(true) }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Finish", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } else { + Button( + onClick = { + val parsed = parseGatewayEndpoint(gatewayUrl) + if (parsed == null) { + step = OnboardingStep.Gateway + gatewayError = "Invalid gateway URL." + return@Button + } + val token = persistedGatewayToken.trim() + val password = gatewayPassword.trim() + attemptedConnect = true + viewModel.setManualEnabled(true) + viewModel.setManualHost(parsed.host) + viewModel.setManualPort(parsed.port) + viewModel.setManualTls(parsed.tls) + if (token.isNotEmpty()) { + viewModel.setGatewayToken(token) + } + viewModel.setGatewayPassword(password) + viewModel.connectManual() + }, + modifier = Modifier.weight(1f).height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = onboardingAccent, + contentColor = Color.White, + disabledContainerColor = onboardingAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White, + ), + ) { + Text("Connect", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) + } + } + } + } + } + } + } +} + +@Composable +private fun StepRailWrap(current: OnboardingStep) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + HorizontalDivider(color = onboardingBorder) + StepRail(current = current) + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun StepRail(current: OnboardingStep) { + val steps = OnboardingStep.entries + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + steps.forEach { step -> + val complete = step.index < current.index + val active = step.index == current.index + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(5.dp) + .background( + color = + when { + complete -> onboardingSuccess + active -> onboardingAccent + else -> onboardingBorder + }, + shape = RoundedCornerShape(999.dp), + ), + ) + Text( + text = step.label, + style = onboardingCaption2Style.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), + color = if (active) onboardingAccent else onboardingTextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun WelcomeStep() { + StepShell(title = "What You Get") { + Bullet("Control the gateway and operator chat from one mobile surface.") + Bullet("Connect with setup code and recover pairing with CLI commands.") + Bullet("Enable only the permissions and capabilities you want.") + Bullet("Finish with a real connection check before entering the app.") + } +} + +@Composable +private fun GatewayStep( + inputMode: GatewayInputMode, + setupCode: String, + manualHost: String, + manualPort: String, + manualTls: Boolean, + gatewayToken: String, + gatewayPassword: String, + gatewayError: String?, + onInputModeChange: (GatewayInputMode) -> Unit, + onSetupCodeChange: (String) -> Unit, + onManualHostChange: (String) -> Unit, + onManualPortChange: (String) -> Unit, + onManualTlsChange: (Boolean) -> Unit, + onTokenChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, +) { + val resolvedEndpoint = remember(setupCode) { decodeGatewaySetupCode(setupCode)?.url?.let { parseGatewayEndpoint(it)?.displayUrl } } + val manualResolvedEndpoint = remember(manualHost, manualPort, manualTls) { composeGatewayManualUrl(manualHost, manualPort, manualTls)?.let { parseGatewayEndpoint(it)?.displayUrl } } + + StepShell(title = "Gateway Connection") { + GuideBlock(title = "Get setup code + gateway URL") { + Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) + CommandBlock("openclaw qr --setup-code-only") + CommandBlock("openclaw qr --json") + Text( + "`--json` prints `setupCode` and `gatewayUrl`.", + style = onboardingCalloutStyle, + color = onboardingTextSecondary, + ) + Text( + "Auto URL discovery is not wired yet. Android emulator uses `10.0.2.2`; real devices need LAN/Tailscale host.", + style = onboardingCalloutStyle, + color = onboardingTextSecondary, + ) + } + GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange) + + if (inputMode == GatewayInputMode.SetupCode) { + Text("SETUP CODE", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = setupCode, + onValueChange = onSetupCodeChange, + placeholder = { Text("Paste code from `openclaw qr --setup-code-only`", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 5, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + if (!resolvedEndpoint.isNullOrBlank()) { + ResolvedEndpoint(endpoint = resolvedEndpoint) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + QuickFillChip(label = "Android Emulator", onClick = { + onManualHostChange("10.0.2.2") + onManualPortChange("18789") + onManualTlsChange(false) + }) + QuickFillChip(label = "Localhost", onClick = { + onManualHostChange("127.0.0.1") + onManualPortChange("18789") + onManualTlsChange(false) + }) + } + + Text("HOST", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = manualHost, + onValueChange = onManualHostChange, + placeholder = { Text("10.0.2.2", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + textStyle = onboardingBodyStyle.copy(color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = manualPort, + onValueChange = onManualPortChange, + placeholder = { Text("18789", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText) + Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary) + } + Switch( + checked = manualTls, + onCheckedChange = onManualTlsChange, + colors = + SwitchDefaults.colors( + checkedTrackColor = onboardingAccent, + uncheckedTrackColor = onboardingBorderStrong, + checkedThumbColor = Color.White, + uncheckedThumbColor = Color.White, + ), + ) + } + + Text("TOKEN (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = gatewayToken, + onValueChange = onTokenChange, + placeholder = { Text("token", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = onboardingBodyStyle.copy(color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) + OutlinedTextField( + value = gatewayPassword, + onValueChange = onPasswordChange, + placeholder = { Text("password", color = onboardingTextTertiary, style = onboardingBodyStyle) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + textStyle = onboardingBodyStyle.copy(color = onboardingText), + shape = RoundedCornerShape(14.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = onboardingSurface, + unfocusedContainerColor = onboardingSurface, + focusedBorderColor = onboardingAccent, + unfocusedBorderColor = onboardingBorder, + focusedTextColor = onboardingText, + unfocusedTextColor = onboardingText, + cursorColor = onboardingAccent, + ), + ) + + if (!manualResolvedEndpoint.isNullOrBlank()) { + ResolvedEndpoint(endpoint = manualResolvedEndpoint) + } + } + + if (!gatewayError.isNullOrBlank()) { + Text(gatewayError, color = onboardingWarning, style = onboardingCaption1Style) + } + } +} + +@Composable +private fun GuideBlock( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Row(modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(modifier = Modifier.width(2.dp).fillMaxHeight().background(onboardingAccent.copy(alpha = 0.4f))) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = onboardingHeadlineStyle, color = onboardingText) + content() + } + } +} + +@Composable +private fun GatewayModeToggle( + inputMode: GatewayInputMode, + onInputModeChange: (GatewayInputMode) -> Unit, +) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + GatewayModeChip( + label = "Setup Code", + active = inputMode == GatewayInputMode.SetupCode, + onClick = { onInputModeChange(GatewayInputMode.SetupCode) }, + modifier = Modifier.weight(1f), + ) + GatewayModeChip( + label = "Manual", + active = inputMode == GatewayInputMode.Manual, + onClick = { onInputModeChange(GatewayInputMode.Manual) }, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun GatewayModeChip( + label: String, + active: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Button( + onClick = onClick, + modifier = modifier.height(40.dp), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = if (active) onboardingAccent else onboardingSurface, + contentColor = if (active) Color.White else onboardingText, + ), + border = androidx.compose.foundation.BorderStroke(1.dp, if (active) Color(0xFF184DAF) else onboardingBorderStrong), + ) { + Text( + text = label, + style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold), + ) + } +} + +@Composable +private fun QuickFillChip( + label: String, + onClick: () -> Unit, +) { + TextButton( + onClick = onClick, + shape = RoundedCornerShape(999.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 7.dp), + colors = + ButtonDefaults.textButtonColors( + containerColor = onboardingAccentSoft, + contentColor = onboardingAccent, + ), + ) { + Text(label, style = onboardingCaption1Style.copy(fontWeight = FontWeight.SemiBold)) + } +} + +@Composable +private fun ResolvedEndpoint(endpoint: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HorizontalDivider(color = onboardingBorder) + Text( + "RESOLVED ENDPOINT", + style = onboardingCaption2Style.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.7.sp), + color = onboardingTextSecondary, + ) + Text( + endpoint, + style = onboardingCalloutStyle.copy(fontFamily = FontFamily.Monospace), + color = onboardingText, + ) + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun StepShell( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { + HorizontalDivider(color = onboardingBorder) + Column(modifier = Modifier.padding(vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(title, style = onboardingTitle1Style, color = onboardingText) + content() + } + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun InlineDivider() { + HorizontalDivider(color = onboardingBorder) +} + +@Composable +private fun PermissionsStep( + enableDiscovery: Boolean, + enableNotifications: Boolean, + enableMicrophone: Boolean, + enableCamera: Boolean, + enableSms: Boolean, + smsAvailable: Boolean, + context: Context, + onDiscoveryChange: (Boolean) -> Unit, + onNotificationsChange: (Boolean) -> Unit, + onMicrophoneChange: (Boolean) -> Unit, + onCameraChange: (Boolean) -> Unit, + onSmsChange: (Boolean) -> Unit, +) { + val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION + StepShell(title = "Permissions") { + Text( + "Enable only what you need now. You can change everything later in Settings.", + style = onboardingCalloutStyle, + color = onboardingTextSecondary, + ) + PermissionToggleRow( + title = "Gateway discovery", + subtitle = if (Build.VERSION.SDK_INT >= 33) "Nearby devices" else "Location (for NSD)", + checked = enableDiscovery, + granted = isPermissionGranted(context, discoveryPermission), + onCheckedChange = onDiscoveryChange, + ) + InlineDivider() + if (Build.VERSION.SDK_INT >= 33) { + PermissionToggleRow( + title = "Notifications", + subtitle = "Foreground service + alerts", + checked = enableNotifications, + granted = isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS), + onCheckedChange = onNotificationsChange, + ) + InlineDivider() + } + PermissionToggleRow( + title = "Microphone", + subtitle = "Talk mode + voice features", + checked = enableMicrophone, + granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO), + onCheckedChange = onMicrophoneChange, + ) + InlineDivider() + PermissionToggleRow( + title = "Camera", + subtitle = "camera.snap and camera.clip", + checked = enableCamera, + granted = isPermissionGranted(context, Manifest.permission.CAMERA), + onCheckedChange = onCameraChange, + ) + if (smsAvailable) { + InlineDivider() + PermissionToggleRow( + title = "SMS", + subtitle = "Allow gateway-triggered SMS sending", + checked = enableSms, + granted = isPermissionGranted(context, Manifest.permission.SEND_SMS), + onCheckedChange = onSmsChange, + ) + } + Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } +} + +@Composable +private fun PermissionToggleRow( + title: String, + subtitle: String, + checked: Boolean, + granted: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 50.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = onboardingHeadlineStyle, color = onboardingText) + Text(subtitle, style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary) + Text( + if (granted) "Granted" else "Not granted", + style = onboardingCaption1Style, + color = if (granted) onboardingSuccess else onboardingTextSecondary, + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = + SwitchDefaults.colors( + checkedTrackColor = onboardingAccent, + uncheckedTrackColor = onboardingBorderStrong, + checkedThumbColor = Color.White, + uncheckedThumbColor = Color.White, + ), + ) + } +} + +@Composable +private fun FinalStep( + parsedGateway: GatewayEndpointConfig?, + statusText: String, + isConnected: Boolean, + serverName: String?, + remoteAddress: String?, + attemptedConnect: Boolean, + enabledPermissions: String, + methodLabel: String, +) { + StepShell(title = "Review") { + SummaryField(label = "Method", value = methodLabel) + SummaryField(label = "Gateway", value = parsedGateway?.displayUrl ?: "Invalid gateway URL") + SummaryField(label = "Enabled Permissions", value = enabledPermissions) + + if (!attemptedConnect) { + Text("Press Connect to verify gateway reachability and auth.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } else { + Text("Status: $statusText", style = onboardingCalloutStyle, color = if (isConnected) onboardingSuccess else onboardingTextSecondary) + if (isConnected) { + Text("Connected to ${serverName ?: remoteAddress ?: "gateway"}", style = onboardingCalloutStyle, color = onboardingSuccess) + } else { + GuideBlock(title = "Pairing Required") { + Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary) + CommandBlock("openclaw nodes pending") + CommandBlock("openclaw nodes approve ") + Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary) + } + } + } + } +} + +@Composable +private fun SummaryField(label: String, value: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + label, + style = onboardingCaption2Style.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp), + color = onboardingTextSecondary, + ) + Text(value, style = onboardingHeadlineStyle, color = onboardingText) + HorizontalDivider(color = onboardingBorder) + } +} + +@Composable +private fun CommandBlock(command: String) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(onboardingCommandBg, RoundedCornerShape(12.dp)) + .border(width = 1.dp, color = onboardingCommandBorder, shape = RoundedCornerShape(12.dp)), + ) { + Box(modifier = Modifier.width(3.dp).height(42.dp).background(onboardingCommandAccent)) + Text( + command, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = onboardingCalloutStyle, + fontFamily = FontFamily.Monospace, + color = onboardingCommandText, + ) + } +} + +@Composable +private fun Bullet(text: String) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) { + Box( + modifier = + Modifier + .padding(top = 7.dp) + .size(8.dp) + .background(onboardingAccentSoft, CircleShape), + ) + Box( + modifier = + Modifier + .padding(top = 9.dp) + .size(4.dp) + .background(onboardingAccent, CircleShape), + ) + Text(text, style = onboardingBodyStyle, color = onboardingTextSecondary, modifier = Modifier.weight(1f)) + } +} + +private fun isPermissionGranted(context: Context, permission: String): Boolean { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt new file mode 100644 index 00000000..aad743a6 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt @@ -0,0 +1,32 @@ +package ai.openclaw.android.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +@Composable +fun OpenClawTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val isDark = isSystemInDarkTheme() + val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + + MaterialTheme(colorScheme = colorScheme, content = content) +} + +@Composable +fun overlayContainerColor(): Color { + val scheme = MaterialTheme.colorScheme + val isDark = isSystemInDarkTheme() + val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh + // Light mode: background stays dark (canvas), so clamp overlays away from pure-white glare. + return if (isDark) base else base.copy(alpha = 0.88f) +} + +@Composable +fun overlayIconColor(): Color { + return MaterialTheme.colorScheme.onSurfaceVariant +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt new file mode 100644 index 00000000..b68c06ff --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/PostOnboardingTabs.kt @@ -0,0 +1,333 @@ +package ai.openclaw.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ScreenShare +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.RecordVoiceOver +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import ai.openclaw.android.MainViewModel + +private enum class HomeTab( + val label: String, + val icon: ImageVector, +) { + Connect(label = "Connect", icon = Icons.Default.CheckCircle), + Chat(label = "Chat", icon = Icons.Default.ChatBubble), + Voice(label = "Voice", icon = Icons.Default.RecordVoiceOver), + Screen(label = "Screen", icon = Icons.AutoMirrored.Filled.ScreenShare), + Settings(label = "Settings", icon = Icons.Default.Settings), +} + +private enum class StatusVisual { + Connected, + Connecting, + Warning, + Error, + Offline, +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) { + var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) } + val imeVisible = WindowInsets.isImeVisible + + val statusText by viewModel.statusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + + val statusVisual = + remember(statusText, isConnected) { + val lower = statusText.lowercase() + when { + isConnected -> StatusVisual.Connected + lower.contains("connecting") || lower.contains("reconnecting") -> StatusVisual.Connecting + lower.contains("pairing") || lower.contains("approval") || lower.contains("auth") -> StatusVisual.Warning + lower.contains("error") || lower.contains("failed") -> StatusVisual.Error + else -> StatusVisual.Offline + } + } + + Scaffold( + modifier = modifier, + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + TopStatusBar( + statusText = statusText, + statusVisual = statusVisual, + ) + }, + bottomBar = { + if (!imeVisible) { + BottomTabBar( + activeTab = activeTab, + onSelect = { activeTab = it }, + ) + } + }, + ) { innerPadding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .background(mobileBackgroundGradient), + ) { + when (activeTab) { + HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel) + HomeTab.Chat -> ChatSheet(viewModel = viewModel) + HomeTab.Voice -> ComingSoonTabScreen(label = "VOICE", title = "Coming soon", description = "Voice mode is coming soon.") + HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel) + HomeTab.Settings -> SettingsSheet(viewModel = viewModel) + } + } + } +} + +@Composable +private fun ScreenTabScreen(viewModel: MainViewModel) { + val isConnected by viewModel.isConnected.collectAsState() + val isNodeConnected by viewModel.isNodeConnected.collectAsState() + val canvasUrl by viewModel.canvasCurrentUrl.collectAsState() + val canvasA2uiHydrated by viewModel.canvasA2uiHydrated.collectAsState() + val canvasRehydratePending by viewModel.canvasRehydratePending.collectAsState() + val canvasRehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState() + val isA2uiUrl = canvasUrl?.contains("/__openclaw__/a2ui/") == true + val showRestoreCta = isConnected && isNodeConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated)) + val restoreCtaText = + when { + canvasRehydratePending -> "Restore requested. Waiting for agent…" + !canvasRehydrateErrorText.isNullOrBlank() -> canvasRehydrateErrorText!! + else -> "Canvas reset. Tap to restore dashboard." + } + + Box(modifier = Modifier.fillMaxSize()) { + CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize()) + + if (showRestoreCta) { + Surface( + onClick = { + if (canvasRehydratePending) return@Surface + viewModel.requestCanvasRehydrate(source = "screen_tab_cta") + }, + modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 16.dp), + shape = RoundedCornerShape(12.dp), + color = mobileSurface.copy(alpha = 0.9f), + border = BorderStroke(1.dp, mobileBorder), + shadowElevation = 4.dp, + ) { + Text( + text = restoreCtaText, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = mobileCallout.copy(fontWeight = FontWeight.Medium), + color = mobileText, + ) + } + } + } +} + +@Composable +private fun TopStatusBar( + statusText: String, + statusVisual: StatusVisual, +) { + val safeInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + + val (chipBg, chipDot, chipText, chipBorder) = + when (statusVisual) { + StatusVisual.Connected -> + listOf( + mobileSuccessSoft, + mobileSuccess, + mobileSuccess, + Color(0xFFCFEBD8), + ) + StatusVisual.Connecting -> + listOf( + mobileAccentSoft, + mobileAccent, + mobileAccent, + Color(0xFFD5E2FA), + ) + StatusVisual.Warning -> + listOf( + mobileWarningSoft, + mobileWarning, + mobileWarning, + Color(0xFFEED8B8), + ) + StatusVisual.Error -> + listOf( + mobileDangerSoft, + mobileDanger, + mobileDanger, + Color(0xFFF3C8C8), + ) + StatusVisual.Offline -> + listOf( + mobileSurface, + mobileTextTertiary, + mobileTextSecondary, + mobileBorder, + ) + } + + Surface( + modifier = Modifier.fillMaxWidth().windowInsetsPadding(safeInsets), + color = Color.Transparent, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "OpenClaw", + style = mobileTitle2, + color = mobileText, + ) + Surface( + shape = RoundedCornerShape(999.dp), + color = chipBg, + border = androidx.compose.foundation.BorderStroke(1.dp, chipBorder), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.padding(top = 1.dp), + color = chipDot, + shape = RoundedCornerShape(999.dp), + ) { + Box(modifier = Modifier.padding(4.dp)) + } + Text( + text = statusText.trim().ifEmpty { "Offline" }, + style = mobileCaption1, + color = chipText, + maxLines = 1, + ) + } + } + } + } +} + +@Composable +private fun BottomTabBar( + activeTab: HomeTab, + onSelect: (HomeTab) -> Unit, +) { + val safeInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + + Box( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(safeInsets), + ) { + Surface( + modifier = Modifier.fillMaxWidth().offset(y = (-4).dp), + color = Color.White.copy(alpha = 0.97f), + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + border = BorderStroke(1.dp, mobileBorder), + shadowElevation = 6.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HomeTab.entries.forEach { tab -> + val active = tab == activeTab + Surface( + onClick = { onSelect(tab) }, + modifier = Modifier.weight(1f).heightIn(min = 58.dp), + shape = RoundedCornerShape(16.dp), + color = if (active) mobileAccentSoft else Color.Transparent, + border = if (active) BorderStroke(1.dp, Color(0xFFD5E2FA)) else null, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 7.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Icon( + imageVector = tab.icon, + contentDescription = tab.label, + tint = if (active) mobileAccent else mobileTextTertiary, + ) + Text( + text = tab.label, + color = if (active) mobileAccent else mobileTextSecondary, + style = mobileCaption2.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.Medium), + ) + } + } + } + } + } + } +} + +@Composable +private fun ComingSoonTabScreen( + label: String, + title: String, + description: String, +) { + Box(modifier = Modifier.fillMaxSize().padding(horizontal = 22.dp, vertical = 18.dp)) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent) + Text(title, style = mobileTitle1, color = mobileText) + Text(description, style = mobileBody, color = mobileTextSecondary) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt new file mode 100644 index 00000000..e50a03cc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/RootScreen.kt @@ -0,0 +1,20 @@ +package ai.openclaw.android.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import ai.openclaw.android.MainViewModel + +@Composable +fun RootScreen(viewModel: MainViewModel) { + val onboardingCompleted by viewModel.onboardingCompleted.collectAsState() + + if (!onboardingCompleted) { + OnboardingFlow(viewModel = viewModel, modifier = Modifier.fillMaxSize()) + return + } + + PostOnboardingTabs(viewModel = viewModel, modifier = Modifier.fillMaxSize()) +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt new file mode 100644 index 00000000..2a621957 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt @@ -0,0 +1,673 @@ +package ai.openclaw.android.ui + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import ai.openclaw.android.BuildConfig +import ai.openclaw.android.LocationMode +import ai.openclaw.android.MainViewModel +import ai.openclaw.android.VoiceWakeMode +import ai.openclaw.android.WakeWords + +@Composable +fun SettingsSheet(viewModel: MainViewModel) { + val context = LocalContext.current + val instanceId by viewModel.instanceId.collectAsState() + val displayName by viewModel.displayName.collectAsState() + val cameraEnabled by viewModel.cameraEnabled.collectAsState() + val locationMode by viewModel.locationMode.collectAsState() + val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState() + val preventSleep by viewModel.preventSleep.collectAsState() + val wakeWords by viewModel.wakeWords.collectAsState() + val voiceWakeMode by viewModel.voiceWakeMode.collectAsState() + val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState() + + val listState = rememberLazyListState() + val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } + val focusManager = LocalFocusManager.current + var wakeWordsHadFocus by remember { mutableStateOf(false) } + val deviceModel = + remember { + listOfNotNull(Build.MANUFACTURER, Build.MODEL) + .joinToString(" ") + .trim() + .ifEmpty { "Android" } + } + val appVersion = + remember { + val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" } + if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) { + "$versionName-dev" + } else { + versionName + } + } + val listItemColors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = mobileText, + supportingColor = mobileTextSecondary, + trailingIconColor = mobileTextSecondary, + leadingIconColor = mobileTextSecondary, + ) + + LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } + val commitWakeWords = { + val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords) + if (parsed != null) { + viewModel.setWakeWords(parsed) + } + } + + val permissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> + val cameraOk = perms[Manifest.permission.CAMERA] == true + viewModel.setCameraEnabled(cameraOk) + } + + var pendingLocationMode by remember { mutableStateOf(null) } + var pendingPreciseToggle by remember { mutableStateOf(false) } + + val locationPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> + val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true + val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true + val granted = fineOk || coarseOk + val requestedMode = pendingLocationMode + pendingLocationMode = null + + if (pendingPreciseToggle) { + pendingPreciseToggle = false + viewModel.setLocationPreciseEnabled(fineOk) + return@rememberLauncherForActivityResult + } + + if (!granted) { + viewModel.setLocationMode(LocationMode.Off) + return@rememberLauncherForActivityResult + } + + if (requestedMode != null) { + viewModel.setLocationMode(requestedMode) + if (requestedMode == LocationMode.Always) { + val backgroundOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!backgroundOk) { + openAppSettings(context) + } + } + } + } + + val audioPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ -> + // Status text is handled by NodeRuntime. + } + + val smsPermissionAvailable = + remember { + context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + } + var smsPermissionGranted by + remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == + PackageManager.PERMISSION_GRANTED, + ) + } + val smsPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + smsPermissionGranted = granted + viewModel.refreshGatewayConnection() + } + + fun setCameraEnabledChecked(checked: Boolean) { + if (!checked) { + viewModel.setCameraEnabled(false) + return + } + + val cameraOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + if (cameraOk) { + viewModel.setCameraEnabled(true) + } else { + permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)) + } + } + + fun requestLocationPermissions(targetMode: LocationMode) { + val fineOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + val coarseOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (fineOk || coarseOk) { + viewModel.setLocationMode(targetMode) + if (targetMode == LocationMode.Always) { + val backgroundOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!backgroundOk) { + openAppSettings(context) + } + } + } else { + pendingLocationMode = targetMode + locationPermissionLauncher.launch( + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), + ) + } + } + + fun setPreciseLocationChecked(checked: Boolean) { + if (!checked) { + viewModel.setLocationPreciseEnabled(false) + return + } + val fineOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (fineOk) { + viewModel.setLocationPreciseEnabled(true) + } else { + pendingPreciseToggle = true + locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)) + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(mobileBackgroundGradient), + ) { + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight() + .imePadding() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + "SETTINGS", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + Text("Device Configuration", style = mobileTitle2, color = mobileText) + Text( + "Manage capabilities, permissions, and diagnostics.", + style = mobileCallout, + color = mobileTextSecondary, + ) + } + } + item { HorizontalDivider(color = mobileBorder) } + + // Order parity: Node → Voice → Camera → Messaging → Location → Screen. + item { + Text( + "NODE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } + item { + OutlinedTextField( + value = displayName, + onValueChange = viewModel::setDisplayName, + label = { Text("Name", style = mobileCaption1, color = mobileTextSecondary) }, + modifier = Modifier.fillMaxWidth(), + textStyle = mobileBody.copy(color = mobileText), + colors = settingsTextFieldColors(), + ) + } + item { Text("Instance ID: $instanceId", style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileTextSecondary) } + item { Text("Device: $deviceModel", style = mobileCallout, color = mobileTextSecondary) } + item { Text("Version: $appVersion", style = mobileCallout, color = mobileTextSecondary) } + + item { HorizontalDivider(color = mobileBorder) } + + // Voice + item { + Text( + "VOICE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } + item { + val enabled = voiceWakeMode != VoiceWakeMode.Off + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Voice Wake", style = mobileHeadline) }, + supportingContent = { Text(voiceWakeStatusText, style = mobileCallout) }, + trailingContent = { + Switch( + checked = enabled, + onCheckedChange = { on -> + if (on) { + val micOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground) + } else { + viewModel.setVoiceWakeMode(VoiceWakeMode.Off) + } + }, + ) + }, + ) + } + item { + AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Foreground Only", style = mobileHeadline) }, + supportingContent = { Text("Listens only while OpenClaw is open.", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = voiceWakeMode == VoiceWakeMode.Foreground, + onClick = { + val micOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground) + }, + ) + }, + ) + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Always", style = mobileHeadline) }, + supportingContent = { Text("Keeps listening in the background (shows a persistent notification).", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = voiceWakeMode == VoiceWakeMode.Always, + onClick = { + val micOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + viewModel.setVoiceWakeMode(VoiceWakeMode.Always) + }, + ) + }, + ) + } + } + } + item { + OutlinedTextField( + value = wakeWordsText, + onValueChange = setWakeWordsText, + label = { Text("Wake Words (comma-separated)", style = mobileCaption1, color = mobileTextSecondary) }, + modifier = + Modifier.fillMaxWidth().onFocusChanged { focusState -> + if (focusState.isFocused) { + wakeWordsHadFocus = true + } else if (wakeWordsHadFocus) { + wakeWordsHadFocus = false + commitWakeWords() + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + commitWakeWords() + focusManager.clearFocus() + }, + ), + textStyle = mobileBody.copy(color = mobileText), + colors = settingsTextFieldColors(), + ) + } + item { + Button( + onClick = viewModel::resetWakeWordsDefaults, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text("Reset defaults", style = mobileCallout.copy(fontWeight = FontWeight.Bold)) + } + } + item { + Text( + if (isConnected) { + "Any node can edit wake words. Changes sync via the gateway." + } else { + "Connect to a gateway to sync wake words globally." + }, + style = mobileCallout, + color = mobileTextSecondary, + ) + } + + item { HorizontalDivider(color = mobileBorder) } + + // Camera + item { + Text( + "CAMERA", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } + item { + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Allow Camera", style = mobileHeadline) }, + supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).", style = mobileCallout) }, + trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) }, + ) + } + item { + Text( + "Tip: grant Microphone permission for video clips with audio.", + style = mobileCallout, + color = mobileTextSecondary, + ) + } + + item { HorizontalDivider(color = mobileBorder) } + + // Messaging + item { + Text( + "MESSAGING", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } + item { + val buttonLabel = + when { + !smsPermissionAvailable -> "Unavailable" + smsPermissionGranted -> "Manage" + else -> "Grant" + } + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("SMS Permission", style = mobileHeadline) }, + supportingContent = { + Text( + if (smsPermissionAvailable) { + "Allow the gateway to send SMS from this device." + } else { + "SMS requires a device with telephony hardware." + }, + style = mobileCallout, + ) + }, + trailingContent = { + Button( + onClick = { + if (!smsPermissionAvailable) return@Button + if (smsPermissionGranted) { + openAppSettings(context) + } else { + smsPermissionLauncher.launch(Manifest.permission.SEND_SMS) + } + }, + enabled = smsPermissionAvailable, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold)) + } + }, + ) + } + + item { HorizontalDivider(color = mobileBorder) } + + // Location + item { + Text( + "LOCATION", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } + item { + Column(modifier = settingsRowModifier(), verticalArrangement = Arrangement.spacedBy(0.dp)) { + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Off", style = mobileHeadline) }, + supportingContent = { Text("Disable location sharing.", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.Off, + onClick = { viewModel.setLocationMode(LocationMode.Off) }, + ) + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("While Using", style = mobileHeadline) }, + supportingContent = { Text("Only while OpenClaw is open.", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.WhileUsing, + onClick = { requestLocationPermissions(LocationMode.WhileUsing) }, + ) + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Always", style = mobileHeadline) }, + supportingContent = { Text("Allow background location (requires system permission).", style = mobileCallout) }, + trailingContent = { + RadioButton( + selected = locationMode == LocationMode.Always, + onClick = { requestLocationPermissions(LocationMode.Always) }, + ) + }, + ) + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Precise Location", style = mobileHeadline) }, + supportingContent = { Text("Use precise GPS when available.", style = mobileCallout) }, + trailingContent = { + Switch( + checked = locationPreciseEnabled, + onCheckedChange = ::setPreciseLocationChecked, + enabled = locationMode != LocationMode.Off, + ) + }, + ) + } + } + item { + Text( + "Always may require Android Settings to allow background location.", + style = mobileCallout, + color = mobileTextSecondary, + ) + } + + item { HorizontalDivider(color = mobileBorder) } + + // Screen + item { + Text( + "SCREEN", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } + item { + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Prevent Sleep", style = mobileHeadline) }, + supportingContent = { Text("Keeps the screen awake while OpenClaw is open.", style = mobileCallout) }, + trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) }, + ) + } + + item { HorizontalDivider(color = mobileBorder) } + + // Debug + item { + Text( + "DEBUG", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), + color = mobileAccent, + ) + } + item { + ListItem( + modifier = settingsRowModifier(), + colors = listItemColors, + headlineContent = { Text("Debug Canvas Status", style = mobileHeadline) }, + supportingContent = { Text("Show status text in the canvas when debug is enabled.", style = mobileCallout) }, + trailingContent = { + Switch( + checked = canvasDebugStatusEnabled, + onCheckedChange = viewModel::setCanvasDebugStatusEnabled, + ) + }, + ) + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + } + } +} + +@Composable +private fun settingsTextFieldColors() = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = mobileSurface, + unfocusedContainerColor = mobileSurface, + focusedBorderColor = mobileAccent, + unfocusedBorderColor = mobileBorder, + focusedTextColor = mobileText, + unfocusedTextColor = mobileText, + cursorColor = mobileAccent, + ) + +private fun settingsRowModifier() = + Modifier + .fillMaxWidth() + .border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp)) + .background(Color.White, RoundedCornerShape(14.dp)) + +@Composable +private fun settingsPrimaryButtonColors() = + ButtonDefaults.buttonColors( + containerColor = mobileAccent, + contentColor = Color.White, + disabledContainerColor = mobileAccent.copy(alpha = 0.45f), + disabledContentColor = Color.White.copy(alpha = 0.9f), + ) + +@Composable +private fun settingsDangerButtonColors() = + ButtonDefaults.buttonColors( + containerColor = mobileDanger, + contentColor = Color.White, + disabledContainerColor = mobileDanger.copy(alpha = 0.45f), + disabledContentColor = Color.White.copy(alpha = 0.9f), + ) + +private fun openAppSettings(context: Context) { + val intent = + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ) + context.startActivity(intent) +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/TalkOrbOverlay.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/TalkOrbOverlay.kt new file mode 100644 index 00000000..f89b298d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/TalkOrbOverlay.kt @@ -0,0 +1,134 @@ +package ai.openclaw.android.ui + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun TalkOrbOverlay( + seamColor: Color, + statusText: String, + isListening: Boolean, + isSpeaking: Boolean, + modifier: Modifier = Modifier, +) { + val transition = rememberInfiniteTransition(label = "talk-orb") + val t by + transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "pulse", + ) + + val trimmed = statusText.trim() + val showStatus = trimmed.isNotEmpty() && trimmed != "Off" + val phase = + when { + isSpeaking -> "Speaking" + isListening -> "Listening" + else -> "Thinking" + } + + Column( + modifier = modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Canvas(modifier = Modifier.size(360.dp)) { + val center = this.center + val baseRadius = size.minDimension * 0.30f + + val ring1 = 1.05f + (t * 0.25f) + val ring2 = 1.20f + (t * 0.55f) + val ringAlpha1 = (1f - t) * 0.34f + val ringAlpha2 = (1f - t) * 0.22f + + drawCircle( + color = seamColor.copy(alpha = ringAlpha1), + radius = baseRadius * ring1, + center = center, + style = Stroke(width = 3.dp.toPx()), + ) + drawCircle( + color = seamColor.copy(alpha = ringAlpha2), + radius = baseRadius * ring2, + center = center, + style = Stroke(width = 3.dp.toPx()), + ) + + drawCircle( + brush = + Brush.radialGradient( + colors = + listOf( + seamColor.copy(alpha = 0.92f), + seamColor.copy(alpha = 0.40f), + Color.Black.copy(alpha = 0.56f), + ), + center = center, + radius = baseRadius * 1.35f, + ), + radius = baseRadius, + center = center, + ) + + drawCircle( + color = seamColor.copy(alpha = 0.34f), + radius = baseRadius, + center = center, + style = Stroke(width = 1.dp.toPx()), + ) + } + } + + if (showStatus) { + Surface( + color = Color.Black.copy(alpha = 0.40f), + shape = CircleShape, + ) { + Text( + text = trimmed, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + color = Color.White.copy(alpha = 0.92f), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } else { + Text( + text = phase, + color = Color.White.copy(alpha = 0.80f), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt new file mode 100644 index 00000000..7f719959 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatComposer.kt @@ -0,0 +1,342 @@ +package ai.openclaw.android.ui.chat + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileAccentSoft +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileBorderStrong +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileHeadline +import ai.openclaw.android.ui.mobileSurface +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.android.ui.mobileTextTertiary + +@Composable +fun ChatComposer( + healthOk: Boolean, + thinkingLevel: String, + pendingRunCount: Int, + attachments: List, + onPickImages: () -> Unit, + onRemoveAttachment: (id: String) -> Unit, + onSetThinkingLevel: (level: String) -> Unit, + onRefresh: () -> Unit, + onAbort: () -> Unit, + onSend: (text: String) -> Unit, +) { + var input by rememberSaveable { mutableStateOf("") } + var showThinkingMenu by remember { mutableStateOf(false) } + + val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk + val sendBusy = pendingRunCount > 0 + + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(modifier = Modifier.weight(1f)) { + Surface( + onClick = { showThinkingMenu = true }, + shape = RoundedCornerShape(14.dp), + color = mobileAccentSoft, + border = BorderStroke(1.dp, mobileBorderStrong), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Thinking: ${thinkingLabel(thinkingLevel)}", + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = mobileText, + ) + Icon(Icons.Default.ArrowDropDown, contentDescription = "Select thinking level", tint = mobileTextSecondary) + } + } + + DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { + ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + } + } + + SecondaryActionButton( + label = "Attach", + icon = Icons.Default.AttachFile, + enabled = true, + onClick = onPickImages, + ) + } + + if (attachments.isNotEmpty()) { + AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment) + } + + HorizontalDivider(color = mobileBorder) + + Text( + text = "MESSAGE", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.9.sp), + color = mobileTextSecondary, + ) + + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth().height(92.dp), + placeholder = { Text("Type a message", style = mobileBodyStyle(), color = mobileTextTertiary) }, + minLines = 2, + maxLines = 5, + textStyle = mobileBodyStyle().copy(color = mobileText), + shape = RoundedCornerShape(14.dp), + colors = chatTextFieldColors(), + ) + + if (!healthOk) { + Text( + text = "Gateway is offline. Connect first in the Connect tab.", + style = mobileCallout, + color = ai.openclaw.android.ui.mobileWarning, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryActionButton( + label = "Refresh", + icon = Icons.Default.Refresh, + enabled = true, + onClick = onRefresh, + ) + + SecondaryActionButton( + label = "Abort", + icon = Icons.Default.Stop, + enabled = pendingRunCount > 0, + onClick = onAbort, + ) + } + + Button( + onClick = { + val text = input + input = "" + onSend(text) + }, + enabled = canSend, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = mobileAccent, + contentColor = Color.White, + disabledContainerColor = mobileBorderStrong, + disabledContentColor = mobileTextTertiary, + ), + border = BorderStroke(1.dp, if (canSend) Color(0xFF154CAD) else mobileBorderStrong), + ) { + if (sendBusy) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = Color.White) + } else { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp)) + } + Spacer(modifier = Modifier.width(8.dp)) + Text("Send", style = mobileHeadline.copy(fontWeight = FontWeight.Bold)) + } + } + } +} + +@Composable +private fun SecondaryActionButton( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + enabled: Boolean, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.height(44.dp), + shape = RoundedCornerShape(14.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = mobileTextSecondary, + disabledContainerColor = Color.White, + disabledContentColor = mobileTextTertiary, + ), + border = BorderStroke(1.dp, mobileBorderStrong), + contentPadding = ButtonDefaults.ContentPadding, + ) { + Icon(icon, contentDescription = label, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(5.dp)) + Text( + text = label, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = if (enabled) mobileTextSecondary else mobileTextTertiary, + ) + } +} + +@Composable +private fun ThinkingMenuItem( + value: String, + current: String, + onSet: (String) -> Unit, + onDismiss: () -> Unit, +) { + DropdownMenuItem( + text = { Text(thinkingLabel(value), style = mobileCallout, color = mobileText) }, + onClick = { + onSet(value) + onDismiss() + }, + trailingIcon = { + if (value == current.trim().lowercase()) { + Text("✓", style = mobileCallout, color = mobileAccent) + } else { + Spacer(modifier = Modifier.width(10.dp)) + } + }, + ) +} + +private fun thinkingLabel(raw: String): String { + return when (raw.trim().lowercase()) { + "low" -> "Low" + "medium" -> "Medium" + "high" -> "High" + else -> "Off" + } +} + +@Composable +private fun AttachmentsStrip( + attachments: List, + onRemoveAttachment: (id: String) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (att in attachments) { + AttachmentChip( + fileName = att.fileName, + onRemove = { onRemoveAttachment(att.id) }, + ) + } + } +} + +@Composable +private fun AttachmentChip(fileName: String, onRemove: () -> Unit) { + Surface( + shape = RoundedCornerShape(999.dp), + color = mobileAccentSoft, + border = BorderStroke(1.dp, mobileBorderStrong), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = fileName, + style = mobileCaption1, + color = mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Surface( + onClick = onRemove, + shape = RoundedCornerShape(999.dp), + color = Color.White, + border = BorderStroke(1.dp, mobileBorderStrong), + ) { + Text( + text = "×", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold), + color = mobileTextSecondary, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) + } + } + } +} + +@Composable +private fun chatTextFieldColors() = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = mobileSurface, + unfocusedContainerColor = mobileSurface, + focusedBorderColor = mobileAccent, + unfocusedBorderColor = mobileBorder, + focusedTextColor = mobileText, + unfocusedTextColor = mobileText, + cursorColor = mobileAccent, + ) + +@Composable +private fun mobileBodyStyle() = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = ai.openclaw.android.ui.mobileFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + lineHeight = 22.sp, + ) diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt new file mode 100644 index 00000000..e1212125 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt @@ -0,0 +1,591 @@ +package ai.openclaw.android.ui.chat + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileCodeBg +import ai.openclaw.android.ui.mobileCodeText +import ai.openclaw.android.ui.mobileTextSecondary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.commonmark.Extension +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.Strikethrough +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TableBlock +import org.commonmark.ext.gfm.tables.TableBody +import org.commonmark.ext.gfm.tables.TableCell +import org.commonmark.ext.gfm.tables.TableHead +import org.commonmark.ext.gfm.tables.TableRow +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.ext.task.list.items.TaskListItemMarker +import org.commonmark.ext.task.list.items.TaskListItemsExtension +import org.commonmark.node.BlockQuote +import org.commonmark.node.BulletList +import org.commonmark.node.Code +import org.commonmark.node.Document +import org.commonmark.node.Emphasis +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.Heading +import org.commonmark.node.HardLineBreak +import org.commonmark.node.HtmlBlock +import org.commonmark.node.HtmlInline +import org.commonmark.node.Image as MarkdownImage +import org.commonmark.node.IndentedCodeBlock +import org.commonmark.node.Link +import org.commonmark.node.ListItem +import org.commonmark.node.Node +import org.commonmark.node.OrderedList +import org.commonmark.node.Paragraph +import org.commonmark.node.SoftLineBreak +import org.commonmark.node.StrongEmphasis +import org.commonmark.node.Text as MarkdownTextNode +import org.commonmark.node.ThematicBreak +import org.commonmark.parser.Parser + +private const val LIST_INDENT_DP = 14 +private val dataImageRegex = Regex("^data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)$") + +private val markdownParser: Parser by lazy { + val extensions: List = + listOf( + AutolinkExtension.create(), + StrikethroughExtension.create(), + TablesExtension.create(), + TaskListItemsExtension.create(), + ) + Parser.builder() + .extensions(extensions) + .build() +} + +@Composable +fun ChatMarkdown(text: String, textColor: Color) { + val document = remember(text) { markdownParser.parse(text) as Document } + val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText) + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + RenderMarkdownBlocks( + start = document.firstChild, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = 0, + ) + } +} + +@Composable +private fun RenderMarkdownBlocks( + start: Node?, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + var node = start + while (node != null) { + val current = node + when (current) { + is Paragraph -> { + RenderParagraph(current, textColor = textColor, inlineStyles = inlineStyles) + } + is Heading -> { + val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) } + Text( + text = headingText, + style = headingStyle(current.level), + color = textColor, + ) + } + is FencedCodeBlock -> { + SelectionContainer(modifier = Modifier.fillMaxWidth()) { + ChatCodeBlock(code = current.literal.orEmpty(), language = current.info?.trim()?.ifEmpty { null }) + } + } + is IndentedCodeBlock -> { + SelectionContainer(modifier = Modifier.fillMaxWidth()) { + ChatCodeBlock(code = current.literal.orEmpty(), language = null) + } + } + is BlockQuote -> { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(mobileTextSecondary.copy(alpha = 0.35f)), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + RenderMarkdownBlocks( + start = current.firstChild, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + } + } + } + is BulletList -> { + RenderBulletList( + list = current, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + } + is OrderedList -> { + RenderOrderedList( + list = current, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + } + is TableBlock -> { + RenderTableBlock( + table = current, + textColor = textColor, + inlineStyles = inlineStyles, + ) + } + is ThematicBreak -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(mobileTextSecondary.copy(alpha = 0.25f)), + ) + } + is HtmlBlock -> { + val literal = current.literal.orEmpty().trim() + if (literal.isNotEmpty()) { + Text( + text = literal, + style = mobileCallout.copy(fontFamily = FontFamily.Monospace), + color = textColor, + ) + } + } + } + node = current.next + } +} + +@Composable +private fun RenderParagraph( + paragraph: Paragraph, + textColor: Color, + inlineStyles: InlineStyles, +) { + val standaloneImage = remember(paragraph) { standaloneDataImage(paragraph) } + if (standaloneImage != null) { + InlineBase64Image(base64 = standaloneImage.base64, mimeType = standaloneImage.mimeType) + return + } + + val annotated = remember(paragraph) { buildInlineMarkdown(paragraph.firstChild, inlineStyles) } + if (annotated.text.trimEnd().isEmpty()) { + return + } + + Text( + text = annotated, + style = mobileCallout, + color = textColor, + ) +} + +@Composable +private fun RenderBulletList( + list: BulletList, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + Column( + modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + var item = list.firstChild + while (item != null) { + if (item is ListItem) { + RenderListItem( + item = item, + markerText = "•", + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + } + item = item.next + } + } +} + +@Composable +private fun RenderOrderedList( + list: OrderedList, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + Column( + modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + var index = list.markerStartNumber ?: 1 + var item = list.firstChild + while (item != null) { + if (item is ListItem) { + RenderListItem( + item = item, + markerText = "$index.", + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth, + ) + index += 1 + } + item = item.next + } + } +} + +@Composable +private fun RenderListItem( + item: ListItem, + markerText: String, + textColor: Color, + inlineStyles: InlineStyles, + listDepth: Int, +) { + var contentStart = item.firstChild + var marker = markerText + val task = contentStart as? TaskListItemMarker + if (task != null) { + marker = if (task.isChecked) "☑" else "☐" + contentStart = task.next + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = marker, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = textColor, + modifier = Modifier.width(24.dp), + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + RenderMarkdownBlocks( + start = contentStart, + textColor = textColor, + inlineStyles = inlineStyles, + listDepth = listDepth + 1, + ) + } + } +} + +@Composable +private fun RenderTableBlock( + table: TableBlock, + textColor: Color, + inlineStyles: InlineStyles, +) { + val rows = remember(table) { buildTableRows(table, inlineStyles) } + if (rows.isEmpty()) return + + val maxCols = rows.maxOf { row -> row.cells.size }.coerceAtLeast(1) + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState) + .border(1.dp, mobileTextSecondary.copy(alpha = 0.25f)), + ) { + for (row in rows) { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + for (index in 0 until maxCols) { + val cell = row.cells.getOrNull(index) ?: AnnotatedString("") + Text( + text = cell, + style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else mobileCallout, + color = textColor, + modifier = Modifier + .border(1.dp, mobileTextSecondary.copy(alpha = 0.22f)) + .padding(horizontal = 8.dp, vertical = 6.dp) + .width(160.dp), + ) + } + } + } + } +} + +private fun buildTableRows(table: TableBlock, inlineStyles: InlineStyles): List { + val rows = mutableListOf() + var child = table.firstChild + while (child != null) { + when (child) { + is TableHead -> rows.addAll(readTableSection(child, isHeader = true, inlineStyles = inlineStyles)) + is TableBody -> rows.addAll(readTableSection(child, isHeader = false, inlineStyles = inlineStyles)) + is TableRow -> rows.add(readTableRow(child, isHeader = false, inlineStyles = inlineStyles)) + } + child = child.next + } + return rows +} + +private fun readTableSection(section: Node, isHeader: Boolean, inlineStyles: InlineStyles): List { + val rows = mutableListOf() + var row = section.firstChild + while (row != null) { + if (row is TableRow) { + rows.add(readTableRow(row, isHeader = isHeader, inlineStyles = inlineStyles)) + } + row = row.next + } + return rows +} + +private fun readTableRow(row: TableRow, isHeader: Boolean, inlineStyles: InlineStyles): TableRenderRow { + val cells = mutableListOf() + var cellNode = row.firstChild + while (cellNode != null) { + if (cellNode is TableCell) { + cells.add(buildInlineMarkdown(cellNode.firstChild, inlineStyles)) + } + cellNode = cellNode.next + } + return TableRenderRow(isHeader = isHeader, cells = cells) +} + +private fun buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): AnnotatedString { + return buildAnnotatedString { + appendInlineNode( + node = start, + inlineCodeBg = inlineStyles.inlineCodeBg, + inlineCodeColor = inlineStyles.inlineCodeColor, + ) + } +} + +private fun AnnotatedString.Builder.appendInlineNode( + node: Node?, + inlineCodeBg: Color, + inlineCodeColor: Color, +) { + var current = node + while (current != null) { + when (current) { + is MarkdownTextNode -> append(current.literal) + is SoftLineBreak -> append('\n') + is HardLineBreak -> append('\n') + is Code -> { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + background = inlineCodeBg, + color = inlineCodeColor, + ), + ) { + append(current.literal) + } + } + is Emphasis -> { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + is StrongEmphasis -> { + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + is Strikethrough -> { + withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + is Link -> { + withStyle( + SpanStyle( + color = mobileAccent, + textDecoration = TextDecoration.Underline, + ), + ) { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + is MarkdownImage -> { + val alt = buildPlainText(current.firstChild) + if (alt.isNotBlank()) { + append(alt) + } else { + append("image") + } + } + is HtmlInline -> { + if (!current.literal.isNullOrBlank()) { + append(current.literal) + } + } + else -> { + appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) + } + } + current = current.next + } +} + +private fun buildPlainText(start: Node?): String { + val sb = StringBuilder() + var node = start + while (node != null) { + when (node) { + is MarkdownTextNode -> sb.append(node.literal) + is SoftLineBreak, is HardLineBreak -> sb.append('\n') + else -> sb.append(buildPlainText(node.firstChild)) + } + node = node.next + } + return sb.toString() +} + +private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? { + val only = paragraph.firstChild as? MarkdownImage ?: return null + if (only.next != null) return null + return parseDataImageDestination(only.destination) +} + +private fun parseDataImageDestination(destination: String?): ParsedDataImage? { + val raw = destination?.trim().orEmpty() + if (raw.isEmpty()) return null + val match = dataImageRegex.matchEntire(raw) ?: return null + val subtype = match.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png" + val base64 = match.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty() + if (base64.isEmpty()) return null + return ParsedDataImage(mimeType = "image/$subtype", base64 = base64) +} + +private fun headingStyle(level: Int): TextStyle { + return when (level.coerceIn(1, 6)) { + 1 -> mobileCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold) + 2 -> mobileCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold) + 3 -> mobileCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold) + 4 -> mobileCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold) + else -> mobileCallout.copy(fontWeight = FontWeight.SemiBold) + } +} + +private data class InlineStyles( + val inlineCodeBg: Color, + val inlineCodeColor: Color, +) + +private data class TableRenderRow( + val isHeader: Boolean, + val cells: List, +) + +private data class ParsedDataImage( + val mimeType: String, + val base64: String, +) + +@Composable +private fun InlineBase64Image(base64: String, mimeType: String?) { + var image by remember(base64) { mutableStateOf(null) } + var failed by remember(base64) { mutableStateOf(false) } + + LaunchedEffect(base64) { + failed = false + image = + withContext(Dispatchers.Default) { + try { + val bytes = Base64.decode(base64, Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null + bitmap.asImageBitmap() + } catch (_: Throwable) { + null + } + } + if (image == null) failed = true + } + + if (image != null) { + Image( + bitmap = image!!, + contentDescription = mimeType ?: "image", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth(), + ) + } else if (failed) { + Text( + text = "Image unavailable", + modifier = Modifier.padding(vertical = 2.dp), + style = mobileCaption1, + color = mobileTextSecondary, + ) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt new file mode 100644 index 00000000..889de006 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageListCard.kt @@ -0,0 +1,108 @@ +package ai.openclaw.android.ui.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import ai.openclaw.android.chat.ChatMessage +import ai.openclaw.android.chat.ChatPendingToolCall +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileHeadline +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary + +@Composable +fun ChatMessageListCard( + messages: List, + pendingRunCount: Int, + pendingToolCalls: List, + streamingAssistantText: String?, + healthOk: Boolean, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + + // With reverseLayout the newest item is at index 0 (bottom of screen). + LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) { + listState.animateScrollToItem(index = 0) + } + + Box(modifier = modifier.fillMaxWidth()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + reverseLayout = true, + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 8.dp), + ) { + // With reverseLayout = true, index 0 renders at the BOTTOM. + // So we emit newest items first: streaming → tools → typing → messages (newest→oldest). + + val stream = streamingAssistantText?.trim() + if (!stream.isNullOrEmpty()) { + item(key = "stream") { + ChatStreamingAssistantBubble(text = stream) + } + } + + if (pendingToolCalls.isNotEmpty()) { + item(key = "tools") { + ChatPendingToolsBubble(toolCalls = pendingToolCalls) + } + } + + if (pendingRunCount > 0) { + item(key = "typing") { + ChatTypingIndicatorBubble() + } + } + + items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx -> + ChatMessageBubble(message = messages[messages.size - 1 - idx]) + } + } + + if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { + EmptyChatHint(modifier = Modifier.align(Alignment.Center), healthOk = healthOk) + } + } +} + +@Composable +private fun EmptyChatHint(modifier: Modifier = Modifier, healthOk: Boolean) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f), + border = androidx.compose.foundation.BorderStroke(1.dp, mobileBorder), + ) { + androidx.compose.foundation.layout.Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("No messages yet", style = mobileHeadline, color = mobileText) + Text( + text = + if (healthOk) { + "Send the first prompt to start this session." + } else { + "Connect gateway first, then return to chat." + }, + style = mobileCallout, + color = mobileTextSecondary, + ) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt new file mode 100644 index 00000000..3f4250c3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt @@ -0,0 +1,323 @@ +package ai.openclaw.android.ui.chat + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ai.openclaw.android.chat.ChatMessage +import ai.openclaw.android.chat.ChatMessageContent +import ai.openclaw.android.chat.ChatPendingToolCall +import ai.openclaw.android.tools.ToolDisplayRegistry +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileAccentSoft +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileBorderStrong +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileCaption2 +import ai.openclaw.android.ui.mobileCodeBg +import ai.openclaw.android.ui.mobileCodeText +import ai.openclaw.android.ui.mobileHeadline +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.android.ui.mobileWarning +import ai.openclaw.android.ui.mobileWarningSoft +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private data class ChatBubbleStyle( + val alignEnd: Boolean, + val containerColor: Color, + val borderColor: Color, + val roleColor: Color, +) + +@Composable +fun ChatMessageBubble(message: ChatMessage) { + val role = message.role.trim().lowercase(Locale.US) + val style = bubbleStyle(role) + + // Filter to only displayable content parts (text with content, or base64 images). + val displayableContent = + message.content.filter { part -> + when (part.type) { + "text" -> !part.text.isNullOrBlank() + else -> part.base64 != null + } + } + + if (displayableContent.isEmpty()) return + + ChatBubbleContainer(style = style, roleLabel = roleLabel(role)) { + ChatMessageBody(content = displayableContent, textColor = mobileText) + } +} + +@Composable +private fun ChatBubbleContainer( + style: ChatBubbleStyle, + roleLabel: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = if (style.alignEnd) Arrangement.End else Arrangement.Start, + ) { + Surface( + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, style.borderColor), + color = style.containerColor, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + modifier = Modifier.fillMaxWidth(0.90f), + ) { + Column( + modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = roleLabel, + style = mobileCaption2.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp), + color = style.roleColor, + ) + content() + } + } + } +} + +@Composable +private fun ChatMessageBody(content: List, textColor: Color) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (part in content) { + when (part.type) { + "text" -> { + val text = part.text ?: continue + ChatMarkdown(text = text, textColor = textColor) + } + else -> { + val b64 = part.base64 ?: continue + ChatBase64Image(base64 = b64, mimeType = part.mimeType) + } + } + } + } +} + +@Composable +fun ChatTypingIndicatorBubble() { + ChatBubbleContainer( + style = bubbleStyle("assistant"), + roleLabel = roleLabel("assistant"), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + DotPulse(color = mobileTextSecondary) + Text("Thinking...", style = mobileCallout, color = mobileTextSecondary) + } + } +} + +@Composable +fun ChatPendingToolsBubble(toolCalls: List) { + val context = LocalContext.current + val displays = + remember(toolCalls, context) { + toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) } + } + + ChatBubbleContainer( + style = bubbleStyle("assistant"), + roleLabel = "TOOLS", + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Running tools...", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) + for (display in displays.take(6)) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + "${display.emoji} ${display.label}", + style = mobileCallout, + color = mobileTextSecondary, + fontFamily = FontFamily.Monospace, + ) + display.detailLine?.let { detail -> + Text( + detail, + style = mobileCaption1, + color = mobileTextSecondary, + fontFamily = FontFamily.Monospace, + ) + } + } + } + if (toolCalls.size > 6) { + Text( + text = "... +${toolCalls.size - 6} more", + style = mobileCaption1, + color = mobileTextSecondary, + ) + } + } + } +} + +@Composable +fun ChatStreamingAssistantBubble(text: String) { + ChatBubbleContainer( + style = bubbleStyle("assistant").copy(borderColor = mobileAccent), + roleLabel = "ASSISTANT · LIVE", + ) { + ChatMarkdown(text = text, textColor = mobileText) + } +} + +private fun bubbleStyle(role: String): ChatBubbleStyle { + return when (role) { + "user" -> + ChatBubbleStyle( + alignEnd = true, + containerColor = mobileAccentSoft, + borderColor = mobileAccent, + roleColor = mobileAccent, + ) + + "system" -> + ChatBubbleStyle( + alignEnd = false, + containerColor = mobileWarningSoft, + borderColor = mobileWarning.copy(alpha = 0.45f), + roleColor = mobileWarning, + ) + + else -> + ChatBubbleStyle( + alignEnd = false, + containerColor = Color.White, + borderColor = mobileBorderStrong, + roleColor = mobileTextSecondary, + ) + } +} + +private fun roleLabel(role: String): String { + return when (role) { + "user" -> "USER" + "system" -> "SYSTEM" + else -> "ASSISTANT" + } +} + +@Composable +private fun ChatBase64Image(base64: String, mimeType: String?) { + var image by remember(base64) { mutableStateOf(null) } + var failed by remember(base64) { mutableStateOf(false) } + + LaunchedEffect(base64) { + failed = false + image = + withContext(Dispatchers.Default) { + try { + val bytes = Base64.decode(base64, Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null + bitmap.asImageBitmap() + } catch (_: Throwable) { + null + } + } + if (image == null) failed = true + } + + if (image != null) { + Surface( + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, mobileBorder), + color = Color.White, + modifier = Modifier.fillMaxWidth(), + ) { + Image( + bitmap = image!!, + contentDescription = mimeType ?: "attachment", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth(), + ) + } + } else if (failed) { + Text("Unsupported attachment", style = mobileCaption1, color = mobileTextSecondary) + } +} + +@Composable +private fun DotPulse(color: Color) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { + PulseDot(alpha = 0.38f, color = color) + PulseDot(alpha = 0.62f, color = color) + PulseDot(alpha = 0.90f, color = color) + } +} + +@Composable +private fun PulseDot(alpha: Float, color: Color) { + Surface( + modifier = Modifier.size(6.dp).alpha(alpha), + shape = CircleShape, + color = color, + ) {} +} + +@Composable +fun ChatCodeBlock(code: String, language: String?) { + Surface( + shape = RoundedCornerShape(8.dp), + color = mobileCodeBg, + border = BorderStroke(1.dp, Color(0xFF2B2E35)), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (!language.isNullOrBlank()) { + Text( + text = language.uppercase(Locale.US), + style = mobileCaption2.copy(letterSpacing = 0.4.sp), + color = mobileTextSecondary, + ) + } + Text( + text = code.trimEnd(), + fontFamily = FontFamily.Monospace, + style = mobileCallout, + color = mobileCodeText, + ) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt new file mode 100644 index 00000000..d1c2743e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatSheetContent.kt @@ -0,0 +1,279 @@ +package ai.openclaw.android.ui.chat + +import android.content.ContentResolver +import android.net.Uri +import android.util.Base64 +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ai.openclaw.android.MainViewModel +import ai.openclaw.android.chat.ChatSessionEntry +import ai.openclaw.android.chat.OutgoingAttachment +import ai.openclaw.android.ui.mobileAccent +import ai.openclaw.android.ui.mobileBorder +import ai.openclaw.android.ui.mobileBorderStrong +import ai.openclaw.android.ui.mobileCallout +import ai.openclaw.android.ui.mobileCaption1 +import ai.openclaw.android.ui.mobileCaption2 +import ai.openclaw.android.ui.mobileDanger +import ai.openclaw.android.ui.mobileSuccess +import ai.openclaw.android.ui.mobileSuccessSoft +import ai.openclaw.android.ui.mobileText +import ai.openclaw.android.ui.mobileTextSecondary +import ai.openclaw.android.ui.mobileWarning +import ai.openclaw.android.ui.mobileWarningSoft +import java.io.ByteArrayOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun ChatSheetContent(viewModel: MainViewModel) { + val messages by viewModel.chatMessages.collectAsState() + val errorText by viewModel.chatError.collectAsState() + val pendingRunCount by viewModel.pendingRunCount.collectAsState() + val healthOk by viewModel.chatHealthOk.collectAsState() + val sessionKey by viewModel.chatSessionKey.collectAsState() + val mainSessionKey by viewModel.mainSessionKey.collectAsState() + val thinkingLevel by viewModel.chatThinkingLevel.collectAsState() + val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState() + val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() + val sessions by viewModel.chatSessions.collectAsState() + + LaunchedEffect(mainSessionKey) { + viewModel.loadChat(mainSessionKey) + viewModel.refreshChatSessions(limit = 200) + } + + val context = LocalContext.current + val resolver = context.contentResolver + val scope = rememberCoroutineScope() + + val attachments = remember { mutableStateListOf() } + + val pickImages = + rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> + if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult + scope.launch(Dispatchers.IO) { + val next = + uris.take(8).mapNotNull { uri -> + try { + loadImageAttachment(resolver, uri) + } catch (_: Throwable) { + null + } + } + withContext(Dispatchers.Main) { + attachments.addAll(next) + } + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChatThreadSelector( + sessionKey = sessionKey, + sessions = sessions, + mainSessionKey = mainSessionKey, + healthOk = healthOk, + onSelectSession = { key -> viewModel.switchChatSession(key) }, + ) + + if (!errorText.isNullOrBlank()) { + ChatErrorRail(errorText = errorText!!) + } + + ChatMessageListCard( + messages = messages, + pendingRunCount = pendingRunCount, + pendingToolCalls = pendingToolCalls, + streamingAssistantText = streamingAssistantText, + healthOk = healthOk, + modifier = Modifier.weight(1f, fill = true), + ) + + ChatComposer( + healthOk = healthOk, + thinkingLevel = thinkingLevel, + pendingRunCount = pendingRunCount, + attachments = attachments, + onPickImages = { pickImages.launch("image/*") }, + onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, + onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, + onRefresh = { + viewModel.refreshChat() + viewModel.refreshChatSessions(limit = 200) + }, + onAbort = { viewModel.abortChat() }, + onSend = { text -> + val outgoing = + attachments.map { att -> + OutgoingAttachment( + type = "image", + mimeType = att.mimeType, + fileName = att.fileName, + base64 = att.base64, + ) + } + viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing) + attachments.clear() + }, + ) + } +} + +@Composable +private fun ChatThreadSelector( + sessionKey: String, + sessions: List, + mainSessionKey: String, + healthOk: Boolean, + onSelectSession: (String) -> Unit, +) { + val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey) + val currentSessionLabel = + friendlySessionName(sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey) + + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Text( + text = "SESSION", + style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp), + color = mobileTextSecondary, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Text( + text = currentSessionLabel, + style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), + color = mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ChatConnectionPill(healthOk = healthOk) + } + } + + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (entry in sessionOptions) { + val active = entry.key == sessionKey + Surface( + onClick = { onSelectSession(entry.key) }, + shape = RoundedCornerShape(14.dp), + color = if (active) mobileAccent else Color.White, + border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Text( + text = friendlySessionName(entry.displayName ?: entry.key), + style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold), + color = if (active) Color.White else mobileText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } + } + } +} + +@Composable +private fun ChatConnectionPill(healthOk: Boolean) { + Surface( + shape = RoundedCornerShape(999.dp), + color = if (healthOk) mobileSuccessSoft else mobileWarningSoft, + border = BorderStroke(1.dp, if (healthOk) mobileSuccess.copy(alpha = 0.35f) else mobileWarning.copy(alpha = 0.35f)), + ) { + Text( + text = if (healthOk) "Connected" else "Offline", + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = if (healthOk) mobileSuccess else mobileWarning, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + +@Composable +private fun ChatErrorRail(errorText: String) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = androidx.compose.ui.graphics.Color.White, + shape = RoundedCornerShape(12.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger), + ) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "CHAT ERROR", + style = mobileCaption2.copy(letterSpacing = 0.6.sp), + color = mobileDanger, + ) + Text(text = errorText, style = mobileCallout, color = mobileText) + } + } +} + +data class PendingImageAttachment( + val id: String, + val fileName: String, + val mimeType: String, + val base64: String, +) + +private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment { + val mimeType = resolver.getType(uri) ?: "image/*" + val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/') + val bytes = + withContext(Dispatchers.IO) { + resolver.openInputStream(uri)?.use { input -> + val out = ByteArrayOutputStream() + input.copyTo(out) + out.toByteArray() + } ?: ByteArray(0) + } + if (bytes.isEmpty()) throw IllegalStateException("empty attachment") + val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) + return PendingImageAttachment( + id = uri.toString() + "#" + System.currentTimeMillis().toString(), + fileName = fileName, + mimeType = mimeType, + base64 = base64, + ) +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/SessionFilters.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/SessionFilters.kt new file mode 100644 index 00000000..68f3f409 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/SessionFilters.kt @@ -0,0 +1,73 @@ +package ai.openclaw.android.ui.chat + +import ai.openclaw.android.chat.ChatSessionEntry + +private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L + +/** + * Derive a human-friendly label from a raw session key. + * Examples: + * "telegram:g-agent-main-main" -> "Main" + * "agent:main:main" -> "Main" + * "discord:g-server-channel" -> "Server Channel" + * "my-custom-session" -> "My Custom Session" + */ +fun friendlySessionName(key: String): String { + // Strip common prefixes like "telegram:", "agent:", "discord:" etc. + val stripped = key.substringAfterLast(":") + + // Remove leading "g-" prefix (gateway artifact) + val cleaned = if (stripped.startsWith("g-")) stripped.removePrefix("g-") else stripped + + // Split on hyphens/underscores, title-case each word, collapse "main main" -> "Main" + val words = cleaned.split('-', '_').filter { it.isNotBlank() }.map { word -> + word.replaceFirstChar { it.uppercaseChar() } + }.distinct() + + val result = words.joinToString(" ") + return result.ifBlank { key } +} + +fun resolveSessionChoices( + currentSessionKey: String, + sessions: List, + mainSessionKey: String, + nowMs: Long = System.currentTimeMillis(), +): List { + val mainKey = mainSessionKey.trim().ifEmpty { "main" } + val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it } + val aliasKey = if (mainKey == "main") null else "main" + val cutoff = nowMs - RECENT_WINDOW_MS + val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L } + val recent = mutableListOf() + val seen = mutableSetOf() + for (entry in sorted) { + if (aliasKey != null && entry.key == aliasKey) continue + if (!seen.add(entry.key)) continue + if ((entry.updatedAtMs ?: 0L) < cutoff) continue + recent.add(entry) + } + + val result = mutableListOf() + val included = mutableSetOf() + val mainEntry = sorted.firstOrNull { it.key == mainKey } + if (mainEntry != null) { + result.add(mainEntry) + included.add(mainKey) + } else if (current == mainKey) { + result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null)) + included.add(mainKey) + } + + for (entry in recent) { + if (included.add(entry.key)) { + result.add(entry) + } + } + + if (current.isNotEmpty() && !included.contains(current)) { + result.add(ChatSessionEntry(key = current, updatedAtMs = null)) + } + + return result +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/StreamingMediaDataSource.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/StreamingMediaDataSource.kt new file mode 100644 index 00000000..329707ad --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/StreamingMediaDataSource.kt @@ -0,0 +1,98 @@ +package ai.openclaw.android.voice + +import android.media.MediaDataSource +import kotlin.math.min + +internal class StreamingMediaDataSource : MediaDataSource() { + private data class Chunk(val start: Long, val data: ByteArray) + + private val lock = Object() + private val chunks = ArrayList() + private var totalSize: Long = 0 + private var closed = false + private var finished = false + private var lastReadIndex = 0 + + fun append(data: ByteArray) { + if (data.isEmpty()) return + synchronized(lock) { + if (closed || finished) return + val chunk = Chunk(totalSize, data) + chunks.add(chunk) + totalSize += data.size.toLong() + lock.notifyAll() + } + } + + fun finish() { + synchronized(lock) { + if (closed) return + finished = true + lock.notifyAll() + } + } + + fun fail() { + synchronized(lock) { + closed = true + lock.notifyAll() + } + } + + override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { + if (position < 0) return -1 + synchronized(lock) { + while (!closed && !finished && position >= totalSize) { + lock.wait() + } + if (closed) return -1 + if (position >= totalSize && finished) return -1 + + val available = (totalSize - position).toInt() + val toRead = min(size, available) + var remaining = toRead + var destOffset = offset + var pos = position + + var index = findChunkIndex(pos) + while (remaining > 0 && index < chunks.size) { + val chunk = chunks[index] + val inChunkOffset = (pos - chunk.start).toInt() + if (inChunkOffset >= chunk.data.size) { + index++ + continue + } + val copyLen = min(remaining, chunk.data.size - inChunkOffset) + System.arraycopy(chunk.data, inChunkOffset, buffer, destOffset, copyLen) + remaining -= copyLen + destOffset += copyLen + pos += copyLen + if (inChunkOffset + copyLen >= chunk.data.size) { + index++ + } + } + + return toRead - remaining + } + } + + override fun getSize(): Long = -1 + + override fun close() { + synchronized(lock) { + closed = true + lock.notifyAll() + } + } + + private fun findChunkIndex(position: Long): Int { + var index = lastReadIndex + while (index < chunks.size) { + val chunk = chunks[index] + if (position < chunk.start + chunk.data.size) break + index++ + } + lastReadIndex = index + return index + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkDirectiveParser.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkDirectiveParser.kt new file mode 100644 index 00000000..5c80cc1f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkDirectiveParser.kt @@ -0,0 +1,191 @@ +package ai.openclaw.android.voice + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +private val directiveJson = Json { ignoreUnknownKeys = true } + +data class TalkDirective( + val voiceId: String? = null, + val modelId: String? = null, + val speed: Double? = null, + val rateWpm: Int? = null, + val stability: Double? = null, + val similarity: Double? = null, + val style: Double? = null, + val speakerBoost: Boolean? = null, + val seed: Long? = null, + val normalize: String? = null, + val language: String? = null, + val outputFormat: String? = null, + val latencyTier: Int? = null, + val once: Boolean? = null, +) + +data class TalkDirectiveParseResult( + val directive: TalkDirective?, + val stripped: String, + val unknownKeys: List, +) + +object TalkDirectiveParser { + fun parse(text: String): TalkDirectiveParseResult { + val normalized = text.replace("\r\n", "\n") + val lines = normalized.split("\n").toMutableList() + if (lines.isEmpty()) return TalkDirectiveParseResult(null, text, emptyList()) + + val firstNonEmpty = lines.indexOfFirst { it.trim().isNotEmpty() } + if (firstNonEmpty == -1) return TalkDirectiveParseResult(null, text, emptyList()) + + val head = lines[firstNonEmpty].trim() + if (!head.startsWith("{") || !head.endsWith("}")) { + return TalkDirectiveParseResult(null, text, emptyList()) + } + + val obj = parseJsonObject(head) ?: return TalkDirectiveParseResult(null, text, emptyList()) + + val speakerBoost = + boolValue(obj, listOf("speaker_boost", "speakerBoost")) + ?: boolValue(obj, listOf("no_speaker_boost", "noSpeakerBoost"))?.not() + + val directive = TalkDirective( + voiceId = stringValue(obj, listOf("voice", "voice_id", "voiceId")), + modelId = stringValue(obj, listOf("model", "model_id", "modelId")), + speed = doubleValue(obj, listOf("speed")), + rateWpm = intValue(obj, listOf("rate", "wpm")), + stability = doubleValue(obj, listOf("stability")), + similarity = doubleValue(obj, listOf("similarity", "similarity_boost", "similarityBoost")), + style = doubleValue(obj, listOf("style")), + speakerBoost = speakerBoost, + seed = longValue(obj, listOf("seed")), + normalize = stringValue(obj, listOf("normalize", "apply_text_normalization")), + language = stringValue(obj, listOf("lang", "language_code", "language")), + outputFormat = stringValue(obj, listOf("output_format", "format")), + latencyTier = intValue(obj, listOf("latency", "latency_tier", "latencyTier")), + once = boolValue(obj, listOf("once")), + ) + + val hasDirective = listOf( + directive.voiceId, + directive.modelId, + directive.speed, + directive.rateWpm, + directive.stability, + directive.similarity, + directive.style, + directive.speakerBoost, + directive.seed, + directive.normalize, + directive.language, + directive.outputFormat, + directive.latencyTier, + directive.once, + ).any { it != null } + + if (!hasDirective) return TalkDirectiveParseResult(null, text, emptyList()) + + val knownKeys = setOf( + "voice", "voice_id", "voiceid", + "model", "model_id", "modelid", + "speed", "rate", "wpm", + "stability", "similarity", "similarity_boost", "similarityboost", + "style", + "speaker_boost", "speakerboost", + "no_speaker_boost", "nospeakerboost", + "seed", + "normalize", "apply_text_normalization", + "lang", "language_code", "language", + "output_format", "format", + "latency", "latency_tier", "latencytier", + "once", + ) + val unknownKeys = obj.keys.filter { !knownKeys.contains(it.lowercase()) }.sorted() + + lines.removeAt(firstNonEmpty) + if (firstNonEmpty < lines.size) { + if (lines[firstNonEmpty].trim().isEmpty()) { + lines.removeAt(firstNonEmpty) + } + } + + return TalkDirectiveParseResult(directive, lines.joinToString("\n"), unknownKeys) + } + + private fun parseJsonObject(line: String): JsonObject? { + return try { + directiveJson.parseToJsonElement(line) as? JsonObject + } catch (_: Throwable) { + null + } + } + + private fun stringValue(obj: JsonObject, keys: List): String? { + for (key in keys) { + val value = obj[key].asStringOrNull()?.trim() + if (!value.isNullOrEmpty()) return value + } + return null + } + + private fun doubleValue(obj: JsonObject, keys: List): Double? { + for (key in keys) { + val value = obj[key].asDoubleOrNull() + if (value != null) return value + } + return null + } + + private fun intValue(obj: JsonObject, keys: List): Int? { + for (key in keys) { + val value = obj[key].asIntOrNull() + if (value != null) return value + } + return null + } + + private fun longValue(obj: JsonObject, keys: List): Long? { + for (key in keys) { + val value = obj[key].asLongOrNull() + if (value != null) return value + } + return null + } + + private fun boolValue(obj: JsonObject, keys: List): Boolean? { + for (key in keys) { + val value = obj[key].asBooleanOrNull() + if (value != null) return value + } + return null + } +} + +private fun JsonElement?.asStringOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it.isString }?.content + +private fun JsonElement?.asDoubleOrNull(): Double? { + val primitive = this as? JsonPrimitive ?: return null + return primitive.content.toDoubleOrNull() +} + +private fun JsonElement?.asIntOrNull(): Int? { + val primitive = this as? JsonPrimitive ?: return null + return primitive.content.toIntOrNull() +} + +private fun JsonElement?.asLongOrNull(): Long? { + val primitive = this as? JsonPrimitive ?: return null + return primitive.content.toLongOrNull() +} + +private fun JsonElement?.asBooleanOrNull(): Boolean? { + val primitive = this as? JsonPrimitive ?: return null + val content = primitive.content.trim().lowercase() + return when (content) { + "true", "yes", "1" -> true + "false", "no", "0" -> false + else -> null + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt new file mode 100644 index 00000000..f0048198 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt @@ -0,0 +1,1317 @@ +package ai.openclaw.android.voice + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.media.MediaPlayer +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import androidx.core.content.ContextCompat +import ai.openclaw.android.gateway.GatewaySession +import ai.openclaw.android.isCanonicalMainSessionKey +import ai.openclaw.android.normalizeMainKey +import java.net.HttpURLConnection +import java.net.URL +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.math.max + +class TalkModeManager( + private val context: Context, + private val scope: CoroutineScope, + private val session: GatewaySession, + private val supportsChatSubscribe: Boolean, + private val isConnected: () -> Boolean, +) { + companion object { + private const val tag = "TalkMode" + private const val defaultModelIdFallback = "eleven_v3" + private const val defaultOutputFormatFallback = "pcm_24000" + private const val defaultTalkProvider = "elevenlabs" + + internal data class TalkProviderConfigSelection( + val provider: String, + val config: JsonObject, + val normalizedPayload: Boolean, + ) + + private fun normalizeTalkProviderId(raw: String?): String? { + val trimmed = raw?.trim()?.lowercase().orEmpty() + return trimmed.takeIf { it.isNotEmpty() } + } + + internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? { + if (talk == null) return null + val rawProvider = talk["provider"].asStringOrNull() + val rawProviders = talk["providers"].asObjectOrNull() + val hasNormalizedPayload = rawProvider != null || rawProviders != null + if (hasNormalizedPayload) { + val providers = + rawProviders?.entries?.mapNotNull { (key, value) -> + val providerId = normalizeTalkProviderId(key) ?: return@mapNotNull null + val providerConfig = value.asObjectOrNull() ?: return@mapNotNull null + providerId to providerConfig + }?.toMap().orEmpty() + val providerId = + normalizeTalkProviderId(rawProvider) + ?: providers.keys.sorted().firstOrNull() + ?: defaultTalkProvider + return TalkProviderConfigSelection( + provider = providerId, + config = providers[providerId] ?: buildJsonObject {}, + normalizedPayload = true, + ) + } + return TalkProviderConfigSelection( + provider = defaultTalkProvider, + config = talk, + normalizedPayload = false, + ) + } + } + + private val mainHandler = Handler(Looper.getMainLooper()) + private val json = Json { ignoreUnknownKeys = true } + + private val _isEnabled = MutableStateFlow(false) + val isEnabled: StateFlow = _isEnabled + + private val _isListening = MutableStateFlow(false) + val isListening: StateFlow = _isListening + + private val _isSpeaking = MutableStateFlow(false) + val isSpeaking: StateFlow = _isSpeaking + + private val _statusText = MutableStateFlow("Off") + val statusText: StateFlow = _statusText + + private val _lastAssistantText = MutableStateFlow(null) + val lastAssistantText: StateFlow = _lastAssistantText + + private val _usingFallbackTts = MutableStateFlow(false) + val usingFallbackTts: StateFlow = _usingFallbackTts + + private var recognizer: SpeechRecognizer? = null + private var restartJob: Job? = null + private var stopRequested = false + private var listeningMode = false + + private var silenceJob: Job? = null + private val silenceWindowMs = 700L + private var lastTranscript: String = "" + private var lastHeardAtMs: Long? = null + private var lastSpokenText: String? = null + private var lastInterruptedAtSeconds: Double? = null + + private var defaultVoiceId: String? = null + private var currentVoiceId: String? = null + private var fallbackVoiceId: String? = null + private var defaultModelId: String? = null + private var currentModelId: String? = null + private var defaultOutputFormat: String? = null + private var apiKey: String? = null + private var voiceAliases: Map = emptyMap() + private var interruptOnSpeech: Boolean = true + private var voiceOverrideActive = false + private var modelOverrideActive = false + private var mainSessionKey: String = "main" + + private var pendingRunId: String? = null + private var pendingFinal: CompletableDeferred? = null + private var chatSubscribedSessionKey: String? = null + + private var player: MediaPlayer? = null + private var streamingSource: StreamingMediaDataSource? = null + private var pcmTrack: AudioTrack? = null + @Volatile private var pcmStopRequested = false + private var systemTts: TextToSpeech? = null + private var systemTtsPending: CompletableDeferred? = null + private var systemTtsPendingId: String? = null + + fun setMainSessionKey(sessionKey: String?) { + val trimmed = sessionKey?.trim().orEmpty() + if (trimmed.isEmpty()) return + if (isCanonicalMainSessionKey(mainSessionKey)) return + mainSessionKey = trimmed + } + + fun setEnabled(enabled: Boolean) { + if (_isEnabled.value == enabled) return + _isEnabled.value = enabled + if (enabled) { + Log.d(tag, "enabled") + start() + } else { + Log.d(tag, "disabled") + stop() + } + } + + fun handleGatewayEvent(event: String, payloadJson: String?) { + if (event != "chat") return + if (payloadJson.isNullOrBlank()) return + val pending = pendingRunId ?: return + val obj = + try { + json.parseToJsonElement(payloadJson).asObjectOrNull() + } catch (_: Throwable) { + null + } ?: return + val runId = obj["runId"].asStringOrNull() ?: return + if (runId != pending) return + val state = obj["state"].asStringOrNull() ?: return + if (state == "final") { + pendingFinal?.complete(true) + pendingFinal = null + pendingRunId = null + } + } + + private fun start() { + mainHandler.post { + if (_isListening.value) return@post + stopRequested = false + listeningMode = true + Log.d(tag, "start") + + if (!SpeechRecognizer.isRecognitionAvailable(context)) { + _statusText.value = "Speech recognizer unavailable" + Log.w(tag, "speech recognizer unavailable") + return@post + } + + val micOk = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (!micOk) { + _statusText.value = "Microphone permission required" + Log.w(tag, "microphone permission required") + return@post + } + + try { + recognizer?.destroy() + recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } + startListeningInternal(markListening = true) + startSilenceMonitor() + Log.d(tag, "listening") + } catch (err: Throwable) { + _statusText.value = "Start failed: ${err.message ?: err::class.simpleName}" + Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}") + } + } + } + + private fun stop() { + stopRequested = true + listeningMode = false + restartJob?.cancel() + restartJob = null + silenceJob?.cancel() + silenceJob = null + lastTranscript = "" + lastHeardAtMs = null + _isListening.value = false + _statusText.value = "Off" + stopSpeaking() + _usingFallbackTts.value = false + chatSubscribedSessionKey = null + + mainHandler.post { + recognizer?.cancel() + recognizer?.destroy() + recognizer = null + } + systemTts?.stop() + systemTtsPending?.cancel() + systemTtsPending = null + systemTtsPendingId = null + } + + private fun startListeningInternal(markListening: Boolean) { + val r = recognizer ?: return + val intent = + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) + putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) + } + + if (markListening) { + _statusText.value = "Listening" + _isListening.value = true + } + r.startListening(intent) + } + + private fun scheduleRestart(delayMs: Long = 350) { + if (stopRequested) return + restartJob?.cancel() + restartJob = + scope.launch { + delay(delayMs) + mainHandler.post { + if (stopRequested) return@post + try { + recognizer?.cancel() + val shouldListen = listeningMode + val shouldInterrupt = _isSpeaking.value && interruptOnSpeech + if (!shouldListen && !shouldInterrupt) return@post + startListeningInternal(markListening = shouldListen) + } catch (_: Throwable) { + // handled by onError + } + } + } + } + + private fun handleTranscript(text: String, isFinal: Boolean) { + val trimmed = text.trim() + if (_isSpeaking.value && interruptOnSpeech) { + if (shouldInterrupt(trimmed)) { + stopSpeaking() + } + return + } + + if (!_isListening.value) return + + if (trimmed.isNotEmpty()) { + lastTranscript = trimmed + lastHeardAtMs = SystemClock.elapsedRealtime() + } + + if (isFinal) { + lastTranscript = trimmed + } + } + + private fun startSilenceMonitor() { + silenceJob?.cancel() + silenceJob = + scope.launch { + while (_isEnabled.value) { + delay(200) + checkSilence() + } + } + } + + private fun checkSilence() { + if (!_isListening.value) return + val transcript = lastTranscript.trim() + if (transcript.isEmpty()) return + val lastHeard = lastHeardAtMs ?: return + val elapsed = SystemClock.elapsedRealtime() - lastHeard + if (elapsed < silenceWindowMs) return + scope.launch { finalizeTranscript(transcript) } + } + + private suspend fun finalizeTranscript(transcript: String) { + listeningMode = false + _isListening.value = false + _statusText.value = "Thinking…" + lastTranscript = "" + lastHeardAtMs = null + + reloadConfig() + val prompt = buildPrompt(transcript) + if (!isConnected()) { + _statusText.value = "Gateway not connected" + Log.w(tag, "finalize: gateway not connected") + start() + return + } + + try { + val startedAt = System.currentTimeMillis().toDouble() / 1000.0 + subscribeChatIfNeeded(session = session, sessionKey = mainSessionKey) + Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}") + val runId = sendChat(prompt, session) + Log.d(tag, "chat.send ok runId=$runId") + val ok = waitForChatFinal(runId) + if (!ok) { + Log.w(tag, "chat final timeout runId=$runId; attempting history fallback") + } + val assistant = waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000) + if (assistant.isNullOrBlank()) { + _statusText.value = "No reply" + Log.w(tag, "assistant text timeout runId=$runId") + start() + return + } + Log.d(tag, "assistant text ok chars=${assistant.length}") + playAssistant(assistant) + } catch (err: Throwable) { + _statusText.value = "Talk failed: ${err.message ?: err::class.simpleName}" + Log.w(tag, "finalize failed: ${err.message ?: err::class.simpleName}") + } + + if (_isEnabled.value) { + start() + } + } + + private suspend fun subscribeChatIfNeeded(session: GatewaySession, sessionKey: String) { + if (!supportsChatSubscribe) return + val key = sessionKey.trim() + if (key.isEmpty()) return + if (chatSubscribedSessionKey == key) return + val sent = session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") + if (sent) { + chatSubscribedSessionKey = key + Log.d(tag, "chat.subscribe ok sessionKey=$key") + } else { + Log.w(tag, "chat.subscribe failed sessionKey=$key") + } + } + + private fun buildPrompt(transcript: String): String { + val lines = mutableListOf( + "Talk Mode active. Reply in a concise, spoken tone.", + "You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"\",\"once\":true}.", + ) + lastInterruptedAtSeconds?.let { + lines.add("Assistant speech interrupted at ${"%.1f".format(it)}s.") + lastInterruptedAtSeconds = null + } + lines.add("") + lines.add(transcript) + return lines.joinToString("\n") + } + + private suspend fun sendChat(message: String, session: GatewaySession): String { + val runId = UUID.randomUUID().toString() + val params = + buildJsonObject { + put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" })) + put("message", JsonPrimitive(message)) + put("thinking", JsonPrimitive("low")) + put("timeoutMs", JsonPrimitive(30_000)) + put("idempotencyKey", JsonPrimitive(runId)) + } + val res = session.request("chat.send", params.toString()) + val parsed = parseRunId(res) ?: runId + if (parsed != runId) { + pendingRunId = parsed + } + return parsed + } + + private suspend fun waitForChatFinal(runId: String): Boolean { + pendingFinal?.cancel() + val deferred = CompletableDeferred() + pendingRunId = runId + pendingFinal = deferred + + val result = + withContext(Dispatchers.IO) { + try { + kotlinx.coroutines.withTimeout(120_000) { deferred.await() } + } catch (_: Throwable) { + false + } + } + + if (!result) { + pendingFinal = null + pendingRunId = null + } + return result + } + + private suspend fun waitForAssistantText( + session: GatewaySession, + sinceSeconds: Double, + timeoutMs: Long, + ): String? { + val deadline = SystemClock.elapsedRealtime() + timeoutMs + while (SystemClock.elapsedRealtime() < deadline) { + val text = fetchLatestAssistantText(session, sinceSeconds) + if (!text.isNullOrBlank()) return text + delay(300) + } + return null + } + + private suspend fun fetchLatestAssistantText( + session: GatewaySession, + sinceSeconds: Double? = null, + ): String? { + val key = mainSessionKey.ifBlank { "main" } + val res = session.request("chat.history", "{\"sessionKey\":\"$key\"}") + val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null + val messages = root["messages"] as? JsonArray ?: return null + for (item in messages.reversed()) { + val obj = item.asObjectOrNull() ?: continue + if (obj["role"].asStringOrNull() != "assistant") continue + if (sinceSeconds != null) { + val timestamp = obj["timestamp"].asDoubleOrNull() + if (timestamp != null && !TalkModeRuntime.isMessageTimestampAfter(timestamp, sinceSeconds)) continue + } + val content = obj["content"] as? JsonArray ?: continue + val text = + content.mapNotNull { entry -> + entry.asObjectOrNull()?.get("text")?.asStringOrNull()?.trim() + }.filter { it.isNotEmpty() } + if (text.isNotEmpty()) return text.joinToString("\n") + } + return null + } + + private suspend fun playAssistant(text: String) { + val parsed = TalkDirectiveParser.parse(text) + if (parsed.unknownKeys.isNotEmpty()) { + Log.w(tag, "Unknown talk directive keys: ${parsed.unknownKeys}") + } + val directive = parsed.directive + val cleaned = parsed.stripped.trim() + if (cleaned.isEmpty()) return + _lastAssistantText.value = cleaned + + val requestedVoice = directive?.voiceId?.trim()?.takeIf { it.isNotEmpty() } + val resolvedVoice = resolveVoiceAlias(requestedVoice) + if (requestedVoice != null && resolvedVoice == null) { + Log.w(tag, "unknown voice alias: $requestedVoice") + } + + if (directive?.voiceId != null) { + if (directive.once != true) { + currentVoiceId = resolvedVoice + voiceOverrideActive = true + } + } + if (directive?.modelId != null) { + if (directive.once != true) { + currentModelId = directive.modelId + modelOverrideActive = true + } + } + + val apiKey = + apiKey?.trim()?.takeIf { it.isNotEmpty() } + ?: System.getenv("ELEVENLABS_API_KEY")?.trim() + val preferredVoice = resolvedVoice ?: currentVoiceId ?: defaultVoiceId + val voiceId = + if (!apiKey.isNullOrEmpty()) { + resolveVoiceId(preferredVoice, apiKey) + } else { + null + } + + _statusText.value = "Speaking…" + _isSpeaking.value = true + lastSpokenText = cleaned + ensureInterruptListener() + + try { + val canUseElevenLabs = !voiceId.isNullOrBlank() && !apiKey.isNullOrEmpty() + if (!canUseElevenLabs) { + if (voiceId.isNullOrBlank()) { + Log.w(tag, "missing voiceId; falling back to system voice") + } + if (apiKey.isNullOrEmpty()) { + Log.w(tag, "missing ELEVENLABS_API_KEY; falling back to system voice") + } + _usingFallbackTts.value = true + _statusText.value = "Speaking (System)…" + speakWithSystemTts(cleaned) + } else { + _usingFallbackTts.value = false + val ttsStarted = SystemClock.elapsedRealtime() + val modelId = directive?.modelId ?: currentModelId ?: defaultModelId + val request = + ElevenLabsRequest( + text = cleaned, + modelId = modelId, + outputFormat = + TalkModeRuntime.validatedOutputFormat(directive?.outputFormat ?: defaultOutputFormat), + speed = TalkModeRuntime.resolveSpeed(directive?.speed, directive?.rateWpm), + stability = TalkModeRuntime.validatedStability(directive?.stability, modelId), + similarity = TalkModeRuntime.validatedUnit(directive?.similarity), + style = TalkModeRuntime.validatedUnit(directive?.style), + speakerBoost = directive?.speakerBoost, + seed = TalkModeRuntime.validatedSeed(directive?.seed), + normalize = TalkModeRuntime.validatedNormalize(directive?.normalize), + language = TalkModeRuntime.validatedLanguage(directive?.language), + latencyTier = TalkModeRuntime.validatedLatencyTier(directive?.latencyTier), + ) + streamAndPlay(voiceId = voiceId!!, apiKey = apiKey!!, request = request) + Log.d(tag, "elevenlabs stream ok durMs=${SystemClock.elapsedRealtime() - ttsStarted}") + } + } catch (err: Throwable) { + Log.w(tag, "speak failed: ${err.message ?: err::class.simpleName}; falling back to system voice") + try { + _usingFallbackTts.value = true + _statusText.value = "Speaking (System)…" + speakWithSystemTts(cleaned) + } catch (fallbackErr: Throwable) { + _statusText.value = "Speak failed: ${fallbackErr.message ?: fallbackErr::class.simpleName}" + Log.w(tag, "system voice failed: ${fallbackErr.message ?: fallbackErr::class.simpleName}") + } + } + + _isSpeaking.value = false + } + + private suspend fun streamAndPlay(voiceId: String, apiKey: String, request: ElevenLabsRequest) { + stopSpeaking(resetInterrupt = false) + + pcmStopRequested = false + val pcmSampleRate = TalkModeRuntime.parsePcmSampleRate(request.outputFormat) + if (pcmSampleRate != null) { + try { + streamAndPlayPcm(voiceId = voiceId, apiKey = apiKey, request = request, sampleRate = pcmSampleRate) + return + } catch (err: Throwable) { + if (pcmStopRequested) return + Log.w(tag, "pcm playback failed; falling back to mp3: ${err.message ?: err::class.simpleName}") + } + } + + streamAndPlayMp3(voiceId = voiceId, apiKey = apiKey, request = request) + } + + private suspend fun streamAndPlayMp3(voiceId: String, apiKey: String, request: ElevenLabsRequest) { + val dataSource = StreamingMediaDataSource() + streamingSource = dataSource + + val player = MediaPlayer() + this.player = player + + val prepared = CompletableDeferred() + val finished = CompletableDeferred() + + player.setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_ASSISTANT) + .build(), + ) + player.setOnPreparedListener { + it.start() + prepared.complete(Unit) + } + player.setOnCompletionListener { + finished.complete(Unit) + } + player.setOnErrorListener { _, _, _ -> + finished.completeExceptionally(IllegalStateException("MediaPlayer error")) + true + } + + player.setDataSource(dataSource) + withContext(Dispatchers.Main) { + player.prepareAsync() + } + + val fetchError = CompletableDeferred() + val fetchJob = + scope.launch(Dispatchers.IO) { + try { + streamTts(voiceId = voiceId, apiKey = apiKey, request = request, sink = dataSource) + fetchError.complete(null) + } catch (err: Throwable) { + dataSource.fail() + fetchError.complete(err) + } + } + + Log.d(tag, "play start") + try { + prepared.await() + finished.await() + fetchError.await()?.let { throw it } + } finally { + fetchJob.cancel() + cleanupPlayer() + } + Log.d(tag, "play done") + } + + private suspend fun streamAndPlayPcm( + voiceId: String, + apiKey: String, + request: ElevenLabsRequest, + sampleRate: Int, + ) { + val minBuffer = + AudioTrack.getMinBufferSize( + sampleRate, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, + ) + if (minBuffer <= 0) { + throw IllegalStateException("AudioTrack buffer size invalid: $minBuffer") + } + + val bufferSize = max(minBuffer * 2, 8 * 1024) + val track = + AudioTrack( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .setUsage(AudioAttributes.USAGE_ASSISTANT) + .build(), + AudioFormat.Builder() + .setSampleRate(sampleRate) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .build(), + bufferSize, + AudioTrack.MODE_STREAM, + AudioManager.AUDIO_SESSION_ID_GENERATE, + ) + if (track.state != AudioTrack.STATE_INITIALIZED) { + track.release() + throw IllegalStateException("AudioTrack init failed") + } + pcmTrack = track + track.play() + + Log.d(tag, "pcm play start sampleRate=$sampleRate bufferSize=$bufferSize") + try { + streamPcm(voiceId = voiceId, apiKey = apiKey, request = request, track = track) + } finally { + cleanupPcmTrack() + } + Log.d(tag, "pcm play done") + } + + private suspend fun speakWithSystemTts(text: String) { + val trimmed = text.trim() + if (trimmed.isEmpty()) return + val ok = ensureSystemTts() + if (!ok) { + throw IllegalStateException("system TTS unavailable") + } + + val tts = systemTts ?: throw IllegalStateException("system TTS unavailable") + val utteranceId = "talk-${UUID.randomUUID()}" + val deferred = CompletableDeferred() + systemTtsPending?.cancel() + systemTtsPending = deferred + systemTtsPendingId = utteranceId + + withContext(Dispatchers.Main) { + val params = Bundle() + tts.speak(trimmed, TextToSpeech.QUEUE_FLUSH, params, utteranceId) + } + + withContext(Dispatchers.IO) { + try { + kotlinx.coroutines.withTimeout(180_000) { deferred.await() } + } catch (err: Throwable) { + throw err + } + } + } + + private suspend fun ensureSystemTts(): Boolean { + if (systemTts != null) return true + return withContext(Dispatchers.Main) { + val deferred = CompletableDeferred() + val tts = + try { + TextToSpeech(context) { status -> + deferred.complete(status == TextToSpeech.SUCCESS) + } + } catch (_: Throwable) { + deferred.complete(false) + null + } + if (tts == null) return@withContext false + + tts.setOnUtteranceProgressListener( + object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) {} + + override fun onDone(utteranceId: String?) { + if (utteranceId == null) return + if (utteranceId != systemTtsPendingId) return + systemTtsPending?.complete(Unit) + systemTtsPending = null + systemTtsPendingId = null + } + + @Suppress("OVERRIDE_DEPRECATION") + @Deprecated("Deprecated in Java") + override fun onError(utteranceId: String?) { + if (utteranceId == null) return + if (utteranceId != systemTtsPendingId) return + systemTtsPending?.completeExceptionally(IllegalStateException("system TTS error")) + systemTtsPending = null + systemTtsPendingId = null + } + + override fun onError(utteranceId: String?, errorCode: Int) { + if (utteranceId == null) return + if (utteranceId != systemTtsPendingId) return + systemTtsPending?.completeExceptionally(IllegalStateException("system TTS error $errorCode")) + systemTtsPending = null + systemTtsPendingId = null + } + }, + ) + + val ok = + try { + deferred.await() + } catch (_: Throwable) { + false + } + if (ok) { + systemTts = tts + } else { + tts.shutdown() + } + ok + } + } + + private fun stopSpeaking(resetInterrupt: Boolean = true) { + pcmStopRequested = true + if (!_isSpeaking.value) { + cleanupPlayer() + cleanupPcmTrack() + systemTts?.stop() + systemTtsPending?.cancel() + systemTtsPending = null + systemTtsPendingId = null + return + } + if (resetInterrupt) { + val currentMs = player?.currentPosition?.toDouble() ?: 0.0 + lastInterruptedAtSeconds = currentMs / 1000.0 + } + cleanupPlayer() + cleanupPcmTrack() + systemTts?.stop() + systemTtsPending?.cancel() + systemTtsPending = null + systemTtsPendingId = null + _isSpeaking.value = false + } + + private fun cleanupPlayer() { + player?.stop() + player?.release() + player = null + streamingSource?.close() + streamingSource = null + } + + private fun cleanupPcmTrack() { + val track = pcmTrack ?: return + try { + track.pause() + track.flush() + track.stop() + } catch (_: Throwable) { + // ignore cleanup errors + } finally { + track.release() + } + pcmTrack = null + } + + private fun shouldInterrupt(transcript: String): Boolean { + val trimmed = transcript.trim() + if (trimmed.length < 3) return false + val spoken = lastSpokenText?.lowercase() + if (spoken != null && spoken.contains(trimmed.lowercase())) return false + return true + } + + private suspend fun reloadConfig() { + val envVoice = System.getenv("ELEVENLABS_VOICE_ID")?.trim() + val sagVoice = System.getenv("SAG_VOICE_ID")?.trim() + val envKey = System.getenv("ELEVENLABS_API_KEY")?.trim() + try { + val res = session.request("talk.config", """{"includeSecrets":true}""") + val root = json.parseToJsonElement(res).asObjectOrNull() + val config = root?.get("config").asObjectOrNull() + val talk = config?.get("talk").asObjectOrNull() + val selection = selectTalkProviderConfig(talk) + val activeProvider = selection?.provider ?: defaultTalkProvider + val activeConfig = selection?.config + val sessionCfg = config?.get("session").asObjectOrNull() + val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) + val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val aliases = + activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) -> + val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + normalizeAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id } + }?.toMap().orEmpty() + val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val outputFormat = + activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() + + if (!isCanonicalMainSessionKey(mainSessionKey)) { + mainSessionKey = mainKey + } + defaultVoiceId = + if (activeProvider == defaultTalkProvider) { + voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } + } else { + voice + } + voiceAliases = aliases + if (!voiceOverrideActive) currentVoiceId = defaultVoiceId + defaultModelId = model ?: defaultModelIdFallback + if (!modelOverrideActive) currentModelId = defaultModelId + defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback + apiKey = + if (activeProvider == defaultTalkProvider) { + key ?: envKey?.takeIf { it.isNotEmpty() } + } else { + null + } + if (interrupt != null) interruptOnSpeech = interrupt + if (activeProvider != defaultTalkProvider) { + Log.w(tag, "talk provider $activeProvider unsupported; using system voice fallback") + } else if (selection?.normalizedPayload == true) { + Log.d(tag, "talk config provider=elevenlabs") + } + } catch (_: Throwable) { + defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } + defaultModelId = defaultModelIdFallback + if (!modelOverrideActive) currentModelId = defaultModelId + apiKey = envKey?.takeIf { it.isNotEmpty() } + voiceAliases = emptyMap() + defaultOutputFormat = defaultOutputFormatFallback + } + } + + private fun parseRunId(jsonString: String): String? { + val obj = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return null + return obj["runId"].asStringOrNull() + } + + private suspend fun streamTts( + voiceId: String, + apiKey: String, + request: ElevenLabsRequest, + sink: StreamingMediaDataSource, + ) { + withContext(Dispatchers.IO) { + val conn = openTtsConnection(voiceId = voiceId, apiKey = apiKey, request = request) + try { + val payload = buildRequestPayload(request) + conn.outputStream.use { it.write(payload.toByteArray()) } + + val code = conn.responseCode + if (code >= 400) { + val message = conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" + sink.fail() + throw IllegalStateException("ElevenLabs failed: $code $message") + } + + val buffer = ByteArray(8 * 1024) + conn.inputStream.use { input -> + while (true) { + val read = input.read(buffer) + if (read <= 0) break + sink.append(buffer.copyOf(read)) + } + } + sink.finish() + } finally { + conn.disconnect() + } + } + } + + private suspend fun streamPcm( + voiceId: String, + apiKey: String, + request: ElevenLabsRequest, + track: AudioTrack, + ) { + withContext(Dispatchers.IO) { + val conn = openTtsConnection(voiceId = voiceId, apiKey = apiKey, request = request) + try { + val payload = buildRequestPayload(request) + conn.outputStream.use { it.write(payload.toByteArray()) } + + val code = conn.responseCode + if (code >= 400) { + val message = conn.errorStream?.readBytes()?.toString(Charsets.UTF_8) ?: "" + throw IllegalStateException("ElevenLabs failed: $code $message") + } + + val buffer = ByteArray(8 * 1024) + conn.inputStream.use { input -> + while (true) { + if (pcmStopRequested) return@withContext + val read = input.read(buffer) + if (read <= 0) break + var offset = 0 + while (offset < read) { + if (pcmStopRequested) return@withContext + val wrote = + try { + track.write(buffer, offset, read - offset) + } catch (err: Throwable) { + if (pcmStopRequested) return@withContext + throw err + } + if (wrote <= 0) { + if (pcmStopRequested) return@withContext + throw IllegalStateException("AudioTrack write failed: $wrote") + } + offset += wrote + } + } + } + } finally { + conn.disconnect() + } + } + } + + private fun openTtsConnection( + voiceId: String, + apiKey: String, + request: ElevenLabsRequest, + ): HttpURLConnection { + val baseUrl = "https://api.elevenlabs.io/v1/text-to-speech/$voiceId/stream" + val latencyTier = request.latencyTier + val url = + if (latencyTier != null) { + URL("$baseUrl?optimize_streaming_latency=$latencyTier") + } else { + URL(baseUrl) + } + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.connectTimeout = 30_000 + conn.readTimeout = 30_000 + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Accept", resolveAcceptHeader(request.outputFormat)) + conn.setRequestProperty("xi-api-key", apiKey) + conn.doOutput = true + return conn + } + + private fun resolveAcceptHeader(outputFormat: String?): String { + val normalized = outputFormat?.trim()?.lowercase().orEmpty() + return if (normalized.startsWith("pcm_")) "audio/pcm" else "audio/mpeg" + } + + private fun buildRequestPayload(request: ElevenLabsRequest): String { + val voiceSettingsEntries = + buildJsonObject { + request.speed?.let { put("speed", JsonPrimitive(it)) } + request.stability?.let { put("stability", JsonPrimitive(it)) } + request.similarity?.let { put("similarity_boost", JsonPrimitive(it)) } + request.style?.let { put("style", JsonPrimitive(it)) } + request.speakerBoost?.let { put("use_speaker_boost", JsonPrimitive(it)) } + } + + val payload = + buildJsonObject { + put("text", JsonPrimitive(request.text)) + request.modelId?.takeIf { it.isNotEmpty() }?.let { put("model_id", JsonPrimitive(it)) } + request.outputFormat?.takeIf { it.isNotEmpty() }?.let { put("output_format", JsonPrimitive(it)) } + request.seed?.let { put("seed", JsonPrimitive(it)) } + request.normalize?.let { put("apply_text_normalization", JsonPrimitive(it)) } + request.language?.let { put("language_code", JsonPrimitive(it)) } + if (voiceSettingsEntries.isNotEmpty()) { + put("voice_settings", voiceSettingsEntries) + } + } + + return payload.toString() + } + + private data class ElevenLabsRequest( + val text: String, + val modelId: String?, + val outputFormat: String?, + val speed: Double?, + val stability: Double?, + val similarity: Double?, + val style: Double?, + val speakerBoost: Boolean?, + val seed: Long?, + val normalize: String?, + val language: String?, + val latencyTier: Int?, + ) + + private object TalkModeRuntime { + fun resolveSpeed(speed: Double?, rateWpm: Int?): Double? { + if (rateWpm != null && rateWpm > 0) { + val resolved = rateWpm.toDouble() / 175.0 + if (resolved <= 0.5 || resolved >= 2.0) return null + return resolved + } + if (speed != null) { + if (speed <= 0.5 || speed >= 2.0) return null + return speed + } + return null + } + + fun validatedUnit(value: Double?): Double? { + if (value == null) return null + if (value < 0 || value > 1) return null + return value + } + + fun validatedStability(value: Double?, modelId: String?): Double? { + if (value == null) return null + val normalized = modelId?.trim()?.lowercase() + if (normalized == "eleven_v3") { + return if (value == 0.0 || value == 0.5 || value == 1.0) value else null + } + return validatedUnit(value) + } + + fun validatedSeed(value: Long?): Long? { + if (value == null) return null + if (value < 0 || value > 4294967295L) return null + return value + } + + fun validatedNormalize(value: String?): String? { + val normalized = value?.trim()?.lowercase() ?: return null + return if (normalized in listOf("auto", "on", "off")) normalized else null + } + + fun validatedLanguage(value: String?): String? { + val normalized = value?.trim()?.lowercase() ?: return null + if (normalized.length != 2) return null + if (!normalized.all { it in 'a'..'z' }) return null + return normalized + } + + fun validatedOutputFormat(value: String?): String? { + val trimmed = value?.trim()?.lowercase() ?: return null + if (trimmed.isEmpty()) return null + if (trimmed.startsWith("mp3_")) return trimmed + return if (parsePcmSampleRate(trimmed) != null) trimmed else null + } + + fun validatedLatencyTier(value: Int?): Int? { + if (value == null) return null + if (value < 0 || value > 4) return null + return value + } + + fun parsePcmSampleRate(value: String?): Int? { + val trimmed = value?.trim()?.lowercase() ?: return null + if (!trimmed.startsWith("pcm_")) return null + val suffix = trimmed.removePrefix("pcm_") + val digits = suffix.takeWhile { it.isDigit() } + val rate = digits.toIntOrNull() ?: return null + return if (rate in setOf(16000, 22050, 24000, 44100)) rate else null + } + + fun isMessageTimestampAfter(timestamp: Double, sinceSeconds: Double): Boolean { + val sinceMs = sinceSeconds * 1000 + return if (timestamp > 10_000_000_000) { + timestamp >= sinceMs - 500 + } else { + timestamp >= sinceSeconds - 0.5 + } + } + } + + private fun ensureInterruptListener() { + if (!interruptOnSpeech || !_isEnabled.value) return + mainHandler.post { + if (stopRequested) return@post + if (!SpeechRecognizer.isRecognitionAvailable(context)) return@post + try { + if (recognizer == null) { + recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } + } + recognizer?.cancel() + startListeningInternal(markListening = false) + } catch (_: Throwable) { + // ignore + } + } + } + + private fun resolveVoiceAlias(value: String?): String? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isEmpty()) return null + val normalized = normalizeAliasKey(trimmed) + voiceAliases[normalized]?.let { return it } + if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed + return if (isLikelyVoiceId(trimmed)) trimmed else null + } + + private suspend fun resolveVoiceId(preferred: String?, apiKey: String): String? { + val trimmed = preferred?.trim().orEmpty() + if (trimmed.isNotEmpty()) { + val resolved = resolveVoiceAlias(trimmed) + if (resolved != null) return resolved + Log.w(tag, "unknown voice alias $trimmed") + } + fallbackVoiceId?.let { return it } + + return try { + val voices = listVoices(apiKey) + val first = voices.firstOrNull() ?: return null + fallbackVoiceId = first.voiceId + if (defaultVoiceId.isNullOrBlank()) { + defaultVoiceId = first.voiceId + } + if (!voiceOverrideActive) { + currentVoiceId = first.voiceId + } + val name = first.name ?: "unknown" + Log.d(tag, "default voice selected $name (${first.voiceId})") + first.voiceId + } catch (err: Throwable) { + Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}") + null + } + } + + private suspend fun listVoices(apiKey: String): List { + return withContext(Dispatchers.IO) { + val url = URL("https://api.elevenlabs.io/v1/voices") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + conn.setRequestProperty("xi-api-key", apiKey) + + val code = conn.responseCode + val stream = if (code >= 400) conn.errorStream else conn.inputStream + val data = stream.readBytes() + if (code >= 400) { + val message = data.toString(Charsets.UTF_8) + throw IllegalStateException("ElevenLabs voices failed: $code $message") + } + + val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull() + val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList()) + voices.mapNotNull { entry -> + val obj = entry.asObjectOrNull() ?: return@mapNotNull null + val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null + val name = obj["name"].asStringOrNull() + ElevenLabsVoice(voiceId, name) + } + } + } + + private fun isLikelyVoiceId(value: String): Boolean { + if (value.length < 10) return false + return value.all { it.isLetterOrDigit() || it == '-' || it == '_' } + } + + private fun normalizeAliasKey(value: String): String = + value.trim().lowercase() + + private data class ElevenLabsVoice(val voiceId: String, val name: String?) + + private val listener = + object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) { + if (_isEnabled.value) { + _statusText.value = if (_isListening.value) "Listening" else _statusText.value + } + } + + override fun onBeginningOfSpeech() {} + + override fun onRmsChanged(rmsdB: Float) {} + + override fun onBufferReceived(buffer: ByteArray?) {} + + override fun onEndOfSpeech() { + scheduleRestart() + } + + override fun onError(error: Int) { + if (stopRequested) return + _isListening.value = false + if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) { + _statusText.value = "Microphone permission required" + return + } + + _statusText.value = + when (error) { + SpeechRecognizer.ERROR_AUDIO -> "Audio error" + SpeechRecognizer.ERROR_CLIENT -> "Client error" + SpeechRecognizer.ERROR_NETWORK -> "Network error" + SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout" + SpeechRecognizer.ERROR_NO_MATCH -> "Listening" + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy" + SpeechRecognizer.ERROR_SERVER -> "Server error" + SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening" + else -> "Speech error ($error)" + } + scheduleRestart(delayMs = 600) + } + + override fun onResults(results: Bundle?) { + val list = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() + list.firstOrNull()?.let { handleTranscript(it, isFinal = true) } + scheduleRestart() + } + + override fun onPartialResults(partialResults: Bundle?) { + val list = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() + list.firstOrNull()?.let { handleTranscript(it, isFinal = false) } + } + + override fun onEvent(eventType: Int, params: Bundle?) {} + } +} + +private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject + +private fun JsonElement?.asStringOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it.isString }?.content + +private fun JsonElement?.asDoubleOrNull(): Double? { + val primitive = this as? JsonPrimitive ?: return null + return primitive.content.toDoubleOrNull() +} + +private fun JsonElement?.asBooleanOrNull(): Boolean? { + val primitive = this as? JsonPrimitive ?: return null + val content = primitive.content.trim().lowercase() + return when (content) { + "true", "yes", "1" -> true + "false", "no", "0" -> false + else -> null + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeCommandExtractor.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeCommandExtractor.kt new file mode 100644 index 00000000..dccd3950 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeCommandExtractor.kt @@ -0,0 +1,40 @@ +package ai.openclaw.android.voice + +object VoiceWakeCommandExtractor { + fun extractCommand(text: String, triggerWords: List): String? { + val raw = text.trim() + if (raw.isEmpty()) return null + + val triggers = + triggerWords + .map { it.trim().lowercase() } + .filter { it.isNotEmpty() } + .distinct() + if (triggers.isEmpty()) return null + + val alternation = triggers.joinToString("|") { Regex.escape(it) } + // Match: " " + val regex = Regex("(?i)(?:^|\\s)($alternation)\\b[\\s\\p{Punct}]*([\\s\\S]+)$") + val match = regex.find(raw) ?: return null + val extracted = match.groupValues.getOrNull(2)?.trim().orEmpty() + if (extracted.isEmpty()) return null + + val cleaned = extracted.trimStart { it.isWhitespace() || it.isPunctuation() }.trim() + if (cleaned.isEmpty()) return null + return cleaned + } +} + +private fun Char.isPunctuation(): Boolean { + return when (Character.getType(this)) { + Character.CONNECTOR_PUNCTUATION.toInt(), + Character.DASH_PUNCTUATION.toInt(), + Character.START_PUNCTUATION.toInt(), + Character.END_PUNCTUATION.toInt(), + Character.INITIAL_QUOTE_PUNCTUATION.toInt(), + Character.FINAL_QUOTE_PUNCTUATION.toInt(), + Character.OTHER_PUNCTUATION.toInt(), + -> true + else -> false + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeManager.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeManager.kt new file mode 100644 index 00000000..334f985a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/java/ai/openclaw/android/voice/VoiceWakeManager.kt @@ -0,0 +1,173 @@ +package ai.openclaw.android.voice + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class VoiceWakeManager( + private val context: Context, + private val scope: CoroutineScope, + private val onCommand: suspend (String) -> Unit, +) { + private val mainHandler = Handler(Looper.getMainLooper()) + + private val _isListening = MutableStateFlow(false) + val isListening: StateFlow = _isListening + + private val _statusText = MutableStateFlow("Off") + val statusText: StateFlow = _statusText + + var triggerWords: List = emptyList() + private set + + private var recognizer: SpeechRecognizer? = null + private var restartJob: Job? = null + private var lastDispatched: String? = null + private var stopRequested = false + + fun setTriggerWords(words: List) { + triggerWords = words + } + + fun start() { + mainHandler.post { + if (_isListening.value) return@post + stopRequested = false + + if (!SpeechRecognizer.isRecognitionAvailable(context)) { + _isListening.value = false + _statusText.value = "Speech recognizer unavailable" + return@post + } + + try { + recognizer?.destroy() + recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) } + startListeningInternal() + } catch (err: Throwable) { + _isListening.value = false + _statusText.value = "Start failed: ${err.message ?: err::class.simpleName}" + } + } + } + + fun stop(statusText: String = "Off") { + stopRequested = true + restartJob?.cancel() + restartJob = null + mainHandler.post { + _isListening.value = false + _statusText.value = statusText + recognizer?.cancel() + recognizer?.destroy() + recognizer = null + } + } + + private fun startListeningInternal() { + val r = recognizer ?: return + val intent = + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) + putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) + } + + _statusText.value = "Listening" + _isListening.value = true + r.startListening(intent) + } + + private fun scheduleRestart(delayMs: Long = 350) { + if (stopRequested) return + restartJob?.cancel() + restartJob = + scope.launch { + delay(delayMs) + mainHandler.post { + if (stopRequested) return@post + try { + recognizer?.cancel() + startListeningInternal() + } catch (_: Throwable) { + // Will be picked up by onError and retry again. + } + } + } + } + + private fun handleTranscription(text: String) { + val command = VoiceWakeCommandExtractor.extractCommand(text, triggerWords) ?: return + if (command == lastDispatched) return + lastDispatched = command + + scope.launch { onCommand(command) } + _statusText.value = "Triggered" + scheduleRestart(delayMs = 650) + } + + private val listener = + object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) { + _statusText.value = "Listening" + } + + override fun onBeginningOfSpeech() {} + + override fun onRmsChanged(rmsdB: Float) {} + + override fun onBufferReceived(buffer: ByteArray?) {} + + override fun onEndOfSpeech() { + scheduleRestart() + } + + override fun onError(error: Int) { + if (stopRequested) return + _isListening.value = false + if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) { + _statusText.value = "Microphone permission required" + return + } + + _statusText.value = + when (error) { + SpeechRecognizer.ERROR_AUDIO -> "Audio error" + SpeechRecognizer.ERROR_CLIENT -> "Client error" + SpeechRecognizer.ERROR_NETWORK -> "Network error" + SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout" + SpeechRecognizer.ERROR_NO_MATCH -> "Listening" + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy" + SpeechRecognizer.ERROR_SERVER -> "Server error" + SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening" + else -> "Speech error ($error)" + } + scheduleRestart(delayMs = 600) + } + + override fun onResults(results: Bundle?) { + val list = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() + list.firstOrNull()?.let(::handleTranscription) + scheduleRestart() + } + + override fun onPartialResults(partialResults: Bundle?) { + val list = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty() + list.firstOrNull()?.let(::handleTranscription) + } + + override fun onEvent(eventType: Int, params: Bundle?) {} + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/font/manrope_400_regular.ttf b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/font/manrope_400_regular.ttf new file mode 100644 index 00000000..9a108f1c Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/font/manrope_400_regular.ttf differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/font/manrope_500_medium.ttf b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/font/manrope_500_medium.ttf new file mode 100644 index 00000000..c6d28def Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/font/manrope_500_medium.ttf differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/font/manrope_600_semibold.ttf b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/font/manrope_600_semibold.ttf new file mode 100644 index 00000000..46a13d61 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/font/manrope_600_semibold.ttf differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/font/manrope_700_bold.ttf b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/font/manrope_700_bold.ttf new file mode 100644 index 00000000..62a61839 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/font/manrope_700_bold.ttf differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 00000000..6f379984 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 00000000..6f379984 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..613e2666 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..22442bc1 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..b1fd747d Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..d26c0189 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..038e3dc7 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..2f065970 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..a5d995c2 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..7c976dc7 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..ceabff1f Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..240acdf4 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/values/colors.xml b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..dfadc94c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/values/colors.xml @@ -0,0 +1,3 @@ + + #0A0A0A + diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/values/strings.xml b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..0098cee2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + OpenClaw Node + diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/values/themes.xml b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..3ac5d04d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/xml/backup_rules.xml b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..21e592ca --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/xml/data_extraction_rules.xml b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..46e58c54 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/xml/file_paths.xml b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..5e0f4f1e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/xml/network_security_config.xml b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..7ac5f5cd --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,12 @@ + + + + + + + openclaw.local + + + ts.net + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/NodeForegroundServiceTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/NodeForegroundServiceTest.kt new file mode 100644 index 00000000..7a81936e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/NodeForegroundServiceTest.kt @@ -0,0 +1,43 @@ +package ai.openclaw.android + +import android.app.Notification +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class NodeForegroundServiceTest { + @Test + fun buildNotificationSetsLaunchIntent() { + val service = Robolectric.buildService(NodeForegroundService::class.java).get() + val notification = buildNotification(service) + + val pendingIntent = notification.contentIntent + assertNotNull(pendingIntent) + + val savedIntent = Shadows.shadowOf(pendingIntent).savedIntent + assertNotNull(savedIntent) + assertEquals(MainActivity::class.java.name, savedIntent.component?.className) + + val expectedFlags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + assertEquals(expectedFlags, savedIntent.flags and expectedFlags) + } + + private fun buildNotification(service: NodeForegroundService): Notification { + val method = + NodeForegroundService::class.java.getDeclaredMethod( + "buildNotification", + String::class.java, + String::class.java, + ) + method.isAccessible = true + return method.invoke(service, "Title", "Text") as Notification + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/WakeWordsTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/WakeWordsTest.kt new file mode 100644 index 00000000..55730e2f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/WakeWordsTest.kt @@ -0,0 +1,50 @@ +package ai.openclaw.android + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class WakeWordsTest { + @Test + fun parseCommaSeparatedTrimsAndDropsEmpty() { + assertEquals(listOf("openclaw", "claude"), WakeWords.parseCommaSeparated(" openclaw , claude, , ")) + } + + @Test + fun sanitizeTrimsCapsAndFallsBack() { + val defaults = listOf("openclaw", "claude") + val long = "x".repeat(WakeWords.maxWordLength + 10) + val words = listOf(" ", " hello ", long) + + val sanitized = WakeWords.sanitize(words, defaults) + assertEquals(2, sanitized.size) + assertEquals("hello", sanitized[0]) + assertEquals("x".repeat(WakeWords.maxWordLength), sanitized[1]) + + assertEquals(defaults, WakeWords.sanitize(listOf(" ", ""), defaults)) + } + + @Test + fun sanitizeLimitsWordCount() { + val defaults = listOf("openclaw") + val words = (1..(WakeWords.maxWords + 5)).map { "w$it" } + val sanitized = WakeWords.sanitize(words, defaults) + assertEquals(WakeWords.maxWords, sanitized.size) + assertEquals("w1", sanitized.first()) + assertEquals("w${WakeWords.maxWords}", sanitized.last()) + } + + @Test + fun parseIfChangedSkipsWhenUnchanged() { + val current = listOf("openclaw", "claude") + val parsed = WakeWords.parseIfChanged(" openclaw , claude ", current) + assertNull(parsed) + } + + @Test + fun parseIfChangedReturnsUpdatedList() { + val current = listOf("openclaw") + val parsed = WakeWords.parseIfChanged(" openclaw , jarvis ", current) + assertEquals(listOf("openclaw", "jarvis"), parsed) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/gateway/BonjourEscapesTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/gateway/BonjourEscapesTest.kt new file mode 100644 index 00000000..fe00e50a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/gateway/BonjourEscapesTest.kt @@ -0,0 +1,19 @@ +package ai.openclaw.android.gateway + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BonjourEscapesTest { + @Test + fun decodeNoop() { + assertEquals("", BonjourEscapes.decode("")) + assertEquals("hello", BonjourEscapes.decode("hello")) + } + + @Test + fun decodeDecodesDecimalEscapes() { + assertEquals("OpenClaw Gateway", BonjourEscapes.decode("OpenClaw\\032Gateway")) + assertEquals("A B", BonjourEscapes.decode("A\\032B")) + assertEquals("Peter\u2019s Mac", BonjourEscapes.decode("Peter\\226\\128\\153s Mac")) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt new file mode 100644 index 00000000..743ed92c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt @@ -0,0 +1,65 @@ +package ai.openclaw.android.node + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class AppUpdateHandlerTest { + @Test + fun parseAppUpdateRequest_acceptsHttpsWithMatchingHost() { + val req = + parseAppUpdateRequest( + paramsJson = + """{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""", + connectedHost = "gw.example.com", + ) + + assertEquals("https://gw.example.com/releases/openclaw.apk", req.url) + assertEquals("a".repeat(64), req.expectedSha256) + } + + @Test + fun parseAppUpdateRequest_rejectsNonHttps() { + assertThrows(IllegalArgumentException::class.java) { + parseAppUpdateRequest( + paramsJson = """{"url":"http://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""", + connectedHost = "gw.example.com", + ) + } + } + + @Test + fun parseAppUpdateRequest_rejectsHostMismatch() { + assertThrows(IllegalArgumentException::class.java) { + parseAppUpdateRequest( + paramsJson = """{"url":"https://evil.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""", + connectedHost = "gw.example.com", + ) + } + } + + @Test + fun parseAppUpdateRequest_rejectsInvalidSha256() { + assertThrows(IllegalArgumentException::class.java) { + parseAppUpdateRequest( + paramsJson = """{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"bad"}""", + connectedHost = "gw.example.com", + ) + } + } + + @Test + fun sha256Hex_computesExpectedDigest() { + val tmp = File.createTempFile("openclaw-update-hash", ".bin") + try { + tmp.writeText("hello", Charsets.UTF_8) + assertEquals( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + sha256Hex(tmp), + ) + } finally { + tmp.delete() + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/CanvasControllerSnapshotParamsTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/CanvasControllerSnapshotParamsTest.kt new file mode 100644 index 00000000..dd1b9d5d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/CanvasControllerSnapshotParamsTest.kt @@ -0,0 +1,43 @@ +package ai.openclaw.android.node + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CanvasControllerSnapshotParamsTest { + @Test + fun parseSnapshotParamsDefaultsToJpeg() { + val params = CanvasController.parseSnapshotParams(null) + assertEquals(CanvasController.SnapshotFormat.Jpeg, params.format) + assertNull(params.quality) + assertNull(params.maxWidth) + } + + @Test + fun parseSnapshotParamsParsesPng() { + val params = CanvasController.parseSnapshotParams("""{"format":"png","maxWidth":900}""") + assertEquals(CanvasController.SnapshotFormat.Png, params.format) + assertEquals(900, params.maxWidth) + } + + @Test + fun parseSnapshotParamsParsesJpegAliases() { + assertEquals( + CanvasController.SnapshotFormat.Jpeg, + CanvasController.parseSnapshotParams("""{"format":"jpeg"}""").format, + ) + assertEquals( + CanvasController.SnapshotFormat.Jpeg, + CanvasController.parseSnapshotParams("""{"format":"jpg"}""").format, + ) + } + + @Test + fun parseSnapshotParamsClampsQuality() { + val low = CanvasController.parseSnapshotParams("""{"quality":0.01}""") + assertEquals(0.1, low.quality) + + val high = CanvasController.parseSnapshotParams("""{"quality":5}""") + assertEquals(1.0, high.quality) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt new file mode 100644 index 00000000..534b90a2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/ConnectionManagerTest.kt @@ -0,0 +1,76 @@ +package ai.openclaw.android.node + +import ai.openclaw.android.gateway.GatewayEndpoint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ConnectionManagerTest { + @Test + fun resolveTlsParamsForEndpoint_prefersStoredPinOverAdvertisedFingerprint() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "10.0.0.2", + port = 18789, + tlsEnabled = true, + tlsFingerprintSha256 = "attacker", + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = "legit", + manualTlsEnabled = false, + ) + + assertEquals("legit", params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_doesNotTrustAdvertisedFingerprintWhenNoStoredPin() { + val endpoint = + GatewayEndpoint( + stableId = "_openclaw-gw._tcp.|local.|Test", + name = "Test", + host = "10.0.0.2", + port = 18789, + tlsEnabled = true, + tlsFingerprintSha256 = "attacker", + ) + + val params = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + + assertNull(params?.expectedFingerprint) + assertEquals(false, params?.allowTOFU) + } + + @Test + fun resolveTlsParamsForEndpoint_manualRespectsManualTlsToggle() { + val endpoint = GatewayEndpoint.manual(host = "example.com", port = 443) + + val off = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = false, + ) + assertNull(off) + + val on = + ConnectionManager.resolveTlsParamsForEndpoint( + endpoint, + storedFingerprint = null, + manualTlsEnabled = true, + ) + assertNull(on?.expectedFingerprint) + assertEquals(false, on?.allowTOFU) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/JpegSizeLimiterTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/JpegSizeLimiterTest.kt new file mode 100644 index 00000000..5de1dd54 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/JpegSizeLimiterTest.kt @@ -0,0 +1,47 @@ +package ai.openclaw.android.node + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.min + +class JpegSizeLimiterTest { + @Test + fun compressesLargePayloadsUnderLimit() { + val maxBytes = 5 * 1024 * 1024 + val result = + JpegSizeLimiter.compressToLimit( + initialWidth = 4000, + initialHeight = 3000, + startQuality = 95, + maxBytes = maxBytes, + encode = { width, height, quality -> + val estimated = (width.toLong() * height.toLong() * quality.toLong()) / 100 + val size = min(maxBytes.toLong() * 2, estimated).toInt() + ByteArray(size) + }, + ) + + assertTrue(result.bytes.size <= maxBytes) + assertTrue(result.width <= 4000) + assertTrue(result.height <= 3000) + assertTrue(result.quality <= 95) + } + + @Test + fun keepsSmallPayloadsAsIs() { + val maxBytes = 5 * 1024 * 1024 + val result = + JpegSizeLimiter.compressToLimit( + initialWidth = 800, + initialHeight = 600, + startQuality = 90, + maxBytes = maxBytes, + encode = { _, _, _ -> ByteArray(120_000) }, + ) + + assertEquals(800, result.width) + assertEquals(600, result.height) + assertEquals(90, result.quality) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/SmsManagerTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/SmsManagerTest.kt new file mode 100644 index 00000000..a3d61329 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/node/SmsManagerTest.kt @@ -0,0 +1,91 @@ +package ai.openclaw.android.node + +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SmsManagerTest { + private val json = SmsManager.JsonConfig + + @Test + fun parseParamsRejectsEmptyPayload() { + val result = SmsManager.parseParams("", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: paramsJSON required", error.error) + } + + @Test + fun parseParamsRejectsInvalidJson() { + val result = SmsManager.parseParams("not-json", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: expected JSON object", error.error) + } + + @Test + fun parseParamsRejectsNonObjectJson() { + val result = SmsManager.parseParams("[]", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: expected JSON object", error.error) + } + + @Test + fun parseParamsRejectsMissingTo() { + val result = SmsManager.parseParams("{\"message\":\"Hi\"}", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: 'to' phone number required", error.error) + assertEquals("Hi", error.message) + } + + @Test + fun parseParamsRejectsMissingMessage() { + val result = SmsManager.parseParams("{\"to\":\"+1234\"}", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: 'message' text required", error.error) + assertEquals("+1234", error.to) + } + + @Test + fun parseParamsTrimsToField() { + val result = SmsManager.parseParams("{\"to\":\" +1555 \",\"message\":\"Hello\"}", json) + assertTrue(result is SmsManager.ParseResult.Ok) + val ok = result as SmsManager.ParseResult.Ok + assertEquals("+1555", ok.params.to) + assertEquals("Hello", ok.params.message) + } + + @Test + fun buildPayloadJsonEscapesFields() { + val payload = SmsManager.buildPayloadJson( + json = json, + ok = false, + to = "+1\"23", + error = "SMS_SEND_FAILED: \"nope\"", + ) + val parsed = json.parseToJsonElement(payload).jsonObject + assertEquals("false", parsed["ok"]?.jsonPrimitive?.content) + assertEquals("+1\"23", parsed["to"]?.jsonPrimitive?.content) + assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content) + } + + @Test + fun buildSendPlanUsesMultipartWhenMultipleParts() { + val plan = SmsManager.buildSendPlan("hello") { listOf("a", "b") } + assertTrue(plan.useMultipart) + assertEquals(listOf("a", "b"), plan.parts) + } + + @Test + fun buildSendPlanFallsBackToSinglePartWhenDividerEmpty() { + val plan = SmsManager.buildSendPlan("hello") { emptyList() } + assertFalse(plan.useMultipart) + assertEquals(listOf("hello"), plan.parts) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIActionTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIActionTest.kt new file mode 100644 index 00000000..c767d2eb --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIActionTest.kt @@ -0,0 +1,49 @@ +package ai.openclaw.android.protocol + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Test + +class OpenClawCanvasA2UIActionTest { + @Test + fun extractActionNameAcceptsNameOrAction() { + val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject + assertEquals("Hello", OpenClawCanvasA2UIAction.extractActionName(nameObj)) + + val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject + assertEquals("Wave", OpenClawCanvasA2UIAction.extractActionName(actionObj)) + + val fallbackObj = + Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject + assertEquals("Fallback", OpenClawCanvasA2UIAction.extractActionName(fallbackObj)) + } + + @Test + fun formatAgentMessageMatchesSharedSpec() { + val msg = + OpenClawCanvasA2UIAction.formatAgentMessage( + actionName = "Get Weather", + sessionKey = "main", + surfaceId = "main", + sourceComponentId = "btnWeather", + host = "Peter’s iPad", + instanceId = "ipad16,6", + contextJson = "{\"city\":\"Vienna\"}", + ) + + assertEquals( + "CANVAS_A2UI action=Get_Weather session=main surface=main component=btnWeather host=Peter_s_iPad instance=ipad16_6 ctx={\"city\":\"Vienna\"} default=update_canvas", + msg, + ) + } + + @Test + fun jsDispatchA2uiStatusIsStable() { + val js = OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null) + assertEquals( + "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));", + js, + ) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt new file mode 100644 index 00000000..10ab733a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/protocol/OpenClawProtocolConstantsTest.kt @@ -0,0 +1,35 @@ +package ai.openclaw.android.protocol + +import org.junit.Assert.assertEquals +import org.junit.Test + +class OpenClawProtocolConstantsTest { + @Test + fun canvasCommandsUseStableStrings() { + assertEquals("canvas.present", OpenClawCanvasCommand.Present.rawValue) + assertEquals("canvas.hide", OpenClawCanvasCommand.Hide.rawValue) + assertEquals("canvas.navigate", OpenClawCanvasCommand.Navigate.rawValue) + assertEquals("canvas.eval", OpenClawCanvasCommand.Eval.rawValue) + assertEquals("canvas.snapshot", OpenClawCanvasCommand.Snapshot.rawValue) + } + + @Test + fun a2uiCommandsUseStableStrings() { + assertEquals("canvas.a2ui.push", OpenClawCanvasA2UICommand.Push.rawValue) + assertEquals("canvas.a2ui.pushJSONL", OpenClawCanvasA2UICommand.PushJSONL.rawValue) + assertEquals("canvas.a2ui.reset", OpenClawCanvasA2UICommand.Reset.rawValue) + } + + @Test + fun capabilitiesUseStableStrings() { + assertEquals("canvas", OpenClawCapability.Canvas.rawValue) + assertEquals("camera", OpenClawCapability.Camera.rawValue) + assertEquals("screen", OpenClawCapability.Screen.rawValue) + assertEquals("voiceWake", OpenClawCapability.VoiceWake.rawValue) + } + + @Test + fun screenCommandsUseStableStrings() { + assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/ui/chat/SessionFiltersTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/ui/chat/SessionFiltersTest.kt new file mode 100644 index 00000000..8e9e5800 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/ui/chat/SessionFiltersTest.kt @@ -0,0 +1,35 @@ +package ai.openclaw.android.ui.chat + +import ai.openclaw.android.chat.ChatSessionEntry +import org.junit.Assert.assertEquals +import org.junit.Test + +class SessionFiltersTest { + @Test + fun sessionChoicesPreferMainAndRecent() { + val now = 1_700_000_000_000L + val recent1 = now - 2 * 60 * 60 * 1000L + val recent2 = now - 5 * 60 * 60 * 1000L + val stale = now - 26 * 60 * 60 * 1000L + val sessions = + listOf( + ChatSessionEntry(key = "recent-1", updatedAtMs = recent1), + ChatSessionEntry(key = "main", updatedAtMs = stale), + ChatSessionEntry(key = "old-1", updatedAtMs = stale), + ChatSessionEntry(key = "recent-2", updatedAtMs = recent2), + ) + + val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key } + assertEquals(listOf("main", "recent-1", "recent-2"), result) + } + + @Test + fun sessionChoicesIncludeCurrentWhenMissing() { + val now = 1_700_000_000_000L + val recent = now - 10 * 60 * 1000L + val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent)) + + val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key } + assertEquals(listOf("main", "custom"), result) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkDirectiveParserTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkDirectiveParserTest.kt new file mode 100644 index 00000000..77d62849 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkDirectiveParserTest.kt @@ -0,0 +1,55 @@ +package ai.openclaw.android.voice + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TalkDirectiveParserTest { + @Test + fun parsesDirectiveAndStripsHeader() { + val input = """ + {"voice":"voice-123","once":true} + Hello from talk mode. + """.trimIndent() + val result = TalkDirectiveParser.parse(input) + assertEquals("voice-123", result.directive?.voiceId) + assertEquals(true, result.directive?.once) + assertEquals("Hello from talk mode.", result.stripped.trim()) + } + + @Test + fun ignoresUnknownKeysButReportsThem() { + val input = """ + {"voice":"abc","foo":1,"bar":"baz"} + Hi there. + """.trimIndent() + val result = TalkDirectiveParser.parse(input) + assertEquals("abc", result.directive?.voiceId) + assertTrue(result.unknownKeys.containsAll(listOf("bar", "foo"))) + } + + @Test + fun parsesAlternateKeys() { + val input = """ + {"model_id":"eleven_v3","similarity_boost":0.4,"no_speaker_boost":true,"rate":200} + Speak. + """.trimIndent() + val result = TalkDirectiveParser.parse(input) + assertEquals("eleven_v3", result.directive?.modelId) + assertEquals(0.4, result.directive?.similarity) + assertEquals(false, result.directive?.speakerBoost) + assertEquals(200, result.directive?.rateWpm) + } + + @Test + fun returnsNullWhenNoDirectivePresent() { + val input = """ + {} + Hello. + """.trimIndent() + val result = TalkDirectiveParser.parse(input) + assertNull(result.directive) + assertEquals(input, result.stripped) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt new file mode 100644 index 00000000..5daa6208 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/voice/TalkModeConfigParsingTest.kt @@ -0,0 +1,59 @@ +package ai.openclaw.android.voice + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TalkModeConfigParsingTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun prefersNormalizedTalkProviderPayload() { + val talk = + json.parseToJsonElement( + """ + { + "provider": "elevenlabs", + "providers": { + "elevenlabs": { + "voiceId": "voice-normalized" + } + }, + "voiceId": "voice-legacy" + } + """.trimIndent(), + ) + .jsonObject + + val selection = TalkModeManager.selectTalkProviderConfig(talk) + assertNotNull(selection) + assertEquals("elevenlabs", selection?.provider) + assertTrue(selection?.normalizedPayload == true) + assertEquals("voice-normalized", selection?.config?.get("voiceId")?.jsonPrimitive?.content) + } + + @Test + fun fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() { + val talk = + json.parseToJsonElement( + """ + { + "voiceId": "voice-legacy", + "apiKey": "legacy-key" + } + """.trimIndent(), + ) + .jsonObject + + val selection = TalkModeManager.selectTalkProviderConfig(talk) + assertNotNull(selection) + assertEquals("elevenlabs", selection?.provider) + assertTrue(selection?.normalizedPayload == false) + assertEquals("voice-legacy", selection?.config?.get("voiceId")?.jsonPrimitive?.content) + assertEquals("legacy-key", selection?.config?.get("apiKey")?.jsonPrimitive?.content) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/voice/VoiceWakeCommandExtractorTest.kt b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/voice/VoiceWakeCommandExtractorTest.kt new file mode 100644 index 00000000..76b50d8a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/app/src/test/java/ai/openclaw/android/voice/VoiceWakeCommandExtractorTest.kt @@ -0,0 +1,25 @@ +package ai.openclaw.android.voice + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class VoiceWakeCommandExtractorTest { + @Test + fun extractsCommandAfterTriggerWord() { + val res = VoiceWakeCommandExtractor.extractCommand("Claude take a photo", listOf("openclaw", "claude")) + assertEquals("take a photo", res) + } + + @Test + fun extractsCommandWithPunctuation() { + val res = VoiceWakeCommandExtractor.extractCommand("hey openclaw, what's the weather?", listOf("openclaw")) + assertEquals("what's the weather?", res) + } + + @Test + fun returnsNullWhenNoCommandProvided() { + assertNull(VoiceWakeCommandExtractor.extractCommand("claude", listOf("claude"))) + assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude"))) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/build.gradle.kts b/backend/app/one_person_security_dept/openclaw/apps/android/build.gradle.kts new file mode 100644 index 00000000..bea7b46b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/gradle.properties b/backend/app/one_person_security_dept/openclaw/apps/android/gradle.properties new file mode 100644 index 00000000..4134274a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/gradle.properties @@ -0,0 +1,14 @@ +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED +org.gradle.warning.mode=none +android.useAndroidX=true +android.nonTransitiveRClass=true +android.enableR8.fullMode=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.newDsl=true diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/gradle/gradle-daemon-jvm.properties b/backend/app/one_person_security_dept/openclaw/apps/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..6c1139ec --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/gradle/wrapper/gradle-wrapper.jar b/backend/app/one_person_security_dept/openclaw/apps/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e6441136 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/gradle/wrapper/gradle-wrapper.properties b/backend/app/one_person_security_dept/openclaw/apps/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..23449a2b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/gradlew b/backend/app/one_person_security_dept/openclaw/apps/android/gradlew new file mode 100755 index 00000000..6e5806dc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m" "--enable-native-access=ALL-UNNAMED"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/gradlew.bat b/backend/app/one_person_security_dept/openclaw/apps/android/gradlew.bat new file mode 100644 index 00000000..1e5ac0bd --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "--enable-native-access=ALL-UNNAMED" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/settings.gradle.kts b/backend/app/one_person_security_dept/openclaw/apps/android/settings.gradle.kts new file mode 100644 index 00000000..b3b43a44 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "OpenClawNodeAndroid" +include(":app") diff --git a/backend/app/one_person_security_dept/openclaw/apps/android/style.md b/backend/app/one_person_security_dept/openclaw/apps/android/style.md new file mode 100644 index 00000000..f2b892ac --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/android/style.md @@ -0,0 +1,113 @@ +# OpenClaw Android UI Style Guide + +Scope: all native Android UI in `apps/android` (Jetpack Compose). +Goal: one coherent visual system across onboarding, settings, and future screens. + +## 1. Design Direction + +- Clean, quiet surfaces. +- Strong readability first. +- One clear primary action per screen state. +- Progressive disclosure for advanced controls. +- Deterministic flows: validate early, fail clearly. + +## 2. Style Baseline + +The onboarding flow defines the current visual baseline. +New screens should match that language unless there is a strong product reason not to. + +Baseline traits: + +- Light neutral background with subtle depth. +- Clear blue accent for active/primary states. +- Strong border hierarchy for structure. +- Medium/semibold typography (no thin text). +- Divider-and-spacing layout over heavy card nesting. + +## 3. Core Tokens + +Use these as shared design tokens for new Compose UI. + +- Background gradient: `#FFFFFF`, `#F7F8FA`, `#EFF1F5` +- Surface: `#F6F7FA` +- Border: `#E5E7EC` +- Border strong: `#D6DAE2` +- Text primary: `#17181C` +- Text secondary: `#4D5563` +- Text tertiary: `#8A92A2` +- Accent primary: `#1D5DD8` +- Accent soft: `#ECF3FF` +- Success: `#2F8C5A` +- Warning: `#C8841A` + +Rule: do not introduce random per-screen colors when an existing token fits. + +## 4. Typography + +Primary type family: Manrope (`400/500/600/700`). + +Recommended scale: + +- Display: `34sp / 40sp`, bold +- Section title: `24sp / 30sp`, semibold +- Headline/action: `16sp / 22sp`, semibold +- Body: `15sp / 22sp`, medium +- Callout/helper: `14sp / 20sp`, medium +- Caption 1: `12sp / 16sp`, medium +- Caption 2: `11sp / 14sp`, medium + +Use monospace only for commands, setup codes, endpoint-like values. +Hard rule: avoid ultra-thin weights on light backgrounds. + +## 5. Layout And Spacing + +- Respect safe drawing insets. +- Keep content hierarchy mostly via spacing + dividers. +- Prefer vertical rhythm from `8/10/12/14/20dp`. +- Use pinned bottom actions for multi-step or high-importance flows. +- Avoid unnecessary container nesting. + +## 6. Buttons And Actions + +- Primary action: filled accent button, visually dominant. +- Secondary action: lower emphasis (outlined/text/surface button). +- Icon-only buttons must remain legible and >=44dp target. +- Back buttons in action rows use rounded-square shape, not circular by default. + +## 7. Inputs And Forms + +- Always show explicit label or clear context title. +- Keep helper copy short and actionable. +- Validate before advancing steps. +- Prefer immediate inline errors over hidden failure states. +- Keep optional advanced fields explicit (`Manual`, `Advanced`, etc.). + +## 8. Progress And Multi-Step Flows + +- Use clear step count (`Step X of N`). +- Use labeled progress rail/indicator when steps are discrete. +- Keep navigation predictable: back/next behavior should never surprise. + +## 9. Accessibility + +- Minimum practical touch target: `44dp`. +- Do not rely on color alone for status. +- Preserve high contrast for all text tiers. +- Add meaningful `contentDescription` for icon-only controls. + +## 10. Architecture Rules + +- Durable UI state in `MainViewModel`. +- Composables: state in, callbacks out. +- No business/network logic in composables. +- Keep side effects explicit (`LaunchedEffect`, activity result APIs). + +## 11. Source Of Truth + +- `app/src/main/java/ai/openclaw/android/ui/OpenClawTheme.kt` +- `app/src/main/java/ai/openclaw/android/ui/OnboardingFlow.kt` +- `app/src/main/java/ai/openclaw/android/ui/RootScreen.kt` +- `app/src/main/java/ai/openclaw/android/ui/SettingsSheet.kt` +- `app/src/main/java/ai/openclaw/android/MainViewModel.kt` + +If style and implementation diverge, update both in the same change. diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/.swiftlint.yml b/backend/app/one_person_security_dept/openclaw/apps/ios/.swiftlint.yml new file mode 100644 index 00000000..23db4515 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/.swiftlint.yml @@ -0,0 +1,9 @@ +parent_config: ../../.swiftlint.yml + +included: + - Sources + - ../shared/ClawdisNodeKit/Sources + +type_body_length: + warning: 900 + error: 1300 diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Config/Signing.xcconfig b/backend/app/one_person_security_dept/openclaw/apps/ios/Config/Signing.xcconfig new file mode 100644 index 00000000..e0afd46a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Config/Signing.xcconfig @@ -0,0 +1,18 @@ +// Shared iOS signing defaults for local development + CI. +OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ +OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM) +OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios +OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.watchkitapp +OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.watchkitapp.extension + +// Local contributors can override this by running scripts/ios-configure-signing.sh. +// Keep include after defaults: xcconfig is evaluated top-to-bottom. +#include? "../.local-signing.xcconfig" +#include? "../LocalSigning.xcconfig" + +CODE_SIGN_STYLE = Automatic +CODE_SIGN_IDENTITY = Apple Development +DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM) + +// Let Xcode manage provisioning for the selected local team. +PROVISIONING_PROFILE_SPECIFIER = diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/LocalSigning.xcconfig.example b/backend/app/one_person_security_dept/openclaw/apps/ios/LocalSigning.xcconfig.example new file mode 100644 index 00000000..bfa610fb --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/LocalSigning.xcconfig.example @@ -0,0 +1,14 @@ +// Copy to LocalSigning.xcconfig for personal local signing overrides. +// This file is only an example and should stay committed. + +OPENCLAW_CODE_SIGN_STYLE = Automatic +OPENCLAW_DEVELOPMENT_TEAM = P5Z8X89DJL + +OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano +OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.test.mariano.share +OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp +OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp.extension + +// Leave empty with automatic signing. +OPENCLAW_APP_PROFILE = +OPENCLAW_SHARE_PROFILE = diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/README.md b/backend/app/one_person_security_dept/openclaw/apps/ios/README.md new file mode 100644 index 00000000..c7c501fc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/README.md @@ -0,0 +1,141 @@ +# OpenClaw iOS (Super Alpha) + +NO TEST FLIGHT AVAILABLE AT THIS POINT + +This iPhone app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node`. + +## Distribution Status + +NO TEST FLIGHT AVAILABLE AT THIS POINT + +- Current distribution: local/manual deploy from source via Xcode. +- App Store flow is not part of the current internal development path. + +## Super-Alpha Disclaimer + +- Breaking changes are expected. +- UI and onboarding flows can change without migration guarantees. +- Foreground use is the only reliable mode right now. +- Treat this build as sensitive while permissions and background behavior are still being hardened. + +## Exact Xcode Manual Deploy Flow + +1. Prereqs: + - Xcode 16+ + - `pnpm` + - `xcodegen` + - Apple Development signing set up in Xcode +2. From repo root: + +```bash +pnpm install +./scripts/ios-configure-signing.sh +cd apps/ios +xcodegen generate +open OpenClaw.xcodeproj +``` + +3. In Xcode: + - Scheme: `OpenClaw` + - Destination: connected iPhone (recommended for real behavior) + - Build configuration: `Debug` + - Run (`Product` -> `Run`) +4. If signing fails on a personal team: + - Use unique local bundle IDs via `apps/ios/LocalSigning.xcconfig`. + - Start from `apps/ios/LocalSigning.xcconfig.example`. + +Shortcut command (same flow + open project): + +```bash +pnpm ios:open +``` + +## APNs Expectations For Local/Manual Builds + +- The app calls `registerForRemoteNotifications()` at launch. +- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`. +- APNs token registration to gateway happens only after gateway connection (`push.apns.register`). +- Your selected team/profile must support Push Notifications for the app bundle ID you are signing. +- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`). +- Debug builds register as APNs sandbox; Release builds use production. + +## What Works Now (Concrete) + +- Pairing via setup code flow (`/pair` then `/pair approve` in Telegram). +- Gateway connection via discovery or manual host/port with TLS fingerprint trust prompt. +- Chat + Talk surfaces through the operator gateway session. +- iPhone node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications. +- Share extension deep-link forwarding into the connected gateway session. + +## Location Automation Use Case (Testing) + +Use this for automation signals ("I moved", "I arrived", "I left"), not as a keep-awake mechanism. + +- Product intent: + - movement-aware automations driven by iOS location events + - example: arrival/exit geofence, significant movement, visit detection +- Non-goal: + - continuous GPS polling just to keep the app alive + +Test path to include in QA runs: + +1. Enable location permission in app: + - set `Always` permission + - verify background location capability is enabled in the build profile +2. Background the app and trigger movement: + - walk/drive enough for a significant location update, or cross a configured geofence +3. Validate gateway side effects: + - node reconnect/wake if needed + - expected location/movement event arrives at gateway + - automation trigger executes once (no duplicate storm) +4. Validate resource impact: + - no sustained high thermal state + - no excessive background battery drain over a short observation window + +Pass criteria: + +- movement events are delivered reliably enough for automation UX +- no location-driven reconnect spam loops +- app remains stable after repeated background/foreground transitions + +## Known Issues / Limitations / Problems + +- Foreground-first: iOS can suspend sockets in background; reconnect recovery is still being tuned. +- Background command limits are strict: `canvas.*`, `camera.*`, `screen.*`, and `talk.*` are blocked when backgrounded. +- Background location requires `Always` location permission. +- Pairing/auth errors intentionally pause reconnect loops until a human fixes auth/pairing state. +- Voice Wake and Talk contend for the same microphone; Talk suppresses wake capture while active. +- APNs reliability depends on local signing/provisioning/topic alignment. +- Expect rough UX edges and occasional reconnect churn during active development. + +## Current In-Progress Workstream + +Automatic wake/reconnect hardening: + +- improve wake/resume behavior across scene transitions +- reduce dead-socket states after background -> foreground +- tighten node/operator session reconnect coordination +- reduce manual recovery steps after transient network failures + +## Debugging Checklist + +1. Confirm build/signing baseline: + - regenerate project (`xcodegen generate`) + - verify selected team + bundle IDs +2. In app `Settings -> Gateway`: + - confirm status text, server, and remote address + - verify whether status shows pairing/auth gating +3. If pairing is required: + - run `/pair approve` from Telegram, then reconnect +4. If discovery is flaky: + - enable `Discovery Debug Logs` + - inspect `Settings -> Gateway -> Discovery Logs` +5. If network path is unclear: + - switch to manual host/port + TLS in Gateway Advanced settings +6. In Xcode console, filter for subsystem/category signals: + - `ai.openclaw.ios` + - `GatewayDiag` + - `APNs registration failed` +7. Validate background expectations: + - repro in foreground first + - then test background transitions and confirm reconnect on return diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/ShareExtension/Info.plist b/backend/app/one_person_security_dept/openclaw/apps/ios/ShareExtension/Info.plist new file mode 100644 index 00000000..aedea62a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/ShareExtension/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenClaw Share + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 2026.2.23 + CFBundleVersion + 20260223 + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsImageWithMaxCount + 10 + NSExtensionActivationSupportsMovieWithMaxCount + 1 + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/ShareExtension/ShareViewController.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/ShareExtension/ShareViewController.swift new file mode 100644 index 00000000..1181641e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/ShareExtension/ShareViewController.swift @@ -0,0 +1,548 @@ +import Foundation +import OpenClawKit +import os +import UIKit +import UniformTypeIdentifiers + +final class ShareViewController: UIViewController { + private struct ShareAttachment: Codable { + var type: String + var mimeType: String + var fileName: String + var content: String + } + + private struct ExtractedShareContent { + var payload: SharedContentPayload + var attachments: [ShareAttachment] + } + + private let logger = Logger(subsystem: "ai.openclaw.ios", category: "ShareExtension") + private var statusLabel: UILabel? + private let draftTextView = UITextView() + private let sendButton = UIButton(type: .system) + private let cancelButton = UIButton(type: .system) + private var didPrepareDraft = false + private var isSending = false + private var pendingAttachments: [ShareAttachment] = [] + + override func viewDidLoad() { + super.viewDidLoad() + self.preferredContentSize = CGSize(width: UIScreen.main.bounds.width, height: 420) + self.setupUI() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard !self.didPrepareDraft else { return } + self.didPrepareDraft = true + Task { await self.prepareDraft() } + } + + private func setupUI() { + self.view.backgroundColor = .systemBackground + + self.draftTextView.translatesAutoresizingMaskIntoConstraints = false + self.draftTextView.font = .preferredFont(forTextStyle: .body) + self.draftTextView.backgroundColor = UIColor.secondarySystemBackground + self.draftTextView.layer.cornerRadius = 10 + self.draftTextView.textContainerInset = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10) + + self.sendButton.translatesAutoresizingMaskIntoConstraints = false + self.sendButton.setTitle("Send to OpenClaw", for: .normal) + self.sendButton.titleLabel?.font = .preferredFont(forTextStyle: .headline) + self.sendButton.addTarget(self, action: #selector(self.handleSendTap), for: .touchUpInside) + self.sendButton.isEnabled = false + + self.cancelButton.translatesAutoresizingMaskIntoConstraints = false + self.cancelButton.setTitle("Cancel", for: .normal) + self.cancelButton.addTarget(self, action: #selector(self.handleCancelTap), for: .touchUpInside) + + let buttons = UIStackView(arrangedSubviews: [self.cancelButton, self.sendButton]) + buttons.translatesAutoresizingMaskIntoConstraints = false + buttons.axis = .horizontal + buttons.alignment = .fill + buttons.distribution = .fillEqually + buttons.spacing = 12 + + self.view.addSubview(self.draftTextView) + self.view.addSubview(buttons) + + NSLayoutConstraint.activate([ + self.draftTextView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 14), + self.draftTextView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 14), + self.draftTextView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -14), + self.draftTextView.bottomAnchor.constraint(equalTo: buttons.topAnchor, constant: -12), + + buttons.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 14), + buttons.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -14), + buttons.bottomAnchor.constraint(equalTo: self.view.keyboardLayoutGuide.topAnchor, constant: -8), + buttons.heightAnchor.constraint(equalToConstant: 44), + ]) + } + + private func prepareDraft() async { + let traceId = UUID().uuidString + ShareGatewayRelaySettings.saveLastEvent("Share opened.") + self.showStatus("Preparing share…") + self.logger.info("share begin trace=\(traceId, privacy: .public)") + let extracted = await self.extractSharedContent() + let payload = extracted.payload + self.pendingAttachments = extracted.attachments + self.logger.info( + "share payload trace=\(traceId, privacy: .public) titleChars=\(payload.title?.count ?? 0) textChars=\(payload.text?.count ?? 0) hasURL=\(payload.url != nil) imageAttachments=\(self.pendingAttachments.count)" + ) + let message = self.composeDraft(from: payload) + await MainActor.run { + self.draftTextView.text = message + self.sendButton.isEnabled = true + self.draftTextView.becomeFirstResponder() + } + if message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + ShareGatewayRelaySettings.saveLastEvent("Share ready: waiting for message input.") + self.showStatus("Add a message, then tap Send.") + } else { + ShareGatewayRelaySettings.saveLastEvent("Share ready: draft prepared.") + self.showStatus("Edit text, then tap Send.") + } + } + + @objc + private func handleSendTap() { + guard !self.isSending else { return } + Task { await self.sendCurrentDraft() } + } + + @objc + private func handleCancelTap() { + self.extensionContext?.completeRequest(returningItems: nil) + } + + private func sendCurrentDraft() async { + let message = await MainActor.run { self.draftTextView.text ?? "" } + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + ShareGatewayRelaySettings.saveLastEvent("Share blocked: message is empty.") + self.showStatus("Message is empty.") + return + } + + await MainActor.run { + self.isSending = true + self.sendButton.isEnabled = false + self.cancelButton.isEnabled = false + } + self.showStatus("Sending to OpenClaw gateway…") + ShareGatewayRelaySettings.saveLastEvent("Sending to gateway…") + do { + try await self.sendMessageToGateway(trimmed, attachments: self.pendingAttachments) + ShareGatewayRelaySettings.saveLastEvent( + "Sent to gateway (\(trimmed.count) chars, \(self.pendingAttachments.count) attachment(s)).") + self.showStatus("Sent to OpenClaw.") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { + self.extensionContext?.completeRequest(returningItems: nil) + } + } catch { + self.logger.error("share send failed reason=\(error.localizedDescription, privacy: .public)") + ShareGatewayRelaySettings.saveLastEvent("Send failed: \(error.localizedDescription)") + self.showStatus("Send failed: \(error.localizedDescription)") + await MainActor.run { + self.isSending = false + self.sendButton.isEnabled = true + self.cancelButton.isEnabled = true + } + } + } + + private func sendMessageToGateway(_ message: String, attachments: [ShareAttachment]) async throws { + guard let config = ShareGatewayRelaySettings.loadConfig() else { + throw NSError( + domain: "OpenClawShare", + code: 10, + userInfo: [NSLocalizedDescriptionKey: "OpenClaw is not connected to a gateway yet."]) + } + guard let url = URL(string: config.gatewayURLString) else { + throw NSError( + domain: "OpenClawShare", + code: 11, + userInfo: [NSLocalizedDescriptionKey: "Invalid saved gateway URL."]) + } + + let gateway = GatewayNodeSession() + defer { + Task { await gateway.disconnect() } + } + let makeOptions: (String) -> GatewayConnectOptions = { clientId in + GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: clientId, + clientMode: "node", + clientDisplayName: "OpenClaw Share", + includeDeviceIdentity: false) + } + + do { + try await gateway.connect( + url: url, + token: config.token, + password: config.password, + connectOptions: makeOptions("openclaw-ios"), + sessionBox: nil, + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .invalidRequest, + message: "share extension does not support node invoke")) + }) + } catch { + let expectsLegacyClientId = self.shouldRetryWithLegacyClientId(error) + guard expectsLegacyClientId else { throw error } + try await gateway.connect( + url: url, + token: config.token, + password: config.password, + connectOptions: makeOptions("moltbot-ios"), + sessionBox: nil, + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .invalidRequest, + message: "share extension does not support node invoke")) + }) + } + + struct AgentRequestPayload: Codable { + var message: String + var sessionKey: String? + var thinking: String + var deliver: Bool + var attachments: [ShareAttachment]? + var receipt: Bool + var receiptText: String? + var to: String? + var channel: String? + var timeoutSeconds: Int? + var key: String? + } + + let deliveryChannel = config.deliveryChannel?.trimmingCharacters(in: .whitespacesAndNewlines) + let deliveryTo = config.deliveryTo?.trimmingCharacters(in: .whitespacesAndNewlines) + let canDeliverToRoute = (deliveryChannel?.isEmpty == false) && (deliveryTo?.isEmpty == false) + + let params = AgentRequestPayload( + message: message, + sessionKey: config.sessionKey, + thinking: "low", + deliver: canDeliverToRoute, + attachments: attachments.isEmpty ? nil : attachments, + receipt: canDeliverToRoute, + receiptText: canDeliverToRoute ? "Just received your iOS share + request, working on it." : nil, + to: canDeliverToRoute ? deliveryTo : nil, + channel: canDeliverToRoute ? deliveryChannel : nil, + timeoutSeconds: nil, + key: UUID().uuidString) + let data = try JSONEncoder().encode(params) + guard let json = String(data: data, encoding: .utf8) else { + throw NSError( + domain: "OpenClawShare", + code: 12, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload."]) + } + struct NodeEventParams: Codable { + var event: String + var payloadJSON: String + } + let eventData = try JSONEncoder().encode(NodeEventParams(event: "agent.request", payloadJSON: json)) + guard let nodeEventParams = String(data: eventData, encoding: .utf8) else { + throw NSError( + domain: "OpenClawShare", + code: 13, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode node event payload."]) + } + _ = try await gateway.request(method: "node.event", paramsJSON: nodeEventParams, timeoutSeconds: 25) + } + + private func shouldRetryWithLegacyClientId(_ error: Error) -> Bool { + if let gatewayError = error as? GatewayResponseError { + let code = gatewayError.code.lowercased() + let message = gatewayError.message.lowercased() + let pathValue = (gatewayError.details["path"]?.value as? String)?.lowercased() ?? "" + let mentionsClientIdPath = + message.contains("/client/id") || message.contains("client id") + || pathValue.contains("/client/id") + let isInvalidConnectParams = + (code.contains("invalid") && code.contains("connect")) + || message.contains("invalid connect params") + if isInvalidConnectParams && mentionsClientIdPath { + return true + } + } + + let text = error.localizedDescription.lowercased() + return text.contains("invalid connect params") + && (text.contains("/client/id") || text.contains("client id")) + } + + private func showStatus(_ text: String) { + DispatchQueue.main.async { + let label: UILabel + if let existing = self.statusLabel { + label = existing + } else { + let newLabel = UILabel() + newLabel.translatesAutoresizingMaskIntoConstraints = false + newLabel.numberOfLines = 0 + newLabel.textAlignment = .center + newLabel.font = .preferredFont(forTextStyle: .body) + newLabel.textColor = .label + newLabel.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.92) + newLabel.layer.cornerRadius = 12 + newLabel.clipsToBounds = true + newLabel.layoutMargins = UIEdgeInsets(top: 12, left: 14, bottom: 12, right: 14) + self.view.addSubview(newLabel) + NSLayoutConstraint.activate([ + newLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 18), + newLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -18), + newLabel.bottomAnchor.constraint(equalTo: self.sendButton.topAnchor, constant: -10), + ]) + self.statusLabel = newLabel + label = newLabel + } + label.text = " \(text) " + } + } + + private func composeDraft(from payload: SharedContentPayload) -> String { + var lines: [String] = [] + let title = self.sanitizeDraftFragment(payload.title) + let text = self.sanitizeDraftFragment(payload.text) + let url = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if let title, !title.isEmpty { lines.append(title) } + if let text, !text.isEmpty { lines.append(text) } + if !url.isEmpty { lines.append(url) } + + return lines.joined(separator: "\n\n") + } + + private func sanitizeDraftFragment(_ raw: String?) -> String? { + guard let raw else { return nil } + let banned = [ + "shared from ios.", + "text:", + "shared attachment(s):", + "please help me with this.", + "please help me with this.w", + ] + let cleanedLines = raw + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { line in + guard !line.isEmpty else { return false } + let lowered = line.lowercased() + return !banned.contains { lowered == $0 || lowered.hasPrefix($0) } + } + let cleaned = cleanedLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned + } + + private func extractSharedContent() async -> ExtractedShareContent { + guard let items = self.extensionContext?.inputItems as? [NSExtensionItem] else { + return ExtractedShareContent( + payload: SharedContentPayload(title: nil, url: nil, text: nil), + attachments: []) + } + + var title: String? + var sharedURL: URL? + var sharedText: String? + var imageCount = 0 + var videoCount = 0 + var fileCount = 0 + var unknownCount = 0 + var attachments: [ShareAttachment] = [] + let maxImageAttachments = 3 + + for item in items { + if title == nil { + title = item.attributedTitle?.string ?? item.attributedContentText?.string + } + + for provider in item.attachments ?? [] { + if sharedURL == nil { + sharedURL = await self.loadURL(from: provider) + } + + if sharedText == nil { + sharedText = await self.loadText(from: provider) + } + + if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + imageCount += 1 + if attachments.count < maxImageAttachments, + let attachment = await self.loadImageAttachment(from: provider, index: attachments.count) + { + attachments.append(attachment) + } + } else if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + videoCount += 1 + } else if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + fileCount += 1 + } else { + unknownCount += 1 + } + + } + } + + _ = imageCount + _ = videoCount + _ = fileCount + _ = unknownCount + + return ExtractedShareContent( + payload: SharedContentPayload(title: title, url: sharedURL, text: sharedText), + attachments: attachments) + } + + private func loadImageAttachment(from provider: NSItemProvider, index: Int) async -> ShareAttachment? { + let imageUTI = self.preferredImageTypeIdentifier(from: provider) ?? UTType.image.identifier + guard let rawData = await self.loadDataValue(from: provider, typeIdentifier: imageUTI) else { + return nil + } + + let maxBytes = 5_000_000 + guard let image = UIImage(data: rawData), + let data = self.normalizedJPEGData(from: image, maxBytes: maxBytes) + else { + return nil + } + + return ShareAttachment( + type: "image", + mimeType: "image/jpeg", + fileName: "shared-image-\(index + 1).jpg", + content: data.base64EncodedString()) + } + + private func preferredImageTypeIdentifier(from provider: NSItemProvider) -> String? { + for identifier in provider.registeredTypeIdentifiers { + guard let utType = UTType(identifier) else { continue } + if utType.conforms(to: .image) { + return identifier + } + } + return nil + } + + private func normalizedJPEGData(from image: UIImage, maxBytes: Int) -> Data? { + var quality: CGFloat = 0.9 + while quality >= 0.4 { + if let data = image.jpegData(compressionQuality: quality), data.count <= maxBytes { + return data + } + quality -= 0.1 + } + guard let fallback = image.jpegData(compressionQuality: 0.35) else { return nil } + if fallback.count <= maxBytes { return fallback } + return nil + } + + private func loadURL(from provider: NSItemProvider) async -> URL? { + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + if let url = await self.loadURLValue( + from: provider, + typeIdentifier: UTType.url.identifier) + { + return url + } + } + + if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) { + if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.text.identifier), + let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)), + url.scheme != nil + { + return url + } + } + + return nil + } + + private func loadText(from provider: NSItemProvider) async -> String? { + if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.plainText.identifier) { + return text + } + } + + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + if let url = await self.loadURLValue(from: provider, typeIdentifier: UTType.url.identifier) { + return url.absoluteString + } + } + + return nil + } + + private func loadURLValue(from provider: NSItemProvider, typeIdentifier: String) async -> URL? { + await withCheckedContinuation { continuation in + provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in + if let url = item as? URL { + continuation.resume(returning: url) + return + } + if let str = item as? String, let url = URL(string: str) { + continuation.resume(returning: url) + return + } + if let ns = item as? NSString, let url = URL(string: ns as String) { + continuation.resume(returning: url) + return + } + continuation.resume(returning: nil) + } + } + } + + private func loadTextValue(from provider: NSItemProvider, typeIdentifier: String) async -> String? { + await withCheckedContinuation { continuation in + provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in + if let text = item as? String { + continuation.resume(returning: text) + return + } + if let text = item as? NSString { + continuation.resume(returning: text as String) + return + } + if let text = item as? NSAttributedString { + continuation.resume(returning: text.string) + return + } + continuation.resume(returning: nil) + } + } + } + + private func loadDataValue(from provider: NSItemProvider, typeIdentifier: String) async -> Data? { + await withCheckedContinuation { continuation in + provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in + continuation.resume(returning: data) + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Signing.xcconfig b/backend/app/one_person_security_dept/openclaw/apps/ios/Signing.xcconfig new file mode 100644 index 00000000..f942fc02 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Signing.xcconfig @@ -0,0 +1,17 @@ +// Default signing values for shared/repo builds. +// Auto-selected local team overrides live in .local-signing.xcconfig (git-ignored). +// Manual local overrides can go in LocalSigning.xcconfig (git-ignored). + +OPENCLAW_CODE_SIGN_STYLE = Manual +OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ + +OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios +OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.share + +OPENCLAW_APP_PROFILE = ai.openclaw.ios Development +OPENCLAW_SHARE_PROFILE = ai.openclaw.ios.share Development + +// Keep local includes after defaults: xcconfig is evaluated top-to-bottom, +// so later assignments in local files override the defaults above. +#include? ".local-signing.xcconfig" +#include? "LocalSigning.xcconfig" diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/100.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 00000000..22a04c9f Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/102.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/102.png new file mode 100644 index 00000000..ff8397de Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/102.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/1024.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 00000000..ecea7880 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/108.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/108.png new file mode 100644 index 00000000..a6888456 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/108.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/114.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 00000000..20e9ea1a Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/120.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 00000000..154836b4 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/172.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/172.png new file mode 100644 index 00000000..a66c0132 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/172.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/180.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 00000000..d01e83d8 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/196.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/196.png new file mode 100644 index 00000000..b7989e43 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/196.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/216.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/216.png new file mode 100644 index 00000000..4dfb94ab Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/216.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/234.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/234.png new file mode 100644 index 00000000..c0da9ae9 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/234.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/258.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/258.png new file mode 100644 index 00000000..dbfb7505 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/258.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/29.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 00000000..f4d57311 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/40.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 00000000..87a14602 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/48.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/48.png new file mode 100644 index 00000000..f66c2ded Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/48.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/55.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/55.png new file mode 100644 index 00000000..0730736f Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/55.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/57.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 00000000..f8946de3 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/58.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 00000000..92ae2f99 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/60.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 00000000..03231a71 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/66.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/66.png new file mode 100644 index 00000000..834c6b09 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/66.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/80.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 00000000..485a1aae Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/87.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 00000000..61da8b5f Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/88.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/88.png new file mode 100644 index 00000000..f47fb37b Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/88.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/92.png b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/92.png new file mode 100644 index 00000000..67a10a48 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/92.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..922e8c6d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"}]} \ No newline at end of file diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Calendar/CalendarService.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Calendar/CalendarService.swift new file mode 100644 index 00000000..94b2d9ea --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Calendar/CalendarService.swift @@ -0,0 +1,135 @@ +import EventKit +import Foundation +import OpenClawKit + +final class CalendarService: CalendarServicing { + func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload { + let store = EKEventStore() + let status = EKEventStore.authorizationStatus(for: .event) + let authorized = EventKitAuthorization.allowsRead(status: status) + guard authorized else { + throw NSError(domain: "Calendar", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission", + ]) + } + + let (start, end) = Self.resolveRange( + startISO: params.startISO, + endISO: params.endISO) + let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil) + let events = store.events(matching: predicate) + let limit = max(1, min(params.limit ?? 50, 500)) + let selected = Array(events.prefix(limit)) + + let formatter = ISO8601DateFormatter() + let payload = selected.map { event in + OpenClawCalendarEventPayload( + identifier: event.eventIdentifier ?? UUID().uuidString, + title: event.title ?? "(untitled)", + startISO: formatter.string(from: event.startDate), + endISO: formatter.string(from: event.endDate), + isAllDay: event.isAllDay, + location: event.location, + calendarTitle: event.calendar.title) + } + + return OpenClawCalendarEventsPayload(events: payload) + } + + func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload { + let store = EKEventStore() + let status = EKEventStore.authorizationStatus(for: .event) + let authorized = EventKitAuthorization.allowsWrite(status: status) + guard authorized else { + throw NSError(domain: "Calendar", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission", + ]) + } + + let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + throw NSError(domain: "Calendar", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "CALENDAR_INVALID: title required", + ]) + } + + let formatter = ISO8601DateFormatter() + guard let start = formatter.date(from: params.startISO) else { + throw NSError(domain: "Calendar", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "CALENDAR_INVALID: startISO required", + ]) + } + guard let end = formatter.date(from: params.endISO) else { + throw NSError(domain: "Calendar", code: 5, userInfo: [ + NSLocalizedDescriptionKey: "CALENDAR_INVALID: endISO required", + ]) + } + + let event = EKEvent(eventStore: store) + event.title = title + event.startDate = start + event.endDate = end + event.isAllDay = params.isAllDay ?? false + if let location = params.location?.trimmingCharacters(in: .whitespacesAndNewlines), !location.isEmpty { + event.location = location + } + if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty { + event.notes = notes + } + event.calendar = try Self.resolveCalendar( + store: store, + calendarId: params.calendarId, + calendarTitle: params.calendarTitle) + + try store.save(event, span: .thisEvent) + + let payload = OpenClawCalendarEventPayload( + identifier: event.eventIdentifier ?? UUID().uuidString, + title: event.title ?? title, + startISO: formatter.string(from: event.startDate), + endISO: formatter.string(from: event.endDate), + isAllDay: event.isAllDay, + location: event.location, + calendarTitle: event.calendar.title) + + return OpenClawCalendarAddPayload(event: payload) + } + + private static func resolveCalendar( + store: EKEventStore, + calendarId: String?, + calendarTitle: String?) throws -> EKCalendar + { + if let id = calendarId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty, + let calendar = store.calendar(withIdentifier: id) + { + return calendar + } + + if let title = calendarTitle?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + if let calendar = store.calendars(for: .event).first(where: { + $0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + }) { + return calendar + } + throw NSError(domain: "Calendar", code: 6, userInfo: [ + NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no calendar named \(title)", + ]) + } + + if let fallback = store.defaultCalendarForNewEvents { + return fallback + } + + throw NSError(domain: "Calendar", code: 7, userInfo: [ + NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no default calendar", + ]) + } + + private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) { + let formatter = ISO8601DateFormatter() + let start = startISO.flatMap { formatter.date(from: $0) } ?? Date() + let end = endISO.flatMap { formatter.date(from: $0) } ?? start.addingTimeInterval(7 * 24 * 3600) + return (start, end) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Camera/CameraController.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Camera/CameraController.swift new file mode 100644 index 00000000..1e9c10bc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Camera/CameraController.swift @@ -0,0 +1,402 @@ +import AVFoundation +import OpenClawKit +import Foundation + +actor CameraController { + struct CameraDeviceInfo: Codable, Sendable { + var id: String + var name: String + var position: String + var deviceType: String + } + + enum CameraError: LocalizedError, Sendable { + case cameraUnavailable + case microphoneUnavailable + case permissionDenied(kind: String) + case invalidParams(String) + case captureFailed(String) + case exportFailed(String) + + var errorDescription: String? { + switch self { + case .cameraUnavailable: + "Camera unavailable" + case .microphoneUnavailable: + "Microphone unavailable" + case let .permissionDenied(kind): + "\(kind) permission denied" + case let .invalidParams(msg): + msg + case let .captureFailed(msg): + msg + case let .exportFailed(msg): + msg + } + } + } + + func snap(params: OpenClawCameraSnapParams) async throws -> ( + format: String, + base64: String, + width: Int, + height: Int) + { + let facing = params.facing ?? .front + let format = params.format ?? .jpg + // Default to a reasonable max width to keep gateway payload sizes manageable. + // If you need the full-res photo, explicitly request a larger maxWidth. + let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600 + let quality = Self.clampQuality(params.quality) + let delayMs = max(0, params.delayMs ?? 0) + + try await self.ensureAccess(for: .video) + + let session = AVCaptureSession() + session.sessionPreset = .photo + + guard let device = Self.pickCamera(facing: facing, deviceId: params.deviceId) else { + throw CameraError.cameraUnavailable + } + + let input = try AVCaptureDeviceInput(device: device) + guard session.canAddInput(input) else { + throw CameraError.captureFailed("Failed to add camera input") + } + session.addInput(input) + + let output = AVCapturePhotoOutput() + guard session.canAddOutput(output) else { + throw CameraError.captureFailed("Failed to add photo output") + } + session.addOutput(output) + output.maxPhotoQualityPrioritization = .quality + + session.startRunning() + defer { session.stopRunning() } + await Self.warmUpCaptureSession() + await Self.sleepDelayMs(delayMs) + + let settings: AVCapturePhotoSettings = { + if output.availablePhotoCodecTypes.contains(.jpeg) { + return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg]) + } + return AVCapturePhotoSettings() + }() + settings.photoQualityPrioritization = .quality + + var delegate: PhotoCaptureDelegate? + let rawData: Data = try await withCheckedThrowingContinuation { cont in + let d = PhotoCaptureDelegate(cont) + delegate = d + output.capturePhoto(with: settings, delegate: d) + } + withExtendedLifetime(delegate) {} + + let res = try PhotoCapture.transcodeJPEGForGateway( + rawData: rawData, + maxWidthPx: maxWidth, + quality: quality) + + return ( + format: format.rawValue, + base64: res.data.base64EncodedString(), + width: res.widthPx, + height: res.heightPx) + } + + func clip(params: OpenClawCameraClipParams) async throws -> ( + format: String, + base64: String, + durationMs: Int, + hasAudio: Bool) + { + let facing = params.facing ?? .front + let durationMs = Self.clampDurationMs(params.durationMs) + let includeAudio = params.includeAudio ?? true + let format = params.format ?? .mp4 + + try await self.ensureAccess(for: .video) + if includeAudio { + try await self.ensureAccess(for: .audio) + } + + let session = AVCaptureSession() + session.sessionPreset = .high + + guard let camera = Self.pickCamera(facing: facing, deviceId: params.deviceId) else { + throw CameraError.cameraUnavailable + } + let cameraInput = try AVCaptureDeviceInput(device: camera) + guard session.canAddInput(cameraInput) else { + throw CameraError.captureFailed("Failed to add camera input") + } + session.addInput(cameraInput) + + if includeAudio { + guard let mic = AVCaptureDevice.default(for: .audio) else { + throw CameraError.microphoneUnavailable + } + let micInput = try AVCaptureDeviceInput(device: mic) + if session.canAddInput(micInput) { + session.addInput(micInput) + } else { + throw CameraError.captureFailed("Failed to add microphone input") + } + } + + let output = AVCaptureMovieFileOutput() + guard session.canAddOutput(output) else { + throw CameraError.captureFailed("Failed to add movie output") + } + session.addOutput(output) + output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000) + + session.startRunning() + defer { session.stopRunning() } + await Self.warmUpCaptureSession() + + let movURL = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov") + let mp4URL = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4") + + defer { + try? FileManager().removeItem(at: movURL) + try? FileManager().removeItem(at: mp4URL) + } + + var delegate: MovieFileDelegate? + let recordedURL: URL = try await withCheckedThrowingContinuation { cont in + let d = MovieFileDelegate(cont) + delegate = d + output.startRecording(to: movURL, recordingDelegate: d) + } + withExtendedLifetime(delegate) {} + + // Transcode .mov -> .mp4 for easier downstream handling. + try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL) + + let data = try Data(contentsOf: mp4URL) + return ( + format: format.rawValue, + base64: data.base64EncodedString(), + durationMs: durationMs, + hasAudio: includeAudio) + } + + func listDevices() -> [CameraDeviceInfo] { + return Self.discoverVideoDevices().map { device in + CameraDeviceInfo( + id: device.uniqueID, + name: device.localizedName, + position: Self.positionLabel(device.position), + deviceType: device.deviceType.rawValue) + } + } + + private func ensureAccess(for mediaType: AVMediaType) async throws { + let status = AVCaptureDevice.authorizationStatus(for: mediaType) + switch status { + case .authorized: + return + case .notDetermined: + let ok = await withCheckedContinuation(isolation: nil) { cont in + AVCaptureDevice.requestAccess(for: mediaType) { granted in + cont.resume(returning: granted) + } + } + if !ok { + throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") + } + case .denied, .restricted: + throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") + @unknown default: + throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") + } + } + + private nonisolated static func pickCamera( + facing: OpenClawCameraFacing, + deviceId: String?) -> AVCaptureDevice? + { + if let deviceId, !deviceId.isEmpty { + if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) { + return match + } + } + let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back + if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) { + return device + } + // Fall back to any default camera (e.g. simulator / unusual device configurations). + return AVCaptureDevice.default(for: .video) + } + + private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String { + switch position { + case .front: "front" + case .back: "back" + default: "unspecified" + } + } + + private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] { + let types: [AVCaptureDevice.DeviceType] = [ + .builtInWideAngleCamera, + .builtInUltraWideCamera, + .builtInTelephotoCamera, + .builtInDualCamera, + .builtInDualWideCamera, + .builtInTripleCamera, + .builtInTrueDepthCamera, + .builtInLiDARDepthCamera, + ] + let session = AVCaptureDevice.DiscoverySession( + deviceTypes: types, + mediaType: .video, + position: .unspecified) + return session.devices + } + + nonisolated static func clampQuality(_ quality: Double?) -> Double { + let q = quality ?? 0.9 + return min(1.0, max(0.05, q)) + } + + nonisolated static func clampDurationMs(_ ms: Int?) -> Int { + let v = ms ?? 3000 + // Keep clips short by default; avoid huge base64 payloads on the gateway. + return min(60000, max(250, v)) + } + + private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws { + let asset = AVURLAsset(url: inputURL) + guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else { + throw CameraError.exportFailed("Failed to create export session") + } + exporter.shouldOptimizeForNetworkUse = true + + if #available(iOS 18.0, tvOS 18.0, visionOS 2.0, *) { + do { + try await exporter.export(to: outputURL, as: .mp4) + return + } catch { + throw CameraError.exportFailed(error.localizedDescription) + } + } else { + exporter.outputURL = outputURL + exporter.outputFileType = .mp4 + + try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in + exporter.exportAsynchronously { + cont.resume(returning: ()) + } + } + + switch exporter.status { + case .completed: + return + case .failed: + throw CameraError.exportFailed(exporter.error?.localizedDescription ?? "export failed") + case .cancelled: + throw CameraError.exportFailed("export cancelled") + default: + throw CameraError.exportFailed("export did not complete") + } + } + } + + private nonisolated static func warmUpCaptureSession() async { + // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices. + try? await Task.sleep(nanoseconds: 150_000_000) // 150ms + } + + private nonisolated static func sleepDelayMs(_ delayMs: Int) async { + guard delayMs > 0 else { return } + let maxDelayMs = 10 * 1000 + let ns = UInt64(min(delayMs, maxDelayMs)) * UInt64(NSEC_PER_MSEC) + try? await Task.sleep(nanoseconds: ns) + } +} + +private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate { + private let continuation: CheckedContinuation + private var didResume = false + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func photoOutput( + _ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error? + ) { + guard !self.didResume else { return } + self.didResume = true + + if let error { + self.continuation.resume(throwing: error) + return + } + guard let data = photo.fileDataRepresentation() else { + self.continuation.resume( + throwing: NSError(domain: "Camera", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "photo data missing", + ])) + return + } + if data.isEmpty { + self.continuation.resume( + throwing: NSError(domain: "Camera", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "photo data empty", + ])) + return + } + self.continuation.resume(returning: data) + } + + func photoOutput( + _ output: AVCapturePhotoOutput, + didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, + error: Error? + ) { + guard let error else { return } + guard !self.didResume else { return } + self.didResume = true + self.continuation.resume(throwing: error) + } +} + +private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate { + private let continuation: CheckedContinuation + private var didResume = false + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func fileOutput( + _ output: AVCaptureFileOutput, + didFinishRecordingTo outputFileURL: URL, + from connections: [AVCaptureConnection], + error: Error?) + { + guard !self.didResume else { return } + self.didResume = true + + if let error { + let ns = error as NSError + if ns.domain == AVFoundationErrorDomain, + ns.code == AVError.maximumDurationReached.rawValue + { + self.continuation.resume(returning: outputFileURL) + return + } + self.continuation.resume(throwing: error) + return + } + self.continuation.resume(returning: outputFileURL) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift new file mode 100644 index 00000000..6dbdd51e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift @@ -0,0 +1,25 @@ +import Foundation +import OpenClawKit + +@MainActor +final class NodeCapabilityRouter { + enum RouterError: Error { + case unknownCommand + case handlerUnavailable + } + + typealias Handler = (BridgeInvokeRequest) async throws -> BridgeInvokeResponse + + private let handlers: [String: Handler] + + init(handlers: [String: Handler]) { + self.handlers = handlers + } + + func handle(_ request: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + guard let handler = handlers[request.command] else { + throw RouterError.unknownCommand + } + return try await handler(request) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Chat/ChatSheet.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Chat/ChatSheet.swift new file mode 100644 index 00000000..bbed501c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Chat/ChatSheet.swift @@ -0,0 +1,47 @@ +import OpenClawChatUI +import OpenClawKit +import SwiftUI + +struct ChatSheet: View { + @Environment(\.dismiss) private var dismiss + @State private var viewModel: OpenClawChatViewModel + private let userAccent: Color? + private let agentName: String? + + init(gateway: GatewayNodeSession, sessionKey: String, agentName: String? = nil, userAccent: Color? = nil) { + let transport = IOSGatewayChatTransport(gateway: gateway) + self._viewModel = State( + initialValue: OpenClawChatViewModel( + sessionKey: sessionKey, + transport: transport)) + self.userAccent = userAccent + self.agentName = agentName + } + + var body: some View { + NavigationStack { + OpenClawChatView( + viewModel: self.viewModel, + showsSessionSwitcher: true, + userAccent: self.userAccent) + .navigationTitle(self.chatTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + self.dismiss() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("Close") + } + } + } + } + + private var chatTitle: String { + let trimmed = (self.agentName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return "Chat" } + return "Chat (\(trimmed))" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift new file mode 100644 index 00000000..95718390 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -0,0 +1,137 @@ +import OpenClawChatUI +import OpenClawKit +import OpenClawProtocol +import Foundation +import OSLog + +struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable { + private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport") + private let gateway: GatewayNodeSession + + init(gateway: GatewayNodeSession) { + self.gateway = gateway + } + + func abortRun(sessionKey: String, runId: String) async throws { + struct Params: Codable { + var sessionKey: String + var runId: String + } + let data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId)) + let json = String(data: data, encoding: .utf8) + _ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10) + } + + func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse { + struct Params: Codable { + var includeGlobal: Bool + var includeUnknown: Bool + var limit: Int? + } + let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit)) + let json = String(data: data, encoding: .utf8) + let res = try await self.gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15) + return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: res) + } + + func setActiveSessionKey(_ sessionKey: String) async throws { + // Operator clients receive chat events without node-style subscriptions. + // (chat.subscribe is a node event, not an operator RPC method.) + } + + func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { + struct Params: Codable { var sessionKey: String } + let data = try JSONEncoder().encode(Params(sessionKey: sessionKey)) + let json = String(data: data, encoding: .utf8) + let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15) + return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res) + } + + func sendMessage( + sessionKey: String, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + { + Self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) len=\(message.count, privacy: .public) attachments=\(attachments.count, privacy: .public)") + struct Params: Codable { + var sessionKey: String + var message: String + var thinking: String + var attachments: [OpenClawChatAttachmentPayload]? + var timeoutMs: Int + var idempotencyKey: String + } + + let params = Params( + sessionKey: sessionKey, + message: message, + thinking: thinking, + attachments: attachments.isEmpty ? nil : attachments, + timeoutMs: 30000, + idempotencyKey: idempotencyKey) + let data = try JSONEncoder().encode(params) + let json = String(data: data, encoding: .utf8) + do { + let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35) + let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res) + Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)") + return decoded + } catch { + Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)") + throw error + } + } + + func requestHealth(timeoutMs: Int) async throws -> Bool { + let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0))) + let res = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds) + return (try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: res))?.ok ?? true + } + + func events() -> AsyncStream { + AsyncStream { continuation in + let task = Task { + let stream = await self.gateway.subscribeServerEvents() + for await evt in stream { + if Task.isCancelled { return } + switch evt.event { + case "tick": + continuation.yield(.tick) + case "seqGap": + continuation.yield(.seqGap) + case "health": + guard let payload = evt.payload else { break } + let ok = (try? GatewayPayloadDecoding.decode( + payload, + as: OpenClawGatewayHealthOK.self))?.ok ?? true + continuation.yield(.health(ok: ok)) + case "chat": + guard let payload = evt.payload else { break } + if let chatPayload = try? GatewayPayloadDecoding.decode( + payload, + as: OpenClawChatEventPayload.self) + { + continuation.yield(.chat(chatPayload)) + } + case "agent": + guard let payload = evt.payload else { break } + if let agentPayload = try? GatewayPayloadDecoding.decode( + payload, + as: OpenClawAgentEventPayload.self) + { + continuation.yield(.agent(agentPayload)) + } + default: + break + } + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Contacts/ContactsService.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Contacts/ContactsService.swift new file mode 100644 index 00000000..db203d07 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Contacts/ContactsService.swift @@ -0,0 +1,212 @@ +import Contacts +import Foundation +import OpenClawKit + +final class ContactsService: ContactsServicing { + private static var payloadKeys: [CNKeyDescriptor] { + [ + CNContactIdentifierKey as CNKeyDescriptor, + CNContactGivenNameKey as CNKeyDescriptor, + CNContactFamilyNameKey as CNKeyDescriptor, + CNContactOrganizationNameKey as CNKeyDescriptor, + CNContactPhoneNumbersKey as CNKeyDescriptor, + CNContactEmailAddressesKey as CNKeyDescriptor, + ] + } + + func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload { + let store = CNContactStore() + let status = CNContactStore.authorizationStatus(for: .contacts) + let authorized = await Self.ensureAuthorization(store: store, status: status) + guard authorized else { + throw NSError(domain: "Contacts", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission", + ]) + } + + let limit = max(1, min(params.limit ?? 25, 200)) + + var contacts: [CNContact] = [] + if let query = params.query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty { + let predicate = CNContact.predicateForContacts(matchingName: query) + contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys) + } else { + let request = CNContactFetchRequest(keysToFetch: Self.payloadKeys) + try store.enumerateContacts(with: request) { contact, stop in + contacts.append(contact) + if contacts.count >= limit { + stop.pointee = true + } + } + } + + let sliced = Array(contacts.prefix(limit)) + let payload = sliced.map { Self.payload(from: $0) } + + return OpenClawContactsSearchPayload(contacts: payload) + } + + func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload { + let store = CNContactStore() + let status = CNContactStore.authorizationStatus(for: .contacts) + let authorized = await Self.ensureAuthorization(store: store, status: status) + guard authorized else { + throw NSError(domain: "Contacts", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission", + ]) + } + + let givenName = params.givenName?.trimmingCharacters(in: .whitespacesAndNewlines) + let familyName = params.familyName?.trimmingCharacters(in: .whitespacesAndNewlines) + let organizationName = params.organizationName?.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = params.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) + let phoneNumbers = Self.normalizeStrings(params.phoneNumbers) + let emails = Self.normalizeStrings(params.emails, lowercased: true) + + let hasName = !(givenName ?? "").isEmpty || !(familyName ?? "").isEmpty || !(displayName ?? "").isEmpty + let hasOrg = !(organizationName ?? "").isEmpty + let hasDetails = !phoneNumbers.isEmpty || !emails.isEmpty + guard hasName || hasOrg || hasDetails else { + throw NSError(domain: "Contacts", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "CONTACTS_INVALID: include a name, organization, phone, or email", + ]) + } + + if !phoneNumbers.isEmpty || !emails.isEmpty { + if let existing = try Self.findExistingContact( + store: store, + phoneNumbers: phoneNumbers, + emails: emails) + { + return OpenClawContactsAddPayload(contact: Self.payload(from: existing)) + } + } + + let contact = CNMutableContact() + contact.givenName = givenName ?? "" + contact.familyName = familyName ?? "" + contact.organizationName = organizationName ?? "" + if contact.givenName.isEmpty && contact.familyName.isEmpty, let displayName { + contact.givenName = displayName + } + contact.phoneNumbers = phoneNumbers.map { + CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0)) + } + contact.emailAddresses = emails.map { + CNLabeledValue(label: CNLabelHome, value: $0 as NSString) + } + + let save = CNSaveRequest() + save.add(contact, toContainerWithIdentifier: nil) + try store.execute(save) + + let persisted: CNContact + if !contact.identifier.isEmpty { + persisted = try store.unifiedContact( + withIdentifier: contact.identifier, + keysToFetch: Self.payloadKeys) + } else { + persisted = contact + } + + return OpenClawContactsAddPayload(contact: Self.payload(from: persisted)) + } + + private static func ensureAuthorization(store: CNContactStore, status: CNAuthorizationStatus) async -> Bool { + switch status { + case .authorized, .limited: + return true + case .notDetermined: + // Don’t prompt during node.invoke; the caller should instruct the user to grant permission. + // Prompts block the invoke and lead to timeouts in headless flows. + return false + case .restricted, .denied: + return false + @unknown default: + return false + } + } + + private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] { + (values ?? []) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { lowercased ? $0.lowercased() : $0 } + } + + private static func findExistingContact( + store: CNContactStore, + phoneNumbers: [String], + emails: [String]) throws -> CNContact? + { + if phoneNumbers.isEmpty && emails.isEmpty { + return nil + } + + var matches: [CNContact] = [] + + for phone in phoneNumbers { + let predicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: phone)) + let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys) + matches.append(contentsOf: contacts) + } + + for email in emails { + let predicate = CNContact.predicateForContacts(matchingEmailAddress: email) + let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys) + matches.append(contentsOf: contacts) + } + + return Self.matchContacts(contacts: matches, phoneNumbers: phoneNumbers, emails: emails) + } + + private static func matchContacts( + contacts: [CNContact], + phoneNumbers: [String], + emails: [String]) -> CNContact? + { + let normalizedPhones = Set(phoneNumbers.map { normalizePhone($0) }.filter { !$0.isEmpty }) + let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty }) + var seen = Set() + + for contact in contacts { + guard seen.insert(contact.identifier).inserted else { continue } + let contactPhones = Set(contact.phoneNumbers.map { normalizePhone($0.value.stringValue) }) + let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() }) + + if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) { + return contact + } + if !normalizedEmails.isEmpty, !contactEmails.isDisjoint(with: normalizedEmails) { + return contact + } + } + + return nil + } + + private static func normalizePhone(_ phone: String) -> String { + let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines) + let digits = trimmed.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) } + let normalized = String(String.UnicodeScalarView(digits)) + return normalized.isEmpty ? trimmed : normalized + } + + private static func payload(from contact: CNContact) -> OpenClawContactPayload { + OpenClawContactPayload( + identifier: contact.identifier, + displayName: CNContactFormatter.string(from: contact, style: .fullName) + ?? "\(contact.givenName) \(contact.familyName)".trimmingCharacters(in: .whitespacesAndNewlines), + givenName: contact.givenName, + familyName: contact.familyName, + organizationName: contact.organizationName, + phoneNumbers: contact.phoneNumbers.map { $0.value.stringValue }, + emails: contact.emailAddresses.map { String($0.value) }) + } + +#if DEBUG + static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool { + matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil + } +#endif +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Device/DeviceInfoHelper.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Device/DeviceInfoHelper.swift new file mode 100644 index 00000000..eeed54c4 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Device/DeviceInfoHelper.swift @@ -0,0 +1,71 @@ +import Foundation +import UIKit + +import Darwin + +/// Shared device and platform info for Settings, gateway node payloads, and device status. +enum DeviceInfoHelper { + /// e.g. "iOS 18.0.0" or "iPadOS 18.0.0" by interface idiom. Use for gateway/device payloads. + static func platformString() -> String { + let v = ProcessInfo.processInfo.operatingSystemVersion + let name = switch UIDevice.current.userInterfaceIdiom { + case .pad: + "iPadOS" + case .phone: + "iOS" + default: + "iOS" + } + return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + /// Always "iOS X.Y.Z" for UI display (e.g. Settings), matching legacy behavior on iPad. + static func platformStringForDisplay() -> String { + let v = ProcessInfo.processInfo.operatingSystemVersion + return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + /// Device family for display: "iPad", "iPhone", or "iOS". + static func deviceFamily() -> String { + switch UIDevice.current.userInterfaceIdiom { + case .pad: + "iPad" + case .phone: + "iPhone" + default: + "iOS" + } + } + + /// Machine model identifier from uname (e.g. "iPhone17,1"). + static func modelIdentifier() -> String { + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in + String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) + } + let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? "unknown" : trimmed + } + + /// App marketing version only, e.g. "2026.2.0" or "dev". + static func appVersion() -> String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" + } + + /// App build string, e.g. "123" or "". + static func appBuild() -> String { + let raw = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + return raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Display string for Settings: "1.2.3" or "1.2.3 (456)" when build differs. + static func openClawVersionString() -> String { + let version = appVersion() + let build = appBuild() + if build.isEmpty || build == version { + return version + } + return "\(version) (\(build))" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Device/DeviceStatusService.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Device/DeviceStatusService.swift new file mode 100644 index 00000000..a80a9810 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Device/DeviceStatusService.swift @@ -0,0 +1,82 @@ +import Foundation +import OpenClawKit +import UIKit + +final class DeviceStatusService: DeviceStatusServicing { + private let networkStatus: NetworkStatusService + + init(networkStatus: NetworkStatusService = NetworkStatusService()) { + self.networkStatus = networkStatus + } + + func status() async throws -> OpenClawDeviceStatusPayload { + let battery = self.batteryStatus() + let thermal = self.thermalStatus() + let storage = self.storageStatus() + let network = await self.networkStatus.currentStatus() + let uptime = ProcessInfo.processInfo.systemUptime + + return OpenClawDeviceStatusPayload( + battery: battery, + thermal: thermal, + storage: storage, + network: network, + uptimeSeconds: uptime) + } + + func info() -> OpenClawDeviceInfoPayload { + let device = UIDevice.current + let appVersion = DeviceInfoHelper.appVersion() + let appBuild = DeviceStatusService.fallbackAppBuild(DeviceInfoHelper.appBuild()) + let locale = Locale.preferredLanguages.first ?? Locale.current.identifier + return OpenClawDeviceInfoPayload( + deviceName: device.name, + modelIdentifier: DeviceInfoHelper.modelIdentifier(), + systemName: device.systemName, + systemVersion: device.systemVersion, + appVersion: appVersion, + appBuild: appBuild, + locale: locale) + } + + private func batteryStatus() -> OpenClawBatteryStatusPayload { + let device = UIDevice.current + device.isBatteryMonitoringEnabled = true + let level = device.batteryLevel >= 0 ? Double(device.batteryLevel) : nil + let state: OpenClawBatteryState = switch device.batteryState { + case .charging: .charging + case .full: .full + case .unplugged: .unplugged + case .unknown: .unknown + @unknown default: .unknown + } + return OpenClawBatteryStatusPayload( + level: level, + state: state, + lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled) + } + + private func thermalStatus() -> OpenClawThermalStatusPayload { + let state: OpenClawThermalState = switch ProcessInfo.processInfo.thermalState { + case .nominal: .nominal + case .fair: .fair + case .serious: .serious + case .critical: .critical + @unknown default: .nominal + } + return OpenClawThermalStatusPayload(state: state) + } + + private func storageStatus() -> OpenClawStorageStatusPayload { + let attrs = (try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())) ?? [:] + let total = (attrs[.systemSize] as? NSNumber)?.int64Value ?? 0 + let free = (attrs[.systemFreeSize] as? NSNumber)?.int64Value ?? 0 + let used = max(0, total - free) + return OpenClawStorageStatusPayload(totalBytes: total, freeBytes: free, usedBytes: used) + } + + /// Fallback for payloads that require a non-empty build (e.g. "0"). + private static func fallbackAppBuild(_ build: String) -> String { + build.isEmpty ? "0" : build + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Device/NetworkStatusService.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Device/NetworkStatusService.swift new file mode 100644 index 00000000..bc27eb19 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Device/NetworkStatusService.swift @@ -0,0 +1,69 @@ +import Foundation +import Network +import OpenClawKit + +final class NetworkStatusService: @unchecked Sendable { + func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload { + await withCheckedContinuation { cont in + let monitor = NWPathMonitor() + let queue = DispatchQueue(label: "ai.openclaw.ios.network-status") + let state = NetworkStatusState() + + monitor.pathUpdateHandler = { path in + guard state.markCompleted() else { return } + monitor.cancel() + cont.resume(returning: Self.payload(from: path)) + } + + monitor.start(queue: queue) + + queue.asyncAfter(deadline: .now() + .milliseconds(timeoutMs)) { + guard state.markCompleted() else { return } + monitor.cancel() + cont.resume(returning: Self.fallbackPayload()) + } + } + } + + private static func payload(from path: NWPath) -> OpenClawNetworkStatusPayload { + let status: OpenClawNetworkPathStatus = switch path.status { + case .satisfied: .satisfied + case .requiresConnection: .requiresConnection + case .unsatisfied: .unsatisfied + @unknown default: .unsatisfied + } + + var interfaces: [OpenClawNetworkInterfaceType] = [] + if path.usesInterfaceType(.wifi) { interfaces.append(.wifi) } + if path.usesInterfaceType(.cellular) { interfaces.append(.cellular) } + if path.usesInterfaceType(.wiredEthernet) { interfaces.append(.wired) } + if interfaces.isEmpty { interfaces.append(.other) } + + return OpenClawNetworkStatusPayload( + status: status, + isExpensive: path.isExpensive, + isConstrained: path.isConstrained, + interfaces: interfaces) + } + + private static func fallbackPayload() -> OpenClawNetworkStatusPayload { + OpenClawNetworkStatusPayload( + status: .unsatisfied, + isExpensive: false, + isConstrained: false, + interfaces: [.other]) + } +} + +private final class NetworkStatusState: @unchecked Sendable { + private let lock = NSLock() + private var completed = false + + func markCompleted() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + if self.completed { return false } + self.completed = true + return true + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Device/NodeDisplayName.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Device/NodeDisplayName.swift new file mode 100644 index 00000000..9ddf38b2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Device/NodeDisplayName.swift @@ -0,0 +1,48 @@ +import Foundation +import UIKit + +enum NodeDisplayName { + private static let genericNames: Set = ["iOS Node", "iPhone Node", "iPad Node"] + + static func isGeneric(_ name: String) -> Bool { + Self.genericNames.contains(name) + } + + static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String { + switch interfaceIdiom { + case .phone: + return "iPhone Node" + case .pad: + return "iPad Node" + default: + return "iOS Node" + } + } + + static func resolve( + existing: String?, + deviceName: String, + interfaceIdiom: UIUserInterfaceIdiom + ) -> String { + let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) { + return trimmedExisting + } + + let trimmedDevice = deviceName.trimmingCharacters(in: .whitespacesAndNewlines) + if let normalized = Self.normalizedDeviceName(trimmedDevice) { + return normalized + } + + return Self.defaultValue(for: interfaceIdiom) + } + + private static func normalizedDeviceName(_ deviceName: String) -> String? { + guard !deviceName.isEmpty else { return nil } + let lower = deviceName.lowercased() + if lower.contains("iphone") || lower.contains("ipad") || lower.contains("ios") { + return deviceName + } + return nil + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/EventKit/EventKitAuthorization.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/EventKit/EventKitAuthorization.swift new file mode 100644 index 00000000..c27e9a3e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/EventKit/EventKitAuthorization.swift @@ -0,0 +1,34 @@ +import EventKit + +enum EventKitAuthorization { + static func allowsRead(status: EKAuthorizationStatus) -> Bool { + switch status { + case .authorized, .fullAccess: + return true + case .writeOnly: + return false + case .notDetermined: + // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts. + return false + case .restricted, .denied: + return false + @unknown default: + return false + } + } + + static func allowsWrite(status: EKAuthorizationStatus) -> Bool { + switch status { + case .authorized, .fullAccess, .writeOnly: + return true + case .notDetermined: + // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts. + return false + case .restricted, .denied: + return false + @unknown default: + return false + } + } +} + diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift new file mode 100644 index 00000000..0624e976 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct DeepLinkAgentPromptAlert: ViewModifier { + @Environment(NodeAppModel.self) private var appModel: NodeAppModel + + private var promptBinding: Binding { + Binding( + get: { self.appModel.pendingAgentDeepLinkPrompt }, + set: { _ in + // Keep prompt state until explicit user action. + }) + } + + func body(content: Content) -> some View { + content.alert(item: self.promptBinding) { prompt in + Alert( + title: Text("Run OpenClaw agent?"), + message: Text( + """ + Message: + \(prompt.messagePreview) + + URL: + \(prompt.urlPreview) + """), + primaryButton: .cancel(Text("Cancel")) { + self.appModel.declinePendingAgentDeepLinkPrompt() + }, + secondaryButton: .default(Text("Run")) { + Task { await self.appModel.approvePendingAgentDeepLinkPrompt() } + }) + } + } +} + +extension View { + func deepLinkAgentPromptAlert() -> some View { + self.modifier(DeepLinkAgentPromptAlert()) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayConnectConfig.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayConnectConfig.swift new file mode 100644 index 00000000..7f4e9338 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayConnectConfig.swift @@ -0,0 +1,27 @@ +import Foundation +import OpenClawKit + +/// Single source of truth for "how we connect" to the current gateway. +/// +/// The iOS app maintains two WebSocket sessions to the same gateway: +/// - a `role=node` session for device capabilities (`node.invoke.*`) +/// - a `role=operator` session for chat/talk/config (`chat.*`, `talk.*`, etc.) +/// +/// Both sessions should derive all connection inputs from this config so we +/// don't accidentally persist gateway-scoped state under different keys. +struct GatewayConnectConfig: Sendable { + let url: URL + let stableID: String + let tls: GatewayTLSParams? + let token: String? + let password: String? + let nodeOptions: GatewayConnectOptions + + /// Stable, non-empty identifier used for gateway-scoped persistence keys. + /// If the caller doesn't provide a stableID, fall back to URL identity. + var effectiveStableID: String { + let trimmed = self.stableID.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return self.url.absoluteString } + return trimmed + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayConnectionController.swift new file mode 100644 index 00000000..a770fcb2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -0,0 +1,1055 @@ +import AVFoundation +import Contacts +import CoreLocation +import CoreMotion +import CryptoKit +import EventKit +import Foundation +import Darwin +import OpenClawKit +import Network +import Observation +import Photos +import ReplayKit +import Security +import Speech +import SwiftUI +import UIKit + +@MainActor +@Observable +final class GatewayConnectionController { + struct TrustPrompt: Identifiable, Equatable { + let stableID: String + let gatewayName: String + let host: String + let port: Int + let fingerprintSha256: String + let isManual: Bool + + var id: String { self.stableID } + } + + private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = [] + private(set) var discoveryStatusText: String = "Idle" + private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = [] + private(set) var pendingTrustPrompt: TrustPrompt? + + private let discovery = GatewayDiscoveryModel() + private weak var appModel: NodeAppModel? + private var didAutoConnect = false + private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:] + private var pendingTrustConnect: (url: URL, stableID: String, isManual: Bool)? + + init(appModel: NodeAppModel, startDiscovery: Bool = true) { + self.appModel = appModel + + GatewaySettingsStore.bootstrapPersistence() + let defaults = UserDefaults.standard + self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "gateway.discovery.debugLogs")) + + self.updateFromDiscovery() + self.observeDiscovery() + + if startDiscovery { + self.discovery.start() + } + } + + func setDiscoveryDebugLoggingEnabled(_ enabled: Bool) { + self.discovery.setDebugLoggingEnabled(enabled) + } + + func setScenePhase(_ phase: ScenePhase) { + switch phase { + case .background: + self.discovery.stop() + case .active, .inactive: + self.discovery.start() + self.attemptAutoReconnectIfNeeded() + @unknown default: + self.discovery.start() + self.attemptAutoReconnectIfNeeded() + } + } + + func allowAutoConnectAgain() { + self.didAutoConnect = false + self.maybeAutoConnect() + } + + func restartDiscovery() { + self.discovery.stop() + self.didAutoConnect = false + self.discovery.start() + self.updateFromDiscovery() + } + + + /// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error. + func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? { + await self.connectDiscoveredGateway(gateway) + } + + private func connectDiscoveredGateway( + _ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? + { + let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if instanceId.isEmpty { + return "Missing instanceId (node.instanceId). Try restarting the app." + } + let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) + + // Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT. + guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else { + return "Failed to resolve the discovered gateway endpoint." + } + + let stableID = gateway.stableID + // Discovery is a LAN operation; refuse unauthenticated plaintext connects. + let tlsRequired = true + let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) + + guard gateway.tlsEnabled || stored != nil else { + return "Discovered gateway is missing TLS and no trusted fingerprint is stored." + } + + if tlsRequired, stored == nil { + guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true) + else { return "Failed to build TLS URL for trust verification." } + guard let fp = await self.probeTLSFingerprint(url: url) else { + return "Failed to read TLS fingerprint from discovered gateway." + } + self.pendingTrustConnect = (url: url, stableID: stableID, isManual: false) + self.pendingTrustPrompt = TrustPrompt( + stableID: stableID, + gatewayName: gateway.name, + host: target.host, + port: target.port, + fingerprintSha256: fp, + isManual: false) + self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint" + return nil + } + + let tlsParams = stored.map { fp in + GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID) + } + + guard let url = self.buildGatewayURL( + host: target.host, + port: target.port, + useTLS: tlsParams?.required == true) + else { return "Failed to build discovered gateway URL." } + GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: stableID, useTLS: true) + self.didAutoConnect = true + self.startAutoConnect( + url: url, + gatewayStableID: stableID, + tls: tlsParams, + token: token, + password: password) + return nil + } + + func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { + _ = await self.connectWithDiagnostics(gateway) + } + + func connectManual(host: String, port: Int, useTLS: Bool) async { + let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) + let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS) + guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS) + else { return } + let stableID = self.manualStableID(host: host, port: resolvedPort) + let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) + if resolvedUseTLS, stored == nil { + guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return } + guard let fp = await self.probeTLSFingerprint(url: url) else { return } + self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true) + self.pendingTrustPrompt = TrustPrompt( + stableID: stableID, + gatewayName: "\(host):\(resolvedPort)", + host: host, + port: resolvedPort, + fingerprintSha256: fp, + isManual: true) + self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint" + return + } + + let tlsParams = stored.map { fp in + GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID) + } + guard let url = self.buildGatewayURL( + host: host, + port: resolvedPort, + useTLS: tlsParams?.required == true) + else { return } + GatewaySettingsStore.saveLastGatewayConnectionManual( + host: host, + port: resolvedPort, + useTLS: resolvedUseTLS && tlsParams != nil, + stableID: stableID) + self.didAutoConnect = true + self.startAutoConnect( + url: url, + gatewayStableID: stableID, + tls: tlsParams, + token: token, + password: password) + } + + func connectLastKnown() async { + guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return } + switch last { + case let .manual(host, port, useTLS, _): + await self.connectManual(host: host, port: port, useTLS: useTLS) + case let .discovered(stableID, _): + guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else { return } + await self.connectDiscoveredGateway(gateway) + } + } + + /// Rebuild connect options from current local settings (caps/commands/permissions) + /// and re-apply the active gateway config so capability changes take effect immediately. + func refreshActiveGatewayRegistrationFromSettings() { + guard let appModel else { return } + guard let cfg = appModel.activeGatewayConnectConfig else { return } + guard appModel.gatewayAutoReconnectEnabled else { return } + + let refreshedConfig = GatewayConnectConfig( + url: cfg.url, + stableID: cfg.stableID, + tls: cfg.tls, + token: cfg.token, + password: cfg.password, + nodeOptions: self.makeConnectOptions(stableID: cfg.stableID)) + appModel.applyGatewayConnectConfig(refreshedConfig) + } + + func clearPendingTrustPrompt() { + self.pendingTrustPrompt = nil + self.pendingTrustConnect = nil + } + + func acceptPendingTrustPrompt() async { + guard let pending = self.pendingTrustConnect, + let prompt = self.pendingTrustPrompt, + pending.stableID == prompt.stableID + else { return } + + GatewayTLSStore.saveFingerprint(prompt.fingerprintSha256, stableID: pending.stableID) + self.clearPendingTrustPrompt() + + if pending.isManual { + GatewaySettingsStore.saveLastGatewayConnectionManual( + host: prompt.host, + port: prompt.port, + useTLS: true, + stableID: pending.stableID) + } else { + GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: pending.stableID, useTLS: true) + } + + let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) + let tlsParams = GatewayTLSParams( + required: true, + expectedFingerprint: prompt.fingerprintSha256, + allowTOFU: false, + storeKey: pending.stableID) + + self.didAutoConnect = true + self.startAutoConnect( + url: pending.url, + gatewayStableID: pending.stableID, + tls: tlsParams, + token: token, + password: password) + } + + func declinePendingTrustPrompt() { + self.clearPendingTrustPrompt() + self.appModel?.gatewayStatusText = "Offline" + } + + private func updateFromDiscovery() { + let newGateways = self.discovery.gateways + self.gateways = newGateways + self.discoveryStatusText = self.discovery.statusText + self.discoveryDebugLog = self.discovery.debugLog + self.updateLastDiscoveredGateway(from: newGateways) + self.maybeAutoConnect() + } + + private func observeDiscovery() { + withObservationTracking { + _ = self.discovery.gateways + _ = self.discovery.statusText + _ = self.discovery.debugLog + } onChange: { [weak self] in + Task { @MainActor in + guard let self else { return } + self.updateFromDiscovery() + self.observeDiscovery() + } + } + } + + private func maybeAutoConnect() { + guard !self.didAutoConnect else { return } + guard let appModel = self.appModel else { return } + guard appModel.gatewayServerName == nil else { return } + + let defaults = UserDefaults.standard + guard defaults.bool(forKey: "gateway.autoconnect") else { return } + let manualEnabled = defaults.bool(forKey: "gateway.manual.enabled") + + let instanceId = defaults.string(forKey: "node.instanceId")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !instanceId.isEmpty else { return } + + let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) + + if manualEnabled { + let manualHost = defaults.string(forKey: "gateway.manual.host")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !manualHost.isEmpty else { return } + + let manualPort = defaults.integer(forKey: "gateway.manual.port") + let manualTLS = defaults.bool(forKey: "gateway.manual.tls") + let resolvedUseTLS = self.resolveManualUseTLS(host: manualHost, useTLS: manualTLS) + guard let resolvedPort = self.resolveManualPort( + host: manualHost, + port: manualPort, + useTLS: resolvedUseTLS) + else { return } + + let stableID = self.manualStableID(host: manualHost, port: resolvedPort) + let tlsParams = self.resolveManualTLSParams( + stableID: stableID, + tlsEnabled: resolvedUseTLS, + allowTOFUReset: self.shouldRequireTLS(host: manualHost)) + + guard let url = self.buildGatewayURL( + host: manualHost, + port: resolvedPort, + useTLS: tlsParams?.required == true) + else { return } + + self.didAutoConnect = true + self.startAutoConnect( + url: url, + gatewayStableID: stableID, + tls: tlsParams, + token: token, + password: password) + return + } + + if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() { + if case let .manual(host, port, useTLS, stableID) = lastKnown { + let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS) + let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) + let tlsParams = stored.map { fp in + GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID) + } + guard let url = self.buildGatewayURL( + host: host, + port: port, + useTLS: resolvedUseTLS && tlsParams != nil) + else { return } + + // Security: autoconnect only to previously trusted gateways (stored TLS pin). + guard tlsParams != nil else { return } + + self.didAutoConnect = true + self.startAutoConnect( + url: url, + gatewayStableID: stableID, + tls: tlsParams, + token: token, + password: password) + return + } + } + + let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty } + if let targetStableID = candidates.first(where: { id in + self.gateways.contains(where: { $0.stableID == id }) + }) { + guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return } + // Security: autoconnect only to previously trusted gateways (stored TLS pin). + guard GatewayTLSStore.loadFingerprint(stableID: target.stableID) != nil else { return } + + self.didAutoConnect = true + Task { [weak self] in + guard let self else { return } + await self.connectDiscoveredGateway(target) + } + return + } + + if self.gateways.count == 1, let gateway = self.gateways.first { + // Security: autoconnect only to previously trusted gateways (stored TLS pin). + guard GatewayTLSStore.loadFingerprint(stableID: gateway.stableID) != nil else { return } + + self.didAutoConnect = true + Task { [weak self] in + guard let self else { return } + await self.connectDiscoveredGateway(gateway) + } + return + } + } + + private func attemptAutoReconnectIfNeeded() { + guard let appModel = self.appModel else { return } + guard appModel.gatewayAutoReconnectEnabled else { return } + // Avoid starting duplicate connect loops while a prior config is active. + guard appModel.activeGatewayConnectConfig == nil else { return } + guard UserDefaults.standard.bool(forKey: "gateway.autoconnect") else { return } + self.didAutoConnect = false + self.maybeAutoConnect() + } + + private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) { + let defaults = UserDefaults.standard + let preferred = defaults.string(forKey: "gateway.preferredStableID")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let existingLast = defaults.string(forKey: "gateway.lastDiscoveredStableID")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + // Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect). + guard preferred.isEmpty, existingLast.isEmpty else { return } + guard let first = gateways.first else { return } + + defaults.set(first.stableID, forKey: "gateway.lastDiscoveredStableID") + GatewaySettingsStore.saveLastDiscoveredGatewayStableID(first.stableID) + } + + private func startAutoConnect( + url: URL, + gatewayStableID: String, + tls: GatewayTLSParams?, + token: String?, + password: String?) + { + guard let appModel else { return } + let connectOptions = self.makeConnectOptions(stableID: gatewayStableID) + + Task { [weak appModel] in + guard let appModel else { return } + await MainActor.run { + appModel.gatewayStatusText = "Connecting…" + } + let cfg = GatewayConnectConfig( + url: url, + stableID: gatewayStableID, + tls: tls, + token: token, + password: password, + nodeOptions: connectOptions) + appModel.applyGatewayConnectConfig(cfg) + } + } + + private func resolveDiscoveredTLSParams( + gateway: GatewayDiscoveryModel.DiscoveredGateway, + allowTOFU: Bool) -> GatewayTLSParams? + { + let stableID = gateway.stableID + let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) + + // Never let unauthenticated discovery (TXT) override a stored pin. + if let stored { + return GatewayTLSParams( + required: true, + expectedFingerprint: stored, + allowTOFU: false, + storeKey: stableID) + } + + if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil { + return GatewayTLSParams( + required: true, + expectedFingerprint: nil, + allowTOFU: false, + storeKey: stableID) + } + + return nil + } + + private func resolveManualTLSParams( + stableID: String, + tlsEnabled: Bool, + allowTOFUReset: Bool = false) -> GatewayTLSParams? + { + let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) + if tlsEnabled || stored != nil { + return GatewayTLSParams( + required: true, + expectedFingerprint: stored, + allowTOFU: false, + storeKey: stableID) + } + + return nil + } + + private func probeTLSFingerprint(url: URL) async -> String? { + await withCheckedContinuation { continuation in + let probe = GatewayTLSFingerprintProbe(url: url, timeoutSeconds: 3) { fp in + continuation.resume(returning: fp) + } + probe.start() + } + } + + private func resolveServiceEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? { + guard case let .service(name, type, domain, _) = endpoint else { return nil } + let key = "\(domain)|\(type)|\(name)" + return await withCheckedContinuation { continuation in + let resolver = GatewayServiceResolver(name: name, type: type, domain: domain) { [weak self] result in + Task { @MainActor in + self?.pendingServiceResolvers[key] = nil + continuation.resume(returning: result) + } + } + self.pendingServiceResolvers[key] = resolver + resolver.start() + } + } + + private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? { + switch endpoint { + case let .hostPort(host, port): + return (host: host.debugDescription, port: Int(port.rawValue)) + case let .service(name, type, domain, _): + return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain) + default: + return nil + } + } + + private static func resolveBonjourServiceToHostPort( + name: String, + type: String, + domain: String, + timeoutSeconds: TimeInterval = 3.0 + ) async -> (host: String, port: Int)? { + // NetService callbacks are delivered via a run loop. If we resolve from a thread without one, + // we can end up never receiving callbacks, which in turn leaks the continuation and leaves + // the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always + // resume the continuation exactly once (timeout/cancel safe). + @MainActor + final class Resolver: NSObject, @preconcurrency NetServiceDelegate { + private var cont: CheckedContinuation<(host: String, port: Int)?, Never>? + private let service: NetService + private var timeoutTask: Task? + private var finished = false + + init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) { + self.cont = cont + self.service = service + super.init() + } + + func start(timeoutSeconds: TimeInterval) { + self.service.delegate = self + self.service.schedule(in: .main, forMode: .default) + + // NetService has its own timeout, but we keep a manual one as a backstop in case + // callbacks never arrive (e.g. local network permission issues). + self.timeoutTask = Task { @MainActor [weak self] in + guard let self else { return } + let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000) + try? await Task.sleep(nanoseconds: ns) + self.finish(nil) + } + + self.service.resolve(withTimeout: timeoutSeconds) + } + + func netServiceDidResolveAddress(_ sender: NetService) { + self.finish(Self.extractHostPort(sender)) + } + + func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { + _ = errorDict // currently best-effort; callers surface a generic failure + self.finish(nil) + } + + private func finish(_ result: (host: String, port: Int)?) { + guard !self.finished else { return } + self.finished = true + + self.timeoutTask?.cancel() + self.timeoutTask = nil + + self.service.stop() + self.service.remove(from: .main, forMode: .default) + + let c = self.cont + self.cont = nil + c?.resume(returning: result) + } + + private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? { + let port = svc.port + + if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty { + return (host: host, port: port) + } + + guard let addrs = svc.addresses else { return nil } + for addrData in addrs { + let host = addrData.withUnsafeBytes { ptr -> String? in + guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil } + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + + let rc = getnameinfo( + base.assumingMemoryBound(to: sockaddr.self), + socklen_t(ptr.count), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard rc == 0 else { return nil } + return String(cString: buffer) + } + + if let host, !host.isEmpty { + return (host: host, port: port) + } + } + + return nil + } + } + + return await withCheckedContinuation { cont in + Task { @MainActor in + let service = NetService(domain: domain, type: type, name: name) + let resolver = Resolver(cont: cont, service: service) + // Keep the resolver alive for the lifetime of the NetService resolve. + objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + resolver.start(timeoutSeconds: timeoutSeconds) + } + } + } + + private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? { + let scheme = useTLS ? "wss" : "ws" + var components = URLComponents() + components.scheme = scheme + components.host = host + components.port = port + return components.url + } + + private func resolveManualUseTLS(host: String, useTLS: Bool) -> Bool { + useTLS || self.shouldRequireTLS(host: host) + } + + private func shouldRequireTLS(host: String) -> Bool { + !Self.isLoopbackHost(host) + } + + private func shouldForceTLS(host: String) -> Bool { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.isEmpty { return false } + return trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") + } + + private static func isLoopbackHost(_ rawHost: String) -> Bool { + var host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !host.isEmpty else { return false } + + if host.hasPrefix("[") && host.hasSuffix("]") { + host.removeFirst() + host.removeLast() + } + if host.hasSuffix(".") { + host.removeLast() + } + if let zoneIndex = host.firstIndex(of: "%") { + host = String(host[.. Bool { + var addr = in_addr() + let parsed = host.withCString { inet_pton(AF_INET, $0, &addr) == 1 } + guard parsed else { return false } + let value = UInt32(bigEndian: addr.s_addr) + let firstOctet = UInt8((value >> 24) & 0xFF) + return firstOctet == 127 + } + + private static func isLoopbackIPv6(_ host: String) -> Bool { + var addr = in6_addr() + let parsed = host.withCString { inet_pton(AF_INET6, $0, &addr) == 1 } + guard parsed else { return false } + return withUnsafeBytes(of: &addr) { rawBytes in + let bytes = rawBytes.bindMemory(to: UInt8.self) + let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1 + if isV6Loopback { return true } + + let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF + return isMappedV4 && bytes[12] == 127 + } + } + + private func manualStableID(host: String, port: Int) -> String { + "manual|\(host.lowercased())|\(port)" + } + + private func makeConnectOptions(stableID: String?) -> GatewayConnectOptions { + let defaults = UserDefaults.standard + let displayName = self.resolvedDisplayName(defaults: defaults) + let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID) + + return GatewayConnectOptions( + role: "node", + scopes: [], + caps: self.currentCaps(), + commands: self.currentCommands(), + permissions: self.currentPermissions(), + clientId: resolvedClientId, + clientMode: "node", + clientDisplayName: displayName) + } + + private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String { + if let stableID, + let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) { + return override + } + let manualClientId = defaults.string(forKey: "gateway.manual.clientId")? + .trimmingCharacters(in: .whitespacesAndNewlines) + if manualClientId?.isEmpty == false { + return manualClientId! + } + return "openclaw-ios" + } + + private func resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? { + if port > 0 { + return port <= 65535 ? port : nil + } + let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedHost.isEmpty else { return nil } + if useTLS && self.shouldForceTLS(host: trimmedHost) { + return 443 + } + return 18789 + } + + private func resolvedDisplayName(defaults: UserDefaults) -> String { + let key = "node.displayName" + let existingRaw = defaults.string(forKey: key) + let resolved = NodeDisplayName.resolve( + existing: existingRaw, + deviceName: UIDevice.current.name, + interfaceIdiom: UIDevice.current.userInterfaceIdiom) + let existing = existingRaw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if existing.isEmpty || NodeDisplayName.isGeneric(existing) { + defaults.set(resolved, forKey: key) + } + return resolved + } + + private func currentCaps() -> [String] { + var caps = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue] + + // Default-on: if the key doesn't exist yet, treat it as enabled. + let cameraEnabled = + UserDefaults.standard.object(forKey: "camera.enabled") == nil + ? true + : UserDefaults.standard.bool(forKey: "camera.enabled") + if cameraEnabled { caps.append(OpenClawCapability.camera.rawValue) } + + let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey) + if voiceWakeEnabled { caps.append(OpenClawCapability.voiceWake.rawValue) } + + let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" + let locationMode = OpenClawLocationMode(rawValue: locationModeRaw) ?? .off + if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) } + + caps.append(OpenClawCapability.device.rawValue) + if WatchMessagingService.isSupportedOnDevice() { + caps.append(OpenClawCapability.watch.rawValue) + } + caps.append(OpenClawCapability.photos.rawValue) + caps.append(OpenClawCapability.contacts.rawValue) + caps.append(OpenClawCapability.calendar.rawValue) + caps.append(OpenClawCapability.reminders.rawValue) + if Self.motionAvailable() { + caps.append(OpenClawCapability.motion.rawValue) + } + + return caps + } + + private func currentCommands() -> [String] { + var commands: [String] = [ + OpenClawCanvasCommand.present.rawValue, + OpenClawCanvasCommand.hide.rawValue, + OpenClawCanvasCommand.navigate.rawValue, + OpenClawCanvasCommand.evalJS.rawValue, + OpenClawCanvasCommand.snapshot.rawValue, + OpenClawCanvasA2UICommand.push.rawValue, + OpenClawCanvasA2UICommand.pushJSONL.rawValue, + OpenClawCanvasA2UICommand.reset.rawValue, + OpenClawScreenCommand.record.rawValue, + OpenClawSystemCommand.notify.rawValue, + OpenClawChatCommand.push.rawValue, + OpenClawTalkCommand.pttStart.rawValue, + OpenClawTalkCommand.pttStop.rawValue, + OpenClawTalkCommand.pttCancel.rawValue, + OpenClawTalkCommand.pttOnce.rawValue, + ] + + let caps = Set(self.currentCaps()) + if caps.contains(OpenClawCapability.camera.rawValue) { + commands.append(OpenClawCameraCommand.list.rawValue) + commands.append(OpenClawCameraCommand.snap.rawValue) + commands.append(OpenClawCameraCommand.clip.rawValue) + } + if caps.contains(OpenClawCapability.location.rawValue) { + commands.append(OpenClawLocationCommand.get.rawValue) + } + if caps.contains(OpenClawCapability.device.rawValue) { + commands.append(OpenClawDeviceCommand.status.rawValue) + commands.append(OpenClawDeviceCommand.info.rawValue) + } + if caps.contains(OpenClawCapability.watch.rawValue) { + commands.append(OpenClawWatchCommand.status.rawValue) + commands.append(OpenClawWatchCommand.notify.rawValue) + } + if caps.contains(OpenClawCapability.photos.rawValue) { + commands.append(OpenClawPhotosCommand.latest.rawValue) + } + if caps.contains(OpenClawCapability.contacts.rawValue) { + commands.append(OpenClawContactsCommand.search.rawValue) + commands.append(OpenClawContactsCommand.add.rawValue) + } + if caps.contains(OpenClawCapability.calendar.rawValue) { + commands.append(OpenClawCalendarCommand.events.rawValue) + commands.append(OpenClawCalendarCommand.add.rawValue) + } + if caps.contains(OpenClawCapability.reminders.rawValue) { + commands.append(OpenClawRemindersCommand.list.rawValue) + commands.append(OpenClawRemindersCommand.add.rawValue) + } + if caps.contains(OpenClawCapability.motion.rawValue) { + commands.append(OpenClawMotionCommand.activity.rawValue) + commands.append(OpenClawMotionCommand.pedometer.rawValue) + } + + return commands + } + + private func currentPermissions() -> [String: Bool] { + var permissions: [String: Bool] = [:] + permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized + permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + permissions["speechRecognition"] = SFSpeechRecognizer.authorizationStatus() == .authorized + permissions["location"] = Self.isLocationAuthorized( + status: CLLocationManager().authorizationStatus) + && CLLocationManager.locationServicesEnabled() + permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable + + let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) + permissions["photos"] = photoStatus == .authorized || photoStatus == .limited + let contactsStatus = CNContactStore.authorizationStatus(for: .contacts) + permissions["contacts"] = contactsStatus == .authorized || contactsStatus == .limited + + let calendarStatus = EKEventStore.authorizationStatus(for: .event) + permissions["calendar"] = + calendarStatus == .authorized || calendarStatus == .fullAccess || calendarStatus == .writeOnly + let remindersStatus = EKEventStore.authorizationStatus(for: .reminder) + permissions["reminders"] = + remindersStatus == .authorized || remindersStatus == .fullAccess || remindersStatus == .writeOnly + + let motionStatus = CMMotionActivityManager.authorizationStatus() + let pedometerStatus = CMPedometer.authorizationStatus() + permissions["motion"] = + motionStatus == .authorized || pedometerStatus == .authorized + + let watchStatus = WatchMessagingService.currentStatusSnapshot() + permissions["watchSupported"] = watchStatus.supported + permissions["watchPaired"] = watchStatus.paired + permissions["watchAppInstalled"] = watchStatus.appInstalled + permissions["watchReachable"] = watchStatus.reachable + + return permissions + } + + private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool { + switch status { + case .authorizedAlways, .authorizedWhenInUse, .authorized: + return true + default: + return false + } + } + + private static func motionAvailable() -> Bool { + CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable() + } +} + +#if DEBUG +extension GatewayConnectionController { + func _test_resolvedDisplayName(defaults: UserDefaults) -> String { + self.resolvedDisplayName(defaults: defaults) + } + + func _test_currentCaps() -> [String] { + self.currentCaps() + } + + func _test_currentCommands() -> [String] { + self.currentCommands() + } + + func _test_currentPermissions() -> [String: Bool] { + self.currentPermissions() + } + + func _test_platformString() -> String { + DeviceInfoHelper.platformString() + } + + func _test_deviceFamily() -> String { + DeviceInfoHelper.deviceFamily() + } + + func _test_modelIdentifier() -> String { + DeviceInfoHelper.modelIdentifier() + } + + func _test_appVersion() -> String { + DeviceInfoHelper.appVersion() + } + + func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) { + self.gateways = gateways + } + + func _test_triggerAutoConnect() { + self.maybeAutoConnect() + } + + func _test_didAutoConnect() -> Bool { + self.didAutoConnect + } + + func _test_resolveDiscoveredTLSParams( + gateway: GatewayDiscoveryModel.DiscoveredGateway, + allowTOFU: Bool) -> GatewayTLSParams? + { + self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU) + } + + func _test_resolveManualUseTLS(host: String, useTLS: Bool) -> Bool { + self.resolveManualUseTLS(host: host, useTLS: useTLS) + } + + func _test_resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? { + self.resolveManualPort(host: host, port: port, useTLS: useTLS) + } +} +#endif + +private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate { + private let url: URL + private let timeoutSeconds: Double + private let onComplete: (String?) -> Void + private var didFinish = false + private var session: URLSession? + private var task: URLSessionWebSocketTask? + + init(url: URL, timeoutSeconds: Double, onComplete: @escaping (String?) -> Void) { + self.url = url + self.timeoutSeconds = timeoutSeconds + self.onComplete = onComplete + } + + func start() { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = self.timeoutSeconds + config.timeoutIntervalForResource = self.timeoutSeconds + let session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + self.session = session + let task = session.webSocketTask(with: self.url) + self.task = task + task.resume() + + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + self.timeoutSeconds) { [weak self] in + self?.finish(nil) + } + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust + else { + completionHandler(.performDefaultHandling, nil) + return + } + + let fp = GatewayTLSFingerprintProbe.certificateFingerprint(trust) + completionHandler(.cancelAuthenticationChallenge, nil) + self.finish(fp) + } + + private func finish(_ fingerprint: String?) { + objc_sync_enter(self) + defer { objc_sync_exit(self) } + guard !self.didFinish else { return } + self.didFinish = true + self.task?.cancel(with: .goingAway, reason: nil) + self.session?.invalidateAndCancel() + self.onComplete(fingerprint) + } + + private static func certificateFingerprint(_ trust: SecTrust) -> String? { + guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], + let cert = chain.first + else { + return nil + } + let data = SecCertificateCopyData(cert) as Data + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift new file mode 100644 index 00000000..56d490e2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift @@ -0,0 +1,71 @@ +import Foundation + +enum GatewayConnectionIssue: Equatable { + case none + case tokenMissing + case unauthorized + case pairingRequired(requestId: String?) + case network + case unknown(String) + + var requestId: String? { + if case let .pairingRequired(requestId) = self { + return requestId + } + return nil + } + + var needsAuthToken: Bool { + switch self { + case .tokenMissing, .unauthorized: + return true + default: + return false + } + } + + var needsPairing: Bool { + if case .pairingRequired = self { return true } + return false + } + + static func detect(from statusText: String) -> Self { + let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .none } + let lower = trimmed.lowercased() + + if lower.contains("pairing required") || lower.contains("not_paired") || lower.contains("not paired") { + return .pairingRequired(requestId: self.extractRequestId(from: trimmed)) + } + if lower.contains("gateway token missing") { + return .tokenMissing + } + if lower.contains("unauthorized") { + return .unauthorized + } + if lower.contains("connection refused") || + lower.contains("timed out") || + lower.contains("network is unreachable") || + lower.contains("cannot find host") || + lower.contains("could not connect") + { + return .network + } + if lower.hasPrefix("gateway error:") { + return .unknown(trimmed) + } + return .none + } + + private static func extractRequestId(from statusText: String) -> String? { + let marker = "requestId:" + guard let range = statusText.range(of: marker) else { return nil } + let suffix = statusText[range.upperBound...] + let trimmed = suffix.trimmingCharacters(in: .whitespacesAndNewlines) + let end = trimmed.firstIndex(where: { ch in + ch == ")" || ch.isWhitespace || ch == "," || ch == ";" + }) ?? trimmed.endIndex + let id = String(trimmed[.. String { + self.gatewayController.discoveryDebugLog + .map { "\(Self.formatISO($0.ts)) \($0.message)" } + .joined(separator: "\n") + } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter + }() + + private static let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static func formatTime(_ date: Date) -> String { + self.timeFormatter.string(from: date) + } + + private static func formatISO(_ date: Date) -> String { + self.isoFormatter.string(from: date) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift new file mode 100644 index 00000000..04bb220d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift @@ -0,0 +1,190 @@ +import OpenClawKit +import Foundation +import Network +import Observation + +@MainActor +@Observable +final class GatewayDiscoveryModel { + struct DebugLogEntry: Identifiable, Equatable { + var id = UUID() + var ts: Date + var message: String + } + + struct DiscoveredGateway: Identifiable, Equatable { + var id: String { self.stableID } + var name: String + var endpoint: NWEndpoint + var stableID: String + var debugID: String + var lanHost: String? + var tailnetDns: String? + var gatewayPort: Int? + var canvasPort: Int? + var tlsEnabled: Bool + var tlsFingerprintSha256: String? + var cliPath: String? + } + + var gateways: [DiscoveredGateway] = [] + var statusText: String = "Idle" + private(set) var debugLog: [DebugLogEntry] = [] + + private var browsers: [String: NWBrowser] = [:] + private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:] + private var statesByDomain: [String: NWBrowser.State] = [:] + private var debugLoggingEnabled = false + private var lastStableIDs = Set() + + func setDebugLoggingEnabled(_ enabled: Bool) { + let wasEnabled = self.debugLoggingEnabled + self.debugLoggingEnabled = enabled + if !enabled { + self.debugLog = [] + } else if !wasEnabled { + self.appendDebugLog("debug logging enabled") + self.appendDebugLog("snapshot: status=\(self.statusText) gateways=\(self.gateways.count)") + } + } + + func start() { + if !self.browsers.isEmpty { return } + self.appendDebugLog("start()") + + for domain in OpenClawBonjour.gatewayServiceDomains { + let params = NWParameters.tcp + params.includePeerToPeer = true + let browser = NWBrowser( + for: .bonjour(type: OpenClawBonjour.gatewayServiceType, domain: domain), + using: params) + + browser.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + guard let self else { return } + self.statesByDomain[domain] = state + self.updateStatusText() + self.appendDebugLog("state[\(domain)]: \(Self.prettyState(state))") + } + } + + browser.browseResultsChangedHandler = { [weak self] results, _ in + Task { @MainActor in + guard let self else { return } + self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in + switch result.endpoint { + case let .service(name, _, _, _): + let decodedName = BonjourEscapes.decode(name) + let txt = result.endpoint.txtRecord?.dictionary ?? [:] + let advertisedName = txt["displayName"] + let prettyAdvertised = advertisedName + .map(Self.prettifyInstanceName) + .flatMap { $0.isEmpty ? nil : $0 } + let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName) + return DiscoveredGateway( + name: prettyName, + endpoint: result.endpoint, + stableID: GatewayEndpointID.stableID(result.endpoint), + debugID: GatewayEndpointID.prettyDescription(result.endpoint), + lanHost: Self.txtValue(txt, key: "lanHost"), + tailnetDns: Self.txtValue(txt, key: "tailnetDns"), + gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"), + canvasPort: Self.txtIntValue(txt, key: "canvasPort"), + tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"), + tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"), + cliPath: Self.txtValue(txt, key: "cliPath")) + default: + return nil + } + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + self.recomputeGateways() + } + } + + self.browsers[domain] = browser + browser.start(queue: DispatchQueue(label: "ai.openclaw.ios.gateway-discovery.\(domain)")) + } + } + + func stop() { + self.appendDebugLog("stop()") + for browser in self.browsers.values { + browser.cancel() + } + self.browsers = [:] + self.gatewaysByDomain = [:] + self.statesByDomain = [:] + self.gateways = [] + self.statusText = "Stopped" + } + + private func recomputeGateways() { + let next = self.gatewaysByDomain.values + .flatMap(\.self) + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + let nextIDs = Set(next.map(\.stableID)) + let added = nextIDs.subtracting(self.lastStableIDs) + let removed = self.lastStableIDs.subtracting(nextIDs) + if !added.isEmpty || !removed.isEmpty { + self.appendDebugLog("results: total=\(next.count) added=\(added.count) removed=\(removed.count)") + } + self.lastStableIDs = nextIDs + self.gateways = next + } + + private func updateStatusText() { + self.statusText = GatewayDiscoveryStatusText.make( + states: Array(self.statesByDomain.values), + hasBrowsers: !self.browsers.isEmpty) + } + + private static func prettyState(_ state: NWBrowser.State) -> String { + switch state { + case .setup: + "setup" + case .ready: + "ready" + case let .failed(err): + "failed (\(err))" + case .cancelled: + "cancelled" + case let .waiting(err): + "waiting (\(err))" + @unknown default: + "unknown" + } + } + + private func appendDebugLog(_ message: String) { + guard self.debugLoggingEnabled else { return } + self.debugLog.append(DebugLogEntry(ts: Date(), message: message)) + if self.debugLog.count > 200 { + self.debugLog.removeFirst(self.debugLog.count - 200) + } + } + + private static func prettifyInstanceName(_ decodedName: String) -> String { + let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ") + let stripped = normalized.replacingOccurrences(of: " (OpenClaw)", with: "") + .replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression) + return stripped.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func txtValue(_ dict: [String: String], key: String) -> String? { + let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return raw.isEmpty ? nil : raw + } + + private static func txtIntValue(_ dict: [String: String], key: String) -> Int? { + guard let raw = self.txtValue(dict, key: key) else { return nil } + return Int(raw) + } + + private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool { + guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false } + return raw == "1" || raw == "true" || raw == "yes" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayHealthMonitor.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayHealthMonitor.swift new file mode 100644 index 00000000..182df942 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayHealthMonitor.swift @@ -0,0 +1,85 @@ +import Foundation +import OpenClawKit + +@MainActor +final class GatewayHealthMonitor { + struct Config: Sendable { + var intervalSeconds: Double + var timeoutSeconds: Double + var maxFailures: Int + } + + private let config: Config + private let sleep: @Sendable (UInt64) async -> Void + private var task: Task? + + init( + config: Config = Config(intervalSeconds: 15, timeoutSeconds: 5, maxFailures: 3), + sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in + try? await Task.sleep(nanoseconds: nanoseconds) + } + ) { + self.config = config + self.sleep = sleep + } + + func start( + check: @escaping @Sendable () async throws -> Bool, + onFailure: @escaping @Sendable (_ failureCount: Int) async -> Void) + { + self.stop() + let config = self.config + let sleep = self.sleep + self.task = Task { @MainActor in + var failures = 0 + while !Task.isCancelled { + let ok = await Self.runCheck(check: check, timeoutSeconds: config.timeoutSeconds) + if ok { + failures = 0 + } else { + failures += 1 + if failures >= max(1, config.maxFailures) { + await onFailure(failures) + failures = 0 + } + } + + if Task.isCancelled { break } + let interval = max(0.0, config.intervalSeconds) + let nanos = UInt64(interval * 1_000_000_000) + if nanos > 0 { + await sleep(nanos) + } else { + await Task.yield() + } + } + } + } + + func stop() { + self.task?.cancel() + self.task = nil + } + + private static func runCheck( + check: @escaping @Sendable () async throws -> Bool, + timeoutSeconds: Double) async -> Bool + { + let timeout = max(0.0, timeoutSeconds) + if timeout == 0 { + return (try? await check()) ?? false + } + do { + let timeoutError = NSError( + domain: "GatewayHealthMonitor", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "health check timed out"]) + return try await AsyncTimeout.withTimeout( + seconds: timeout, + onTimeout: { timeoutError }, + operation: check) + } catch { + return false + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift new file mode 100644 index 00000000..eac92df7 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift @@ -0,0 +1,113 @@ +import SwiftUI + +struct GatewayQuickSetupSheet: View { + @Environment(NodeAppModel.self) private var appModel + @Environment(GatewayConnectionController.self) private var gatewayController + @Environment(\.dismiss) private var dismiss + + @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false + @State private var connecting: Bool = false + @State private var connectError: String? + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 16) { + Text("Connect to a Gateway?") + .font(.title2.bold()) + + if let candidate = self.bestCandidate { + VStack(alignment: .leading, spacing: 6) { + Text(verbatim: candidate.name) + .font(.headline) + Text(verbatim: candidate.debugID) + .font(.footnote) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 2) { + // Use verbatim strings so Bonjour-provided values can't be interpreted as + // localized format strings (which can crash with Objective-C exceptions). + Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)") + Text(verbatim: "Status: \(self.appModel.gatewayStatusText)") + Text(verbatim: "Node: \(self.appModel.nodeStatusText)") + Text(verbatim: "Operator: \(self.appModel.operatorStatusText)") + } + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(12) + .background(.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 14)) + + Button { + self.connectError = nil + self.connecting = true + Task { + let err = await self.gatewayController.connectWithDiagnostics(candidate) + await MainActor.run { + self.connecting = false + self.connectError = err + // If we kicked off a connect, leave the sheet up so the user can see status evolve. + } + } + } label: { + Group { + if self.connecting { + HStack(spacing: 8) { + ProgressView().progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(self.connecting) + + if let connectError { + Text(connectError) + .font(.footnote) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + Button { + self.dismiss() + } label: { + Text("Not now") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(self.connecting) + + Toggle("Don’t show this again", isOn: self.$quickSetupDismissed) + .padding(.top, 4) + } else { + Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.") + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding() + .navigationTitle("Quick Setup") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + self.quickSetupDismissed = true + self.dismiss() + } label: { + Text("Close") + } + } + } + } + } + + private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? { + // Prefer whatever discovery says is first; the list is already name-sorted. + self.gatewayController.gateways.first + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayServiceResolver.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayServiceResolver.swift new file mode 100644 index 00000000..882a4e7d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayServiceResolver.swift @@ -0,0 +1,55 @@ +import Foundation + +// NetService-based resolver for Bonjour services. +// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing. +final class GatewayServiceResolver: NSObject, NetServiceDelegate { + private let service: NetService + private let completion: ((host: String, port: Int)?) -> Void + private var didFinish = false + + init( + name: String, + type: String, + domain: String, + completion: @escaping ((host: String, port: Int)?) -> Void) + { + self.service = NetService(domain: domain, type: type, name: name) + self.completion = completion + super.init() + self.service.delegate = self + } + + func start(timeout: TimeInterval = 2.0) { + self.service.schedule(in: .main, forMode: .common) + self.service.resolve(withTimeout: timeout) + } + + func netServiceDidResolveAddress(_ sender: NetService) { + let host = Self.normalizeHost(sender.hostName) + let port = sender.port + guard let host, !host.isEmpty, port > 0 else { + self.finish(result: nil) + return + } + self.finish(result: (host: host, port: port)) + } + + func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { + self.finish(result: nil) + } + + private func finish(result: ((host: String, port: Int))?) { + guard !self.didFinish else { return } + self.didFinish = true + self.service.stop() + self.service.remove(from: .main, forMode: .common) + self.completion(result) + } + + private static func normalizeHost(_ raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { return nil } + return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed + } +} + diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewaySettingsStore.swift new file mode 100644 index 00000000..49db9bb1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -0,0 +1,448 @@ +import Foundation +import os + +enum GatewaySettingsStore { + private static let gatewayService = "ai.openclaw.gateway" + private static let nodeService = "ai.openclaw.node" + private static let talkService = "ai.openclaw.talk" + + private static let instanceIdDefaultsKey = "node.instanceId" + private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID" + private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID" + private static let manualEnabledDefaultsKey = "gateway.manual.enabled" + private static let manualHostDefaultsKey = "gateway.manual.host" + private static let manualPortDefaultsKey = "gateway.manual.port" + private static let manualTlsDefaultsKey = "gateway.manual.tls" + private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs" + private static let lastGatewayKindDefaultsKey = "gateway.last.kind" + private static let lastGatewayHostDefaultsKey = "gateway.last.host" + private static let lastGatewayPortDefaultsKey = "gateway.last.port" + private static let lastGatewayTlsDefaultsKey = "gateway.last.tls" + private static let lastGatewayStableIDDefaultsKey = "gateway.last.stableID" + private static let clientIdOverrideDefaultsPrefix = "gateway.clientIdOverride." + private static let selectedAgentDefaultsPrefix = "gateway.selectedAgentId." + + private static let instanceIdAccount = "instanceId" + private static let preferredGatewayStableIDAccount = "preferredStableID" + private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID" + private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." + + static func bootstrapPersistence() { + self.ensureStableInstanceID() + self.ensurePreferredGatewayStableID() + self.ensureLastDiscoveredGatewayStableID() + } + + static func loadStableInstanceID() -> String? { + if let value = KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + { + return value + } + + return nil + } + + static func saveStableInstanceID(_ instanceId: String) { + _ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount) + } + + static func loadPreferredGatewayStableID() -> String? { + if let value = KeychainStore.loadString( + service: self.gatewayService, + account: self.preferredGatewayStableIDAccount + )?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + { + return value + } + + return nil + } + + static func savePreferredGatewayStableID(_ stableID: String) { + _ = KeychainStore.saveString( + stableID, + service: self.gatewayService, + account: self.preferredGatewayStableIDAccount) + } + + static func loadLastDiscoveredGatewayStableID() -> String? { + if let value = KeychainStore.loadString( + service: self.gatewayService, + account: self.lastDiscoveredGatewayStableIDAccount + )?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + { + return value + } + + return nil + } + + static func saveLastDiscoveredGatewayStableID(_ stableID: String) { + _ = KeychainStore.saveString( + stableID, + service: self.gatewayService, + account: self.lastDiscoveredGatewayStableIDAccount) + } + + static func loadGatewayToken(instanceId: String) -> String? { + let account = self.gatewayTokenAccount(instanceId: instanceId) + let token = KeychainStore.loadString(service: self.gatewayService, account: account)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if token?.isEmpty == false { return token } + return nil + } + + static func saveGatewayToken(_ token: String, instanceId: String) { + _ = KeychainStore.saveString( + token, + service: self.gatewayService, + account: self.gatewayTokenAccount(instanceId: instanceId)) + } + + static func loadGatewayPassword(instanceId: String) -> String? { + KeychainStore.loadString( + service: self.gatewayService, + account: self.gatewayPasswordAccount(instanceId: instanceId))? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func saveGatewayPassword(_ password: String, instanceId: String) { + _ = KeychainStore.saveString( + password, + service: self.gatewayService, + account: self.gatewayPasswordAccount(instanceId: instanceId)) + } + + enum LastGatewayConnection: Equatable { + case manual(host: String, port: Int, useTLS: Bool, stableID: String) + case discovered(stableID: String, useTLS: Bool) + + var stableID: String { + switch self { + case let .manual(_, _, _, stableID): + return stableID + case let .discovered(stableID, _): + return stableID + } + } + + var useTLS: Bool { + switch self { + case let .manual(_, _, useTLS, _): + return useTLS + case let .discovered(_, useTLS): + return useTLS + } + } + } + + private enum LastGatewayKind: String { + case manual + case discovered + } + + static func loadTalkProviderApiKey(provider: String) -> String? { + guard let providerId = self.normalizedTalkProviderID(provider) else { return nil } + let account = self.talkProviderApiKeyAccount(providerId: providerId) + let value = KeychainStore.loadString( + service: self.talkService, + account: account)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if value?.isEmpty == false { return value } + return nil + } + + static func saveTalkProviderApiKey(_ apiKey: String?, provider: String) { + guard let providerId = self.normalizedTalkProviderID(provider) else { return } + let account = self.talkProviderApiKeyAccount(providerId: providerId) + let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + _ = KeychainStore.delete(service: self.talkService, account: account) + return + } + _ = KeychainStore.saveString(trimmed, service: self.talkService, account: account) + } + + static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) { + let defaults = UserDefaults.standard + defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey) + defaults.set(host, forKey: self.lastGatewayHostDefaultsKey) + defaults.set(port, forKey: self.lastGatewayPortDefaultsKey) + defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey) + defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey) + } + + static func saveLastGatewayConnectionDiscovered(stableID: String, useTLS: Bool) { + let defaults = UserDefaults.standard + defaults.set(LastGatewayKind.discovered.rawValue, forKey: self.lastGatewayKindDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey) + defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey) + defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey) + } + + static func loadLastGatewayConnection() -> LastGatewayConnection? { + let defaults = UserDefaults.standard + let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !stableID.isEmpty else { return nil } + let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey) + let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let kind = LastGatewayKind(rawValue: kindRaw) ?? .manual + + if kind == .discovered { + return .discovered(stableID: stableID, useTLS: useTLS) + } + + let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey) + + // Back-compat: older builds persisted manual-style host/port without a kind marker. + guard !host.isEmpty, port > 0, port <= 65535 else { return nil } + return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID) + } + + static func clearLastGatewayConnection(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey) + } + + static func deleteGatewayCredentials(instanceId: String) { + let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.gatewayTokenAccount(instanceId: trimmed)) + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.gatewayPasswordAccount(instanceId: trimmed)) + } + + static func loadGatewayClientIdOverride(stableID: String) -> String? { + let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedID.isEmpty else { return nil } + let key = self.clientIdOverrideDefaultsPrefix + trimmedID + let value = UserDefaults.standard.string(forKey: key)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if value?.isEmpty == false { return value } + return nil + } + + static func saveGatewayClientIdOverride(stableID: String, clientId: String?) { + let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedID.isEmpty else { return } + let key = self.clientIdOverrideDefaultsPrefix + trimmedID + let trimmedClientId = clientId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmedClientId.isEmpty { + UserDefaults.standard.removeObject(forKey: key) + } else { + UserDefaults.standard.set(trimmedClientId, forKey: key) + } + } + + static func loadGatewaySelectedAgentId(stableID: String) -> String? { + let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedID.isEmpty else { return nil } + let key = self.selectedAgentDefaultsPrefix + trimmedID + let value = UserDefaults.standard.string(forKey: key)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if value?.isEmpty == false { return value } + return nil + } + + static func saveGatewaySelectedAgentId(stableID: String, agentId: String?) { + let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedID.isEmpty else { return } + let key = self.selectedAgentDefaultsPrefix + trimmedID + let trimmedAgentId = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmedAgentId.isEmpty { + UserDefaults.standard.removeObject(forKey: key) + } else { + UserDefaults.standard.set(trimmedAgentId, forKey: key) + } + } + + private static func gatewayTokenAccount(instanceId: String) -> String { + "gateway-token.\(instanceId)" + } + + private static func gatewayPasswordAccount(instanceId: String) -> String { + "gateway-password.\(instanceId)" + } + + private static func talkProviderApiKeyAccount(providerId: String) -> String { + self.talkProviderApiKeyAccountPrefix + providerId + } + + private static func normalizedTalkProviderID(_ provider: String) -> String? { + let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + + private static func ensureStableInstanceID() { + let defaults = UserDefaults.standard + + if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !existing.isEmpty + { + if self.loadStableInstanceID() == nil { + self.saveStableInstanceID(existing) + } + return + } + + if let stored = self.loadStableInstanceID(), !stored.isEmpty { + defaults.set(stored, forKey: self.instanceIdDefaultsKey) + return + } + + let fresh = UUID().uuidString + self.saveStableInstanceID(fresh) + defaults.set(fresh, forKey: self.instanceIdDefaultsKey) + } + + private static func ensurePreferredGatewayStableID() { + let defaults = UserDefaults.standard + + if let existing = defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !existing.isEmpty + { + if self.loadPreferredGatewayStableID() == nil { + self.savePreferredGatewayStableID(existing) + } + return + } + + if let stored = self.loadPreferredGatewayStableID(), !stored.isEmpty { + defaults.set(stored, forKey: self.preferredGatewayStableIDDefaultsKey) + } + } + + private static func ensureLastDiscoveredGatewayStableID() { + let defaults = UserDefaults.standard + + if let existing = defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !existing.isEmpty + { + if self.loadLastDiscoveredGatewayStableID() == nil { + self.saveLastDiscoveredGatewayStableID(existing) + } + return + } + + if let stored = self.loadLastDiscoveredGatewayStableID(), !stored.isEmpty { + defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey) + } + } + +} + +enum GatewayDiagnostics { + private static let logger = Logger(subsystem: "ai.openclaw.ios", category: "GatewayDiag") + private static let queue = DispatchQueue(label: "ai.openclaw.gateway.diagnostics") + private static let maxLogBytes: Int64 = 512 * 1024 + private static let keepLogBytes: Int64 = 256 * 1024 + private static let logSizeCheckEveryWrites = 50 + nonisolated(unsafe) private static var logWritesSinceCheck = 0 + private static var fileURL: URL? { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first? + .appendingPathComponent("openclaw-gateway.log") + } + + private static func truncateLogIfNeeded(url: URL) { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let sizeNumber = attrs[.size] as? NSNumber + else { return } + let size = sizeNumber.int64Value + guard size > self.maxLogBytes else { return } + + do { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + + let start = max(Int64(0), size - self.keepLogBytes) + try handle.seek(toOffset: UInt64(start)) + var tail = try handle.readToEnd() ?? Data() + + // If we truncated mid-line, drop the first partial line so logs remain readable. + if start > 0, let nl = tail.firstIndex(of: 10) { + let next = tail.index(after: nl) + if next < tail.endIndex { + tail = tail.suffix(from: next) + } else { + tail = Data() + } + } + + try tail.write(to: url, options: .atomic) + } catch { + // Best-effort only. + } + } + + private static func appendToLog(url: URL, data: Data) { + if FileManager.default.fileExists(atPath: url.path) { + if let handle = try? FileHandle(forWritingTo: url) { + defer { try? handle.close() } + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: data) + } + } else { + try? data.write(to: url, options: .atomic) + } + } + + static func bootstrap() { + guard let url = fileURL else { return } + queue.async { + self.truncateLogIfNeeded(url: url) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let timestamp = formatter.string(from: Date()) + let line = "[\(timestamp)] gateway diagnostics started\n" + if let data = line.data(using: .utf8) { + self.appendToLog(url: url, data: data) + } + } + } + + static func log(_ message: String) { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let timestamp = formatter.string(from: Date()) + let line = "[\(timestamp)] \(message)" + logger.info("\(line, privacy: .public)") + + guard let url = fileURL else { return } + queue.async { + self.logWritesSinceCheck += 1 + if self.logWritesSinceCheck >= self.logSizeCheckEveryWrites { + self.logWritesSinceCheck = 0 + self.truncateLogIfNeeded(url: url) + } + let entry = line + "\n" + if let data = entry.data(using: .utf8) { + self.appendToLog(url: url, data: data) + } + } + } + + static func reset() { + guard let url = fileURL else { return } + queue.async { + try? FileManager.default.removeItem(at: url) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewaySetupCode.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewaySetupCode.swift new file mode 100644 index 00000000..8ccbab42 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewaySetupCode.swift @@ -0,0 +1,42 @@ +import Foundation + +struct GatewaySetupPayload: Codable { + var url: String? + var host: String? + var port: Int? + var tls: Bool? + var token: String? + var password: String? +} + +enum GatewaySetupCode { + static func decode(raw: String) -> GatewaySetupPayload? { + if let payload = decodeFromJSON(raw) { + return payload + } + if let decoded = decodeBase64Payload(raw), + let payload = decodeFromJSON(decoded) + { + return payload + } + return nil + } + + private static func decodeFromJSON(_ json: String) -> GatewaySetupPayload? { + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(GatewaySetupPayload.self, from: data) + } + + private static func decodeBase64Payload(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let normalized = trimmed + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = normalized.count % 4 + let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding) + guard let data = Data(base64Encoded: padded) else { return nil } + return String(data: data, encoding: .utf8) + } +} + diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift new file mode 100644 index 00000000..eff6b71b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct GatewayTrustPromptAlert: ViewModifier { + @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController + + private var promptBinding: Binding { + Binding( + get: { self.gatewayController.pendingTrustPrompt }, + set: { _ in + // Keep pending trust state until explicit user action. + // `alert(item:)` may set the binding to nil during dismissal, which can race with + // the button handler and cause accept to no-op. + }) + } + + func body(content: Content) -> some View { + content.alert(item: self.promptBinding) { prompt in + Alert( + title: Text("Trust this gateway?"), + message: Text( + """ + First-time TLS connection. + + Verify this SHA-256 fingerprint out-of-band before trusting: + \(prompt.fingerprintSha256) + """), + primaryButton: .cancel(Text("Cancel")) { + self.gatewayController.declinePendingTrustPrompt() + }, + secondaryButton: .default(Text("Trust and connect")) { + Task { await self.gatewayController.acceptPendingTrustPrompt() } + }) + } + } +} + +extension View { + func gatewayTrustPromptAlert() -> some View { + self.modifier(GatewayTrustPromptAlert()) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/KeychainStore.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/KeychainStore.swift new file mode 100644 index 00000000..1377d851 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/KeychainStore.swift @@ -0,0 +1,48 @@ +import Foundation +import Security + +enum KeychainStore { + static func loadString(service: String, account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func saveString(_ value: String, service: String, account: String) -> Bool { + let data = Data(value.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + + let update: [String: Any] = [kSecValueData as String: data] + let status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + if status == errSecSuccess { return true } + if status != errSecItemNotFound { return false } + + var insert = query + insert[kSecValueData as String] = data + insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess + } + + static func delete(service: String, account: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/TCPProbe.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/TCPProbe.swift new file mode 100644 index 00000000..e22da962 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Gateway/TCPProbe.swift @@ -0,0 +1,43 @@ +import Foundation +import Network +import os + +enum TCPProbe { + static func probe(host: String, port: Int, timeoutSeconds: Double, queueLabel: String) async -> Bool { + guard port >= 1, port <= 65535 else { return false } + guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false } + + let endpointHost = NWEndpoint.Host(host) + let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp) + + return await withCheckedContinuation { cont in + let queue = DispatchQueue(label: queueLabel) + let finished = OSAllocatedUnfairLock(initialState: false) + let finish: @Sendable (Bool) -> Void = { ok in + let shouldResume = finished.withLock { flag -> Bool in + if flag { return false } + flag = true + return true + } + guard shouldResume else { return } + connection.cancel() + cont.resume(returning: ok) + } + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + finish(true) + case .failed, .cancelled: + finish(false) + default: + break + } + } + + connection.start(queue: queue) + queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) } + } + } +} + diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Info.plist b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Info.plist new file mode 100644 index 00000000..bcb8c251 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Info.plist @@ -0,0 +1,88 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenClaw + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconName + AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 2026.2.25 + CFBundleURLTypes + + + CFBundleURLName + ai.openclaw.ios + CFBundleURLSchemes + + openclaw + + + + CFBundleVersion + 20260225 + NSAppTransportSecurity + + NSAllowsArbitraryLoadsInWebContent + + + NSBonjourServices + + _openclaw-gw._tcp + + NSCameraUsageDescription + OpenClaw can capture photos or short video clips when requested via the gateway. + NSLocalNetworkUsageDescription + OpenClaw discovers and connects to your OpenClaw gateway on the local network. + NSLocationAlwaysAndWhenInUseUsageDescription + OpenClaw can share your location in the background when you enable Always. + NSLocationWhenInUseUsageDescription + OpenClaw uses your location when you allow location sharing. + NSMicrophoneUsageDescription + OpenClaw needs microphone access for voice wake. + NSSpeechRecognitionUsageDescription + OpenClaw uses on-device speech recognition for voice wake. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + audio + remote-notification + + BGTaskSchedulerPermittedIdentifiers + + ai.openclaw.ios.bgrefresh + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Location/LocationService.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Location/LocationService.swift new file mode 100644 index 00000000..f1f0f69e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Location/LocationService.swift @@ -0,0 +1,202 @@ +import OpenClawKit +import CoreLocation +import Foundation + +@MainActor +final class LocationService: NSObject, CLLocationManagerDelegate { + enum Error: Swift.Error { + case timeout + case unavailable + } + + private let manager = CLLocationManager() + private var authContinuation: CheckedContinuation? + private var locationContinuation: CheckedContinuation? + private var updatesContinuation: AsyncStream.Continuation? + private var isStreaming = false + private var significantLocationCallback: (@Sendable (CLLocation) -> Void)? + private var isMonitoringSignificantChanges = false + + override init() { + super.init() + self.manager.delegate = self + self.manager.desiredAccuracy = kCLLocationAccuracyBest + } + + func authorizationStatus() -> CLAuthorizationStatus { + self.manager.authorizationStatus + } + + func accuracyAuthorization() -> CLAccuracyAuthorization { + if #available(iOS 14.0, *) { + return self.manager.accuracyAuthorization + } + return .fullAccuracy + } + + func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus { + guard CLLocationManager.locationServicesEnabled() else { return .denied } + + let status = self.manager.authorizationStatus + if status == .notDetermined { + self.manager.requestWhenInUseAuthorization() + let updated = await self.awaitAuthorizationChange() + if mode != .always { return updated } + } + + if mode == .always { + let current = self.manager.authorizationStatus + if current == .authorizedWhenInUse { + self.manager.requestAlwaysAuthorization() + return await self.awaitAuthorizationChange() + } + return current + } + + return self.manager.authorizationStatus + } + + func currentLocation( + params: OpenClawLocationGetParams, + desiredAccuracy: OpenClawLocationAccuracy, + maxAgeMs: Int?, + timeoutMs: Int?) async throws -> CLLocation + { + let now = Date() + if let maxAgeMs, + let cached = self.manager.location, + now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs) + { + return cached + } + + self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy) + let timeout = max(0, timeoutMs ?? 10000) + return try await self.withTimeout(timeoutMs: timeout) { + try await self.requestLocation() + } + } + + private func requestLocation() async throws -> CLLocation { + try await withCheckedThrowingContinuation { cont in + self.locationContinuation = cont + self.manager.requestLocation() + } + } + + private func awaitAuthorizationChange() async -> CLAuthorizationStatus { + await withCheckedContinuation { cont in + self.authContinuation = cont + } + } + + private func withTimeout( + timeoutMs: Int, + operation: @escaping @Sendable () async throws -> T) async throws -> T + { + try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation) + } + + private static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy { + switch accuracy { + case .coarse: + kCLLocationAccuracyKilometer + case .balanced: + kCLLocationAccuracyHundredMeters + case .precise: + kCLLocationAccuracyBest + } + } + + func startLocationUpdates( + desiredAccuracy: OpenClawLocationAccuracy, + significantChangesOnly: Bool) -> AsyncStream + { + self.stopLocationUpdates() + + self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy) + self.manager.pausesLocationUpdatesAutomatically = true + self.manager.allowsBackgroundLocationUpdates = true + + self.isStreaming = true + if significantChangesOnly { + self.manager.startMonitoringSignificantLocationChanges() + } else { + self.manager.startUpdatingLocation() + } + + return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + self.updatesContinuation = continuation + continuation.onTermination = { @Sendable _ in + Task { @MainActor in + self.stopLocationUpdates() + } + } + } + } + + func stopLocationUpdates() { + guard self.isStreaming else { return } + self.isStreaming = false + self.manager.stopUpdatingLocation() + self.manager.stopMonitoringSignificantLocationChanges() + self.updatesContinuation?.finish() + self.updatesContinuation = nil + } + + func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) { + self.significantLocationCallback = onUpdate + guard !self.isMonitoringSignificantChanges else { return } + self.isMonitoringSignificantChanges = true + self.manager.startMonitoringSignificantLocationChanges() + } + + func stopMonitoringSignificantLocationChanges() { + guard self.isMonitoringSignificantChanges else { return } + self.isMonitoringSignificantChanges = false + self.significantLocationCallback = nil + self.manager.stopMonitoringSignificantLocationChanges() + } + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + Task { @MainActor in + if let cont = self.authContinuation { + self.authContinuation = nil + cont.resume(returning: status) + } + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + let locs = locations + Task { @MainActor in + // Resolve the one-shot continuation first (if any). + if let cont = self.locationContinuation { + self.locationContinuation = nil + if let latest = locs.last { + cont.resume(returning: latest) + } else { + cont.resume(throwing: Error.unavailable) + } + // Don't return — also forward to significant-change callback below + // so both consumers receive updates when both are active. + } + if let callback = self.significantLocationCallback, let latest = locs.last { + callback(latest) + } + if let latest = locs.last, let updates = self.updatesContinuation { + updates.yield(latest) + } + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) { + let err = error + Task { @MainActor in + guard let cont = self.locationContinuation else { return } + self.locationContinuation = nil + cont.resume(throwing: err) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Location/SignificantLocationMonitor.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Location/SignificantLocationMonitor.swift new file mode 100644 index 00000000..1b8d5ca2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Location/SignificantLocationMonitor.swift @@ -0,0 +1,42 @@ +import CoreLocation +import Foundation +import OpenClawKit + +/// Monitors significant location changes and pushes `location.update` +/// events to the gateway so the severance hook can determine whether +/// the user is at their configured work location. +@MainActor +enum SignificantLocationMonitor { + static func startIfNeeded( + locationService: any LocationServicing, + locationMode: OpenClawLocationMode, + gateway: GatewayNodeSession, + beforeSend: (@MainActor @Sendable () async -> Void)? = nil + ) { + guard locationMode == .always else { return } + let status = locationService.authorizationStatus() + guard status == .authorizedAlways else { return } + locationService.startMonitoringSignificantLocationChanges { location in + struct Payload: Codable { + var lat: Double + var lon: Double + var accuracyMeters: Double + var source: String? + } + let payload = Payload( + lat: location.coordinate.latitude, + lon: location.coordinate.longitude, + accuracyMeters: location.horizontalAccuracy, + source: "ios-significant-location") + guard let data = try? JSONEncoder().encode(payload), + let json = String(data: data, encoding: .utf8) + else { return } + Task { @MainActor in + if let beforeSend { + await beforeSend() + } + await gateway.sendEvent(event: "location.update", payloadJSON: json) + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Media/PhotoLibraryService.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Media/PhotoLibraryService.swift new file mode 100644 index 00000000..f66beb3e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Media/PhotoLibraryService.swift @@ -0,0 +1,164 @@ +import Foundation +import Photos +import OpenClawKit +import UIKit + +final class PhotoLibraryService: PhotosServicing { + // The gateway WebSocket has a max payload size; returning large base64 blobs + // can cause the gateway to close the connection. Keep photo payloads small + // enough to safely fit in a single RPC frame. + // + // This is a transport constraint (not a security policy). If callers need + // full-resolution media, we should switch to an HTTP media handle flow. + private static let maxTotalBase64Chars = 340 * 1024 + private static let maxPerPhotoBase64Chars = 300 * 1024 + + func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload { + let status = await Self.ensureAuthorization() + guard status == .authorized || status == .limited else { + throw NSError(domain: "Photos", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "PHOTOS_PERMISSION_REQUIRED: grant Photos permission", + ]) + } + + let limit = max(1, min(params.limit ?? 1, 20)) + let fetchOptions = PHFetchOptions() + fetchOptions.fetchLimit = limit + fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + let assets = PHAsset.fetchAssets(with: .image, options: fetchOptions) + + var results: [OpenClawPhotoPayload] = [] + var remainingBudget = Self.maxTotalBase64Chars + let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600 + let quality = params.quality.map { max(0.1, min(1.0, $0)) } ?? 0.85 + let formatter = ISO8601DateFormatter() + + assets.enumerateObjects { asset, _, stop in + if results.count >= limit { stop.pointee = true; return } + if let payload = try? Self.renderAsset( + asset, + maxWidth: maxWidth, + quality: quality, + formatter: formatter) + { + // Keep the entire response under the gateway WS max payload. + if payload.base64.count > remainingBudget { + stop.pointee = true + return + } + remainingBudget -= payload.base64.count + results.append(payload) + } + } + + return OpenClawPhotosLatestPayload(photos: results) + } + + private static func ensureAuthorization() async -> PHAuthorizationStatus { + // Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts. + PHPhotoLibrary.authorizationStatus(for: .readWrite) + } + + private static func renderAsset( + _ asset: PHAsset, + maxWidth: Int, + quality: Double, + formatter: ISO8601DateFormatter) throws -> OpenClawPhotoPayload + { + let manager = PHImageManager.default() + let options = PHImageRequestOptions() + options.isSynchronous = true + options.isNetworkAccessAllowed = true + options.deliveryMode = .highQualityFormat + + let targetSize: CGSize = { + guard maxWidth > 0 else { return PHImageManagerMaximumSize } + let aspect = CGFloat(asset.pixelHeight) / CGFloat(max(1, asset.pixelWidth)) + let width = CGFloat(maxWidth) + return CGSize(width: width, height: width * aspect) + }() + + var image: UIImage? + manager.requestImage( + for: asset, + targetSize: targetSize, + contentMode: .aspectFit, + options: options) + { result, _ in + image = result + } + + guard let image else { + throw NSError(domain: "Photos", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "photo load failed", + ]) + } + + let (data, finalImage) = try encodeJpegUnderBudget( + image: image, + quality: quality, + maxBase64Chars: maxPerPhotoBase64Chars) + + let created = asset.creationDate.map { formatter.string(from: $0) } + return OpenClawPhotoPayload( + format: "jpeg", + base64: data.base64EncodedString(), + width: Int(finalImage.size.width), + height: Int(finalImage.size.height), + createdAt: created) + } + + private static func encodeJpegUnderBudget( + image: UIImage, + quality: Double, + maxBase64Chars: Int) throws -> (Data, UIImage) + { + var currentImage = image + var currentQuality = max(0.1, min(1.0, quality)) + + // Try lowering JPEG quality first, then downscale if needed. + for _ in 0..<10 { + guard let data = currentImage.jpegData(compressionQuality: currentQuality) else { + throw NSError(domain: "Photos", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "photo encode failed", + ]) + } + + let base64Len = ((data.count + 2) / 3) * 4 + if base64Len <= maxBase64Chars { + return (data, currentImage) + } + + if currentQuality > 0.35 { + currentQuality = max(0.25, currentQuality - 0.15) + continue + } + + // Downscale by ~25% each step once quality is low. + let newWidth = max(240, currentImage.size.width * 0.75) + if newWidth >= currentImage.size.width { + break + } + currentImage = resize(image: currentImage, targetWidth: newWidth) + } + + throw NSError(domain: "Photos", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "photo too large for gateway transport; try smaller maxWidth/quality", + ]) + } + + private static func resize(image: UIImage, targetWidth: CGFloat) -> UIImage { + let size = image.size + if size.width <= 0 || size.height <= 0 || targetWidth <= 0 { + return image + } + let scale = targetWidth / size.width + let targetSize = CGSize(width: targetWidth, height: max(1, size.height * scale)) + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + let renderer = UIGraphicsImageRenderer(size: targetSize, format: format) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: targetSize)) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Model/NodeAppModel+Canvas.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Model/NodeAppModel+Canvas.swift new file mode 100644 index 00000000..e8dce2cd --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Model/NodeAppModel+Canvas.swift @@ -0,0 +1,70 @@ +import Foundation +import Network +import os + +extension NodeAppModel { + func _test_resolveA2UIHostURL() async -> String? { + await self.resolveA2UIHostURL() + } + + func resolveA2UIHostURL() async -> String? { + guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } + if let host = base.host, Self.isLoopbackHost(host) { + return nil + } + return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios" + } + + private static func isLoopbackHost(_ host: String) -> Bool { + let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized.isEmpty { return true } + if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" { + return true + } + if normalized == "127.0.0.1" || normalized.hasPrefix("127.") { + return true + } + return false + } + + func showA2UIOnConnectIfNeeded() async { + guard let a2uiUrl = await self.resolveA2UIHostURL() else { + await MainActor.run { + self.lastAutoA2uiURL = nil + self.screen.showDefaultCanvas() + } + return + } + let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines) + if current.isEmpty || current == self.lastAutoA2uiURL { + // Avoid navigating the WKWebView to an unreachable host: it leaves a persistent + // "could not connect to the server" overlay even when the gateway is connected. + if let url = URL(string: a2uiUrl), + await Self.probeTCP(url: url, timeoutSeconds: 2.5) + { + self.screen.navigate(to: a2uiUrl) + self.lastAutoA2uiURL = a2uiUrl + } else { + self.lastAutoA2uiURL = nil + self.screen.showDefaultCanvas() + } + } + } + + func showLocalCanvasOnDisconnect() { + self.lastAutoA2uiURL = nil + self.screen.showDefaultCanvas() + } + + private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool { + guard let host = url.host, !host.isEmpty else { return false } + let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80) + return await TCPProbe.probe( + host: host, + port: portInt, + timeoutSeconds: timeoutSeconds, + queueLabel: "a2ui.preflight") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift new file mode 100644 index 00000000..08ef81e0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift @@ -0,0 +1,103 @@ +import Foundation +import OpenClawKit + +extension NodeAppModel { + static func normalizeWatchNotifyParams(_ params: OpenClawWatchNotifyParams) -> OpenClawWatchNotifyParams { + var normalized = params + normalized.title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + normalized.body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + normalized.promptId = self.trimmedOrNil(params.promptId) + normalized.sessionKey = self.trimmedOrNil(params.sessionKey) + normalized.kind = self.trimmedOrNil(params.kind) + normalized.details = self.trimmedOrNil(params.details) + normalized.priority = self.normalizedWatchPriority(params.priority, risk: params.risk) + normalized.risk = self.normalizedWatchRisk(params.risk, priority: normalized.priority) + + let normalizedActions = self.normalizeWatchActions( + params.actions, + kind: normalized.kind, + promptId: normalized.promptId) + normalized.actions = normalizedActions.isEmpty ? nil : normalizedActions + return normalized + } + + static func normalizeWatchActions( + _ actions: [OpenClawWatchAction]?, + kind: String?, + promptId: String?) -> [OpenClawWatchAction] + { + let provided = (actions ?? []).compactMap { action -> OpenClawWatchAction? in + let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines) + let label = action.label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, !label.isEmpty else { return nil } + return OpenClawWatchAction( + id: id, + label: label, + style: self.trimmedOrNil(action.style)) + } + if !provided.isEmpty { + return Array(provided.prefix(4)) + } + + // Only auto-insert quick actions when this is a prompt/decision flow. + guard promptId?.isEmpty == false else { + return [] + } + + let normalizedKind = kind?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + if normalizedKind.contains("approval") || normalizedKind.contains("approve") { + return [ + OpenClawWatchAction(id: "approve", label: "Approve"), + OpenClawWatchAction(id: "decline", label: "Decline", style: "destructive"), + OpenClawWatchAction(id: "open_phone", label: "Open iPhone"), + OpenClawWatchAction(id: "escalate", label: "Escalate"), + ] + } + + return [ + OpenClawWatchAction(id: "done", label: "Done"), + OpenClawWatchAction(id: "snooze_10m", label: "Snooze 10m"), + OpenClawWatchAction(id: "open_phone", label: "Open iPhone"), + OpenClawWatchAction(id: "escalate", label: "Escalate"), + ] + } + + static func normalizedWatchRisk( + _ risk: OpenClawWatchRisk?, + priority: OpenClawNotificationPriority?) -> OpenClawWatchRisk? + { + if let risk { return risk } + switch priority { + case .passive: + return .low + case .active: + return .medium + case .timeSensitive: + return .high + case nil: + return nil + } + } + + static func normalizedWatchPriority( + _ priority: OpenClawNotificationPriority?, + risk: OpenClawWatchRisk?) -> OpenClawNotificationPriority? + { + if let priority { return priority } + switch risk { + case .low: + return .passive + case .medium: + return .active + case .high: + return .timeSensitive + case nil: + return nil + } + } + + static func trimmedOrNil(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Model/NodeAppModel.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Model/NodeAppModel.swift new file mode 100644 index 00000000..d763a3b9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Model/NodeAppModel.swift @@ -0,0 +1,2730 @@ +import OpenClawChatUI +import OpenClawKit +import OpenClawProtocol +import Observation +import os +import Security +import SwiftUI +import UIKit +import UserNotifications + +// Wrap errors without pulling non-Sendable types into async notification paths. +private struct NotificationCallError: Error, Sendable { + let message: String +} +// Ensures notification requests return promptly even if the system prompt blocks. +private final class NotificationInvokeLatch: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation, Never>? + private var resumed = false + + func setContinuation(_ continuation: CheckedContinuation, Never>) { + self.lock.lock() + defer { self.lock.unlock() } + self.continuation = continuation + } + + func resume(_ response: Result) { + let cont: CheckedContinuation, Never>? + self.lock.lock() + if self.resumed { + self.lock.unlock() + return + } + self.resumed = true + cont = self.continuation + self.continuation = nil + self.lock.unlock() + cont?.resume(returning: response) + } +} + +private enum IOSDeepLinkAgentPolicy { + static let maxMessageChars = 20000 + static let maxUnkeyedConfirmChars = 240 +} + +@MainActor +@Observable +final class NodeAppModel { + struct AgentDeepLinkPrompt: Identifiable, Equatable { + let id: String + let messagePreview: String + let urlPreview: String + let request: AgentDeepLink + } + + private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink") + private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake") + private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake") + private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply") + enum CameraHUDKind { + case photo + case recording + case success + case error + } + + var isBackgrounded: Bool = false + let screen: ScreenController + private let camera: any CameraServicing + private let screenRecorder: any ScreenRecordingServicing + var gatewayStatusText: String = "Offline" + var nodeStatusText: String = "Offline" + var operatorStatusText: String = "Offline" + var gatewayServerName: String? + var gatewayRemoteAddress: String? + var connectedGatewayID: String? + var gatewayAutoReconnectEnabled: Bool = true + // When the gateway requires pairing approval, we pause reconnect churn and show a stable UX. + // Reconnect loops (both our own and the underlying WebSocket watchdog) can otherwise generate + // multiple pending requests and cause the onboarding UI to "flip-flop". + var gatewayPairingPaused: Bool = false + var gatewayPairingRequestId: String? + var seamColorHex: String? + private var mainSessionBaseKey: String = "main" + var selectedAgentId: String? + var gatewayDefaultAgentId: String? + var gatewayAgents: [AgentSummary] = [] + var lastShareEventText: String = "No share events yet." + var openChatRequestID: Int = 0 + private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt? + private var lastAgentDeepLinkPromptAt: Date = .distantPast + + // Primary "node" connection: used for device capabilities and node.invoke requests. + private let nodeGateway = GatewayNodeSession() + // Secondary "operator" connection: used for chat/talk/config/voicewake requests. + private let operatorGateway = GatewayNodeSession() + private var nodeGatewayTask: Task? + private var operatorGatewayTask: Task? + private var voiceWakeSyncTask: Task? + @ObservationIgnored private var cameraHUDDismissTask: Task? + @ObservationIgnored private lazy var capabilityRouter: NodeCapabilityRouter = self.buildCapabilityRouter() + private let gatewayHealthMonitor = GatewayHealthMonitor() + private var gatewayHealthMonitorDisabled = false + private let notificationCenter: NotificationCentering + let voiceWake = VoiceWakeManager() + let talkMode: TalkModeManager + private let locationService: any LocationServicing + private let deviceStatusService: any DeviceStatusServicing + private let photosService: any PhotosServicing + private let contactsService: any ContactsServicing + private let calendarService: any CalendarServicing + private let remindersService: any RemindersServicing + private let motionService: any MotionServicing + private let watchMessagingService: any WatchMessagingServicing + var lastAutoA2uiURL: String? + private var pttVoiceWakeSuspended = false + private var talkVoiceWakeSuspended = false + private var backgroundVoiceWakeSuspended = false + private var backgroundTalkSuspended = false + private var backgroundTalkKeptActive = false + private var backgroundedAt: Date? + private var reconnectAfterBackgroundArmed = false + private var backgroundGraceTaskID: UIBackgroundTaskIdentifier = .invalid + @ObservationIgnored private var backgroundGraceTaskTimer: Task? + private var backgroundReconnectSuppressed = false + private var backgroundReconnectLeaseUntil: Date? + private var lastSignificantLocationWakeAt: Date? + private var queuedWatchReplies: [WatchQuickReplyEvent] = [] + private var seenWatchReplyIds = Set() + + private var gatewayConnected = false + private var operatorConnected = false + private var shareDeliveryChannel: String? + private var shareDeliveryTo: String? + private var apnsDeviceTokenHex: String? + private var apnsLastRegisteredTokenHex: String? + var gatewaySession: GatewayNodeSession { self.nodeGateway } + var operatorSession: GatewayNodeSession { self.operatorGateway } + private(set) var activeGatewayConnectConfig: GatewayConnectConfig? + + var cameraHUDText: String? + var cameraHUDKind: CameraHUDKind? + var cameraFlashNonce: Int = 0 + var screenRecordActive: Bool = false + + init( + screen: ScreenController = ScreenController(), + camera: any CameraServicing = CameraController(), + screenRecorder: any ScreenRecordingServicing = ScreenRecordService(), + locationService: any LocationServicing = LocationService(), + notificationCenter: NotificationCentering = LiveNotificationCenter(), + deviceStatusService: any DeviceStatusServicing = DeviceStatusService(), + photosService: any PhotosServicing = PhotoLibraryService(), + contactsService: any ContactsServicing = ContactsService(), + calendarService: any CalendarServicing = CalendarService(), + remindersService: any RemindersServicing = RemindersService(), + motionService: any MotionServicing = MotionService(), + watchMessagingService: any WatchMessagingServicing = WatchMessagingService(), + talkMode: TalkModeManager = TalkModeManager()) + { + self.screen = screen + self.camera = camera + self.screenRecorder = screenRecorder + self.locationService = locationService + self.notificationCenter = notificationCenter + self.deviceStatusService = deviceStatusService + self.photosService = photosService + self.contactsService = contactsService + self.calendarService = calendarService + self.remindersService = remindersService + self.motionService = motionService + self.watchMessagingService = watchMessagingService + self.talkMode = talkMode + self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey) + GatewayDiagnostics.bootstrap() + self.watchMessagingService.setReplyHandler { [weak self] event in + Task { @MainActor in + await self?.handleWatchQuickReply(event) + } + } + + self.voiceWake.configure { [weak self] cmd in + guard let self else { return } + let sessionKey = await MainActor.run { self.mainSessionKey } + do { + try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey) + } catch { + // Best-effort only. + } + } + + let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled") + self.voiceWake.setEnabled(enabled) + self.talkMode.attachGateway(self.operatorGateway) + self.refreshLastShareEventFromRelay() + let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled") + // Route through the coordinator so VoiceWake and Talk don't fight over the microphone. + self.setTalkEnabled(talkEnabled) + + // Wire up deep links from canvas taps + self.screen.onDeepLink = { [weak self] url in + guard let self else { return } + Task { @MainActor in + await self.handleDeepLink(url: url) + } + } + + // Wire up A2UI action clicks (buttons, etc.) + self.screen.onA2UIAction = { [weak self] body in + guard let self else { return } + Task { @MainActor in + await self.handleCanvasA2UIAction(body: body) + } + } + } + + private func handleCanvasA2UIAction(body: [String: Any]) async { + let userActionAny = body["userAction"] ?? body + let userAction: [String: Any] = { + if let dict = userActionAny as? [String: Any] { return dict } + if let dict = userActionAny as? [AnyHashable: Any] { + return dict.reduce(into: [String: Any]()) { acc, pair in + guard let key = pair.key as? String else { return } + acc[key] = pair.value + } + } + return [:] + }() + guard !userAction.isEmpty else { return } + + guard let name = OpenClawCanvasA2UIAction.extractActionName(userAction) else { return } + let actionId: String = { + let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return id.isEmpty ? UUID().uuidString : id + }() + + let surfaceId: String = { + let raw = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return raw.isEmpty ? "main" : raw + }() + let sourceComponentId: String = { + let raw = (userAction[ + "sourceComponentId", + ] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return raw.isEmpty ? "-" : raw + }() + + let host = NodeDisplayName.resolve( + existing: UserDefaults.standard.string(forKey: "node.displayName"), + deviceName: UIDevice.current.name, + interfaceIdiom: UIDevice.current.userInterfaceIdiom) + let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased() + let contextJSON = OpenClawCanvasA2UIAction.compactJSON(userAction["context"]) + let sessionKey = self.mainSessionKey + + let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext( + actionName: name, + session: .init(key: sessionKey, surfaceId: surfaceId), + component: .init(id: sourceComponentId, host: host, instanceId: instanceId), + contextJSON: contextJSON) + let message = OpenClawCanvasA2UIAction.formatAgentMessage(messageContext) + + let ok: Bool + var errorText: String? + if await !self.isGatewayConnected() { + ok = false + errorText = "gateway not connected" + } else { + do { + try await self.sendAgentRequest(link: AgentDeepLink( + message: message, + sessionKey: sessionKey, + thinking: "low", + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: nil, + key: actionId)) + ok = true + } catch { + ok = false + errorText = error.localizedDescription + } + } + + let js = OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: ok, error: errorText) + do { + _ = try await self.screen.eval(javaScript: js) + } catch { + // ignore + } + } + + + func setScenePhase(_ phase: ScenePhase) { + let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled") + switch phase { + case .background: + self.isBackgrounded = true + self.stopGatewayHealthMonitor() + self.backgroundedAt = Date() + self.reconnectAfterBackgroundArmed = true + self.beginBackgroundConnectionGracePeriod() + // Release voice wake mic in background. + self.backgroundVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() + let shouldKeepTalkActive = keepTalkActive && self.talkMode.isEnabled + self.backgroundTalkKeptActive = shouldKeepTalkActive + self.backgroundTalkSuspended = self.talkMode.suspendForBackground(keepActive: shouldKeepTalkActive) + case .active, .inactive: + self.isBackgrounded = false + self.endBackgroundConnectionGracePeriod(reason: "scene_foreground") + self.clearBackgroundReconnectSuppression(reason: "scene_foreground") + if self.operatorConnected { + self.startGatewayHealthMonitor() + } + if phase == .active { + self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.backgroundVoiceWakeSuspended) + self.backgroundVoiceWakeSuspended = false + Task { [weak self] in + guard let self else { return } + let suspended = await MainActor.run { self.backgroundTalkSuspended } + let keptActive = await MainActor.run { self.backgroundTalkKeptActive } + await MainActor.run { + self.backgroundTalkSuspended = false + self.backgroundTalkKeptActive = false + } + await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive) + } + } + if phase == .active, self.reconnectAfterBackgroundArmed { + self.reconnectAfterBackgroundArmed = false + let backgroundedFor = self.backgroundedAt.map { Date().timeIntervalSince($0) } ?? 0 + self.backgroundedAt = nil + // iOS may suspend network sockets in background without a clean close. + // On foreground, force a fresh handshake to avoid "connected but dead" states. + if backgroundedFor >= 3.0 { + Task { [weak self] in + guard let self else { return } + let operatorWasConnected = await MainActor.run { self.operatorConnected } + if operatorWasConnected { + // Prefer keeping the connection if it's healthy; reconnect only when needed. + let healthy = (try? await self.operatorGateway.request( + method: "health", + paramsJSON: nil, + timeoutSeconds: 2)) != nil + if healthy { + await MainActor.run { self.startGatewayHealthMonitor() } + return + } + } + + await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() + await MainActor.run { + self.operatorConnected = false + self.gatewayConnected = false + self.talkMode.updateGatewayConnected(false) + } + } + } + } + @unknown default: + self.isBackgrounded = false + self.endBackgroundConnectionGracePeriod(reason: "scene_unknown") + self.clearBackgroundReconnectSuppression(reason: "scene_unknown") + } + } + + private func beginBackgroundConnectionGracePeriod(seconds: TimeInterval = 25) { + self.grantBackgroundReconnectLease(seconds: seconds, reason: "scene_background_grace") + self.endBackgroundConnectionGracePeriod(reason: "restart") + let taskID = UIApplication.shared.beginBackgroundTask(withName: "gateway-background-grace") { [weak self] in + Task { @MainActor in + self?.suppressBackgroundReconnect( + reason: "background_grace_expired", + disconnectIfNeeded: true) + self?.endBackgroundConnectionGracePeriod(reason: "expired") + } + } + guard taskID != .invalid else { + self.pushWakeLogger.info("Background grace unavailable: beginBackgroundTask returned invalid") + return + } + self.backgroundGraceTaskID = taskID + self.pushWakeLogger.info("Background grace started seconds=\(seconds, privacy: .public)") + self.backgroundGraceTaskTimer = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(max(1, seconds) * 1_000_000_000)) + await MainActor.run { + self.suppressBackgroundReconnect(reason: "background_grace_timer", disconnectIfNeeded: true) + self.endBackgroundConnectionGracePeriod(reason: "timer") + } + } + } + + private func endBackgroundConnectionGracePeriod(reason: String) { + self.backgroundGraceTaskTimer?.cancel() + self.backgroundGraceTaskTimer = nil + guard self.backgroundGraceTaskID != .invalid else { return } + UIApplication.shared.endBackgroundTask(self.backgroundGraceTaskID) + self.backgroundGraceTaskID = .invalid + self.pushWakeLogger.info("Background grace ended reason=\(reason, privacy: .public)") + } + + private func grantBackgroundReconnectLease(seconds: TimeInterval, reason: String) { + guard self.isBackgrounded else { return } + let leaseSeconds = max(5, seconds) + let leaseUntil = Date().addingTimeInterval(leaseSeconds) + if let existing = self.backgroundReconnectLeaseUntil, existing > leaseUntil { + // Keep the longer lease if one is already active. + } else { + self.backgroundReconnectLeaseUntil = leaseUntil + } + let wasSuppressed = self.backgroundReconnectSuppressed + self.backgroundReconnectSuppressed = false + self.pushWakeLogger.info( + "Background reconnect lease reason=\(reason, privacy: .public) seconds=\(leaseSeconds, privacy: .public) wasSuppressed=\(wasSuppressed, privacy: .public)") + } + + private func suppressBackgroundReconnect(reason: String, disconnectIfNeeded: Bool) { + guard self.isBackgrounded else { return } + let hadLease = self.backgroundReconnectLeaseUntil != nil + let changed = hadLease || !self.backgroundReconnectSuppressed + self.backgroundReconnectLeaseUntil = nil + self.backgroundReconnectSuppressed = true + guard changed else { return } + self.pushWakeLogger.info( + "Background reconnect suppressed reason=\(reason, privacy: .public) disconnect=\(disconnectIfNeeded, privacy: .public)") + guard disconnectIfNeeded else { return } + Task { [weak self] in + guard let self else { return } + await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() + await MainActor.run { + self.operatorConnected = false + self.gatewayConnected = false + self.talkMode.updateGatewayConnected(false) + if self.isBackgrounded { + self.gatewayStatusText = "Background idle" + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + self.showLocalCanvasOnDisconnect() + } + } + } + } + + private func clearBackgroundReconnectSuppression(reason: String) { + let changed = self.backgroundReconnectSuppressed || self.backgroundReconnectLeaseUntil != nil + self.backgroundReconnectSuppressed = false + self.backgroundReconnectLeaseUntil = nil + guard changed else { return } + self.pushWakeLogger.info("Background reconnect cleared reason=\(reason, privacy: .public)") + } + + func setVoiceWakeEnabled(_ enabled: Bool) { + self.voiceWake.setEnabled(enabled) + if enabled { + // If talk is enabled, voice wake should not grab the mic. + if self.talkMode.isEnabled { + self.voiceWake.setSuppressedByTalk(true) + self.talkVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() + } + } else { + self.voiceWake.setSuppressedByTalk(false) + self.talkVoiceWakeSuspended = false + } + } + + func setTalkEnabled(_ enabled: Bool) { + UserDefaults.standard.set(enabled, forKey: "talk.enabled") + if enabled { + // Voice wake holds the microphone continuously; talk mode needs exclusive access for STT. + // When talk is enabled from the UI, prioritize talk and pause voice wake. + self.voiceWake.setSuppressedByTalk(true) + self.talkVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() + } else { + self.voiceWake.setSuppressedByTalk(false) + self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.talkVoiceWakeSuspended) + self.talkVoiceWakeSuspended = false + } + self.talkMode.setEnabled(enabled) + Task { [weak self] in + await self?.pushTalkModeToGateway( + enabled: enabled, + phase: enabled ? "enabled" : "disabled") + } + } + + func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool { + guard mode != .off else { return true } + let status = await self.locationService.ensureAuthorization(mode: mode) + switch status { + case .authorizedAlways: + return true + case .authorizedWhenInUse: + return mode != .always + default: + return false + } + } + + var seamColor: Color { + Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor + } + + private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0) + private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex" + private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key" + private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey() + private static var apnsEnvironment: String { +#if DEBUG + "sandbox" +#else + "production" +#endif + } + + private func refreshBrandingFromGateway() async { + do { + let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) + guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } + guard let config = json["config"] as? [String: Any] else { return } + let ui = config["ui"] as? [String: Any] + let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let session = config["session"] as? [String: Any] + let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) + await MainActor.run { + self.seamColorHex = raw.isEmpty ? nil : raw + self.mainSessionBaseKey = mainKey + self.talkMode.updateMainSessionKey(self.mainSessionKey) + } + } catch { + if let gatewayError = error as? GatewayResponseError { + let lower = gatewayError.message.lowercased() + if lower.contains("unauthorized role") { + return + } + } + // ignore + } + } + + private func refreshAgentsFromGateway() async { + do { + let res = try await self.operatorGateway.request(method: "agents.list", paramsJSON: "{}", timeoutSeconds: 8) + let decoded = try JSONDecoder().decode(AgentsListResult.self, from: res) + await MainActor.run { + self.gatewayDefaultAgentId = decoded.defaultid + self.gatewayAgents = decoded.agents + self.applyMainSessionKey(decoded.mainkey) + + let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if !selected.isEmpty && !decoded.agents.contains(where: { $0.id == selected }) { + self.selectedAgentId = nil + } + self.talkMode.updateMainSessionKey(self.mainSessionKey) + } + } catch { + // Best-effort only. + } + } + + func setSelectedAgentId(_ agentId: String?) { + let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if stableID.isEmpty { + self.selectedAgentId = trimmed.isEmpty ? nil : trimmed + } else { + self.selectedAgentId = trimmed.isEmpty ? nil : trimmed + GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId) + } + self.talkMode.updateMainSessionKey(self.mainSessionKey) + if let relay = ShareGatewayRelaySettings.loadConfig() { + ShareGatewayRelaySettings.saveConfig( + ShareGatewayRelayConfig( + gatewayURLString: relay.gatewayURLString, + token: relay.token, + password: relay.password, + sessionKey: self.mainSessionKey, + deliveryChannel: self.shareDeliveryChannel, + deliveryTo: self.shareDeliveryTo)) + } + } + + func setGlobalWakeWords(_ words: [String]) async { + let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words) + + struct Payload: Codable { + var triggers: [String] + } + let payload = Payload(triggers: sanitized) + guard let data = try? JSONEncoder().encode(payload), + let json = String(data: data, encoding: .utf8) + else { return } + + do { + _ = try await self.operatorGateway.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12) + } catch { + // Best-effort only. + } + } + + private func startVoiceWakeSync() async { + self.voiceWakeSyncTask?.cancel() + self.voiceWakeSyncTask = Task { [weak self] in + guard let self else { return } + + if !(await self.isGatewayHealthMonitorDisabled()) { + await self.refreshWakeWordsFromGateway() + } + + let stream = await self.operatorGateway.subscribeServerEvents(bufferingNewest: 200) + for await evt in stream { + if Task.isCancelled { return } + guard let payload = evt.payload else { continue } + switch evt.event { + case "voicewake.changed": + struct Payload: Decodable { var triggers: [String] } + guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue } + let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers) + VoiceWakePreferences.saveTriggerWords(triggers) + case "talk.mode": + struct Payload: Decodable { + var enabled: Bool + var phase: String? + } + guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue } + self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase) + default: + continue + } + } + } + } + + private func applyTalkModeSync(enabled: Bool, phase: String?) { + _ = phase + guard self.talkMode.isEnabled != enabled else { return } + self.setTalkEnabled(enabled) + } + + private func pushTalkModeToGateway(enabled: Bool, phase: String?) async { + guard await self.isOperatorConnected() else { return } + struct TalkModePayload: Encodable { + var enabled: Bool + var phase: String? + } + let payload = TalkModePayload(enabled: enabled, phase: phase) + guard let data = try? JSONEncoder().encode(payload), + let json = String(data: data, encoding: .utf8) + else { return } + _ = try? await self.operatorGateway.request( + method: "talk.mode", + paramsJSON: json, + timeoutSeconds: 8) + } + + private func startGatewayHealthMonitor() { + self.gatewayHealthMonitorDisabled = false + self.gatewayHealthMonitor.start( + check: { [weak self] in + guard let self else { return false } + if await self.isGatewayHealthMonitorDisabled() { return true } + do { + let data = try await self.operatorGateway.request(method: "health", paramsJSON: nil, timeoutSeconds: 6) + guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else { + return false + } + return decoded.ok ?? false + } catch { + if let gatewayError = error as? GatewayResponseError { + let lower = gatewayError.message.lowercased() + if lower.contains("unauthorized role") || lower.contains("missing scope") { + await self.setGatewayHealthMonitorDisabled(true) + return true + } + } + return false + } + }, + onFailure: { [weak self] _ in + guard let self else { return } + await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() + await MainActor.run { + self.operatorConnected = false + self.gatewayConnected = false + self.gatewayStatusText = "Reconnecting…" + self.talkMode.updateGatewayConnected(false) + } + }) + } + + private func stopGatewayHealthMonitor() { + self.gatewayHealthMonitor.stop() + } + + private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { + let command = req.command + + if self.isBackgrounded, self.isBackgroundRestricted(command) { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .backgroundUnavailable, + message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground")) + } + + if command.hasPrefix("camera."), !self.isCameraEnabled() { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "CAMERA_DISABLED: enable Camera in iOS Settings → Camera → Allow Camera")) + } + + do { + return try await self.capabilityRouter.handle(req) + } catch let error as NodeCapabilityRouter.RouterError { + switch error { + case .unknownCommand: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + case .handlerUnavailable: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .unavailable, message: "node handler unavailable")) + } + } catch { + if command.hasPrefix("camera.") { + let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + self.showCameraHUD(text: text, kind: .error, autoHideSeconds: 2.2) + } + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .unavailable, message: error.localizedDescription)) + } + } + + private func isBackgroundRestricted(_ command: String) -> Bool { + command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.") || + command.hasPrefix("talk.") + } + + private func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let mode = self.locationMode() + guard mode != .off else { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "LOCATION_DISABLED: enable Location in Settings")) + } + if self.isBackgrounded, mode != .always { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .backgroundUnavailable, + message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always")) + } + let params = (try? Self.decodeParams(OpenClawLocationGetParams.self, from: req.paramsJSON)) ?? + OpenClawLocationGetParams() + let desired = params.desiredAccuracy ?? + (self.isLocationPreciseEnabled() ? .precise : .balanced) + let status = self.locationService.authorizationStatus() + if status != .authorizedAlways, status != .authorizedWhenInUse { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "LOCATION_PERMISSION_REQUIRED: grant Location permission")) + } + if self.isBackgrounded, status != .authorizedAlways { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access")) + } + let location = try await self.locationService.currentLocation( + params: params, + desiredAccuracy: desired, + maxAgeMs: params.maxAgeMs, + timeoutMs: params.timeoutMs) + let isPrecise = self.locationService.accuracyAuthorization() == .fullAccuracy + let payload = OpenClawLocationPayload( + lat: location.coordinate.latitude, + lon: location.coordinate.longitude, + accuracyMeters: location.horizontalAccuracy, + altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil, + speedMps: location.speed >= 0 ? location.speed : nil, + headingDeg: location.course >= 0 ? location.course : nil, + timestamp: ISO8601DateFormatter().string(from: location.timestamp), + isPrecise: isPrecise, + source: nil) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + } + + private func handleCanvasInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + switch req.command { + case OpenClawCanvasCommand.present.rawValue: + // iOS ignores placement hints; canvas always fills the screen. + let params = (try? Self.decodeParams(OpenClawCanvasPresentParams.self, from: req.paramsJSON)) ?? + OpenClawCanvasPresentParams() + let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if url.isEmpty { + self.screen.showDefaultCanvas() + } else { + self.screen.navigate(to: url) + } + return BridgeInvokeResponse(id: req.id, ok: true) + case OpenClawCanvasCommand.hide.rawValue: + self.screen.showDefaultCanvas() + return BridgeInvokeResponse(id: req.id, ok: true) + case OpenClawCanvasCommand.navigate.rawValue: + let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON) + self.screen.navigate(to: params.url) + return BridgeInvokeResponse(id: req.id, ok: true) + case OpenClawCanvasCommand.evalJS.rawValue: + let params = try Self.decodeParams(OpenClawCanvasEvalParams.self, from: req.paramsJSON) + let result = try await self.screen.eval(javaScript: params.javaScript) + let payload = try Self.encodePayload(["result": result]) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + case OpenClawCanvasCommand.snapshot.rawValue: + let params = try? Self.decodeParams(OpenClawCanvasSnapshotParams.self, from: req.paramsJSON) + let format = params?.format ?? .jpeg + let maxWidth: CGFloat? = { + if let raw = params?.maxWidth, raw > 0 { return CGFloat(raw) } + // Keep default snapshots comfortably below the gateway client's maxPayload. + // For full-res, clients should explicitly request a larger maxWidth. + return switch format { + case .png: 900 + case .jpeg: 1600 + } + }() + let base64 = try await self.screen.snapshotBase64( + maxWidth: maxWidth, + format: format, + quality: params?.quality) + let payload = try Self.encodePayload([ + "format": format == .jpeg ? "jpeg" : "png", + "base64": base64, + ]) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } + } + + private func handleCanvasA2UIInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let command = req.command + switch command { + case OpenClawCanvasA2UICommand.reset.rawValue: + guard let a2uiUrl = await self.resolveA2UIHostURL() else { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host")) + } + self.screen.navigate(to: a2uiUrl) + if await !self.screen.waitForA2UIReady(timeoutMs: 5000) { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable")) + } + + let json = try await self.screen.eval(javaScript: """ + (() => { + const host = globalThis.openclawA2UI; + if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" }); + return JSON.stringify(host.reset()); + })() + """) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawCanvasA2UICommand.push.rawValue, OpenClawCanvasA2UICommand.pushJSONL.rawValue: + let messages: [OpenClawKit.AnyCodable] + if command == OpenClawCanvasA2UICommand.pushJSONL.rawValue { + let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON) + messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl) + } else { + do { + let params = try Self.decodeParams(OpenClawCanvasA2UIPushParams.self, from: req.paramsJSON) + messages = params.messages + } catch { + // Be forgiving: some clients still send JSONL payloads to `canvas.a2ui.push`. + let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON) + messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl) + } + } + + guard let a2uiUrl = await self.resolveA2UIHostURL() else { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host")) + } + self.screen.navigate(to: a2uiUrl) + if await !self.screen.waitForA2UIReady(timeoutMs: 5000) { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable")) + } + + let messagesJSON = try OpenClawCanvasA2UIJSONL.encodeMessagesJSONArray(messages) + let js = """ + (() => { + try { + const host = globalThis.openclawA2UI; + if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" }); + const messages = \(messagesJSON); + return JSON.stringify(host.applyMessages(messages)); + } catch (e) { + return JSON.stringify({ ok: false, error: String(e?.message ?? e) }); + } + })() + """ + let resultJSON = try await self.screen.eval(javaScript: js) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON) + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } + } + + private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + switch req.command { + case OpenClawCameraCommand.list.rawValue: + let devices = await self.camera.listDevices() + struct Payload: Codable { + var devices: [CameraController.CameraDeviceInfo] + } + let payload = try Self.encodePayload(Payload(devices: devices)) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + case OpenClawCameraCommand.snap.rawValue: + self.showCameraHUD(text: "Taking photo…", kind: .photo) + self.triggerCameraFlash() + let params = (try? Self.decodeParams(OpenClawCameraSnapParams.self, from: req.paramsJSON)) ?? + OpenClawCameraSnapParams() + let res = try await self.camera.snap(params: params) + + struct Payload: Codable { + var format: String + var base64: String + var width: Int + var height: Int + } + let payload = try Self.encodePayload(Payload( + format: res.format, + base64: res.base64, + width: res.width, + height: res.height)) + self.showCameraHUD(text: "Photo captured", kind: .success, autoHideSeconds: 1.6) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + case OpenClawCameraCommand.clip.rawValue: + let params = (try? Self.decodeParams(OpenClawCameraClipParams.self, from: req.paramsJSON)) ?? + OpenClawCameraClipParams() + + let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false + defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) } + + self.showCameraHUD(text: "Recording…", kind: .recording) + let res = try await self.camera.clip(params: params) + + struct Payload: Codable { + var format: String + var base64: String + var durationMs: Int + var hasAudio: Bool + } + let payload = try Self.encodePayload(Payload( + format: res.format, + base64: res.base64, + durationMs: res.durationMs, + hasAudio: res.hasAudio)) + self.showCameraHUD(text: "Clip captured", kind: .success, autoHideSeconds: 1.8) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } + } + + private func handleScreenRecordInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let params = (try? Self.decodeParams(OpenClawScreenRecordParams.self, from: req.paramsJSON)) ?? + OpenClawScreenRecordParams() + if let format = params.format, format.lowercased() != "mp4" { + throw NSError(domain: "Screen", code: 30, userInfo: [ + NSLocalizedDescriptionKey: "INVALID_REQUEST: screen format must be mp4", + ]) + } + // Status pill mirrors screen recording state so it stays visible without overlay stacking. + self.screenRecordActive = true + defer { self.screenRecordActive = false } + let path = try await self.screenRecorder.record( + screenIndex: params.screenIndex, + durationMs: params.durationMs, + fps: params.fps, + includeAudio: params.includeAudio, + outPath: nil) + defer { try? FileManager().removeItem(atPath: path) } + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + struct Payload: Codable { + var format: String + var base64: String + var durationMs: Int? + var fps: Double? + var screenIndex: Int? + var hasAudio: Bool + } + let payload = try Self.encodePayload(Payload( + format: "mp4", + base64: data.base64EncodedString(), + durationMs: params.durationMs, + fps: params.fps, + screenIndex: params.screenIndex, + hasAudio: params.includeAudio ?? true)) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + } + + private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let params = try Self.decodeParams(OpenClawSystemNotifyParams.self, from: req.paramsJSON) + let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + if title.isEmpty, body.isEmpty { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty notification")) + } + + let finalStatus = await self.requestNotificationAuthorizationIfNeeded() + guard finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral else { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications")) + } + + let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in + let content = UNMutableNotificationContent() + content.title = title + content.body = body + if #available(iOS 15.0, *) { + switch params.priority ?? .active { + case .passive: + content.interruptionLevel = .passive + case .timeSensitive: + content.interruptionLevel = .timeSensitive + case .active: + content.interruptionLevel = .active + } + } + let soundValue = params.sound?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if let soundValue, ["none", "silent", "off", "false", "0"].contains(soundValue) { + content.sound = nil + } else { + content.sound = .default + } + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil) + try await notificationCenter.add(request) + } + if case let .failure(error) = addResult { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .unavailable, message: "NOTIFICATION_FAILED: \(error.message)")) + } + return BridgeInvokeResponse(id: req.id, ok: true) + } + + private func handleChatPushInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let params = try Self.decodeParams(OpenClawChatPushParams.self, from: req.paramsJSON) + let text = params.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty chat.push text")) + } + + let finalStatus = await self.requestNotificationAuthorizationIfNeeded() + let messageId = UUID().uuidString + if finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral { + let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in + let content = UNMutableNotificationContent() + content.title = "OpenClaw" + content.body = text + content.sound = .default + content.userInfo = ["messageId": messageId] + let request = UNNotificationRequest( + identifier: messageId, + content: content, + trigger: nil) + try await notificationCenter.add(request) + } + if case let .failure(error) = addResult { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .unavailable, message: "NOTIFICATION_FAILED: \(error.message)")) + } + } + + if params.speak ?? true { + let toSpeak = text + Task { @MainActor in + try? await TalkSystemSpeechSynthesizer.shared.speak(text: toSpeak) + } + } + + let payload = OpenClawChatPushPayload(messageId: messageId) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + } + + private func requestNotificationAuthorizationIfNeeded() async -> NotificationAuthorizationStatus { + let status = await self.notificationAuthorizationStatus() + guard status == .notDetermined else { return status } + + // Avoid hanging invoke requests if the permission prompt is never answered. + _ = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in + _ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) + } + + return await self.notificationAuthorizationStatus() + } + + private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus { + let result = await self.runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in + await notificationCenter.authorizationStatus() + } + switch result { + case let .success(status): + return status + case .failure: + return .denied + } + } + + private func runNotificationCall( + timeoutSeconds: Double, + operation: @escaping @Sendable () async throws -> T + ) async -> Result { + let latch = NotificationInvokeLatch() + var opTask: Task? + var timeoutTask: Task? + defer { + opTask?.cancel() + timeoutTask?.cancel() + } + let clamped = max(0.0, timeoutSeconds) + return await withCheckedContinuation { (cont: CheckedContinuation, Never>) in + latch.setContinuation(cont) + opTask = Task { @MainActor in + do { + let value = try await operation() + latch.resume(.success(value)) + } catch { + latch.resume(.failure(NotificationCallError(message: error.localizedDescription))) + } + } + timeoutTask = Task.detached { + if clamped > 0 { + try? await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000)) + } + latch.resume(.failure(NotificationCallError(message: "notification request timed out"))) + } + } + } + + private func handleDeviceInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + switch req.command { + case OpenClawDeviceCommand.status.rawValue: + let payload = try await self.deviceStatusService.status() + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawDeviceCommand.info.rawValue: + let payload = self.deviceStatusService.info() + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } + } + + private func handlePhotosInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let params = (try? Self.decodeParams(OpenClawPhotosLatestParams.self, from: req.paramsJSON)) ?? + OpenClawPhotosLatestParams() + let payload = try await self.photosService.latest(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + } + + private func handleContactsInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + switch req.command { + case OpenClawContactsCommand.search.rawValue: + let params = (try? Self.decodeParams(OpenClawContactsSearchParams.self, from: req.paramsJSON)) ?? + OpenClawContactsSearchParams() + let payload = try await self.contactsService.search(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawContactsCommand.add.rawValue: + let params = try Self.decodeParams(OpenClawContactsAddParams.self, from: req.paramsJSON) + let payload = try await self.contactsService.add(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } + } + + private func handleCalendarInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + switch req.command { + case OpenClawCalendarCommand.events.rawValue: + let params = (try? Self.decodeParams(OpenClawCalendarEventsParams.self, from: req.paramsJSON)) ?? + OpenClawCalendarEventsParams() + let payload = try await self.calendarService.events(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawCalendarCommand.add.rawValue: + let params = try Self.decodeParams(OpenClawCalendarAddParams.self, from: req.paramsJSON) + let payload = try await self.calendarService.add(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } + } + + private func handleRemindersInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + switch req.command { + case OpenClawRemindersCommand.list.rawValue: + let params = (try? Self.decodeParams(OpenClawRemindersListParams.self, from: req.paramsJSON)) ?? + OpenClawRemindersListParams() + let payload = try await self.remindersService.list(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawRemindersCommand.add.rawValue: + let params = try Self.decodeParams(OpenClawRemindersAddParams.self, from: req.paramsJSON) + let payload = try await self.remindersService.add(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } + } + + private func handleMotionInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + switch req.command { + case OpenClawMotionCommand.activity.rawValue: + let params = (try? Self.decodeParams(OpenClawMotionActivityParams.self, from: req.paramsJSON)) ?? + OpenClawMotionActivityParams() + let payload = try await self.motionService.activities(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawMotionCommand.pedometer.rawValue: + let params = (try? Self.decodeParams(OpenClawPedometerParams.self, from: req.paramsJSON)) ?? + OpenClawPedometerParams() + let payload = try await self.motionService.pedometer(params: params) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } + } + + private func handleTalkInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + switch req.command { + case OpenClawTalkCommand.pttStart.rawValue: + self.pttVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() + let payload = try await self.talkMode.beginPushToTalk() + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawTalkCommand.pttStop.rawValue: + let payload = await self.talkMode.endPushToTalk() + self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) + self.pttVoiceWakeSuspended = false + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawTalkCommand.pttCancel.rawValue: + let payload = await self.talkMode.cancelPushToTalk() + self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) + self.pttVoiceWakeSuspended = false + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawTalkCommand.pttOnce.rawValue: + self.pttVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() + defer { + self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) + self.pttVoiceWakeSuspended = false + } + let payload = try await self.talkMode.runPushToTalkOnce() + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } + } + +} + +private extension NodeAppModel { + // Central registry for node invoke routing to keep commands in one place. + func buildCapabilityRouter() -> NodeCapabilityRouter { + var handlers: [String: NodeCapabilityRouter.Handler] = [:] + + func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) { + for command in commands { + handlers[command] = handler + } + } + + register([OpenClawLocationCommand.get.rawValue]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleLocationInvoke(req) + } + + register([ + OpenClawCanvasCommand.present.rawValue, + OpenClawCanvasCommand.hide.rawValue, + OpenClawCanvasCommand.navigate.rawValue, + OpenClawCanvasCommand.evalJS.rawValue, + OpenClawCanvasCommand.snapshot.rawValue, + ]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleCanvasInvoke(req) + } + + register([ + OpenClawCanvasA2UICommand.reset.rawValue, + OpenClawCanvasA2UICommand.push.rawValue, + OpenClawCanvasA2UICommand.pushJSONL.rawValue, + ]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleCanvasA2UIInvoke(req) + } + + register([ + OpenClawCameraCommand.list.rawValue, + OpenClawCameraCommand.snap.rawValue, + OpenClawCameraCommand.clip.rawValue, + ]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleCameraInvoke(req) + } + + register([OpenClawScreenCommand.record.rawValue]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleScreenRecordInvoke(req) + } + + register([OpenClawSystemCommand.notify.rawValue]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleSystemNotify(req) + } + + register([OpenClawChatCommand.push.rawValue]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleChatPushInvoke(req) + } + + register([ + OpenClawDeviceCommand.status.rawValue, + OpenClawDeviceCommand.info.rawValue, + ]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleDeviceInvoke(req) + } + + register([ + OpenClawWatchCommand.status.rawValue, + OpenClawWatchCommand.notify.rawValue, + ]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleWatchInvoke(req) + } + + register([OpenClawPhotosCommand.latest.rawValue]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handlePhotosInvoke(req) + } + + register([ + OpenClawContactsCommand.search.rawValue, + OpenClawContactsCommand.add.rawValue, + ]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleContactsInvoke(req) + } + + register([ + OpenClawCalendarCommand.events.rawValue, + OpenClawCalendarCommand.add.rawValue, + ]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleCalendarInvoke(req) + } + + register([ + OpenClawRemindersCommand.list.rawValue, + OpenClawRemindersCommand.add.rawValue, + ]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleRemindersInvoke(req) + } + + register([ + OpenClawMotionCommand.activity.rawValue, + OpenClawMotionCommand.pedometer.rawValue, + ]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleMotionInvoke(req) + } + + register([ + OpenClawTalkCommand.pttStart.rawValue, + OpenClawTalkCommand.pttStop.rawValue, + OpenClawTalkCommand.pttCancel.rawValue, + OpenClawTalkCommand.pttOnce.rawValue, + ]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleTalkInvoke(req) + } + + return NodeCapabilityRouter(handlers: handlers) + } + + func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + switch req.command { + case OpenClawWatchCommand.status.rawValue: + let status = await self.watchMessagingService.status() + let payload = OpenClawWatchStatusPayload( + supported: status.supported, + paired: status.paired, + appInstalled: status.appInstalled, + reachable: status.reachable, + activationState: status.activationState) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawWatchCommand.notify.rawValue: + let params = try Self.decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON) + let normalizedParams = Self.normalizeWatchNotifyParams(params) + let title = normalizedParams.title + let body = normalizedParams.body + if title.isEmpty && body.isEmpty { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .invalidRequest, + message: "INVALID_REQUEST: empty watch notification")) + } + do { + let result = try await self.watchMessagingService.sendNotification( + id: req.id, + params: normalizedParams) + if result.queuedForDelivery || !result.deliveredImmediately { + let invokeID = req.id + Task { @MainActor in + await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded( + invokeID: invokeID, + params: normalizedParams, + sendResult: result) + } + } + let payload = OpenClawWatchNotifyPayload( + deliveredImmediately: result.deliveredImmediately, + queuedForDelivery: result.queuedForDelivery, + transport: result.transport) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + } catch { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: error.localizedDescription)) + } + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } + } + + func locationMode() -> OpenClawLocationMode { + let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" + return OpenClawLocationMode(rawValue: raw) ?? .off + } + + func isLocationPreciseEnabled() -> Bool { + // iOS settings now expose a single location mode control. + // Default location tool precision stays high unless a command explicitly requests balanced. + true + } + + static func decodeParams(_ type: T.Type, from json: String?) throws -> T { + guard let json, let data = json.data(using: .utf8) else { + throw NSError(domain: "Gateway", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required", + ]) + } + return try JSONDecoder().decode(type, from: data) + } + + static func encodePayload(_ obj: some Encodable) throws -> String { + let data = try JSONEncoder().encode(obj) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 21, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8", + ]) + } + return json + } + + func isCameraEnabled() -> Bool { + // Default-on: if the key doesn't exist yet, treat it as enabled. + if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true } + return UserDefaults.standard.bool(forKey: "camera.enabled") + } + + func triggerCameraFlash() { + self.cameraFlashNonce &+= 1 + } + + func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) { + self.cameraHUDDismissTask?.cancel() + + withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { + self.cameraHUDText = text + self.cameraHUDKind = kind + } + + guard let autoHideSeconds else { return } + self.cameraHUDDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(autoHideSeconds * 1_000_000_000)) + withAnimation(.easeOut(duration: 0.25)) { + self.cameraHUDText = nil + self.cameraHUDKind = nil + } + } + } +} + +extension NodeAppModel { + var mainSessionKey: String { + let base = SessionKey.normalizeMainKey(self.mainSessionBaseKey) + let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base } + return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base) + } + + var chatSessionKey: String { + let base = "ios" + let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base } + return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base) + } + + var activeAgentName: String { + let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedId = agentId.isEmpty ? defaultId : agentId + if resolvedId.isEmpty { return "Main" } + if let match = self.gatewayAgents.first(where: { $0.id == resolvedId }) { + let name = (match.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? match.id : name + } + return resolvedId + } + + func connectToGateway( + url: URL, + gatewayStableID: String, + tls: GatewayTLSParams?, + token: String?, + password: String?, + connectOptions: GatewayConnectOptions) + { + let stableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) + let effectiveStableID = stableID.isEmpty ? url.absoluteString : stableID + let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) } + + self.activeGatewayConnectConfig = GatewayConnectConfig( + url: url, + stableID: stableID, + tls: tls, + token: token, + password: password, + nodeOptions: connectOptions) + self.prepareForGatewayConnect(url: url, stableID: effectiveStableID) + self.startOperatorGatewayLoop( + url: url, + stableID: effectiveStableID, + token: token, + password: password, + nodeOptions: connectOptions, + sessionBox: sessionBox) + self.startNodeGatewayLoop( + url: url, + stableID: effectiveStableID, + token: token, + password: password, + nodeOptions: connectOptions, + sessionBox: sessionBox) + } + + /// Preferred entry-point: apply a single config object and start both sessions. + func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig) { + self.activeGatewayConnectConfig = cfg + self.connectToGateway( + url: cfg.url, + // Preserve the caller-provided stableID (may be empty) and let connectToGateway + // derive the effective stable id consistently for persistence keys. + gatewayStableID: cfg.stableID, + tls: cfg.tls, + token: cfg.token, + password: cfg.password, + connectOptions: cfg.nodeOptions) + } + + func disconnectGateway() { + self.gatewayAutoReconnectEnabled = false + self.gatewayPairingPaused = false + self.gatewayPairingRequestId = nil + self.nodeGatewayTask?.cancel() + self.nodeGatewayTask = nil + self.operatorGatewayTask?.cancel() + self.operatorGatewayTask = nil + self.voiceWakeSyncTask?.cancel() + self.voiceWakeSyncTask = nil + self.gatewayHealthMonitor.stop() + Task { + await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() + } + self.gatewayStatusText = "Offline" + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + self.connectedGatewayID = nil + self.activeGatewayConnectConfig = nil + self.gatewayConnected = false + self.operatorConnected = false + self.talkMode.updateGatewayConnected(false) + self.seamColorHex = nil + self.mainSessionBaseKey = "main" + self.talkMode.updateMainSessionKey(self.mainSessionKey) + ShareGatewayRelaySettings.clearConfig() + self.showLocalCanvasOnDisconnect() + } +} + +private extension NodeAppModel { + func prepareForGatewayConnect(url: URL, stableID: String) { + self.gatewayAutoReconnectEnabled = true + self.gatewayPairingPaused = false + self.gatewayPairingRequestId = nil + self.nodeGatewayTask?.cancel() + self.operatorGatewayTask?.cancel() + self.gatewayHealthMonitor.stop() + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + self.connectedGatewayID = stableID + self.gatewayConnected = false + self.operatorConnected = false + self.voiceWakeSyncTask?.cancel() + self.voiceWakeSyncTask = nil + self.gatewayDefaultAgentId = nil + self.gatewayAgents = [] + self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID) + self.apnsLastRegisteredTokenHex = nil + } + + func refreshBackgroundReconnectSuppressionIfNeeded(source: String) { + guard self.isBackgrounded else { return } + guard !self.backgroundReconnectSuppressed else { return } + guard let leaseUntil = self.backgroundReconnectLeaseUntil else { + self.suppressBackgroundReconnect(reason: "\(source):no_lease", disconnectIfNeeded: true) + return + } + if Date() >= leaseUntil { + self.suppressBackgroundReconnect(reason: "\(source):lease_expired", disconnectIfNeeded: true) + } + } + + func shouldPauseReconnectLoopInBackground(source: String) -> Bool { + self.refreshBackgroundReconnectSuppressionIfNeeded(source: source) + return self.isBackgrounded && self.backgroundReconnectSuppressed + } + + func startOperatorGatewayLoop( + url: URL, + stableID: String, + token: String?, + password: String?, + nodeOptions: GatewayConnectOptions, + sessionBox: WebSocketSessionBox?) + { + // Operator session reconnects independently (chat/talk/config/voicewake), but we tie its + // lifecycle to the current gateway config so it doesn't keep running across Disconnect. + self.operatorGatewayTask = Task { [weak self] in + guard let self else { return } + var attempt = 0 + while !Task.isCancelled { + if self.gatewayPairingPaused { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } + if !self.gatewayAutoReconnectEnabled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } + if self.shouldPauseReconnectLoopInBackground(source: "operator_loop") { try? await Task.sleep(nanoseconds: 2_000_000_000); continue } + if await self.isOperatorConnected() { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } + + let effectiveClientId = + GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) ?? nodeOptions.clientId + let operatorOptions = self.makeOperatorConnectOptions( + clientId: effectiveClientId, + displayName: nodeOptions.clientDisplayName) + + do { + try await self.operatorGateway.connect( + url: url, + token: token, + password: password, + connectOptions: operatorOptions, + sessionBox: sessionBox, + onConnected: { [weak self] in + guard let self else { return } + await MainActor.run { + self.operatorConnected = true + self.talkMode.updateGatewayConnected(true) + } + GatewayDiagnostics.log( + "operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")") + await self.talkMode.reloadConfig() + await self.refreshBrandingFromGateway() + await self.refreshAgentsFromGateway() + await self.refreshShareRouteFromGateway() + await self.startVoiceWakeSync() + await MainActor.run { self.startGatewayHealthMonitor() } + }, + onDisconnected: { [weak self] reason in + guard let self else { return } + await MainActor.run { + self.operatorConnected = false + self.talkMode.updateGatewayConnected(false) + } + GatewayDiagnostics.log("operator gateway disconnected reason=\(reason)") + await MainActor.run { self.stopGatewayHealthMonitor() } + }, + onInvoke: { req in + // Operator session should not handle node.invoke requests. + BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .invalidRequest, + message: "INVALID_REQUEST: operator session cannot invoke node commands")) + }) + + attempt = 0 + try? await Task.sleep(nanoseconds: 1_000_000_000) + } catch { + attempt += 1 + GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)") + let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt))) + try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000)) + } + } + } + } + + func startNodeGatewayLoop( + url: URL, + stableID: String, + token: String?, + password: String?, + nodeOptions: GatewayConnectOptions, + sessionBox: WebSocketSessionBox?) + { + self.nodeGatewayTask = Task { [weak self] in + guard let self else { return } + var attempt = 0 + var currentOptions = nodeOptions + var didFallbackClientId = false + var pausedForPairingApproval = false + + while !Task.isCancelled { + if self.gatewayPairingPaused { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } + if !self.gatewayAutoReconnectEnabled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } + if self.shouldPauseReconnectLoopInBackground(source: "node_loop") { try? await Task.sleep(nanoseconds: 2_000_000_000); continue } + if await self.isGatewayConnected() { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } + await MainActor.run { + self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…" + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + } + + do { + let epochMs = Int(Date().timeIntervalSince1970 * 1000) + GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)") + try await self.nodeGateway.connect( + url: url, + token: token, + password: password, + connectOptions: currentOptions, + sessionBox: sessionBox, + onConnected: { [weak self] in + guard let self else { return } + await MainActor.run { + self.gatewayStatusText = "Connected" + self.gatewayServerName = url.host ?? "gateway" + self.gatewayConnected = true + self.screen.errorText = nil + UserDefaults.standard.set(true, forKey: "gateway.autoconnect") + } + let relayData = await MainActor.run { + ( + sessionKey: self.mainSessionKey, + deliveryChannel: self.shareDeliveryChannel, + deliveryTo: self.shareDeliveryTo + ) + } + ShareGatewayRelaySettings.saveConfig( + ShareGatewayRelayConfig( + gatewayURLString: url.absoluteString, + token: token, + password: password, + sessionKey: relayData.sessionKey, + deliveryChannel: relayData.deliveryChannel, + deliveryTo: relayData.deliveryTo)) + GatewayDiagnostics.log("gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")") + if let addr = await self.nodeGateway.currentRemoteAddress() { + await MainActor.run { self.gatewayRemoteAddress = addr } + } + await self.showA2UIOnConnectIfNeeded() + await self.onNodeGatewayConnected() + await MainActor.run { + SignificantLocationMonitor.startIfNeeded( + locationService: self.locationService, + locationMode: self.locationMode(), + gateway: self.nodeGateway, + beforeSend: { [weak self] in + await self?.handleSignificantLocationWakeIfNeeded() + }) + } + }, + onDisconnected: { [weak self] reason in + guard let self else { return } + await MainActor.run { + self.gatewayStatusText = "Disconnected: \(reason)" + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + self.gatewayConnected = false + self.showLocalCanvasOnDisconnect() + } + GatewayDiagnostics.log("gateway disconnected reason: \(reason)") + }, + onInvoke: { [weak self] req in + guard let self else { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "UNAVAILABLE: node not ready")) + } + return await self.handleInvoke(req) + }) + + attempt = 0 + try? await Task.sleep(nanoseconds: 1_000_000_000) + } catch { + if Task.isCancelled { break } + if !didFallbackClientId, + let fallbackClientId = self.legacyClientIdFallback( + currentClientId: currentOptions.clientId, + error: error) + { + didFallbackClientId = true + currentOptions.clientId = fallbackClientId + GatewaySettingsStore.saveGatewayClientIdOverride( + stableID: stableID, + clientId: fallbackClientId) + await MainActor.run { self.gatewayStatusText = "Gateway rejected client id. Retrying…" } + continue + } + + attempt += 1 + await MainActor.run { + self.gatewayStatusText = "Gateway error: \(error.localizedDescription)" + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + self.gatewayConnected = false + self.showLocalCanvasOnDisconnect() + } + GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)") + + // If auth is missing/rejected, pause reconnect churn until the user intervenes. + // Reconnect loops only spam the same failing handshake and make onboarding noisy. + let lower = error.localizedDescription.lowercased() + if lower.contains("unauthorized") || lower.contains("gateway token missing") { + await MainActor.run { + self.gatewayAutoReconnectEnabled = false + } + } + + // If pairing is required, stop reconnect churn. The user must approve the request + // on the gateway before another connect attempt will succeed, and retry loops can + // generate multiple pending requests. + if lower.contains("not_paired") || lower.contains("pairing required") { + let requestId: String? = { + // GatewayResponseError for connect decorates the message with `(requestId: ...)`. + // Keep this resilient since other layers may wrap the text. + let text = error.localizedDescription + guard let start = text.range(of: "(requestId: ")?.upperBound else { return nil } + guard let end = text[start...].firstIndex(of: ")") else { return nil } + let raw = String(text[start.. GatewayConnectOptions { + GatewayConnectOptions( + role: "operator", + scopes: ["operator.read", "operator.write", "operator.talk.secrets"], + caps: [], + commands: [], + permissions: [:], + clientId: clientId, + clientMode: "ui", + clientDisplayName: displayName, + includeDeviceIdentity: true) + } + + func legacyClientIdFallback(currentClientId: String, error: Error) -> String? { + let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalizedClientId == "openclaw-ios" else { return nil } + let message = error.localizedDescription.lowercased() + guard message.contains("invalid connect params"), message.contains("/client/id") else { + return nil + } + return "moltbot-ios" + } + + func isOperatorConnected() async -> Bool { + self.operatorConnected + } +} + +extension NodeAppModel { + private func refreshShareRouteFromGateway() async { + struct Params: Codable { + var includeGlobal: Bool + var includeUnknown: Bool + var limit: Int + } + struct SessionRow: Decodable { + var key: String + var updatedAt: Double? + var lastChannel: String? + var lastTo: String? + } + struct SessionsListResult: Decodable { + var sessions: [SessionRow] + } + + let normalize: (String?) -> String? = { raw in + let value = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + do { + let data = try JSONEncoder().encode( + Params(includeGlobal: true, includeUnknown: false, limit: 80)) + guard let json = String(data: data, encoding: .utf8) else { return } + let response = try await self.operatorGateway.request( + method: "sessions.list", + paramsJSON: json, + timeoutSeconds: 10) + let decoded = try JSONDecoder().decode(SessionsListResult.self, from: response) + let currentKey = self.mainSessionKey + let sorted = decoded.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } + let exactMatch = sorted.first { row in + row.key == currentKey && normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil + } + let selected = exactMatch + let channel = normalize(selected?.lastChannel) + let to = normalize(selected?.lastTo) + + await MainActor.run { + self.shareDeliveryChannel = channel + self.shareDeliveryTo = to + if let relay = ShareGatewayRelaySettings.loadConfig() { + ShareGatewayRelaySettings.saveConfig( + ShareGatewayRelayConfig( + gatewayURLString: relay.gatewayURLString, + token: relay.token, + password: relay.password, + sessionKey: self.mainSessionKey, + deliveryChannel: channel, + deliveryTo: to)) + } + } + } catch { + // Best-effort only. + } + } + + func runSharePipelineSelfTest() async { + self.recordShareEvent("Share self-test running…") + + let payload = SharedContentPayload( + title: "OpenClaw Share Self-Test", + url: URL(string: "https://openclaw.ai/share-self-test"), + text: "Validate iOS share->deep-link->gateway forwarding.") + guard let deepLink = ShareToAgentDeepLink.buildURL( + from: payload, + instruction: "Reply with: SHARE SELF-TEST OK") + else { + self.recordShareEvent("Self-test failed: could not build deep link.") + return + } + + await self.handleDeepLink(url: deepLink) + } + + func refreshLastShareEventFromRelay() { + if let event = ShareGatewayRelaySettings.loadLastEvent() { + self.lastShareEventText = event + } + } + + func recordShareEvent(_ text: String) { + ShareGatewayRelaySettings.saveLastEvent(text) + self.refreshLastShareEventFromRelay() + } + + func reloadTalkConfig() { + Task { [weak self] in + await self?.talkMode.reloadConfig() + } + } + + /// Back-compat hook retained for older gateway-connect flows. + func onNodeGatewayConnected() async { + await self.registerAPNsTokenIfNeeded() + await self.flushQueuedWatchRepliesIfConnected() + } + + private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async { + let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines) + let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines) + if replyId.isEmpty || actionId.isEmpty { + self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId") + return + } + + if self.seenWatchReplyIds.contains(replyId) { + self.watchReplyLogger.debug( + "watch reply deduped replyId=\(replyId, privacy: .public)") + return + } + self.seenWatchReplyIds.insert(replyId) + + if await !self.isGatewayConnected() { + self.queuedWatchReplies.append(event) + self.watchReplyLogger.info( + "watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)") + return + } + + await self.forwardWatchReplyToAgent(event) + } + + private func flushQueuedWatchRepliesIfConnected() async { + guard await self.isGatewayConnected() else { return } + guard !self.queuedWatchReplies.isEmpty else { return } + + let pending = self.queuedWatchReplies + self.queuedWatchReplies.removeAll() + for event in pending { + await self.forwardWatchReplyToAgent(event) + } + } + + private func forwardWatchReplyToAgent(_ event: WatchQuickReplyEvent) async { + let sessionKey = event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) + let effectiveSessionKey = (sessionKey?.isEmpty == false) ? sessionKey : self.mainSessionKey + let message = Self.makeWatchReplyAgentMessage(event) + let link = AgentDeepLink( + message: message, + sessionKey: effectiveSessionKey, + thinking: "low", + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: nil, + key: event.replyId) + do { + try await self.sendAgentRequest(link: link) + self.watchReplyLogger.info( + "watch reply forwarded replyId=\(event.replyId, privacy: .public) action=\(event.actionId, privacy: .public)") + self.openChatRequestID &+= 1 + } catch { + self.watchReplyLogger.error( + "watch reply forwarding failed replyId=\(event.replyId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + self.queuedWatchReplies.insert(event, at: 0) + } + } + + private static func makeWatchReplyAgentMessage(_ event: WatchQuickReplyEvent) -> String { + let actionLabel = event.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines) + let promptId = event.promptId.trimmingCharacters(in: .whitespacesAndNewlines) + let transport = event.transport.trimmingCharacters(in: .whitespacesAndNewlines) + let summary = actionLabel?.isEmpty == false ? actionLabel! : event.actionId + var lines: [String] = [] + lines.append("Watch reply: \(summary)") + lines.append("promptId=\(promptId.isEmpty ? "unknown" : promptId)") + lines.append("actionId=\(event.actionId)") + lines.append("replyId=\(event.replyId)") + if !transport.isEmpty { + lines.append("transport=\(transport)") + } + if let sentAtMs = event.sentAtMs { + lines.append("sentAtMs=\(sentAtMs)") + } + if let note = event.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { + lines.append("note=\(note)") + } + return lines.joined(separator: "\n") + } + + func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool { + let wakeId = Self.makePushWakeAttemptID() + guard Self.isSilentPushPayload(userInfo) else { + self.pushWakeLogger.info("Ignored APNs payload wakeId=\(wakeId, privacy: .public): not silent push") + return false + } + let pushKind = Self.openclawPushKind(userInfo) + self.pushWakeLogger.info( + "Silent push received wakeId=\(wakeId, privacy: .public) kind=\(pushKind, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)") + let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + self.pushWakeLogger.info( + "Silent push outcome wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)") + return result.applied + } + + func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool { + let wakeId = Self.makePushWakeAttemptID() + self.pushWakeLogger.info( + "Background refresh wake received wakeId=\(wakeId, privacy: .public) trigger=\(trigger, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)") + let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + self.pushWakeLogger.info( + "Background refresh wake outcome wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)") + return result.applied + } + + func handleSignificantLocationWakeIfNeeded() async { + let wakeId = Self.makePushWakeAttemptID() + let now = Date() + let throttleWindowSeconds: TimeInterval = 180 + + if await self.isGatewayConnected() { + self.locationWakeLogger.info( + "Location wake no-op wakeId=\(wakeId, privacy: .public): already connected") + return + } + if let last = self.lastSignificantLocationWakeAt, + now.timeIntervalSince(last) < throttleWindowSeconds + { + self.locationWakeLogger.info( + "Location wake throttled wakeId=\(wakeId, privacy: .public) elapsedSec=\(now.timeIntervalSince(last), privacy: .public)") + return + } + self.lastSignificantLocationWakeAt = now + + self.locationWakeLogger.info( + "Location wake begin wakeId=\(wakeId, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)") + let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + self.locationWakeLogger.info( + "Location wake trigger wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)") + + guard result.applied else { return } + let connected = await self.waitForGatewayConnection(timeoutMs: 5000, pollMs: 250) + self.locationWakeLogger.info( + "Location wake post-check wakeId=\(wakeId, privacy: .public) connected=\(connected, privacy: .public)") + } + + func updateAPNsDeviceToken(_ tokenData: Data) { + let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() + let trimmed = tokenHex.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + self.apnsDeviceTokenHex = trimmed + UserDefaults.standard.set(trimmed, forKey: Self.apnsDeviceTokenUserDefaultsKey) + Task { [weak self] in + await self?.registerAPNsTokenIfNeeded() + } + } + + private func registerAPNsTokenIfNeeded() async { + guard self.gatewayConnected else { return } + guard let token = self.apnsDeviceTokenHex?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty + else { + return + } + if token == self.apnsLastRegisteredTokenHex { + return + } + guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !topic.isEmpty + else { + return + } + + struct PushRegistrationPayload: Codable { + var token: String + var topic: String + var environment: String + } + + let payload = PushRegistrationPayload( + token: token, + topic: topic, + environment: Self.apnsEnvironment) + do { + let json = try Self.encodePayload(payload) + await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: json) + self.apnsLastRegisteredTokenHex = token + } catch { + // Best-effort only. + } + } + + private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool { + guard let apsAny = userInfo["aps"] else { return false } + if let aps = apsAny as? [AnyHashable: Any] { + return Self.hasContentAvailable(aps["content-available"]) + } + if let aps = apsAny as? [String: Any] { + return Self.hasContentAvailable(aps["content-available"]) + } + return false + } + + private static func hasContentAvailable(_ value: Any?) -> Bool { + if let number = value as? NSNumber { + return number.intValue == 1 + } + if let text = value as? String { + return text.trimmingCharacters(in: .whitespacesAndNewlines) == "1" + } + return false + } + + private static func makePushWakeAttemptID() -> String { + let raw = UUID().uuidString.replacingOccurrences(of: "-", with: "") + return String(raw.prefix(8)) + } + + private static func openclawPushKind(_ userInfo: [AnyHashable: Any]) -> String { + if let payload = userInfo["openclaw"] as? [String: Any], + let kind = payload["kind"] as? String + { + let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + if let payload = userInfo["openclaw"] as? [AnyHashable: Any], + let kind = payload["kind"] as? String + { + let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + return "unknown" + } + + private struct SilentPushWakeAttemptResult { + var applied: Bool + var reason: String + var durationMs: Int + } + + private func waitForGatewayConnection(timeoutMs: Int, pollMs: Int) async -> Bool { + let clampedTimeoutMs = max(0, timeoutMs) + let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000 + let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0) + while Date() < deadline { + if await self.isGatewayConnected() { + return true + } + try? await Task.sleep(nanoseconds: pollIntervalNs) + } + return await self.isGatewayConnected() + } + + private func reconnectGatewaySessionsForSilentPushIfNeeded( + wakeId: String + ) async -> SilentPushWakeAttemptResult { + let startedAt = Date() + let makeResult: (Bool, String) -> SilentPushWakeAttemptResult = { applied, reason in + let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000) + return SilentPushWakeAttemptResult( + applied: applied, + reason: reason, + durationMs: max(0, durationMs)) + } + + guard self.isBackgrounded else { + self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): app not backgrounded") + return makeResult(false, "not_backgrounded") + } + guard self.gatewayAutoReconnectEnabled else { + self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): auto reconnect disabled") + return makeResult(false, "auto_reconnect_disabled") + } + guard let cfg = self.activeGatewayConnectConfig else { + self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): no active gateway config") + return makeResult(false, "no_active_gateway_config") + } + + self.pushWakeLogger.info( + "Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)") + self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)") + await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() + self.operatorConnected = false + self.gatewayConnected = false + self.gatewayStatusText = "Reconnecting…" + self.talkMode.updateGatewayConnected(false) + self.applyGatewayConnectConfig(cfg) + self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)") + return makeResult(true, "reconnect_triggered") + } +} + +extension NodeAppModel { + private func refreshWakeWordsFromGateway() async { + do { + let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) + guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } + VoiceWakePreferences.saveTriggerWords(triggers) + } catch { + if let gatewayError = error as? GatewayResponseError { + let lower = gatewayError.message.lowercased() + if lower.contains("unauthorized role") || lower.contains("missing scope") { + await self.setGatewayHealthMonitorDisabled(true) + return + } + } + // Best-effort only. + } + } + + private func isGatewayHealthMonitorDisabled() -> Bool { + self.gatewayHealthMonitorDisabled + } + + private func setGatewayHealthMonitorDisabled(_ disabled: Bool) { + self.gatewayHealthMonitorDisabled = disabled + } + + func sendVoiceTranscript(text: String, sessionKey: String?) async throws { + if await !self.isGatewayConnected() { + throw NSError(domain: "Gateway", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "Gateway not connected", + ]) + } + struct Payload: Codable { + var text: String + var sessionKey: String? + } + let payload = Payload(text: text, sessionKey: sessionKey) + let data = try JSONEncoder().encode(payload) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", + ]) + } + await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json) + } + + func handleDeepLink(url: URL) async { + guard let route = DeepLinkParser.parse(url) else { return } + + switch route { + case let .agent(link): + await self.handleAgentDeepLink(link, originalURL: url) + case .gateway: + break + } + } + + private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async { + let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { return } + self.deepLinkLogger.info( + "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)" + ) + + if message.count > IOSDeepLinkAgentPolicy.maxMessageChars { + self.screen.errorText = "Deep link too large (message exceeds \(IOSDeepLinkAgentPolicy.maxMessageChars) characters)." + self.recordShareEvent("Rejected: message too large (\(message.count) chars).") + return + } + + guard await self.isGatewayConnected() else { + self.screen.errorText = "Gateway not connected (cannot forward deep link)." + self.recordShareEvent("Failed: gateway not connected.") + self.deepLinkLogger.error("agent deep link rejected: gateway not connected") + return + } + + let allowUnattended = self.isUnattendedDeepLinkAllowed(link.key) + if !allowUnattended { + if message.count > IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars { + self.screen.errorText = "Deep link blocked (message too long without key)." + self.recordShareEvent( + "Rejected: deep link over \(IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars) chars without key.") + self.deepLinkLogger.error( + "agent deep link rejected: unkeyed message too long chars=\(message.count, privacy: .public)") + return + } + if Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) < 1.0 { + self.deepLinkLogger.debug("agent deep link prompt throttled") + return + } + self.lastAgentDeepLinkPromptAt = Date() + + let urlText = originalURL.absoluteString + let prompt = AgentDeepLinkPrompt( + id: UUID().uuidString, + messagePreview: message, + urlPreview: urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText, + request: self.effectiveAgentDeepLinkForPrompt(link)) + self.pendingAgentDeepLinkPrompt = prompt + self.recordShareEvent("Awaiting local confirmation (\(message.count) chars).") + self.deepLinkLogger.info("agent deep link requires local confirmation") + return + } + + await self.submitAgentDeepLink(link, messageCharCount: message.count) + } + + private func sendAgentRequest(link: AgentDeepLink) async throws { + if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw NSError(domain: "DeepLink", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "invalid agent message", + ]) + } + + let data = try JSONEncoder().encode(link) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", + ]) + } + await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json) + } + + private func isGatewayConnected() async -> Bool { + self.gatewayConnected + } + + private func applyMainSessionKey(_ key: String?) { + let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == current { return } + self.mainSessionBaseKey = trimmed + self.talkMode.updateMainSessionKey(self.mainSessionKey) + } + + private static func color(fromHex raw: String?) -> Color? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } + let r = Double((value >> 16) & 0xFF) / 255.0 + let g = Double((value >> 8) & 0xFF) / 255.0 + let b = Double(value & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } + + func approvePendingAgentDeepLinkPrompt() async { + guard let prompt = self.pendingAgentDeepLinkPrompt else { return } + self.pendingAgentDeepLinkPrompt = nil + guard await self.isGatewayConnected() else { + self.screen.errorText = "Gateway not connected (cannot forward deep link)." + self.recordShareEvent("Failed: gateway not connected.") + self.deepLinkLogger.error("agent deep link approval failed: gateway not connected") + return + } + await self.submitAgentDeepLink(prompt.request, messageCharCount: prompt.messagePreview.count) + } + + func declinePendingAgentDeepLinkPrompt() { + guard self.pendingAgentDeepLinkPrompt != nil else { return } + self.pendingAgentDeepLinkPrompt = nil + self.screen.errorText = "Deep link cancelled." + self.recordShareEvent("Cancelled: deep link confirmation declined.") + self.deepLinkLogger.info("agent deep link cancelled by local user") + } + + private func submitAgentDeepLink(_ link: AgentDeepLink, messageCharCount: Int) async { + do { + try await self.sendAgentRequest(link: link) + self.screen.errorText = nil + self.recordShareEvent("Sent to gateway (\(messageCharCount) chars).") + self.deepLinkLogger.info("agent deep link forwarded to gateway") + self.openChatRequestID &+= 1 + } catch { + self.screen.errorText = "Agent request failed: \(error.localizedDescription)" + self.recordShareEvent("Failed: \(error.localizedDescription)") + self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func effectiveAgentDeepLinkForPrompt(_ link: AgentDeepLink) -> AgentDeepLink { + // Without a trusted key, strip delivery/routing knobs to reduce exfiltration risk. + AgentDeepLink( + message: link.message, + sessionKey: link.sessionKey, + thinking: link.thinking, + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: link.timeoutSeconds, + key: link.key) + } + + private func isUnattendedDeepLinkAllowed(_ key: String?) -> Bool { + let normalizedKey = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !normalizedKey.isEmpty else { return false } + return normalizedKey == Self.canvasUnattendedDeepLinkKey || normalizedKey == Self.expectedDeepLinkKey() + } + + private static func expectedDeepLinkKey() -> String { + let defaults = UserDefaults.standard + if let key = defaults.string(forKey: self.deepLinkKeyUserDefaultsKey), !key.isEmpty { + return key + } + let key = self.generateDeepLinkKey() + defaults.set(key, forKey: self.deepLinkKeyUserDefaultsKey) + return key + } + + private static func generateDeepLinkKey() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let data = Data(bytes) + return data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +extension NodeAppModel { + func _bridgeConsumeMirroredWatchReply(_ event: WatchQuickReplyEvent) async { + await self.handleWatchQuickReply(event) + } +} + +#if DEBUG +extension NodeAppModel { + func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { + await self.handleInvoke(req) + } + + static func _test_decodeParams(_ type: T.Type, from json: String?) throws -> T { + try self.decodeParams(type, from: json) + } + + static func _test_encodePayload(_ obj: some Encodable) throws -> String { + try self.encodePayload(obj) + } + + func _test_isCameraEnabled() -> Bool { + self.isCameraEnabled() + } + + func _test_triggerCameraFlash() { + self.triggerCameraFlash() + } + + func _test_showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) { + self.showCameraHUD(text: text, kind: kind, autoHideSeconds: autoHideSeconds) + } + + func _test_handleCanvasA2UIAction(body: [String: Any]) async { + await self.handleCanvasA2UIAction(body: body) + } + + func _test_showLocalCanvasOnDisconnect() { + self.showLocalCanvasOnDisconnect() + } + + func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) { + self.applyTalkModeSync(enabled: enabled, phase: phase) + } + + func _test_queuedWatchReplyCount() -> Int { + self.queuedWatchReplies.count + } + + func _test_setGatewayConnected(_ connected: Bool) { + self.gatewayConnected = connected + } + + static func _test_currentDeepLinkKey() -> String { + self.expectedDeepLinkKey() + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Motion/MotionService.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Motion/MotionService.swift new file mode 100644 index 00000000..f108e0b5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Motion/MotionService.swift @@ -0,0 +1,100 @@ +import CoreMotion +import Foundation +import OpenClawKit + +final class MotionService: MotionServicing { + func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload { + guard CMMotionActivityManager.isActivityAvailable() else { + throw NSError(domain: "Motion", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "MOTION_UNAVAILABLE: activity not supported on this device", + ]) + } + let auth = CMMotionActivityManager.authorizationStatus() + guard auth == .authorized else { + throw NSError(domain: "Motion", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "MOTION_PERMISSION_REQUIRED: grant Motion & Fitness permission", + ]) + } + + let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO) + let limit = max(1, min(params.limit ?? 200, 1000)) + + let manager = CMMotionActivityManager() + let mapped = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawMotionActivityEntry], Error>) in + manager.queryActivityStarting(from: start, to: end, to: OperationQueue()) { activity, error in + if let error { + cont.resume(throwing: error) + } else { + let formatter = ISO8601DateFormatter() + let sliced = Array((activity ?? []).suffix(limit)) + let entries = sliced.map { entry in + OpenClawMotionActivityEntry( + startISO: formatter.string(from: entry.startDate), + endISO: formatter.string(from: end), + confidence: Self.confidenceString(entry.confidence), + isWalking: entry.walking, + isRunning: entry.running, + isCycling: entry.cycling, + isAutomotive: entry.automotive, + isStationary: entry.stationary, + isUnknown: entry.unknown) + } + cont.resume(returning: entries) + } + } + } + + return OpenClawMotionActivityPayload(activities: mapped) + } + + func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload { + guard CMPedometer.isStepCountingAvailable() else { + throw NSError(domain: "Motion", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "PEDOMETER_UNAVAILABLE: step counting not supported", + ]) + } + let auth = CMPedometer.authorizationStatus() + guard auth == .authorized else { + throw NSError(domain: "Motion", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "MOTION_PERMISSION_REQUIRED: grant Motion & Fitness permission", + ]) + } + + let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO) + let pedometer = CMPedometer() + let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + pedometer.queryPedometerData(from: start, to: end) { data, error in + if let error { + cont.resume(throwing: error) + } else { + let formatter = ISO8601DateFormatter() + let payload = OpenClawPedometerPayload( + startISO: formatter.string(from: start), + endISO: formatter.string(from: end), + steps: data?.numberOfSteps.intValue, + distanceMeters: data?.distance?.doubleValue, + floorsAscended: data?.floorsAscended?.intValue, + floorsDescended: data?.floorsDescended?.intValue) + cont.resume(returning: payload) + } + } + } + return payload + } + + private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) { + let formatter = ISO8601DateFormatter() + let start = startISO.flatMap { formatter.date(from: $0) } ?? Calendar.current.startOfDay(for: Date()) + let end = endISO.flatMap { formatter.date(from: $0) } ?? Date() + return (start, end) + } + + private static func confidenceString(_ confidence: CMMotionActivityConfidence) -> String { + switch confidence { + case .low: "low" + case .medium: "medium" + case .high: "high" + @unknown default: "unknown" + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift new file mode 100644 index 00000000..bf6c0ba2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift @@ -0,0 +1,354 @@ +import Foundation +import SwiftUI + +struct GatewayOnboardingView: View { + var body: some View { + NavigationStack { + List { + Section { + Text("Connect to your gateway to get started.") + .foregroundStyle(.secondary) + } + + Section { + NavigationLink("Auto detect") { + AutoDetectStep() + } + NavigationLink("Manual entry") { + ManualEntryStep() + } + } + } + .navigationTitle("Connect Gateway") + } + .gatewayTrustPromptAlert() + } +} + +private struct AutoDetectStep: View { + @Environment(NodeAppModel.self) private var appModel: NodeAppModel + @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController + @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" + @AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = "" + + @State private var connectingGatewayID: String? + @State private var connectStatusText: String? + + var body: some View { + Form { + Section { + Text("We’ll scan for gateways on your network and connect automatically when we find one.") + .foregroundStyle(.secondary) + } + + Section("Connection status") { + ConnectionStatusBox( + statusLines: self.connectionStatusLines(), + secondaryLine: self.connectStatusText) + } + + Section { + Button("Retry") { + self.resetConnectionState() + self.triggerAutoConnect() + } + .disabled(self.connectingGatewayID != nil) + } + } + .navigationTitle("Auto detect") + .onAppear { self.triggerAutoConnect() } + .onChange(of: self.gatewayController.gateways) { _, _ in + self.triggerAutoConnect() + } + } + + private func triggerAutoConnect() { + guard self.appModel.gatewayServerName == nil else { return } + guard self.connectingGatewayID == nil else { return } + guard let candidate = self.autoCandidate() else { return } + + self.connectingGatewayID = candidate.id + Task { + defer { self.connectingGatewayID = nil } + await self.gatewayController.connect(candidate) + } + } + + private func autoCandidate() -> GatewayDiscoveryModel.DiscoveredGateway? { + let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) + let lastDiscovered = self.lastDiscoveredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) + + if !preferred.isEmpty, + let match = self.gatewayController.gateways.first(where: { $0.stableID == preferred }) + { + return match + } + if !lastDiscovered.isEmpty, + let match = self.gatewayController.gateways.first(where: { $0.stableID == lastDiscovered }) + { + return match + } + if self.gatewayController.gateways.count == 1 { + return self.gatewayController.gateways.first + } + return nil + } + + private func connectionStatusLines() -> [String] { + ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController) + } + + private func resetConnectionState() { + self.appModel.disconnectGateway() + self.connectStatusText = nil + self.connectingGatewayID = nil + } +} + +private struct ManualEntryStep: View { + @Environment(NodeAppModel.self) private var appModel: NodeAppModel + @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController + + @State private var setupCode: String = "" + @State private var setupStatusText: String? + @State private var manualHost: String = "" + @State private var manualPortText: String = "" + @State private var manualUseTLS: Bool = true + @State private var manualToken: String = "" + @State private var manualPassword: String = "" + + @State private var connectingGatewayID: String? + @State private var connectStatusText: String? + + var body: some View { + Form { + Section("Setup code") { + Text("Use /pair in your bot to get a setup code.") + .font(.footnote) + .foregroundStyle(.secondary) + + TextField("Paste setup code", text: self.$setupCode) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button("Apply setup code") { + self.applySetupCode() + } + .disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if let setupStatusText, !setupStatusText.isEmpty { + Text(setupStatusText) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Section { + TextField("Host", text: self.$manualHost) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + TextField("Port", text: self.$manualPortText) + .keyboardType(.numberPad) + + Toggle("Use TLS", isOn: self.$manualUseTLS) + + TextField("Gateway token", text: self.$manualToken) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + SecureField("Gateway password", text: self.$manualPassword) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + Section("Connection status") { + ConnectionStatusBox( + statusLines: self.connectionStatusLines(), + secondaryLine: self.connectStatusText) + } + + Section { + Button { + Task { await self.connectManual() } + } label: { + if self.connectingGatewayID == "manual" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect") + } + } + .disabled(self.connectingGatewayID != nil) + + Button("Retry") { + self.resetConnectionState() + self.resetManualForm() + } + .disabled(self.connectingGatewayID != nil) + } + } + .navigationTitle("Manual entry") + } + + private func connectManual() async { + let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { + self.connectStatusText = "Failed: host required" + return + } + + if let port = self.manualPortValue(), !(1...65535).contains(port) { + self.connectStatusText = "Failed: invalid port" + return + } + + let defaults = UserDefaults.standard + defaults.set(true, forKey: "gateway.manual.enabled") + defaults.set(host, forKey: "gateway.manual.host") + defaults.set(self.manualPortValue() ?? 0, forKey: "gateway.manual.port") + defaults.set(self.manualUseTLS, forKey: "gateway.manual.tls") + + if let instanceId = defaults.string(forKey: "node.instanceId")?.trimmingCharacters(in: .whitespacesAndNewlines), + !instanceId.isEmpty + { + let trimmedToken = self.manualToken.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedPassword = self.manualPassword.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedToken.isEmpty { + GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: instanceId) + } + GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: instanceId) + } + + self.connectingGatewayID = "manual" + defer { self.connectingGatewayID = nil } + await self.gatewayController.connectManual( + host: host, + port: self.manualPortValue() ?? 0, + useTLS: self.manualUseTLS) + } + + private func manualPortValue() -> Int? { + let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return Int(trimmed.filter { $0.isNumber }) + } + + private func connectionStatusLines() -> [String] { + ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController) + } + + private func resetConnectionState() { + self.appModel.disconnectGateway() + self.connectStatusText = nil + self.connectingGatewayID = nil + } + + private func resetManualForm() { + self.setupCode = "" + self.setupStatusText = nil + self.manualHost = "" + self.manualPortText = "" + self.manualUseTLS = true + self.manualToken = "" + self.manualPassword = "" + } + + private func applySetupCode() { + let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { + self.setupStatusText = "Paste a setup code to continue." + return + } + + guard let payload = GatewaySetupCode.decode(raw: raw) else { + self.setupStatusText = "Setup code not recognized." + return + } + + if let urlString = payload.url, let url = URL(string: urlString) { + self.applyURL(url) + } else if let host = payload.host, !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.manualHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + if let port = payload.port { + self.manualPortText = String(port) + } else { + self.manualPortText = "" + } + if let tls = payload.tls { + self.manualUseTLS = tls + } + } else if let url = URL(string: raw), url.scheme != nil { + self.applyURL(url) + } else { + self.setupStatusText = "Setup code missing URL or host." + return + } + + if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + } + if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) + } + + self.setupStatusText = "Setup code applied." + } + + private func applyURL(_ url: URL) { + guard let host = url.host, !host.isEmpty else { return } + self.manualHost = host + if let port = url.port { + self.manualPortText = String(port) + } else { + self.manualPortText = "" + } + let scheme = (url.scheme ?? "").lowercased() + if scheme == "wss" || scheme == "https" { + self.manualUseTLS = true + } else if scheme == "ws" || scheme == "http" { + self.manualUseTLS = false + } + } + + // (GatewaySetupCode) decode raw setup codes. +} + +private struct ConnectionStatusBox: View { + let statusLines: [String] + let secondaryLine: String? + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(self.statusLines, id: \.self) { line in + Text(line) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .foregroundStyle(.secondary) + } + if let secondaryLine, !secondaryLine.isEmpty { + Text(secondaryLine) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + static func defaultLines( + appModel: NodeAppModel, + gatewayController: GatewayConnectionController + ) -> [String] { + var lines: [String] = [ + "gateway: \(appModel.gatewayStatusText)", + "discovery: \(gatewayController.discoveryStatusText)", + ] + lines.append("server: \(appModel.gatewayServerName ?? "—")") + lines.append("address: \(appModel.gatewayRemoteAddress ?? "—")") + return lines + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Onboarding/OnboardingStateStore.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Onboarding/OnboardingStateStore.swift new file mode 100644 index 00000000..9822ac17 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Onboarding/OnboardingStateStore.swift @@ -0,0 +1,52 @@ +import Foundation + +enum OnboardingConnectionMode: String, CaseIterable { + case homeNetwork = "home_network" + case remoteDomain = "remote_domain" + case developerLocal = "developer_local" + + var title: String { + switch self { + case .homeNetwork: + "Home Network" + case .remoteDomain: + "Remote Domain" + case .developerLocal: + "Same Machine (Dev)" + } + } +} + +enum OnboardingStateStore { + private static let completedDefaultsKey = "onboarding.completed" + private static let lastModeDefaultsKey = "onboarding.last_mode" + private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time" + + @MainActor + static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool { + if defaults.bool(forKey: Self.completedDefaultsKey) { return false } + // If we have a last-known connection config, don't force onboarding on launch. Auto-connect + // should handle reconnecting, and users can always open onboarding manually if needed. + if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false } + return appModel.gatewayServerName == nil + } + + static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) { + defaults.set(true, forKey: Self.completedDefaultsKey) + if let mode { + defaults.set(mode.rawValue, forKey: Self.lastModeDefaultsKey) + } + defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey) + } + + static func markIncomplete(defaults: UserDefaults = .standard) { + defaults.set(false, forKey: Self.completedDefaultsKey) + } + + static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? { + let raw = defaults.string(forKey: Self.lastModeDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !raw.isEmpty else { return nil } + return OnboardingConnectionMode(rawValue: raw) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Onboarding/OnboardingWizardView.swift new file mode 100644 index 00000000..c0e872b2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -0,0 +1,890 @@ +import CoreImage +import Combine +import OpenClawKit +import PhotosUI +import SwiftUI +import UIKit + +private enum OnboardingStep: Int, CaseIterable { + case welcome + case mode + case connect + case auth + case success + + var previous: Self? { + Self(rawValue: self.rawValue - 1) + } + + var next: Self? { + Self(rawValue: self.rawValue + 1) + } + + /// Progress label for the manual setup flow (mode → connect → auth → success). + var manualProgressTitle: String { + let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success] + guard let idx = manualSteps.firstIndex(of: self) else { return "" } + return "Step \(idx + 1) of \(manualSteps.count)" + } + + var title: String { + switch self { + case .welcome: "Welcome" + case .mode: "Connection Mode" + case .connect: "Connect" + case .auth: "Authentication" + case .success: "Connected" + } + } + + var canGoBack: Bool { + self != .welcome && self != .success + } +} + +struct OnboardingWizardView: View { + @Environment(NodeAppModel.self) private var appModel: NodeAppModel + @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController + @Environment(\.scenePhase) private var scenePhase + @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString + @AppStorage("gateway.discovery.domain") private var discoveryDomain: String = "" + @AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false + @State private var step: OnboardingStep = .welcome + @State private var selectedMode: OnboardingConnectionMode? + @State private var manualHost: String = "" + @State private var manualPort: Int = 18789 + @State private var manualPortText: String = "18789" + @State private var manualTLS: Bool = true + @State private var gatewayToken: String = "" + @State private var gatewayPassword: String = "" + @State private var connectMessage: String? + @State private var statusLine: String = "Scan the QR code from your gateway to connect." + @State private var connectingGatewayID: String? + @State private var issue: GatewayConnectionIssue = .none + @State private var didMarkCompleted = false + @State private var didAutoPresentQR = false + @State private var pairingRequestId: String? + @State private var discoveryRestartTask: Task? + @State private var showQRScanner: Bool = false + @State private var scannerError: String? + @State private var selectedPhoto: PhotosPickerItem? + @State private var lastPairingAutoResumeAttemptAt: Date? + private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect() + + let allowSkip: Bool + let onClose: () -> Void + + private var isFullScreenStep: Bool { + self.step == .welcome || self.step == .success + } + + var body: some View { + NavigationStack { + Group { + switch self.step { + case .welcome: + self.welcomeStep + case .success: + self.successStep + default: + Form { + switch self.step { + case .mode: + self.modeStep + case .connect: + self.connectStep + case .auth: + self.authStep + default: + EmptyView() + } + } + .scrollDismissesKeyboard(.interactively) + } + } + .navigationTitle(self.isFullScreenStep ? "" : self.step.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if !self.isFullScreenStep { + ToolbarItem(placement: .principal) { + VStack(spacing: 2) { + Text(self.step.title) + .font(.headline) + Text(self.step.manualProgressTitle) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + ToolbarItem(placement: .topBarLeading) { + if self.step.canGoBack { + Button { + self.navigateBack() + } label: { + Label("Back", systemImage: "chevron.left") + } + } else if self.allowSkip { + Button("Close") { + self.onClose() + } + } + } + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, from: nil, for: nil) + } + } + } + } + .gatewayTrustPromptAlert() + .alert("QR Scanner Unavailable", isPresented: Binding( + get: { self.scannerError != nil }, + set: { if !$0 { self.scannerError = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(self.scannerError ?? "") + } + .sheet(isPresented: self.$showQRScanner) { + NavigationStack { + QRScannerView( + onGatewayLink: { link in + self.handleScannedLink(link) + }, + onError: { error in + self.showQRScanner = false + self.statusLine = "Scanner error: \(error)" + self.scannerError = error + }, + onDismiss: { + self.showQRScanner = false + }) + .ignoresSafeArea() + .navigationTitle("Scan QR Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { self.showQRScanner = false } + } + ToolbarItem(placement: .topBarTrailing) { + PhotosPicker(selection: self.$selectedPhoto, matching: .images) { + Label("Photos", systemImage: "photo") + } + } + } + } + .onChange(of: self.selectedPhoto) { _, newValue in + guard let item = newValue else { return } + self.selectedPhoto = nil + Task { + guard let data = try? await item.loadTransferable(type: Data.self) else { + self.showQRScanner = false + self.scannerError = "Could not load the selected image." + return + } + if let message = self.detectQRCode(from: data) { + if let link = GatewayConnectDeepLink.fromSetupCode(message) { + self.handleScannedLink(link) + return + } + if let url = URL(string: message), + let route = DeepLinkParser.parse(url), + case let .gateway(link) = route + { + self.handleScannedLink(link) + return + } + } + self.showQRScanner = false + self.scannerError = "No valid QR code found in the selected image." + } + } + } + .onAppear { + self.initializeState() + } + .onDisappear { + self.discoveryRestartTask?.cancel() + self.discoveryRestartTask = nil + } + .onChange(of: self.discoveryDomain) { _, _ in + self.scheduleDiscoveryRestart() + } + .onChange(of: self.manualPortText) { _, newValue in + let digits = newValue.filter(\.isNumber) + if digits != newValue { + self.manualPortText = digits + return + } + guard let parsed = Int(digits), parsed > 0 else { + self.manualPort = 0 + return + } + self.manualPort = min(parsed, 65535) + } + .onChange(of: self.manualPort) { _, newValue in + let normalized = newValue > 0 ? String(newValue) : "" + if self.manualPortText != normalized { + self.manualPortText = normalized + } + } + .onChange(of: self.gatewayToken) { _, newValue in + self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword) + } + .onChange(of: self.gatewayPassword) { _, newValue in + self.saveGatewayCredentials(token: self.gatewayToken, password: newValue) + } + .onChange(of: self.appModel.gatewayStatusText) { _, newValue in + let next = GatewayConnectionIssue.detect(from: newValue) + // Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection + // transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns). + if self.issue.needsPairing, next.needsPairing { + // Keep the requestId sticky even if the status line omits it after we pause. + let mergedRequestId = next.requestId ?? self.issue.requestId ?? self.pairingRequestId + self.issue = .pairingRequired(requestId: mergedRequestId) + } else if self.issue.needsPairing, !next.needsPairing { + // Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect. + } else if self.issue.needsAuthToken, !next.needsAuthToken, !next.needsPairing { + // Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until + // the user retries/scans again or we successfully connect. + } else { + self.issue = next + } + + if let requestId = next.requestId, !requestId.isEmpty { + self.pairingRequestId = requestId + } + + // If the gateway tells us auth is missing/rejected, stop reconnect churn until the user intervenes. + if next.needsAuthToken { + self.appModel.gatewayAutoReconnectEnabled = false + } + + if self.issue.needsAuthToken || self.issue.needsPairing { + self.step = .auth + } + if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.connectMessage = newValue + self.statusLine = newValue + } + } + .onChange(of: self.appModel.gatewayServerName) { _, newValue in + guard newValue != nil else { return } + self.showQRScanner = false + self.statusLine = "Connected." + if !self.didMarkCompleted, let selectedMode { + OnboardingStateStore.markCompleted(mode: selectedMode) + self.didMarkCompleted = true + } + self.onClose() + } + .onChange(of: self.scenePhase) { _, newValue in + guard newValue == .active else { return } + self.attemptAutomaticPairingResumeIfNeeded() + } + .onReceive(Self.pairingAutoResumeTicker) { _ in + self.attemptAutomaticPairingResumeIfNeeded() + } + } + + @ViewBuilder + private var welcomeStep: some View { + VStack(spacing: 0) { + Spacer() + + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 64)) + .foregroundStyle(.tint) + .padding(.bottom, 20) + + Text("Welcome") + .font(.largeTitle.weight(.bold)) + .padding(.bottom, 8) + + Text("Connect to your OpenClaw gateway") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Spacer() + + VStack(spacing: 12) { + Button { + self.statusLine = "Opening QR scanner…" + self.showQRScanner = true + } label: { + Label("Scan QR Code", systemImage: "qrcode") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Button { + self.step = .mode + } label: { + Text("Set Up Manually") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + } + .padding(.bottom, 12) + + Text(self.statusLine) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.horizontal, 24) + .padding(.bottom, 48) + } + } + + @ViewBuilder + private var modeStep: some View { + Section("Connection Mode") { + OnboardingModeRow( + title: OnboardingConnectionMode.homeNetwork.title, + subtitle: "LAN or Tailscale host", + selected: self.selectedMode == .homeNetwork) + { + self.selectMode(.homeNetwork) + } + + OnboardingModeRow( + title: OnboardingConnectionMode.remoteDomain.title, + subtitle: "VPS with domain", + selected: self.selectedMode == .remoteDomain) + { + self.selectMode(.remoteDomain) + } + + Toggle( + "Developer mode", + isOn: Binding( + get: { self.developerModeEnabled }, + set: { newValue in + self.developerModeEnabled = newValue + if !newValue, self.selectedMode == .developerLocal { + self.selectedMode = nil + } + })) + + if self.developerModeEnabled { + OnboardingModeRow( + title: OnboardingConnectionMode.developerLocal.title, + subtitle: "For local iOS app development", + selected: self.selectedMode == .developerLocal) + { + self.selectMode(.developerLocal) + } + } + } + + Section { + Button("Continue") { + self.step = .connect + } + .disabled(self.selectedMode == nil) + } + } + + @ViewBuilder + private var connectStep: some View { + if let selectedMode { + Section { + LabeledContent("Mode", value: selectedMode.title) + LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) + LabeledContent("Status", value: self.appModel.gatewayStatusText) + LabeledContent("Progress", value: self.statusLine) + } header: { + Text("Status") + } footer: { + if let connectMessage { + Text(connectMessage) + } + } + + switch selectedMode { + case .homeNetwork: + self.homeNetworkConnectSection + case .remoteDomain: + self.remoteDomainConnectSection + case .developerLocal: + self.developerConnectSection + } + } else { + Section { + Text("Choose a mode first.") + Button("Back to Mode Selection") { + self.step = .mode + } + } + } + } + + private var homeNetworkConnectSection: some View { + Group { + Section("Discovered Gateways") { + if self.gatewayController.gateways.isEmpty { + Text("No gateways found yet.") + .foregroundStyle(.secondary) + } else { + ForEach(self.gatewayController.gateways) { gateway in + let hasHost = self.gatewayHasResolvableHost(gateway) + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(gateway.name) + if let host = gateway.lanHost ?? gateway.tailnetDns { + Text(host) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + Spacer() + Button { + Task { await self.connectDiscoveredGateway(gateway) } + } label: { + if self.connectingGatewayID == gateway.id { + ProgressView() + .progressViewStyle(.circular) + } else if !hasHost { + Text("Resolving…") + } else { + Text("Connect") + } + } + .disabled(self.connectingGatewayID != nil || !hasHost) + } + } + } + + Button("Restart Discovery") { + self.gatewayController.restartDiscovery() + } + .disabled(self.connectingGatewayID != nil) + } + + self.manualConnectionFieldsSection(title: "Manual Fallback") + } + } + + private var remoteDomainConnectSection: some View { + self.manualConnectionFieldsSection(title: "Domain Settings") + } + + private var developerConnectSection: some View { + Section { + TextField("Host", text: self.$manualHost) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Port", text: self.$manualPortText) + .keyboardType(.numberPad) + Toggle("Use TLS", isOn: self.$manualTLS) + + Button { + Task { await self.connectManual() } + } label: { + if self.connectingGatewayID == "manual" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect") + } + } + .disabled(!self.canConnectManual || self.connectingGatewayID != nil) + } header: { + Text("Developer Local") + } footer: { + Text("Default host is localhost. Use your Mac LAN IP if simulator networking requires it.") + } + } + + private var authStep: some View { + Group { + Section("Authentication") { + TextField("Gateway Auth Token", text: self.$gatewayToken) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + SecureField("Gateway Password", text: self.$gatewayPassword) + + if self.issue.needsAuthToken { + Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Text("Auth token looks valid.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + if self.issue.needsPairing { + Section { + Button { + self.resumeAfterPairingApproval() + } label: { + Label("Resume After Approval", systemImage: "arrow.clockwise") + } + .disabled(self.connectingGatewayID != nil) + } header: { + Text("Pairing Approval") + } footer: { + let requestLine: String = { + if let id = self.issue.requestId, !id.isEmpty { + return "Request ID: \(id)" + } + return "Request ID: check `openclaw devices list`." + }() + Text( + "Approve this device on the gateway.\n" + + "1) `openclaw devices approve` (or `openclaw devices approve `)\n" + + "2) `/pair approve` in Telegram\n" + + "\(requestLine)\n" + + "OpenClaw will also retry automatically when you return to this app.") + } + } + + Section { + Button { + self.openQRScannerFromOnboarding() + } label: { + Label("Scan QR Code Again", systemImage: "qrcode.viewfinder") + } + .disabled(self.connectingGatewayID != nil) + + Button { + Task { await self.retryLastAttempt() } + } label: { + if self.connectingGatewayID == "retry" { + ProgressView() + .progressViewStyle(.circular) + } else { + Text("Retry Connection") + } + } + .disabled(self.connectingGatewayID != nil) + } + } + } + + private var successStep: some View { + VStack(spacing: 0) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(.green) + .padding(.bottom, 20) + + Text("Connected") + .font(.largeTitle.weight(.bold)) + .padding(.bottom, 8) + + let server = self.appModel.gatewayServerName ?? "gateway" + Text(server) + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.bottom, 4) + + if let addr = self.appModel.gatewayRemoteAddress { + Text(addr) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + self.onClose() + } label: { + Text("Open OpenClaw") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.horizontal, 24) + .padding(.bottom, 48) + } + } + + @ViewBuilder + private func manualConnectionFieldsSection(title: String) -> some View { + Section(title) { + TextField("Host", text: self.$manualHost) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Port", text: self.$manualPortText) + .keyboardType(.numberPad) + Toggle("Use TLS", isOn: self.$manualTLS) + TextField("Discovery Domain (optional)", text: self.$discoveryDomain) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button { + Task { await self.connectManual() } + } label: { + if self.connectingGatewayID == "manual" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect") + } + } + .disabled(!self.canConnectManual || self.connectingGatewayID != nil) + } + } + + private func handleScannedLink(_ link: GatewayConnectDeepLink) { + self.manualHost = link.host + self.manualPort = link.port + self.manualTLS = link.tls + if let token = link.token { + self.gatewayToken = token + } + if let password = link.password { + self.gatewayPassword = password + } + self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword) + self.showQRScanner = false + self.connectMessage = "Connecting via QR code…" + self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)…" + if self.selectedMode == nil { + self.selectedMode = link.tls ? .remoteDomain : .homeNetwork + } + Task { await self.connectManual() } + } + + private func openQRScannerFromOnboarding() { + // Stop active reconnect loops before scanning new credentials. + self.appModel.disconnectGateway() + self.connectingGatewayID = nil + self.connectMessage = nil + self.issue = .none + self.pairingRequestId = nil + self.statusLine = "Opening QR scanner…" + self.showQRScanner = true + } + + private func resumeAfterPairingApproval() { + // We intentionally stop reconnect churn while unpaired to avoid generating multiple pending requests. + self.appModel.gatewayAutoReconnectEnabled = true + self.appModel.gatewayPairingPaused = false + self.appModel.gatewayPairingRequestId = nil + // Pairing state is sticky to prevent UI flip-flop during reconnect churn. + // Once the user explicitly resumes after approving, clear the sticky issue + // so new status/auth errors can surface instead of being masked as pairing. + self.issue = .none + self.connectMessage = "Retrying after approval…" + self.statusLine = "Retrying after approval…" + Task { await self.retryLastAttempt() } + } + + private func resumeAfterPairingApprovalInBackground() { + // Keep the pairing issue sticky to avoid visual flicker while we probe for approval. + self.appModel.gatewayAutoReconnectEnabled = true + self.appModel.gatewayPairingPaused = false + self.appModel.gatewayPairingRequestId = nil + Task { await self.retryLastAttempt(silent: true) } + } + + private func attemptAutomaticPairingResumeIfNeeded() { + guard self.scenePhase == .active else { return } + guard self.step == .auth else { return } + guard self.issue.needsPairing else { return } + guard self.connectingGatewayID == nil else { return } + + let now = Date() + if let last = self.lastPairingAutoResumeAttemptAt, now.timeIntervalSince(last) < 6 { + return + } + self.lastPairingAutoResumeAttemptAt = now + self.resumeAfterPairingApprovalInBackground() + } + + private func detectQRCode(from data: Data) -> String? { + guard let ciImage = CIImage(data: data) else { return nil } + let detector = CIDetector( + ofType: CIDetectorTypeQRCode, context: nil, + options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]) + let features = detector?.features(in: ciImage) ?? [] + for feature in features { + if let qr = feature as? CIQRCodeFeature, let message = qr.messageString { + return message + } + } + return nil + } + + private func navigateBack() { + guard let target = self.step.previous else { return } + self.connectingGatewayID = nil + self.connectMessage = nil + self.step = target + } + private var canConnectManual: Bool { + let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) + return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535 + } + + private func initializeState() { + if self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let last = GatewaySettingsStore.loadLastGatewayConnection() { + switch last { + case let .manual(host, port, useTLS, _): + self.manualHost = host + self.manualPort = port + self.manualTLS = useTLS + case .discovered: + self.manualHost = "openclaw.local" + self.manualPort = 18789 + self.manualTLS = true + } + } else { + self.manualHost = "openclaw.local" + self.manualPort = 18789 + self.manualTLS = true + } + } + self.manualPortText = self.manualPort > 0 ? String(self.manualPort) : "" + if self.selectedMode == nil { + self.selectedMode = OnboardingStateStore.lastMode() + } + if self.selectedMode == .developerLocal && self.manualHost == "openclaw.local" { + self.manualHost = "localhost" + self.manualTLS = false + } + + let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedInstanceId.isEmpty { + self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? "" + self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? "" + } + + let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil + let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword { + self.didAutoPresentQR = true + self.statusLine = "No saved pairing found. Scan QR code to connect." + self.showQRScanner = true + } + } + + private func scheduleDiscoveryRestart() { + self.discoveryRestartTask?.cancel() + self.discoveryRestartTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 350_000_000) + guard !Task.isCancelled else { return } + self.gatewayController.restartDiscovery() + } + } + + private func saveGatewayCredentials(token: String, password: String) { + let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedInstanceId.isEmpty else { return } + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId) + let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) + GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId) + } + + private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { + self.connectingGatewayID = gateway.id + self.issue = .none + self.connectMessage = "Connecting to \(gateway.name)…" + self.statusLine = "Connecting to \(gateway.name)…" + defer { self.connectingGatewayID = nil } + await self.gatewayController.connect(gateway) + } + + private func selectMode(_ mode: OnboardingConnectionMode) { + self.selectedMode = mode + self.applyModeDefaults(mode) + } + + private func applyModeDefaults(_ mode: OnboardingConnectionMode) { + let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let hostIsDefaultLike = host.isEmpty || host == "openclaw.local" || host == "localhost" + + switch mode { + case .homeNetwork: + if hostIsDefaultLike { self.manualHost = "openclaw.local" } + self.manualTLS = true + if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 } + case .remoteDomain: + if host == "openclaw.local" || host == "localhost" { self.manualHost = "" } + self.manualTLS = true + if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 } + case .developerLocal: + if hostIsDefaultLike { self.manualHost = "localhost" } + self.manualTLS = false + if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 } + } + } + + private func gatewayHasResolvableHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool { + let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !lanHost.isEmpty { return true } + let tailnetDns = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !tailnetDns.isEmpty + } + + private func connectManual() async { + let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return } + self.connectingGatewayID = "manual" + self.issue = .none + self.connectMessage = "Connecting to \(host)…" + self.statusLine = "Connecting to \(host):\(self.manualPort)…" + defer { self.connectingGatewayID = nil } + await self.gatewayController.connectManual(host: host, port: self.manualPort, useTLS: self.manualTLS) + } + + private func retryLastAttempt(silent: Bool = false) async { + self.connectingGatewayID = silent ? "retry-auto" : "retry" + // Keep current auth/pairing issue sticky while retrying to avoid Step 3 UI flip-flop. + if !silent { + self.connectMessage = "Retrying…" + self.statusLine = "Retrying last connection…" + } + defer { self.connectingGatewayID = nil } + await self.gatewayController.connectLastKnown() + } +} + +private struct OnboardingModeRow: View { + let title: String + let subtitle: String + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: self.action) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(self.title) + .font(.body.weight(.semibold)) + Text(self.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: self.selected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(self.selected ? Color.accentColor : Color.secondary) + } + } + .buttonStyle(.plain) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Onboarding/QRScannerView.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Onboarding/QRScannerView.swift new file mode 100644 index 00000000..d326c09c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Onboarding/QRScannerView.swift @@ -0,0 +1,96 @@ +import OpenClawKit +import SwiftUI +import VisionKit + +struct QRScannerView: UIViewControllerRepresentable { + let onGatewayLink: (GatewayConnectDeepLink) -> Void + let onError: (String) -> Void + let onDismiss: () -> Void + + func makeUIViewController(context: Context) -> UIViewController { + guard DataScannerViewController.isSupported else { + context.coordinator.reportError("QR scanning is not supported on this device.") + return UIViewController() + } + guard DataScannerViewController.isAvailable else { + context.coordinator.reportError("Camera scanning is currently unavailable.") + return UIViewController() + } + let scanner = DataScannerViewController( + recognizedDataTypes: [.barcode(symbologies: [.qr])], + isHighlightingEnabled: true) + scanner.delegate = context.coordinator + do { + try scanner.startScanning() + } catch { + context.coordinator.reportError("Could not start QR scanner.") + } + return scanner + } + + func updateUIViewController(_: UIViewController, context _: Context) {} + + static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) { + if let scanner = uiViewController as? DataScannerViewController { + scanner.stopScanning() + } + coordinator.parent.onDismiss() + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + final class Coordinator: NSObject, DataScannerViewControllerDelegate { + let parent: QRScannerView + private var handled = false + private var reportedError = false + + init(parent: QRScannerView) { + self.parent = parent + } + + func reportError(_ message: String) { + guard !self.reportedError else { return } + self.reportedError = true + Task { @MainActor in + self.parent.onError(message) + } + } + + func dataScanner(_: DataScannerViewController, didAdd items: [RecognizedItem], allItems _: [RecognizedItem]) { + guard !self.handled else { return } + for item in items { + guard case let .barcode(barcode) = item, + let payload = barcode.payloadStringValue + else { continue } + + // Try setup code format first (base64url JSON from /pair qr). + if let link = GatewayConnectDeepLink.fromSetupCode(payload) { + self.handled = true + self.parent.onGatewayLink(link) + return + } + + // Fall back to deep link URL format (openclaw://gateway?...). + if let url = URL(string: payload), + let route = DeepLinkParser.parse(url), + case let .gateway(link) = route + { + self.handled = true + self.parent.onGatewayLink(link) + return + } + } + } + + func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {} + + func dataScanner( + _: DataScannerViewController, + becameUnavailableWithError _: DataScannerViewController.ScanningUnavailable) + { + self.reportError("Camera is not available on this device.") + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/OpenClaw.entitlements b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/OpenClaw.entitlements new file mode 100644 index 00000000..a2663ce9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/OpenClaw.entitlements @@ -0,0 +1,9 @@ + + + + + aps-environment + development + + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/OpenClawApp.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/OpenClawApp.swift new file mode 100644 index 00000000..0dc0c4ca --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/OpenClawApp.swift @@ -0,0 +1,532 @@ +import SwiftUI +import Foundation +import OpenClawKit +import os +import UIKit +import BackgroundTasks +import UserNotifications + +private struct PendingWatchPromptAction { + var promptId: String? + var actionId: String + var actionLabel: String? + var sessionKey: String? +} + +@MainActor +final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate { + private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push") + private let backgroundWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "BackgroundWake") + private static let wakeRefreshTaskIdentifier = "ai.openclaw.ios.bgrefresh" + private var backgroundWakeTask: Task? + private var pendingAPNsDeviceToken: Data? + private var pendingWatchPromptActions: [PendingWatchPromptAction] = [] + + weak var appModel: NodeAppModel? { + didSet { + guard let model = self.appModel else { return } + if let token = self.pendingAPNsDeviceToken { + self.pendingAPNsDeviceToken = nil + Task { @MainActor in + model.updateAPNsDeviceToken(token) + } + } + if !self.pendingWatchPromptActions.isEmpty { + let pending = self.pendingWatchPromptActions + self.pendingWatchPromptActions.removeAll() + Task { @MainActor in + for action in pending { + await model.handleMirroredWatchPromptAction( + promptId: action.promptId, + actionId: action.actionId, + actionLabel: action.actionLabel, + sessionKey: action.sessionKey) + } + } + } + } + } + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool + { + self.registerBackgroundWakeRefreshTask() + UNUserNotificationCenter.current().delegate = self + application.registerForRemoteNotifications() + return true + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + if let appModel = self.appModel { + Task { @MainActor in + appModel.updateAPNsDeviceToken(deviceToken) + } + return + } + + self.pendingAPNsDeviceToken = deviceToken + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { + self.logger.error("APNs registration failed: \(error.localizedDescription, privacy: .public)") + } + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) + { + self.logger.info("APNs remote notification received keys=\(userInfo.keys.count, privacy: .public)") + Task { @MainActor in + guard let appModel = self.appModel else { + self.logger.info("APNs wake skipped: appModel unavailable") + self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_no_model") + completionHandler(.noData) + return + } + let handled = await appModel.handleSilentPushWake(userInfo) + self.logger.info("APNs wake handled=\(handled, privacy: .public)") + if !handled { + self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_not_applied") + } + completionHandler(handled ? .newData : .noData) + } + } + + func scenePhaseChanged(_ phase: ScenePhase) { + if phase == .background { + self.scheduleBackgroundWakeRefresh(afterSeconds: 120, reason: "scene_background") + } + } + + private func registerBackgroundWakeRefreshTask() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.wakeRefreshTaskIdentifier, + using: nil + ) { [weak self] task in + guard let refreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + self?.handleBackgroundWakeRefresh(task: refreshTask) + } + } + + private func scheduleBackgroundWakeRefresh(afterSeconds delay: TimeInterval, reason: String) { + let request = BGAppRefreshTaskRequest(identifier: Self.wakeRefreshTaskIdentifier) + request.earliestBeginDate = Date().addingTimeInterval(max(60, delay)) + do { + try BGTaskScheduler.shared.submit(request) + self.backgroundWakeLogger.info( + "Scheduled background wake refresh reason=\(reason, privacy: .public) delaySeconds=\(max(60, delay), privacy: .public)") + } catch { + self.backgroundWakeLogger.error( + "Failed scheduling background wake refresh reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + } + } + + private func handleBackgroundWakeRefresh(task: BGAppRefreshTask) { + self.scheduleBackgroundWakeRefresh(afterSeconds: 15 * 60, reason: "reschedule") + self.backgroundWakeTask?.cancel() + + let wakeTask = Task { @MainActor [weak self] in + guard let self, let appModel = self.appModel else { return false } + return await appModel.handleBackgroundRefreshWake(trigger: "bg_app_refresh") + } + self.backgroundWakeTask = wakeTask + task.expirationHandler = { + wakeTask.cancel() + } + Task { + let applied = await wakeTask.value + task.setTaskCompleted(success: applied) + self.backgroundWakeLogger.info( + "Background wake refresh finished applied=\(applied, privacy: .public)") + } + } + + private static func isWatchPromptNotification(_ userInfo: [AnyHashable: Any]) -> Bool { + (userInfo[WatchPromptNotificationBridge.typeKey] as? String) == WatchPromptNotificationBridge.typeValue + } + + private static func parseWatchPromptAction( + from response: UNNotificationResponse) -> PendingWatchPromptAction? + { + let userInfo = response.notification.request.content.userInfo + guard Self.isWatchPromptNotification(userInfo) else { return nil } + + let promptId = userInfo[WatchPromptNotificationBridge.promptIDKey] as? String + let sessionKey = userInfo[WatchPromptNotificationBridge.sessionKeyKey] as? String + + switch response.actionIdentifier { + case WatchPromptNotificationBridge.actionPrimaryIdentifier: + let actionId = (userInfo[WatchPromptNotificationBridge.actionPrimaryIDKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !actionId.isEmpty else { return nil } + let actionLabel = userInfo[WatchPromptNotificationBridge.actionPrimaryLabelKey] as? String + return PendingWatchPromptAction( + promptId: promptId, + actionId: actionId, + actionLabel: actionLabel, + sessionKey: sessionKey) + case WatchPromptNotificationBridge.actionSecondaryIdentifier: + let actionId = (userInfo[WatchPromptNotificationBridge.actionSecondaryIDKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !actionId.isEmpty else { return nil } + let actionLabel = userInfo[WatchPromptNotificationBridge.actionSecondaryLabelKey] as? String + return PendingWatchPromptAction( + promptId: promptId, + actionId: actionId, + actionLabel: actionLabel, + sessionKey: sessionKey) + default: + break + } + + guard response.actionIdentifier.hasPrefix(WatchPromptNotificationBridge.actionIdentifierPrefix) else { + return nil + } + let indexString = String( + response.actionIdentifier.dropFirst(WatchPromptNotificationBridge.actionIdentifierPrefix.count)) + guard let actionIndex = Int(indexString), actionIndex >= 0 else { + return nil + } + let actionIdKey = WatchPromptNotificationBridge.actionIDKey(index: actionIndex) + let actionLabelKey = WatchPromptNotificationBridge.actionLabelKey(index: actionIndex) + let actionId = (userInfo[actionIdKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !actionId.isEmpty else { + return nil + } + let actionLabel = userInfo[actionLabelKey] as? String + return PendingWatchPromptAction( + promptId: promptId, + actionId: actionId, + actionLabel: actionLabel, + sessionKey: sessionKey) + } + + private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async { + guard let appModel = self.appModel else { + self.pendingWatchPromptActions.append(action) + return + } + await appModel.handleMirroredWatchPromptAction( + promptId: action.promptId, + actionId: action.actionId, + actionLabel: action.actionLabel, + sessionKey: action.sessionKey) + _ = await appModel.handleBackgroundRefreshWake(trigger: "watch_prompt_action") + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) + { + let userInfo = notification.request.content.userInfo + if Self.isWatchPromptNotification(userInfo) { + completionHandler([.banner, .list, .sound]) + return + } + completionHandler([]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) + { + guard let action = Self.parseWatchPromptAction(from: response) else { + completionHandler() + return + } + Task { @MainActor [weak self] in + guard let self else { + completionHandler() + return + } + await self.routeWatchPromptAction(action) + completionHandler() + } + } +} + +enum WatchPromptNotificationBridge { + static let typeKey = "openclaw.type" + static let typeValue = "watch.prompt" + static let promptIDKey = "openclaw.watch.promptId" + static let sessionKeyKey = "openclaw.watch.sessionKey" + static let actionPrimaryIDKey = "openclaw.watch.action.primary.id" + static let actionPrimaryLabelKey = "openclaw.watch.action.primary.label" + static let actionSecondaryIDKey = "openclaw.watch.action.secondary.id" + static let actionSecondaryLabelKey = "openclaw.watch.action.secondary.label" + static let actionPrimaryIdentifier = "openclaw.watch.action.primary" + static let actionSecondaryIdentifier = "openclaw.watch.action.secondary" + static let actionIdentifierPrefix = "openclaw.watch.action." + static let actionIDKeyPrefix = "openclaw.watch.action.id." + static let actionLabelKeyPrefix = "openclaw.watch.action.label." + static let categoryPrefix = "openclaw.watch.prompt.category." + + @MainActor + static func scheduleMirroredWatchPromptNotificationIfNeeded( + invokeID: String, + params: OpenClawWatchNotifyParams, + sendResult: WatchNotificationSendResult) async + { + guard sendResult.queuedForDelivery || !sendResult.deliveredImmediately else { return } + + let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty || !body.isEmpty else { return } + guard await self.requestNotificationAuthorizationIfNeeded() else { return } + + let normalizedActions = (params.actions ?? []).compactMap { action -> OpenClawWatchAction? in + let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines) + let label = action.label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, !label.isEmpty else { return nil } + return OpenClawWatchAction(id: id, label: label, style: action.style) + } + let displayedActions = Array(normalizedActions.prefix(4)) + + let center = UNUserNotificationCenter.current() + var categoryIdentifier = "" + if !displayedActions.isEmpty { + let categoryID = "\(self.categoryPrefix)\(invokeID)" + let category = UNNotificationCategory( + identifier: categoryID, + actions: self.categoryActions(displayedActions), + intentIdentifiers: [], + options: []) + await self.upsertNotificationCategory(category, center: center) + categoryIdentifier = categoryID + } + + var userInfo: [AnyHashable: Any] = [ + self.typeKey: self.typeValue, + ] + if let promptId = params.promptId?.trimmingCharacters(in: .whitespacesAndNewlines), !promptId.isEmpty { + userInfo[self.promptIDKey] = promptId + } + if let sessionKey = params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty { + userInfo[self.sessionKeyKey] = sessionKey + } + for (index, action) in displayedActions.enumerated() { + userInfo[self.actionIDKey(index: index)] = action.id + userInfo[self.actionLabelKey(index: index)] = action.label + if index == 0 { + userInfo[self.actionPrimaryIDKey] = action.id + userInfo[self.actionPrimaryLabelKey] = action.label + } else if index == 1 { + userInfo[self.actionSecondaryIDKey] = action.id + userInfo[self.actionSecondaryLabelKey] = action.label + } + } + + let content = UNMutableNotificationContent() + content.title = title.isEmpty ? "OpenClaw" : title + content.body = body + content.sound = .default + content.userInfo = userInfo + if !categoryIdentifier.isEmpty { + content.categoryIdentifier = categoryIdentifier + } + if #available(iOS 15.0, *) { + switch params.priority ?? .active { + case .passive: + content.interruptionLevel = .passive + case .timeSensitive: + content.interruptionLevel = .timeSensitive + case .active: + content.interruptionLevel = .active + } + } + + let request = UNNotificationRequest( + identifier: "watch.prompt.\(invokeID)", + content: content, + trigger: nil) + try? await self.addNotificationRequest(request, center: center) + } + + static func actionIDKey(index: Int) -> String { + "\(self.actionIDKeyPrefix)\(index)" + } + + static func actionLabelKey(index: Int) -> String { + "\(self.actionLabelKeyPrefix)\(index)" + } + + private static func categoryActions(_ actions: [OpenClawWatchAction]) -> [UNNotificationAction] { + actions.enumerated().map { index, action in + let identifier: String + switch index { + case 0: + identifier = self.actionPrimaryIdentifier + case 1: + identifier = self.actionSecondaryIdentifier + default: + identifier = "\(self.actionIdentifierPrefix)\(index)" + } + return UNNotificationAction( + identifier: identifier, + title: action.label, + options: self.notificationActionOptions(style: action.style)) + } + } + + private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions { + switch style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "destructive": + return [.destructive] + case "foreground": + // For mirrored watch actions, keep handling in background when possible. + return [] + default: + return [] + } + } + + private static func requestNotificationAuthorizationIfNeeded() async -> Bool { + let center = UNUserNotificationCenter.current() + let status = await self.notificationAuthorizationStatus(center: center) + switch status { + case .authorized, .provisional, .ephemeral: + return true + case .notDetermined: + let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false + if !granted { return false } + let updatedStatus = await self.notificationAuthorizationStatus(center: center) + return self.isAuthorizationStatusAllowed(updatedStatus) + case .denied: + return false + @unknown default: + return false + } + } + + private static func isAuthorizationStatusAllowed(_ status: UNAuthorizationStatus) -> Bool { + switch status { + case .authorized, .provisional, .ephemeral: + return true + case .denied, .notDetermined: + return false + @unknown default: + return false + } + } + + private static func notificationAuthorizationStatus(center: UNUserNotificationCenter) async -> UNAuthorizationStatus { + await withCheckedContinuation { continuation in + center.getNotificationSettings { settings in + continuation.resume(returning: settings.authorizationStatus) + } + } + } + + private static func upsertNotificationCategory( + _ category: UNNotificationCategory, + center: UNUserNotificationCenter) async + { + await withCheckedContinuation { continuation in + center.getNotificationCategories { categories in + var updated = categories + updated.update(with: category) + center.setNotificationCategories(updated) + continuation.resume() + } + } + } + + private static func addNotificationRequest(_ request: UNNotificationRequest, center: UNUserNotificationCenter) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + center.add(request) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + } +} + +extension NodeAppModel { + func handleMirroredWatchPromptAction( + promptId: String?, + actionId: String, + actionLabel: String?, + sessionKey: String?) async + { + let normalizedActionID = actionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedActionID.isEmpty else { return } + + let normalizedPromptID = promptId?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedSessionKey = sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedActionLabel = actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines) + + let event = WatchQuickReplyEvent( + replyId: UUID().uuidString, + promptId: (normalizedPromptID?.isEmpty == false) ? normalizedPromptID! : "unknown", + actionId: normalizedActionID, + actionLabel: (normalizedActionLabel?.isEmpty == false) ? normalizedActionLabel : nil, + sessionKey: (normalizedSessionKey?.isEmpty == false) ? normalizedSessionKey : nil, + note: "source=ios.notification", + sentAtMs: Int(Date().timeIntervalSince1970 * 1000), + transport: "ios.notification") + await self._bridgeConsumeMirroredWatchReply(event) + } +} + +@main +struct OpenClawApp: App { + @State private var appModel: NodeAppModel + @State private var gatewayController: GatewayConnectionController + @UIApplicationDelegateAdaptor(OpenClawAppDelegate.self) private var appDelegate + @Environment(\.scenePhase) private var scenePhase + + init() { + Self.installUncaughtExceptionLogger() + GatewaySettingsStore.bootstrapPersistence() + let appModel = NodeAppModel() + _appModel = State(initialValue: appModel) + _gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel)) + } + + var body: some Scene { + WindowGroup { + RootCanvas() + .environment(self.appModel) + .environment(self.appModel.voiceWake) + .environment(self.gatewayController) + .task { + self.appDelegate.appModel = self.appModel + } + .onOpenURL { url in + Task { await self.appModel.handleDeepLink(url: url) } + } + .onChange(of: self.scenePhase) { _, newValue in + self.appModel.setScenePhase(newValue) + self.gatewayController.setScenePhase(newValue) + self.appDelegate.scenePhaseChanged(newValue) + } + } + } +} + +extension OpenClawApp { + private static func installUncaughtExceptionLogger() { + NSLog("OpenClaw: installing uncaught exception handler") + NSSetUncaughtExceptionHandler { exception in + // Useful when the app hits NSExceptions from SwiftUI/WebKit internals; these do not + // produce a normal Swift error backtrace. + let reason = exception.reason ?? "(no reason)" + NSLog("UNCAUGHT EXCEPTION: %@ %@", exception.name.rawValue, reason) + for line in exception.callStackSymbols { + NSLog(" %@", line) + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Reminders/RemindersService.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Reminders/RemindersService.swift new file mode 100644 index 00000000..249f439f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Reminders/RemindersService.swift @@ -0,0 +1,133 @@ +import EventKit +import Foundation +import OpenClawKit + +final class RemindersService: RemindersServicing { + func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload { + let store = EKEventStore() + let status = EKEventStore.authorizationStatus(for: .reminder) + let authorized = EventKitAuthorization.allowsRead(status: status) + guard authorized else { + throw NSError(domain: "Reminders", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission", + ]) + } + + let limit = max(1, min(params.limit ?? 50, 500)) + let statusFilter = params.status ?? .incomplete + + let predicate = store.predicateForReminders(in: nil) + let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawReminderPayload], Error>) in + store.fetchReminders(matching: predicate) { items in + let formatter = ISO8601DateFormatter() + let filtered = (items ?? []).filter { reminder in + switch statusFilter { + case .all: + return true + case .completed: + return reminder.isCompleted + case .incomplete: + return !reminder.isCompleted + } + } + let selected = Array(filtered.prefix(limit)) + let payload = selected.map { reminder in + let due = reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) } + return OpenClawReminderPayload( + identifier: reminder.calendarItemIdentifier, + title: reminder.title, + dueISO: due.map { formatter.string(from: $0) }, + completed: reminder.isCompleted, + listName: reminder.calendar.title) + } + cont.resume(returning: payload) + } + } + + return OpenClawRemindersListPayload(reminders: payload) + } + + func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload { + let store = EKEventStore() + let status = EKEventStore.authorizationStatus(for: .reminder) + let authorized = EventKitAuthorization.allowsWrite(status: status) + guard authorized else { + throw NSError(domain: "Reminders", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission", + ]) + } + + let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + throw NSError(domain: "Reminders", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "REMINDERS_INVALID: title required", + ]) + } + + let reminder = EKReminder(eventStore: store) + reminder.title = title + if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty { + reminder.notes = notes + } + reminder.calendar = try Self.resolveList( + store: store, + listId: params.listId, + listName: params.listName) + + if let dueISO = params.dueISO?.trimmingCharacters(in: .whitespacesAndNewlines), !dueISO.isEmpty { + let formatter = ISO8601DateFormatter() + guard let dueDate = formatter.date(from: dueISO) else { + throw NSError(domain: "Reminders", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "REMINDERS_INVALID: dueISO must be ISO-8601", + ]) + } + reminder.dueDateComponents = Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute, .second], + from: dueDate) + } + + try store.save(reminder, commit: true) + + let formatter = ISO8601DateFormatter() + let due = reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) } + let payload = OpenClawReminderPayload( + identifier: reminder.calendarItemIdentifier, + title: reminder.title, + dueISO: due.map { formatter.string(from: $0) }, + completed: reminder.isCompleted, + listName: reminder.calendar.title) + + return OpenClawRemindersAddPayload(reminder: payload) + } + + private static func resolveList( + store: EKEventStore, + listId: String?, + listName: String?) throws -> EKCalendar + { + if let id = listId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty, + let calendar = store.calendar(withIdentifier: id) + { + return calendar + } + + if let title = listName?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + if let calendar = store.calendars(for: .reminder).first(where: { + $0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + }) { + return calendar + } + throw NSError(domain: "Reminders", code: 5, userInfo: [ + NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no list named \(title)", + ]) + } + + if let fallback = store.defaultCalendarForNewReminders() { + return fallback + } + + throw NSError(domain: "Reminders", code: 6, userInfo: [ + NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no default list", + ]) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/RootCanvas.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/RootCanvas.swift new file mode 100644 index 00000000..dd0f389e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/RootCanvas.swift @@ -0,0 +1,506 @@ +import SwiftUI +import UIKit + +struct RootCanvas: View { + @Environment(NodeAppModel.self) private var appModel + @Environment(GatewayConnectionController.self) private var gatewayController + @Environment(VoiceWakeManager.self) private var voiceWake + @Environment(\.colorScheme) private var systemColorScheme + @Environment(\.scenePhase) private var scenePhase + @AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false + @AppStorage("screen.preventSleep") private var preventSleep: Bool = true + @AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false + @AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0 + @AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false + @AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false + @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" + @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false + @AppStorage("gateway.manual.host") private var manualGatewayHost: String = "" + @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false + @State private var presentedSheet: PresentedSheet? + @State private var voiceWakeToastText: String? + @State private var toastDismissTask: Task? + @State private var showOnboarding: Bool = false + @State private var onboardingAllowSkip: Bool = true + @State private var didEvaluateOnboarding: Bool = false + @State private var didAutoOpenSettings: Bool = false + + private enum PresentedSheet: Identifiable { + case settings + case chat + case quickSetup + + var id: Int { + switch self { + case .settings: 0 + case .chat: 1 + case .quickSetup: 2 + } + } + } + + enum StartupPresentationRoute: Equatable { + case none + case onboarding + case settings + } + + static func startupPresentationRoute( + gatewayConnected: Bool, + hasConnectedOnce: Bool, + onboardingComplete: Bool, + hasExistingGatewayConfig: Bool, + shouldPresentOnLaunch: Bool) -> StartupPresentationRoute + { + if gatewayConnected { + return .none + } + // On first run or explicit launch onboarding state, onboarding always wins. + if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete { + return .onboarding + } + // Settings auto-open is a recovery path for previously-connected installs only. + if !hasExistingGatewayConfig { + return .settings + } + return .none + } + + var body: some View { + ZStack { + CanvasContent( + systemColorScheme: self.systemColorScheme, + gatewayStatus: self.gatewayStatus, + voiceWakeEnabled: self.voiceWakeEnabled, + voiceWakeToastText: self.voiceWakeToastText, + cameraHUDText: self.appModel.cameraHUDText, + cameraHUDKind: self.appModel.cameraHUDKind, + openChat: { + self.presentedSheet = .chat + }, + openSettings: { + self.presentedSheet = .settings + }) + .preferredColorScheme(.dark) + + if self.appModel.cameraFlashNonce != 0 { + CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce) + } + } + .gatewayTrustPromptAlert() + .deepLinkAgentPromptAlert() + .sheet(item: self.$presentedSheet) { sheet in + switch sheet { + case .settings: + SettingsTab() + .environment(self.appModel) + .environment(self.appModel.voiceWake) + .environment(self.gatewayController) + case .chat: + ChatSheet( + // Chat RPCs run on the operator session (read/write scopes). + gateway: self.appModel.operatorSession, + sessionKey: self.appModel.chatSessionKey, + agentName: self.appModel.activeAgentName, + userAccent: self.appModel.seamColor) + case .quickSetup: + GatewayQuickSetupSheet() + .environment(self.appModel) + .environment(self.gatewayController) + } + } + .fullScreenCover(isPresented: self.$showOnboarding) { + OnboardingWizardView( + allowSkip: self.onboardingAllowSkip, + onClose: { + self.showOnboarding = false + }) + .environment(self.appModel) + .environment(self.appModel.voiceWake) + .environment(self.gatewayController) + } + .onAppear { self.updateIdleTimer() } + .onAppear { self.evaluateOnboardingPresentation(force: false) } + .onAppear { self.maybeAutoOpenSettings() } + .onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() } + .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } + .onAppear { self.maybeShowQuickSetup() } + .onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() } + .onAppear { self.updateCanvasDebugStatus() } + .onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayServerName) { _, newValue in + if newValue != nil { + self.showOnboarding = false + } + } + .onChange(of: self.onboardingRequestID) { _, _ in + self.evaluateOnboardingPresentation(force: true) + } + .onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayServerName) { _, newValue in + if newValue != nil { + self.onboardingComplete = true + self.hasConnectedOnce = true + OnboardingStateStore.markCompleted(mode: nil) + } + self.maybeAutoOpenSettings() + } + .onChange(of: self.appModel.openChatRequestID) { _, _ in + self.presentedSheet = .chat + } + .onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in + guard let newValue else { return } + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + self.toastDismissTask?.cancel() + withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { + self.voiceWakeToastText = trimmed + } + + self.toastDismissTask = Task { + try? await Task.sleep(nanoseconds: 2_300_000_000) + await MainActor.run { + withAnimation(.easeOut(duration: 0.25)) { + self.voiceWakeToastText = nil + } + } + } + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + self.toastDismissTask?.cancel() + self.toastDismissTask = nil + } + } + + private var gatewayStatus: StatusPill.GatewayState { + if self.appModel.gatewayServerName != nil { return .connected } + + let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + if text.localizedCaseInsensitiveContains("connecting") || + text.localizedCaseInsensitiveContains("reconnecting") + { + return .connecting + } + + if text.localizedCaseInsensitiveContains("error") { + return .error + } + + return .disconnected + } + + private func updateIdleTimer() { + UIApplication.shared.isIdleTimerDisabled = (self.scenePhase == .active && self.preventSleep) + } + + private func updateCanvasDebugStatus() { + self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled) + guard self.canvasDebugStatusEnabled else { return } + let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress + self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle) + } + + private func evaluateOnboardingPresentation(force: Bool) { + if force { + self.onboardingAllowSkip = true + self.showOnboarding = true + return + } + + guard !self.didEvaluateOnboarding else { return } + self.didEvaluateOnboarding = true + let route = Self.startupPresentationRoute( + gatewayConnected: self.appModel.gatewayServerName != nil, + hasConnectedOnce: self.hasConnectedOnce, + onboardingComplete: self.onboardingComplete, + hasExistingGatewayConfig: self.hasExistingGatewayConfig(), + shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel)) + switch route { + case .none: + break + case .onboarding: + self.onboardingAllowSkip = true + self.showOnboarding = true + case .settings: + self.didAutoOpenSettings = true + self.presentedSheet = .settings + } + } + + private func hasExistingGatewayConfig() -> Bool { + if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true } + let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) + return self.manualGatewayEnabled && !manualHost.isEmpty + } + + private func maybeAutoOpenSettings() { + guard !self.didAutoOpenSettings else { return } + guard !self.showOnboarding else { return } + let route = Self.startupPresentationRoute( + gatewayConnected: self.appModel.gatewayServerName != nil, + hasConnectedOnce: self.hasConnectedOnce, + onboardingComplete: self.onboardingComplete, + hasExistingGatewayConfig: self.hasExistingGatewayConfig(), + shouldPresentOnLaunch: false) + guard route == .settings else { return } + self.didAutoOpenSettings = true + self.presentedSheet = .settings + } + + private func maybeShowQuickSetup() { + guard !self.quickSetupDismissed else { return } + guard !self.showOnboarding else { return } + guard self.presentedSheet == nil else { return } + guard self.appModel.gatewayServerName == nil else { return } + guard !self.gatewayController.gateways.isEmpty else { return } + self.presentedSheet = .quickSetup + } +} + +private struct CanvasContent: View { + @Environment(NodeAppModel.self) private var appModel + @AppStorage("talk.enabled") private var talkEnabled: Bool = false + @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true + @State private var showGatewayActions: Bool = false + var systemColorScheme: ColorScheme + var gatewayStatus: StatusPill.GatewayState + var voiceWakeEnabled: Bool + var voiceWakeToastText: String? + var cameraHUDText: String? + var cameraHUDKind: NodeAppModel.CameraHUDKind? + var openChat: () -> Void + var openSettings: () -> Void + + private var brightenButtons: Bool { self.systemColorScheme == .light } + + var body: some View { + ZStack(alignment: .topTrailing) { + ScreenTab() + + VStack(spacing: 10) { + OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) { + self.openChat() + } + .accessibilityLabel("Chat") + + if self.talkButtonEnabled { + // Talk mode lives on a side bubble so it doesn't get buried in settings. + OverlayButton( + systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle", + brighten: self.brightenButtons, + tint: self.appModel.seamColor, + isActive: self.appModel.talkMode.isEnabled) + { + let next = !self.appModel.talkMode.isEnabled + self.talkEnabled = next + self.appModel.setTalkEnabled(next) + } + .accessibilityLabel("Talk Mode") + } + + OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) { + self.openSettings() + } + .accessibilityLabel("Settings") + } + .padding(.top, 10) + .padding(.trailing, 10) + } + .overlay(alignment: .center) { + if self.appModel.talkMode.isEnabled { + TalkOrbOverlay() + .transition(.opacity) + } + } + .overlay(alignment: .topLeading) { + StatusPill( + gateway: self.gatewayStatus, + voiceWakeEnabled: self.voiceWakeEnabled, + activity: self.statusActivity, + brighten: self.brightenButtons, + onTap: { + if self.gatewayStatus == .connected { + self.showGatewayActions = true + } else { + self.openSettings() + } + }) + .padding(.leading, 10) + .safeAreaPadding(.top, 10) + } + .overlay(alignment: .topLeading) { + if let voiceWakeToastText, !voiceWakeToastText.isEmpty { + VoiceWakeToast( + command: voiceWakeToastText, + brighten: self.brightenButtons) + .padding(.leading, 10) + .safeAreaPadding(.top, 58) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .confirmationDialog( + "Gateway", + isPresented: self.$showGatewayActions, + titleVisibility: .visible) + { + Button("Disconnect", role: .destructive) { + self.appModel.disconnectGateway() + } + Button("Open Settings") { + self.openSettings() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Disconnect from the gateway?") + } + } + + private var statusActivity: StatusPill.Activity? { + // Status pill owns transient activity state so it doesn't overlap the connection indicator. + if self.appModel.isBackgrounded { + return StatusPill.Activity( + title: "Foreground required", + systemImage: "exclamationmark.triangle.fill", + tint: .orange) + } + + let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + let gatewayLower = gatewayStatus.lowercased() + if gatewayLower.contains("repair") { + return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) + } + if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { + return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") + } + // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. + + if self.appModel.screenRecordActive { + return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) + } + + if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind { + let systemImage: String + let tint: Color? + switch cameraHUDKind { + case .photo: + systemImage = "camera.fill" + tint = nil + case .recording: + systemImage = "video.fill" + tint = .red + case .success: + systemImage = "checkmark.circle.fill" + tint = .green + case .error: + systemImage = "exclamationmark.triangle.fill" + tint = .red + } + return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint) + } + + if self.voiceWakeEnabled { + let voiceStatus = self.appModel.voiceWake.statusText + if voiceStatus.localizedCaseInsensitiveContains("microphone permission") { + return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange) + } + if voiceStatus == "Paused" { + // Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case. + if self.appModel.talkMode.isEnabled { + return nil + } + let suffix = self.appModel.isBackgrounded ? " (background)" : "" + return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill") + } + } + + return nil + } +} + +private struct OverlayButton: View { + let systemImage: String + let brighten: Bool + var tint: Color? + var isActive: Bool = false + let action: () -> Void + + var body: some View { + Button(action: self.action) { + Image(systemName: self.systemImage) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary) + .padding(10) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.ultraThinMaterial) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill( + LinearGradient( + colors: [ + .white.opacity(self.brighten ? 0.26 : 0.18), + .white.opacity(self.brighten ? 0.08 : 0.04), + .clear, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing)) + .blendMode(.overlay) + } + .overlay { + if let tint { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill( + LinearGradient( + colors: [ + tint.opacity(self.isActive ? 0.22 : 0.14), + tint.opacity(self.isActive ? 0.10 : 0.06), + .clear, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing)) + .blendMode(.overlay) + } + } + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + (self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)), + lineWidth: self.isActive ? 0.7 : 0.5) + } + .shadow(color: .black.opacity(0.35), radius: 12, y: 6) + } + } + .buttonStyle(.plain) + } +} + +private struct CameraFlashOverlay: View { + var nonce: Int + + @State private var opacity: CGFloat = 0 + @State private var task: Task? + + var body: some View { + Color.white + .opacity(self.opacity) + .ignoresSafeArea() + .allowsHitTesting(false) + .onChange(of: self.nonce) { _, _ in + self.task?.cancel() + self.task = Task { @MainActor in + withAnimation(.easeOut(duration: 0.08)) { + self.opacity = 0.85 + } + try? await Task.sleep(nanoseconds: 110_000_000) + withAnimation(.easeOut(duration: 0.32)) { + self.opacity = 0 + } + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/RootTabs.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/RootTabs.swift new file mode 100644 index 00000000..4733a4a3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/RootTabs.swift @@ -0,0 +1,114 @@ +import SwiftUI + +struct RootTabs: View { + @Environment(NodeAppModel.self) private var appModel + @Environment(VoiceWakeManager.self) private var voiceWake + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false + @State private var selectedTab: Int = 0 + @State private var voiceWakeToastText: String? + @State private var toastDismissTask: Task? + @State private var showGatewayActions: Bool = false + + var body: some View { + TabView(selection: self.$selectedTab) { + ScreenTab() + .tabItem { Label("Screen", systemImage: "rectangle.and.hand.point.up.left") } + .tag(0) + + VoiceTab() + .tabItem { Label("Voice", systemImage: "mic") } + .tag(1) + + SettingsTab() + .tabItem { Label("Settings", systemImage: "gearshape") } + .tag(2) + } + .overlay(alignment: .topLeading) { + StatusPill( + gateway: self.gatewayStatus, + voiceWakeEnabled: self.voiceWakeEnabled, + activity: self.statusActivity, + onTap: { + if self.gatewayStatus == .connected { + self.showGatewayActions = true + } else { + self.selectedTab = 2 + } + }) + .padding(.leading, 10) + .safeAreaPadding(.top, 10) + } + .overlay(alignment: .topLeading) { + if let voiceWakeToastText, !voiceWakeToastText.isEmpty { + VoiceWakeToast(command: voiceWakeToastText) + .padding(.leading, 10) + .safeAreaPadding(.top, 58) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in + guard let newValue else { return } + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + self.toastDismissTask?.cancel() + withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) { + self.voiceWakeToastText = trimmed + } + + self.toastDismissTask = Task { + try? await Task.sleep(nanoseconds: 2_300_000_000) + await MainActor.run { + withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) { + self.voiceWakeToastText = nil + } + } + } + } + .onDisappear { + self.toastDismissTask?.cancel() + self.toastDismissTask = nil + } + .confirmationDialog( + "Gateway", + isPresented: self.$showGatewayActions, + titleVisibility: .visible) + { + Button("Disconnect", role: .destructive) { + self.appModel.disconnectGateway() + } + Button("Open Settings") { + self.selectedTab = 2 + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Disconnect from the gateway?") + } + } + + private var gatewayStatus: StatusPill.GatewayState { + if self.appModel.gatewayServerName != nil { return .connected } + + let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + if text.localizedCaseInsensitiveContains("connecting") || + text.localizedCaseInsensitiveContains("reconnecting") + { + return .connecting + } + + if text.localizedCaseInsensitiveContains("error") { + return .error + } + + return .disconnected + } + + private var statusActivity: StatusPill.Activity? { + StatusActivityBuilder.build( + appModel: self.appModel, + voiceWakeEnabled: self.voiceWakeEnabled, + cameraHUDText: self.appModel.cameraHUDText, + cameraHUDKind: self.appModel.cameraHUDKind) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/RootView.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/RootView.swift new file mode 100644 index 00000000..b0281865 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/RootView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct RootView: View { + var body: some View { + RootCanvas() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Screen/ScreenController.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Screen/ScreenController.swift new file mode 100644 index 00000000..00452323 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Screen/ScreenController.swift @@ -0,0 +1,373 @@ +import OpenClawKit +import Observation +import UIKit +import WebKit + +@MainActor +@Observable +final class ScreenController { + private weak var activeWebView: WKWebView? + + var urlString: String = "" + var errorText: String? + + /// Callback invoked when an openclaw:// deep link is tapped in the canvas + var onDeepLink: ((URL) -> Void)? + + /// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI. + var onA2UIAction: (([String: Any]) -> Void)? + + private var debugStatusEnabled: Bool = false + private var debugStatusTitle: String? + private var debugStatusSubtitle: String? + + init() { + self.reload() + } + + func navigate(to urlString: String) { + let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + self.urlString = "" + self.reload() + return + } + if let url = URL(string: trimmed), + !url.isFileURL, + let host = url.host, + Self.isLoopbackHost(host) + { + // Never try to load loopback URLs from a remote gateway. + self.showDefaultCanvas() + return + } + self.urlString = (trimmed == "/" ? "" : trimmed) + self.reload() + } + + func reload() { + self.applyScrollBehavior() + guard let webView = self.activeWebView else { return } + + let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + guard let url = Self.canvasScaffoldURL else { return } + self.errorText = nil + webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) + return + } + + guard let url = URL(string: trimmed) else { + self.errorText = "Invalid URL: \(trimmed)" + return + } + self.errorText = nil + if url.isFileURL { + webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) + } else { + webView.load(URLRequest(url: url)) + } + } + + func showDefaultCanvas() { + self.urlString = "" + self.reload() + } + + func setDebugStatusEnabled(_ enabled: Bool) { + self.debugStatusEnabled = enabled + self.applyDebugStatusIfNeeded() + } + + func updateDebugStatus(title: String?, subtitle: String?) { + self.debugStatusTitle = title + self.debugStatusSubtitle = subtitle + self.applyDebugStatusIfNeeded() + } + + func applyDebugStatusIfNeeded() { + guard let webView = self.activeWebView else { return } + let enabled = self.debugStatusEnabled + let title = self.debugStatusTitle + let subtitle = self.debugStatusSubtitle + let js = """ + (() => { + try { + const api = globalThis.__openclaw; + if (!api) return; + if (typeof api.setDebugStatusEnabled === 'function') { + api.setDebugStatusEnabled(\(enabled ? "true" : "false")); + } + if (!\(enabled ? "true" : "false")) return; + if (typeof api.setStatus === 'function') { + api.setStatus(\(Self.jsValue(title)), \(Self.jsValue(subtitle))); + } + } catch (_) {} + })() + """ + webView.evaluateJavaScript(js) { _, _ in } + } + + func waitForA2UIReady(timeoutMs: Int) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .milliseconds(timeoutMs)) + while clock.now < deadline { + do { + let res = try await self.eval(javaScript: """ + (() => { + try { + const host = globalThis.openclawA2UI; + return !!host && typeof host.applyMessages === 'function'; + } catch (_) { return false; } + })() + """) + let trimmed = res.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed == "true" || trimmed == "1" { return true } + } catch { + // ignore; page likely still loading + } + try? await Task.sleep(nanoseconds: 120_000_000) + } + return false + } + + func eval(javaScript: String) async throws -> String { + guard let webView = self.activeWebView else { + throw NSError(domain: "Screen", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "web view unavailable", + ]) + } + return try await withCheckedThrowingContinuation { cont in + webView.evaluateJavaScript(javaScript) { result, error in + if let error { + cont.resume(throwing: error) + return + } + if let result { + cont.resume(returning: String(describing: result)) + } else { + cont.resume(returning: "") + } + } + } + } + + func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String { + let config = WKSnapshotConfiguration() + if let maxWidth { + config.snapshotWidth = NSNumber(value: Double(maxWidth)) + } + guard let webView = self.activeWebView else { + throw NSError(domain: "Screen", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "web view unavailable", + ]) + } + let image: UIImage = try await withCheckedThrowingContinuation { cont in + webView.takeSnapshot(with: config) { image, error in + if let error { + cont.resume(throwing: error) + return + } + guard let image else { + cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "snapshot failed", + ])) + return + } + cont.resume(returning: image) + } + } + guard let data = image.pngData() else { + throw NSError(domain: "Screen", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "snapshot encode failed", + ]) + } + return data.base64EncodedString() + } + + func snapshotBase64( + maxWidth: CGFloat? = nil, + format: OpenClawCanvasSnapshotFormat, + quality: Double? = nil) async throws -> String + { + let config = WKSnapshotConfiguration() + if let maxWidth { + config.snapshotWidth = NSNumber(value: Double(maxWidth)) + } + guard let webView = self.activeWebView else { + throw NSError(domain: "Screen", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "web view unavailable", + ]) + } + let image: UIImage = try await withCheckedThrowingContinuation { cont in + webView.takeSnapshot(with: config) { image, error in + if let error { + cont.resume(throwing: error) + return + } + guard let image else { + cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "snapshot failed", + ])) + return + } + cont.resume(returning: image) + } + } + + let data: Data? + switch format { + case .png: + data = image.pngData() + case .jpeg: + let q = (quality ?? 0.82).clamped(to: 0.1...1.0) + data = image.jpegData(compressionQuality: q) + } + guard let data else { + throw NSError(domain: "Screen", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "snapshot encode failed", + ]) + } + return data.base64EncodedString() + } + + func attachWebView(_ webView: WKWebView) { + self.activeWebView = webView + self.reload() + self.applyDebugStatusIfNeeded() + } + + func detachWebView(_ webView: WKWebView) { + guard self.activeWebView === webView else { return } + self.activeWebView = nil + } + + private static func bundledResourceURL( + name: String, + ext: String, + subdirectory: String) + -> URL? + { + let bundle = OpenClawKitResources.bundle + return bundle.url(forResource: name, withExtension: ext, subdirectory: subdirectory) + ?? bundle.url(forResource: name, withExtension: ext) + } + + private static let canvasScaffoldURL: URL? = ScreenController.bundledResourceURL( + name: "scaffold", + ext: "html", + subdirectory: "CanvasScaffold") + + private static func isLoopbackHost(_ host: String) -> Bool { + let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized.isEmpty { return true } + if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" { + return true + } + if normalized == "127.0.0.1" || normalized.hasPrefix("127.") { + return true + } + return false + } + func isTrustedCanvasUIURL(_ url: URL) -> Bool { + guard url.isFileURL else { return false } + let std = url.standardizedFileURL + if let expected = Self.canvasScaffoldURL, + std == expected.standardizedFileURL + { + return true + } + return false + } + + private func applyScrollBehavior() { + guard let webView = self.activeWebView else { return } + let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines) + let allowScroll = !trimmed.isEmpty + let scrollView = webView.scrollView + // Default canvas needs raw touch events; external pages should scroll. + scrollView.isScrollEnabled = allowScroll + scrollView.bounces = allowScroll + } + + private static func jsValue(_ value: String?) -> String { + guard let value else { return "null" } + if let data = try? JSONSerialization.data(withJSONObject: [value]), + let encoded = String(data: data, encoding: .utf8), + encoded.count >= 2 + { + return String(encoded.dropFirst().dropLast()) + } + return "null" + } + + func isLocalNetworkCanvasURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { + return false + } + guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else { + return false + } + if host == "localhost" { return true } + if host.hasSuffix(".local") { return true } + if host.hasSuffix(".ts.net") { return true } + if host.hasSuffix(".tailscale.net") { return true } + // Allow MagicDNS / LAN hostnames like "peters-mac-studio-1". + if !host.contains("."), !host.contains(":") { return true } + if let ipv4 = Self.parseIPv4(host) { + return Self.isLocalNetworkIPv4(ipv4) + } + return false + } + + private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + let bytes: [UInt8] = parts.compactMap { UInt8($0) } + guard bytes.count == 4 else { return nil } + return (bytes[0], bytes[1], bytes[2], bytes[3]) + } + + private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { + let (a, b, _, _) = ip + // 10.0.0.0/8 + if a == 10 { return true } + // 172.16.0.0/12 + if a == 172, (16...31).contains(Int(b)) { return true } + // 192.168.0.0/16 + if a == 192, b == 168 { return true } + // 127.0.0.0/8 + if a == 127 { return true } + // 169.254.0.0/16 (link-local) + if a == 169, b == 254 { return true } + // Tailscale: 100.64.0.0/10 + if a == 100, (64...127).contains(Int(b)) { return true } + return false + } + + nonisolated static func parseA2UIActionBody(_ body: Any) -> [String: Any]? { + if let dict = body as? [String: Any] { return dict.isEmpty ? nil : dict } + if let str = body as? String, + let data = str.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + { + return json.isEmpty ? nil : json + } + if let dict = body as? [AnyHashable: Any] { + let mapped = dict.reduce(into: [String: Any]()) { acc, pair in + guard let key = pair.key as? String else { return } + acc[key] = pair.value + } + return mapped.isEmpty ? nil : mapped + } + return nil + } +} + +extension Double { + fileprivate func clamped(to range: ClosedRange) -> Double { + if self < range.lowerBound { return range.lowerBound } + if self > range.upperBound { return range.upperBound } + return self + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Screen/ScreenRecordService.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Screen/ScreenRecordService.swift new file mode 100644 index 00000000..c353d86f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Screen/ScreenRecordService.swift @@ -0,0 +1,360 @@ +import AVFoundation +import ReplayKit + +final class ScreenRecordService: @unchecked Sendable { + private struct UncheckedSendableBox: @unchecked Sendable { + let value: T + } + + private final class CaptureState: @unchecked Sendable { + private let lock = NSLock() + var writer: AVAssetWriter? + var videoInput: AVAssetWriterInput? + var audioInput: AVAssetWriterInput? + var started = false + var sawVideo = false + var lastVideoTime: CMTime? + var handlerError: Error? + + func withLock(_ body: (CaptureState) -> T) -> T { + self.lock.lock() + defer { lock.unlock() } + return body(self) + } + } + + enum ScreenRecordError: LocalizedError { + case invalidScreenIndex(Int) + case captureFailed(String) + case writeFailed(String) + + var errorDescription: String? { + switch self { + case let .invalidScreenIndex(idx): + "Invalid screen index \(idx)" + case let .captureFailed(msg): + msg + case let .writeFailed(msg): + msg + } + } + } + + func record( + screenIndex: Int?, + durationMs: Int?, + fps: Double?, + includeAudio: Bool?, + outPath: String?) async throws -> String + { + let config = try self.makeRecordConfig( + screenIndex: screenIndex, + durationMs: durationMs, + fps: fps, + includeAudio: includeAudio, + outPath: outPath) + + let state = CaptureState() + let recordQueue = DispatchQueue(label: "ai.openclaw.screenrecord") + + try await self.startCapture(state: state, config: config, recordQueue: recordQueue) + try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000) + try await self.stopCapture() + try self.finalizeCapture(state: state) + try await self.finishWriting(state: state) + + return config.outURL.path + } + + private struct RecordConfig { + let durationMs: Int + let fpsValue: Double + let includeAudio: Bool + let outURL: URL + } + + private func makeRecordConfig( + screenIndex: Int?, + durationMs: Int?, + fps: Double?, + includeAudio: Bool?, + outPath: String?) throws -> RecordConfig + { + if let idx = screenIndex, idx != 0 { + throw ScreenRecordError.invalidScreenIndex(idx) + } + + let durationMs = Self.clampDurationMs(durationMs) + let fps = Self.clampFps(fps) + let fpsInt = Int32(fps.rounded()) + let fpsValue = Double(fpsInt) + let includeAudio = includeAudio ?? true + + let outURL = self.makeOutputURL(outPath: outPath) + try? FileManager().removeItem(at: outURL) + + return RecordConfig( + durationMs: durationMs, + fpsValue: fpsValue, + includeAudio: includeAudio, + outURL: outURL) + } + + private func makeOutputURL(outPath: String?) -> URL { + if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: outPath) + } + return FileManager().temporaryDirectory + .appendingPathComponent("openclaw-screen-record-\(UUID().uuidString).mp4") + } + + private func startCapture( + state: CaptureState, + config: RecordConfig, + recordQueue: DispatchQueue) async throws + { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let handler = self.makeCaptureHandler( + state: state, + config: config, + recordQueue: recordQueue) + let completion: @Sendable (Error?) -> Void = { error in + if let error { cont.resume(throwing: error) } else { cont.resume() } + } + + Task { @MainActor in + startReplayKitCapture( + includeAudio: config.includeAudio, + handler: handler, + completion: completion) + } + } + } + + private func makeCaptureHandler( + state: CaptureState, + config: RecordConfig, + recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void + { + { sample, type, error in + let sampleBox = UncheckedSendableBox(value: sample) + // ReplayKit can call the capture handler on a background queue. + // Serialize writes to avoid queue asserts. + recordQueue.async { + let sample = sampleBox.value + if let error { + state.withLock { state in + if state.handlerError == nil { state.handlerError = error } + } + return + } + guard CMSampleBufferDataIsReady(sample) else { return } + + switch type { + case .video: + self.handleVideoSample(sample, state: state, config: config) + case .audioApp, .audioMic: + self.handleAudioSample(sample, state: state, includeAudio: config.includeAudio) + @unknown default: + break + } + } + } + } + + private func handleVideoSample( + _ sample: CMSampleBuffer, + state: CaptureState, + config: RecordConfig) + { + let pts = CMSampleBufferGetPresentationTimeStamp(sample) + let shouldSkip = state.withLock { state in + if let lastVideoTime = state.lastVideoTime { + let delta = CMTimeSubtract(pts, lastVideoTime) + return delta.seconds < (1.0 / config.fpsValue) + } + return false + } + if shouldSkip { return } + + if state.withLock({ $0.writer == nil }) { + self.prepareWriter(sample: sample, state: state, config: config, pts: pts) + } + + let vInput = state.withLock { $0.videoInput } + let isStarted = state.withLock { $0.started } + guard let vInput, isStarted else { return } + if vInput.isReadyForMoreMediaData { + if vInput.append(sample) { + state.withLock { state in + state.sawVideo = true + state.lastVideoTime = pts + } + } else { + let err = state.withLock { $0.writer?.error } + if let err { + state.withLock { state in + if state.handlerError == nil { + state.handlerError = ScreenRecordError.writeFailed(err.localizedDescription) + } + } + } + } + } + } + + private func prepareWriter( + sample: CMSampleBuffer, + state: CaptureState, + config: RecordConfig, + pts: CMTime) + { + guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else { + state.withLock { state in + if state.handlerError == nil { + state.handlerError = ScreenRecordError.captureFailed("Missing image buffer") + } + } + return + } + let width = CVPixelBufferGetWidth(imageBuffer) + let height = CVPixelBufferGetHeight(imageBuffer) + do { + let writer = try AVAssetWriter(outputURL: config.outURL, fileType: .mp4) + let settings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: width, + AVVideoHeightKey: height, + ] + let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings) + vInput.expectsMediaDataInRealTime = true + guard writer.canAdd(vInput) else { + throw ScreenRecordError.writeFailed("Cannot add video input") + } + writer.add(vInput) + + if config.includeAudio { + let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil) + aInput.expectsMediaDataInRealTime = true + if writer.canAdd(aInput) { + writer.add(aInput) + state.withLock { state in + state.audioInput = aInput + } + } + } + + guard writer.startWriting() else { + throw ScreenRecordError.writeFailed( + writer.error?.localizedDescription ?? "Failed to start writer") + } + writer.startSession(atSourceTime: pts) + state.withLock { state in + state.writer = writer + state.videoInput = vInput + state.started = true + } + } catch { + state.withLock { state in + if state.handlerError == nil { state.handlerError = error } + } + } + } + + private func handleAudioSample( + _ sample: CMSampleBuffer, + state: CaptureState, + includeAudio: Bool) + { + let aInput = state.withLock { $0.audioInput } + let isStarted = state.withLock { $0.started } + guard includeAudio, let aInput, isStarted else { return } + if aInput.isReadyForMoreMediaData { + _ = aInput.append(sample) + } + } + + private func stopCapture() async throws { + let stopError = await withCheckedContinuation { cont in + Task { @MainActor in + stopReplayKitCapture { error in cont.resume(returning: error) } + } + } + if let stopError { throw stopError } + } + + private func finalizeCapture(state: CaptureState) throws { + if let handlerErrorSnapshot = state.withLock({ $0.handlerError }) { + throw handlerErrorSnapshot + } + let writerSnapshot = state.withLock { $0.writer } + let videoInputSnapshot = state.withLock { $0.videoInput } + let audioInputSnapshot = state.withLock { $0.audioInput } + let sawVideoSnapshot = state.withLock { $0.sawVideo } + guard let writerSnapshot, let videoInputSnapshot, sawVideoSnapshot else { + throw ScreenRecordError.captureFailed("No frames captured") + } + + videoInputSnapshot.markAsFinished() + audioInputSnapshot?.markAsFinished() + _ = writerSnapshot + } + + private func finishWriting(state: CaptureState) async throws { + guard let writerSnapshot = state.withLock({ $0.writer }) else { + throw ScreenRecordError.captureFailed("Missing writer") + } + let writerBox = UncheckedSendableBox(value: writerSnapshot) + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + writerBox.value.finishWriting { + let writer = writerBox.value + if let err = writer.error { + cont.resume(throwing: ScreenRecordError.writeFailed(err.localizedDescription)) + } else if writer.status != .completed { + cont.resume(throwing: ScreenRecordError.writeFailed("Failed to finalize video")) + } else { + cont.resume() + } + } + } + } + + private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { + let v = ms ?? 10000 + return min(60000, max(250, v)) + } + + private nonisolated static func clampFps(_ fps: Double?) -> Double { + let v = fps ?? 10 + if !v.isFinite { return 10 } + return min(30, max(1, v)) + } +} + +@MainActor +private func startReplayKitCapture( + includeAudio: Bool, + handler: @escaping @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void, + completion: @escaping @Sendable (Error?) -> Void) +{ + let recorder = RPScreenRecorder.shared() + recorder.isMicrophoneEnabled = includeAudio + recorder.startCapture(handler: handler, completionHandler: completion) +} + +@MainActor +private func stopReplayKitCapture(_ completion: @escaping @Sendable (Error?) -> Void) { + RPScreenRecorder.shared().stopCapture { error in completion(error) } +} + +#if DEBUG +extension ScreenRecordService { + nonisolated static func _test_clampDurationMs(_ ms: Int?) -> Int { + self.clampDurationMs(ms) + } + + nonisolated static func _test_clampFps(_ fps: Double?) -> Double { + self.clampFps(fps) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Screen/ScreenTab.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Screen/ScreenTab.swift new file mode 100644 index 00000000..16b5f857 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Screen/ScreenTab.swift @@ -0,0 +1,27 @@ +import OpenClawKit +import SwiftUI + +struct ScreenTab: View { + @Environment(NodeAppModel.self) private var appModel + + var body: some View { + ZStack(alignment: .top) { + ScreenWebView(controller: self.appModel.screen) + .ignoresSafeArea() + .overlay(alignment: .top) { + if let errorText = self.appModel.screen.errorText, + self.appModel.gatewayServerName == nil + { + Text(errorText) + .font(.footnote) + .padding(10) + .background(.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding() + } + } + } + } + + // Navigation is agent-driven; no local URL bar here. +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Screen/ScreenWebView.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Screen/ScreenWebView.swift new file mode 100644 index 00000000..a30d78cb --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Screen/ScreenWebView.swift @@ -0,0 +1,193 @@ +import OpenClawKit +import SwiftUI +import WebKit + +struct ScreenWebView: UIViewRepresentable { + var controller: ScreenController + + func makeCoordinator() -> ScreenWebViewCoordinator { + ScreenWebViewCoordinator(controller: self.controller) + } + + func makeUIView(context: Context) -> UIView { + context.coordinator.makeContainerView() + } + + func updateUIView(_: UIView, context: Context) { + context.coordinator.updateController(self.controller) + } + + static func dismantleUIView(_: UIView, coordinator: ScreenWebViewCoordinator) { + coordinator.teardown() + } +} + +@MainActor +final class ScreenWebViewCoordinator: NSObject { + private weak var controller: ScreenController? + private let navigationDelegate = ScreenNavigationDelegate() + private let a2uiActionHandler = CanvasA2UIActionMessageHandler() + private let userContentController = WKUserContentController() + + private(set) var managedWebView: WKWebView? + private weak var containerView: UIView? + + init(controller: ScreenController) { + self.controller = controller + super.init() + self.navigationDelegate.controller = controller + self.a2uiActionHandler.controller = controller + } + + func makeContainerView() -> UIView { + if let containerView { + return containerView + } + + let container = UIView(frame: .zero) + container.backgroundColor = .black + + let webView = Self.makeWebView(userContentController: self.userContentController) + webView.navigationDelegate = self.navigationDelegate + self.installA2UIHandlers() + + webView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(webView) + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + webView.topAnchor.constraint(equalTo: container.topAnchor), + webView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + self.managedWebView = webView + self.containerView = container + self.controller?.attachWebView(webView) + return container + } + + func updateController(_ controller: ScreenController) { + let previousController = self.controller + let controllerChanged = self.controller !== controller + self.controller = controller + self.navigationDelegate.controller = controller + self.a2uiActionHandler.controller = controller + if controllerChanged, let managedWebView { + previousController?.detachWebView(managedWebView) + controller.attachWebView(managedWebView) + } + } + + func teardown() { + if let managedWebView { + self.controller?.detachWebView(managedWebView) + managedWebView.navigationDelegate = nil + } + self.removeA2UIHandlers() + self.navigationDelegate.controller = nil + self.a2uiActionHandler.controller = nil + self.managedWebView = nil + self.containerView = nil + } + + private static func makeWebView(userContentController: WKUserContentController) -> WKWebView { + let config = WKWebViewConfiguration() + config.websiteDataStore = .nonPersistent() + config.userContentController = userContentController + + let webView = WKWebView(frame: .zero, configuration: config) + // Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays. + webView.isOpaque = true + webView.backgroundColor = .black + + let scrollView = webView.scrollView + scrollView.backgroundColor = .black + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.contentInset = .zero + scrollView.scrollIndicatorInsets = .zero + scrollView.automaticallyAdjustsScrollIndicatorInsets = false + + return webView + } + + private func installA2UIHandlers() { + for name in CanvasA2UIActionMessageHandler.handlerNames { + self.userContentController.add(self.a2uiActionHandler, name: name) + } + } + + private func removeA2UIHandlers() { + for name in CanvasA2UIActionMessageHandler.handlerNames { + self.userContentController.removeScriptMessageHandler(forName: name) + } + } +} + +// MARK: - Navigation Delegate + +/// Handles navigation policy to intercept openclaw:// deep links from canvas +@MainActor +private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate { + weak var controller: ScreenController? + + func webView( + _: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) + { + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + // Intercept openclaw:// deep links. + if url.scheme?.lowercased() == "openclaw" { + decisionHandler(.cancel) + self.controller?.onDeepLink?(url) + return + } + + decisionHandler(.allow) + } + + func webView( + _: WKWebView, + didFailProvisionalNavigation _: WKNavigation?, + withError error: any Error) + { + self.controller?.errorText = error.localizedDescription + } + + func webView(_: WKWebView, didFinish _: WKNavigation?) { + self.controller?.errorText = nil + self.controller?.applyDebugStatusIfNeeded() + } + + func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) { + self.controller?.errorText = error.localizedDescription + } +} + +private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { + static let messageName = "openclawCanvasA2UIAction" + static let handlerNames = [messageName] + + weak var controller: ScreenController? + + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + guard Self.handlerNames.contains(message.name) else { return } + guard let controller else { return } + + guard let url = message.webView?.url else { return } + if url.isFileURL { + guard controller.isTrustedCanvasUIURL(url) else { return } + } else { + // For security, only accept actions from local-network pages (e.g. the canvas host). + guard controller.isLocalNetworkCanvasURL(url) else { return } + } + + guard let body = ScreenController.parseA2UIActionBody(message.body) else { return } + + controller.onA2UIAction?(body) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Services/NodeServiceProtocols.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Services/NodeServiceProtocols.swift new file mode 100644 index 00000000..27ee7cc2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Services/NodeServiceProtocols.swift @@ -0,0 +1,103 @@ +import CoreLocation +import Foundation +import OpenClawKit +import UIKit + +protocol CameraServicing: Sendable { + func listDevices() async -> [CameraController.CameraDeviceInfo] + func snap(params: OpenClawCameraSnapParams) async throws -> (format: String, base64: String, width: Int, height: Int) + func clip(params: OpenClawCameraClipParams) async throws -> (format: String, base64: String, durationMs: Int, hasAudio: Bool) +} + +protocol ScreenRecordingServicing: Sendable { + func record( + screenIndex: Int?, + durationMs: Int?, + fps: Double?, + includeAudio: Bool?, + outPath: String?) async throws -> String +} + +@MainActor +protocol LocationServicing: Sendable { + func authorizationStatus() -> CLAuthorizationStatus + func accuracyAuthorization() -> CLAccuracyAuthorization + func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus + func currentLocation( + params: OpenClawLocationGetParams, + desiredAccuracy: OpenClawLocationAccuracy, + maxAgeMs: Int?, + timeoutMs: Int?) async throws -> CLLocation + func startLocationUpdates( + desiredAccuracy: OpenClawLocationAccuracy, + significantChangesOnly: Bool) -> AsyncStream + func stopLocationUpdates() + func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) + func stopMonitoringSignificantLocationChanges() +} + +protocol DeviceStatusServicing: Sendable { + func status() async throws -> OpenClawDeviceStatusPayload + func info() -> OpenClawDeviceInfoPayload +} + +protocol PhotosServicing: Sendable { + func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload +} + +protocol ContactsServicing: Sendable { + func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload + func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload +} + +protocol CalendarServicing: Sendable { + func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload + func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload +} + +protocol RemindersServicing: Sendable { + func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload + func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload +} + +protocol MotionServicing: Sendable { + func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload + func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload +} + +struct WatchMessagingStatus: Sendable, Equatable { + var supported: Bool + var paired: Bool + var appInstalled: Bool + var reachable: Bool + var activationState: String +} + +struct WatchQuickReplyEvent: Sendable, Equatable { + var replyId: String + var promptId: String + var actionId: String + var actionLabel: String? + var sessionKey: String? + var note: String? + var sentAtMs: Int? + var transport: String +} + +struct WatchNotificationSendResult: Sendable, Equatable { + var deliveredImmediately: Bool + var queuedForDelivery: Bool + var transport: String +} + +protocol WatchMessagingServicing: AnyObject, Sendable { + func status() async -> WatchMessagingStatus + func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) + func sendNotification( + id: String, + params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult +} + +extension CameraController: CameraServicing {} +extension ScreenRecordService: ScreenRecordingServicing {} +extension LocationService: LocationServicing {} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Services/NotificationService.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Services/NotificationService.swift new file mode 100644 index 00000000..348e93ed --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Services/NotificationService.swift @@ -0,0 +1,58 @@ +import Foundation +import UserNotifications + +enum NotificationAuthorizationStatus: Sendable { + case notDetermined + case denied + case authorized + case provisional + case ephemeral +} + +protocol NotificationCentering: Sendable { + func authorizationStatus() async -> NotificationAuthorizationStatus + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool + func add(_ request: UNNotificationRequest) async throws +} + +struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable { + private let center: UNUserNotificationCenter + + init(center: UNUserNotificationCenter = .current()) { + self.center = center + } + + func authorizationStatus() async -> NotificationAuthorizationStatus { + let settings = await self.center.notificationSettings() + return switch settings.authorizationStatus { + case .authorized: + .authorized + case .provisional: + .provisional + case .ephemeral: + .ephemeral + case .denied: + .denied + case .notDetermined: + .notDetermined + @unknown default: + .denied + } + } + + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { + try await self.center.requestAuthorization(options: options) + } + + func add(_ request: UNNotificationRequest) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + self.center.add(request) { error in + if let error { + cont.resume(throwing: error) + } else { + cont.resume(returning: ()) + } + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Services/WatchMessagingService.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Services/WatchMessagingService.swift new file mode 100644 index 00000000..3511a06c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Services/WatchMessagingService.swift @@ -0,0 +1,280 @@ +import Foundation +import OpenClawKit +import OSLog +@preconcurrency import WatchConnectivity + +enum WatchMessagingError: LocalizedError { + case unsupported + case notPaired + case watchAppNotInstalled + + var errorDescription: String? { + switch self { + case .unsupported: + "WATCH_UNAVAILABLE: WatchConnectivity is not supported on this device" + case .notPaired: + "WATCH_UNAVAILABLE: no paired Apple Watch" + case .watchAppNotInstalled: + "WATCH_UNAVAILABLE: OpenClaw watch companion app is not installed" + } + } +} + +final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable { + private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging") + private let session: WCSession? + private let replyHandlerLock = NSLock() + private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? + + override init() { + if WCSession.isSupported() { + self.session = WCSession.default + } else { + self.session = nil + } + super.init() + if let session = self.session { + session.delegate = self + session.activate() + } + } + + static func isSupportedOnDevice() -> Bool { + WCSession.isSupported() + } + + static func currentStatusSnapshot() -> WatchMessagingStatus { + guard WCSession.isSupported() else { + return WatchMessagingStatus( + supported: false, + paired: false, + appInstalled: false, + reachable: false, + activationState: "unsupported") + } + let session = WCSession.default + return status(for: session) + } + + func status() async -> WatchMessagingStatus { + await self.ensureActivated() + guard let session = self.session else { + return WatchMessagingStatus( + supported: false, + paired: false, + appInstalled: false, + reachable: false, + activationState: "unsupported") + } + return Self.status(for: session) + } + + func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { + self.replyHandlerLock.lock() + self.replyHandler = handler + self.replyHandlerLock.unlock() + } + + func sendNotification( + id: String, + params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult + { + await self.ensureActivated() + guard let session = self.session else { + throw WatchMessagingError.unsupported + } + + let snapshot = Self.status(for: session) + guard snapshot.paired else { throw WatchMessagingError.notPaired } + guard snapshot.appInstalled else { throw WatchMessagingError.watchAppNotInstalled } + + var payload: [String: Any] = [ + "type": "watch.notify", + "id": id, + "title": params.title, + "body": params.body, + "priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue, + "sentAtMs": Int(Date().timeIntervalSince1970 * 1000), + ] + if let promptId = Self.nonEmpty(params.promptId) { + payload["promptId"] = promptId + } + if let sessionKey = Self.nonEmpty(params.sessionKey) { + payload["sessionKey"] = sessionKey + } + if let kind = Self.nonEmpty(params.kind) { + payload["kind"] = kind + } + if let details = Self.nonEmpty(params.details) { + payload["details"] = details + } + if let expiresAtMs = params.expiresAtMs { + payload["expiresAtMs"] = expiresAtMs + } + if let risk = params.risk { + payload["risk"] = risk.rawValue + } + if let actions = params.actions, !actions.isEmpty { + payload["actions"] = actions.map { action in + var encoded: [String: Any] = [ + "id": action.id, + "label": action.label, + ] + if let style = Self.nonEmpty(action.style) { + encoded["style"] = style + } + return encoded + } + } + + if snapshot.reachable { + do { + try await self.sendReachableMessage(payload, with: session) + return WatchNotificationSendResult( + deliveredImmediately: true, + queuedForDelivery: false, + transport: "sendMessage") + } catch { + Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)") + } + } + + _ = session.transferUserInfo(payload) + return WatchNotificationSendResult( + deliveredImmediately: false, + queuedForDelivery: true, + transport: "transferUserInfo") + } + + private func sendReachableMessage(_ payload: [String: Any], with session: WCSession) async throws { + try await withCheckedThrowingContinuation { continuation in + session.sendMessage(payload, replyHandler: { _ in + continuation.resume() + }, errorHandler: { error in + continuation.resume(throwing: error) + }) + } + } + + private func emitReply(_ event: WatchQuickReplyEvent) { + let handler: ((WatchQuickReplyEvent) -> Void)? + self.replyHandlerLock.lock() + handler = self.replyHandler + self.replyHandlerLock.unlock() + handler?(event) + } + + private static func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func parseQuickReplyPayload( + _ payload: [String: Any], + transport: String) -> WatchQuickReplyEvent? + { + guard (payload["type"] as? String) == "watch.reply" else { + return nil + } + guard let actionId = nonEmpty(payload["actionId"] as? String) else { + return nil + } + let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown" + let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString + let actionLabel = nonEmpty(payload["actionLabel"] as? String) + let sessionKey = nonEmpty(payload["sessionKey"] as? String) + let note = nonEmpty(payload["note"] as? String) + let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + + return WatchQuickReplyEvent( + replyId: replyId, + promptId: promptId, + actionId: actionId, + actionLabel: actionLabel, + sessionKey: sessionKey, + note: note, + sentAtMs: sentAtMs, + transport: transport) + } + + private func ensureActivated() async { + guard let session = self.session else { return } + if session.activationState == .activated { return } + session.activate() + for _ in 0..<8 { + if session.activationState == .activated { return } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + + private static func status(for session: WCSession) -> WatchMessagingStatus { + WatchMessagingStatus( + supported: true, + paired: session.isPaired, + appInstalled: session.isWatchAppInstalled, + reachable: session.isReachable, + activationState: activationStateLabel(session.activationState)) + } + + private static func activationStateLabel(_ state: WCSessionActivationState) -> String { + switch state { + case .notActivated: + "notActivated" + case .inactive: + "inactive" + case .activated: + "activated" + @unknown default: + "unknown" + } + } +} + +extension WatchMessagingService: WCSessionDelegate { + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: (any Error)?) + { + if let error { + Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)") + return + } + Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)") + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + + func session(_: WCSession, didReceiveMessage message: [String: Any]) { + guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else { + return + } + self.emitReply(event) + } + + func session( + _: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void) + { + guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else { + replyHandler(["ok": false, "error": "unsupported_payload"]) + return + } + replyHandler(["ok": true]) + self.emitReply(event) + } + + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else { + return + } + self.emitReply(event) + } + + func sessionReachabilityDidChange(_ session: WCSession) {} +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/SessionKey.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/SessionKey.swift new file mode 100644 index 00000000..89798b6a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/SessionKey.swift @@ -0,0 +1,23 @@ +import Foundation + +enum SessionKey { + static func normalizeMainKey(_ raw: String?) -> String { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "main" : trimmed + } + + static func makeAgentSessionKey(agentId: String, baseKey: String) -> String { + let trimmedAgent = agentId.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedBase = baseKey.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedAgent.isEmpty { return trimmedBase.isEmpty ? "main" : trimmedBase } + let normalizedBase = trimmedBase.isEmpty ? "main" : trimmedBase + return "agent:\(trimmedAgent):\(normalizedBase)" + } + + static func isCanonicalMainSessionKey(_ value: String?) -> Bool { + let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return false } + if trimmed == "global" { return true } + return trimmed.hasPrefix("agent:") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift new file mode 100644 index 00000000..f061ff9a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift @@ -0,0 +1,40 @@ +import Foundation + +struct SettingsHostPort: Equatable { + var host: String + var port: Int +} + +enum SettingsNetworkingHelpers { + static func parseHostPort(from address: String) -> SettingsHostPort? { + let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("["), + let close = trimmed.firstIndex(of: "]"), + close < trimmed.endIndex + { + let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. String { + if let host, let port { + let needsBrackets = host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]") + let hostPart = needsBrackets ? "[\(host)]" : host + return "http://\(hostPart):\(port)" + } + return "http://\(fallback)" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Settings/SettingsTab.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Settings/SettingsTab.swift new file mode 100644 index 00000000..3ff2ed46 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Settings/SettingsTab.swift @@ -0,0 +1,1016 @@ +import OpenClawKit +import Network +import Observation +import os +import SwiftUI +import UIKit + +struct SettingsTab: View { + private struct FeatureHelp: Identifiable { + let id = UUID() + let title: String + let message: String + } + + @Environment(NodeAppModel.self) private var appModel: NodeAppModel + @Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager + @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController + @Environment(\.dismiss) private var dismiss + @AppStorage("node.displayName") private var displayName: String = "iOS Node" + @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString + @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false + @AppStorage("talk.enabled") private var talkEnabled: Bool = false + @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true + @AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false + @AppStorage("talk.voiceDirectiveHint.enabled") private var talkVoiceDirectiveHintEnabled: Bool = true + @AppStorage("camera.enabled") private var cameraEnabled: Bool = true + @AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue + @AppStorage("screen.preventSleep") private var preventSleep: Bool = true + @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" + @AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = "" + @AppStorage("gateway.autoconnect") private var gatewayAutoConnect: Bool = false + @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false + @AppStorage("gateway.manual.host") private var manualGatewayHost: String = "" + @AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789 + @AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true + @AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false + @AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false + + // Onboarding control (RootCanvas listens to onboarding.requestID and force-opens the wizard). + @AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0 + @AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false + @AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false + + @State private var connectingGatewayID: String? + @State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue + @State private var gatewayToken: String = "" + @State private var gatewayPassword: String = "" + @State private var defaultShareInstruction: String = "" + @AppStorage("gateway.setupCode") private var setupCode: String = "" + @State private var setupStatusText: String? + @State private var manualGatewayPortText: String = "" + @State private var gatewayExpanded: Bool = true + @State private var selectedAgentPickerId: String = "" + + @State private var showResetOnboardingAlert: Bool = false + @State private var activeFeatureHelp: FeatureHelp? + @State private var suppressCredentialPersist: Bool = false + + private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings") + + var body: some View { + NavigationStack { + Form { + Section { + DisclosureGroup(isExpanded: self.$gatewayExpanded) { + if !self.isGatewayConnected { + Text( + "1. Open Telegram and message your bot: /pair\n" + + "2. Copy the setup code it returns\n" + + "3. Paste here and tap Connect\n" + + "4. Back in Telegram, run /pair approve") + .font(.footnote) + .foregroundStyle(.secondary) + + if let warning = self.tailnetWarningText { + Text(warning) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.orange) + } + + TextField("Paste setup code", text: self.$setupCode) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button { + Task { await self.applySetupCodeAndConnect() } + } label: { + if self.connectingGatewayID == "manual" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect with setup code") + } + } + .disabled(self.connectingGatewayID != nil + || self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if let status = self.setupStatusLine { + Text(status) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + if self.isGatewayConnected { + Picker("Bot", selection: self.$selectedAgentPickerId) { + Text("Default").tag("") + let defaultId = (self.appModel.gatewayDefaultAgentId ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + ForEach(self.appModel.gatewayAgents.filter { $0.id != defaultId }, id: \.id) { agent in + let name = (agent.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + Text(name.isEmpty ? agent.id : name).tag(agent.id) + } + } + Text("Controls which bot Chat and Talk speak to.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if self.appModel.gatewayServerName == nil { + LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) + } + LabeledContent("Status", value: self.appModel.gatewayStatusText) + Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect) + + if let serverName = self.appModel.gatewayServerName { + LabeledContent("Server", value: serverName) + if let addr = self.appModel.gatewayRemoteAddress { + let parts = Self.parseHostPort(from: addr) + let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr) + LabeledContent("Address") { + Text(urlString) + } + .contextMenu { + Button { + UIPasteboard.general.string = urlString + } label: { + Label("Copy URL", systemImage: "doc.on.doc") + } + + if let parts { + Button { + UIPasteboard.general.string = parts.host + } label: { + Label("Copy Host", systemImage: "doc.on.doc") + } + + Button { + UIPasteboard.general.string = "\(parts.port)" + } label: { + Label("Copy Port", systemImage: "doc.on.doc") + } + } + } + } + + Button("Disconnect", role: .destructive) { + self.appModel.disconnectGateway() + } + } else { + self.gatewayList(showing: .all) + } + + DisclosureGroup("Advanced") { + Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled) + + TextField("Host", text: self.$manualGatewayHost) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + TextField("Port (optional)", text: self.manualPortBinding) + .keyboardType(.numberPad) + + Toggle("Use TLS", isOn: self.$manualGatewayTLS) + + Button { + Task { await self.connectManual() } + } label: { + if self.connectingGatewayID == "manual" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect (Manual)") + } + } + .disabled(self.connectingGatewayID != nil || self.manualGatewayHost + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty || !self.manualPortIsValid) + + Text( + "Use this when mDNS/Bonjour discovery is blocked. " + + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.") + .font(.footnote) + .foregroundStyle(.secondary) + + Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) + .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in + self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue) + } + + NavigationLink("Discovery Logs") { + GatewayDiscoveryDebugLogView() + } + + Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled) + + TextField("Gateway Auth Token", text: self.$gatewayToken) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + SecureField("Gateway Password", text: self.$gatewayPassword) + + Button("Reset Onboarding", role: .destructive) { + self.showResetOnboardingAlert = true + } + + VStack(alignment: .leading, spacing: 6) { + Text("Debug") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + Text(self.gatewayDebugText()) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + } label: { + HStack(spacing: 10) { + Circle() + .fill(self.isGatewayConnected ? Color.green : Color.secondary.opacity(0.35)) + .frame(width: 10, height: 10) + Text("Gateway") + Spacer() + Text(self.gatewaySummaryText) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + Section("Device") { + DisclosureGroup("Features") { + self.featureToggle( + "Voice Wake", + isOn: self.$voiceWakeEnabled, + help: "Enables wake-word activation to start a hands-free session.") { newValue in + self.appModel.setVoiceWakeEnabled(newValue) + } + self.featureToggle( + "Talk Mode", + isOn: self.$talkEnabled, + help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in + self.appModel.setTalkEnabled(newValue) + } + self.featureToggle( + "Background Listening", + isOn: self.$talkBackgroundEnabled, + help: "Keeps listening while the app is backgrounded. Uses more battery.") + + NavigationLink { + VoiceWakeWordsSettingsView() + } label: { + LabeledContent( + "Wake Words", + value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords)) + } + + self.featureToggle( + "Allow Camera", + isOn: self.$cameraEnabled, + help: "Allows the gateway to request photos or short video clips while OpenClaw is foregrounded.") + + HStack(spacing: 8) { + Text("Location Access") + Spacer() + Button { + self.activeFeatureHelp = FeatureHelp( + title: "Location Access", + message: "Controls location permissions for OpenClaw. Off disables location tools, While Using enables foreground location, and Always enables background location.") + } label: { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Location Access info") + } + Picker("Location Access", selection: self.$locationEnabledModeRaw) { + Text("Off").tag(OpenClawLocationMode.off.rawValue) + Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue) + Text("Always").tag(OpenClawLocationMode.always.rawValue) + } + .labelsHidden() + .pickerStyle(.segmented) + + self.featureToggle( + "Prevent Sleep", + isOn: self.$preventSleep, + help: "Keeps the screen awake while OpenClaw is open.") + + DisclosureGroup("Advanced") { + VStack(alignment: .leading, spacing: 8) { + Text("Talk Voice (Gateway)") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + LabeledContent("Provider", value: "ElevenLabs") + LabeledContent( + "API Key", + value: self.appModel.talkMode.gatewayTalkConfigLoaded + ? (self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured") + : "Not loaded") + LabeledContent( + "Default Model", + value: self.appModel.talkMode.gatewayTalkDefaultModelId ?? "eleven_v3 (fallback)") + LabeledContent( + "Default Voice", + value: self.appModel.talkMode.gatewayTalkDefaultVoiceId ?? "auto (first available)") + Text("Configured on gateway via talk.apiKey, talk.modelId, and talk.voiceId.") + .font(.footnote) + .foregroundStyle(.secondary) + } + self.featureToggle( + "Voice Directive Hint", + isOn: self.$talkVoiceDirectiveHintEnabled, + help: "Adds voice-switching instructions to Talk prompts. Disable to reduce prompt size.") + self.featureToggle( + "Show Talk Button", + isOn: self.$talkButtonEnabled, + help: "Shows the floating Talk button in the main interface.") + TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical) + .lineLimit(2 ... 6) + .textInputAutocapitalization(.sentences) + HStack(spacing: 8) { + Text("Default Share Instruction") + .font(.footnote) + .foregroundStyle(.secondary) + Spacer() + Button { + self.activeFeatureHelp = FeatureHelp( + title: "Default Share Instruction", + message: "Appends this instruction when sharing content into OpenClaw from iOS.") + } label: { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Default Share Instruction info") + } + + VStack(alignment: .leading, spacing: 8) { + Button { + Task { await self.appModel.runSharePipelineSelfTest() } + } label: { + Label("Run Share Self-Test", systemImage: "checkmark.seal") + } + Text(self.appModel.lastShareEventText) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + DisclosureGroup("Device Info") { + TextField("Name", text: self.$displayName) + Text(self.instanceId) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + LabeledContent("Device", value: DeviceInfoHelper.deviceFamily()) + LabeledContent("Platform", value: DeviceInfoHelper.platformStringForDisplay()) + LabeledContent("OpenClaw", value: DeviceInfoHelper.openClawVersionString()) + } + } + } + .navigationTitle("Settings") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + self.dismiss() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("Close") + } + } + .alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) { + Button("Reset", role: .destructive) { + self.resetOnboarding() + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + "This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.") + } + .alert(item: self.$activeFeatureHelp) { help in + Alert( + title: Text(help.title), + message: Text(help.message), + dismissButton: .default(Text("OK"))) + } + .onAppear { + self.lastLocationModeRaw = self.locationEnabledModeRaw + self.syncManualPortText() + let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedInstanceId.isEmpty { + self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? "" + self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? "" + } + self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction() + self.appModel.refreshLastShareEventFromRelay() + // Keep setup front-and-center when disconnected; keep things compact once connected. + self.gatewayExpanded = !self.isGatewayConnected + self.selectedAgentPickerId = self.appModel.selectedAgentId ?? "" + if self.isGatewayConnected { + self.appModel.reloadTalkConfig() + } + } + .onChange(of: self.selectedAgentPickerId) { _, newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed) + } + .onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in + if newValue != self.selectedAgentPickerId { + self.selectedAgentPickerId = newValue + } + } + .onChange(of: self.preferredGatewayStableID) { _, newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + GatewaySettingsStore.savePreferredGatewayStableID(trimmed) + } + .onChange(of: self.gatewayToken) { _, newValue in + guard !self.suppressCredentialPersist else { return } + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !instanceId.isEmpty else { return } + GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId) + } + .onChange(of: self.gatewayPassword) { _, newValue in + guard !self.suppressCredentialPersist else { return } + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !instanceId.isEmpty else { return } + GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId) + } + .onChange(of: self.defaultShareInstruction) { _, newValue in + ShareToAgentSettings.saveDefaultInstruction(newValue) + } + .onChange(of: self.manualGatewayPort) { _, _ in + self.syncManualPortText() + } + .onChange(of: self.appModel.gatewayServerName) { _, newValue in + if newValue != nil { + self.setupCode = "" + self.setupStatusText = nil + return + } + if self.manualGatewayEnabled { + self.setupStatusText = self.appModel.gatewayStatusText + } + } + .onChange(of: self.appModel.gatewayStatusText) { _, newValue in + guard self.manualGatewayEnabled || self.connectingGatewayID == "manual" else { return } + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + self.setupStatusText = trimmed + } + .onChange(of: self.locationEnabledModeRaw) { _, newValue in + let previous = self.lastLocationModeRaw + self.lastLocationModeRaw = newValue + guard let mode = OpenClawLocationMode(rawValue: newValue) else { return } + Task { + let granted = await self.appModel.requestLocationPermissions(mode: mode) + if !granted { + await MainActor.run { + self.locationEnabledModeRaw = previous + self.lastLocationModeRaw = previous + } + return + } + await MainActor.run { + self.gatewayController.refreshActiveGatewayRegistrationFromSettings() + } + } + } + } + .gatewayTrustPromptAlert() + } + + @ViewBuilder + private func gatewayList(showing: GatewayListMode) -> some View { + if self.gatewayController.gateways.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("No gateways found yet.") + .foregroundStyle(.secondary) + Text("If your gateway is on another network, connect it and ensure DNS is working.") + .font(.footnote) + .foregroundStyle(.secondary) + + if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection(), + case let .manual(host, port, _, _) = lastKnown + { + Button { + Task { await self.connectLastKnown() } + } label: { + self.lastKnownButtonLabel(host: host, port: port) + } + .disabled(self.connectingGatewayID != nil) + .buttonStyle(.borderedProminent) + .tint(self.appModel.seamColor) + } + } + } else { + let connectedID = self.appModel.connectedGatewayID + let rows = self.gatewayController.gateways.filter { gateway in + let isConnected = gateway.stableID == connectedID + switch showing { + case .all: + return true + case .availableOnly: + return !isConnected + } + } + + if rows.isEmpty, showing == .availableOnly { + Text("No other gateways found.") + .foregroundStyle(.secondary) + } else { + ForEach(rows) { gateway in + HStack { + VStack(alignment: .leading, spacing: 2) { + // Avoid localized-string formatting edge cases from Bonjour-advertised names. + Text(verbatim: gateway.name) + let detailLines = self.gatewayDetailLines(gateway) + ForEach(detailLines, id: \.self) { line in + Text(verbatim: line) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + Spacer() + + Button { + Task { await self.connect(gateway) } + } label: { + if self.connectingGatewayID == gateway.id { + ProgressView() + .progressViewStyle(.circular) + } else { + Text("Connect") + } + } + .disabled(self.connectingGatewayID != nil) + } + } + } + } + } + + private enum GatewayListMode: Equatable { + case all + case availableOnly + } + + private var isGatewayConnected: Bool { + let status = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if status.contains("connected") { return true } + return self.appModel.gatewayServerName != nil && !status.contains("offline") + } + + private var gatewaySummaryText: String { + if let server = self.appModel.gatewayServerName, self.isGatewayConnected { + return server + } + let trimmed = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Not connected" : trimmed + } + + private func featureToggle( + _ title: String, + isOn: Binding, + help: String, + onChange: ((Bool) -> Void)? = nil + ) -> some View { + HStack(spacing: 8) { + Toggle(title, isOn: isOn) + Button { + self.activeFeatureHelp = FeatureHelp(title: title, message: help) + } label: { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("\(title) info") + } + .onChange(of: isOn.wrappedValue) { _, newValue in + onChange?(newValue) + } + } + + private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { + self.connectingGatewayID = gateway.id + self.manualGatewayEnabled = false + self.preferredGatewayStableID = gateway.stableID + GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID) + self.lastDiscoveredGatewayStableID = gateway.stableID + GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID) + defer { self.connectingGatewayID = nil } + + let err = await self.gatewayController.connectWithDiagnostics(gateway) + if let err { + self.setupStatusText = err + } + } + + private func connectLastKnown() async { + self.connectingGatewayID = "last-known" + defer { self.connectingGatewayID = nil } + await self.gatewayController.connectLastKnown() + } + + private func gatewayDebugText() -> String { + var lines: [String] = [ + "gateway: \(self.appModel.gatewayStatusText)", + "discovery: \(self.gatewayController.discoveryStatusText)", + ] + lines.append("server: \(self.appModel.gatewayServerName ?? "—")") + lines.append("address: \(self.appModel.gatewayRemoteAddress ?? "—")") + if let last = self.gatewayController.discoveryDebugLog.last?.message { + lines.append("discovery log: \(last)") + } + return lines.joined(separator: "\n") + } + + @ViewBuilder + private func lastKnownButtonLabel(host: String, port: Int) -> some View { + if self.connectingGatewayID == "last-known" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting…") + } + .frame(maxWidth: .infinity) + } else { + HStack(spacing: 8) { + Image(systemName: "bolt.horizontal.circle.fill") + VStack(alignment: .leading, spacing: 2) { + Text("Connect last known") + Text("\(host):\(port)") + .font(.footnote) + .foregroundStyle(.secondary) + } + Spacer() + } + .frame(maxWidth: .infinity) + } + } + + private var manualPortBinding: Binding { + Binding( + get: { self.manualGatewayPortText }, + set: { newValue in + let filtered = newValue.filter(\.isNumber) + if self.manualGatewayPortText != filtered { + self.manualGatewayPortText = filtered + } + if filtered.isEmpty { + if self.manualGatewayPort != 0 { + self.manualGatewayPort = 0 + } + } else if let port = Int(filtered), self.manualGatewayPort != port { + self.manualGatewayPort = port + } + }) + } + + private var manualPortIsValid: Bool { + if self.manualGatewayPortText.isEmpty { return true } + return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535 + } + + private func syncManualPortText() { + if self.manualGatewayPort > 0 { + let next = String(self.manualGatewayPort) + if self.manualGatewayPortText != next { + self.manualGatewayPortText = next + } + } else if !self.manualGatewayPortText.isEmpty { + self.manualGatewayPortText = "" + } + } + + private func applySetupCodeAndConnect() async { + self.setupStatusText = nil + guard self.applySetupCode() else { return } + let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedPort = self.resolvedManualPort(host: host) + let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + GatewayDiagnostics.log( + "setup code applied host=\(host) port=\(resolvedPort ?? -1) tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)") + guard let port = resolvedPort else { + self.setupStatusText = "Failed: invalid port" + return + } + let ok = await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) + guard ok else { return } + self.setupStatusText = "Setup code applied. Connecting…" + await self.connectManual() + } + + @discardableResult + private func applySetupCode() -> Bool { + let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { + self.setupStatusText = "Paste a setup code to continue." + return false + } + + guard let payload = GatewaySetupCode.decode(raw: raw) else { + self.setupStatusText = "Setup code not recognized." + return false + } + + if let urlString = payload.url, let url = URL(string: urlString) { + self.applySetupURL(url) + } else if let host = payload.host, !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.manualGatewayHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + if let port = payload.port { + self.manualGatewayPort = port + self.manualGatewayPortText = String(port) + } else { + self.manualGatewayPort = 0 + self.manualGatewayPortText = "" + } + if let tls = payload.tls { + self.manualGatewayTLS = tls + } + } else if let url = URL(string: raw), url.scheme != nil { + self.applySetupURL(url) + } else { + self.setupStatusText = "Setup code missing URL or host." + return false + } + + let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + self.gatewayToken = trimmedToken + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId) + } + } + if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) + self.gatewayPassword = trimmedPassword + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId) + } + } + + return true + } + + private func applySetupURL(_ url: URL) { + guard let host = url.host, !host.isEmpty else { return } + self.manualGatewayHost = host + if let port = url.port { + self.manualGatewayPort = port + self.manualGatewayPortText = String(port) + } else { + self.manualGatewayPort = 0 + self.manualGatewayPortText = "" + } + let scheme = (url.scheme ?? "").lowercased() + if scheme == "wss" || scheme == "https" { + self.manualGatewayTLS = true + } else if scheme == "ws" || scheme == "http" { + self.manualGatewayTLS = false + } + } + + private func resolvedManualPort(host: String) -> Int? { + if self.manualGatewayPort > 0 { + return self.manualGatewayPort <= 65535 ? self.manualGatewayPort : nil + } + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if self.manualGatewayTLS && trimmed.lowercased().hasSuffix(".ts.net") { + return 443 + } + return 18789 + } + + private func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + + if Self.isTailnetHostOrIP(trimmed) && !Self.hasTailnetIPv4() { + let msg = "Tailscale is off on this iPhone. Turn it on, then try again." + self.setupStatusText = msg + GatewayDiagnostics.log("preflight fail: tailnet missing host=\(trimmed)") + self.gatewayLogger.warning("\(msg, privacy: .public)") + return false + } + + self.setupStatusText = "Checking gateway reachability…" + let ok = await Self.probeTCP(host: trimmed, port: port, timeoutSeconds: 3) + if !ok { + let msg = "Can't reach gateway at \(trimmed):\(port). Check Tailscale or LAN." + self.setupStatusText = msg + GatewayDiagnostics.log("preflight fail: unreachable host=\(trimmed) port=\(port)") + self.gatewayLogger.warning("\(msg, privacy: .public)") + return false + } + GatewayDiagnostics.log("preflight ok host=\(trimmed) port=\(port) tls=\(useTLS)") + return true + } + + private static func probeTCP(host: String, port: Int, timeoutSeconds: Double) async -> Bool { + await TCPProbe.probe( + host: host, + port: port, + timeoutSeconds: timeoutSeconds, + queueLabel: "gateway.preflight") + } + + // (GatewaySetupCode) decode raw setup codes. + + private func connectManual() async { + let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { + self.setupStatusText = "Failed: host required" + return + } + guard self.manualPortIsValid else { + self.setupStatusText = "Failed: invalid port" + return + } + + self.connectingGatewayID = "manual" + self.manualGatewayEnabled = true + defer { self.connectingGatewayID = nil } + + GatewayDiagnostics.log( + "connect manual host=\(host) port=\(self.manualGatewayPort) tls=\(self.manualGatewayTLS)") + await self.gatewayController.connectManual( + host: host, + port: self.manualGatewayPort, + useTLS: self.manualGatewayTLS) + } + + private var setupStatusLine: String? { + let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly } + if let friendly = self.friendlyGatewayMessage(from: trimmedSetup) { return friendly } + if !trimmedSetup.isEmpty { return trimmedSetup } + if gatewayStatus.isEmpty || gatewayStatus == "Offline" { return nil } + return gatewayStatus + } + + private var tailnetWarningText: String? { + let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { return nil } + guard Self.isTailnetHostOrIP(host) else { return nil } + guard !Self.hasTailnetIPv4() else { return nil } + return "This gateway is on your tailnet. Turn on Tailscale on this iPhone, then tap Connect." + } + + private func friendlyGatewayMessage(from raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let lower = trimmed.lowercased() + if lower.contains("pairing required") { + return "Pairing required. Go back to Telegram and run /pair approve, then tap Connect again." + } + if lower.contains("device nonce required") || lower.contains("device nonce mismatch") { + return "Secure handshake failed. Make sure Tailscale is connected, then tap Connect again." + } + if lower.contains("device signature expired") || lower.contains("device signature invalid") { + return "Secure handshake failed. Check that your iPhone time is correct, then tap Connect again." + } + if lower.contains("connect timed out") || lower.contains("timed out") { + return "Connection timed out. Make sure Tailscale is connected, then try again." + } + if lower.contains("unauthorized role") { + return "Connected, but some controls are restricted for nodes. This is expected." + } + return nil + } + + private static func hasTailnetIPv4() -> Bool { + var addrList: UnsafeMutablePointer? + guard getifaddrs(&addrList) == 0, let first = addrList else { return false } + defer { freeifaddrs(addrList) } + + for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { + let flags = Int32(ptr.pointee.ifa_flags) + let isUp = (flags & IFF_UP) != 0 + let isLoopback = (flags & IFF_LOOPBACK) != 0 + let family = ptr.pointee.ifa_addr.pointee.sa_family + if !isUp || isLoopback || family != UInt8(AF_INET) { continue } + + var addr = ptr.pointee.ifa_addr.pointee + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + &addr, + socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard result == 0 else { continue } + let len = buffer.prefix { $0 != 0 } + let bytes = len.map { UInt8(bitPattern: $0) } + guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } + if self.isTailnetIPv4(ip) { return true } + } + + return false + } + + private static func isTailnetHostOrIP(_ host: String) -> Bool { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") { + return true + } + return self.isTailnetIPv4(trimmed) + } + + private static func isTailnetIPv4(_ ip: String) -> Bool { + let parts = ip.split(separator: ".") + guard parts.count == 4 else { return false } + let octets = parts.compactMap { Int($0) } + guard octets.count == 4 else { return false } + let a = octets[0] + let b = octets[1] + guard (0...255).contains(a), (0...255).contains(b) else { return false } + return a == 100 && b >= 64 && b <= 127 + } + + private static func parseHostPort(from address: String) -> SettingsHostPort? { + SettingsNetworkingHelpers.parseHostPort(from: address) + } + + private static func httpURLString(host: String?, port: Int?, fallback: String) -> String { + SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback) + } + + private func resetOnboarding() { + // Disconnect first so RootCanvas doesn't instantly mark onboarding complete again. + self.appModel.disconnectGateway() + self.connectingGatewayID = nil + self.setupStatusText = nil + self.setupCode = "" + self.gatewayAutoConnect = false + + self.suppressCredentialPersist = true + defer { self.suppressCredentialPersist = false } + + self.gatewayToken = "" + self.gatewayPassword = "" + + let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId) + } + + // Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks). + GatewaySettingsStore.clearLastGatewayConnection() + + // RootCanvas also short-circuits onboarding when these are true. + self.onboardingComplete = false + self.hasConnectedOnce = false + + // Clear manual override so it doesn't count as an existing gateway config. + self.manualGatewayEnabled = false + self.manualGatewayHost = "" + + // Force re-present even without app restart. + self.onboardingRequestID += 1 + + // The onboarding wizard is presented from RootCanvas; dismiss Settings so it can show. + self.dismiss() + } + + private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] { + var lines: [String] = [] + if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") } + if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") } + + let gatewayPort = gateway.gatewayPort + let canvasPort = gateway.canvasPort + if gatewayPort != nil || canvasPort != nil { + let gw = gatewayPort.map(String.init) ?? "—" + let canvas = canvasPort.map(String.init) ?? "—" + lines.append("Ports: gateway \(gw) · canvas \(canvas)") + } + + if lines.isEmpty { + lines.append(gateway.debugID) + } + + return lines + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift new file mode 100644 index 00000000..e00e87e5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift @@ -0,0 +1,98 @@ +import SwiftUI +import Combine + +struct VoiceWakeWordsSettingsView: View { + @Environment(NodeAppModel.self) private var appModel + @State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords() + @FocusState private var focusedTriggerIndex: Int? + @State private var syncTask: Task? + + var body: some View { + Form { + Section { + ForEach(self.triggerWords.indices, id: \.self) { index in + TextField("Wake word", text: self.binding(for: index)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused(self.$focusedTriggerIndex, equals: index) + .onSubmit { + self.commitTriggerWords() + } + } + .onDelete(perform: self.removeWords) + + Button { + self.addWord() + } label: { + Label("Add word", systemImage: "plus") + } + .disabled(self.triggerWords + .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) + + Button("Reset defaults") { + self.triggerWords = VoiceWakePreferences.defaultTriggerWords + } + } header: { + Text("Wake Words") + } footer: { + Text( + "OpenClaw reacts when any trigger appears in a transcription. " + + "Keep them short to avoid false positives.") + } + } + .navigationTitle("Wake Words") + .toolbar { EditButton() } + .onAppear { + if self.triggerWords.isEmpty { + self.triggerWords = VoiceWakePreferences.defaultTriggerWords + self.commitTriggerWords() + } + } + .onChange(of: self.focusedTriggerIndex) { oldValue, newValue in + guard oldValue != nil, oldValue != newValue else { return } + self.commitTriggerWords() + } + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + guard self.focusedTriggerIndex == nil else { return } + let updated = VoiceWakePreferences.loadTriggerWords() + if updated != self.triggerWords { + self.triggerWords = updated + } + } + } + + private func addWord() { + self.triggerWords.append("") + } + + private func removeWords(at offsets: IndexSet) { + self.triggerWords.remove(atOffsets: offsets) + if self.triggerWords.isEmpty { + self.triggerWords = VoiceWakePreferences.defaultTriggerWords + } + self.commitTriggerWords() + } + + private func binding(for index: Int) -> Binding { + Binding( + get: { + guard self.triggerWords.indices.contains(index) else { return "" } + return self.triggerWords[index] + }, + set: { newValue in + guard self.triggerWords.indices.contains(index) else { return } + self.triggerWords[index] = newValue + }) + } + + private func commitTriggerWords() { + VoiceWakePreferences.saveTriggerWords(self.triggerWords) + + let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords) + self.syncTask?.cancel() + self.syncTask = Task { [snapshot, weak appModel = self.appModel] in + try? await Task.sleep(nanoseconds: 650_000_000) + await appModel?.setGlobalWakeWords(snapshot) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Status/StatusActivityBuilder.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Status/StatusActivityBuilder.swift new file mode 100644 index 00000000..381b3d2b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Status/StatusActivityBuilder.swift @@ -0,0 +1,71 @@ +import SwiftUI + +enum StatusActivityBuilder { + @MainActor + static func build( + appModel: NodeAppModel, + voiceWakeEnabled: Bool, + cameraHUDText: String?, + cameraHUDKind: NodeAppModel.CameraHUDKind? + ) -> StatusPill.Activity? { + // Keep the top pill consistent across tabs (camera + voice wake + pairing states). + if appModel.isBackgrounded { + return StatusPill.Activity( + title: "Foreground required", + systemImage: "exclamationmark.triangle.fill", + tint: .orange) + } + + let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + let gatewayLower = gatewayStatus.lowercased() + if gatewayLower.contains("repair") { + return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) + } + if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { + return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") + } + // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. + + if appModel.screenRecordActive { + return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) + } + + if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind { + let systemImage: String + let tint: Color? + switch cameraHUDKind { + case .photo: + systemImage = "camera.fill" + tint = nil + case .recording: + systemImage = "video.fill" + tint = .red + case .success: + systemImage = "checkmark.circle.fill" + tint = .green + case .error: + systemImage = "exclamationmark.triangle.fill" + tint = .red + } + return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint) + } + + if voiceWakeEnabled { + let voiceStatus = appModel.voiceWake.statusText + if voiceStatus.localizedCaseInsensitiveContains("microphone permission") { + return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange) + } + if voiceStatus == "Paused" { + // Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case. + if appModel.talkMode.isEnabled { + return nil + } + let suffix = appModel.isBackgrounded ? " (background)" : "" + return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill") + } + } + + return nil + } +} + diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Status/StatusPill.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Status/StatusPill.swift new file mode 100644 index 00000000..ea5e425c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Status/StatusPill.swift @@ -0,0 +1,136 @@ +import SwiftUI + +struct StatusPill: View { + @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorSchemeContrast) private var contrast + + enum GatewayState: Equatable { + case connected + case connecting + case error + case disconnected + + var title: String { + switch self { + case .connected: "Connected" + case .connecting: "Connecting…" + case .error: "Error" + case .disconnected: "Offline" + } + } + + var color: Color { + switch self { + case .connected: .green + case .connecting: .yellow + case .error: .red + case .disconnected: .gray + } + } + } + + struct Activity: Equatable { + var title: String + var systemImage: String + var tint: Color? + } + + var gateway: GatewayState + var voiceWakeEnabled: Bool + var activity: Activity? + var brighten: Bool = false + var onTap: () -> Void + + @State private var pulse: Bool = false + + var body: some View { + Button(action: self.onTap) { + HStack(spacing: 10) { + HStack(spacing: 8) { + Circle() + .fill(self.gateway.color) + .frame(width: 9, height: 9) + .scaleEffect(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.15 : 0.85) : 1.0) + .opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0) + + Text(self.gateway.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + } + + Divider() + .frame(height: 14) + .opacity(0.35) + + if let activity { + HStack(spacing: 6) { + Image(systemName: activity.systemImage) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(activity.tint ?? .primary) + Text(activity.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } else { + Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary) + .accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled") + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.ultraThinMaterial) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder( + .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)), + lineWidth: self.contrast == .increased ? 1.0 : 0.5 + ) + } + .shadow(color: .black.opacity(0.25), radius: 12, y: 6) + } + } + .buttonStyle(.plain) + .accessibilityLabel("Connection Status") + .accessibilityValue(self.accessibilityValue) + .accessibilityHint("Double tap to open settings") + .onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) } + .onDisappear { self.pulse = false } + .onChange(of: self.gateway) { _, newValue in + self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) + } + .onChange(of: self.scenePhase) { _, newValue in + self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion) + } + .onChange(of: self.reduceMotion) { _, newValue in + self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue) + } + .animation(.easeInOut(duration: 0.18), value: self.activity?.title) + } + + private var accessibilityValue: String { + if let activity { + return "\(self.gateway.title), \(activity.title)" + } + return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")" + } + + private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) { + guard gateway == .connecting, scenePhase == .active, !reduceMotion else { + withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false } + return + } + + guard !self.pulse else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + self.pulse = true + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Status/VoiceWakeToast.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Status/VoiceWakeToast.swift new file mode 100644 index 00000000..ef6fc129 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Status/VoiceWakeToast.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct VoiceWakeToast: View { + @Environment(\.colorSchemeContrast) private var contrast + + var command: String + var brighten: Bool = false + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "mic.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + + Text(self.command) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.ultraThinMaterial) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder( + .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)), + lineWidth: self.contrast == .increased ? 1.0 : 0.5 + ) + } + .shadow(color: .black.opacity(0.25), radius: 12, y: 6) + } + .accessibilityLabel("Voice Wake triggered") + .accessibilityValue("Command: \(self.command)") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/TalkModeManager.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/TalkModeManager.swift new file mode 100644 index 00000000..0f8a7e64 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/TalkModeManager.swift @@ -0,0 +1,2139 @@ +import AVFAudio +import OpenClawChatUI +import OpenClawKit +import OpenClawProtocol +import Foundation +import Observation +import OSLog +import Speech + +// This file intentionally centralizes talk mode state + behavior. +// It's large, and splitting would force `private` -> `fileprivate` across many members. +// We'll refactor into smaller files when the surface stabilizes. +// swiftlint:disable type_body_length +@MainActor +@Observable +final class TalkModeManager: NSObject { + private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest + private static let defaultModelIdFallback = "eleven_v3" + private static let defaultTalkProvider = "elevenlabs" + private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__" + var isEnabled: Bool = false + var isListening: Bool = false + var isSpeaking: Bool = false + var isPushToTalkActive: Bool = false + var statusText: String = "Off" + /// 0..1-ish (not calibrated). Intended for UI feedback only. + var micLevel: Double = 0 + var gatewayTalkConfigLoaded: Bool = false + var gatewayTalkApiKeyConfigured: Bool = false + var gatewayTalkDefaultModelId: String? + var gatewayTalkDefaultVoiceId: String? + + private enum CaptureMode { + case idle + case continuous + case pushToTalk + } + + private var captureMode: CaptureMode = .idle + private var resumeContinuousAfterPTT: Bool = false + private var activePTTCaptureId: String? + private var pttAutoStopEnabled: Bool = false + private var pttCompletion: CheckedContinuation? + private var pttTimeoutTask: Task? + + private let allowSimulatorCapture: Bool + + private let audioEngine = AVAudioEngine() + private var inputTapInstalled = false + private var audioTapDiagnostics: AudioTapDiagnostics? + private var speechRecognizer: SFSpeechRecognizer? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var silenceTask: Task? + + private var lastHeard: Date? + private var lastTranscript: String = "" + private var loggedPartialThisCycle: Bool = false + private var lastSpokenText: String? + private var lastInterruptedAtSeconds: Double? + + private var defaultVoiceId: String? + private var currentVoiceId: String? + private var defaultModelId: String? + private var currentModelId: String? + private var voiceOverrideActive = false + private var modelOverrideActive = false + private var defaultOutputFormat: String? + private var apiKey: String? + private var voiceAliases: [String: String] = [:] + private var interruptOnSpeech: Bool = true + private var mainSessionKey: String = "main" + private var fallbackVoiceId: String? + private var lastPlaybackWasPCM: Bool = false + var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared + var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared + + private var gateway: GatewayNodeSession? + private var gatewayConnected = false + private let silenceWindow: TimeInterval = 0.9 + private var lastAudioActivity: Date? + private var noiseFloorSamples: [Double] = [] + private var noiseFloor: Double? + private var noiseFloorReady: Bool = false + + private var chatSubscribedSessionKeys = Set() + private var incrementalSpeechQueue: [String] = [] + private var incrementalSpeechTask: Task? + private var incrementalSpeechActive = false + private var incrementalSpeechUsed = false + private var incrementalSpeechLanguage: String? + private var incrementalSpeechBuffer = IncrementalSpeechBuffer() + private var incrementalSpeechContext: IncrementalSpeechContext? + private var incrementalSpeechDirective: TalkDirective? + private var incrementalSpeechPrefetch: IncrementalSpeechPrefetchState? + private var incrementalSpeechPrefetchMonitorTask: Task? + + private let logger = Logger(subsystem: "ai.openclaw", category: "TalkMode") + + init(allowSimulatorCapture: Bool = false) { + self.allowSimulatorCapture = allowSimulatorCapture + super.init() + } + + func attachGateway(_ gateway: GatewayNodeSession) { + self.gateway = gateway + } + + func updateGatewayConnected(_ connected: Bool) { + self.gatewayConnected = connected + if connected { + // If talk mode is enabled before the gateway connects (common on cold start), + // kick recognition once we're online so the UI doesn’t stay “Offline”. + if self.isEnabled, !self.isListening, self.captureMode != .pushToTalk { + Task { await self.start() } + } + } else { + if self.isEnabled, !self.isSpeaking { + self.statusText = "Offline" + } + } + } + + func updateMainSessionKey(_ sessionKey: String?) { + let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if trimmed == self.mainSessionKey { return } + self.mainSessionKey = trimmed + if self.gatewayConnected, self.isEnabled { + Task { await self.subscribeChatIfNeeded(sessionKey: trimmed) } + } + } + + func setEnabled(_ enabled: Bool) { + self.isEnabled = enabled + if enabled { + self.logger.info("enabled") + Task { await self.start() } + } else { + self.logger.info("disabled") + self.stop() + } + } + + func start() async { + guard self.isEnabled else { return } + guard self.captureMode != .pushToTalk else { return } + if self.isListening { return } + guard self.gatewayConnected else { + self.statusText = "Offline" + return + } + + self.logger.info("start") + self.statusText = "Requesting permissions…" + let micOk = await Self.requestMicrophonePermission() + guard micOk else { + self.logger.warning("start blocked: microphone permission denied") + self.statusText = Self.permissionMessage( + kind: "Microphone", + status: AVAudioSession.sharedInstance().recordPermission) + return + } + let speechOk = await Self.requestSpeechPermission() + guard speechOk else { + self.logger.warning("start blocked: speech permission denied") + self.statusText = Self.permissionMessage( + kind: "Speech recognition", + status: SFSpeechRecognizer.authorizationStatus()) + return + } + + await self.reloadConfig() + do { + try Self.configureAudioSession() + // Set this before starting recognition so any early speech errors are classified correctly. + self.captureMode = .continuous + try self.startRecognition() + self.isListening = true + self.statusText = "Listening" + self.startSilenceMonitor() + await self.subscribeChatIfNeeded(sessionKey: self.mainSessionKey) + self.logger.info("listening") + } catch { + self.isListening = false + self.statusText = "Start failed: \(error.localizedDescription)" + self.logger.error("start failed: \(error.localizedDescription, privacy: .public)") + } + } + + func stop() { + self.isEnabled = false + self.isListening = false + self.isPushToTalkActive = false + self.captureMode = .idle + self.statusText = "Off" + self.lastTranscript = "" + self.lastHeard = nil + self.silenceTask?.cancel() + self.silenceTask = nil + self.stopRecognition() + self.stopSpeaking() + self.lastInterruptedAtSeconds = nil + let pendingPTT = self.pttCompletion != nil + let pendingCaptureId = self.activePTTCaptureId ?? UUID().uuidString + self.pttTimeoutTask?.cancel() + self.pttTimeoutTask = nil + self.pttAutoStopEnabled = false + if pendingPTT { + let payload = OpenClawTalkPTTStopPayload( + captureId: pendingCaptureId, + transcript: nil, + status: "cancelled") + self.finishPTTOnce(payload) + } + self.resumeContinuousAfterPTT = false + self.activePTTCaptureId = nil + TalkSystemSpeechSynthesizer.shared.stop() + do { + try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) + } catch { + self.logger.warning("audio session deactivate failed: \(error.localizedDescription, privacy: .public)") + } + Task { await self.unsubscribeAllChats() } + } + + /// Suspends microphone usage without disabling Talk Mode. + /// Used when the app backgrounds (or when we need to temporarily release the mic). + func suspendForBackground(keepActive: Bool = false) -> Bool { + guard self.isEnabled else { return false } + if keepActive { + self.statusText = self.isListening ? "Listening" : self.statusText + return false + } + let wasActive = self.isListening || self.isSpeaking || self.isPushToTalkActive + + self.isListening = false + self.isPushToTalkActive = false + self.captureMode = .idle + self.statusText = "Paused" + self.lastTranscript = "" + self.lastHeard = nil + self.silenceTask?.cancel() + self.silenceTask = nil + + self.stopRecognition() + self.stopSpeaking() + self.lastInterruptedAtSeconds = nil + TalkSystemSpeechSynthesizer.shared.stop() + + do { + try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) + } catch { + self.logger.warning("audio session deactivate failed: \(error.localizedDescription, privacy: .public)") + } + + Task { await self.unsubscribeAllChats() } + return wasActive + } + + func resumeAfterBackground(wasSuspended: Bool, wasKeptActive: Bool = false) async { + if wasKeptActive { return } + guard wasSuspended else { return } + guard self.isEnabled else { return } + await self.start() + } + + func userTappedOrb() { + self.stopSpeaking() + } + + func beginPushToTalk() async throws -> OpenClawTalkPTTStartPayload { + guard self.gatewayConnected else { + self.statusText = "Offline" + throw NSError(domain: "TalkMode", code: 7, userInfo: [ + NSLocalizedDescriptionKey: "Gateway not connected", + ]) + } + if self.isPushToTalkActive, let captureId = self.activePTTCaptureId { + return OpenClawTalkPTTStartPayload(captureId: captureId) + } + + self.stopSpeaking(storeInterruption: false) + self.pttTimeoutTask?.cancel() + self.pttTimeoutTask = nil + self.pttAutoStopEnabled = false + + self.resumeContinuousAfterPTT = self.isEnabled && self.captureMode == .continuous + self.silenceTask?.cancel() + self.silenceTask = nil + self.stopRecognition() + self.isListening = false + + let captureId = UUID().uuidString + self.activePTTCaptureId = captureId + self.lastTranscript = "" + self.lastHeard = nil + + self.statusText = "Requesting permissions…" + if !self.allowSimulatorCapture { + let micOk = await Self.requestMicrophonePermission() + guard micOk else { + self.statusText = Self.permissionMessage( + kind: "Microphone", + status: AVAudioSession.sharedInstance().recordPermission) + throw NSError(domain: "TalkMode", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "Microphone permission denied", + ]) + } + let speechOk = await Self.requestSpeechPermission() + guard speechOk else { + self.statusText = Self.permissionMessage( + kind: "Speech recognition", + status: SFSpeechRecognizer.authorizationStatus()) + throw NSError(domain: "TalkMode", code: 5, userInfo: [ + NSLocalizedDescriptionKey: "Speech recognition permission denied", + ]) + } + } + + do { + try Self.configureAudioSession() + self.captureMode = .pushToTalk + try self.startRecognition() + self.isListening = true + self.isPushToTalkActive = true + self.statusText = "Listening (PTT)" + } catch { + self.isListening = false + self.isPushToTalkActive = false + self.captureMode = .idle + self.statusText = "Start failed: \(error.localizedDescription)" + throw error + } + + return OpenClawTalkPTTStartPayload(captureId: captureId) + } + + func endPushToTalk() async -> OpenClawTalkPTTStopPayload { + let captureId = self.activePTTCaptureId ?? UUID().uuidString + guard self.isPushToTalkActive else { + let payload = OpenClawTalkPTTStopPayload( + captureId: captureId, + transcript: nil, + status: "idle") + self.finishPTTOnce(payload) + return payload + } + + self.isPushToTalkActive = false + self.isListening = false + self.captureMode = .idle + self.stopRecognition() + self.pttTimeoutTask?.cancel() + self.pttTimeoutTask = nil + self.pttAutoStopEnabled = false + + let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + self.lastTranscript = "" + self.lastHeard = nil + + guard !transcript.isEmpty else { + self.statusText = "Ready" + if self.resumeContinuousAfterPTT { + await self.start() + } + self.resumeContinuousAfterPTT = false + self.activePTTCaptureId = nil + let payload = OpenClawTalkPTTStopPayload( + captureId: captureId, + transcript: nil, + status: "empty") + self.finishPTTOnce(payload) + return payload + } + + guard self.gatewayConnected else { + self.statusText = "Gateway not connected" + if self.resumeContinuousAfterPTT { + await self.start() + } + self.resumeContinuousAfterPTT = false + self.activePTTCaptureId = nil + let payload = OpenClawTalkPTTStopPayload( + captureId: captureId, + transcript: transcript, + status: "offline") + self.finishPTTOnce(payload) + return payload + } + + self.statusText = "Thinking…" + Task { @MainActor in + await self.processTranscript(transcript, restartAfter: self.resumeContinuousAfterPTT) + } + self.resumeContinuousAfterPTT = false + self.activePTTCaptureId = nil + let payload = OpenClawTalkPTTStopPayload( + captureId: captureId, + transcript: transcript, + status: "queued") + self.finishPTTOnce(payload) + return payload + } + + func runPushToTalkOnce(maxDurationSeconds: TimeInterval = 12) async throws -> OpenClawTalkPTTStopPayload { + if self.pttCompletion != nil { + _ = await self.cancelPushToTalk() + } + + if self.isPushToTalkActive { + let captureId = self.activePTTCaptureId ?? UUID().uuidString + return OpenClawTalkPTTStopPayload( + captureId: captureId, + transcript: nil, + status: "busy") + } + + _ = try await self.beginPushToTalk() + + return await withCheckedContinuation { cont in + self.pttCompletion = cont + self.pttAutoStopEnabled = true + self.startSilenceMonitor() + self.schedulePTTTimeout(seconds: maxDurationSeconds) + } + } + + func cancelPushToTalk() async -> OpenClawTalkPTTStopPayload { + let captureId = self.activePTTCaptureId ?? UUID().uuidString + guard self.isPushToTalkActive else { + let payload = OpenClawTalkPTTStopPayload( + captureId: captureId, + transcript: nil, + status: "idle") + self.finishPTTOnce(payload) + self.pttAutoStopEnabled = false + self.pttTimeoutTask?.cancel() + self.pttTimeoutTask = nil + self.resumeContinuousAfterPTT = false + self.activePTTCaptureId = nil + return payload + } + + let shouldResume = self.resumeContinuousAfterPTT + self.isPushToTalkActive = false + self.isListening = false + self.captureMode = .idle + self.stopRecognition() + self.lastTranscript = "" + self.lastHeard = nil + self.pttAutoStopEnabled = false + self.pttTimeoutTask?.cancel() + self.pttTimeoutTask = nil + self.resumeContinuousAfterPTT = false + self.activePTTCaptureId = nil + self.statusText = "Ready" + + let payload = OpenClawTalkPTTStopPayload( + captureId: captureId, + transcript: nil, + status: "cancelled") + self.finishPTTOnce(payload) + + if shouldResume { + await self.start() + } + return payload + } + + private func startRecognition() throws { + #if targetEnvironment(simulator) + if !self.allowSimulatorCapture { + throw NSError(domain: "TalkMode", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator", + ]) + } else { + self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + self.recognitionRequest?.shouldReportPartialResults = true + return + } + #endif + + self.stopRecognition() + self.speechRecognizer = SFSpeechRecognizer() + guard let recognizer = self.speechRecognizer else { + throw NSError(domain: "TalkMode", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Speech recognizer unavailable", + ]) + } + + self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + self.recognitionRequest?.shouldReportPartialResults = true + self.recognitionRequest?.taskHint = .dictation + guard let request = self.recognitionRequest else { return } + + GatewayDiagnostics.log("talk audio: session \(Self.describeAudioSession())") + + let input = self.audioEngine.inputNode + let format = input.inputFormat(forBus: 0) + guard format.sampleRate > 0, format.channelCount > 0 else { + throw NSError(domain: "TalkMode", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "Invalid audio input format", + ]) + } + input.removeTap(onBus: 0) + let tapDiagnostics = AudioTapDiagnostics(label: "talk") { [weak self] level in + guard let self else { return } + Task { @MainActor in + // Smooth + clamp for UI, and keep it cheap. + let raw = max(0, min(Double(level) * 10.0, 1.0)) + let next = (self.micLevel * 0.80) + (raw * 0.20) + self.micLevel = next + + // Dynamic thresholding so background noise doesn’t prevent endpointing. + if self.isListening, !self.isSpeaking, !self.noiseFloorReady { + self.noiseFloorSamples.append(raw) + if self.noiseFloorSamples.count >= 22 { + let sorted = self.noiseFloorSamples.sorted() + let take = max(6, sorted.count / 2) + let slice = sorted.prefix(take) + let avg = slice.reduce(0.0, +) / Double(slice.count) + self.noiseFloor = avg + self.noiseFloorReady = true + self.noiseFloorSamples.removeAll(keepingCapacity: true) + let threshold = min(0.35, max(0.12, avg + 0.10)) + GatewayDiagnostics.log( + "talk audio: noiseFloor=\(String(format: "%.3f", avg)) threshold=\(String(format: "%.3f", threshold))") + } + } + + let threshold: Double = if let floor = self.noiseFloor, self.noiseFloorReady { + min(0.35, max(0.12, floor + 0.10)) + } else { + 0.18 + } + if raw >= threshold { + self.lastAudioActivity = Date() + } + } + } + self.audioTapDiagnostics = tapDiagnostics + let tapBlock = Self.makeAudioTapAppendCallback(request: request, diagnostics: tapDiagnostics) + input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock) + self.inputTapInstalled = true + + self.audioEngine.prepare() + try self.audioEngine.start() + self.loggedPartialThisCycle = false + + GatewayDiagnostics.log( + "talk speech: recognition started mode=\(String(describing: self.captureMode)) engineRunning=\(self.audioEngine.isRunning)") + self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self else { return } + if let error { + let msg = error.localizedDescription + let lowered = msg.lowercased() + let isCancellation = lowered.contains("cancelled") || lowered.contains("canceled") + if isCancellation { + GatewayDiagnostics.log("talk speech: cancelled") + if self.captureMode == .continuous, self.isEnabled, !self.isSpeaking { + self.statusText = "Listening" + } + self.logger.debug("speech recognition cancelled") + return + } + GatewayDiagnostics.log("talk speech: error=\(msg)") + if !self.isSpeaking { + if msg.localizedCaseInsensitiveContains("no speech detected") { + // Treat as transient silence. Don't scare users with an error banner. + self.statusText = self.isEnabled ? "Listening" : "Speech error: \(msg)" + } else { + self.statusText = "Speech error: \(msg)" + } + } + self.logger.debug("speech recognition error: \(msg, privacy: .public)") + // Speech recognition can terminate on transient errors (e.g. no speech detected). + // If talk mode is enabled and we're in continuous capture, try to restart. + if self.captureMode == .continuous, self.isEnabled, !self.isSpeaking { + // Treat the task as terminal on error so we don't get stuck with a dead recognizer. + self.stopRecognition() + Task { @MainActor [weak self] in + await self?.restartRecognitionAfterError() + } + } + } + guard let result else { return } + let transcript = result.bestTranscription.formattedString + if !result.isFinal, !self.loggedPartialThisCycle { + let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + self.loggedPartialThisCycle = true + GatewayDiagnostics.log("talk speech: partial chars=\(trimmed.count)") + } + } + Task { @MainActor in + await self.handleTranscript(transcript: transcript, isFinal: result.isFinal) + } + } + } + + private func restartRecognitionAfterError() async { + guard self.isEnabled, self.captureMode == .continuous else { return } + // Avoid thrashing the audio engine if it’s already running. + if self.recognitionTask != nil, self.audioEngine.isRunning { return } + try? await Task.sleep(nanoseconds: 250_000_000) + guard self.isEnabled, self.captureMode == .continuous else { return } + do { + try Self.configureAudioSession() + try self.startRecognition() + self.isListening = true + if self.statusText.localizedCaseInsensitiveContains("speech error") { + self.statusText = "Listening" + } + GatewayDiagnostics.log("talk speech: recognition restarted") + } catch { + let msg = error.localizedDescription + GatewayDiagnostics.log("talk speech: restart failed error=\(msg)") + } + } + + private func stopRecognition() { + self.recognitionTask?.cancel() + self.recognitionTask = nil + self.recognitionRequest?.endAudio() + self.recognitionRequest = nil + self.micLevel = 0 + self.lastAudioActivity = nil + self.noiseFloorSamples.removeAll(keepingCapacity: true) + self.noiseFloor = nil + self.noiseFloorReady = false + self.audioTapDiagnostics = nil + if self.inputTapInstalled { + self.audioEngine.inputNode.removeTap(onBus: 0) + self.inputTapInstalled = false + } + self.audioEngine.stop() + self.speechRecognizer = nil + } + + private nonisolated static func makeAudioTapAppendCallback( + request: SpeechRequest, + diagnostics: AudioTapDiagnostics) -> AVAudioNodeTapBlock + { + { buffer, _ in + request.append(buffer) + diagnostics.onBuffer(buffer) + } + } + + private func handleTranscript(transcript: String, isFinal: Bool) async { + let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + let ttsActive = self.isSpeechOutputActive + if ttsActive, self.interruptOnSpeech { + if self.shouldInterrupt(with: trimmed) { + self.stopSpeaking() + } + return + } + + guard self.isListening else { return } + if !trimmed.isEmpty { + self.lastTranscript = trimmed + self.lastHeard = Date() + } + if isFinal { + self.lastTranscript = trimmed + guard !trimmed.isEmpty else { return } + GatewayDiagnostics.log("talk speech: final transcript chars=\(trimmed.count)") + self.loggedPartialThisCycle = false + if self.captureMode == .pushToTalk, self.pttAutoStopEnabled, self.isPushToTalkActive { + _ = await self.endPushToTalk() + return + } + if self.captureMode == .continuous, !self.isSpeechOutputActive { + await self.processTranscript(trimmed, restartAfter: true) + } + } + } + + private func startSilenceMonitor() { + self.silenceTask?.cancel() + self.silenceTask = Task { [weak self] in + guard let self else { return } + while self.isEnabled || (self.isPushToTalkActive && self.pttAutoStopEnabled) { + try? await Task.sleep(nanoseconds: 200_000_000) + await self.checkSilence() + } + } + } + + private func checkSilence() async { + if self.captureMode == .continuous { + guard self.isListening, !self.isSpeechOutputActive else { return } + let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + guard !transcript.isEmpty else { return } + let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap { $0 }.max() + guard let lastActivity else { return } + if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return } + await self.processTranscript(transcript, restartAfter: true) + return + } + + guard self.captureMode == .pushToTalk, self.pttAutoStopEnabled else { return } + guard self.isListening, !self.isSpeaking, self.isPushToTalkActive else { return } + let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + guard !transcript.isEmpty else { return } + let lastActivity = [self.lastHeard, self.lastAudioActivity].compactMap { $0 }.max() + guard let lastActivity else { return } + if Date().timeIntervalSince(lastActivity) < self.silenceWindow { return } + _ = await self.endPushToTalk() + } + + // Guardrail for PTT once so we don't stay open indefinitely. + private func schedulePTTTimeout(seconds: TimeInterval) { + guard seconds > 0 else { return } + let nanos = UInt64(seconds * 1_000_000_000) + self.pttTimeoutTask?.cancel() + self.pttTimeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: nanos) + await self?.handlePTTTimeout() + } + } + + private func handlePTTTimeout() async { + guard self.pttAutoStopEnabled, self.isPushToTalkActive else { return } + _ = await self.endPushToTalk() + } + + private func finishPTTOnce(_ payload: OpenClawTalkPTTStopPayload) { + guard let continuation = self.pttCompletion else { return } + self.pttCompletion = nil + continuation.resume(returning: payload) + } + + private func processTranscript(_ transcript: String, restartAfter: Bool) async { + self.isListening = false + self.captureMode = .idle + self.statusText = "Thinking…" + self.lastTranscript = "" + self.lastHeard = nil + self.stopRecognition() + + GatewayDiagnostics.log("talk: process transcript chars=\(transcript.count) restartAfter=\(restartAfter)") + await self.reloadConfig() + let prompt = self.buildPrompt(transcript: transcript) + guard self.gatewayConnected, let gateway else { + self.statusText = "Gateway not connected" + self.logger.warning("finalize: gateway not connected") + GatewayDiagnostics.log("talk: abort gateway not connected") + if restartAfter { + await self.start() + } + return + } + + do { + let startedAt = Date().timeIntervalSince1970 + let sessionKey = self.mainSessionKey + await self.subscribeChatIfNeeded(sessionKey: sessionKey) + self.logger.info( + "chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)") + GatewayDiagnostics.log("talk: chat.send start sessionKey=\(sessionKey) chars=\(prompt.count)") + let runId = try await self.sendChat(prompt, gateway: gateway) + self.logger.info("chat.send ok runId=\(runId, privacy: .public)") + GatewayDiagnostics.log("talk: chat.send ok runId=\(runId)") + let shouldIncremental = self.shouldUseIncrementalTTS() + var streamingTask: Task? + if shouldIncremental { + self.resetIncrementalSpeech() + streamingTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.streamAssistant(runId: runId, gateway: gateway) + } + } + let completion = await self.waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120) + if completion == .timeout { + self.logger.warning( + "chat completion timeout runId=\(runId, privacy: .public); attempting history fallback") + GatewayDiagnostics.log("talk: chat completion timeout runId=\(runId)") + } else if completion == .aborted { + self.statusText = "Aborted" + self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)") + GatewayDiagnostics.log("talk: chat completion aborted runId=\(runId)") + streamingTask?.cancel() + await self.finishIncrementalSpeech() + await self.start() + return + } else if completion == .error { + self.statusText = "Chat error" + self.logger.warning("chat completion error runId=\(runId, privacy: .public)") + GatewayDiagnostics.log("talk: chat completion error runId=\(runId)") + streamingTask?.cancel() + await self.finishIncrementalSpeech() + await self.start() + return + } + + var assistantText = try await self.waitForAssistantText( + gateway: gateway, + since: startedAt, + timeoutSeconds: completion == .final ? 12 : 25) + if assistantText == nil, shouldIncremental { + let fallback = self.incrementalSpeechBuffer.latestText + if !fallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + assistantText = fallback + } + } + guard let assistantText else { + self.statusText = "No reply" + self.logger.warning("assistant text timeout runId=\(runId, privacy: .public)") + GatewayDiagnostics.log("talk: assistant text timeout runId=\(runId)") + streamingTask?.cancel() + await self.finishIncrementalSpeech() + await self.start() + return + } + self.logger.info("assistant text ok chars=\(assistantText.count, privacy: .public)") + GatewayDiagnostics.log("talk: assistant text ok chars=\(assistantText.count)") + streamingTask?.cancel() + if shouldIncremental { + await self.handleIncrementalAssistantFinal(text: assistantText) + } else { + await self.playAssistant(text: assistantText) + } + } catch { + self.statusText = "Talk failed: \(error.localizedDescription)" + self.logger.error("finalize failed: \(error.localizedDescription, privacy: .public)") + GatewayDiagnostics.log("talk: failed error=\(error.localizedDescription)") + } + + if restartAfter { + await self.start() + } + } + + private func subscribeChatIfNeeded(sessionKey: String) async { + let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + guard !self.chatSubscribedSessionKeys.contains(key) else { return } + + // Operator clients receive chat events without node-style subscriptions. + self.chatSubscribedSessionKeys.insert(key) + } + + private func unsubscribeAllChats() async { + self.chatSubscribedSessionKeys.removeAll() + } + + private func buildPrompt(transcript: String) -> String { + let interrupted = self.lastInterruptedAtSeconds + self.lastInterruptedAtSeconds = nil + let includeVoiceDirectiveHint = (UserDefaults.standard.object(forKey: "talk.voiceDirectiveHint.enabled") as? Bool) ?? true + return TalkPromptBuilder.build( + transcript: transcript, + interruptedAtSeconds: interrupted, + includeVoiceDirectiveHint: includeVoiceDirectiveHint) + } + + private enum ChatCompletionState: CustomStringConvertible { + case final + case aborted + case error + case timeout + + var description: String { + switch self { + case .final: "final" + case .aborted: "aborted" + case .error: "error" + case .timeout: "timeout" + } + } + } + + private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String { + struct SendResponse: Decodable { let runId: String } + let payload: [String: Any] = [ + "sessionKey": self.mainSessionKey, + "message": message, + "thinking": "low", + "timeoutMs": 30000, + "idempotencyKey": UUID().uuidString, + ] + let data = try JSONSerialization.data(withJSONObject: payload) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError( + domain: "TalkModeManager", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"]) + } + let res = try await gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30) + let decoded = try JSONDecoder().decode(SendResponse.self, from: res) + return decoded.runId + } + + private func waitForChatCompletion( + runId: String, + gateway: GatewayNodeSession, + timeoutSeconds: Int = 120) async -> ChatCompletionState + { + let stream = await gateway.subscribeServerEvents(bufferingNewest: 200) + return await withTaskGroup(of: ChatCompletionState.self) { group in + group.addTask { [runId] in + for await evt in stream { + if Task.isCancelled { return .timeout } + guard evt.event == "chat", let payload = evt.payload else { continue } + guard let chatEvent = try? GatewayPayloadDecoding.decode(payload, as: ChatEvent.self) else { + continue + } + guard chatEvent.runid == runId else { continue } + if let state = chatEvent.state.value as? String { + switch state { + case "final": return .final + case "aborted": return .aborted + case "error": return .error + default: break + } + } + } + return .timeout + } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000) + return .timeout + } + let result = await group.next() ?? .timeout + group.cancelAll() + return result + } + } + + private func waitForAssistantText( + gateway: GatewayNodeSession, + since: Double, + timeoutSeconds: Int) async throws -> String? + { + let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) + while Date() < deadline { + if let text = try await self.fetchLatestAssistantText(gateway: gateway, since: since) { + return text + } + try? await Task.sleep(nanoseconds: 300_000_000) + } + return nil + } + + private func fetchLatestAssistantText(gateway: GatewayNodeSession, since: Double? = nil) async throws -> String? { + let res = try await gateway.request( + method: "chat.history", + paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}", + timeoutSeconds: 15) + guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return nil } + guard let messages = json["messages"] as? [[String: Any]] else { return nil } + for msg in messages.reversed() { + guard (msg["role"] as? String) == "assistant" else { continue } + if let since, let timestamp = msg["timestamp"] as? Double, + TalkHistoryTimestamp.isAfter(timestamp, sinceSeconds: since) == false + { + continue + } + guard let content = msg["content"] as? [[String: Any]] else { continue } + let text = content.compactMap { $0["text"] as? String }.joined(separator: "\n") + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + return nil + } + + private func playAssistant(text: String) async { + let parsed = TalkDirectiveParser.parse(text) + let directive = parsed.directive + let cleaned = parsed.stripped.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return } + self.applyDirective(directive) + + self.statusText = "Generating voice…" + self.isSpeaking = true + self.lastSpokenText = cleaned + + do { + let started = Date() + let language = ElevenLabsTTSClient.validatedLanguage(directive?.language) + let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedVoice = self.resolveVoiceAlias(requestedVoice) + if requestedVoice?.isEmpty == false, resolvedVoice == nil { + self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)") + } + + let resolvedKey = + (self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil) ?? + ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] + let apiKey = resolvedKey?.trimmingCharacters(in: .whitespacesAndNewlines) + let preferredVoice = resolvedVoice ?? self.currentVoiceId ?? self.defaultVoiceId + let voiceId: String? = if let apiKey, !apiKey.isEmpty { + await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey) + } else { + nil + } + let canUseElevenLabs = (voiceId?.isEmpty == false) && (apiKey?.isEmpty == false) + + if canUseElevenLabs, let voiceId, let apiKey { + GatewayDiagnostics.log("talk tts: provider=elevenlabs voiceId=\(voiceId)") + let desiredOutputFormat = (directive?.outputFormat ?? self.defaultOutputFormat)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let requestedOutputFormat = (desiredOutputFormat?.isEmpty == false) ? desiredOutputFormat : nil + let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(requestedOutputFormat ?? "pcm_44100") + if outputFormat == nil, let requestedOutputFormat { + self.logger.warning( + "talk output_format unsupported for local playback: \(requestedOutputFormat, privacy: .public)") + } + + let modelId = directive?.modelId ?? self.currentModelId ?? self.defaultModelId + if let modelId { + GatewayDiagnostics.log("talk tts: modelId=\(modelId)") + } + func makeRequest(outputFormat: String?) -> ElevenLabsTTSRequest { + ElevenLabsTTSRequest( + text: cleaned, + modelId: modelId, + outputFormat: outputFormat, + speed: TalkTTSValidation.resolveSpeed(speed: directive?.speed, rateWPM: directive?.rateWPM), + stability: TalkTTSValidation.validatedStability(directive?.stability, modelId: modelId), + similarity: TalkTTSValidation.validatedUnit(directive?.similarity), + style: TalkTTSValidation.validatedUnit(directive?.style), + speakerBoost: directive?.speakerBoost, + seed: TalkTTSValidation.validatedSeed(directive?.seed), + normalize: ElevenLabsTTSClient.validatedNormalize(directive?.normalize), + language: language, + latencyTier: TalkTTSValidation.validatedLatencyTier(directive?.latencyTier)) + } + + let request = makeRequest(outputFormat: outputFormat) + + let client = ElevenLabsTTSClient(apiKey: apiKey) + let stream = client.streamSynthesize(voiceId: voiceId, request: request) + + if self.interruptOnSpeech { + do { + try self.startRecognition() + } catch { + self.logger.warning( + "startRecognition during speak failed: \(error.localizedDescription, privacy: .public)") + } + } + + self.statusText = "Speaking…" + let sampleRate = TalkTTSValidation.pcmSampleRate(from: outputFormat) + let result: StreamingPlaybackResult + if let sampleRate { + self.lastPlaybackWasPCM = true + var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate) + if !playback.finished, playback.interruptedAt == nil { + let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") + self.logger.warning("pcm playback failed; retrying mp3") + self.lastPlaybackWasPCM = false + let mp3Stream = client.streamSynthesize( + voiceId: voiceId, + request: makeRequest(outputFormat: mp3Format)) + playback = await self.mp3Player.play(stream: mp3Stream) + } + result = playback + } else { + self.lastPlaybackWasPCM = false + result = await self.mp3Player.play(stream: stream) + } + let duration = Date().timeIntervalSince(started) + self.logger.info("elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s") + if !result.finished, let interruptedAt = result.interruptedAt { + self.lastInterruptedAtSeconds = interruptedAt + } + } else { + self.logger.warning("tts unavailable; falling back to system voice (missing key or voiceId)") + GatewayDiagnostics.log("talk tts: provider=system (missing key or voiceId)") + if self.interruptOnSpeech { + do { + try self.startRecognition() + } catch { + self.logger.warning( + "startRecognition during speak failed: \(error.localizedDescription, privacy: .public)") + } + } + self.statusText = "Speaking (System)…" + try await TalkSystemSpeechSynthesizer.shared.speak(text: cleaned, language: language) + } + } catch { + self.logger.error( + "tts failed: \(error.localizedDescription, privacy: .public); falling back to system voice") + GatewayDiagnostics.log("talk tts: provider=system (error) msg=\(error.localizedDescription)") + do { + if self.interruptOnSpeech { + do { + try self.startRecognition() + } catch { + self.logger.warning( + "startRecognition during speak failed: \(error.localizedDescription, privacy: .public)") + } + } + self.statusText = "Speaking (System)…" + let language = ElevenLabsTTSClient.validatedLanguage(directive?.language) + try await TalkSystemSpeechSynthesizer.shared.speak(text: cleaned, language: language) + } catch { + self.statusText = "Speak failed: \(error.localizedDescription)" + self.logger.error("system voice failed: \(error.localizedDescription, privacy: .public)") + } + } + + self.stopRecognition() + self.isSpeaking = false + } + + private func stopSpeaking(storeInterruption: Bool = true) { + let hasIncremental = self.incrementalSpeechActive || + self.incrementalSpeechTask != nil || + !self.incrementalSpeechQueue.isEmpty + if self.isSpeaking { + let interruptedAt = self.lastPlaybackWasPCM + ? self.pcmPlayer.stop() + : self.mp3Player.stop() + if storeInterruption { + self.lastInterruptedAtSeconds = interruptedAt + } + _ = self.lastPlaybackWasPCM + ? self.mp3Player.stop() + : self.pcmPlayer.stop() + } else if !hasIncremental { + return + } + TalkSystemSpeechSynthesizer.shared.stop() + self.cancelIncrementalSpeech() + self.isSpeaking = false + } + + private func shouldInterrupt(with transcript: String) -> Bool { + guard self.shouldAllowSpeechInterruptForCurrentRoute() else { return false } + let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= 3 else { return false } + if let spoken = self.lastSpokenText?.lowercased(), spoken.contains(trimmed.lowercased()) { + return false + } + return true + } + + private func shouldAllowSpeechInterruptForCurrentRoute() -> Bool { + let route = AVAudioSession.sharedInstance().currentRoute + // Built-in speaker/receiver often feeds TTS back into STT, causing false interrupts. + // Allow barge-in for isolated outputs (headphones/Bluetooth/USB/CarPlay/AirPlay). + return !route.outputs.contains { output in + switch output.portType { + case .builtInSpeaker, .builtInReceiver: + return true + default: + return false + } + } + } + + private func shouldUseIncrementalTTS() -> Bool { + true + } + + private var isSpeechOutputActive: Bool { + self.isSpeaking || + self.incrementalSpeechActive || + self.incrementalSpeechTask != nil || + !self.incrementalSpeechQueue.isEmpty + } + + private func applyDirective(_ directive: TalkDirective?) { + let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedVoice = self.resolveVoiceAlias(requestedVoice) + if requestedVoice?.isEmpty == false, resolvedVoice == nil { + self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)") + } + if let voice = resolvedVoice { + if directive?.once != true { + self.currentVoiceId = voice + self.voiceOverrideActive = true + } + } + if let model = directive?.modelId { + if directive?.once != true { + self.currentModelId = model + self.modelOverrideActive = true + } + } + } + + private func resetIncrementalSpeech() { + self.incrementalSpeechQueue.removeAll() + self.incrementalSpeechTask?.cancel() + self.incrementalSpeechTask = nil + self.cancelIncrementalPrefetch() + self.incrementalSpeechActive = true + self.incrementalSpeechUsed = false + self.incrementalSpeechLanguage = nil + self.incrementalSpeechBuffer = IncrementalSpeechBuffer() + self.incrementalSpeechContext = nil + self.incrementalSpeechDirective = nil + } + + private func cancelIncrementalSpeech() { + self.incrementalSpeechQueue.removeAll() + self.incrementalSpeechTask?.cancel() + self.incrementalSpeechTask = nil + self.cancelIncrementalPrefetch() + self.incrementalSpeechActive = false + self.incrementalSpeechContext = nil + self.incrementalSpeechDirective = nil + } + + private func enqueueIncrementalSpeech(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + self.incrementalSpeechQueue.append(trimmed) + self.incrementalSpeechUsed = true + if self.incrementalSpeechTask == nil { + self.startIncrementalSpeechTask() + } + } + + private func startIncrementalSpeechTask() { + if self.interruptOnSpeech { + do { + try self.startRecognition() + } catch { + self.logger.warning( + "startRecognition during incremental speak failed: \(error.localizedDescription, privacy: .public)") + } + } + + self.incrementalSpeechTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + self.cancelIncrementalPrefetch() + self.isSpeaking = false + self.stopRecognition() + self.incrementalSpeechTask = nil + } + while !Task.isCancelled { + guard !self.incrementalSpeechQueue.isEmpty else { break } + let segment = self.incrementalSpeechQueue.removeFirst() + self.statusText = "Speaking…" + self.isSpeaking = true + self.lastSpokenText = segment + await self.updateIncrementalContextIfNeeded() + let context = self.incrementalSpeechContext + let prefetchedAudio = await self.consumeIncrementalPrefetchedAudioIfAvailable( + for: segment, + context: context) + if let context { + self.startIncrementalPrefetchMonitor(context: context) + } + await self.speakIncrementalSegment( + segment, + context: context, + prefetchedAudio: prefetchedAudio) + self.cancelIncrementalPrefetchMonitor() + } + } + } + + private func cancelIncrementalPrefetch() { + self.cancelIncrementalPrefetchMonitor() + self.incrementalSpeechPrefetch?.task.cancel() + self.incrementalSpeechPrefetch = nil + } + + private func cancelIncrementalPrefetchMonitor() { + self.incrementalSpeechPrefetchMonitorTask?.cancel() + self.incrementalSpeechPrefetchMonitorTask = nil + } + + private func startIncrementalPrefetchMonitor(context: IncrementalSpeechContext) { + self.cancelIncrementalPrefetchMonitor() + self.incrementalSpeechPrefetchMonitorTask = Task { @MainActor [weak self] in + guard let self else { return } + while !Task.isCancelled { + if self.ensureIncrementalPrefetchForUpcomingSegment(context: context) { + return + } + try? await Task.sleep(nanoseconds: 40_000_000) + } + } + } + + private func ensureIncrementalPrefetchForUpcomingSegment(context: IncrementalSpeechContext) -> Bool { + guard context.canUseElevenLabs else { + self.cancelIncrementalPrefetch() + return false + } + guard let nextSegment = self.incrementalSpeechQueue.first else { return false } + if let existing = self.incrementalSpeechPrefetch { + if existing.segment == nextSegment, existing.context == context { + return true + } + existing.task.cancel() + self.incrementalSpeechPrefetch = nil + } + self.startIncrementalPrefetch(segment: nextSegment, context: context) + return self.incrementalSpeechPrefetch != nil + } + + private func startIncrementalPrefetch(segment: String, context: IncrementalSpeechContext) { + guard context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId else { return } + let prefetchOutputFormat = self.resolveIncrementalPrefetchOutputFormat(context: context) + let request = self.makeIncrementalTTSRequest( + text: segment, + context: context, + outputFormat: prefetchOutputFormat) + let id = UUID() + let task = Task { [weak self] in + let stream = ElevenLabsTTSClient(apiKey: apiKey).streamSynthesize(voiceId: voiceId, request: request) + var chunks: [Data] = [] + do { + for try await chunk in stream { + try Task.checkCancellation() + chunks.append(chunk) + } + await self?.completeIncrementalPrefetch(id: id, chunks: chunks) + } catch is CancellationError { + await self?.clearIncrementalPrefetch(id: id) + } catch { + await self?.failIncrementalPrefetch(id: id, error: error) + } + } + self.incrementalSpeechPrefetch = IncrementalSpeechPrefetchState( + id: id, + segment: segment, + context: context, + outputFormat: prefetchOutputFormat, + chunks: nil, + task: task) + } + + private func completeIncrementalPrefetch(id: UUID, chunks: [Data]) { + guard var prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return } + prefetch.chunks = chunks + self.incrementalSpeechPrefetch = prefetch + } + + private func clearIncrementalPrefetch(id: UUID) { + guard let prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return } + prefetch.task.cancel() + self.incrementalSpeechPrefetch = nil + } + + private func failIncrementalPrefetch(id: UUID, error: any Error) { + guard let prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return } + self.logger.debug("incremental prefetch failed: \(error.localizedDescription, privacy: .public)") + prefetch.task.cancel() + self.incrementalSpeechPrefetch = nil + } + + private func consumeIncrementalPrefetchedAudioIfAvailable( + for segment: String, + context: IncrementalSpeechContext? + ) async -> IncrementalPrefetchedAudio? + { + guard let context else { + self.cancelIncrementalPrefetch() + return nil + } + guard let prefetch = self.incrementalSpeechPrefetch else { + return nil + } + guard prefetch.context == context else { + prefetch.task.cancel() + self.incrementalSpeechPrefetch = nil + return nil + } + guard prefetch.segment == segment else { + return nil + } + if let chunks = prefetch.chunks, !chunks.isEmpty { + let prefetched = IncrementalPrefetchedAudio(chunks: chunks, outputFormat: prefetch.outputFormat) + self.incrementalSpeechPrefetch = nil + return prefetched + } + await prefetch.task.value + guard let completed = self.incrementalSpeechPrefetch else { return nil } + guard completed.context == context, completed.segment == segment else { return nil } + guard let chunks = completed.chunks, !chunks.isEmpty else { return nil } + let prefetched = IncrementalPrefetchedAudio(chunks: chunks, outputFormat: completed.outputFormat) + self.incrementalSpeechPrefetch = nil + return prefetched + } + + private func resolveIncrementalPrefetchOutputFormat(context: IncrementalSpeechContext) -> String? { + if TalkTTSValidation.pcmSampleRate(from: context.outputFormat) != nil { + return ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") + } + return context.outputFormat + } + + private func finishIncrementalSpeech() async { + guard self.incrementalSpeechActive else { return } + let leftover = self.incrementalSpeechBuffer.flush() + if let leftover { + self.enqueueIncrementalSpeech(leftover) + } + if let task = self.incrementalSpeechTask { + _ = await task.result + } + self.incrementalSpeechActive = false + } + + private func handleIncrementalAssistantFinal(text: String) async { + let parsed = TalkDirectiveParser.parse(text) + self.applyDirective(parsed.directive) + if let lang = parsed.directive?.language { + self.incrementalSpeechLanguage = ElevenLabsTTSClient.validatedLanguage(lang) + } + await self.updateIncrementalContextIfNeeded() + let segments = self.incrementalSpeechBuffer.ingest(text: text, isFinal: true) + for segment in segments { + self.enqueueIncrementalSpeech(segment) + } + await self.finishIncrementalSpeech() + if !self.incrementalSpeechUsed { + await self.playAssistant(text: text) + } + } + + private func streamAssistant(runId: String, gateway: GatewayNodeSession) async { + let stream = await gateway.subscribeServerEvents(bufferingNewest: 200) + for await evt in stream { + if Task.isCancelled { return } + guard evt.event == "agent", let payload = evt.payload else { continue } + guard let agentEvent = try? GatewayPayloadDecoding.decode(payload, as: OpenClawAgentEventPayload.self) else { + continue + } + guard agentEvent.runId == runId, agentEvent.stream == "assistant" else { continue } + guard let text = agentEvent.data["text"]?.value as? String else { continue } + let segments = self.incrementalSpeechBuffer.ingest(text: text, isFinal: false) + if let lang = self.incrementalSpeechBuffer.directive?.language { + self.incrementalSpeechLanguage = ElevenLabsTTSClient.validatedLanguage(lang) + } + await self.updateIncrementalContextIfNeeded() + for segment in segments { + self.enqueueIncrementalSpeech(segment) + } + } + } + + private func updateIncrementalContextIfNeeded() async { + let directive = self.incrementalSpeechBuffer.directive + if let existing = self.incrementalSpeechContext, directive == self.incrementalSpeechDirective { + if existing.language != self.incrementalSpeechLanguage { + self.incrementalSpeechContext = IncrementalSpeechContext( + apiKey: existing.apiKey, + voiceId: existing.voiceId, + modelId: existing.modelId, + outputFormat: existing.outputFormat, + language: self.incrementalSpeechLanguage, + directive: existing.directive, + canUseElevenLabs: existing.canUseElevenLabs) + } + return + } + let context = await self.buildIncrementalSpeechContext(directive: directive) + self.incrementalSpeechContext = context + self.incrementalSpeechDirective = directive + } + + private func buildIncrementalSpeechContext(directive: TalkDirective?) async -> IncrementalSpeechContext { + let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedVoice = self.resolveVoiceAlias(requestedVoice) + if requestedVoice?.isEmpty == false, resolvedVoice == nil { + self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)") + } + let preferredVoice = resolvedVoice ?? self.currentVoiceId ?? self.defaultVoiceId + let modelId = directive?.modelId ?? self.currentModelId ?? self.defaultModelId + let desiredOutputFormat = (directive?.outputFormat ?? self.defaultOutputFormat)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let requestedOutputFormat = (desiredOutputFormat?.isEmpty == false) ? desiredOutputFormat : nil + let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(requestedOutputFormat ?? "pcm_44100") + if outputFormat == nil, let requestedOutputFormat { + self.logger.warning( + "talk output_format unsupported for local playback: \(requestedOutputFormat, privacy: .public)") + } + + let resolvedKey = + (self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil) ?? + ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] + let apiKey = resolvedKey?.trimmingCharacters(in: .whitespacesAndNewlines) + let voiceId: String? = if let apiKey, !apiKey.isEmpty { + await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey) + } else { + nil + } + let canUseElevenLabs = (voiceId?.isEmpty == false) && (apiKey?.isEmpty == false) + return IncrementalSpeechContext( + apiKey: apiKey, + voiceId: voiceId, + modelId: modelId, + outputFormat: outputFormat, + language: self.incrementalSpeechLanguage, + directive: directive, + canUseElevenLabs: canUseElevenLabs) + } + + private func makeIncrementalTTSRequest( + text: String, + context: IncrementalSpeechContext, + outputFormat: String? + ) -> ElevenLabsTTSRequest + { + ElevenLabsTTSRequest( + text: text, + modelId: context.modelId, + outputFormat: outputFormat, + speed: TalkTTSValidation.resolveSpeed( + speed: context.directive?.speed, + rateWPM: context.directive?.rateWPM), + stability: TalkTTSValidation.validatedStability( + context.directive?.stability, + modelId: context.modelId), + similarity: TalkTTSValidation.validatedUnit(context.directive?.similarity), + style: TalkTTSValidation.validatedUnit(context.directive?.style), + speakerBoost: context.directive?.speakerBoost, + seed: TalkTTSValidation.validatedSeed(context.directive?.seed), + normalize: ElevenLabsTTSClient.validatedNormalize(context.directive?.normalize), + language: context.language, + latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier)) + } + + private static func makeBufferedAudioStream(chunks: [Data]) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + for chunk in chunks { + continuation.yield(chunk) + } + continuation.finish() + } + } + + private func speakIncrementalSegment( + _ text: String, + context preferredContext: IncrementalSpeechContext? = nil, + prefetchedAudio: IncrementalPrefetchedAudio? = nil + ) async + { + let context: IncrementalSpeechContext + if let preferredContext { + context = preferredContext + } else { + await self.updateIncrementalContextIfNeeded() + guard let resolvedContext = self.incrementalSpeechContext else { + try? await TalkSystemSpeechSynthesizer.shared.speak( + text: text, + language: self.incrementalSpeechLanguage) + return + } + context = resolvedContext + } + + guard context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId else { + try? await TalkSystemSpeechSynthesizer.shared.speak( + text: text, + language: self.incrementalSpeechLanguage) + return + } + + let client = ElevenLabsTTSClient(apiKey: apiKey) + let request = self.makeIncrementalTTSRequest( + text: text, + context: context, + outputFormat: context.outputFormat) + let stream: AsyncThrowingStream + if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty { + stream = Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks) + } else { + stream = client.streamSynthesize(voiceId: voiceId, request: request) + } + let playbackFormat = prefetchedAudio?.outputFormat ?? context.outputFormat + let sampleRate = TalkTTSValidation.pcmSampleRate(from: playbackFormat) + let result: StreamingPlaybackResult + if let sampleRate { + self.lastPlaybackWasPCM = true + var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate) + if !playback.finished, playback.interruptedAt == nil { + self.logger.warning("pcm playback failed; retrying mp3") + self.lastPlaybackWasPCM = false + let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") + let mp3Stream = client.streamSynthesize( + voiceId: voiceId, + request: self.makeIncrementalTTSRequest( + text: text, + context: context, + outputFormat: mp3Format)) + playback = await self.mp3Player.play(stream: mp3Stream) + } + result = playback + } else { + self.lastPlaybackWasPCM = false + result = await self.mp3Player.play(stream: stream) + } + if !result.finished, let interruptedAt = result.interruptedAt { + self.lastInterruptedAtSeconds = interruptedAt + } + } + +} + +private struct IncrementalSpeechBuffer { + private(set) var latestText: String = "" + private(set) var directive: TalkDirective? + private var spokenOffset: Int = 0 + private var inCodeBlock = false + private var directiveParsed = false + + mutating func ingest(text: String, isFinal: Bool) -> [String] { + let normalized = text.replacingOccurrences(of: "\r\n", with: "\n") + guard let usable = self.stripDirectiveIfReady(from: normalized) else { return [] } + self.updateText(usable) + return self.extractSegments(isFinal: isFinal) + } + + mutating func flush() -> String? { + guard !self.latestText.isEmpty else { return nil } + let segments = self.extractSegments(isFinal: true) + return segments.first + } + + private mutating func stripDirectiveIfReady(from text: String) -> String? { + guard !self.directiveParsed else { return text } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.hasPrefix("{") { + guard let newlineRange = text.range(of: "\n") else { return nil } + let firstLine = text[.. commonPrefix { + self.spokenOffset = commonPrefix + } + } + if self.spokenOffset > self.latestText.count { + self.spokenOffset = self.latestText.count + } + } + + private static func commonPrefixCount(_ lhs: String, _ rhs: String) -> Int { + let left = Array(lhs) + let right = Array(rhs) + let limit = min(left.count, right.count) + var idx = 0 + while idx < limit, left[idx] == right[idx] { + idx += 1 + } + return idx + } + + private mutating func extractSegments(isFinal: Bool) -> [String] { + let chars = Array(self.latestText) + guard self.spokenOffset < chars.count else { return [] } + var idx = self.spokenOffset + var lastBoundary: Int? + var inCodeBlock = self.inCodeBlock + var buffer = "" + var bufferAtBoundary = "" + var inCodeBlockAtBoundary = inCodeBlock + + while idx < chars.count { + if idx + 2 < chars.count, + chars[idx] == "`", + chars[idx + 1] == "`", + chars[idx + 2] == "`" + { + inCodeBlock.toggle() + idx += 3 + continue + } + + if !inCodeBlock { + buffer.append(chars[idx]) + if Self.isBoundary(chars[idx]) { + lastBoundary = idx + 1 + bufferAtBoundary = buffer + inCodeBlockAtBoundary = inCodeBlock + } + } + + idx += 1 + } + + if let boundary = lastBoundary { + self.spokenOffset = boundary + self.inCodeBlock = inCodeBlockAtBoundary + let trimmed = bufferAtBoundary.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? [] : [trimmed] + } + + guard isFinal else { return [] } + self.spokenOffset = chars.count + self.inCodeBlock = inCodeBlock + let trimmed = buffer.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? [] : [trimmed] + } + + private static func isBoundary(_ ch: Character) -> Bool { + ch == "." || ch == "!" || ch == "?" || ch == "\n" + } +} + +extension TalkModeManager { + nonisolated static func requestMicrophonePermission() async -> Bool { + let session = AVAudioSession.sharedInstance() + switch session.recordPermission { + case .granted: + return true + case .denied: + return false + case .undetermined: + break + @unknown default: + return false + } + + return await self.requestPermissionWithTimeout { completion in + AVAudioSession.sharedInstance().requestRecordPermission { ok in + completion(ok) + } + } + } + + nonisolated static func requestSpeechPermission() async -> Bool { + let status = SFSpeechRecognizer.authorizationStatus() + switch status { + case .authorized: + return true + case .denied, .restricted: + return false + case .notDetermined: + break + @unknown default: + return false + } + + return await self.requestPermissionWithTimeout { completion in + SFSpeechRecognizer.requestAuthorization { authStatus in + completion(authStatus == .authorized) + } + } + } + + private nonisolated static func requestPermissionWithTimeout( + _ operation: @escaping @Sendable (@escaping (Bool) -> Void) -> Void) async -> Bool + { + do { + return try await AsyncTimeout.withTimeout( + seconds: 8, + onTimeout: { NSError(domain: "TalkMode", code: 6, userInfo: [ + NSLocalizedDescriptionKey: "permission request timed out", + ]) }, + operation: { + await withCheckedContinuation(isolation: nil) { cont in + Task { @MainActor in + operation { ok in + cont.resume(returning: ok) + } + } + } + }) + } catch { + return false + } + } + + static func permissionMessage( + kind: String, + status: AVAudioSession.RecordPermission) -> String + { + switch status { + case .denied: + return "\(kind) permission denied" + case .undetermined: + return "\(kind) permission not granted" + case .granted: + return "\(kind) permission denied" + @unknown default: + return "\(kind) permission denied" + } + } + + static func permissionMessage( + kind: String, + status: SFSpeechRecognizerAuthorizationStatus) -> String + { + switch status { + case .denied: + return "\(kind) permission denied" + case .restricted: + return "\(kind) permission restricted" + case .notDetermined: + return "\(kind) permission not granted" + case .authorized: + return "\(kind) permission denied" + @unknown default: + return "\(kind) permission denied" + } + } +} + +extension TalkModeManager { + func resolveVoiceAlias(_ value: String?) -> String? { + let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let normalized = trimmed.lowercased() + if let mapped = self.voiceAliases[normalized] { return mapped } + if self.voiceAliases.values.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { + return trimmed + } + return Self.isLikelyVoiceId(trimmed) ? trimmed : nil + } + + func resolveVoiceId(preferred: String?, apiKey: String) async -> String? { + let trimmed = preferred?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { + // Config / directives can provide a raw ElevenLabs voiceId (not an alias). + // Accept it directly to avoid unnecessary listVoices calls (and accidental fallback selection). + if Self.isLikelyVoiceId(trimmed) { + return trimmed + } + if let resolved = self.resolveVoiceAlias(trimmed) { return resolved } + self.logger.warning("unknown voice alias \(trimmed, privacy: .public)") + } + if let fallbackVoiceId { return fallbackVoiceId } + + do { + let voices = try await ElevenLabsTTSClient(apiKey: apiKey).listVoices() + guard let first = voices.first else { + self.logger.warning("elevenlabs voices list empty") + return nil + } + self.fallbackVoiceId = first.voiceId + if self.defaultVoiceId == nil { + self.defaultVoiceId = first.voiceId + } + if !self.voiceOverrideActive { + self.currentVoiceId = first.voiceId + } + let name = first.name ?? "unknown" + self.logger + .info("default voice selected \(name, privacy: .public) (\(first.voiceId, privacy: .public))") + return first.voiceId + } catch { + self.logger.error("elevenlabs list voices failed: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + static func isLikelyVoiceId(_ value: String) -> Bool { + guard value.count >= 10 else { return false } + return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } + } + + private static func normalizedTalkApiKey(_ raw: String?) -> String? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard trimmed != Self.redactedConfigSentinel else { return nil } + // Config values may be env placeholders (for example `${ELEVENLABS_API_KEY}`). + if trimmed.hasPrefix("${"), trimmed.hasSuffix("}") { return nil } + return trimmed + } + + struct TalkProviderConfigSelection { + let provider: String + let config: [String: Any] + } + + private static func normalizedTalkProviderID(_ raw: String?) -> String? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + + static func selectTalkProviderConfig(_ talk: [String: Any]?) -> TalkProviderConfigSelection? { + guard let talk else { return nil } + let rawProvider = talk["provider"] as? String + let rawProviders = talk["providers"] as? [String: Any] + guard rawProvider != nil || rawProviders != nil else { return nil } + let providers = rawProviders ?? [:] + let normalizedProviders = providers.reduce(into: [String: [String: Any]]()) { acc, entry in + guard + let providerID = Self.normalizedTalkProviderID(entry.key), + let config = entry.value as? [String: Any] + else { return } + acc[providerID] = config + } + let providerID = + Self.normalizedTalkProviderID(rawProvider) ?? + normalizedProviders.keys.sorted().first ?? + Self.defaultTalkProvider + return TalkProviderConfigSelection( + provider: providerID, + config: normalizedProviders[providerID] ?? [:]) + } + + func reloadConfig() async { + guard let gateway else { return } + do { + let res = try await gateway.request(method: "talk.config", paramsJSON: "{\"includeSecrets\":true}", timeoutSeconds: 8) + guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } + guard let config = json["config"] as? [String: Any] else { return } + let talk = config["talk"] as? [String: Any] + let selection = Self.selectTalkProviderConfig(talk) + if talk != nil, selection == nil { + GatewayDiagnostics.log( + "talk config ignored: legacy payload unsupported on iOS beta; expected talk.provider/providers") + } + let activeProvider = selection?.provider ?? Self.defaultTalkProvider + let activeConfig = selection?.config + self.defaultVoiceId = (activeConfig?["voiceId"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let aliases = activeConfig?["voiceAliases"] as? [String: Any] { + var resolved: [String: String] = [:] + for (key, value) in aliases { + guard let id = value as? String else { continue } + let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue } + resolved[normalizedKey] = trimmedId + } + self.voiceAliases = resolved + } else { + self.voiceAliases = [:] + } + if !self.voiceOverrideActive { + self.currentVoiceId = self.defaultVoiceId + } + let model = (activeConfig?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback + if !self.modelOverrideActive { + self.currentModelId = self.defaultModelId + } + self.defaultOutputFormat = (activeConfig?["outputFormat"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let rawConfigApiKey = (activeConfig?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey) + let localApiKey = Self.normalizedTalkApiKey( + GatewaySettingsStore.loadTalkProviderApiKey(provider: activeProvider)) + if rawConfigApiKey == Self.redactedConfigSentinel { + self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : nil + GatewayDiagnostics.log("talk config apiKey redacted; using local override if present") + } else { + self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : configApiKey + } + if activeProvider != Self.defaultTalkProvider { + self.apiKey = nil + GatewayDiagnostics.log( + "talk provider '\(activeProvider)' not yet supported on iOS; using system voice fallback") + } + self.gatewayTalkDefaultVoiceId = self.defaultVoiceId + self.gatewayTalkDefaultModelId = self.defaultModelId + self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false) + self.gatewayTalkConfigLoaded = true + if let interrupt = talk?["interruptOnSpeech"] as? Bool { + self.interruptOnSpeech = interrupt + } + if selection != nil { + GatewayDiagnostics.log("talk config provider=\(activeProvider)") + } + } catch { + self.defaultModelId = Self.defaultModelIdFallback + if !self.modelOverrideActive { + self.currentModelId = self.defaultModelId + } + self.gatewayTalkDefaultVoiceId = nil + self.gatewayTalkDefaultModelId = nil + self.gatewayTalkApiKeyConfigured = false + self.gatewayTalkConfigLoaded = false + } + } + + static func configureAudioSession() throws { + let session = AVAudioSession.sharedInstance() + // Prefer `.spokenAudio` for STT; it tends to preserve speech energy better than `.voiceChat`. + try session.setCategory(.playAndRecord, mode: .spokenAudio, options: [ + .allowBluetoothHFP, + .defaultToSpeaker, + ]) + try? session.setPreferredSampleRate(48_000) + try? session.setPreferredIOBufferDuration(0.02) + try session.setActive(true, options: []) + } + + private static func describeAudioSession() -> String { + let session = AVAudioSession.sharedInstance() + let inputs = session.currentRoute.inputs.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",") + let outputs = session.currentRoute.outputs.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",") + let available = session.availableInputs?.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",") ?? "" + return "category=\(session.category.rawValue) mode=\(session.mode.rawValue) opts=\(session.categoryOptions.rawValue) inputAvail=\(session.isInputAvailable) routeIn=[\(inputs)] routeOut=[\(outputs)] availIn=[\(available)]" + } +} + +private final class AudioTapDiagnostics: @unchecked Sendable { + private let label: String + private let onLevel: (@Sendable (Float) -> Void)? + private let lock = NSLock() + private var bufferCount: Int = 0 + private var lastLoggedAt = Date.distantPast + private var lastLevelEmitAt = Date.distantPast + private var maxRmsWindow: Float = 0 + private var lastRms: Float = 0 + + init(label: String, onLevel: (@Sendable (Float) -> Void)? = nil) { + self.label = label + self.onLevel = onLevel + } + + func onBuffer(_ buffer: AVAudioPCMBuffer) { + var shouldLog = false + var shouldEmitLevel = false + var count = 0 + lock.lock() + bufferCount += 1 + count = bufferCount + let now = Date() + if now.timeIntervalSince(lastLoggedAt) >= 1.0 { + lastLoggedAt = now + shouldLog = true + } + if now.timeIntervalSince(lastLevelEmitAt) >= 0.12 { + lastLevelEmitAt = now + shouldEmitLevel = true + } + lock.unlock() + + let rate = buffer.format.sampleRate + let ch = buffer.format.channelCount + let frames = buffer.frameLength + + var rms: Float? + if let data = buffer.floatChannelData?.pointee { + let n = Int(frames) + if n > 0 { + var sum: Float = 0 + for i in 0.. maxRmsWindow { maxRmsWindow = resolvedRms } + let maxRms = maxRmsWindow + if shouldLog { maxRmsWindow = 0 } + lock.unlock() + + if shouldEmitLevel, let onLevel { + onLevel(resolvedRms) + } + + guard shouldLog else { return } + GatewayDiagnostics.log( + "\(label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))") + } +} + +#if DEBUG +extension TalkModeManager { + func _test_seedTranscript(_ transcript: String) { + self.lastTranscript = transcript + self.lastHeard = Date() + } + + func _test_handleTranscript(_ transcript: String, isFinal: Bool) async { + await self.handleTranscript(transcript: transcript, isFinal: isFinal) + } + + func _test_backdateLastHeard(seconds: TimeInterval) { + self.lastHeard = Date().addingTimeInterval(-seconds) + } + + func _test_runSilenceCheck() async { + await self.checkSilence() + } + + func _test_incrementalReset() { + self.incrementalSpeechBuffer = IncrementalSpeechBuffer() + } + + func _test_incrementalIngest(_ text: String, isFinal: Bool) -> [String] { + self.incrementalSpeechBuffer.ingest(text: text, isFinal: isFinal) + } +} +#endif + +private struct IncrementalSpeechContext: Equatable { + let apiKey: String? + let voiceId: String? + let modelId: String? + let outputFormat: String? + let language: String? + let directive: TalkDirective? + let canUseElevenLabs: Bool +} + +private struct IncrementalSpeechPrefetchState { + let id: UUID + let segment: String + let context: IncrementalSpeechContext + let outputFormat: String? + var chunks: [Data]? + let task: Task +} + +private struct IncrementalPrefetchedAudio { + let chunks: [Data] + let outputFormat: String? +} + +// swiftlint:enable type_body_length diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/TalkOrbOverlay.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/TalkOrbOverlay.swift new file mode 100644 index 00000000..f24cab5a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/TalkOrbOverlay.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct TalkOrbOverlay: View { + @Environment(NodeAppModel.self) private var appModel + @State private var pulse: Bool = false + + var body: some View { + let seam = self.appModel.seamColor + let status = self.appModel.talkMode.statusText.trimmingCharacters(in: .whitespacesAndNewlines) + let mic = min(max(self.appModel.talkMode.micLevel, 0), 1) + + VStack(spacing: 14) { + ZStack { + Circle() + .stroke(seam.opacity(0.26), lineWidth: 2) + .frame(width: 320, height: 320) + .scaleEffect(self.pulse ? 1.15 : 0.96) + .opacity(self.pulse ? 0.0 : 1.0) + .animation(.easeOut(duration: 1.3).repeatForever(autoreverses: false), value: self.pulse) + + Circle() + .stroke(seam.opacity(0.18), lineWidth: 2) + .frame(width: 320, height: 320) + .scaleEffect(self.pulse ? 1.45 : 1.02) + .opacity(self.pulse ? 0.0 : 0.9) + .animation(.easeOut(duration: 1.9).repeatForever(autoreverses: false).delay(0.2), value: self.pulse) + + Circle() + .fill( + RadialGradient( + colors: [ + seam.opacity(0.75 + (0.20 * mic)), + seam.opacity(0.40), + Color.black.opacity(0.55), + ], + center: .center, + startRadius: 1, + endRadius: 112)) + .frame(width: 190, height: 190) + .scaleEffect(1.0 + (0.12 * mic)) + .overlay( + Circle() + .stroke(seam.opacity(0.35), lineWidth: 1)) + .shadow(color: seam.opacity(0.32), radius: 26, x: 0, y: 0) + .shadow(color: Color.black.opacity(0.50), radius: 22, x: 0, y: 10) + } + .contentShape(Circle()) + .onTapGesture { + self.appModel.talkMode.userTappedOrb() + } + + let agentName = self.appModel.activeAgentName.trimmingCharacters(in: .whitespacesAndNewlines) + if !agentName.isEmpty { + Text("Bot: \(agentName)") + .font(.system(.caption, design: .rounded).weight(.semibold)) + .foregroundStyle(Color.white.opacity(0.70)) + } + + if !status.isEmpty, status != "Off" { + Text(status) + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(Color.white.opacity(0.92)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + Capsule() + .fill(Color.black.opacity(0.40)) + .overlay( + Capsule().stroke(seam.opacity(0.22), lineWidth: 1))) + } + + if self.appModel.talkMode.isListening { + Capsule() + .fill(seam.opacity(0.90)) + .frame(width: max(18, 180 * mic), height: 6) + .animation(.easeOut(duration: 0.12), value: mic) + .accessibilityLabel("Microphone level") + } + } + .padding(28) + .onAppear { + self.pulse = true + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Talk Mode \(status)") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/VoiceTab.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/VoiceTab.swift new file mode 100644 index 00000000..4fedd0ce --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/VoiceTab.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct VoiceTab: View { + @Environment(NodeAppModel.self) private var appModel + @Environment(VoiceWakeManager.self) private var voiceWake + @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false + @AppStorage("talk.enabled") private var talkEnabled: Bool = false + + var body: some View { + NavigationStack { + List { + Section("Status") { + LabeledContent("Voice Wake", value: self.voiceWakeEnabled ? "Enabled" : "Disabled") + LabeledContent("Listener", value: self.voiceWake.isListening ? "Listening" : "Idle") + Text(self.voiceWake.statusText) + .font(.footnote) + .foregroundStyle(.secondary) + LabeledContent("Talk Mode", value: self.talkEnabled ? "Enabled" : "Disabled") + } + + Section("Notes") { + let triggers = self.voiceWake.activeTriggerWords + Group { + if triggers.isEmpty { + Text("Add wake words in Settings.") + } else if triggers.count == 1 { + Text("Say “\(triggers[0]) …” to trigger.") + } else if triggers.count == 2 { + Text("Say “\(triggers[0]) …” or “\(triggers[1]) …” to trigger.") + } else { + Text("Say “\(triggers.joined(separator: " …”, “")) …” to trigger.") + } + } + .foregroundStyle(.secondary) + } + } + .navigationTitle("Voice") + .onChange(of: self.voiceWakeEnabled) { _, newValue in + self.appModel.setVoiceWakeEnabled(newValue) + } + .onChange(of: self.talkEnabled) { _, newValue in + self.appModel.setTalkEnabled(newValue) + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/VoiceWakeManager.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/VoiceWakeManager.swift new file mode 100644 index 00000000..15a993fe --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/VoiceWakeManager.swift @@ -0,0 +1,495 @@ +import AVFAudio +import Foundation +import Observation +import OpenClawKit +import Speech +import SwabbleKit + +private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void { + { buffer, _ in + // This callback is invoked on a realtime audio thread/queue. Keep it tiny and nonisolated. + queue.enqueueCopy(of: buffer) + } +} + +private final class AudioBufferQueue: @unchecked Sendable { + private let lock = NSLock() + private var buffers: [AVAudioPCMBuffer] = [] + + func enqueueCopy(of buffer: AVAudioPCMBuffer) { + guard let copy = buffer.deepCopy() else { return } + self.lock.lock() + self.buffers.append(copy) + self.lock.unlock() + } + + func drain() -> [AVAudioPCMBuffer] { + self.lock.lock() + let drained = self.buffers + self.buffers.removeAll(keepingCapacity: true) + self.lock.unlock() + return drained + } + + func clear() { + self.lock.lock() + self.buffers.removeAll(keepingCapacity: false) + self.lock.unlock() + } +} + +extension AVAudioPCMBuffer { + fileprivate func deepCopy() -> AVAudioPCMBuffer? { + let format = self.format + let frameLength = self.frameLength + guard let copy = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameLength) else { + return nil + } + copy.frameLength = frameLength + + if let src = self.floatChannelData, let dst = copy.floatChannelData { + let channels = Int(format.channelCount) + let frames = Int(frameLength) + for ch in 0..? + + private var lastDispatched: String? + private var onCommand: (@Sendable (String) async -> Void)? + private var userDefaultsObserver: NSObjectProtocol? + private var suppressedByTalk: Bool = false + + override init() { + super.init() + self.triggerWords = VoiceWakePreferences.loadTriggerWords() + self.userDefaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: UserDefaults.standard, + queue: .main, + using: { [weak self] _ in + Task { @MainActor in + self?.handleUserDefaultsDidChange() + } + }) + } + + @MainActor deinit { + if let userDefaultsObserver = self.userDefaultsObserver { + NotificationCenter.default.removeObserver(userDefaultsObserver) + } + } + + var activeTriggerWords: [String] { + VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords) + } + + private func handleUserDefaultsDidChange() { + let updated = VoiceWakePreferences.loadTriggerWords() + if updated != self.triggerWords { + self.triggerWords = updated + } + } + + func configure(onCommand: @escaping @Sendable (String) async -> Void) { + self.onCommand = onCommand + } + + func setEnabled(_ enabled: Bool) { + self.isEnabled = enabled + if enabled { + Task { await self.start() } + } else { + self.stop() + } + } + + func setSuppressedByTalk(_ suppressed: Bool) { + self.suppressedByTalk = suppressed + if suppressed { + _ = self.suspendForExternalAudioCapture() + if self.isEnabled { + self.statusText = "Paused" + } + } else { + if self.isEnabled { + Task { await self.start() } + } + } + } + + func start() async { + guard self.isEnabled else { return } + if self.isListening { return } + guard !self.suppressedByTalk else { + self.isListening = false + self.statusText = "Paused" + return + } + + if ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil || + ProcessInfo.processInfo.environment["SIMULATOR_UDID"] != nil + { + // The iOS Simulator’s audio stack is unreliable for long-running microphone capture. + // (We’ve observed CoreAudio deadlocks after TCC permission prompts.) + self.isListening = false + self.statusText = "Voice Wake isn’t supported on Simulator" + return + } + + self.statusText = "Requesting permissions…" + + let micOk = await Self.requestMicrophonePermission() + guard micOk else { + self.statusText = Self.permissionMessage( + kind: "Microphone", + status: AVAudioSession.sharedInstance().recordPermission) + self.isListening = false + return + } + + let speechOk = await Self.requestSpeechPermission() + guard speechOk else { + self.statusText = Self.permissionMessage( + kind: "Speech recognition", + status: SFSpeechRecognizer.authorizationStatus()) + self.isListening = false + return + } + + self.speechRecognizer = SFSpeechRecognizer() + guard self.speechRecognizer != nil else { + self.statusText = "Speech recognizer unavailable" + self.isListening = false + return + } + + do { + try Self.configureAudioSession() + try self.startRecognition() + self.isListening = true + self.statusText = "Listening" + } catch { + self.isListening = false + self.statusText = "Start failed: \(error.localizedDescription)" + } + } + + func stop() { + self.isEnabled = false + self.isListening = false + self.statusText = "Off" + + self.tapDrainTask?.cancel() + self.tapDrainTask = nil + self.tapQueue?.clear() + self.tapQueue = nil + + self.recognitionTask?.cancel() + self.recognitionTask = nil + self.recognitionRequest = nil + + if self.audioEngine.isRunning { + self.audioEngine.stop() + self.audioEngine.inputNode.removeTap(onBus: 0) + } + + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + /// Temporarily releases the microphone so other subsystems (e.g. camera video capture) can record audio. + /// Returns `true` when listening was active and was suspended. + func suspendForExternalAudioCapture() -> Bool { + guard self.isEnabled, self.isListening else { return false } + + self.isListening = false + self.statusText = "Paused" + + self.tapDrainTask?.cancel() + self.tapDrainTask = nil + self.tapQueue?.clear() + self.tapQueue = nil + + self.recognitionTask?.cancel() + self.recognitionTask = nil + self.recognitionRequest = nil + + if self.audioEngine.isRunning { + self.audioEngine.stop() + self.audioEngine.inputNode.removeTap(onBus: 0) + } + + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + return true + } + + func resumeAfterExternalAudioCapture(wasSuspended: Bool) { + guard wasSuspended else { return } + Task { await self.start() } + } + + private func startRecognition() throws { + self.recognitionTask?.cancel() + self.recognitionTask = nil + self.tapDrainTask?.cancel() + self.tapDrainTask = nil + self.tapQueue?.clear() + self.tapQueue = nil + + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + self.recognitionRequest = request + + let inputNode = self.audioEngine.inputNode + inputNode.removeTap(onBus: 0) + + let recordingFormat = inputNode.outputFormat(forBus: 0) + + let queue = AudioBufferQueue() + self.tapQueue = queue + let tapBlock: @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void = makeAudioTapEnqueueCallback(queue: queue) + inputNode.installTap( + onBus: 0, + bufferSize: 1024, + format: recordingFormat, + block: tapBlock) + + self.audioEngine.prepare() + try self.audioEngine.start() + + let handler = self.makeRecognitionResultHandler() + self.recognitionTask = self.speechRecognizer?.recognitionTask(with: request, resultHandler: handler) + + self.tapDrainTask = Task { [weak self] in + guard let self, let queue = self.tapQueue else { return } + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 40_000_000) + let drained = queue.drain() + if drained.isEmpty { continue } + for buf in drained { + request.append(buf) + } + } + } + } + + private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void { + { [weak self] result, error in + let transcript = result?.bestTranscription.formattedString + let segments = result.flatMap { result in + transcript.map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) } + } ?? [] + let errorText = error?.localizedDescription + + Task { @MainActor in + self?.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText) + } + } + } + + private func handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) { + if let errorText { + self.statusText = "Recognizer error: \(errorText)" + self.isListening = false + + let shouldRestart = self.isEnabled + if shouldRestart { + Task { + try? await Task.sleep(nanoseconds: 700_000_000) + await self.start() + } + } + return + } + + guard let transcript else { return } + guard let cmd = self.extractCommand(from: transcript, segments: segments) else { return } + + if cmd == self.lastDispatched { return } + self.lastDispatched = cmd + self.lastTriggeredCommand = cmd + self.statusText = "Triggered" + + Task { [weak self] in + guard let self else { return } + await self.onCommand?(cmd) + await self.startIfEnabled() + } + } + + private func startIfEnabled() async { + let shouldRestart = self.isEnabled + if shouldRestart { + await self.start() + } + } + + private func extractCommand(from transcript: String, segments: [WakeWordSegment]) -> String? { + Self.extractCommand(from: transcript, segments: segments, triggers: self.activeTriggerWords) + } + + nonisolated static func extractCommand( + from transcript: String, + segments: [WakeWordSegment], + triggers: [String], + minPostTriggerGap: TimeInterval = 0.45) -> String? + { + let config = WakeWordGateConfig(triggers: triggers, minPostTriggerGap: minPostTriggerGap) + return WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command + } + + private static func configureAudioSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .measurement, options: [ + .duckOthers, + .mixWithOthers, + .allowBluetoothHFP, + .defaultToSpeaker, + ]) + try session.setActive(true, options: []) + } + + private nonisolated static func requestMicrophonePermission() async -> Bool { + let session = AVAudioSession.sharedInstance() + switch session.recordPermission { + case .granted: + return true + case .denied: + return false + case .undetermined: + break + @unknown default: + return false + } + + return await self.requestPermissionWithTimeout { completion in + AVAudioSession.sharedInstance().requestRecordPermission { ok in + completion(ok) + } + } + } + + private nonisolated static func requestSpeechPermission() async -> Bool { + let status = SFSpeechRecognizer.authorizationStatus() + switch status { + case .authorized: + return true + case .denied, .restricted: + return false + case .notDetermined: + break + @unknown default: + return false + } + + return await self.requestPermissionWithTimeout { completion in + SFSpeechRecognizer.requestAuthorization { authStatus in + completion(authStatus == .authorized) + } + } + } + + private nonisolated static func requestPermissionWithTimeout( + _ operation: @escaping @Sendable (@escaping (Bool) -> Void) -> Void) async -> Bool + { + do { + return try await AsyncTimeout.withTimeout( + seconds: 8, + onTimeout: { NSError(domain: "VoiceWake", code: 6, userInfo: [ + NSLocalizedDescriptionKey: "permission request timed out", + ]) }, + operation: { + await withCheckedContinuation(isolation: nil) { cont in + Task { @MainActor in + operation { ok in + cont.resume(returning: ok) + } + } + } + }) + } catch { + return false + } + } + + private static func permissionMessage( + kind: String, + status: AVAudioSession.RecordPermission) -> String + { + switch status { + case .denied: + return "\(kind) permission denied" + case .undetermined: + return "\(kind) permission not granted" + case .granted: + return "\(kind) permission denied" + @unknown default: + return "\(kind) permission denied" + } + } + + private static func permissionMessage( + kind: String, + status: SFSpeechRecognizerAuthorizationStatus) -> String + { + switch status { + case .denied: + return "\(kind) permission denied" + case .restricted: + return "\(kind) permission restricted" + case .notDetermined: + return "\(kind) permission not granted" + case .authorized: + return "\(kind) permission denied" + @unknown default: + return "\(kind) permission denied" + } + } +} + +#if DEBUG +extension VoiceWakeManager { + func _test_handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) { + self.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/VoiceWakePreferences.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/VoiceWakePreferences.swift new file mode 100644 index 00000000..56762b51 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Sources/Voice/VoiceWakePreferences.swift @@ -0,0 +1,44 @@ +import Foundation + +enum VoiceWakePreferences { + static let enabledKey = "voiceWake.enabled" + static let triggerWordsKey = "voiceWake.triggerWords" + + // Keep defaults aligned with the mac app. + static let defaultTriggerWords: [String] = ["openclaw", "claude"] + static let maxWords = 32 + static let maxWordLength = 64 + + static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? { + guard let data = payloadJSON.data(using: .utf8) else { return nil } + return self.decodeGatewayTriggers(from: data) + } + + static func decodeGatewayTriggers(from data: Data) -> [String]? { + struct Payload: Decodable { var triggers: [String] } + guard let decoded = try? JSONDecoder().decode(Payload.self, from: data) else { return nil } + return self.sanitizeTriggerWords(decoded.triggers) + } + + static func loadTriggerWords(defaults: UserDefaults = .standard) -> [String] { + defaults.stringArray(forKey: self.triggerWordsKey) ?? self.defaultTriggerWords + } + + static func saveTriggerWords(_ words: [String], defaults: UserDefaults = .standard) { + defaults.set(words, forKey: self.triggerWordsKey) + } + + static func sanitizeTriggerWords(_ words: [String]) -> [String] { + let cleaned = words + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .prefix(Self.maxWords) + .map { String($0.prefix(Self.maxWordLength)) } + return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned + } + + static func displayString(for words: [String]) -> String { + let sanitized = self.sanitizeTriggerWords(words) + return sanitized.joined(separator: ", ") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/AppCoverageTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/AppCoverageTests.swift new file mode 100644 index 00000000..33c71ccc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/AppCoverageTests.swift @@ -0,0 +1,31 @@ +import SwiftUI +import Testing +@testable import OpenClaw + +@Suite struct AppCoverageTests { + @Test @MainActor func nodeAppModelUpdatesBackgroundedState() { + let appModel = NodeAppModel() + + appModel.setScenePhase(.background) + #expect(appModel.isBackgrounded == true) + + appModel.setScenePhase(.inactive) + #expect(appModel.isBackgrounded == false) + + appModel.setScenePhase(.active) + #expect(appModel.isBackgrounded == false) + } + + @Test @MainActor func voiceWakeStartReportsUnsupportedOnSimulator() async { + let voiceWake = VoiceWakeManager() + voiceWake.isEnabled = true + + await voiceWake.start() + + #expect(voiceWake.isListening == false) + #expect(voiceWake.statusText.contains("Simulator")) + + voiceWake.stop() + #expect(voiceWake.statusText == "Off") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/CameraControllerClampTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/CameraControllerClampTests.swift new file mode 100644 index 00000000..791010d1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/CameraControllerClampTests.swift @@ -0,0 +1,24 @@ +import Testing +@testable import OpenClaw + +@Suite struct CameraControllerClampTests { + @Test func clampQualityDefaultsAndBounds() { + #expect(CameraController.clampQuality(nil) == 0.9) + #expect(CameraController.clampQuality(0.0) == 0.05) + #expect(CameraController.clampQuality(0.049) == 0.05) + #expect(CameraController.clampQuality(0.05) == 0.05) + #expect(CameraController.clampQuality(0.5) == 0.5) + #expect(CameraController.clampQuality(1.0) == 1.0) + #expect(CameraController.clampQuality(1.1) == 1.0) + } + + @Test func clampDurationDefaultsAndBounds() { + #expect(CameraController.clampDurationMs(nil) == 3000) + #expect(CameraController.clampDurationMs(0) == 250) + #expect(CameraController.clampDurationMs(249) == 250) + #expect(CameraController.clampDurationMs(250) == 250) + #expect(CameraController.clampDurationMs(1000) == 1000) + #expect(CameraController.clampDurationMs(60000) == 60000) + #expect(CameraController.clampDurationMs(60001) == 60000) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/CameraControllerErrorTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/CameraControllerErrorTests.swift new file mode 100644 index 00000000..26cac617 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/CameraControllerErrorTests.swift @@ -0,0 +1,14 @@ +import Testing +@testable import OpenClaw + +@Suite struct CameraControllerErrorTests { + @Test func errorDescriptionsAreStable() { + #expect(CameraController.CameraError.cameraUnavailable.errorDescription == "Camera unavailable") + #expect(CameraController.CameraError.microphoneUnavailable.errorDescription == "Microphone unavailable") + #expect(CameraController.CameraError.permissionDenied(kind: "Camera") + .errorDescription == "Camera permission denied") + #expect(CameraController.CameraError.invalidParams("bad").errorDescription == "bad") + #expect(CameraController.CameraError.captureFailed("nope").errorDescription == "nope") + #expect(CameraController.CameraError.exportFailed("export").errorDescription == "export") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/DeepLinkParserTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/DeepLinkParserTests.swift new file mode 100644 index 00000000..51ef9547 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/DeepLinkParserTests.swift @@ -0,0 +1,181 @@ +import OpenClawKit +import Foundation +import Testing + +@Suite struct DeepLinkParserTests { + @Test func parseRejectsUnknownHost() { + let url = URL(string: "openclaw://nope?message=hi")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseHostIsCaseInsensitive() { + let url = URL(string: "openclaw://AGENT?message=Hello")! + #expect(DeepLinkParser.parse(url) == .agent(.init( + message: "Hello", + sessionKey: nil, + thinking: nil, + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: nil, + key: nil))) + } + + @Test func parseRejectsNonOpenClawScheme() { + let url = URL(string: "https://example.com/agent?message=hi")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseRejectsEmptyMessage() { + let url = URL(string: "openclaw://agent?message=%20%20%0A")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseAgentLinkParsesCommonFields() { + let url = + URL(string: "openclaw://agent?message=Hello&deliver=1&sessionKey=node-test&thinking=low&timeoutSeconds=30")! + #expect( + DeepLinkParser.parse(url) == .agent( + .init( + message: "Hello", + sessionKey: "node-test", + thinking: "low", + deliver: true, + to: nil, + channel: nil, + timeoutSeconds: 30, + key: nil))) + } + + @Test func parseAgentLinkParsesTargetRoutingFields() { + let url = + URL( + string: "openclaw://agent?message=Hello%20World&deliver=1&to=%2B15551234567&channel=whatsapp&key=secret")! + #expect( + DeepLinkParser.parse(url) == .agent( + .init( + message: "Hello World", + sessionKey: nil, + thinking: nil, + deliver: true, + to: "+15551234567", + channel: "whatsapp", + timeoutSeconds: nil, + key: "secret"))) + } + + @Test func parseRejectsNegativeTimeoutSeconds() { + let url = URL(string: "openclaw://agent?message=Hello&timeoutSeconds=-1")! + #expect(DeepLinkParser.parse(url) == .agent(.init( + message: "Hello", + sessionKey: nil, + thinking: nil, + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: nil, + key: nil))) + } + + @Test func parseGatewayLinkParsesCommonFields() { + let url = URL( + string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")! + #expect( + DeepLinkParser.parse(url) == .gateway( + .init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def"))) + } + + @Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() { + let url = URL( + string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseGatewayLinkRejectsInsecurePrefixBypassHost() { + let url = URL( + string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseGatewaySetupCodeParsesBase64UrlPayload() { + let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + + #expect(link == .init( + host: "gateway.example.com", + port: 443, + tls: true, + token: "tok", + password: "pw")) + } + + @Test func parseGatewaySetupCodeRejectsInvalidInput() { + #expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil) + } + + @Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() { + let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + + #expect(link == .init( + host: "gateway.example.com", + port: 443, + tls: true, + token: "tok", + password: nil)) + } + + @Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() { + let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + #expect(link == nil) + } + + @Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() { + let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + #expect(link == nil) + } + + @Test func parseGatewaySetupCodeAllowsLoopbackWs() { + let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + + #expect(link == .init( + host: "127.0.0.1", + port: 18789, + tls: false, + token: "tok", + password: nil)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayConnectionControllerTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayConnectionControllerTests.swift new file mode 100644 index 00000000..27e7aed7 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -0,0 +1,122 @@ +import OpenClawKit +import Foundation +import Testing +import UIKit +@testable import OpenClaw + +private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T { + let defaults = UserDefaults.standard + var snapshot: [String: Any?] = [:] + for key in updates.keys { + snapshot[key] = defaults.object(forKey: key) + } + for (key, value) in updates { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + defer { + for (key, value) in snapshot { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + return try body() +} + +@Suite(.serialized) struct GatewayConnectionControllerTests { + @Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() { + let defaults = UserDefaults.standard + let displayKey = "node.displayName" + + withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + let resolved = controller._test_resolvedDisplayName(defaults: defaults) + #expect(!resolved.isEmpty) + #expect(defaults.string(forKey: displayKey) == resolved) + } + } + + @Test @MainActor func currentCapsReflectToggles() { + withUserDefaults([ + "node.instanceId": "ios-test", + "node.displayName": "Test Node", + "camera.enabled": true, + "location.enabledMode": OpenClawLocationMode.always.rawValue, + VoiceWakePreferences.enabledKey: true, + ]) { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + let caps = Set(controller._test_currentCaps()) + + #expect(caps.contains(OpenClawCapability.canvas.rawValue)) + #expect(caps.contains(OpenClawCapability.screen.rawValue)) + #expect(caps.contains(OpenClawCapability.camera.rawValue)) + #expect(caps.contains(OpenClawCapability.location.rawValue)) + #expect(caps.contains(OpenClawCapability.voiceWake.rawValue)) + } + } + + @Test @MainActor func currentCommandsIncludeLocationWhenEnabled() { + withUserDefaults([ + "node.instanceId": "ios-test", + "location.enabledMode": OpenClawLocationMode.whileUsing.rawValue, + ]) { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + let commands = Set(controller._test_currentCommands()) + + #expect(commands.contains(OpenClawLocationCommand.get.rawValue)) + } + } + @Test @MainActor func currentCommandsExcludeDangerousSystemExecCommands() { + withUserDefaults([ + "node.instanceId": "ios-test", + "camera.enabled": true, + "location.enabledMode": OpenClawLocationMode.whileUsing.rawValue, + ]) { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + let commands = Set(controller._test_currentCommands()) + + // iOS should expose notify, but not host shell/exec-approval commands. + #expect(commands.contains(OpenClawSystemCommand.notify.rawValue)) + #expect(!commands.contains(OpenClawSystemCommand.run.rawValue)) + #expect(!commands.contains(OpenClawSystemCommand.which.rawValue)) + #expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue)) + #expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue)) + } + } + + @Test @MainActor func loadLastConnectionReadsSavedValues() { + withUserDefaults([:]) { + GatewaySettingsStore.saveLastGatewayConnectionManual( + host: "gateway.example.com", + port: 443, + useTLS: true, + stableID: "manual|gateway.example.com|443") + let loaded = GatewaySettingsStore.loadLastGatewayConnection() + #expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443")) + } + } + + @Test @MainActor func loadLastConnectionReturnsNilForInvalidData() { + withUserDefaults([ + "gateway.last.kind": "manual", + "gateway.last.host": "", + "gateway.last.port": 0, + "gateway.last.tls": false, + "gateway.last.stableID": "manual|invalid|0", + ]) { + let loaded = GatewaySettingsStore.loadLastGatewayConnection() + #expect(loaded == nil) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayConnectionIssueTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayConnectionIssueTests.swift new file mode 100644 index 00000000..8eb63f26 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayConnectionIssueTests.swift @@ -0,0 +1,33 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct GatewayConnectionIssueTests { + @Test func detectsTokenMissing() { + let issue = GatewayConnectionIssue.detect(from: "unauthorized: gateway token missing") + #expect(issue == .tokenMissing) + #expect(issue.needsAuthToken) + } + + @Test func detectsUnauthorized() { + let issue = GatewayConnectionIssue.detect(from: "Gateway error: unauthorized role") + #expect(issue == .unauthorized) + #expect(issue.needsAuthToken) + } + + @Test func detectsPairingWithRequestId() { + let issue = GatewayConnectionIssue.detect(from: "pairing required (requestId: abc123)") + #expect(issue == .pairingRequired(requestId: "abc123")) + #expect(issue.needsPairing) + #expect(issue.requestId == "abc123") + } + + @Test func detectsNetworkError() { + let issue = GatewayConnectionIssue.detect(from: "Gateway error: Connection refused") + #expect(issue == .network) + } + + @Test func returnsNoneForBenignStatus() { + let issue = GatewayConnectionIssue.detect(from: "Connected") + #expect(issue == .none) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayConnectionSecurityTests.swift new file mode 100644 index 00000000..3c1b25bc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -0,0 +1,132 @@ +import Foundation +import Network +import OpenClawKit +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct GatewayConnectionSecurityTests { + private func clearTLSFingerprint(stableID: String) { + let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard + suite.removeObject(forKey: "gateway.tls.\(stableID)") + } + + @Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async { + let stableID = "test|\(UUID().uuidString)" + defer { clearTLSFingerprint(stableID: stableID) } + clearTLSFingerprint(stableID: stableID) + + GatewayTLSStore.saveFingerprint("11", stableID: stableID) + + let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil) + let gateway = GatewayDiscoveryModel.DiscoveredGateway( + name: "Test", + endpoint: endpoint, + stableID: stableID, + debugID: "debug", + lanHost: "evil.example.com", + tailnetDns: "evil.example.com", + gatewayPort: 12345, + canvasPort: nil, + tlsEnabled: true, + tlsFingerprintSha256: "22", + cliPath: nil) + + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true) + #expect(params?.expectedFingerprint == "11") + #expect(params?.allowTOFU == false) + } + + @Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async { + let stableID = "test|\(UUID().uuidString)" + defer { clearTLSFingerprint(stableID: stableID) } + clearTLSFingerprint(stableID: stableID) + + let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil) + let gateway = GatewayDiscoveryModel.DiscoveredGateway( + name: "Test", + endpoint: endpoint, + stableID: stableID, + debugID: "debug", + lanHost: nil, + tailnetDns: nil, + gatewayPort: nil, + canvasPort: nil, + tlsEnabled: true, + tlsFingerprintSha256: "22", + cliPath: nil) + + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true) + #expect(params?.expectedFingerprint == nil) + #expect(params?.allowTOFU == false) + } + + @Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async { + let stableID = "test|\(UUID().uuidString)" + defer { clearTLSFingerprint(stableID: stableID) } + clearTLSFingerprint(stableID: stableID) + + let defaults = UserDefaults.standard + defaults.set(true, forKey: "gateway.autoconnect") + defaults.set(false, forKey: "gateway.manual.enabled") + defaults.removeObject(forKey: "gateway.last.host") + defaults.removeObject(forKey: "gateway.last.port") + defaults.removeObject(forKey: "gateway.last.tls") + defaults.removeObject(forKey: "gateway.last.stableID") + defaults.removeObject(forKey: "gateway.last.kind") + defaults.removeObject(forKey: "gateway.preferredStableID") + defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID") + + let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil) + let gateway = GatewayDiscoveryModel.DiscoveredGateway( + name: "Test", + endpoint: endpoint, + stableID: stableID, + debugID: "debug", + lanHost: "test.local", + tailnetDns: nil, + gatewayPort: 18789, + canvasPort: nil, + tlsEnabled: true, + tlsFingerprintSha256: nil, + cliPath: nil) + + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + controller._test_setGateways([gateway]) + controller._test_triggerAutoConnect() + + #expect(controller._test_didAutoConnect() == false) + } + + @Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + #expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true) + #expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == true) + #expect(controller._test_resolveManualUseTLS(host: "127.attacker.example", useTLS: false) == true) + + #expect(controller._test_resolveManualUseTLS(host: "localhost", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "127.0.0.1", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "::1", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "[::1]", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "::ffff:127.0.0.1", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "0.0.0.0", useTLS: false) == false) + } + + @Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + #expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789) + #expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443) + #expect(controller._test_resolveManualPort(host: "device.sample.ts.net.", port: 0, useTLS: true) == 443) + #expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 18789, useTLS: true) == 18789) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayDiscoveryModelTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayDiscoveryModelTests.swift new file mode 100644 index 00000000..2f98948c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayDiscoveryModelTests.swift @@ -0,0 +1,22 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct GatewayDiscoveryModelTests { + @Test @MainActor func debugLoggingCapturesLifecycleAndResets() { + let model = GatewayDiscoveryModel() + + #expect(model.debugLog.isEmpty) + #expect(model.statusText == "Idle") + + model.setDebugLoggingEnabled(true) + #expect(model.debugLog.count >= 2) + + model.stop() + #expect(model.statusText == "Stopped") + #expect(model.gateways.isEmpty) + #expect(model.debugLog.count >= 3) + + model.setDebugLoggingEnabled(false) + #expect(model.debugLog.isEmpty) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayEndpointIDTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayEndpointIDTests.swift new file mode 100644 index 00000000..e6edf2df --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewayEndpointIDTests.swift @@ -0,0 +1,33 @@ +import OpenClawKit +import Network +import Testing +@testable import OpenClaw + +@Suite struct GatewayEndpointIDTests { + @Test func stableIDForServiceDecodesAndNormalizesName() { + let endpoint = NWEndpoint.service( + name: "OpenClaw\\032Gateway \\032 Node\n", + type: "_openclaw-gw._tcp", + domain: "local.", + interface: nil) + + #expect(GatewayEndpointID.stableID(endpoint) == "_openclaw-gw._tcp|local.|OpenClaw Gateway Node") + } + + @Test func stableIDForNonServiceUsesEndpointDescription() { + let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 4242) + #expect(GatewayEndpointID.stableID(endpoint) == String(describing: endpoint)) + } + + @Test func prettyDescriptionDecodesBonjourEscapes() { + let endpoint = NWEndpoint.service( + name: "OpenClaw\\032Gateway", + type: "_openclaw-gw._tcp", + domain: "local.", + interface: nil) + + let pretty = GatewayEndpointID.prettyDescription(endpoint) + #expect(pretty == BonjourEscapes.decode(String(describing: endpoint))) + #expect(!pretty.localizedCaseInsensitiveContains("\\032")) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewaySettingsStoreTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewaySettingsStoreTests.swift new file mode 100644 index 00000000..0bac4015 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -0,0 +1,214 @@ +import Foundation +import Testing +@testable import OpenClaw + +private struct KeychainEntry: Hashable { + let service: String + let account: String +} + +private let gatewayService = "ai.openclaw.gateway" +private let nodeService = "ai.openclaw.node" +private let talkService = "ai.openclaw.talk" +private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") +private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID") +private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID") +private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme") + +private func snapshotDefaults(_ keys: [String]) -> [String: Any?] { + let defaults = UserDefaults.standard + var snapshot: [String: Any?] = [:] + for key in keys { + snapshot[key] = defaults.object(forKey: key) + } + return snapshot +} + +private func applyDefaults(_ values: [String: Any?]) { + let defaults = UserDefaults.standard + for (key, value) in values { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } +} + +private func restoreDefaults(_ snapshot: [String: Any?]) { + applyDefaults(snapshot) +} + +private func snapshotKeychain(_ entries: [KeychainEntry]) -> [KeychainEntry: String?] { + var snapshot: [KeychainEntry: String?] = [:] + for entry in entries { + snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account) + } + return snapshot +} + +private func applyKeychain(_ values: [KeychainEntry: String?]) { + for (entry, value) in values { + if let value { + _ = KeychainStore.saveString(value, service: entry.service, account: entry.account) + } else { + _ = KeychainStore.delete(service: entry.service, account: entry.account) + } + } +} + +private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) { + applyKeychain(snapshot) +} + +@Suite(.serialized) struct GatewaySettingsStoreTests { + @Test func bootstrapCopiesDefaultsToKeychainWhenMissing() { + let defaultsKeys = [ + "node.instanceId", + "gateway.preferredStableID", + "gateway.lastDiscoveredStableID", + ] + let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry] + let defaultsSnapshot = snapshotDefaults(defaultsKeys) + let keychainSnapshot = snapshotKeychain(entries) + defer { + restoreDefaults(defaultsSnapshot) + restoreKeychain(keychainSnapshot) + } + + applyDefaults([ + "node.instanceId": "node-test", + "gateway.preferredStableID": "preferred-test", + "gateway.lastDiscoveredStableID": "last-test", + ]) + applyKeychain([ + instanceIdEntry: nil, + preferredGatewayEntry: nil, + lastGatewayEntry: nil, + ]) + + GatewaySettingsStore.bootstrapPersistence() + + #expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test") + #expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test") + #expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test") + } + + @Test func bootstrapCopiesKeychainToDefaultsWhenMissing() { + let defaultsKeys = [ + "node.instanceId", + "gateway.preferredStableID", + "gateway.lastDiscoveredStableID", + ] + let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry] + let defaultsSnapshot = snapshotDefaults(defaultsKeys) + let keychainSnapshot = snapshotKeychain(entries) + defer { + restoreDefaults(defaultsSnapshot) + restoreKeychain(keychainSnapshot) + } + + applyDefaults([ + "node.instanceId": nil, + "gateway.preferredStableID": nil, + "gateway.lastDiscoveredStableID": nil, + ]) + applyKeychain([ + instanceIdEntry: "node-from-keychain", + preferredGatewayEntry: "preferred-from-keychain", + lastGatewayEntry: "last-from-keychain", + ]) + + GatewaySettingsStore.bootstrapPersistence() + + let defaults = UserDefaults.standard + #expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain") + #expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain") + #expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain") + } + + @Test func lastGateway_manualRoundTrip() { + let keys = [ + "gateway.last.kind", + "gateway.last.host", + "gateway.last.port", + "gateway.last.tls", + "gateway.last.stableID", + ] + let snapshot = snapshotDefaults(keys) + defer { restoreDefaults(snapshot) } + + GatewaySettingsStore.saveLastGatewayConnectionManual( + host: "example.com", + port: 443, + useTLS: true, + stableID: "manual|example.com|443") + + let loaded = GatewaySettingsStore.loadLastGatewayConnection() + #expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443")) + } + + @Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() { + let keys = [ + "gateway.last.kind", + "gateway.last.host", + "gateway.last.port", + "gateway.last.tls", + "gateway.last.stableID", + ] + let snapshot = snapshotDefaults(keys) + defer { restoreDefaults(snapshot) } + + // Simulate a prior manual record that included host/port. + applyDefaults([ + "gateway.last.host": "10.0.0.99", + "gateway.last.port": 18789, + "gateway.last.tls": true, + "gateway.last.stableID": "manual|10.0.0.99|18789", + "gateway.last.kind": "manual", + ]) + + GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true) + + let defaults = UserDefaults.standard + #expect(defaults.object(forKey: "gateway.last.host") == nil) + #expect(defaults.object(forKey: "gateway.last.port") == nil) + #expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true)) + } + + @Test func lastGateway_backCompat_manualLoadsWhenKindMissing() { + let keys = [ + "gateway.last.kind", + "gateway.last.host", + "gateway.last.port", + "gateway.last.tls", + "gateway.last.stableID", + ] + let snapshot = snapshotDefaults(keys) + defer { restoreDefaults(snapshot) } + + applyDefaults([ + "gateway.last.kind": nil, + "gateway.last.host": "example.org", + "gateway.last.port": 18789, + "gateway.last.tls": false, + "gateway.last.stableID": "manual|example.org|18789", + ]) + + let loaded = GatewaySettingsStore.loadLastGatewayConnection() + #expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789")) + } + + @Test func talkProviderApiKey_genericRoundTrip() { + let keychainSnapshot = snapshotKeychain([talkAcmeProviderEntry]) + defer { restoreKeychain(keychainSnapshot) } + + _ = KeychainStore.delete(service: talkService, account: talkAcmeProviderEntry.account) + + GatewaySettingsStore.saveTalkProviderApiKey("acme-key", provider: "acme") + #expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == "acme-key") + + GatewaySettingsStore.saveTalkProviderApiKey(nil, provider: "acme") + #expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/IOSGatewayChatTransportTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/IOSGatewayChatTransportTests.swift new file mode 100644 index 00000000..f49f242f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/IOSGatewayChatTransportTests.swift @@ -0,0 +1,30 @@ +import OpenClawKit +import Testing +@testable import OpenClaw + +@Suite struct IOSGatewayChatTransportTests { + @Test func requestsFailFastWhenGatewayNotConnected() async { + let gateway = GatewayNodeSession() + let transport = IOSGatewayChatTransport(gateway: gateway) + + do { + _ = try await transport.requestHistory(sessionKey: "node-test") + Issue.record("Expected requestHistory to throw when gateway not connected") + } catch {} + + do { + _ = try await transport.sendMessage( + sessionKey: "node-test", + message: "hello", + thinking: "low", + idempotencyKey: "idempotency", + attachments: []) + Issue.record("Expected sendMessage to throw when gateway not connected") + } catch {} + + do { + _ = try await transport.requestHealth(timeoutMs: 250) + Issue.record("Expected requestHealth to throw when gateway not connected") + } catch {} + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/Info.plist b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/Info.plist new file mode 100644 index 00000000..c273b192 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenClawTests + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 2026.2.25 + CFBundleVersion + 20260225 + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/KeychainStoreTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/KeychainStoreTests.swift new file mode 100644 index 00000000..e56f4aa3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/KeychainStoreTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct KeychainStoreTests { + @Test func saveLoadUpdateDeleteRoundTrip() { + let service = "ai.openclaw.tests.\(UUID().uuidString)" + let account = "value" + + #expect(KeychainStore.delete(service: service, account: account)) + #expect(KeychainStore.loadString(service: service, account: account) == nil) + + #expect(KeychainStore.saveString("first", service: service, account: account)) + #expect(KeychainStore.loadString(service: service, account: account) == "first") + + #expect(KeychainStore.saveString("second", service: service, account: account)) + #expect(KeychainStore.loadString(service: service, account: account) == "second") + + #expect(KeychainStore.delete(service: service, account: account)) + #expect(KeychainStore.loadString(service: service, account: account) == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/NodeAppModelInvokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/NodeAppModelInvokeTests.swift new file mode 100644 index 00000000..dbeee118 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -0,0 +1,503 @@ +import OpenClawKit +import Foundation +import Testing +import UIKit +@testable import OpenClaw + +private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T { + let defaults = UserDefaults.standard + var snapshot: [String: Any?] = [:] + for key in updates.keys { + snapshot[key] = defaults.object(forKey: key) + } + for (key, value) in updates { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + defer { + for (key, value) in snapshot { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + return try body() +} + +private func makeAgentDeepLinkURL( + message: String, + deliver: Bool = false, + to: String? = nil, + channel: String? = nil, + key: String? = nil) -> URL +{ + var components = URLComponents() + components.scheme = "openclaw" + components.host = "agent" + var queryItems: [URLQueryItem] = [URLQueryItem(name: "message", value: message)] + if deliver { + queryItems.append(URLQueryItem(name: "deliver", value: "1")) + } + if let to { + queryItems.append(URLQueryItem(name: "to", value: to)) + } + if let channel { + queryItems.append(URLQueryItem(name: "channel", value: channel)) + } + if let key { + queryItems.append(URLQueryItem(name: "key", value: key)) + } + components.queryItems = queryItems + return components.url! +} + +@MainActor +private final class MockWatchMessagingService: @preconcurrency WatchMessagingServicing, @unchecked Sendable { + var currentStatus = WatchMessagingStatus( + supported: true, + paired: true, + appInstalled: true, + reachable: true, + activationState: "activated") + var nextSendResult = WatchNotificationSendResult( + deliveredImmediately: true, + queuedForDelivery: false, + transport: "sendMessage") + var sendError: Error? + var lastSent: (id: String, params: OpenClawWatchNotifyParams)? + private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? + + func status() async -> WatchMessagingStatus { + self.currentStatus + } + + func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { + self.replyHandler = handler + } + + func sendNotification(id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult { + self.lastSent = (id: id, params: params) + if let sendError = self.sendError { + throw sendError + } + return self.nextSendResult + } + + func emitReply(_ event: WatchQuickReplyEvent) { + self.replyHandler?(event) + } +} + +@Suite(.serialized) struct NodeAppModelInvokeTests { + @Test @MainActor func decodeParamsFailsWithoutJSON() { + #expect(throws: Error.self) { + _ = try NodeAppModel._test_decodeParams(OpenClawCanvasNavigateParams.self, from: nil) + } + } + + @Test @MainActor func encodePayloadEmitsJSON() throws { + struct Payload: Codable, Equatable { + var value: String + } + let json = try NodeAppModel._test_encodePayload(Payload(value: "ok")) + #expect(json.contains("\"value\"")) + } + + @Test @MainActor func chatSessionKeyDefaultsToIOSBase() { + let appModel = NodeAppModel() + #expect(appModel.chatSessionKey == "ios") + } + + @Test @MainActor func chatSessionKeyUsesAgentScopedKeyForNonDefaultAgent() { + let appModel = NodeAppModel() + appModel.gatewayDefaultAgentId = "main" + appModel.setSelectedAgentId("agent-123") + #expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "ios")) + #expect(appModel.mainSessionKey == "agent:agent-123:main") + } + + @Test @MainActor func handleInvokeRejectsBackgroundCommands() async { + let appModel = NodeAppModel() + appModel.setScenePhase(.background) + + let req = BridgeInvokeRequest(id: "bg", command: OpenClawCanvasCommand.present.rawValue) + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.code == .backgroundUnavailable) + } + + @Test @MainActor func handleInvokeRejectsCameraWhenDisabled() async { + let appModel = NodeAppModel() + let req = BridgeInvokeRequest(id: "cam", command: OpenClawCameraCommand.snap.rawValue) + + let defaults = UserDefaults.standard + let key = "camera.enabled" + let previous = defaults.object(forKey: key) + defaults.set(false, forKey: key) + defer { + if let previous { + defaults.set(previous, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.code == .unavailable) + #expect(res.error?.message.contains("CAMERA_DISABLED") == true) + } + + @Test @MainActor func handleInvokeRejectsInvalidScreenFormat() async { + let appModel = NodeAppModel() + let params = OpenClawScreenRecordParams(format: "gif") + let data = try? JSONEncoder().encode(params) + let json = data.flatMap { String(data: $0, encoding: .utf8) } + + let req = BridgeInvokeRequest( + id: "screen", + command: OpenClawScreenCommand.record.rawValue, + paramsJSON: json) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.message.contains("screen format must be mp4") == true) + } + + @Test @MainActor func handleInvokeCanvasCommandsUpdateScreen() async throws { + let appModel = NodeAppModel() + appModel.screen.navigate(to: "http://example.com") + + let present = BridgeInvokeRequest(id: "present", command: OpenClawCanvasCommand.present.rawValue) + let presentRes = await appModel._test_handleInvoke(present) + #expect(presentRes.ok == true) + #expect(appModel.screen.urlString.isEmpty) + + // Loopback URLs are rejected (they are not meaningful for a remote gateway). + let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/") + let navData = try JSONEncoder().encode(navigateParams) + let navJSON = String(decoding: navData, as: UTF8.self) + let navigate = BridgeInvokeRequest( + id: "nav", + command: OpenClawCanvasCommand.navigate.rawValue, + paramsJSON: navJSON) + let navRes = await appModel._test_handleInvoke(navigate) + #expect(navRes.ok == true) + #expect(appModel.screen.urlString == "http://example.com/") + + let evalParams = OpenClawCanvasEvalParams(javaScript: "1+1") + let evalData = try JSONEncoder().encode(evalParams) + let evalJSON = String(decoding: evalData, as: UTF8.self) + let eval = BridgeInvokeRequest( + id: "eval", + command: OpenClawCanvasCommand.evalJS.rawValue, + paramsJSON: evalJSON) + let evalRes = await appModel._test_handleInvoke(eval) + #expect(evalRes.ok == true) + let payloadData = try #require(evalRes.payloadJSON?.data(using: .utf8)) + let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any] + #expect(payload?["result"] as? String == "2") + } + + @Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws { + let appModel = NodeAppModel() + + let reset = BridgeInvokeRequest(id: "reset", command: OpenClawCanvasA2UICommand.reset.rawValue) + let resetRes = await appModel._test_handleInvoke(reset) + #expect(resetRes.ok == false) + #expect(resetRes.error?.message.contains("A2UI_HOST_NOT_CONFIGURED") == true) + + let jsonl = "{\"beginRendering\":{}}" + let pushParams = OpenClawCanvasA2UIPushJSONLParams(jsonl: jsonl) + let pushData = try JSONEncoder().encode(pushParams) + let pushJSON = String(decoding: pushData, as: UTF8.self) + let push = BridgeInvokeRequest( + id: "push", + command: OpenClawCanvasA2UICommand.pushJSONL.rawValue, + paramsJSON: pushJSON) + let pushRes = await appModel._test_handleInvoke(push) + #expect(pushRes.ok == false) + #expect(pushRes.error?.message.contains("A2UI_HOST_NOT_CONFIGURED") == true) + } + + @Test @MainActor func handleInvokeUnknownCommandReturnsInvalidRequest() async { + let appModel = NodeAppModel() + let req = BridgeInvokeRequest(id: "unknown", command: "nope") + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.code == .invalidRequest) + } + + @Test @MainActor func handleInvokeWatchStatusReturnsServiceSnapshot() async throws { + let watchService = MockWatchMessagingService() + watchService.currentStatus = WatchMessagingStatus( + supported: true, + paired: true, + appInstalled: true, + reachable: false, + activationState: "inactive") + let appModel = NodeAppModel(watchMessagingService: watchService) + let req = BridgeInvokeRequest(id: "watch-status", command: OpenClawWatchCommand.status.rawValue) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + + let payloadData = try #require(res.payloadJSON?.data(using: .utf8)) + let payload = try JSONDecoder().decode(OpenClawWatchStatusPayload.self, from: payloadData) + #expect(payload.supported == true) + #expect(payload.reachable == false) + #expect(payload.activationState == "inactive") + } + + @Test @MainActor func handleInvokeWatchNotifyRoutesToWatchService() async throws { + let watchService = MockWatchMessagingService() + watchService.nextSendResult = WatchNotificationSendResult( + deliveredImmediately: false, + queuedForDelivery: true, + transport: "transferUserInfo") + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "OpenClaw", + body: "Meeting with Peter is at 4pm", + priority: .timeSensitive) + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + #expect(watchService.lastSent?.params.title == "OpenClaw") + #expect(watchService.lastSent?.params.body == "Meeting with Peter is at 4pm") + #expect(watchService.lastSent?.params.priority == .timeSensitive) + + let payloadData = try #require(res.payloadJSON?.data(using: .utf8)) + let payload = try JSONDecoder().decode(OpenClawWatchNotifyPayload.self, from: payloadData) + #expect(payload.deliveredImmediately == false) + #expect(payload.queuedForDelivery == true) + #expect(payload.transport == "transferUserInfo") + } + + @Test @MainActor func handleInvokeWatchNotifyRejectsEmptyMessage() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams(title: " ", body: "\n") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-empty", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.code == .invalidRequest) + #expect(watchService.lastSent == nil) + } + + @Test @MainActor func handleInvokeWatchNotifyAddsDefaultActionsForPrompt() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "Task", + body: "Action needed", + priority: .passive, + promptId: "prompt-123") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-default-actions", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + #expect(watchService.lastSent?.params.risk == .low) + let actionIDs = watchService.lastSent?.params.actions?.map(\.id) + #expect(actionIDs == ["done", "snooze_10m", "open_phone", "escalate"]) + } + + @Test @MainActor func handleInvokeWatchNotifyAddsApprovalDefaults() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "Approval", + body: "Allow command?", + promptId: "prompt-approval", + kind: "approval") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-approval-defaults", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + let actionIDs = watchService.lastSent?.params.actions?.map(\.id) + #expect(actionIDs == ["approve", "decline", "open_phone", "escalate"]) + #expect(watchService.lastSent?.params.actions?[1].style == "destructive") + } + + @Test @MainActor func handleInvokeWatchNotifyDerivesPriorityFromRiskAndCapsActions() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "Urgent", + body: "Check now", + risk: .high, + actions: [ + OpenClawWatchAction(id: "a1", label: "A1"), + OpenClawWatchAction(id: "a2", label: "A2"), + OpenClawWatchAction(id: "a3", label: "A3"), + OpenClawWatchAction(id: "a4", label: "A4"), + OpenClawWatchAction(id: "a5", label: "A5"), + ]) + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-derive-priority", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + #expect(watchService.lastSent?.params.priority == .timeSensitive) + #expect(watchService.lastSent?.params.risk == .high) + let actionIDs = watchService.lastSent?.params.actions?.map(\.id) + #expect(actionIDs == ["a1", "a2", "a3", "a4"]) + } + + @Test @MainActor func handleInvokeWatchNotifyReturnsUnavailableOnDeliveryFailure() async throws { + let watchService = MockWatchMessagingService() + watchService.sendError = NSError( + domain: "watch", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "WATCH_UNAVAILABLE: no paired Apple Watch"]) + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams(title: "OpenClaw", body: "Delivery check") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-fail", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.code == .unavailable) + #expect(res.error?.message.contains("WATCH_UNAVAILABLE") == true) + } + + @Test @MainActor func watchReplyQueuesWhenGatewayOffline() async { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + watchService.emitReply( + WatchQuickReplyEvent( + replyId: "reply-offline-1", + promptId: "prompt-1", + actionId: "approve", + actionLabel: "Approve", + sessionKey: "ios", + note: nil, + sentAtMs: 1234, + transport: "transferUserInfo")) + #expect(appModel._test_queuedWatchReplyCount() == 1) + } + + @Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async { + let appModel = NodeAppModel() + let url = URL(string: "openclaw://agent?message=hello")! + await appModel.handleDeepLink(url: url) + #expect(appModel.screen.errorText?.contains("Gateway not connected") == true) + } + + @Test @MainActor func handleDeepLinkRejectsOversizedMessage() async { + let appModel = NodeAppModel() + let msg = String(repeating: "a", count: 20001) + let url = URL(string: "openclaw://agent?message=\(msg)")! + await appModel.handleDeepLink(url: url) + #expect(appModel.screen.errorText?.contains("Deep link too large") == true) + } + + @Test @MainActor func handleDeepLinkRequiresConfirmationWhenConnectedAndUnkeyed() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let url = makeAgentDeepLinkURL(message: "hello from deep link") + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt != nil) + #expect(appModel.openChatRequestID == 0) + + await appModel.approvePendingAgentDeepLinkPrompt() + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.openChatRequestID == 1) + } + + @Test @MainActor func handleDeepLinkStripsDeliveryFieldsWhenUnkeyed() async throws { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let url = makeAgentDeepLinkURL( + message: "route this", + deliver: true, + to: "123456", + channel: "telegram") + + await appModel.handleDeepLink(url: url) + let prompt = try #require(appModel.pendingAgentDeepLinkPrompt) + #expect(prompt.request.deliver == false) + #expect(prompt.request.to == nil) + #expect(prompt.request.channel == nil) + } + + @Test @MainActor func handleDeepLinkRejectsLongUnkeyedMessageWhenConnected() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let message = String(repeating: "x", count: 241) + let url = makeAgentDeepLinkURL(message: message) + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.screen.errorText?.contains("blocked") == true) + } + + @Test @MainActor func handleDeepLinkBypassesPromptWithValidKey() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let key = NodeAppModel._test_currentDeepLinkKey() + let url = makeAgentDeepLinkURL(message: "trusted request", key: key) + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.openChatRequestID == 1) + } + + @Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async { + let appModel = NodeAppModel() + await #expect(throws: Error.self) { + try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main") + } + } + + @Test @MainActor func canvasA2UIActionDispatchesStatus() async { + let appModel = NodeAppModel() + let body: [String: Any] = [ + "userAction": [ + "name": "tap", + "id": "action-1", + "surfaceId": "main", + "sourceComponentId": "button-1", + "context": ["value": "ok"], + ], + ] + await appModel._test_handleCanvasA2UIAction(body: body) + #expect(appModel.screen.urlString.isEmpty) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/OnboardingStateStoreTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/OnboardingStateStoreTests.swift new file mode 100644 index 00000000..30c01464 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/OnboardingStateStoreTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct OnboardingStateStoreTests { + @Test @MainActor func shouldPresentWhenFreshAndDisconnected() { + let testDefaults = self.makeDefaults() + let defaults = testDefaults.defaults + defer { self.reset(testDefaults) } + + let appModel = NodeAppModel() + appModel.gatewayServerName = nil + #expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) + } + + @Test @MainActor func doesNotPresentWhenConnected() { + let testDefaults = self.makeDefaults() + let defaults = testDefaults.defaults + defer { self.reset(testDefaults) } + + let appModel = NodeAppModel() + appModel.gatewayServerName = "gateway" + #expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) + } + + @Test @MainActor func markCompletedPersistsMode() { + let testDefaults = self.makeDefaults() + let defaults = testDefaults.defaults + defer { self.reset(testDefaults) } + + let appModel = NodeAppModel() + appModel.gatewayServerName = nil + + OnboardingStateStore.markCompleted(mode: .remoteDomain, defaults: defaults) + #expect(OnboardingStateStore.lastMode(defaults: defaults) == .remoteDomain) + #expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) + + OnboardingStateStore.markIncomplete(defaults: defaults) + #expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) + } + + private struct TestDefaults { + var suiteName: String + var defaults: UserDefaults + } + + private func makeDefaults() -> TestDefaults { + let suiteName = "OnboardingStateStoreTests.\(UUID().uuidString)" + return TestDefaults( + suiteName: suiteName, + defaults: UserDefaults(suiteName: suiteName) ?? .standard) + } + + private func reset(_ defaults: TestDefaults) { + defaults.defaults.removePersistentDomain(forName: defaults.suiteName) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/ScreenControllerTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/ScreenControllerTests.swift new file mode 100644 index 00000000..d0e47c84 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/ScreenControllerTests.swift @@ -0,0 +1,87 @@ +import Testing +import WebKit +@testable import OpenClaw + +@MainActor +private func mountScreen(_ screen: ScreenController) throws -> (ScreenWebViewCoordinator, WKWebView) { + let coordinator = ScreenWebViewCoordinator(controller: screen) + _ = coordinator.makeContainerView() + let webView = try #require(coordinator.managedWebView) + return (coordinator, webView) +} + +@Suite struct ScreenControllerTests { + @Test @MainActor func canvasModeConfiguresWebViewForTouch() throws { + let screen = ScreenController() + let (coordinator, webView) = try mountScreen(screen) + defer { coordinator.teardown() } + + #expect(webView.isOpaque == true) + #expect(webView.backgroundColor == .black) + + let scrollView = webView.scrollView + #expect(scrollView.backgroundColor == .black) + #expect(scrollView.contentInsetAdjustmentBehavior == .never) + #expect(scrollView.isScrollEnabled == false) + #expect(scrollView.bounces == false) + } + + @Test @MainActor func navigateEnablesScrollForWebPages() throws { + let screen = ScreenController() + let (coordinator, webView) = try mountScreen(screen) + defer { coordinator.teardown() } + + screen.navigate(to: "https://example.com") + + let scrollView = webView.scrollView + #expect(scrollView.isScrollEnabled == true) + #expect(scrollView.bounces == true) + } + + @Test @MainActor func navigateSlashShowsDefaultCanvas() { + let screen = ScreenController() + screen.navigate(to: "/") + + #expect(screen.urlString.isEmpty) + } + + @Test @MainActor func evalExecutesJavaScript() async throws { + let screen = ScreenController() + let (coordinator, _) = try mountScreen(screen) + defer { coordinator.teardown() } + + let deadline = ContinuousClock().now.advanced(by: .seconds(3)) + + while true { + do { + let result = try await screen.eval(javaScript: "1+1") + #expect(result == "2") + return + } catch { + if ContinuousClock().now >= deadline { + throw error + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + } + + @Test @MainActor func localNetworkCanvasURLsAreAllowed() { + let screen = ScreenController() + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://localhost:18789/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://openclaw.local:18789/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://peters-mac-studio-1:18789/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "https://peters-mac-studio-1.ts.net:18789/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://192.168.0.10:18789/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://10.0.0.10:18789/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://100.123.224.76:18789/")!) == true) // Tailscale CGNAT + #expect(screen.isLocalNetworkCanvasURL(URL(string: "https://example.com/")!) == false) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://8.8.8.8/")!) == false) + } + + @Test func parseA2UIActionBodyAcceptsJSONString() throws { + let body = ScreenController.parseA2UIActionBody("{\"userAction\":{\"name\":\"hello\"}}") + let userAction = try #require(body?["userAction"] as? [String: Any]) + #expect(userAction["name"] as? String == "hello") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/ScreenRecordServiceTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/ScreenRecordServiceTests.swift new file mode 100644 index 00000000..6ae8f1ca --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/ScreenRecordServiceTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct ScreenRecordServiceTests { + @Test func clampDefaultsAndBounds() { + #expect(ScreenRecordService._test_clampDurationMs(nil) == 10000) + #expect(ScreenRecordService._test_clampDurationMs(0) == 250) + #expect(ScreenRecordService._test_clampDurationMs(60001) == 60000) + + #expect(ScreenRecordService._test_clampFps(nil) == 10) + #expect(ScreenRecordService._test_clampFps(0) == 1) + #expect(ScreenRecordService._test_clampFps(120) == 30) + #expect(ScreenRecordService._test_clampFps(.infinity) == 10) + } + + @Test @MainActor func recordRejectsInvalidScreenIndex() async { + let recorder = ScreenRecordService() + do { + _ = try await recorder.record( + screenIndex: 1, + durationMs: 250, + fps: 5, + includeAudio: false, + outPath: nil) + Issue.record("Expected invalid screen index to throw") + } catch let error as ScreenRecordService.ScreenRecordError { + #expect(error.localizedDescription.contains("Invalid screen index") == true) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/SettingsNetworkingHelpersTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/SettingsNetworkingHelpersTests.swift new file mode 100644 index 00000000..f1a64961 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/SettingsNetworkingHelpersTests.swift @@ -0,0 +1,50 @@ +import Testing +@testable import OpenClaw + +@Suite struct SettingsNetworkingHelpersTests { + @Test func parseHostPortParsesIPv4() { + #expect(SettingsNetworkingHelpers.parseHostPort(from: "127.0.0.1:8080") == .init(host: "127.0.0.1", port: 8080)) + } + + @Test func parseHostPortParsesHostnameAndTrims() { + #expect(SettingsNetworkingHelpers.parseHostPort(from: " example.com:80 \n") == .init( + host: "example.com", + port: 80)) + } + + @Test func parseHostPortParsesBracketedIPv6() { + #expect( + SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:443") == + .init(host: "2001:db8::1", port: 443)) + } + + @Test func parseHostPortRejectsMissingPort() { + #expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com") == nil) + #expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]") == nil) + } + + @Test func parseHostPortRejectsInvalidPort() { + #expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com:lol") == nil) + #expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:lol") == nil) + } + + @Test func httpURLStringFormatsIPv4AndPort() { + #expect(SettingsNetworkingHelpers + .httpURLString(host: "127.0.0.1", port: 8080, fallback: "fallback") == "http://127.0.0.1:8080") + } + + @Test func httpURLStringBracketsIPv6() { + #expect(SettingsNetworkingHelpers + .httpURLString(host: "2001:db8::1", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080") + } + + @Test func httpURLStringLeavesAlreadyBracketedIPv6() { + #expect(SettingsNetworkingHelpers + .httpURLString(host: "[2001:db8::1]", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080") + } + + @Test func httpURLStringFallsBackWhenMissingHostOrPort() { + #expect(SettingsNetworkingHelpers.httpURLString(host: nil, port: 80, fallback: "x") == "http://x") + #expect(SettingsNetworkingHelpers.httpURLString(host: "example.com", port: nil, fallback: "y") == "http://y") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/ShareToAgentDeepLinkTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/ShareToAgentDeepLinkTests.swift new file mode 100644 index 00000000..4ea178ec --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/ShareToAgentDeepLinkTests.swift @@ -0,0 +1,51 @@ +import OpenClawKit +import Foundation +import Testing + +@Suite struct ShareToAgentDeepLinkTests { + @Test func buildMessageIncludesSharedFields() { + let payload = SharedContentPayload( + title: "Article", + url: URL(string: "https://example.com/post")!, + text: "Read this") + + let message = ShareToAgentDeepLink.buildMessage( + from: payload, + instruction: "Summarize and give next steps.") + #expect(message.contains("Shared from iOS.")) + #expect(message.contains("Title: Article")) + #expect(message.contains("URL: https://example.com/post")) + #expect(message.contains("Text:\nRead this")) + #expect(message.contains("Summarize and give next steps.")) + } + + @Test func buildURLEncodesAgentRoute() { + let payload = SharedContentPayload( + title: "", + url: URL(string: "https://example.com")!, + text: nil) + + let url = ShareToAgentDeepLink.buildURL(from: payload) + let parsed = url.flatMap { DeepLinkParser.parse($0) } + guard case let .agent(agent)? = parsed else { + Issue.record("Expected openclaw://agent deep link") + return + } + + #expect(agent.thinking == "low") + #expect(agent.message.contains("https://example.com")) + } + + @Test func buildURLReturnsNilWhenPayloadEmpty() { + let payload = SharedContentPayload(title: nil, url: nil, text: nil) + #expect(ShareToAgentDeepLink.buildURL(from: payload) == nil) + } + + @Test func shareInstructionSettingsRoundTrip() { + let value = "Focus on booking constraints and alternatives." + ShareToAgentSettings.saveDefaultInstruction(value) + defer { ShareToAgentSettings.saveDefaultInstruction(nil) } + + #expect(ShareToAgentSettings.loadDefaultInstruction() == value) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/SwiftUIRenderSmokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/SwiftUIRenderSmokeTests.swift new file mode 100644 index 00000000..4e13b3f4 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/SwiftUIRenderSmokeTests.swift @@ -0,0 +1,81 @@ +import OpenClawKit +import SwiftUI +import Testing +import UIKit +@testable import OpenClaw + +@Suite struct SwiftUIRenderSmokeTests { + @MainActor private static func host(_ view: some View) -> UIWindow { + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = UIHostingController(rootView: view) + window.makeKeyAndVisible() + window.rootViewController?.view.setNeedsLayout() + window.rootViewController?.view.layoutIfNeeded() + return window + } + + @Test @MainActor func statusPillConnectingBuildsAViewHierarchy() { + let root = StatusPill(gateway: .connecting, voiceWakeEnabled: true, brighten: true) {} + _ = Self.host(root) + } + + @Test @MainActor func statusPillDisconnectedBuildsAViewHierarchy() { + let root = StatusPill(gateway: .disconnected, voiceWakeEnabled: false) {} + _ = Self.host(root) + } + + @Test @MainActor func settingsTabBuildsAViewHierarchy() { + let appModel = NodeAppModel() + let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + let root = SettingsTab() + .environment(appModel) + .environment(appModel.voiceWake) + .environment(gatewayController) + + _ = Self.host(root) + } + + @Test @MainActor func rootTabsBuildAViewHierarchy() { + let appModel = NodeAppModel() + let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + let root = RootTabs() + .environment(appModel) + .environment(appModel.voiceWake) + .environment(gatewayController) + + _ = Self.host(root) + } + + @Test @MainActor func voiceTabBuildsAViewHierarchy() { + let appModel = NodeAppModel() + + let root = VoiceTab() + .environment(appModel) + .environment(appModel.voiceWake) + + _ = Self.host(root) + } + + @Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() { + let appModel = NodeAppModel() + let root = NavigationStack { VoiceWakeWordsSettingsView() } + .environment(appModel) + _ = Self.host(root) + } + + @Test @MainActor func chatSheetBuildsAViewHierarchy() { + let appModel = NodeAppModel() + let gateway = GatewayNodeSession() + let root = ChatSheet(gateway: gateway, sessionKey: "test") + .environment(appModel) + .environment(appModel.voiceWake) + _ = Self.host(root) + } + + @Test @MainActor func voiceWakeToastBuildsAViewHierarchy() { + let root = VoiceWakeToast(command: "openclaw: do something") + _ = Self.host(root) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/TalkModeConfigParsingTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/TalkModeConfigParsingTests.swift new file mode 100644 index 00000000..fd6b535f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/TalkModeConfigParsingTests.swift @@ -0,0 +1,31 @@ +import Testing +@testable import OpenClaw + +@MainActor +@Suite struct TalkModeConfigParsingTests { + @Test func prefersNormalizedTalkProviderPayload() { + let talk: [String: Any] = [ + "provider": "elevenlabs", + "providers": [ + "elevenlabs": [ + "voiceId": "voice-normalized", + ], + ], + "voiceId": "voice-legacy", + ] + + let selection = TalkModeManager.selectTalkProviderConfig(talk) + #expect(selection?.provider == "elevenlabs") + #expect(selection?.config["voiceId"] as? String == "voice-normalized") + } + + @Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() { + let talk: [String: Any] = [ + "voiceId": "voice-legacy", + "apiKey": "legacy-key", + ] + + let selection = TalkModeManager.selectTalkProviderConfig(talk) + #expect(selection == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/VoiceWakeGatewaySyncTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/VoiceWakeGatewaySyncTests.swift new file mode 100644 index 00000000..fa4a070d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/VoiceWakeGatewaySyncTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct VoiceWakeGatewaySyncTests { + @Test func decodeGatewayTriggersFromJSONSanitizes() { + let payload = #"{"triggers":[" openclaw ","", "computer"]}"# + let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payload) + #expect(triggers == ["openclaw", "computer"]) + } + + @Test func decodeGatewayTriggersFromJSONFallsBackWhenEmpty() { + let payload = #"{"triggers":[" ",""]}"# + let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payload) + #expect(triggers == VoiceWakePreferences.defaultTriggerWords) + } + + @Test func decodeGatewayTriggersFromInvalidJSONReturnsNil() { + let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: "not json") + #expect(triggers == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/VoiceWakeManagerExtractCommandTests.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/VoiceWakeManagerExtractCommandTests.swift new file mode 100644 index 00000000..f6b0378c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/Tests/VoiceWakeManagerExtractCommandTests.swift @@ -0,0 +1,90 @@ +import Foundation +import SwabbleKit +import Testing +@testable import OpenClaw + +@Suite struct VoiceWakeManagerExtractCommandTests { + @Test func extractCommandReturnsNilWhenNoTriggerFound() { + let transcript = "hello world" + let segments = makeSegments( + transcript: transcript, + words: [("hello", 0.0, 0.1), ("world", 0.2, 0.1)]) + #expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["openclaw"]) == nil) + } + + @Test func extractCommandTrimsTokensAndResult() { + let transcript = "hey openclaw do thing" + let segments = makeSegments( + transcript: transcript, + words: [ + ("hey", 0.0, 0.1), + ("openclaw", 0.2, 0.1), + ("do", 0.9, 0.1), + ("thing", 1.1, 0.1), + ]) + let cmd = VoiceWakeManager.extractCommand( + from: transcript, + segments: segments, + triggers: [" openclaw "], + minPostTriggerGap: 0.3) + #expect(cmd == "do thing") + } + + @Test func extractCommandReturnsNilWhenGapTooShort() { + let transcript = "hey openclaw do thing" + let segments = makeSegments( + transcript: transcript, + words: [ + ("hey", 0.0, 0.1), + ("openclaw", 0.2, 0.1), + ("do", 0.35, 0.1), + ("thing", 0.5, 0.1), + ]) + let cmd = VoiceWakeManager.extractCommand( + from: transcript, + segments: segments, + triggers: ["openclaw"], + minPostTriggerGap: 0.3) + #expect(cmd == nil) + } + + @Test func extractCommandReturnsNilWhenNothingAfterTrigger() { + let transcript = "hey openclaw" + let segments = makeSegments( + transcript: transcript, + words: [("hey", 0.0, 0.1), ("openclaw", 0.2, 0.1)]) + #expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["openclaw"]) == nil) + } + + @Test func extractCommandIgnoresEmptyTriggers() { + let transcript = "hey openclaw do thing" + let segments = makeSegments( + transcript: transcript, + words: [ + ("hey", 0.0, 0.1), + ("openclaw", 0.2, 0.1), + ("do", 0.9, 0.1), + ("thing", 1.1, 0.1), + ]) + let cmd = VoiceWakeManager.extractCommand( + from: transcript, + segments: segments, + triggers: ["", " ", "openclaw"], + minPostTriggerGap: 0.3) + #expect(cmd == "do thing") + } +} + +private func makeSegments( + transcript: String, + words: [(String, TimeInterval, TimeInterval)]) +-> [WakeWordSegment] { + var searchStart = transcript.startIndex + var output: [WakeWordSegment] = [] + for (word, start, duration) in words { + let range = transcript.range(of: word, range: searchStart.. + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenClaw + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 2026.2.23 + CFBundleVersion + 20260223 + WKCompanionAppBundleIdentifier + $(OPENCLAW_APP_BUNDLE_ID) + WKWatchKitApp + + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Info.plist b/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Info.plist new file mode 100644 index 00000000..1b5f28df --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenClaw + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundleShortVersionString + 2026.2.23 + CFBundleVersion + 20260223 + NSExtension + + NSExtensionAttributes + + WKAppBundleIdentifier + $(OPENCLAW_WATCH_APP_BUNDLE_ID) + + NSExtensionPointIdentifier + com.apple.watchkit + + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift new file mode 100644 index 00000000..4c123c49 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift @@ -0,0 +1,28 @@ +import SwiftUI + +@main +struct OpenClawWatchApp: App { + @State private var inboxStore = WatchInboxStore() + @State private var receiver: WatchConnectivityReceiver? + + var body: some Scene { + WindowGroup { + WatchInboxView(store: self.inboxStore) { action in + guard let receiver = self.receiver else { return } + let draft = self.inboxStore.makeReplyDraft(action: action) + self.inboxStore.markReplySending(actionLabel: action.label) + Task { @MainActor in + let result = await receiver.sendReply(draft) + self.inboxStore.markReplyResult(result, actionLabel: action.label) + } + } + .task { + if self.receiver == nil { + let receiver = WatchConnectivityReceiver(store: self.inboxStore) + receiver.activate() + self.receiver = receiver + } + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift new file mode 100644 index 00000000..da1c3c37 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift @@ -0,0 +1,236 @@ +import Foundation +import WatchConnectivity + +struct WatchReplyDraft: Sendable { + var replyId: String + var promptId: String + var actionId: String + var actionLabel: String? + var sessionKey: String? + var note: String? + var sentAtMs: Int +} + +struct WatchReplySendResult: Sendable, Equatable { + var deliveredImmediately: Bool + var queuedForDelivery: Bool + var transport: String + var errorMessage: String? +} + +final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { + private let store: WatchInboxStore + private let session: WCSession? + + init(store: WatchInboxStore) { + self.store = store + if WCSession.isSupported() { + self.session = WCSession.default + } else { + self.session = nil + } + super.init() + } + + func activate() { + guard let session = self.session else { return } + session.delegate = self + session.activate() + } + + private func ensureActivated() async { + guard let session = self.session else { return } + if session.activationState == .activated { + return + } + session.activate() + for _ in 0..<8 { + if session.activationState == .activated { + return + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + + func sendReply(_ draft: WatchReplyDraft) async -> WatchReplySendResult { + await self.ensureActivated() + guard let session = self.session else { + return WatchReplySendResult( + deliveredImmediately: false, + queuedForDelivery: false, + transport: "none", + errorMessage: "watch session unavailable") + } + + var payload: [String: Any] = [ + "type": "watch.reply", + "replyId": draft.replyId, + "promptId": draft.promptId, + "actionId": draft.actionId, + "sentAtMs": draft.sentAtMs, + ] + if let actionLabel = draft.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines), + !actionLabel.isEmpty + { + payload["actionLabel"] = actionLabel + } + if let sessionKey = draft.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), + !sessionKey.isEmpty + { + payload["sessionKey"] = sessionKey + } + if let note = draft.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { + payload["note"] = note + } + + if session.isReachable { + do { + try await withCheckedThrowingContinuation { continuation in + session.sendMessage(payload, replyHandler: { _ in + continuation.resume() + }, errorHandler: { error in + continuation.resume(throwing: error) + }) + } + return WatchReplySendResult( + deliveredImmediately: true, + queuedForDelivery: false, + transport: "sendMessage", + errorMessage: nil) + } catch { + // Fall through to queued delivery below. + } + } + + _ = session.transferUserInfo(payload) + return WatchReplySendResult( + deliveredImmediately: false, + queuedForDelivery: true, + transport: "transferUserInfo", + errorMessage: nil) + } + + private static func normalizeObject(_ value: Any) -> [String: Any]? { + if let object = value as? [String: Any] { + return object + } + if let object = value as? [AnyHashable: Any] { + var normalized: [String: Any] = [:] + normalized.reserveCapacity(object.count) + for (key, item) in object { + guard let stringKey = key as? String else { + continue + } + normalized[stringKey] = item + } + return normalized + } + return nil + } + + private static func parseActions(_ value: Any?) -> [WatchPromptAction] { + guard let raw = value as? [Any] else { + return [] + } + return raw.compactMap { item in + guard let obj = Self.normalizeObject(item) else { + return nil + } + let id = (obj["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let label = (obj["label"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !id.isEmpty, !label.isEmpty else { + return nil + } + let style = (obj["style"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + return WatchPromptAction(id: id, label: label, style: style) + } + } + + private static func parseNotificationPayload(_ payload: [String: Any]) -> WatchNotifyMessage? { + guard let type = payload["type"] as? String, type == "watch.notify" else { + return nil + } + + let title = (payload["title"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let body = (payload["body"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + guard title.isEmpty == false || body.isEmpty == false else { + return nil + } + + let id = (payload["id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + let promptId = (payload["promptId"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let sessionKey = (payload["sessionKey"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let kind = (payload["kind"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let details = (payload["details"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue + let risk = (payload["risk"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let actions = Self.parseActions(payload["actions"]) + + return WatchNotifyMessage( + id: id, + title: title, + body: body, + sentAtMs: sentAtMs, + promptId: promptId, + sessionKey: sessionKey, + kind: kind, + details: details, + expiresAtMs: expiresAtMs, + risk: risk, + actions: actions) + } +} + +extension WatchConnectivityReceiver: WCSessionDelegate { + func session( + _: WCSession, + activationDidCompleteWith _: WCSessionActivationState, + error _: (any Error)?) + {} + + func session(_: WCSession, didReceiveMessage message: [String: Any]) { + guard let incoming = Self.parseNotificationPayload(message) else { return } + Task { @MainActor in + self.store.consume(message: incoming, transport: "sendMessage") + } + } + + func session( + _: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void) + { + guard let incoming = Self.parseNotificationPayload(message) else { + replyHandler(["ok": false]) + return + } + replyHandler(["ok": true]) + Task { @MainActor in + self.store.consume(message: incoming, transport: "sendMessage") + } + } + + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + guard let incoming = Self.parseNotificationPayload(userInfo) else { return } + Task { @MainActor in + self.store.consume(message: incoming, transport: "transferUserInfo") + } + } + + func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + guard let incoming = Self.parseNotificationPayload(applicationContext) else { return } + Task { @MainActor in + self.store.consume(message: incoming, transport: "applicationContext") + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Sources/WatchInboxStore.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Sources/WatchInboxStore.swift new file mode 100644 index 00000000..2ac1d75d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Sources/WatchInboxStore.swift @@ -0,0 +1,230 @@ +import Foundation +import Observation +import UserNotifications +import WatchKit + +struct WatchPromptAction: Codable, Sendable, Equatable, Identifiable { + var id: String + var label: String + var style: String? +} + +struct WatchNotifyMessage: Sendable { + var id: String? + var title: String + var body: String + var sentAtMs: Int? + var promptId: String? + var sessionKey: String? + var kind: String? + var details: String? + var expiresAtMs: Int? + var risk: String? + var actions: [WatchPromptAction] +} + +@MainActor @Observable final class WatchInboxStore { + private struct PersistedState: Codable { + var title: String + var body: String + var transport: String + var updatedAt: Date + var lastDeliveryKey: String? + var promptId: String? + var sessionKey: String? + var kind: String? + var details: String? + var expiresAtMs: Int? + var risk: String? + var actions: [WatchPromptAction]? + var replyStatusText: String? + var replyStatusAt: Date? + } + + private static let persistedStateKey = "watch.inbox.state.v1" + private let defaults: UserDefaults + + var title = "OpenClaw" + var body = "Waiting for messages from your iPhone." + var transport = "none" + var updatedAt: Date? + var promptId: String? + var sessionKey: String? + var kind: String? + var details: String? + var expiresAtMs: Int? + var risk: String? + var actions: [WatchPromptAction] = [] + var replyStatusText: String? + var replyStatusAt: Date? + var isReplySending = false + private var lastDeliveryKey: String? + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.restorePersistedState() + Task { + await self.ensureNotificationAuthorization() + } + } + + func consume(message: WatchNotifyMessage, transport: String) { + let messageID = message.id? + .trimmingCharacters(in: .whitespacesAndNewlines) + let deliveryKey = self.deliveryKey( + messageID: messageID, + title: message.title, + body: message.body, + sentAtMs: message.sentAtMs) + guard deliveryKey != self.lastDeliveryKey else { return } + + let normalizedTitle = message.title.isEmpty ? "OpenClaw" : message.title + self.title = normalizedTitle + self.body = message.body + self.transport = transport + self.updatedAt = Date() + self.promptId = message.promptId + self.sessionKey = message.sessionKey + self.kind = message.kind + self.details = message.details + self.expiresAtMs = message.expiresAtMs + self.risk = message.risk + self.actions = message.actions + self.lastDeliveryKey = deliveryKey + self.replyStatusText = nil + self.replyStatusAt = nil + self.isReplySending = false + self.persistState() + + Task { + await self.postLocalNotification( + identifier: deliveryKey, + title: normalizedTitle, + body: message.body, + risk: message.risk) + } + } + + private func restorePersistedState() { + guard let data = self.defaults.data(forKey: Self.persistedStateKey), + let state = try? JSONDecoder().decode(PersistedState.self, from: data) + else { + return + } + + self.title = state.title + self.body = state.body + self.transport = state.transport + self.updatedAt = state.updatedAt + self.lastDeliveryKey = state.lastDeliveryKey + self.promptId = state.promptId + self.sessionKey = state.sessionKey + self.kind = state.kind + self.details = state.details + self.expiresAtMs = state.expiresAtMs + self.risk = state.risk + self.actions = state.actions ?? [] + self.replyStatusText = state.replyStatusText + self.replyStatusAt = state.replyStatusAt + } + + private func persistState() { + guard let updatedAt = self.updatedAt else { return } + let state = PersistedState( + title: self.title, + body: self.body, + transport: self.transport, + updatedAt: updatedAt, + lastDeliveryKey: self.lastDeliveryKey, + promptId: self.promptId, + sessionKey: self.sessionKey, + kind: self.kind, + details: self.details, + expiresAtMs: self.expiresAtMs, + risk: self.risk, + actions: self.actions, + replyStatusText: self.replyStatusText, + replyStatusAt: self.replyStatusAt) + guard let data = try? JSONEncoder().encode(state) else { return } + self.defaults.set(data, forKey: Self.persistedStateKey) + } + + private func deliveryKey(messageID: String?, title: String, body: String, sentAtMs: Int?) -> String { + if let messageID, messageID.isEmpty == false { + return "id:\(messageID)" + } + return "content:\(title)|\(body)|\(sentAtMs ?? 0)" + } + + private func ensureNotificationAuthorization() async { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .notDetermined: + _ = try? await center.requestAuthorization(options: [.alert, .sound]) + default: + break + } + } + + private func mapHapticRisk(_ risk: String?) -> WKHapticType { + switch risk?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "high": + return .failure + case "medium": + return .notification + default: + return .click + } + } + + func makeReplyDraft(action: WatchPromptAction) -> WatchReplyDraft { + let prompt = self.promptId?.trimmingCharacters(in: .whitespacesAndNewlines) + return WatchReplyDraft( + replyId: UUID().uuidString, + promptId: (prompt?.isEmpty == false) ? prompt! : "unknown", + actionId: action.id, + actionLabel: action.label, + sessionKey: self.sessionKey, + note: nil, + sentAtMs: Int(Date().timeIntervalSince1970 * 1000)) + } + + func markReplySending(actionLabel: String) { + self.isReplySending = true + self.replyStatusText = "Sending \(actionLabel)…" + self.replyStatusAt = Date() + self.persistState() + } + + func markReplyResult(_ result: WatchReplySendResult, actionLabel: String) { + self.isReplySending = false + if let errorMessage = result.errorMessage, !errorMessage.isEmpty { + self.replyStatusText = "Failed: \(errorMessage)" + } else if result.deliveredImmediately { + self.replyStatusText = "\(actionLabel): sent" + } else if result.queuedForDelivery { + self.replyStatusText = "\(actionLabel): queued" + } else { + self.replyStatusText = "\(actionLabel): sent" + } + self.replyStatusAt = Date() + self.persistState() + } + + private func postLocalNotification(identifier: String, title: String, body: String, risk: String?) async { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.threadIdentifier = "openclaw-watch" + + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false)) + + _ = try? await UNUserNotificationCenter.current().add(request) + WKInterfaceDevice.current().play(self.mapHapticRisk(risk)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Sources/WatchInboxView.swift b/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Sources/WatchInboxView.swift new file mode 100644 index 00000000..c6f944a9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/WatchExtension/Sources/WatchInboxView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct WatchInboxView: View { + @Bindable var store: WatchInboxStore + var onAction: ((WatchPromptAction) -> Void)? + + private func role(for action: WatchPromptAction) -> ButtonRole? { + switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "destructive": + return .destructive + case "cancel": + return .cancel + default: + return nil + } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text(store.title) + .font(.headline) + .lineLimit(2) + + Text(store.body) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + + if let details = store.details, !details.isEmpty { + Text(details) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if !store.actions.isEmpty { + ForEach(store.actions) { action in + Button(role: self.role(for: action)) { + self.onAction?(action) + } label: { + Text(action.label) + .frame(maxWidth: .infinity) + } + .disabled(store.isReplySending) + } + } + + if let replyStatusText = store.replyStatusText, !replyStatusText.isEmpty { + Text(replyStatusText) + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let updatedAt = store.updatedAt { + Text("Updated \(updatedAt.formatted(date: .omitted, time: .shortened))") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/fastlane/.env.example b/backend/app/one_person_security_dept/openclaw/apps/ios/fastlane/.env.example new file mode 100644 index 00000000..7f2c6133 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/fastlane/.env.example @@ -0,0 +1,21 @@ +# App Store Connect API key (pick one approach) +# +# Recommended (use the downloaded .p8 directly): +# ASC_KEY_ID=XXXXXXXXXX +# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 +# +# Or (JSON key file): +# APP_STORE_CONNECT_API_KEY_PATH=/absolute/path/to/AuthKey_XXXXXX.json +# +# Or: +# ASC_KEY_ID=XXXXXXXXXX +# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# ASC_KEY_CONTENT=BASE64_P8_CONTENT + +# Code signing +# IOS_DEVELOPMENT_TEAM=XXXXXXXXXX + +# Deliver toggles (off by default) +# DELIVER_METADATA=1 +# DELIVER_SCREENSHOTS=1 diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/fastlane/Appfile b/backend/app/one_person_security_dept/openclaw/apps/ios/fastlane/Appfile new file mode 100644 index 00000000..8dbb75a8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/fastlane/Appfile @@ -0,0 +1,7 @@ +app_identifier("ai.openclaw.ios") + +# Auth is expected via App Store Connect API key. +# Provide either: +# - APP_STORE_CONNECT_API_KEY_PATH=/path/to/AuthKey_XXXXXX.p8.json (recommended) +# or: +# - ASC_KEY_ID, ASC_ISSUER_ID, and ASC_KEY_CONTENT (base64 or raw p8 content) diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/fastlane/Fastfile b/backend/app/one_person_security_dept/openclaw/apps/ios/fastlane/Fastfile new file mode 100644 index 00000000..f1dbf6df --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/fastlane/Fastfile @@ -0,0 +1,104 @@ +require "shellwords" + +default_platform(:ios) + +def load_env_file(path) + return unless File.exist?(path) + + File.foreach(path) do |line| + stripped = line.strip + next if stripped.empty? || stripped.start_with?("#") + + key, value = stripped.split("=", 2) + next if key.nil? || key.empty? || value.nil? + + ENV[key] = value if ENV[key].nil? || ENV[key].strip.empty? + end +end + +platform :ios do + private_lane :asc_api_key do + load_env_file(File.join(__dir__, ".env")) + + api_key = nil + + key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"] + if key_path && !key_path.strip.empty? + api_key = app_store_connect_api_key(path: key_path) + else + p8_path = ENV["ASC_KEY_PATH"] + if p8_path && !p8_path.strip.empty? + key_id = ENV["ASC_KEY_ID"] + issuer_id = ENV["ASC_ISSUER_ID"] + UI.user_error!("Missing ASC_KEY_ID or ASC_ISSUER_ID for ASC_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| v.nil? || v.strip.empty? } + + api_key = app_store_connect_api_key( + key_id: key_id, + issuer_id: issuer_id, + key_filepath: p8_path + ) + else + key_id = ENV["ASC_KEY_ID"] + issuer_id = ENV["ASC_ISSUER_ID"] + key_content = ENV["ASC_KEY_CONTENT"] + + UI.user_error!("Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json) or ASC_KEY_PATH (p8) or ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_CONTENT.") if [key_id, issuer_id, key_content].any? { |v| v.nil? || v.strip.empty? } + + is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true + + api_key = app_store_connect_api_key( + key_id: key_id, + issuer_id: issuer_id, + key_content: key_content, + is_key_content_base64: is_base64 + ) + end + end + + api_key + end + + desc "Build + upload to TestFlight" + lane :beta do + api_key = asc_api_key + + team_id = ENV["IOS_DEVELOPMENT_TEAM"] + if team_id.nil? || team_id.strip.empty? + helper_path = File.expand_path("../../scripts/ios-team-id.sh", __dir__) + if File.exist?(helper_path) + # Keep CI/local compatibility where teams are present in keychain but not Xcode account metadata. + team_id = sh("IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash #{helper_path.shellescape}").strip + end + end + UI.user_error!("Missing IOS_DEVELOPMENT_TEAM (Apple Team ID). Add it to fastlane/.env or export it in your shell.") if team_id.nil? || team_id.strip.empty? + + build_app( + project: "OpenClaw.xcodeproj", + scheme: "OpenClaw", + export_method: "app-store", + clean: true, + xcargs: "DEVELOPMENT_TEAM=#{team_id} -allowProvisioningUpdates", + export_xcargs: "-allowProvisioningUpdates", + export_options: { + signingStyle: "automatic" + } + ) + + upload_to_testflight( + api_key: api_key, + skip_waiting_for_build_processing: true + ) + end + + desc "Upload App Store metadata (and optionally screenshots)" + lane :metadata do + api_key = asc_api_key + + deliver( + api_key: api_key, + force: true, + skip_screenshots: ENV["DELIVER_SCREENSHOTS"] != "1", + skip_metadata: ENV["DELIVER_METADATA"] != "1" + ) + end +end diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/fastlane/SETUP.md b/backend/app/one_person_security_dept/openclaw/apps/ios/fastlane/SETUP.md new file mode 100644 index 00000000..930258fc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/fastlane/SETUP.md @@ -0,0 +1,32 @@ +# fastlane setup (OpenClaw iOS) + +Install: + +```bash +brew install fastlane +``` + +Create an App Store Connect API key: + +- App Store Connect → Users and Access → Keys → App Store Connect API → Generate API Key +- Download the `.p8`, note the **Issuer ID** and **Key ID** + +Create `apps/ios/fastlane/.env` (gitignored): + +```bash +ASC_KEY_ID=YOUR_KEY_ID +ASC_ISSUER_ID=YOUR_ISSUER_ID +ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 + +# Code signing (Apple Team ID / App ID Prefix) +IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID +``` + +Tip: run `scripts/ios-team-id.sh` from the repo root to print a Team ID to paste into `.env`. The helper prefers the canonical OpenClaw team (`Y5PE65HELJ`) when present locally; otherwise it prefers the first non-personal team from your Xcode account (then personal team if needed). Fastlane uses this helper automatically if `IOS_DEVELOPMENT_TEAM` is missing. + +Run: + +```bash +cd apps/ios +fastlane beta +``` diff --git a/backend/app/one_person_security_dept/openclaw/apps/ios/project.yml b/backend/app/one_person_security_dept/openclaw/apps/ios/project.yml new file mode 100644 index 00000000..a4d5928d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/ios/project.yml @@ -0,0 +1,238 @@ +name: OpenClaw +options: + bundleIdPrefix: ai.openclaw + deploymentTarget: + iOS: "18.0" + xcodeVersion: "16.0" + +settings: + base: + SWIFT_VERSION: "6.0" + +packages: + OpenClawKit: + path: ../shared/OpenClawKit + Swabble: + path: ../../Swabble + +schemes: + OpenClaw: + shared: true + build: + targets: + OpenClaw: all + test: + targets: + - OpenClawTests + +targets: + OpenClaw: + type: application + platform: iOS + configFiles: + Debug: Signing.xcconfig + Release: Signing.xcconfig + sources: + - path: Sources + dependencies: + - target: OpenClawShareExtension + embed: true + - target: OpenClawWatchApp + - package: OpenClawKit + - package: OpenClawKit + product: OpenClawChatUI + - package: OpenClawKit + product: OpenClawProtocol + - package: Swabble + product: SwabbleKit + - sdk: AppIntents.framework + preBuildScripts: + - name: SwiftFormat (lint) + basedOnDependencyAnalysis: false + inputFileLists: + - $(SRCROOT)/SwiftSources.input.xcfilelist + script: | + set -euo pipefail + export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + if ! command -v swiftformat >/dev/null 2>&1; then + echo "error: swiftformat not found (brew install swiftformat)" >&2 + exit 1 + fi + swiftformat --lint --config "$SRCROOT/../../.swiftformat" \ + --filelist "$SRCROOT/SwiftSources.input.xcfilelist" + - name: SwiftLint + basedOnDependencyAnalysis: false + inputFileLists: + - $(SRCROOT)/SwiftSources.input.xcfilelist + script: | + set -euo pipefail + export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + if ! command -v swiftlint >/dev/null 2>&1; then + echo "error: swiftlint not found (brew install swiftlint)" >&2 + exit 1 + fi + swiftlint lint --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists + settings: + base: + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements + CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)" + DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" + PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)" + PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_APP_PROFILE)" + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + ENABLE_APPINTENTS_METADATA: NO + info: + path: Sources/Info.plist + properties: + CFBundleDisplayName: OpenClaw + CFBundleIconName: AppIcon + CFBundleURLTypes: + - CFBundleURLName: ai.openclaw.ios + CFBundleURLSchemes: + - openclaw + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" + UILaunchScreen: {} + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: false + UIBackgroundModes: + - audio + - remote-notification + BGTaskSchedulerPermittedIdentifiers: + - ai.openclaw.ios.bgrefresh + NSLocalNetworkUsageDescription: OpenClaw discovers and connects to your OpenClaw gateway on the local network. + NSAppTransportSecurity: + NSAllowsArbitraryLoadsInWebContent: true + NSBonjourServices: + - _openclaw-gw._tcp + NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway. + NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing. + NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always. + NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake. + NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake. + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + + OpenClawShareExtension: + type: app-extension + platform: iOS + configFiles: + Debug: Signing.xcconfig + Release: Signing.xcconfig + sources: + - path: ShareExtension + dependencies: + - package: OpenClawKit + settings: + base: + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)" + DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" + PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)" + PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)" + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + info: + path: ShareExtension/Info.plist + properties: + CFBundleDisplayName: OpenClaw Share + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" + NSExtension: + NSExtensionPointIdentifier: com.apple.share-services + NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController" + NSExtensionAttributes: + NSExtensionActivationRule: + NSExtensionActivationSupportsText: true + NSExtensionActivationSupportsWebURLWithMaxCount: 1 + NSExtensionActivationSupportsImageWithMaxCount: 10 + NSExtensionActivationSupportsMovieWithMaxCount: 1 + + OpenClawWatchApp: + type: application.watchapp2 + platform: watchOS + deploymentTarget: "11.0" + sources: + - path: WatchApp + dependencies: + - target: OpenClawWatchExtension + configFiles: + Debug: Config/Signing.xcconfig + Release: Config/Signing.xcconfig + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)" + info: + path: WatchApp/Info.plist + properties: + CFBundleDisplayName: OpenClaw + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" + WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)" + WKWatchKitApp: true + + OpenClawWatchExtension: + type: watchkit2-extension + platform: watchOS + deploymentTarget: "11.0" + sources: + - path: WatchExtension/Sources + dependencies: + - sdk: WatchConnectivity.framework + - sdk: UserNotifications.framework + configFiles: + Debug: Config/Signing.xcconfig + Release: Config/Signing.xcconfig + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)" + info: + path: WatchExtension/Info.plist + properties: + CFBundleDisplayName: OpenClaw + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" + NSExtension: + NSExtensionAttributes: + WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)" + NSExtensionPointIdentifier: com.apple.watchkit + + OpenClawTests: + type: bundle.unit-test + platform: iOS + configFiles: + Debug: Signing.xcconfig + Release: Signing.xcconfig + sources: + - path: Tests + dependencies: + - target: OpenClaw + - package: Swabble + product: SwabbleKit + - sdk: AppIntents.framework + settings: + base: + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)" + DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" + PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.tests + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/OpenClaw.app/OpenClaw" + BUNDLE_LOADER: "$(TEST_HOST)" + info: + path: Tests/Info.plist + properties: + CFBundleDisplayName: OpenClawTests + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Icon.icon/Assets/openclaw-mac.png b/backend/app/one_person_security_dept/openclaw/apps/macos/Icon.icon/Assets/openclaw-mac.png new file mode 100644 index 00000000..1ebd257d Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/macos/Icon.icon/Assets/openclaw-mac.png differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Icon.icon/icon.json b/backend/app/one_person_security_dept/openclaw/apps/macos/Icon.icon/icon.json new file mode 100644 index 00000000..6172a47e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Icon.icon/icon.json @@ -0,0 +1,36 @@ +{ + "fill" : { + "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "openclaw-mac.png", + "name" : "openclaw-mac", + "position" : { + "scale" : 1.07, + "translation-in-points" : [ + -2, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Package.resolved b/backend/app/one_person_security_dept/openclaw/apps/macos/Package.resolved new file mode 100644 index 00000000..89bbefc5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Package.resolved @@ -0,0 +1,132 @@ +{ + "originHash" : "1c9c9d251b760ed3234ecff741a88eb4bf42315ad6f50ac7392b187cf226c16c", + "pins" : [ + { + "identity" : "axorcist", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/AXorcist.git", + "state" : { + "revision" : "c75d06f7f93e264a9786edc2b78c04973061cb2f", + "version" : "0.1.0" + } + }, + { + "identity" : "commander", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/Commander.git", + "state" : { + "revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce", + "version" : "0.2.1" + } + }, + { + "identity" : "elevenlabskit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/ElevenLabsKit", + "state" : { + "revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d", + "version" : "0.1.0" + } + }, + { + "identity" : "menubarextraaccess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/orchetect/MenuBarExtraAccess", + "state" : { + "revision" : "707dff6f55217b3ef5b6be84ced3e83511d4df5c", + "version" : "1.2.2" + } + }, + { + "identity" : "peekaboo", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/Peekaboo.git", + "state" : { + "branch" : "main", + "revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "21d8df80440b1ca3b65fa82e40782f1e5a9e6ba2", + "version" : "2.9.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-subprocess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-subprocess.git", + "state" : { + "revision" : "ba5888ad7758cbcbe7abebac37860b1652af2d9c", + "version" : "0.3.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "swiftui-math", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swiftui-math", + "state" : { + "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", + "version" : "0.1.0" + } + }, + { + "identity" : "textual", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/textual", + "state" : { + "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38", + "version" : "0.3.1" + } + } + ], + "version" : 3 +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Package.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Package.swift new file mode 100644 index 00000000..10ab47b8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Package.swift @@ -0,0 +1,92 @@ +// swift-tools-version: 6.2 +// Package manifest for the OpenClaw macOS companion (menu bar app + IPC library). + +import PackageDescription + +let package = Package( + name: "OpenClaw", + platforms: [ + .macOS(.v15), + ], + products: [ + .library(name: "OpenClawIPC", targets: ["OpenClawIPC"]), + .library(name: "OpenClawDiscovery", targets: ["OpenClawDiscovery"]), + .executable(name: "OpenClaw", targets: ["OpenClaw"]), + .executable(name: "openclaw-mac", targets: ["OpenClawMacCLI"]), + ], + dependencies: [ + .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), + .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"), + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), + .package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"), + .package(path: "../shared/OpenClawKit"), + .package(path: "../../Swabble"), + ], + targets: [ + .target( + name: "OpenClawIPC", + dependencies: [], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .target( + name: "OpenClawDiscovery", + dependencies: [ + .product(name: "OpenClawKit", package: "OpenClawKit"), + ], + path: "Sources/OpenClawDiscovery", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .executableTarget( + name: "OpenClaw", + dependencies: [ + "OpenClawIPC", + "OpenClawDiscovery", + .product(name: "OpenClawKit", package: "OpenClawKit"), + .product(name: "OpenClawChatUI", package: "OpenClawKit"), + .product(name: "OpenClawProtocol", package: "OpenClawKit"), + .product(name: "SwabbleKit", package: "swabble"), + .product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"), + .product(name: "Subprocess", package: "swift-subprocess"), + .product(name: "Logging", package: "swift-log"), + .product(name: "Sparkle", package: "Sparkle"), + .product(name: "PeekabooBridge", package: "Peekaboo"), + .product(name: "PeekabooAutomationKit", package: "Peekaboo"), + ], + exclude: [ + "Resources/Info.plist", + ], + resources: [ + .copy("Resources/OpenClaw.icns"), + .copy("Resources/DeviceModels"), + ], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .executableTarget( + name: "OpenClawMacCLI", + dependencies: [ + "OpenClawDiscovery", + .product(name: "OpenClawKit", package: "OpenClawKit"), + .product(name: "OpenClawProtocol", package: "OpenClawKit"), + ], + path: "Sources/OpenClawMacCLI", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .testTarget( + name: "OpenClawIPCTests", + dependencies: [ + "OpenClawIPC", + "OpenClaw", + "OpenClawDiscovery", + .product(name: "OpenClawProtocol", package: "OpenClawKit"), + .product(name: "SwabbleKit", package: "swabble"), + ], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("SwiftTesting"), + ]), + ]) diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/README.md b/backend/app/one_person_security_dept/openclaw/apps/macos/README.md new file mode 100644 index 00000000..05743dc6 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/README.md @@ -0,0 +1,64 @@ +# OpenClaw macOS app (dev + signing) + +## Quick dev run + +```bash +# from repo root +scripts/restart-mac.sh +``` + +Options: + +```bash +scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick) +scripts/restart-mac.sh --sign # force code signing (requires cert) +``` + +## Packaging flow + +```bash +scripts/package-mac-app.sh +``` + +Creates `dist/OpenClaw.app` and signs it via `scripts/codesign-mac-app.sh`. + +## Signing behavior + +Auto-selects identity (first match): +1) Developer ID Application +2) Apple Distribution +3) Apple Development +4) first available identity + +If none found: +- errors by default +- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign + +## Team ID audit (Sparkle mismatch guard) + +After signing, we read the app bundle Team ID and compare every Mach-O inside the app. +If any embedded binary has a different Team ID, signing fails. + +Skip the audit: +```bash +SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh +``` + +## Library validation workaround (dev only) + +If Sparkle Team ID mismatch blocks loading (common with Apple Development certs), opt in: + +```bash +DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh +``` + +This adds `com.apple.security.cs.disable-library-validation` to app entitlements. +Use for local dev only; keep off for release builds. + +## Useful env flags + +- `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` +- `ALLOW_ADHOC_SIGNING=1` (ad-hoc, TCC permissions do not persist) +- `CODESIGN_TIMESTAMP=off` (offline debug) +- `DISABLE_LIBRARY_VALIDATION=1` (dev-only Sparkle workaround) +- `SKIP_TEAM_ID_CHECK=1` (bypass audit) diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AboutSettings.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AboutSettings.swift new file mode 100644 index 00000000..b61cfee8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AboutSettings.swift @@ -0,0 +1,199 @@ +import SwiftUI + +struct AboutSettings: View { + weak var updater: UpdaterProviding? + @State private var iconHover = false + @AppStorage("autoUpdateEnabled") private var autoCheckEnabled = true + @State private var didLoadUpdaterState = false + + var body: some View { + VStack(spacing: 8) { + let appIcon = NSApplication.shared.applicationIconImage ?? CritterIconRenderer.makeIcon(blink: 0) + Button { + if let url = URL(string: "https://github.com/openclaw/openclaw") { + NSWorkspace.shared.open(url) + } + } label: { + Image(nsImage: appIcon) + .resizable() + .frame(width: 160, height: 160) + .cornerRadius(24) + .shadow(color: self.iconHover ? .accentColor.opacity(0.25) : .clear, radius: 10) + .scaleEffect(self.iconHover ? 1.05 : 1.0) + } + .buttonStyle(.plain) + .focusable(false) + .pointingHandCursor() + .onHover { hover in + withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover } + } + + VStack(spacing: 3) { + Text("OpenClaw") + .font(.title3.bold()) + Text("Version \(self.versionString)") + .foregroundStyle(.secondary) + if let buildTimestamp { + Text("Built \(buildTimestamp)\(self.buildSuffix)") + .font(.footnote) + .foregroundStyle(.secondary) + } + Text("Menu bar companion for notifications, screenshots, and privileged agent actions.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 18) + } + + VStack(alignment: .center, spacing: 6) { + AboutLinkRow( + icon: "chevron.left.slash.chevron.right", + title: "GitHub", + url: "https://github.com/openclaw/openclaw") + AboutLinkRow(icon: "globe", title: "Website", url: "https://openclaw.ai") + AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete") + AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me") + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.vertical, 10) + + if let updater { + Divider() + .padding(.vertical, 8) + + if updater.isAvailable { + VStack(spacing: 10) { + Toggle("Check for updates automatically", isOn: self.$autoCheckEnabled) + .toggleStyle(.checkbox) + .frame(maxWidth: .infinity, alignment: .center) + + Button("Check for Updates…") { updater.checkForUpdates(nil) } + } + } else { + Text("Updates unavailable in this build.") + .foregroundStyle(.secondary) + .padding(.top, 4) + } + } + + Text("© 2025 Peter Steinberger — MIT License.") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 4) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 4) + .padding(.horizontal, 24) + .padding(.bottom, 24) + .onAppear { + guard let updater, !self.didLoadUpdaterState else { return } + // Keep Sparkle’s auto-check setting in sync with the persisted toggle. + updater.automaticallyChecksForUpdates = self.autoCheckEnabled + updater.automaticallyDownloadsUpdates = self.autoCheckEnabled + self.didLoadUpdaterState = true + } + .onChange(of: self.autoCheckEnabled) { _, newValue in + self.updater?.automaticallyChecksForUpdates = newValue + self.updater?.automaticallyDownloadsUpdates = newValue + } + } + + private var versionString: String { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + return build.map { "\(version) (\($0))" } ?? version + } + + private var buildTimestamp: String? { + guard + let raw = + (Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) ?? + (Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) + else { return nil } + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime] + guard let date = parser.date(from: raw) else { return raw } + + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + formatter.locale = .current + return formatter.string(from: date) + } + + private var gitCommit: String { + (Bundle.main.object(forInfoDictionaryKey: "OpenClawGitCommit") as? String) ?? + (Bundle.main.object(forInfoDictionaryKey: "OpenClawGitCommit") as? String) ?? + "unknown" + } + + private var bundleID: String { + Bundle.main.bundleIdentifier ?? "unknown" + } + + private var buildSuffix: String { + let git = self.gitCommit + guard !git.isEmpty, git != "unknown" else { return "" } + + var suffix = " (\(git)" + #if DEBUG + suffix += " DEBUG" + #endif + suffix += ")" + return suffix + } +} + +@MainActor +private struct AboutLinkRow: View { + let icon: String + let title: String + let url: String + + @State private var hovering = false + + var body: some View { + Button { + if let url = URL(string: url) { NSWorkspace.shared.open(url) } + } label: { + HStack(spacing: 6) { + Image(systemName: self.icon) + Text(self.title) + .underline(self.hovering, color: .accentColor) + } + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .onHover { self.hovering = $0 } + .pointingHandCursor() + } +} + +private struct AboutMetaRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(self.label) + .foregroundStyle(.secondary) + Spacer() + Text(self.value) + .font(.caption.monospaced()) + .foregroundStyle(.primary) + } + } +} + +#if DEBUG +struct AboutSettings_Previews: PreviewProvider { + private static let updater = DisabledUpdaterController() + static var previews: some View { + AboutSettings(updater: updater) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AgeFormatting.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AgeFormatting.swift new file mode 100644 index 00000000..5bb46bf4 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AgeFormatting.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Human-friendly age string (e.g., "2m ago"). +func age(from date: Date, now: Date = .init()) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + let minutes = seconds / 60 + let hours = minutes / 60 + let days = hours / 24 + + if seconds < 60 { return "just now" } + if minutes == 1 { return "1 minute ago" } + if minutes < 60 { return "\(minutes)m ago" } + if hours == 1 { return "1 hour ago" } + if hours < 24 { return "\(hours)h ago" } + if days == 1 { return "yesterday" } + return "\(days)d ago" +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AgentEventStore.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AgentEventStore.swift new file mode 100644 index 00000000..780867a3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AgentEventStore.swift @@ -0,0 +1,22 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class AgentEventStore { + static let shared = AgentEventStore() + + private(set) var events: [ControlAgentEvent] = [] + private let maxEvents = 400 + + func append(_ event: ControlAgentEvent) { + self.events.append(event) + if self.events.count > self.maxEvents { + self.events.removeFirst(self.events.count - self.maxEvents) + } + } + + func clear() { + self.events.removeAll() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AgentEventsWindow.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AgentEventsWindow.swift new file mode 100644 index 00000000..673588cc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AgentEventsWindow.swift @@ -0,0 +1,109 @@ +import OpenClawProtocol +import SwiftUI + +@MainActor +struct AgentEventsWindow: View { + private let store = AgentEventStore.shared + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Agent Events") + .font(.title3.weight(.semibold)) + Spacer() + Button("Clear") { self.store.clear() } + .buttonStyle(.bordered) + } + .padding(.bottom, 4) + + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(self.store.events.reversed(), id: \.seq) { evt in + EventRow(event: evt) + } + } + } + } + .padding(12) + .frame(minWidth: 520, minHeight: 360) + } +} + +private struct EventRow: View { + let event: ControlAgentEvent + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(self.event.stream.uppercased()) + .font(.caption2.weight(.bold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(self.tint) + .foregroundStyle(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + Text("run " + self.event.runId) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + Spacer() + Text(self.formattedTs) + .font(.caption2) + .foregroundStyle(.secondary) + } + if let json = self.prettyJSON(event.data) { + Text(json) + .font(.caption.monospaced()) + .foregroundStyle(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(0.04))) + } + + private var tint: Color { + switch self.event.stream { + case "job": .blue + case "tool": .orange + case "assistant": .green + default: .gray + } + } + + private var formattedTs: String { + let date = Date(timeIntervalSince1970: event.ts / 1000) + let f = DateFormatter() + f.dateFormat = "HH:mm:ss.SSS" + return f.string(from: date) + } + + private func prettyJSON(_ dict: [String: OpenClawProtocol.AnyCodable]) -> String? { + let normalized = dict.mapValues { $0.value } + guard JSONSerialization.isValidJSONObject(normalized), + let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]), + let str = String(data: data, encoding: .utf8) + else { return nil } + return str + } +} + +struct AgentEventsWindow_Previews: PreviewProvider { + static var previews: some View { + let sample = ControlAgentEvent( + runId: "abc", + seq: 1, + stream: "tool", + ts: Date().timeIntervalSince1970 * 1000, + data: [ + "phase": OpenClawProtocol.AnyCodable("start"), + "name": OpenClawProtocol.AnyCodable("bash"), + ], + summary: nil) + AgentEventStore.shared.append(sample) + return AgentEventsWindow() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AgentWorkspace.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AgentWorkspace.swift new file mode 100644 index 00000000..6340dee2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AgentWorkspace.swift @@ -0,0 +1,343 @@ +import Foundation +import OSLog + +enum AgentWorkspace { + private static let logger = Logger(subsystem: "ai.openclaw", category: "workspace") + static let agentsFilename = "AGENTS.md" + static let soulFilename = "SOUL.md" + static let identityFilename = "IDENTITY.md" + static let userFilename = "USER.md" + static let bootstrapFilename = "BOOTSTRAP.md" + private static let templateDirname = "templates" + private static let ignoredEntries: Set = [".DS_Store", ".git", ".gitignore"] + private static let templateEntries: Set = [ + AgentWorkspace.agentsFilename, + AgentWorkspace.soulFilename, + AgentWorkspace.identityFilename, + AgentWorkspace.userFilename, + AgentWorkspace.bootstrapFilename, + ] + struct BootstrapSafety: Equatable { + let unsafeReason: String? + + static let safe = Self(unsafeReason: nil) + + static func blocked(_ reason: String) -> Self { + Self(unsafeReason: reason) + } + } + + static func displayPath(for url: URL) -> String { + let home = FileManager().homeDirectoryForCurrentUser.path + let path = url.path + if path == home { return "~" } + if path.hasPrefix(home + "/") { + return "~/" + String(path.dropFirst(home.count + 1)) + } + return path + } + + static func resolveWorkspaceURL(from userInput: String?) -> URL { + let trimmed = userInput?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { return OpenClawConfigFile.defaultWorkspaceURL() } + let expanded = (trimmed as NSString).expandingTildeInPath + return URL(fileURLWithPath: expanded, isDirectory: true) + } + + static func agentsURL(workspaceURL: URL) -> URL { + workspaceURL.appendingPathComponent(self.agentsFilename) + } + + static func workspaceEntries(workspaceURL: URL) throws -> [String] { + let contents = try FileManager().contentsOfDirectory(atPath: workspaceURL.path) + return contents.filter { !self.ignoredEntries.contains($0) } + } + + static func isWorkspaceEmpty(workspaceURL: URL) -> Bool { + let fm = FileManager() + var isDir: ObjCBool = false + if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { + return true + } + guard isDir.boolValue else { return false } + guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false } + return entries.isEmpty + } + + static func isTemplateOnlyWorkspace(workspaceURL: URL) -> Bool { + guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false } + guard !entries.isEmpty else { return true } + return Set(entries).isSubset(of: self.templateEntries) + } + + static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety { + let fm = FileManager() + var isDir: ObjCBool = false + if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { + return .safe + } + if !isDir.boolValue { return .blocked("Workspace path points to a file.") } + let agentsURL = self.agentsURL(workspaceURL: workspaceURL) + if fm.fileExists(atPath: agentsURL.path) { + return .safe + } + do { + let entries = try self.workspaceEntries(workspaceURL: workspaceURL) + return entries.isEmpty + ? .safe + : .blocked("Folder isn't empty. Choose a new folder or add AGENTS.md first.") + } catch { + return .blocked("Couldn't inspect the workspace folder.") + } + } + + static func bootstrap(workspaceURL: URL) throws -> URL { + let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL) + try FileManager().createDirectory(at: workspaceURL, withIntermediateDirectories: true) + let agentsURL = self.agentsURL(workspaceURL: workspaceURL) + if !FileManager().fileExists(atPath: agentsURL.path) { + try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8) + self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)") + } + let soulURL = workspaceURL.appendingPathComponent(self.soulFilename) + if !FileManager().fileExists(atPath: soulURL.path) { + try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8) + self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)") + } + let identityURL = workspaceURL.appendingPathComponent(self.identityFilename) + if !FileManager().fileExists(atPath: identityURL.path) { + try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8) + self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)") + } + let userURL = workspaceURL.appendingPathComponent(self.userFilename) + if !FileManager().fileExists(atPath: userURL.path) { + try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8) + self.logger.info("Created USER.md at \(userURL.path, privacy: .public)") + } + let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) + if shouldSeedBootstrap, !FileManager().fileExists(atPath: bootstrapURL.path) { + try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8) + self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)") + } + return agentsURL + } + + static func needsBootstrap(workspaceURL: URL) -> Bool { + let fm = FileManager() + var isDir: ObjCBool = false + if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { + return true + } + guard isDir.boolValue else { return true } + if self.hasIdentity(workspaceURL: workspaceURL) { + return false + } + let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) + guard fm.fileExists(atPath: bootstrapURL.path) else { return false } + return self.isTemplateOnlyWorkspace(workspaceURL: workspaceURL) + } + + static func hasIdentity(workspaceURL: URL) -> Bool { + let identityURL = workspaceURL.appendingPathComponent(self.identityFilename) + guard let contents = try? String(contentsOf: identityURL, encoding: .utf8) else { return false } + return self.identityLinesHaveValues(contents) + } + + private static func identityLinesHaveValues(_ content: String) -> Bool { + for line in content.split(separator: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else { continue } + let value = trimmed[trimmed.index(after: colon)...].trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + return true + } + } + return false + } + + static func defaultTemplate() -> String { + let fallback = """ + # AGENTS.md - OpenClaw Workspace + + This folder is the assistant's working directory. + + ## First run (one-time) + - If BOOTSTRAP.md exists, follow its ritual and delete it once complete. + - Your agent identity lives in IDENTITY.md. + - Your profile lives in USER.md. + + ## Backup tip (recommended) + If you treat this workspace as the agent's "memory", make it a git repo (ideally private) so identity + and notes are backed up. + + ```bash + git init + git add AGENTS.md + git commit -m "Add agent workspace" + ``` + + ## Safety defaults + - Don't exfiltrate secrets or private data. + - Don't run destructive commands unless explicitly asked. + - Be concise in chat; write longer output to files in this workspace. + + ## Daily memory (recommended) + - Keep a short daily log at memory/YYYY-MM-DD.md (create memory/ if needed). + - On session start, read today + yesterday if present. + - Capture durable facts, preferences, and decisions; avoid secrets. + + ## Customize + - Add your preferred style, rules, and "memory" here. + """ + return self.loadTemplate(named: self.agentsFilename, fallback: fallback) + } + + static func defaultSoulTemplate() -> String { + let fallback = """ + # SOUL.md - Persona & Boundaries + + Describe who the assistant is, tone, and boundaries. + + - Keep replies concise and direct. + - Ask clarifying questions when needed. + - Never send streaming/partial replies to external messaging surfaces. + """ + return self.loadTemplate(named: self.soulFilename, fallback: fallback) + } + + static func defaultIdentityTemplate() -> String { + let fallback = """ + # IDENTITY.md - Agent Identity + + - Name: + - Creature: + - Vibe: + - Emoji: + """ + return self.loadTemplate(named: self.identityFilename, fallback: fallback) + } + + static func defaultUserTemplate() -> String { + let fallback = """ + # USER.md - User Profile + + - Name: + - Preferred address: + - Pronouns (optional): + - Timezone (optional): + - Notes: + """ + return self.loadTemplate(named: self.userFilename, fallback: fallback) + } + + static func defaultBootstrapTemplate() -> String { + let fallback = """ + # BOOTSTRAP.md - First Run Ritual (delete after) + + Hello. I was just born. + + ## Your mission + Start a short, playful conversation and learn: + - Who am I? + - What am I? + - Who are you? + - How should I call you? + + ## How to ask (cute + helpful) + Say: + "Hello! I was just born. Who am I? What am I? Who are you? How should I call you?" + + Then offer suggestions: + - 3-5 name ideas. + - 3-5 creature/vibe combos. + - 5 emoji ideas. + + ## Write these files + After the user chooses, update: + + 1) IDENTITY.md + - Name + - Creature + - Vibe + - Emoji + + 2) USER.md + - Name + - Preferred address + - Pronouns (optional) + - Timezone (optional) + - Notes + + 3) ~/.openclaw/openclaw.json + Set identity.name, identity.theme, identity.emoji to match IDENTITY.md. + + ## Cleanup + Delete BOOTSTRAP.md once this is complete. + """ + return self.loadTemplate(named: self.bootstrapFilename, fallback: fallback) + } + + private static func loadTemplate(named: String, fallback: String) -> String { + for url in self.templateURLs(named: named) { + if let content = try? String(contentsOf: url, encoding: .utf8) { + let stripped = self.stripFrontMatter(content) + if !stripped.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return stripped + } + } + } + return fallback + } + + private static func templateURLs(named: String) -> [URL] { + var urls: [URL] = [] + if let resource = Bundle.main.url( + forResource: named.replacingOccurrences(of: ".md", with: ""), + withExtension: "md", + subdirectory: self.templateDirname) + { + urls.append(resource) + } + if let resource = Bundle.main.url( + forResource: named, + withExtension: nil, + subdirectory: self.templateDirname) + { + urls.append(resource) + } + if let dev = self.devTemplateURL(named: named) { + urls.append(dev) + } + let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath) + urls.append(cwd.appendingPathComponent("docs") + .appendingPathComponent(self.templateDirname) + .appendingPathComponent(named)) + return urls + } + + private static func devTemplateURL(named: String) -> URL? { + let sourceURL = URL(fileURLWithPath: #filePath) + let repoRoot = sourceURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + return repoRoot.appendingPathComponent("docs") + .appendingPathComponent(self.templateDirname) + .appendingPathComponent(named) + } + + private static func stripFrontMatter(_ content: String) -> String { + guard content.hasPrefix("---") else { return content } + let start = content.index(content.startIndex, offsetBy: 3) + guard let range = content.range(of: "\n---", range: start.. = { + if ProcessInfo.processInfo.isRunningTests { + return Empty(completeImmediately: false).eraseToAnyPublisher() + } + return Timer.publish(every: 0.4, on: .main, in: .common) + .autoconnect() + .eraseToAnyPublisher() + }() + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if self.connectionMode != .local { + Text("Gateway isn’t running locally; OAuth must be created on the gateway host.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 10) { + Circle() + .fill(self.oauthStatus.isConnected ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(self.oauthStatus.shortDescription) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + Button("Reveal") { + NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()]) + } + .buttonStyle(.bordered) + .disabled(!FileManager().fileExists(atPath: OpenClawOAuthStore.oauthURL().path)) + + Button("Refresh") { + self.refresh() + } + .buttonStyle(.bordered) + } + + Text(OpenClawOAuthStore.oauthURL().path) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + + HStack(spacing: 12) { + Button { + self.startOAuth() + } label: { + if self.busy { + ProgressView().controlSize(.small) + } else { + Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.connectionMode != .local || self.busy) + + if self.pkce != nil { + Button("Cancel") { + self.pkce = nil + self.code = "" + self.statusText = nil + } + .buttonStyle(.bordered) + .disabled(self.busy) + } + } + + if self.pkce != nil { + VStack(alignment: .leading, spacing: 8) { + Text("Paste `code#state`") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + + TextField("code#state", text: self.$code) + .textFieldStyle(.roundedBorder) + .disabled(self.busy) + + Toggle("Auto-detect from clipboard", isOn: self.$autoDetectClipboard) + .font(.footnote) + .foregroundStyle(.secondary) + .disabled(self.busy) + + Toggle("Auto-connect when detected", isOn: self.$autoConnectClipboard) + .font(.footnote) + .foregroundStyle(.secondary) + .disabled(self.busy) + + Button("Connect") { + Task { await self.finishOAuth() } + } + .buttonStyle(.bordered) + .disabled(self.busy || self.connectionMode != .local || self.code + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty) + } + } + + if let statusText, !statusText.isEmpty { + Text(statusText) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .onAppear { + self.refresh() + } + .onReceive(Self.clipboardPoll) { _ in + self.pollClipboardIfNeeded() + } + } + + private func refresh() { + let imported = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded() + self.oauthStatus = OpenClawOAuthStore.anthropicOAuthStatus() + if imported != nil { + self.statusText = "Imported existing OAuth credentials." + } + } + + private func startOAuth() { + guard self.connectionMode == .local else { return } + guard !self.busy else { return } + self.busy = true + defer { self.busy = false } + + do { + let pkce = try AnthropicOAuth.generatePKCE() + self.pkce = pkce + let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce) + NSWorkspace.shared.open(url) + self.statusText = "Browser opened. After approving, paste the `code#state` value here." + } catch { + self.statusText = "Failed to start OAuth: \(error.localizedDescription)" + } + } + + @MainActor + private func finishOAuth() async { + guard self.connectionMode == .local else { return } + guard !self.busy else { return } + guard let pkce = self.pkce else { return } + self.busy = true + defer { self.busy = false } + + guard let parsed = AnthropicOAuthCodeState.parse(from: self.code) else { + self.statusText = "OAuth failed: missing or invalid code/state." + return + } + + do { + let creds = try await AnthropicOAuth.exchangeCode( + code: parsed.code, + state: parsed.state, + verifier: pkce.verifier) + try OpenClawOAuthStore.saveAnthropicOAuth(creds) + self.refresh() + self.pkce = nil + self.code = "" + self.statusText = "Connected. OpenClaw can now use Claude via OAuth." + } catch { + self.statusText = "OAuth failed: \(error.localizedDescription)" + } + } + + private func pollClipboardIfNeeded() { + guard self.connectionMode == .local else { return } + guard self.pkce != nil else { return } + guard !self.busy else { return } + guard self.autoDetectClipboard else { return } + + let pb = NSPasteboard.general + let changeCount = pb.changeCount + guard changeCount != self.lastPasteboardChangeCount else { return } + self.lastPasteboardChangeCount = changeCount + + guard let raw = pb.string(forType: .string), !raw.isEmpty else { return } + guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return } + guard let pkce = self.pkce, parsed.state == pkce.verifier else { return } + + let next = "\(parsed.code)#\(parsed.state)" + if self.code != next { + self.code = next + self.statusText = "Detected `code#state` from clipboard." + } + + guard self.autoConnectClipboard else { return } + Task { await self.finishOAuth() } + } +} + +#if DEBUG +extension AnthropicAuthControls { + init( + connectionMode: AppState.ConnectionMode, + oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus, + pkce: AnthropicOAuth.PKCE? = nil, + code: String = "", + busy: Bool = false, + statusText: String? = nil, + autoDetectClipboard: Bool = true, + autoConnectClipboard: Bool = true) + { + self.connectionMode = connectionMode + self._oauthStatus = State(initialValue: oauthStatus) + self._pkce = State(initialValue: pkce) + self._code = State(initialValue: code) + self._busy = State(initialValue: busy) + self._statusText = State(initialValue: statusText) + self._autoDetectClipboard = State(initialValue: autoDetectClipboard) + self._autoConnectClipboard = State(initialValue: autoConnectClipboard) + self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift new file mode 100644 index 00000000..f594cc04 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AnthropicOAuth.swift @@ -0,0 +1,383 @@ +import CryptoKit +import Foundation +import OSLog +import Security + +struct AnthropicOAuthCredentials: Codable { + let type: String + let refresh: String + let access: String + let expires: Int64 +} + +enum AnthropicAuthMode: Equatable { + case oauthFile + case oauthEnv + case apiKeyEnv + case missing + + var shortLabel: String { + switch self { + case .oauthFile: "OAuth (OpenClaw token file)" + case .oauthEnv: "OAuth (env var)" + case .apiKeyEnv: "API key (env var)" + case .missing: "Missing credentials" + } + } + + var isConfigured: Bool { + switch self { + case .missing: false + case .oauthFile, .oauthEnv, .apiKeyEnv: true + } + } +} + +enum AnthropicAuthResolver { + static func resolve( + environment: [String: String] = ProcessInfo.processInfo.environment, + oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore + .anthropicOAuthStatus()) -> AnthropicAuthMode + { + if oauthStatus.isConnected { return .oauthFile } + + if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty + { + return .oauthEnv + } + + if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !key.isEmpty + { + return .apiKeyEnv + } + + return .missing + } +} + +enum AnthropicOAuth { + private static let logger = Logger(subsystem: "ai.openclaw", category: "anthropic-oauth") + + private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")! + private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")! + private static let redirectURI = "https://console.anthropic.com/oauth/code/callback" + private static let scopes = "org:create_api_key user:profile user:inference" + + struct PKCE { + let verifier: String + let challenge: String + } + + static func generatePKCE() throws -> PKCE { + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + let verifier = Data(bytes).base64URLEncodedString() + let hash = SHA256.hash(data: Data(verifier.utf8)) + let challenge = Data(hash).base64URLEncodedString() + return PKCE(verifier: verifier, challenge: challenge) + } + + static func buildAuthorizeURL(pkce: PKCE) -> URL { + var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "code", value: "true"), + URLQueryItem(name: "client_id", value: self.clientId), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "redirect_uri", value: self.redirectURI), + URLQueryItem(name: "scope", value: self.scopes), + URLQueryItem(name: "code_challenge", value: pkce.challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + // Match legacy flow: state is the verifier. + URLQueryItem(name: "state", value: pkce.verifier), + ] + return components.url! + } + + static func exchangeCode( + code: String, + state: String, + verifier: String) async throws -> AnthropicOAuthCredentials + { + let payload: [String: Any] = [ + "grant_type": "authorization_code", + "client_id": self.clientId, + "code": code, + "state": state, + "redirect_uri": self.redirectURI, + "code_verifier": verifier, + ] + let body = try JSONSerialization.data(withJSONObject: payload, options: []) + + var request = URLRequest(url: self.tokenURL) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + guard (200..<300).contains(http.statusCode) else { + let text = String(data: data, encoding: .utf8) ?? "" + throw NSError( + domain: "AnthropicOAuth", + code: http.statusCode, + userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"]) + } + + let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let access = decoded?["access_token"] as? String + let refresh = decoded?["refresh_token"] as? String + let expiresIn = decoded?["expires_in"] as? Double + guard let access, let refresh, let expiresIn else { + throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Unexpected token response.", + ]) + } + + // Match legacy flow: expiresAt = now + expires_in - 5 minutes. + let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000) + + Int64(expiresIn * 1000) + - Int64(5 * 60 * 1000) + + self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)") + return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs) + } + + static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials { + let payload: [String: Any] = [ + "grant_type": "refresh_token", + "client_id": self.clientId, + "refresh_token": refreshToken, + ] + let body = try JSONSerialization.data(withJSONObject: payload, options: []) + + var request = URLRequest(url: self.tokenURL) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + guard (200..<300).contains(http.statusCode) else { + let text = String(data: data, encoding: .utf8) ?? "" + throw NSError( + domain: "AnthropicOAuth", + code: http.statusCode, + userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"]) + } + + let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let access = decoded?["access_token"] as? String + let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken + let expiresIn = decoded?["expires_in"] as? Double + guard let access, let expiresIn else { + throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Unexpected token response.", + ]) + } + + let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000) + + Int64(expiresIn * 1000) + - Int64(5 * 60 * 1000) + + self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)") + return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs) + } +} + +enum OpenClawOAuthStore { + static let oauthFilename = "oauth.json" + private static let providerKey = "anthropic" + private static let openclawOAuthDirEnv = "OPENCLAW_OAUTH_DIR" + private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR" + + enum AnthropicOAuthStatus: Equatable { + case missingFile + case unreadableFile + case invalidJSON + case missingProviderEntry + case missingTokens + case connected(expiresAtMs: Int64?) + + var isConnected: Bool { + if case .connected = self { return true } + return false + } + + var shortDescription: String { + switch self { + case .missingFile: "OpenClaw OAuth token file not found" + case .unreadableFile: "OpenClaw OAuth token file not readable" + case .invalidJSON: "OpenClaw OAuth token file invalid" + case .missingProviderEntry: "No Anthropic entry in OpenClaw OAuth token file" + case .missingTokens: "Anthropic entry missing tokens" + case .connected: "OpenClaw OAuth credentials found" + } + } + } + + static func oauthDir() -> URL { + if let override = ProcessInfo.processInfo.environment[self.openclawOAuthDirEnv]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + let expanded = NSString(string: override).expandingTildeInPath + return URL(fileURLWithPath: expanded, isDirectory: true) + } + let home = FileManager().homeDirectoryForCurrentUser + return home.appendingPathComponent(".openclaw", isDirectory: true) + .appendingPathComponent("credentials", isDirectory: true) + } + + static func oauthURL() -> URL { + self.oauthDir().appendingPathComponent(self.oauthFilename) + } + + static func legacyOAuthURLs() -> [URL] { + var urls: [URL] = [] + let env = ProcessInfo.processInfo.environment + if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + let expanded = NSString(string: override).expandingTildeInPath + urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename)) + } + + let home = FileManager().homeDirectoryForCurrentUser + urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)")) + urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)")) + urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)")) + urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)")) + + var seen = Set() + return urls.filter { url in + let path = url.standardizedFileURL.path + if seen.contains(path) { return false } + seen.insert(path) + return true + } + } + + static func importLegacyAnthropicOAuthIfNeeded() -> URL? { + let dest = self.oauthURL() + guard !FileManager().fileExists(atPath: dest.path) else { return nil } + + for url in self.legacyOAuthURLs() { + guard FileManager().fileExists(atPath: url.path) else { continue } + guard self.anthropicOAuthStatus(at: url).isConnected else { continue } + guard let storage = self.loadStorage(at: url) else { continue } + do { + try self.saveStorage(storage) + return url + } catch { + continue + } + } + + return nil + } + + static func anthropicOAuthStatus() -> AnthropicOAuthStatus { + self.anthropicOAuthStatus(at: self.oauthURL()) + } + + static func hasAnthropicOAuth() -> Bool { + self.anthropicOAuthStatus().isConnected + } + + static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus { + guard FileManager().fileExists(atPath: url.path) else { return .missingFile } + + guard let data = try? Data(contentsOf: url) else { return .unreadableFile } + guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON } + guard let storage = json as? [String: Any] else { return .invalidJSON } + guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry } + guard let entry = rawEntry as? [String: Any] else { return .invalidJSON } + + let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"]) + let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"]) + guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens } + + let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"] + let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 { + ms + } else if let number = expiresAny as? NSNumber { + number.int64Value + } else if let ms = expiresAny as? Double { + Int64(ms) + } else { + nil + } + + return .connected(expiresAtMs: expiresAtMs) + } + + static func loadAnthropicOAuthRefreshToken() -> String? { + let url = self.oauthURL() + guard let storage = self.loadStorage(at: url) else { return nil } + guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil } + let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"]) + return refresh?.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func firstString(in dict: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = dict[key] as? String { return value } + } + return nil + } + + private static func loadStorage(at url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url) else { return nil } + guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } + return json as? [String: Any] + } + + static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws { + let url = self.oauthURL() + let existing: [String: Any] = self.loadStorage(at: url) ?? [:] + + var updated = existing + updated[self.providerKey] = [ + "type": creds.type, + "refresh": creds.refresh, + "access": creds.access, + "expires": creds.expires, + ] + + try self.saveStorage(updated) + } + + private static func saveStorage(_ storage: [String: Any]) throws { + let dir = self.oauthDir() + try FileManager().createDirectory( + at: dir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + + let url = self.oauthURL() + let data = try JSONSerialization.data( + withJSONObject: storage, + options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url, options: [.atomic]) + try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } +} + +extension Data { + fileprivate func base64URLEncodedString() -> String { + self.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift new file mode 100644 index 00000000..2a88898c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift @@ -0,0 +1,59 @@ +import Foundation + +enum AnthropicOAuthCodeState { + struct Parsed: Equatable { + let code: String + let state: String + } + + /// Extracts a `code#state` payload from arbitrary text. + /// + /// Supports: + /// - raw `code#state` + /// - OAuth callback URLs containing `code=` and `state=` query params + /// - surrounding text/backticks from instructions pages + static func extract(from raw: String) -> String? { + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "`")) + if text.isEmpty { return nil } + + if let fromURL = self.extractFromURL(text) { return fromURL } + if let fromToken = self.extractFromToken(text) { return fromToken } + return nil + } + + static func parse(from raw: String) -> Parsed? { + guard let extracted = self.extract(from: raw) else { return nil } + let parts = extracted.split(separator: "#", maxSplits: 1).map(String.init) + let code = parts.first ?? "" + let state = parts.count > 1 ? parts[1] : "" + guard !code.isEmpty, !state.isEmpty else { return nil } + return Parsed(code: code, state: state) + } + + private static func extractFromURL(_ text: String) -> String? { + // Users might copy the callback URL from the browser address bar. + guard let components = URLComponents(string: text), + let items = components.queryItems, + let code = items.first(where: { $0.name == "code" })?.value, + let state = items.first(where: { $0.name == "state" })?.value, + !code.isEmpty, !state.isEmpty + else { return nil } + + return "\(code)#\(state)" + } + + private static func extractFromToken(_ text: String) -> String? { + // Base64url-ish tokens; keep this fairly strict to avoid false positives. + let pattern = #"([A-Za-z0-9._~-]{8,})#([A-Za-z0-9._~-]{8,})"# + guard let re = try? NSRegularExpression(pattern: pattern) else { return nil } + + let range = NSRange(text.startIndex..? + + private func ifNotPreview(_ action: () -> Void) { + guard !self.isPreview else { return } + action() + } + + enum ConnectionMode: String { + case unconfigured + case local + case remote + } + + enum RemoteTransport: String { + case ssh + case direct + } + + var isPaused: Bool { + didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } } + } + + var launchAtLogin: Bool { + didSet { + guard !self.isInitializing else { return } + self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } + } + } + + var onboardingSeen: Bool { + didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: onboardingSeenKey) } + } + } + + var debugPaneEnabled: Bool { + didSet { + self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: debugPaneEnabledKey) } + CanvasManager.shared.refreshDebugStatus() + } + } + + var swabbleEnabled: Bool { + didSet { + self.ifNotPreview { + UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey) + Task { await VoiceWakeRuntime.shared.refresh(state: self) } + } + } + } + + var swabbleTriggerWords: [String] { + didSet { + // Preserve the raw editing state; sanitization happens when we actually use the triggers. + self.ifNotPreview { + UserDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey) + if self.swabbleEnabled { + Task { await VoiceWakeRuntime.shared.refresh(state: self) } + } + self.scheduleVoiceWakeGlobalSyncIfNeeded() + } + } + } + + var voiceWakeTriggerChime: VoiceWakeChime { + didSet { self.ifNotPreview { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } } + } + + var voiceWakeSendChime: VoiceWakeChime { + didSet { self.ifNotPreview { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } } + } + + var iconAnimationsEnabled: Bool { + didSet { self.ifNotPreview { UserDefaults.standard.set( + self.iconAnimationsEnabled, + forKey: iconAnimationsEnabledKey) } } + } + + var showDockIcon: Bool { + didSet { + self.ifNotPreview { + UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey) + AppActivationPolicy.apply(showDockIcon: self.showDockIcon) + } + } + } + + var voiceWakeMicID: String { + didSet { + self.ifNotPreview { + UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey) + if self.swabbleEnabled { + Task { await VoiceWakeRuntime.shared.refresh(state: self) } + } + } + } + } + + var voiceWakeMicName: String { + didSet { self.ifNotPreview { UserDefaults.standard.set(self.voiceWakeMicName, forKey: voiceWakeMicNameKey) } } + } + + var voiceWakeLocaleID: String { + didSet { + self.ifNotPreview { + UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey) + if self.swabbleEnabled { + Task { await VoiceWakeRuntime.shared.refresh(state: self) } + } + } + } + } + + var voiceWakeAdditionalLocaleIDs: [String] { + didSet { self.ifNotPreview { UserDefaults.standard.set( + self.voiceWakeAdditionalLocaleIDs, + forKey: voiceWakeAdditionalLocalesKey) } } + } + + var voicePushToTalkEnabled: Bool { + didSet { self.ifNotPreview { UserDefaults.standard.set( + self.voicePushToTalkEnabled, + forKey: voicePushToTalkEnabledKey) } } + } + + var talkEnabled: Bool { + didSet { + self.ifNotPreview { + UserDefaults.standard.set(self.talkEnabled, forKey: talkEnabledKey) + Task { await TalkModeController.shared.setEnabled(self.talkEnabled) } + } + } + } + + /// Gateway-provided UI accent color (hex). Optional; clients provide a default. + var seamColorHex: String? + + var iconOverride: IconOverrideSelection { + didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } } + } + + var isWorking: Bool = false + var earBoostActive: Bool = false + var blinkTick: Int = 0 + var sendCelebrationTick: Int = 0 + var heartbeatsEnabled: Bool { + didSet { + self.ifNotPreview { + UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey) + Task { _ = await GatewayConnection.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) } + } + } + } + + var connectionMode: ConnectionMode { + didSet { + self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) } + self.syncGatewayConfigIfNeeded() + } + } + + var remoteTransport: RemoteTransport { + didSet { self.syncGatewayConfigIfNeeded() } + } + + var canvasEnabled: Bool { + didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } + } + + var execApprovalMode: ExecApprovalQuickMode { + didSet { + self.ifNotPreview { + ExecApprovalsStore.updateDefaults { defaults in + defaults.security = self.execApprovalMode.security + defaults.ask = self.execApprovalMode.ask + } + } + } + } + + /// Tracks whether the Canvas panel is currently visible (not persisted). + var canvasPanelVisible: Bool = false + + var peekabooBridgeEnabled: Bool { + didSet { + self.ifNotPreview { + UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey) + Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(self.peekabooBridgeEnabled) } + } + } + } + + var remoteTarget: String { + didSet { + self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } + self.syncGatewayConfigIfNeeded() + } + } + + var remoteUrl: String { + didSet { self.syncGatewayConfigIfNeeded() } + } + + var remoteIdentity: String { + didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } } + } + + var remoteProjectRoot: String { + didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } } + } + + var remoteCliPath: String { + didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } } + } + + private var earBoostTask: Task? + + init(preview: Bool = false) { + let isPreview = preview || ProcessInfo.processInfo.isRunningTests + self.isPreview = isPreview + if !isPreview { + migrateLegacyDefaults() + } + let onboardingSeen = UserDefaults.standard.bool(forKey: onboardingSeenKey) + self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) + self.launchAtLogin = false + self.onboardingSeen = onboardingSeen + self.debugPaneEnabled = UserDefaults.standard.bool(forKey: debugPaneEnabledKey) + let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey) + self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false + self.swabbleTriggerWords = UserDefaults.standard + .stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers + self.voiceWakeTriggerChime = Self.loadChime( + key: voiceWakeTriggerChimeKey, + fallback: .system(name: "Glass")) + self.voiceWakeSendChime = Self.loadChime( + key: voiceWakeSendChimeKey, + fallback: .system(name: "Glass")) + if let storedIconAnimations = UserDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool { + self.iconAnimationsEnabled = storedIconAnimations + } else { + self.iconAnimationsEnabled = true + UserDefaults.standard.set(true, forKey: iconAnimationsEnabledKey) + } + self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey) + self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? "" + self.voiceWakeMicName = UserDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? "" + self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier + self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard + .stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? [] + self.voicePushToTalkEnabled = UserDefaults.standard + .object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false + self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey) + self.seamColorHex = nil + if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool { + self.heartbeatsEnabled = storedHeartbeats + } else { + self.heartbeatsEnabled = true + UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey) + } + if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey), + let selection = IconOverrideSelection(rawValue: storedOverride) + { + self.iconOverride = selection + } else { + self.iconOverride = .system + UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey) + } + + let configRoot = OpenClawConfigFile.loadDict() + let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot) + let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot) + let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode + self.remoteTransport = configRemoteTransport + self.connectionMode = resolvedConnectionMode + + let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" + if resolvedConnectionMode == .remote, + configRemoteTransport != .direct, + storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let host = AppState.remoteHost(from: configRemoteUrl) + { + self.remoteTarget = "\(NSUserName())@\(host)" + } else { + self.remoteTarget = storedRemoteTarget + } + self.remoteUrl = configRemoteUrl ?? "" + self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" + self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" + self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" + self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true + let execDefaults = ExecApprovalsStore.resolveDefaults() + self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask) + self.peekabooBridgeEnabled = UserDefaults.standard + .object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true + if !self.isPreview { + Task.detached(priority: .utility) { [weak self] in + let current = await LaunchAgentManager.status() + await MainActor.run { [weak self] in self?.launchAtLogin = current } + } + } + + if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() { + self.swabbleEnabled = false + } + if self.talkEnabled, !PermissionManager.voiceWakePermissionsGranted() { + self.talkEnabled = false + } + + if !self.isPreview { + Task { await VoiceWakeRuntime.shared.refresh(state: self) } + Task { await TalkModeController.shared.setEnabled(self.talkEnabled) } + } + + self.isInitializing = false + if !self.isPreview { + self.startConfigWatcher() + } + } + + @MainActor + deinit { + self.configWatcher?.stop() + } + + private static func remoteHost(from urlString: String?) -> String? { + guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty, + let url = URL(string: raw), + let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), + !host.isEmpty + else { + return nil + } + return host + } + + private static func sanitizeSSHTarget(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("ssh ") { + return trimmed.replacingOccurrences(of: "ssh ", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + return trimmed + } + + private static func updateGatewayString( + _ dictionary: inout [String: Any], + key: String, + value: String?) -> Bool + { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + guard dictionary[key] != nil else { return false } + dictionary.removeValue(forKey: key) + return true + } + if (dictionary[key] as? String) != trimmed { + dictionary[key] = trimmed + return true + } + return false + } + + private static func updatedRemoteGatewayConfig( + current: [String: Any], + transport: RemoteTransport, + remoteUrl: String, + remoteHost: String?, + remoteTarget: String, + remoteIdentity: String) -> (remote: [String: Any], changed: Bool) + { + var remote = current + var changed = false + + switch transport { + case .direct: + changed = Self.updateGatewayString( + &remote, + key: "transport", + value: RemoteTransport.direct.rawValue) || changed + + let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedUrl.isEmpty { + changed = Self.updateGatewayString(&remote, key: "url", value: nil) || changed + } else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) { + changed = Self.updateGatewayString(&remote, key: "url", value: normalizedUrl) || changed + } + + case .ssh: + changed = Self.updateGatewayString(&remote, key: "transport", value: nil) || changed + + if let host = remoteHost { + let existingUrl = (remote["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) + let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" + let port = parsedExisting?.port ?? 18789 + let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" + changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed + } + + let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) + changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed + changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed + } + + return (remote, changed) + } + + private func startConfigWatcher() { + let configUrl = OpenClawConfigFile.url() + self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in + Task { @MainActor in + self?.applyConfigFromDisk() + } + } + self.configWatcher?.start() + } + + private func applyConfigFromDisk() { + let root = OpenClawConfigFile.loadDict() + self.applyConfigOverrides(root) + } + + private func applyConfigOverrides(_ root: [String: Any]) { + let gateway = root["gateway"] as? [String: Any] + let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root) + let hasRemoteUrl = !(remoteUrl? + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty ?? true) + let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root) + + let desiredMode: ConnectionMode? = switch modeRaw { + case "local": + .local + case "remote": + .remote + case "unconfigured": + .unconfigured + default: + nil + } + + if let desiredMode { + if desiredMode != self.connectionMode { + self.connectionMode = desiredMode + } + } else if hasRemoteUrl, self.connectionMode != .remote { + self.connectionMode = .remote + } + + if remoteTransport != self.remoteTransport { + self.remoteTransport = remoteTransport + } + let remoteUrlText = remoteUrl ?? "" + if remoteUrlText != self.remoteUrl { + self.remoteUrl = remoteUrlText + } + + let targetMode = desiredMode ?? self.connectionMode + if targetMode == .remote, + remoteTransport != .direct, + let host = AppState.remoteHost(from: remoteUrl) + { + self.updateRemoteTarget(host: host) + } + } + + private func updateRemoteTarget(host: String) { + let trimmed = self.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines) + guard let parsed = CommandResolver.parseSSHTarget(trimmed) else { return } + let trimmedUser = parsed.user?.trimmingCharacters(in: .whitespacesAndNewlines) + let user = (trimmedUser?.isEmpty ?? true) ? nil : trimmedUser + let port = parsed.port + let assembled: String = if let user { + port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)" + } else { + port == 22 ? host : "\(host):\(port)" + } + if assembled != self.remoteTarget { + self.remoteTarget = assembled + } + } + + private func syncGatewayConfigIfNeeded() { + guard !self.isPreview, !self.isInitializing else { return } + + let connectionMode = self.connectionMode + let remoteTarget = self.remoteTarget + let remoteIdentity = self.remoteIdentity + let remoteTransport = self.remoteTransport + let remoteUrl = self.remoteUrl + let desiredMode: String? = switch connectionMode { + case .local: + "local" + case .remote: + "remote" + case .unconfigured: + nil + } + let remoteHost = connectionMode == .remote + ? CommandResolver.parseSSHTarget(remoteTarget)?.host + : nil + + Task { @MainActor in + // Keep app-only connection settings local to avoid overwriting remote gateway config. + var root = OpenClawConfigFile.loadDict() + var gateway = root["gateway"] as? [String: Any] ?? [:] + var changed = false + + let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let desiredMode { + if currentMode != desiredMode { + gateway["mode"] = desiredMode + changed = true + } + } else if currentMode != nil { + gateway.removeValue(forKey: "mode") + changed = true + } + + if connectionMode == .remote { + let currentRemote = gateway["remote"] as? [String: Any] ?? [:] + let updated = Self.updatedRemoteGatewayConfig( + current: currentRemote, + transport: remoteTransport, + remoteUrl: remoteUrl, + remoteHost: remoteHost, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity) + if updated.changed { + gateway["remote"] = updated.remote + changed = true + } + } + + guard changed else { return } + if gateway.isEmpty { + root.removeValue(forKey: "gateway") + } else { + root["gateway"] = gateway + } + OpenClawConfigFile.saveDict(root) + } + } + + func triggerVoiceEars(ttl: TimeInterval? = 5) { + self.earBoostTask?.cancel() + self.earBoostActive = true + + guard let ttl else { return } + + self.earBoostTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(ttl * 1_000_000_000)) + await MainActor.run { [weak self] in self?.earBoostActive = false } + } + } + + func stopVoiceEars() { + self.earBoostTask?.cancel() + self.earBoostTask = nil + self.earBoostActive = false + } + + func blinkOnce() { + self.blinkTick &+= 1 + } + + func celebrateSend() { + self.sendCelebrationTick &+= 1 + } + + func setVoiceWakeEnabled(_ enabled: Bool) async { + guard voiceWakeSupported else { + self.swabbleEnabled = false + return + } + + self.swabbleEnabled = enabled + guard !self.isPreview else { return } + + if !enabled { + Task { await VoiceWakeRuntime.shared.refresh(state: self) } + return + } + + if PermissionManager.voiceWakePermissionsGranted() { + Task { await VoiceWakeRuntime.shared.refresh(state: self) } + return + } + + let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true) + self.swabbleEnabled = granted + Task { await VoiceWakeRuntime.shared.refresh(state: self) } + } + + func setTalkEnabled(_ enabled: Bool) async { + guard voiceWakeSupported else { + self.talkEnabled = false + await GatewayConnection.shared.talkMode(enabled: false, phase: "disabled") + return + } + + self.talkEnabled = enabled + guard !self.isPreview else { return } + + if !enabled { + await GatewayConnection.shared.talkMode(enabled: false, phase: "disabled") + return + } + + if PermissionManager.voiceWakePermissionsGranted() { + await GatewayConnection.shared.talkMode(enabled: true, phase: "enabled") + return + } + + let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true) + self.talkEnabled = granted + await GatewayConnection.shared.talkMode(enabled: granted, phase: granted ? "enabled" : "denied") + } + + // MARK: - Global wake words sync (Gateway-owned) + + func applyGlobalVoiceWakeTriggers(_ triggers: [String]) { + self.suppressVoiceWakeGlobalSync = true + self.swabbleTriggerWords = triggers + self.suppressVoiceWakeGlobalSync = false + } + + private func scheduleVoiceWakeGlobalSyncIfNeeded() { + guard !self.suppressVoiceWakeGlobalSync else { return } + let sanitized = sanitizeVoiceWakeTriggers(self.swabbleTriggerWords) + self.voiceWakeGlobalSyncTask?.cancel() + self.voiceWakeGlobalSyncTask = Task { [sanitized] in + try? await Task.sleep(nanoseconds: 650_000_000) + await GatewayConnection.shared.voiceWakeSetTriggers(sanitized) + } + } + + func setWorking(_ working: Bool) { + self.isWorking = working + } + + // MARK: - Chime persistence + + private static func loadChime(key: String, fallback: VoiceWakeChime) -> VoiceWakeChime { + guard let data = UserDefaults.standard.data(forKey: key) else { return fallback } + if let decoded = try? JSONDecoder().decode(VoiceWakeChime.self, from: data) { + return decoded + } + return fallback + } + + private func storeChime(_ chime: VoiceWakeChime, key: String) { + guard let data = try? JSONEncoder().encode(chime) else { return } + UserDefaults.standard.set(data, forKey: key) + } +} + +extension AppState { + static var preview: AppState { + let state = AppState(preview: true) + state.isPaused = false + state.launchAtLogin = true + state.onboardingSeen = true + state.debugPaneEnabled = true + state.swabbleEnabled = true + state.swabbleTriggerWords = ["Claude", "Computer", "Jarvis"] + state.voiceWakeTriggerChime = .system(name: "Glass") + state.voiceWakeSendChime = .system(name: "Ping") + state.iconAnimationsEnabled = true + state.showDockIcon = true + state.voiceWakeMicID = "BuiltInMic" + state.voiceWakeMicName = "Built-in Microphone" + state.voiceWakeLocaleID = Locale.current.identifier + state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"] + state.voicePushToTalkEnabled = false + state.talkEnabled = false + state.iconOverride = .system + state.heartbeatsEnabled = true + state.connectionMode = .local + state.remoteTransport = .ssh + state.canvasEnabled = true + state.remoteTarget = "user@example.com" + state.remoteUrl = "wss://gateway.example.ts.net" + state.remoteIdentity = "~/.ssh/id_ed25519" + state.remoteProjectRoot = "~/Projects/openclaw" + state.remoteCliPath = "" + return state + } +} + +@MainActor +enum AppStateStore { + static let shared = AppState() + static var isPausedFlag: Bool { + UserDefaults.standard.bool(forKey: pauseDefaultsKey) + } + + static func updateLaunchAtLogin(enabled: Bool) { + Task.detached(priority: .utility) { + await LaunchAgentManager.set(enabled: enabled, bundlePath: Bundle.main.bundlePath) + } + } + + static var canvasEnabled: Bool { + UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true + } +} + +@MainActor +enum AppActivationPolicy { + static func apply(showDockIcon: Bool) { + _ = showDockIcon + DockIconManager.shared.updateDockVisibility() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift new file mode 100644 index 00000000..6c016281 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift @@ -0,0 +1,225 @@ +import CoreAudio +import Foundation +import OSLog + +final class AudioInputDeviceObserver { + private let logger = Logger(subsystem: "ai.openclaw", category: "audio.devices") + private var isActive = false + private var devicesListener: AudioObjectPropertyListenerBlock? + private var defaultInputListener: AudioObjectPropertyListenerBlock? + + static func defaultInputDeviceUID() -> String? { + let systemObject = AudioObjectID(kAudioObjectSystemObject) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var deviceID = AudioObjectID(0) + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData( + systemObject, + &address, + 0, + nil, + &size, + &deviceID) + guard status == noErr, deviceID != 0 else { return nil } + return self.deviceUID(for: deviceID) + } + + static func aliveInputDeviceUIDs() -> Set { + let systemObject = AudioObjectID(kAudioObjectSystemObject) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + var status = AudioObjectGetPropertyDataSize(systemObject, &address, 0, nil, &size) + guard status == noErr, size > 0 else { return [] } + + let count = Int(size) / MemoryLayout.size + var deviceIDs = [AudioObjectID](repeating: 0, count: count) + status = AudioObjectGetPropertyData(systemObject, &address, 0, nil, &size, &deviceIDs) + guard status == noErr else { return [] } + + var output = Set() + for deviceID in deviceIDs { + guard self.deviceIsAlive(deviceID) else { continue } + guard self.deviceHasInput(deviceID) else { continue } + if let uid = self.deviceUID(for: deviceID) { + output.insert(uid) + } + } + return output + } + + /// Returns true when the system default input device exists and is alive with input channels. + /// Use this preflight before accessing `AVAudioEngine.inputNode` to avoid SIGABRT on Macs + /// without a built-in microphone (Mac mini, Mac Pro, Mac Studio) or when an external mic + /// is disconnected. + static func hasUsableDefaultInputDevice() -> Bool { + guard let uid = self.defaultInputDeviceUID() else { return false } + return self.aliveInputDeviceUIDs().contains(uid) + } + + static func defaultInputDeviceSummary() -> String { + let systemObject = AudioObjectID(kAudioObjectSystemObject) + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var deviceID = AudioObjectID(0) + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData( + systemObject, + &address, + 0, + nil, + &size, + &deviceID) + guard status == noErr, deviceID != 0 else { + return "defaultInput=unknown" + } + let uid = self.deviceUID(for: deviceID) ?? "unknown" + let name = self.deviceName(for: deviceID) ?? "unknown" + return "defaultInput=\(name) (\(uid))" + } + + func start(onChange: @escaping @Sendable () -> Void) { + guard !self.isActive else { return } + self.isActive = true + + let systemObject = AudioObjectID(kAudioObjectSystemObject) + let queue = DispatchQueue.main + + var devicesAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + let devicesListener: AudioObjectPropertyListenerBlock = { _, _ in + self.logDefaultInputChange(reason: "devices") + onChange() + } + let devicesStatus = AudioObjectAddPropertyListenerBlock( + systemObject, + &devicesAddress, + queue, + devicesListener) + + var defaultInputAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + let defaultInputListener: AudioObjectPropertyListenerBlock = { _, _ in + self.logDefaultInputChange(reason: "default") + onChange() + } + let defaultStatus = AudioObjectAddPropertyListenerBlock( + systemObject, + &defaultInputAddress, + queue, + defaultInputListener) + + if devicesStatus != noErr || defaultStatus != noErr { + self.logger.error("audio device observer install failed devices=\(devicesStatus) default=\(defaultStatus)") + } + + self.logger.info("audio device observer started (\(Self.defaultInputDeviceSummary(), privacy: .public))") + + self.devicesListener = devicesListener + self.defaultInputListener = defaultInputListener + } + + func stop() { + guard self.isActive else { return } + self.isActive = false + let systemObject = AudioObjectID(kAudioObjectSystemObject) + + if let devicesListener { + var devicesAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + _ = AudioObjectRemovePropertyListenerBlock( + systemObject, + &devicesAddress, + DispatchQueue.main, + devicesListener) + } + + if let defaultInputListener { + var defaultInputAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + _ = AudioObjectRemovePropertyListenerBlock( + systemObject, + &defaultInputAddress, + DispatchQueue.main, + defaultInputListener) + } + + self.devicesListener = nil + self.defaultInputListener = nil + } + + private static func deviceUID(for deviceID: AudioObjectID) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceUID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var uid: Unmanaged? + var size = UInt32(MemoryLayout?>.size) + let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &uid) + guard status == noErr, let uid else { return nil } + return uid.takeUnretainedValue() as String + } + + private static func deviceName(for deviceID: AudioObjectID) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioObjectPropertyName, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var name: Unmanaged? + var size = UInt32(MemoryLayout?>.size) + let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &name) + guard status == noErr, let name else { return nil } + return name.takeUnretainedValue() as String + } + + private static func deviceIsAlive(_ deviceID: AudioObjectID) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceIsAlive, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var alive: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &alive) + return status == noErr && alive != 0 + } + + private static func deviceHasInput(_ deviceID: AudioObjectID) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + var status = AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size) + guard status == noErr, size > 0 else { return false } + + let raw = UnsafeMutableRawPointer.allocate( + byteCount: Int(size), + alignment: MemoryLayout.alignment) + defer { raw.deallocate() } + let bufferList = raw.bindMemory(to: AudioBufferList.self, capacity: 1) + status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, bufferList) + guard status == noErr else { return false } + + let buffers = UnsafeMutableAudioBufferListPointer(bufferList) + return buffers.contains(where: { $0.mNumberChannels > 0 }) + } + + private func logDefaultInputChange(reason: StaticString) { + self.logger.info("audio input changed (\(reason)) (\(Self.defaultInputDeviceSummary(), privacy: .public))") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift new file mode 100644 index 00000000..482f36fd --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift @@ -0,0 +1,84 @@ +import AppKit +import Foundation +import OSLog + +@MainActor +final class CLIInstallPrompter { + static let shared = CLIInstallPrompter() + private let logger = Logger(subsystem: "ai.openclaw", category: "cli.prompt") + private var isPrompting = false + + func checkAndPromptIfNeeded(reason: String) { + guard self.shouldPrompt() else { return } + guard let version = Self.appVersion() else { return } + self.isPrompting = true + UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey) + + let alert = NSAlert() + alert.messageText = "Install OpenClaw CLI?" + alert.informativeText = "Local mode needs the CLI so launchd can run the gateway." + alert.addButton(withTitle: "Install CLI") + alert.addButton(withTitle: "Not now") + alert.addButton(withTitle: "Open Settings") + let response = alert.runModal() + + switch response { + case .alertFirstButtonReturn: + Task { await self.installCLI() } + case .alertThirdButtonReturn: + self.openSettings(tab: .general) + default: + break + } + + self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)") + self.isPrompting = false + } + + private func shouldPrompt() -> Bool { + guard !self.isPrompting else { return false } + guard AppStateStore.shared.onboardingSeen else { return false } + guard AppStateStore.shared.connectionMode == .local else { return false } + guard CLIInstaller.installedLocation() == nil else { return false } + guard let version = Self.appVersion() else { return false } + let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey) + return lastPrompt != version + } + + private func installCLI() async { + let status = StatusBox() + await CLIInstaller.install { message in + await status.set(message) + } + if let message = await status.get() { + let alert = NSAlert() + alert.messageText = "CLI install finished" + alert.informativeText = message + alert.runModal() + } + } + + private func openSettings(tab: SettingsTab) { + SettingsTabRouter.request(tab) + SettingsWindowOpener.shared.open() + DispatchQueue.main.async { + NotificationCenter.default.post(name: .openclawSelectSettingsTab, object: tab) + } + } + + private static func appVersion() -> String? { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + } +} + +private actor StatusBox { + private var value: String? + + func set(_ value: String) { + self.value = value + } + + func get() -> String? { + self.value + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CLIInstaller.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CLIInstaller.swift new file mode 100644 index 00000000..ce6d2520 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CLIInstaller.swift @@ -0,0 +1,103 @@ +import Foundation + +@MainActor +enum CLIInstaller { + static func installedLocation() -> String? { + self.installedLocation( + searchPaths: CommandResolver.preferredPaths(), + fileManager: .default) + } + + static func installedLocation( + searchPaths: [String], + fileManager: FileManager) -> String? + { + for basePath in searchPaths { + let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("openclaw").path + var isDirectory: ObjCBool = false + + guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory), + !isDirectory.boolValue + else { + continue + } + + guard fileManager.isExecutableFile(atPath: candidate) else { continue } + + return candidate + } + + return nil + } + + static func isInstalled() -> Bool { + self.installedLocation() != nil + } + + static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async { + let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest" + let prefix = Self.installPrefix() + await statusHandler("Installing openclaw CLI…") + let cmd = self.installScriptCommand(version: expected, prefix: prefix) + let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: nil, timeout: 900) + + if response.success { + let parsed = self.parseInstallEvents(response.stdout) + let installedVersion = parsed.last { $0.event == "done" }?.version + let summary = installedVersion.map { "Installed openclaw \($0)." } ?? "Installed openclaw." + await statusHandler(summary) + return + } + + let parsed = self.parseInstallEvents(response.stdout) + if let error = parsed.last(where: { $0.event == "error" })?.message { + await statusHandler("Install failed: \(error)") + return + } + + let detail = response.stderr.trimmingCharacters(in: .whitespacesAndNewlines) + let fallback = response.errorMessage ?? "install failed" + await statusHandler("Install failed: \(detail.isEmpty ? fallback : detail)") + } + + private static func installPrefix() -> String { + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent(".openclaw") + .path + } + + private static func installScriptCommand(version: String, prefix: String) -> [String] { + let escapedVersion = self.shellEscape(version) + let escapedPrefix = self.shellEscape(prefix) + let script = """ + curl -fsSL https://openclaw.bot/install-cli.sh | \ + bash -s -- --json --no-onboard --prefix \(escapedPrefix) --version \(escapedVersion) + """ + return ["/bin/bash", "-lc", script] + } + + private static func parseInstallEvents(_ output: String) -> [InstallEvent] { + let decoder = JSONDecoder() + let lines = output + .split(whereSeparator: \.isNewline) + .map { String($0) } + var events: [InstallEvent] = [] + for line in lines { + guard let data = line.data(using: .utf8) else { continue } + if let event = try? decoder.decode(InstallEvent.self, from: data) { + events.append(event) + } + } + return events + } + + private static func shellEscape(_ raw: String) -> String { + "'" + raw.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} + +private struct InstallEvent: Decodable { + let event: String + let version: String? + let message: String? +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CameraCaptureService.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CameraCaptureService.swift new file mode 100644 index 00000000..4e3749d6 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CameraCaptureService.swift @@ -0,0 +1,427 @@ +import AVFoundation +import CoreGraphics +import Foundation +import OpenClawIPC +import OpenClawKit +import OSLog + +actor CameraCaptureService { + struct CameraDeviceInfo: Encodable, Sendable { + let id: String + let name: String + let position: String + let deviceType: String + } + + enum CameraError: LocalizedError, Sendable { + case cameraUnavailable + case microphoneUnavailable + case permissionDenied(kind: String) + case captureFailed(String) + case exportFailed(String) + + var errorDescription: String? { + switch self { + case .cameraUnavailable: + "Camera unavailable" + case .microphoneUnavailable: + "Microphone unavailable" + case let .permissionDenied(kind): + "\(kind) permission denied" + case let .captureFailed(msg): + msg + case let .exportFailed(msg): + msg + } + } + } + + private let logger = Logger(subsystem: "ai.openclaw", category: "camera") + + func listDevices() -> [CameraDeviceInfo] { + Self.availableCameras().map { device in + CameraDeviceInfo( + id: device.uniqueID, + name: device.localizedName, + position: Self.positionLabel(device.position), + deviceType: device.deviceType.rawValue) + } + } + + func snap( + facing: CameraFacing?, + maxWidth: Int?, + quality: Double?, + deviceId: String?, + delayMs: Int) async throws -> (data: Data, size: CGSize) + { + let facing = facing ?? .front + let normalized = Self.normalizeSnap(maxWidth: maxWidth, quality: quality) + let maxWidth = normalized.maxWidth + let quality = normalized.quality + let delayMs = max(0, delayMs) + let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) + + try await self.ensureAccess(for: .video) + + let session = AVCaptureSession() + session.sessionPreset = .photo + + guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else { + throw CameraError.cameraUnavailable + } + + let input = try AVCaptureDeviceInput(device: device) + guard session.canAddInput(input) else { + throw CameraError.captureFailed("Failed to add camera input") + } + session.addInput(input) + + let output = AVCapturePhotoOutput() + guard session.canAddOutput(output) else { + throw CameraError.captureFailed("Failed to add photo output") + } + session.addOutput(output) + output.maxPhotoQualityPrioritization = .quality + + session.startRunning() + defer { session.stopRunning() } + await Self.warmUpCaptureSession() + await self.waitForExposureAndWhiteBalance(device: device) + await self.sleepDelayMs(delayMs) + + let settings: AVCapturePhotoSettings = { + if output.availablePhotoCodecTypes.contains(.jpeg) { + return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg]) + } + return AVCapturePhotoSettings() + }() + settings.photoQualityPrioritization = .quality + + var delegate: PhotoCaptureDelegate? + let rawData: Data = try await withCheckedThrowingContinuation { cont in + let d = PhotoCaptureDelegate(cont) + delegate = d + output.capturePhoto(with: settings, delegate: d) + } + withExtendedLifetime(delegate) {} + + let res: (data: Data, widthPx: Int, heightPx: Int) + do { + res = try PhotoCapture.transcodeJPEGForGateway( + rawData: rawData, + maxWidthPx: maxWidth, + quality: quality) + } catch { + throw CameraError.captureFailed(error.localizedDescription) + } + + return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx)) + } + + func clip( + facing: CameraFacing?, + durationMs: Int?, + includeAudio: Bool, + deviceId: String?, + outPath: String?) async throws -> (path: String, durationMs: Int, hasAudio: Bool) + { + let facing = facing ?? .front + let durationMs = Self.clampDurationMs(durationMs) + let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) + + try await self.ensureAccess(for: .video) + if includeAudio { + try await self.ensureAccess(for: .audio) + } + + let session = AVCaptureSession() + session.sessionPreset = .high + + guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else { + throw CameraError.cameraUnavailable + } + let cameraInput = try AVCaptureDeviceInput(device: camera) + guard session.canAddInput(cameraInput) else { + throw CameraError.captureFailed("Failed to add camera input") + } + session.addInput(cameraInput) + + if includeAudio { + guard let mic = AVCaptureDevice.default(for: .audio) else { + throw CameraError.microphoneUnavailable + } + let micInput = try AVCaptureDeviceInput(device: mic) + guard session.canAddInput(micInput) else { + throw CameraError.captureFailed("Failed to add microphone input") + } + session.addInput(micInput) + } + + let output = AVCaptureMovieFileOutput() + guard session.canAddOutput(output) else { + throw CameraError.captureFailed("Failed to add movie output") + } + session.addOutput(output) + output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000) + + session.startRunning() + defer { session.stopRunning() } + await Self.warmUpCaptureSession() + + let tmpMovURL = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov") + defer { try? FileManager().removeItem(at: tmpMovURL) } + + let outputURL: URL = { + if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: outPath) + } + return FileManager().temporaryDirectory + .appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4") + }() + + // Ensure we don't fail exporting due to an existing file. + try? FileManager().removeItem(at: outputURL) + + let logger = self.logger + var delegate: MovieFileDelegate? + let recordedURL: URL = try await withCheckedThrowingContinuation { cont in + let d = MovieFileDelegate(cont, logger: logger) + delegate = d + output.startRecording(to: tmpMovURL, recordingDelegate: d) + } + withExtendedLifetime(delegate) {} + + try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL) + return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio) + } + + private func ensureAccess(for mediaType: AVMediaType) async throws { + let status = AVCaptureDevice.authorizationStatus(for: mediaType) + switch status { + case .authorized: + return + case .notDetermined: + let ok = await withCheckedContinuation(isolation: nil) { cont in + AVCaptureDevice.requestAccess(for: mediaType) { granted in + cont.resume(returning: granted) + } + } + if !ok { + throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") + } + case .denied, .restricted: + throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") + @unknown default: + throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone") + } + } + + private nonisolated static func availableCameras() -> [AVCaptureDevice] { + var types: [AVCaptureDevice.DeviceType] = [ + .builtInWideAngleCamera, + .continuityCamera, + ] + if let external = externalDeviceType() { + types.append(external) + } + let session = AVCaptureDevice.DiscoverySession( + deviceTypes: types, + mediaType: .video, + position: .unspecified) + return session.devices + } + + private nonisolated static func externalDeviceType() -> AVCaptureDevice.DeviceType? { + if #available(macOS 14.0, *) { + return .external + } + // Use raw value to avoid deprecated symbol in the SDK. + return AVCaptureDevice.DeviceType(rawValue: "AVCaptureDeviceTypeExternalUnknown") + } + + private nonisolated static func pickCamera( + facing: CameraFacing, + deviceId: String?) -> AVCaptureDevice? + { + if let deviceId, !deviceId.isEmpty { + if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) { + return match + } + } + let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back + + if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) { + return device + } + + // Many macOS cameras report `unspecified` position; fall back to any default. + return AVCaptureDevice.default(for: .video) + } + + private nonisolated static func clampQuality(_ quality: Double?) -> Double { + let q = quality ?? 0.9 + return min(1.0, max(0.05, q)) + } + + nonisolated static func normalizeSnap(maxWidth: Int?, quality: Double?) -> (maxWidth: Int, quality: Double) { + // Default to a reasonable max width to keep downstream payload sizes manageable. + // If you need full-res, explicitly request a larger maxWidth. + let maxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600 + let quality = Self.clampQuality(quality) + return (maxWidth: maxWidth, quality: quality) + } + + private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { + let v = ms ?? 3000 + return min(60000, max(250, v)) + } + + private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws { + let asset = AVURLAsset(url: inputURL) + guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else { + throw CameraError.exportFailed("Failed to create export session") + } + export.shouldOptimizeForNetworkUse = true + + if #available(macOS 15.0, *) { + do { + try await export.export(to: outputURL, as: .mp4) + return + } catch { + throw CameraError.exportFailed(error.localizedDescription) + } + } else { + export.outputURL = outputURL + export.outputFileType = .mp4 + + try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) in + export.exportAsynchronously { + cont.resume(returning: ()) + } + } + + switch export.status { + case .completed: + return + case .failed: + throw CameraError.exportFailed(export.error?.localizedDescription ?? "export failed") + case .cancelled: + throw CameraError.exportFailed("export cancelled") + default: + throw CameraError.exportFailed("export did not complete (\(export.status.rawValue))") + } + } + } + + private nonisolated static func warmUpCaptureSession() async { + // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices. + try? await Task.sleep(nanoseconds: 150_000_000) // 150ms + } + + private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async { + let stepNs: UInt64 = 50_000_000 + let maxSteps = 30 // ~1.5s + for _ in 0.. 0 else { return } + let ns = UInt64(min(delayMs, 10000)) * 1_000_000 + try? await Task.sleep(nanoseconds: ns) + } + + private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String { + switch position { + case .front: "front" + case .back: "back" + default: "unspecified" + } + } +} + +private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate { + private var cont: CheckedContinuation? + private var didResume = false + + init(_ cont: CheckedContinuation) { + self.cont = cont + } + + func photoOutput( + _ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) + { + guard !self.didResume, let cont else { return } + self.didResume = true + self.cont = nil + if let error { + cont.resume(throwing: error) + return + } + guard let data = photo.fileDataRepresentation() else { + cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data")) + return + } + if data.isEmpty { + cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("Photo data empty")) + return + } + cont.resume(returning: data) + } + + func photoOutput( + _ output: AVCapturePhotoOutput, + didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, + error: Error?) + { + guard let error else { return } + guard !self.didResume, let cont else { return } + self.didResume = true + self.cont = nil + cont.resume(throwing: error) + } +} + +private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate { + private var cont: CheckedContinuation? + private let logger: Logger + + init(_ cont: CheckedContinuation, logger: Logger) { + self.cont = cont + self.logger = logger + } + + func fileOutput( + _ output: AVCaptureFileOutput, + didFinishRecordingTo outputFileURL: URL, + from connections: [AVCaptureConnection], + error: Error?) + { + guard let cont else { return } + self.cont = nil + + if let error { + let ns = error as NSError + if ns.domain == AVFoundationErrorDomain, + ns.code == AVError.maximumDurationReached.rawValue + { + cont.resume(returning: outputFileURL) + return + } + + self.logger.error("camera record failed: \(error.localizedDescription, privacy: .public)") + cont.resume(throwing: error) + return + } + + cont.resume(returning: outputFileURL) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift new file mode 100644 index 00000000..40f443c5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift @@ -0,0 +1,149 @@ +import AppKit +import Foundation +import OpenClawIPC +import OpenClawKit +import WebKit + +final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { + static let messageName = "openclawCanvasA2UIAction" + static let allMessageNames = [messageName] + + private let sessionKey: String + + init(sessionKey: String) { + self.sessionKey = sessionKey + super.init() + } + + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + guard Self.allMessageNames.contains(message.name) else { return } + + // Only accept actions from local Canvas content (not arbitrary web pages). + guard let webView = message.webView, let url = webView.url else { return } + if let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) { + // ok + } else if Self.isLocalNetworkCanvasURL(url) { + // ok + } else { + return + } + + let body: [String: Any] = { + if let dict = message.body as? [String: Any] { return dict } + if let dict = message.body as? [AnyHashable: Any] { + return dict.reduce(into: [String: Any]()) { acc, pair in + guard let key = pair.key as? String else { return } + acc[key] = pair.value + } + } + return [:] + }() + guard !body.isEmpty else { return } + + let userActionAny = body["userAction"] ?? body + let userAction: [String: Any] = { + if let dict = userActionAny as? [String: Any] { return dict } + if let dict = userActionAny as? [AnyHashable: Any] { + return dict.reduce(into: [String: Any]()) { acc, pair in + guard let key = pair.key as? String else { return } + acc[key] = pair.value + } + } + return [:] + }() + guard !userAction.isEmpty else { return } + + guard let name = OpenClawCanvasA2UIAction.extractActionName(userAction) else { return } + let actionId = + (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + ?? UUID().uuidString + + canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)") + + let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? "main" + let sourceComponentId = (userAction["sourceComponentId"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-" + let instanceId = InstanceIdentity.instanceId.lowercased() + let contextJSON = OpenClawCanvasA2UIAction.compactJSON(userAction["context"]) + + // Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas. + let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext( + actionName: name, + session: .init(key: self.sessionKey, surfaceId: surfaceId), + component: .init(id: sourceComponentId, host: InstanceIdentity.displayName, instanceId: instanceId), + contextJSON: contextJSON) + let text = OpenClawCanvasA2UIAction.formatAgentMessage(messageContext) + + Task { [weak webView] in + if AppStateStore.shared.connectionMode == .local { + GatewayProcessManager.shared.setActive(true) + } + + let result = await GatewayConnection.shared.sendAgent( + GatewayAgentInvocation( + message: text, + sessionKey: self.sessionKey, + thinking: "low", + deliver: false, + to: nil, + channel: .last, + idempotencyKey: actionId)) + + await MainActor.run { + guard let webView else { return } + let js = OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus( + actionId: actionId, + ok: result.ok, + error: result.error) + webView.evaluateJavaScript(js) { _, _ in } + } + if !result.ok { + canvasWindowLogger.error( + """ + A2UI action send failed name=\(name, privacy: .public) \ + error=\(result.error ?? "unknown", privacy: .public) + """) + } + } + } + + static func isLocalNetworkCanvasURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { + return false + } + guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else { + return false + } + if host == "localhost" { return true } + if host.hasSuffix(".local") { return true } + if host.hasSuffix(".ts.net") { return true } + if host.hasSuffix(".tailscale.net") { return true } + if !host.contains("."), !host.contains(":") { return true } + if let ipv4 = Self.parseIPv4(host) { + return Self.isLocalNetworkIPv4(ipv4) + } + return false + } + + static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + let bytes: [UInt8] = parts.compactMap { UInt8($0) } + guard bytes.count == 4 else { return nil } + return (bytes[0], bytes[1], bytes[2], bytes[3]) + } + + static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { + let (a, b, _, _) = ip + if a == 10 { return true } + if a == 172, (16...31).contains(Int(b)) { return true } + if a == 192, b == 168 { return true } + if a == 127 { return true } + if a == 169, b == 254 { return true } + if a == 100, (64...127).contains(Int(b)) { return true } + return false + } + + // Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`). +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasChromeContainerView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasChromeContainerView.swift new file mode 100644 index 00000000..b4158167 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasChromeContainerView.swift @@ -0,0 +1,235 @@ +import AppKit +import QuartzCore + +final class HoverChromeContainerView: NSView { + private let content: NSView + private let chrome: CanvasChromeOverlayView + private var tracking: NSTrackingArea? + var onClose: (() -> Void)? + + init(containing content: NSView) { + self.content = content + self.chrome = CanvasChromeOverlayView(frame: .zero) + super.init(frame: .zero) + + self.wantsLayer = true + self.layer?.cornerRadius = 12 + self.layer?.masksToBounds = true + self.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + + self.content.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(self.content) + + self.chrome.translatesAutoresizingMaskIntoConstraints = false + self.chrome.alphaValue = 0 + self.chrome.onClose = { [weak self] in self?.onClose?() } + self.addSubview(self.chrome) + + NSLayoutConstraint.activate([ + self.content.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.content.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.content.topAnchor.constraint(equalTo: self.topAnchor), + self.content.bottomAnchor.constraint(equalTo: self.bottomAnchor), + + self.chrome.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.chrome.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.chrome.topAnchor.constraint(equalTo: self.topAnchor), + self.chrome.bottomAnchor.constraint(equalTo: self.bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let tracking { + self.removeTrackingArea(tracking) + } + let area = NSTrackingArea( + rect: self.bounds, + options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect], + owner: self, + userInfo: nil) + self.addTrackingArea(area) + self.tracking = area + } + + private final class CanvasDragHandleView: NSView { + override func mouseDown(with event: NSEvent) { + self.window?.performDrag(with: event) + } + + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { + true + } + } + + private final class CanvasResizeHandleView: NSView { + private var startPoint: NSPoint = .zero + private var startFrame: NSRect = .zero + + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { + true + } + + override func mouseDown(with event: NSEvent) { + guard let window else { return } + _ = window.makeFirstResponder(self) + self.startPoint = NSEvent.mouseLocation + self.startFrame = window.frame + super.mouseDown(with: event) + } + + override func mouseDragged(with _: NSEvent) { + guard let window else { return } + let current = NSEvent.mouseLocation + let dx = current.x - self.startPoint.x + let dy = current.y - self.startPoint.y + + var frame = self.startFrame + frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx) + frame.origin.y += dy + frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy) + + if let screen = window.screen { + frame = CanvasWindowController.constrainFrame(frame, toVisibleFrame: screen.visibleFrame) + } + window.setFrame(frame, display: true) + } + } + + private final class CanvasChromeOverlayView: NSView { + var onClose: (() -> Void)? + + private let dragHandle = CanvasDragHandleView(frame: .zero) + private let resizeHandle = CanvasResizeHandleView(frame: .zero) + + private final class PassthroughVisualEffectView: NSVisualEffectView { + override func hitTest(_: NSPoint) -> NSView? { + nil + } + } + + private let closeBackground: NSVisualEffectView = { + let v = PassthroughVisualEffectView(frame: .zero) + v.material = .hudWindow + v.blendingMode = .withinWindow + v.state = .active + v.appearance = NSAppearance(named: .vibrantDark) + v.wantsLayer = true + v.layer?.cornerRadius = 10 + v.layer?.masksToBounds = true + v.layer?.borderWidth = 1 + v.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor + v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor + v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor + v.layer?.shadowOpacity = 0.35 + v.layer?.shadowRadius = 8 + v.layer?.shadowOffset = .zero + return v + }() + + private let closeButton: NSButton = { + let cfg = NSImage.SymbolConfiguration(pointSize: 8, weight: .semibold) + let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")? + .withSymbolConfiguration(cfg) + ?? NSImage(size: NSSize(width: 18, height: 18)) + let btn = NSButton(image: img, target: nil, action: nil) + btn.isBordered = false + btn.bezelStyle = .regularSquare + btn.imageScaling = .scaleProportionallyDown + btn.contentTintColor = NSColor.white.withAlphaComponent(0.92) + btn.toolTip = "Close" + return btn + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + self.wantsLayer = true + self.layer?.cornerRadius = 12 + self.layer?.masksToBounds = true + self.layer?.borderWidth = 1 + self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor + self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor + + self.dragHandle.translatesAutoresizingMaskIntoConstraints = false + self.dragHandle.wantsLayer = true + self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor + self.addSubview(self.dragHandle) + + self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false + self.resizeHandle.wantsLayer = true + self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor + self.addSubview(self.resizeHandle) + + self.closeBackground.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(self.closeBackground) + + self.closeButton.translatesAutoresizingMaskIntoConstraints = false + self.closeButton.target = self + self.closeButton.action = #selector(self.handleClose) + self.addSubview(self.closeButton) + + NSLayoutConstraint.activate([ + self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor), + self.dragHandle.heightAnchor.constraint(equalToConstant: 30), + + self.closeBackground.centerXAnchor.constraint(equalTo: self.closeButton.centerXAnchor), + self.closeBackground.centerYAnchor.constraint(equalTo: self.closeButton.centerYAnchor), + self.closeBackground.widthAnchor.constraint(equalToConstant: 20), + self.closeBackground.heightAnchor.constraint(equalToConstant: 20), + + self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), + self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), + self.closeButton.widthAnchor.constraint(equalToConstant: 16), + self.closeButton.heightAnchor.constraint(equalToConstant: 16), + + self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.resizeHandle.widthAnchor.constraint(equalToConstant: 18), + self.resizeHandle.heightAnchor.constraint(equalToConstant: 18), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func hitTest(_ point: NSPoint) -> NSView? { + // When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them). + guard self.alphaValue > 0.02 else { return nil } + + if self.closeButton.frame.contains(point) { return self.closeButton } + if self.dragHandle.frame.contains(point) { return self.dragHandle } + if self.resizeHandle.frame.contains(point) { return self.resizeHandle } + return nil + } + + @objc private func handleClose() { + self.onClose?() + } + } + + override func mouseEntered(with _: NSEvent) { + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.12 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + self.chrome.animator().alphaValue = 1 + } + } + + override func mouseExited(with _: NSEvent) { + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.16 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + self.chrome.animator().alphaValue = 0 + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift new file mode 100644 index 00000000..3ed0d67f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift @@ -0,0 +1,24 @@ +import Foundation + +final class CanvasFileWatcher: @unchecked Sendable { + private let watcher: CoalescingFSEventsWatcher + + init(url: URL, onChange: @escaping () -> Void) { + self.watcher = CoalescingFSEventsWatcher( + paths: [url.path], + queueLabel: "ai.openclaw.canvaswatcher", + onChange: onChange) + } + + deinit { + self.stop() + } + + func start() { + self.watcher.start() + } + + func stop() { + self.watcher.stop() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasManager.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasManager.swift new file mode 100644 index 00000000..843f7884 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasManager.swift @@ -0,0 +1,342 @@ +import AppKit +import Foundation +import OpenClawIPC +import OpenClawKit +import OSLog + +@MainActor +final class CanvasManager { + static let shared = CanvasManager() + + private static let logger = Logger(subsystem: "ai.openclaw", category: "CanvasManager") + + private var panelController: CanvasWindowController? + private var panelSessionKey: String? + private var lastAutoA2UIUrl: String? + private var gatewayWatchTask: Task? + + private init() { + self.startGatewayObserver() + } + + var onPanelVisibilityChanged: ((Bool) -> Void)? + + /// Optional anchor provider (e.g. menu bar status item). If nil, Canvas anchors to the mouse cursor. + var defaultAnchorProvider: (() -> NSRect?)? + + private nonisolated static let canvasRoot: URL = { + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("OpenClaw/canvas", isDirectory: true) + }() + + func show(sessionKey: String, path: String? = nil, placement: CanvasPlacement? = nil) throws -> String { + try self.showDetailed(sessionKey: sessionKey, target: path, placement: placement).directory + } + + func showDetailed( + sessionKey: String, + target: String? = nil, + placement: CanvasPlacement? = nil) throws -> CanvasShowResult + { + Self.logger.debug( + """ + showDetailed start session=\(sessionKey, privacy: .public) \ + target=\(target ?? "", privacy: .public) \ + placement=\(placement != nil) + """) + let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider + let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedTarget = target? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty + + if let controller = self.panelController, self.panelSessionKey == session { + Self.logger.debug("showDetailed reuse existing session=\(session, privacy: .public)") + controller.onVisibilityChanged = { [weak self] visible in + self?.onPanelVisibilityChanged?(visible) + } + controller.presentAnchoredPanel(anchorProvider: anchorProvider) + controller.applyPreferredPlacement(placement) + self.refreshDebugStatus() + + // Existing session: only navigate when an explicit target was provided. + if let normalizedTarget { + controller.load(target: normalizedTarget) + return self.makeShowResult( + directory: controller.directoryPath, + target: target, + effectiveTarget: normalizedTarget) + } + + self.maybeAutoNavigateToA2UIAsync(controller: controller) + return CanvasShowResult( + directory: controller.directoryPath, + target: target, + effectiveTarget: nil, + status: .shown, + url: nil) + } + + Self.logger.debug("showDetailed creating new session=\(session, privacy: .public)") + self.panelController?.close() + self.panelController = nil + self.panelSessionKey = nil + + Self.logger.debug("showDetailed ensure canvas root dir") + try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true) + Self.logger.debug("showDetailed init CanvasWindowController") + let controller = try CanvasWindowController( + sessionKey: session, + root: Self.canvasRoot, + presentation: .panel(anchorProvider: anchorProvider)) + Self.logger.debug("showDetailed CanvasWindowController init done") + controller.onVisibilityChanged = { [weak self] visible in + self?.onPanelVisibilityChanged?(visible) + } + self.panelController = controller + self.panelSessionKey = session + controller.applyPreferredPlacement(placement) + + // New session: default to "/" so the user sees either the welcome page or `index.html`. + let effectiveTarget = normalizedTarget ?? "/" + Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)") + controller.showCanvas(path: effectiveTarget) + Self.logger.debug("showDetailed showCanvas done") + if normalizedTarget == nil { + self.maybeAutoNavigateToA2UIAsync(controller: controller) + } + self.refreshDebugStatus() + + return self.makeShowResult( + directory: controller.directoryPath, + target: target, + effectiveTarget: effectiveTarget) + } + + func hide(sessionKey: String) { + let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard self.panelSessionKey == session else { return } + self.panelController?.hideCanvas() + } + + func hideAll() { + self.panelController?.hideCanvas() + } + + func eval(sessionKey: String, javaScript: String) async throws -> String { + _ = try self.show(sessionKey: sessionKey, path: nil) + guard let controller = self.panelController else { return "" } + return try await controller.eval(javaScript: javaScript) + } + + func snapshot(sessionKey: String, outPath: String?) async throws -> String { + _ = try self.show(sessionKey: sessionKey, path: nil) + guard let controller = self.panelController else { + throw NSError(domain: "Canvas", code: 21, userInfo: [NSLocalizedDescriptionKey: "canvas not available"]) + } + return try await controller.snapshot(to: outPath) + } + + // MARK: - Gateway A2UI auto-nav + + private func startGatewayObserver() { + self.gatewayWatchTask?.cancel() + self.gatewayWatchTask = Task { [weak self] in + guard let self else { return } + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 1) + for await push in stream { + self.handleGatewayPush(push) + } + } + } + + private func handleGatewayPush(_ push: GatewayPush) { + guard case let .snapshot(snapshot) = push else { return } + let raw = snapshot.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if raw.isEmpty { + Self.logger.debug("canvas host url missing in gateway snapshot") + } else { + Self.logger.debug("canvas host url snapshot=\(raw, privacy: .public)") + } + let a2uiUrl = Self.resolveA2UIHostUrl(from: raw) + if a2uiUrl == nil, !raw.isEmpty { + Self.logger.debug("canvas host url invalid; cannot resolve A2UI") + } + guard let controller = self.panelController else { + if a2uiUrl != nil { + Self.logger.debug("canvas panel not visible; skipping auto-nav") + } + return + } + self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl) + } + + private func maybeAutoNavigateToA2UIAsync(controller: CanvasWindowController) { + Task { [weak self] in + guard let self else { return } + let a2uiUrl = await self.resolveA2UIHostUrl() + await MainActor.run { + guard self.panelController === controller else { return } + self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl) + } + } + } + + private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) { + guard let a2uiUrl else { return } + let shouldNavigate = controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl) + guard shouldNavigate else { + Self.logger.debug("canvas auto-nav skipped; target unchanged") + return + } + Self.logger.debug("canvas auto-nav -> \(a2uiUrl, privacy: .public)") + controller.load(target: a2uiUrl) + self.lastAutoA2UIUrl = a2uiUrl + } + + private func resolveA2UIHostUrl() async -> String? { + let raw = await GatewayConnection.shared.canvasHostUrl() + return Self.resolveA2UIHostUrl(from: raw) + } + + func refreshDebugStatus() { + guard let controller = self.panelController else { return } + let enabled = AppStateStore.shared.debugPaneEnabled + let mode = AppStateStore.shared.connectionMode + let title: String? + let subtitle: String? + switch mode { + case .remote: + title = "Remote control" + switch ControlChannel.shared.state { + case .connected: + subtitle = "Connected" + case .connecting: + subtitle = "Connecting…" + case .disconnected: + subtitle = "Disconnected" + case let .degraded(message): + subtitle = message.isEmpty ? "Degraded" : message + } + case .local: + title = GatewayProcessManager.shared.status.label + subtitle = mode.rawValue + case .unconfigured: + title = "Unconfigured" + subtitle = mode.rawValue + } + controller.updateDebugStatus(enabled: enabled, title: title, subtitle: subtitle) + } + + private static func resolveA2UIHostUrl(from raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } + return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=macos" + } + + // MARK: - Anchoring + + private static func mouseAnchorProvider() -> NSRect? { + let pt = NSEvent.mouseLocation + return NSRect(x: pt.x, y: pt.y, width: 1, height: 1) + } + + // placement interpretation is handled by the window controller. + + // MARK: - Helpers + + private static func directURL(for target: String?) -> URL? { + guard let target else { return nil } + let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() { + if scheme == "https" || scheme == "http" || scheme == "file" { return url } + } + + // Convenience: existing absolute *file* paths resolve as local files. + // (Avoid treating Canvas routes like "/" as filesystem paths.) + if trimmed.hasPrefix("/") { + var isDir: ObjCBool = false + if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { + return URL(fileURLWithPath: trimmed) + } + } + + return nil + } + + private func makeShowResult( + directory: String, + target: String?, + effectiveTarget: String) -> CanvasShowResult + { + if let url = Self.directURL(for: effectiveTarget) { + return CanvasShowResult( + directory: directory, + target: target, + effectiveTarget: effectiveTarget, + status: .web, + url: url.absoluteString) + } + + let sessionDir = URL(fileURLWithPath: directory) + let status = Self.localStatus(sessionDir: sessionDir, target: effectiveTarget) + let host = sessionDir.lastPathComponent + let canvasURL = CanvasScheme.makeURL(session: host, path: effectiveTarget)?.absoluteString + return CanvasShowResult( + directory: directory, + target: target, + effectiveTarget: effectiveTarget, + status: status, + url: canvasURL) + } + + private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus { + let fm = FileManager() + let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) + let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first + .map(String.init) ?? trimmed + var path = withoutQuery + if path.hasPrefix("/") { path.removeFirst() } + path = path.removingPercentEncoding ?? path + + // Root special-case: built-in scaffold page when no index exists. + if path.isEmpty { + let a = sessionDir.appendingPathComponent("index.html", isDirectory: false) + let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false) + if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok } + return .welcome + } + + // Direct file or directory. + var candidate = sessionDir.appendingPathComponent(path, isDirectory: false) + var isDir: ObjCBool = false + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) { + if isDir.boolValue { + return Self.indexExists(in: candidate) ? .ok : .notFound + } + return .ok + } + + // Directory index behavior ("/yolo" -> "yolo/index.html") if directory exists. + if !path.isEmpty, !path.hasSuffix("/") { + candidate = sessionDir.appendingPathComponent(path, isDirectory: true) + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue { + return Self.indexExists(in: candidate) ? .ok : .notFound + } + } + + return .notFound + } + + private static func indexExists(in dir: URL) -> Bool { + let fm = FileManager() + let a = dir.appendingPathComponent("index.html", isDirectory: false) + if fm.fileExists(atPath: a.path) { return true } + let b = dir.appendingPathComponent("index.htm", isDirectory: false) + return fm.fileExists(atPath: b.path) + } + + // no bundled A2UI shell; scaffold fallback is purely visual +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasScheme.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasScheme.swift new file mode 100644 index 00000000..4f08da2d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasScheme.swift @@ -0,0 +1,42 @@ +import Foundation + +enum CanvasScheme { + static let scheme = "openclaw-canvas" + static let allSchemes = [scheme] + + static func makeURL(session: String, path: String? = nil) -> URL? { + var comps = URLComponents() + comps.scheme = Self.scheme + comps.host = session + let p = (path ?? "/").trimmingCharacters(in: .whitespacesAndNewlines) + if p.isEmpty || p == "/" { + comps.path = "/" + } else if p.hasPrefix("/") { + comps.path = p + } else { + comps.path = "/" + p + } + return comps.url + } + + static func mimeType(forExtension ext: String) -> String { + switch ext.lowercased() { + // Note: WKURLSchemeHandler uses URLResponse(mimeType:), which expects a bare MIME type + // (no `; charset=...`). Encoding is provided via URLResponse(textEncodingName:). + case "html", "htm": "text/html" + case "js", "mjs": "application/javascript" + case "css": "text/css" + case "json", "map": "application/json" + case "svg": "image/svg+xml" + case "png": "image/png" + case "jpg", "jpeg": "image/jpeg" + case "gif": "image/gif" + case "ico": "image/x-icon" + case "woff2": "font/woff2" + case "woff": "font/woff" + case "ttf": "font/ttf" + case "wasm": "application/wasm" + default: "application/octet-stream" + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasSchemeHandler.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasSchemeHandler.swift new file mode 100644 index 00000000..6905af50 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasSchemeHandler.swift @@ -0,0 +1,259 @@ +import Foundation +import OpenClawKit +import OSLog +import WebKit + +private let canvasLogger = Logger(subsystem: "ai.openclaw", category: "Canvas") + +final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { + private let root: URL + + init(root: URL) { + self.root = root + } + + func webView(_: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + urlSchemeTask.didFailWithError(NSError(domain: "Canvas", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "missing url", + ])) + return + } + + let response = self.response(for: url) + let mime = response.mime + let data = response.data + let encoding = self.textEncodingName(forMimeType: mime) + + let urlResponse = URLResponse( + url: url, + mimeType: mime, + expectedContentLength: data.count, + textEncodingName: encoding) + urlSchemeTask.didReceive(urlResponse) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + + func webView(_: WKWebView, stop _: WKURLSchemeTask) { + // no-op + } + + private struct CanvasResponse { + let mime: String + let data: Data + } + + private func response(for url: URL) -> CanvasResponse { + guard let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) else { + return self.html("Invalid scheme.") + } + guard let session = url.host, !session.isEmpty else { + return self.html("Missing session.") + } + + // Keep session component safe; don't allow slashes or traversal. + if session.contains("/") || session.contains("..") { + return self.html("Invalid session.") + } + + let sessionRoot = self.root.appendingPathComponent(session, isDirectory: true) + + // Path mapping: request path maps directly into the session dir. + var path = url.path + if let qIdx = path.firstIndex(of: "?") { path = String(path[.. \(servedPath, privacy: .public)") + return CanvasResponse(mime: mime, data: data) + } catch { + let failedPath = standardizedFile.path + let errorText = error.localizedDescription + canvasLogger + .error( + "failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)") + return self.html("Failed to read file.", title: "Canvas error") + } + } + + private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { + let fm = FileManager() + var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false) + + var isDir: ObjCBool = false + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) { + if isDir.boolValue { + if let idx = self.resolveIndex(in: candidate) { return idx } + return nil + } + return candidate + } + + // Directory index behavior: + // - "/yolo" serves "/index.html" if that directory exists. + if !requestPath.isEmpty, !requestPath.hasSuffix("/") { + candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: true) + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue { + if let idx = self.resolveIndex(in: candidate) { return idx } + } + } + + // Root fallback: + // - "/" serves "/index.html" if present. + if requestPath.isEmpty { + return self.resolveIndex(in: sessionRoot) + } + + return nil + } + + private func resolveIndex(in dir: URL) -> URL? { + let fm = FileManager() + let a = dir.appendingPathComponent("index.html", isDirectory: false) + if fm.fileExists(atPath: a.path) { return a } + let b = dir.appendingPathComponent("index.htm", isDirectory: false) + if fm.fileExists(atPath: b.path) { return b } + return nil + } + + private func html(_ body: String, title: String = "Canvas") -> CanvasResponse { + let html = """ + + + + + + \(title) + + + +
+
\(body)
+
+ + + """ + return CanvasResponse(mime: "text/html", data: Data(html.utf8)) + } + + private func welcomePage(sessionRoot: URL) -> CanvasResponse { + let escaped = sessionRoot.path + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + let body = """ +
Canvas is ready.
+
Create index.html in:
+
\(escaped)
+ """ + return self.html(body, title: "Canvas") + } + + private func scaffoldPage(sessionRoot: URL) -> CanvasResponse { + // Default Canvas UX: when no index exists, show the built-in scaffold page. + if let data = self.loadBundledResourceData(relativePath: "CanvasScaffold/scaffold.html") { + return CanvasResponse(mime: "text/html", data: data) + } + + // Fallback for dev misconfiguration: show the classic welcome page. + return self.welcomePage(sessionRoot: sessionRoot) + } + + private func loadBundledResourceData(relativePath: String) -> Data? { + let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.contains("..") || trimmed.contains("\\") { return nil } + + let parts = trimmed.split(separator: "/") + guard let filename = parts.last else { return nil } + let subdirectory = + parts.count > 1 ? parts.dropLast().joined(separator: "/") : nil + let fileURL = URL(fileURLWithPath: String(filename)) + let ext = fileURL.pathExtension + let name = fileURL.deletingPathExtension().lastPathComponent + guard !name.isEmpty, !ext.isEmpty else { return nil } + + let bundle = OpenClawKitResources.bundle + let resourceURL = + bundle.url(forResource: name, withExtension: ext, subdirectory: subdirectory) + ?? bundle.url(forResource: name, withExtension: ext) + guard let resourceURL else { return nil } + return try? Data(contentsOf: resourceURL) + } + + private func textEncodingName(forMimeType mimeType: String) -> String? { + if mimeType.hasPrefix("text/") { return "utf-8" } + switch mimeType { + case "application/javascript", "application/json", "image/svg+xml": + return "utf-8" + default: + return nil + } + } +} + +#if DEBUG +extension CanvasSchemeHandler { + func _testResponse(for url: URL) -> (mime: String, data: Data) { + let response = self.response(for: url) + return (response.mime, response.data) + } + + func _testResolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { + self.resolveFileURL(sessionRoot: sessionRoot, requestPath: requestPath) + } + + func _testTextEncodingName(for mimeType: String) -> String? { + self.textEncodingName(forMimeType: mimeType) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindow.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindow.swift new file mode 100644 index 00000000..a87f3256 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindow.swift @@ -0,0 +1,31 @@ +import AppKit + +let canvasWindowLogger = Logger(subsystem: "ai.openclaw", category: "Canvas") + +enum CanvasLayout { + static let panelSize = NSSize(width: 520, height: 680) + static let windowSize = NSSize(width: 1120, height: 840) + static let anchorPadding: CGFloat = 8 + static let defaultPadding: CGFloat = 10 + static let minPanelSize = NSSize(width: 360, height: 360) +} + +final class CanvasPanel: NSPanel { + override var canBecomeKey: Bool { + true + } + + override var canBecomeMain: Bool { + true + } +} + +enum CanvasPresentation { + case window + case panel(anchorProvider: () -> NSRect?) + + var isPanel: Bool { + if case .panel = self { return true } + return false + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController+Helpers.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController+Helpers.swift new file mode 100644 index 00000000..a7d10f95 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController+Helpers.swift @@ -0,0 +1,43 @@ +import AppKit +import Foundation + +extension CanvasWindowController { + // MARK: - Helpers + + static func sanitizeSessionKey(_ key: String) -> String { + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return "main" } + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+") + let scalars = trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } + return String(scalars) + } + + static func jsStringLiteral(_ value: String) -> String { + let data = try? JSONEncoder().encode(value) + return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\"" + } + + static func jsOptionalStringLiteral(_ value: String?) -> String { + guard let value else { return "null" } + return Self.jsStringLiteral(value) + } + + static func storedFrameDefaultsKey(sessionKey: String) -> String { + "openclaw.canvas.frame.\(self.sanitizeSessionKey(sessionKey))" + } + + static func loadRestoredFrame(sessionKey: String) -> NSRect? { + let key = self.storedFrameDefaultsKey(sessionKey: sessionKey) + guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil } + let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3]) + if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil } + return rect + } + + static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) { + let key = self.storedFrameDefaultsKey(sessionKey: sessionKey) + UserDefaults.standard.set( + [Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)], + forKey: key) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController+Navigation.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController+Navigation.swift new file mode 100644 index 00000000..16e0b01d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController+Navigation.swift @@ -0,0 +1,64 @@ +import AppKit +import WebKit + +extension CanvasWindowController { + // MARK: - WKNavigationDelegate + + @MainActor + func webView( + _: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) + { + guard let url = navigationAction.request.url else { + decisionHandler(.cancel) + return + } + let scheme = url.scheme?.lowercased() + + // Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace. + if scheme == "openclaw" { + if let currentScheme = self.webView.url?.scheme, + CanvasScheme.allSchemes.contains(currentScheme) + { + Task { await DeepLinkHandler.shared.handle(url: url) } + } else { + canvasWindowLogger + .debug("ignoring deep link from non-canvas page \(url.absoluteString, privacy: .public)") + } + decisionHandler(.cancel) + return + } + + // Keep web content inside the panel when reasonable. + // `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace. + if CanvasScheme.allSchemes.contains(scheme ?? "") + || scheme == "https" + || scheme == "http" + || scheme == "about" + || scheme == "blob" + || scheme == "data" + || scheme == "javascript" + { + decisionHandler(.allow) + return + } + + // Only open external URLs when there is a registered handler, otherwise macOS will show a confusing + // "There is no application set to open the URL ..." alert (e.g. for about:blank). + if let appURL = NSWorkspace.shared.urlForApplication(toOpen: url) { + NSWorkspace.shared.open( + [url], + withApplicationAt: appURL, + configuration: NSWorkspace.OpenConfiguration(), + completionHandler: nil) + } else { + canvasWindowLogger.debug("no application to open url \(url.absoluteString, privacy: .public)") + } + decisionHandler(.cancel) + } + + func webView(_: WKWebView, didFinish _: WKNavigation?) { + self.applyDebugStatusIfNeeded() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift new file mode 100644 index 00000000..6c53fbc9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift @@ -0,0 +1,39 @@ +#if DEBUG +import AppKit +import Foundation + +extension CanvasWindowController { + static func _testSanitizeSessionKey(_ key: String) -> String { + self.sanitizeSessionKey(key) + } + + static func _testJSStringLiteral(_ value: String) -> String { + self.jsStringLiteral(value) + } + + static func _testJSOptionalStringLiteral(_ value: String?) -> String { + self.jsOptionalStringLiteral(value) + } + + static func _testStoredFrameKey(sessionKey: String) -> String { + self.storedFrameDefaultsKey(sessionKey: sessionKey) + } + + static func _testStoreAndLoadFrame(sessionKey: String, frame: NSRect) -> NSRect? { + self.storeRestoredFrame(frame, sessionKey: sessionKey) + return self.loadRestoredFrame(sessionKey: sessionKey) + } + + static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { + CanvasA2UIActionMessageHandler.parseIPv4(host) + } + + static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { + CanvasA2UIActionMessageHandler.isLocalNetworkIPv4(ip) + } + + static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool { + CanvasA2UIActionMessageHandler.isLocalNetworkCanvasURL(url) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController+Window.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController+Window.swift new file mode 100644 index 00000000..042ee00b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController+Window.swift @@ -0,0 +1,166 @@ +import AppKit +import OpenClawIPC + +extension CanvasWindowController { + // MARK: - Window + + static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow { + switch presentation { + case .window: + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false) + window.title = "OpenClaw Canvas" + window.isReleasedWhenClosed = false + window.contentView = contentView + window.center() + window.minSize = NSSize(width: 880, height: 680) + return window + + case .panel: + let panel = CanvasPanel( + contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize), + styleMask: [.borderless, .resizable], + backing: .buffered, + defer: false) + // Keep Canvas below the Voice Wake overlay panel. + panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue - 1) + panel.hasShadow = true + panel.isMovable = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.backgroundColor = .clear + panel.isOpaque = false + panel.contentView = contentView + panel.becomesKeyOnlyIfNeeded = true + panel.hidesOnDeactivate = false + panel.minSize = CanvasLayout.minPanelSize + return panel + } + } + + func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) { + guard case .panel = self.presentation, let window else { return } + self.repositionPanel(using: anchorProvider) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + window.makeFirstResponder(self.webView) + VoiceWakeOverlayController.shared.bringToFrontIfVisible() + self.onVisibilityChanged?(true) + } + + func repositionPanel(using anchorProvider: () -> NSRect?) { + guard let panel = self.window else { return } + let anchor = anchorProvider() + let targetScreen = Self.screen(forAnchor: anchor) + ?? Self.screenContainingMouseCursor() + ?? panel.screen + ?? NSScreen.main + ?? NSScreen.screens.first + + let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey) + let restoredIsValid = if let restored, let targetScreen { + Self.isFrameMeaningfullyVisible(restored, on: targetScreen) + } else { + restored != nil + } + + var frame = if let restored, restoredIsValid { + restored + } else { + Self.defaultTopRightFrame(panel: panel, screen: targetScreen) + } + + // Apply agent placement as partial overrides: + // - If agent provides x/y, override origin. + // - If agent provides width/height, override size. + // - If agent provides only size, keep the remembered origin. + if let placement = self.preferredPlacement { + if let x = placement.x { frame.origin.x = x } + if let y = placement.y { frame.origin.y = y } + if let w = placement.width { frame.size.width = max(CanvasLayout.minPanelSize.width, CGFloat(w)) } + if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) } + } + + self.setPanelFrame(frame, on: targetScreen) + } + + static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect { + let w = max(CanvasLayout.minPanelSize.width, panel.frame.width) + let h = max(CanvasLayout.minPanelSize.height, panel.frame.height) + return WindowPlacement.topRightFrame( + size: NSSize(width: w, height: h), + padding: CanvasLayout.defaultPadding, + on: screen) + } + + func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) { + guard let panel = self.window else { return } + guard let s = screen ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first else { + panel.setFrame(frame, display: false) + self.persistFrameIfPanel() + return + } + + let constrained = Self.constrainFrame(frame, toVisibleFrame: s.visibleFrame) + panel.setFrame(constrained, display: false) + self.persistFrameIfPanel() + } + + static func screen(forAnchor anchor: NSRect?) -> NSScreen? { + guard let anchor else { return nil } + let center = NSPoint(x: anchor.midX, y: anchor.midY) + return NSScreen.screens.first { screen in + screen.frame.contains(anchor.origin) || screen.frame.contains(center) + } + } + + static func screenContainingMouseCursor() -> NSScreen? { + let point = NSEvent.mouseLocation + return NSScreen.screens.first { $0.frame.contains(point) } + } + + static func isFrameMeaningfullyVisible(_ frame: NSRect, on screen: NSScreen) -> Bool { + frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12)) + } + + static func constrainFrame(_ frame: NSRect, toVisibleFrame bounds: NSRect) -> NSRect { + if bounds == .zero { return frame } + + var next = frame + next.size.width = min(max(CanvasLayout.minPanelSize.width, next.size.width), bounds.width) + next.size.height = min(max(CanvasLayout.minPanelSize.height, next.size.height), bounds.height) + + let maxX = bounds.maxX - next.size.width + let maxY = bounds.maxY - next.size.height + + next.origin.x = maxX >= bounds.minX ? min(max(next.origin.x, bounds.minX), maxX) : bounds.minX + next.origin.y = maxY >= bounds.minY ? min(max(next.origin.y, bounds.minY), maxY) : bounds.minY + + next.origin.x = round(next.origin.x) + next.origin.y = round(next.origin.y) + return next + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_: Notification) { + self.onVisibilityChanged?(false) + } + + func windowDidMove(_: Notification) { + self.persistFrameIfPanel() + } + + func windowDidEndLiveResize(_: Notification) { + self.persistFrameIfPanel() + } + + func persistFrameIfPanel() { + guard case .panel = self.presentation, let window else { return } + Self.storeRestoredFrame(window.frame, sessionKey: self.sessionKey) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController.swift new file mode 100644 index 00000000..d30f5418 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CanvasWindowController.swift @@ -0,0 +1,373 @@ +import AppKit +import Foundation +import OpenClawIPC +import OpenClawKit +import WebKit + +@MainActor +final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate { + let sessionKey: String + private let root: URL + private let sessionDir: URL + private let schemeHandler: CanvasSchemeHandler + let webView: WKWebView + private var a2uiActionMessageHandler: CanvasA2UIActionMessageHandler? + private let watcher: CanvasFileWatcher + private let container: HoverChromeContainerView + let presentation: CanvasPresentation + var preferredPlacement: CanvasPlacement? + private(set) var currentTarget: String? + private var debugStatusEnabled = false + private var debugStatusTitle: String? + private var debugStatusSubtitle: String? + + var onVisibilityChanged: ((Bool) -> Void)? + + init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws { + self.sessionKey = sessionKey + self.root = root + self.presentation = presentation + + canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)") + let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey) + canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)") + self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true) + try FileManager().createDirectory(at: self.sessionDir, withIntermediateDirectories: true) + canvasWindowLogger.debug("CanvasWindowController init session dir ready") + + self.schemeHandler = CanvasSchemeHandler(root: root) + canvasWindowLogger.debug("CanvasWindowController init scheme handler ready") + + let config = WKWebViewConfiguration() + config.userContentController = WKUserContentController() + config.preferences.isElementFullscreenEnabled = true + config.preferences.setValue(true, forKey: "developerExtrasEnabled") + canvasWindowLogger.debug("CanvasWindowController init config ready") + for scheme in CanvasScheme.allSchemes { + config.setURLSchemeHandler(self.schemeHandler, forURLScheme: scheme) + } + canvasWindowLogger.debug("CanvasWindowController init scheme handler installed") + + // Bridge A2UI "a2uiaction" DOM events back into the native agent loop. + // + // Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link + // (includes the app-generated key so it won't prompt). + canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script") + let deepLinkKey = DeepLinkHandler.currentCanvasKey() + let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main" + let bridgeScript = """ + (() => { + try { + const allowedSchemes = \(String(describing: CanvasScheme.allSchemes)); + const protocol = location.protocol.replace(':', ''); + if (!allowedSchemes.includes(protocol)) return; + if (globalThis.__openclawA2UIBridgeInstalled) return; + globalThis.__openclawA2UIBridgeInstalled = true; + + const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey)); + const sessionKey = \(Self.jsStringLiteral(injectedSessionKey)); + const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName)); + const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId)); + + globalThis.addEventListener('a2uiaction', (evt) => { + try { + const payload = evt?.detail ?? evt?.payload ?? null; + if (!payload || payload.eventType !== 'a2ui.action') return; + + const action = payload.action ?? null; + const name = action?.name ?? ''; + if (!name) return; + + const context = Array.isArray(action?.context) ? action.context : []; + const userAction = { + id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())), + name, + surfaceId: payload.surfaceId ?? 'main', + sourceComponentId: payload.sourceComponentId ?? '', + dataContextPath: payload.dataContextPath ?? '', + timestamp: new Date().toISOString(), + ...(context.length ? { context } : {}), + }; + + const handler = globalThis.webkit?.messageHandlers?.openclawCanvasA2UIAction; + + // If the bundled A2UI shell is present, let it forward actions so we keep its richer + // context resolution (data model path lookups, surface detection, etc.). + const hasBundledA2UIHost = + !!globalThis.openclawA2UI || + !!document.querySelector('openclaw-a2ui-host'); + if (hasBundledA2UIHost && handler?.postMessage) return; + + // Otherwise, forward directly when possible. + if (!hasBundledA2UIHost && handler?.postMessage) { + handler.postMessage({ userAction }); + return; + } + + const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : ''; + const message = + 'CANVAS_A2UI action=' + userAction.name + + ' session=' + sessionKey + + ' surface=' + userAction.surfaceId + + ' component=' + (userAction.sourceComponentId || '-') + + ' host=' + machineName.replace(/\\s+/g, '_') + + ' instance=' + instanceId + + ctx + + ' default=update_canvas'; + const params = new URLSearchParams(); + params.set('message', message); + params.set('sessionKey', sessionKey); + params.set('thinking', 'low'); + params.set('deliver', 'false'); + params.set('channel', 'last'); + params.set('key', deepLinkKey); + location.href = 'openclaw://agent?' + params.toString(); + } catch {} + }, true); + } catch {} + })(); + """ + config.userContentController.addUserScript( + WKUserScript(source: bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + canvasWindowLogger.debug("CanvasWindowController init A2UI bridge installed") + + canvasWindowLogger.debug("CanvasWindowController init creating WKWebView") + self.webView = WKWebView(frame: .zero, configuration: config) + // Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays. + self.webView.setValue(true, forKey: "drawsBackground") + + let sessionDir = self.sessionDir + let webView = self.webView + self.watcher = CanvasFileWatcher(url: sessionDir) { [weak webView] in + Task { @MainActor in + guard let webView else { return } + + // Only auto-reload when we are showing local canvas content. + guard let scheme = webView.url?.scheme, + CanvasScheme.allSchemes.contains(scheme) else { return } + + let path = webView.url?.path ?? "" + if path == "/" || path.isEmpty { + let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false) + let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false) + if !FileManager().fileExists(atPath: indexA.path), + !FileManager().fileExists(atPath: indexB.path) + { + return + } + } + + webView.reload() + } + } + + self.container = HoverChromeContainerView(containing: self.webView) + let window = Self.makeWindow(for: presentation, contentView: self.container) + canvasWindowLogger.debug("CanvasWindowController init makeWindow done") + super.init(window: window) + + let handler = CanvasA2UIActionMessageHandler(sessionKey: sessionKey) + self.a2uiActionMessageHandler = handler + for name in CanvasA2UIActionMessageHandler.allMessageNames { + self.webView.configuration.userContentController.add(handler, name: name) + } + + self.webView.navigationDelegate = self + self.window?.delegate = self + self.container.onClose = { [weak self] in + self?.hideCanvas() + } + + self.watcher.start() + canvasWindowLogger.debug("CanvasWindowController init done") + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + @MainActor deinit { + for name in CanvasA2UIActionMessageHandler.allMessageNames { + self.webView.configuration.userContentController.removeScriptMessageHandler(forName: name) + } + self.watcher.stop() + } + + func applyPreferredPlacement(_ placement: CanvasPlacement?) { + self.preferredPlacement = placement + } + + func showCanvas(path: String? = nil) { + if case let .panel(anchorProvider) = self.presentation { + self.presentAnchoredPanel(anchorProvider: anchorProvider) + if let path { + self.load(target: path) + } + return + } + + self.showWindow(nil) + self.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + if let path { + self.load(target: path) + } + self.onVisibilityChanged?(true) + } + + func hideCanvas() { + if case .panel = self.presentation { + self.persistFrameIfPanel() + } + self.window?.orderOut(nil) + self.onVisibilityChanged?(false) + } + + func load(target: String) { + let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) + self.currentTarget = trimmed + + if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() { + if scheme == "https" || scheme == "http" { + canvasWindowLogger.debug("canvas load url \(url.absoluteString, privacy: .public)") + self.webView.load(URLRequest(url: url)) + return + } + if scheme == "file" { + canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)") + self.loadFile(url) + return + } + } + + // Convenience: absolute file paths resolve as local files when they exist. + // (Avoid treating Canvas routes like "/" as filesystem paths.) + if trimmed.hasPrefix("/") { + var isDir: ObjCBool = false + if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { + let url = URL(fileURLWithPath: trimmed) + canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)") + self.loadFile(url) + return + } + } + + guard let url = CanvasScheme.makeURL( + session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), + path: trimmed) + else { + canvasWindowLogger + .error( + "invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)") + return + } + canvasWindowLogger.debug("canvas load canvas \(url.absoluteString, privacy: .public)") + self.webView.load(URLRequest(url: url)) + } + + func updateDebugStatus(enabled: Bool, title: String?, subtitle: String?) { + self.debugStatusEnabled = enabled + self.debugStatusTitle = title + self.debugStatusSubtitle = subtitle + self.applyDebugStatusIfNeeded() + } + + func applyDebugStatusIfNeeded() { + let enabled = self.debugStatusEnabled + let title = Self.jsOptionalStringLiteral(self.debugStatusTitle) + let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle) + let js = """ + (() => { + try { + const api = globalThis.__openclaw; + if (!api) return; + if (typeof api.setDebugStatusEnabled === 'function') { + api.setDebugStatusEnabled(\(enabled ? "true" : "false")); + } + if (!\(enabled ? "true" : "false")) return; + if (typeof api.setStatus === 'function') { + api.setStatus(\(title), \(subtitle)); + } + } catch (_) {} + })(); + """ + self.webView.evaluateJavaScript(js) { _, _ in } + } + + private func loadFile(_ url: URL) { + let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path) + let accessDir = fileURL.deletingLastPathComponent() + self.webView.loadFileURL(fileURL, allowingReadAccessTo: accessDir) + } + + func eval(javaScript: String) async throws -> String { + try await withCheckedThrowingContinuation { cont in + self.webView.evaluateJavaScript(javaScript) { result, error in + if let error { + cont.resume(throwing: error) + return + } + if let result { + cont.resume(returning: String(describing: result)) + } else { + cont.resume(returning: "") + } + } + } + } + + func snapshot(to outPath: String?) async throws -> String { + let image: NSImage = try await withCheckedThrowingContinuation { cont in + self.webView.takeSnapshot(with: nil) { image, error in + if let error { + cont.resume(throwing: error) + return + } + guard let image else { + cont.resume(throwing: NSError(domain: "Canvas", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "snapshot returned nil image", + ])) + return + } + cont.resume(returning: image) + } + } + + guard let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) + else { + throw NSError(domain: "Canvas", code: 12, userInfo: [ + NSLocalizedDescriptionKey: "failed to encode png", + ]) + } + + let path: String + if let outPath, !outPath.isEmpty { + path = outPath + } else { + let ts = Int(Date().timeIntervalSince1970) + path = "/tmp/openclaw-canvas-\(CanvasWindowController.sanitizeSessionKey(self.sessionKey))-\(ts).png" + } + + try png.write(to: URL(fileURLWithPath: path), options: [.atomic]) + return path + } + + var directoryPath: String { + self.sessionDir.path + } + + func shouldAutoNavigateToA2UI(lastAutoTarget: String?) -> Bool { + let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed == "/" { return true } + if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines), + !lastAuto.isEmpty, + trimmed == lastAuto + { + return true + } + return false + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelConfigForm.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelConfigForm.swift new file mode 100644 index 00000000..d00725be --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelConfigForm.swift @@ -0,0 +1,363 @@ +import SwiftUI + +struct ConfigSchemaForm: View { + @Bindable var store: ChannelsStore + let schema: ConfigSchemaNode + let path: ConfigPath + + var body: some View { + self.renderNode(self.schema, path: self.path) + } + + private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView { + let storedValue = self.store.configValue(at: path) + let value = storedValue ?? schema.explicitDefault + let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title + let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description + let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf + + if !variants.isEmpty { + let nonNull = variants.filter { !$0.isNullSchema } + if nonNull.count == 1, let only = nonNull.first { + return self.renderNode(only, path: path) + } + let literals = nonNull.compactMap(\.literalValue) + if !literals.isEmpty, literals.count == nonNull.count { + return AnyView( + VStack(alignment: .leading, spacing: 6) { + if let label { Text(label).font(.callout.weight(.semibold)) } + if let help { + Text(help) + .font(.caption) + .foregroundStyle(.secondary) + } + Picker( + "", + selection: self.enumBinding( + path, + options: literals, + defaultValue: schema.explicitDefault)) + { + Text("Select…").tag(-1) + ForEach(literals.indices, id: \ .self) { index in + Text(String(describing: literals[index])).tag(index) + } + } + .pickerStyle(.menu) + }) + } + } + + switch schema.schemaType { + case "object": + return AnyView( + VStack(alignment: .leading, spacing: 12) { + if let label { + Text(label) + .font(.callout.weight(.semibold)) + } + if let help { + Text(help) + .font(.caption) + .foregroundStyle(.secondary) + } + let properties = schema.properties + let sortedKeys = properties.keys.sorted { lhs, rhs in + let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0 + let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0 + if orderA != orderB { return orderA < orderB } + return lhs < rhs + } + ForEach(sortedKeys, id: \ .self) { key in + if let child = properties[key] { + self.renderNode(child, path: path + [.key(key)]) + } + } + if schema.allowsAdditionalProperties { + self.renderAdditionalProperties(schema, path: path, value: value) + } + }) + case "array": + return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help)) + case "boolean": + return AnyView( + Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) { + if let label { Text(label) } else { Text("Enabled") } + } + .help(help ?? "")) + case "number", "integer": + return AnyView(self.renderNumberField(schema, path: path, label: label, help: help)) + case "string": + return AnyView(self.renderStringField(schema, path: path, label: label, help: help)) + default: + return AnyView( + VStack(alignment: .leading, spacing: 6) { + if let label { Text(label).font(.callout.weight(.semibold)) } + Text("Unsupported field type.") + .font(.caption) + .foregroundStyle(.secondary) + }) + } + } + + @ViewBuilder + private func renderStringField( + _ schema: ConfigSchemaNode, + path: ConfigPath, + label: String?, + help: String?) -> some View + { + let hint = hintForPath(path, hints: store.configUiHints) + let placeholder = hint?.placeholder ?? "" + let sensitive = hint?.sensitive ?? isSensitivePath(path) + let defaultValue = schema.explicitDefault as? String + VStack(alignment: .leading, spacing: 6) { + if let label { Text(label).font(.callout.weight(.semibold)) } + if let help { + Text(help) + .font(.caption) + .foregroundStyle(.secondary) + } + if let options = schema.enumValues { + Picker("", selection: self.enumBinding(path, options: options, defaultValue: schema.explicitDefault)) { + Text("Select…").tag(-1) + ForEach(options.indices, id: \ .self) { index in + Text(String(describing: options[index])).tag(index) + } + } + .pickerStyle(.menu) + } else if sensitive { + SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue)) + .textFieldStyle(.roundedBorder) + } else { + TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue)) + .textFieldStyle(.roundedBorder) + } + } + } + + @ViewBuilder + private func renderNumberField( + _ schema: ConfigSchemaNode, + path: ConfigPath, + label: String?, + help: String?) -> some View + { + let defaultValue = (schema.explicitDefault as? Double) + ?? (schema.explicitDefault as? Int).map(Double.init) + VStack(alignment: .leading, spacing: 6) { + if let label { Text(label).font(.callout.weight(.semibold)) } + if let help { + Text(help) + .font(.caption) + .foregroundStyle(.secondary) + } + TextField( + "", + text: self.numberBinding( + path, + isInteger: schema.schemaType == "integer", + defaultValue: defaultValue)) + .textFieldStyle(.roundedBorder) + } + } + + @ViewBuilder + private func renderArray( + _ schema: ConfigSchemaNode, + path: ConfigPath, + value: Any?, + label: String?, + help: String?) -> some View + { + let items = value as? [Any] ?? [] + let itemSchema = schema.items + VStack(alignment: .leading, spacing: 10) { + if let label { Text(label).font(.callout.weight(.semibold)) } + if let help { + Text(help) + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(items.indices, id: \ .self) { index in + HStack(alignment: .top, spacing: 8) { + if let itemSchema { + self.renderNode(itemSchema, path: path + [.index(index)]) + } else { + Text(String(describing: items[index])) + } + Button("Remove") { + var next = items + next.remove(at: index) + self.store.updateConfigValue(path: path, value: next) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + Button("Add") { + var next = items + if let itemSchema { + next.append(itemSchema.defaultValue) + } else { + next.append("") + } + self.store.updateConfigValue(path: path, value: next) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + @ViewBuilder + private func renderAdditionalProperties( + _ schema: ConfigSchemaNode, + path: ConfigPath, + value: Any?) -> some View + { + if let additionalSchema = schema.additionalProperties { + let dict = value as? [String: Any] ?? [:] + let reserved = Set(schema.properties.keys) + let extras = dict.keys.filter { !reserved.contains($0) }.sorted() + + VStack(alignment: .leading, spacing: 8) { + Text("Extra entries") + .font(.callout.weight(.semibold)) + if extras.isEmpty { + Text("No extra entries yet.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(extras, id: \ .self) { key in + let itemPath: ConfigPath = path + [.key(key)] + HStack(alignment: .top, spacing: 8) { + TextField("Key", text: self.mapKeyBinding(path: path, key: key)) + .textFieldStyle(.roundedBorder) + .frame(width: 160) + self.renderNode(additionalSchema, path: itemPath) + Button("Remove") { + var next = dict + next.removeValue(forKey: key) + self.store.updateConfigValue(path: path, value: next) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + Button("Add") { + var next = dict + var index = 1 + var key = "new-\(index)" + while next[key] != nil { + index += 1 + key = "new-\(index)" + } + next[key] = additionalSchema.defaultValue + self.store.updateConfigValue(path: path, value: next) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + + private func stringBinding(_ path: ConfigPath, defaultValue: String?) -> Binding { + Binding( + get: { + if let value = store.configValue(at: path) as? String { return value } + return defaultValue ?? "" + }, + set: { newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed) + }) + } + + private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding { + Binding( + get: { + if let value = store.configValue(at: path) as? Bool { return value } + return defaultValue ?? false + }, + set: { newValue in + self.store.updateConfigValue(path: path, value: newValue) + }) + } + + private func numberBinding( + _ path: ConfigPath, + isInteger: Bool, + defaultValue: Double?) -> Binding + { + Binding( + get: { + if let value = store.configValue(at: path) { return String(describing: value) } + guard let defaultValue else { return "" } + return isInteger ? String(Int(defaultValue)) : String(defaultValue) + }, + set: { newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + self.store.updateConfigValue(path: path, value: nil) + } else if let value = Double(trimmed) { + self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value) + } + }) + } + + private func enumBinding( + _ path: ConfigPath, + options: [Any], + defaultValue: Any?) -> Binding + { + Binding( + get: { + let value = self.store.configValue(at: path) ?? defaultValue + guard let value else { return -1 } + return options.firstIndex { option in + String(describing: option) == String(describing: value) + } ?? -1 + }, + set: { index in + guard index >= 0, index < options.count else { + self.store.updateConfigValue(path: path, value: nil) + return + } + self.store.updateConfigValue(path: path, value: options[index]) + }) + } + + private func mapKeyBinding(path: ConfigPath, key: String) -> Binding { + Binding( + get: { key }, + set: { newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard trimmed != key else { return } + let current = self.store.configValue(at: path) as? [String: Any] ?? [:] + guard current[trimmed] == nil else { return } + var next = current + next[trimmed] = current[key] + next.removeValue(forKey: key) + self.store.updateConfigValue(path: path, value: next) + }) + } +} + +struct ChannelConfigForm: View { + @Bindable var store: ChannelsStore + let channelId: String + + var body: some View { + if self.store.configSchemaLoading { + ProgressView().controlSize(.small) + } else if let schema = store.channelConfigSchema(for: channelId) { + ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)]) + } else { + Text("Schema unavailable for this channel.") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelSections.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelSections.swift new file mode 100644 index 00000000..2bef47f2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelSections.swift @@ -0,0 +1,137 @@ +import SwiftUI + +extension ChannelsSettings { + func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View { + GroupBox(title) { + VStack(alignment: .leading, spacing: 10) { + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + func channelHeaderActions(_ channel: ChannelItem) -> some View { + HStack(spacing: 8) { + if channel.id == "whatsapp" { + Button("Logout") { + Task { await self.store.logoutWhatsApp() } + } + .buttonStyle(.bordered) + .disabled(self.store.whatsappBusy) + } + + if channel.id == "telegram" { + Button("Logout") { + Task { await self.store.logoutTelegram() } + } + .buttonStyle(.bordered) + .disabled(self.store.telegramBusy) + } + + Button { + Task { await self.store.refresh(probe: true) } + } label: { + if self.store.isRefreshing { + ProgressView().controlSize(.small) + } else { + Text("Refresh") + } + } + .buttonStyle(.bordered) + .disabled(self.store.isRefreshing) + } + .controlSize(.small) + } + + var whatsAppSection: some View { + VStack(alignment: .leading, spacing: 16) { + self.formSection("Linking") { + if let message = self.store.whatsappLoginMessage { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) { + Image(nsImage: image) + .resizable() + .interpolation(.none) + .frame(width: 180, height: 180) + .cornerRadius(8) + } + + HStack(spacing: 12) { + Button { + Task { await self.store.startWhatsAppLogin(force: false) } + } label: { + if self.store.whatsappBusy { + ProgressView().controlSize(.small) + } else { + Text("Show QR") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.store.whatsappBusy) + + Button("Relink") { + Task { await self.store.startWhatsAppLogin(force: true) } + } + .buttonStyle(.bordered) + .disabled(self.store.whatsappBusy) + } + .font(.caption) + } + + self.configEditorSection(channelId: "whatsapp") + } + } + + func genericChannelSection(_ channel: ChannelItem) -> some View { + VStack(alignment: .leading, spacing: 16) { + self.configEditorSection(channelId: channel.id) + } + } + + @ViewBuilder + private func configEditorSection(channelId: String) -> some View { + self.formSection("Configuration") { + ChannelConfigForm(store: self.store, channelId: channelId) + } + + self.configStatusMessage + + HStack(spacing: 12) { + Button { + Task { await self.store.saveConfigDraft() } + } label: { + if self.store.isSavingConfig { + ProgressView().controlSize(.small) + } else { + Text("Save") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.store.isSavingConfig || !self.store.configDirty) + + Button("Reload") { + Task { await self.store.reloadConfigDraft() } + } + .buttonStyle(.bordered) + .disabled(self.store.isSavingConfig) + + Spacer() + } + .font(.caption) + } + + @ViewBuilder + var configStatusMessage: some View { + if let status = self.store.configStatus { + Text(status) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift new file mode 100644 index 00000000..5be58184 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift @@ -0,0 +1,508 @@ +import OpenClawProtocol +import SwiftUI + +extension ChannelsSettings { + private func channelStatus( + _ id: String, + as type: T.Type) -> T? + { + self.store.snapshot?.decodeChannel(id, as: type) + } + + var whatsAppTint: Color { + guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) + else { return .secondary } + if !status.configured { return .secondary } + if !status.linked { return .red } + if status.lastError != nil { return .orange } + if status.connected { return .green } + if status.running { return .orange } + return .orange + } + + var telegramTint: Color { + guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) + else { return .secondary } + if !status.configured { return .secondary } + if status.lastError != nil { return .orange } + if status.probe?.ok == false { return .orange } + if status.running { return .green } + return .orange + } + + var discordTint: Color { + guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) + else { return .secondary } + if !status.configured { return .secondary } + if status.lastError != nil { return .orange } + if status.probe?.ok == false { return .orange } + if status.running { return .green } + return .orange + } + + var googlechatTint: Color { + guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self) + else { return .secondary } + if !status.configured { return .secondary } + if status.lastError != nil { return .orange } + if status.probe?.ok == false { return .orange } + if status.running { return .green } + return .orange + } + + var signalTint: Color { + guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) + else { return .secondary } + if !status.configured { return .secondary } + if status.lastError != nil { return .orange } + if status.probe?.ok == false { return .orange } + if status.running { return .green } + return .orange + } + + var imessageTint: Color { + guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) + else { return .secondary } + if !status.configured { return .secondary } + if status.lastError != nil { return .orange } + if status.probe?.ok == false { return .orange } + if status.running { return .green } + return .orange + } + + var whatsAppSummary: String { + guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) + else { return "Checking…" } + if !status.linked { return "Not linked" } + if status.connected { return "Connected" } + if status.running { return "Running" } + return "Linked" + } + + var telegramSummary: String { + guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) + else { return "Checking…" } + if !status.configured { return "Not configured" } + if status.running { return "Running" } + return "Configured" + } + + var discordSummary: String { + guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) + else { return "Checking…" } + if !status.configured { return "Not configured" } + if status.running { return "Running" } + return "Configured" + } + + var googlechatSummary: String { + guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self) + else { return "Checking…" } + if !status.configured { return "Not configured" } + if status.running { return "Running" } + return "Configured" + } + + var signalSummary: String { + guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) + else { return "Checking…" } + if !status.configured { return "Not configured" } + if status.running { return "Running" } + return "Configured" + } + + var imessageSummary: String { + guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) + else { return "Checking…" } + if !status.configured { return "Not configured" } + if status.running { return "Running" } + return "Configured" + } + + var whatsAppDetails: String? { + guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) + else { return nil } + var lines: [String] = [] + if let e164 = status.`self`?.e164 ?? status.`self`?.jid { + lines.append("Linked as \(e164)") + } + if let age = status.authAgeMs { + lines.append("Auth age \(msToAge(age))") + } + if let last = self.date(fromMs: status.lastConnectedAt) { + lines.append("Last connect \(relativeAge(from: last))") + } + if let disconnect = status.lastDisconnect { + let when = self.date(fromMs: disconnect.at).map { relativeAge(from: $0) } ?? "unknown" + let code = disconnect.status.map { "status \($0)" } ?? "status unknown" + let err = disconnect.error ?? "disconnect" + lines.append("Last disconnect \(code) · \(err) · \(when)") + } + if status.reconnectAttempts > 0 { + lines.append("Reconnect attempts \(status.reconnectAttempts)") + } + if let msgAt = self.date(fromMs: status.lastMessageAt) { + lines.append("Last message \(relativeAge(from: msgAt))") + } + if let err = status.lastError, !err.isEmpty { + lines.append("Error: \(err)") + } + return lines.isEmpty ? nil : lines.joined(separator: " · ") + } + + var telegramDetails: String? { + guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) + else { return nil } + var lines: [String] = [] + if let source = status.tokenSource { + lines.append("Token source: \(source)") + } + if let mode = status.mode { + lines.append("Mode: \(mode)") + } + if let probe = status.probe { + if probe.ok { + if let name = probe.bot?.username { + lines.append("Bot: @\(name)") + } + if let url = probe.webhook?.url, !url.isEmpty { + lines.append("Webhook: \(url)") + } + } else { + let code = probe.status.map { String($0) } ?? "unknown" + lines.append("Probe failed (\(code))") + } + } + if let last = self.date(fromMs: status.lastProbeAt) { + lines.append("Last probe \(relativeAge(from: last))") + } + if let err = status.lastError, !err.isEmpty { + lines.append("Error: \(err)") + } + return lines.isEmpty ? nil : lines.joined(separator: " · ") + } + + var discordDetails: String? { + guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) + else { return nil } + var lines: [String] = [] + if let source = status.tokenSource { + lines.append("Token source: \(source)") + } + if let probe = status.probe { + if probe.ok { + if let name = probe.bot?.username { + lines.append("Bot: @\(name)") + } + if let elapsed = probe.elapsedMs { + lines.append("Probe \(Int(elapsed))ms") + } + } else { + let code = probe.status.map { String($0) } ?? "unknown" + lines.append("Probe failed (\(code))") + } + } + if let last = self.date(fromMs: status.lastProbeAt) { + lines.append("Last probe \(relativeAge(from: last))") + } + if let err = status.lastError, !err.isEmpty { + lines.append("Error: \(err)") + } + return lines.isEmpty ? nil : lines.joined(separator: " · ") + } + + var googlechatDetails: String? { + guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self) + else { return nil } + var lines: [String] = [] + if let source = status.credentialSource { + lines.append("Credential: \(source)") + } + if let audienceType = status.audienceType { + let audience = status.audience ?? "" + let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)" + lines.append("Audience: \(label)") + } + if let probe = status.probe { + if probe.ok { + if let elapsed = probe.elapsedMs { + lines.append("Probe \(Int(elapsed))ms") + } + } else { + let code = probe.status.map { String($0) } ?? "unknown" + lines.append("Probe failed (\(code))") + } + } + if let last = self.date(fromMs: status.lastProbeAt) { + lines.append("Last probe \(relativeAge(from: last))") + } + if let err = status.lastError, !err.isEmpty { + lines.append("Error: \(err)") + } + return lines.isEmpty ? nil : lines.joined(separator: " · ") + } + + var signalDetails: String? { + guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) + else { return nil } + var lines: [String] = [] + lines.append("Base URL: \(status.baseUrl)") + if let probe = status.probe { + if probe.ok { + if let version = probe.version, !version.isEmpty { + lines.append("Version \(version)") + } + if let elapsed = probe.elapsedMs { + lines.append("Probe \(Int(elapsed))ms") + } + } else { + let code = probe.status.map { String($0) } ?? "unknown" + lines.append("Probe failed (\(code))") + } + } + if let last = self.date(fromMs: status.lastProbeAt) { + lines.append("Last probe \(relativeAge(from: last))") + } + if let err = status.lastError, !err.isEmpty { + lines.append("Error: \(err)") + } + return lines.isEmpty ? nil : lines.joined(separator: " · ") + } + + var imessageDetails: String? { + guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) + else { return nil } + var lines: [String] = [] + if let cliPath = status.cliPath, !cliPath.isEmpty { + lines.append("CLI: \(cliPath)") + } + if let dbPath = status.dbPath, !dbPath.isEmpty { + lines.append("DB: \(dbPath)") + } + if let probe = status.probe, !probe.ok { + let err = probe.error ?? "probe failed" + lines.append("Probe error: \(err)") + } + if let last = self.date(fromMs: status.lastProbeAt) { + lines.append("Last probe \(relativeAge(from: last))") + } + if let err = status.lastError, !err.isEmpty { + lines.append("Error: \(err)") + } + return lines.isEmpty ? nil : lines.joined(separator: " · ") + } + + var orderedChannels: [ChannelItem] { + let fallback = ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage"] + let order = self.store.snapshot?.channelOrder ?? fallback + let channels = order.enumerated().map { index, id in + ChannelItem( + id: id, + title: self.resolveChannelTitle(id), + detailTitle: self.resolveChannelDetailTitle(id), + systemImage: self.resolveChannelSystemImage(id), + sortOrder: index) + } + return channels.sorted { lhs, rhs in + let lhsEnabled = self.channelEnabled(lhs) + let rhsEnabled = self.channelEnabled(rhs) + if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled } + return lhs.sortOrder < rhs.sortOrder + } + } + + var enabledChannels: [ChannelItem] { + self.orderedChannels.filter { self.channelEnabled($0) } + } + + var availableChannels: [ChannelItem] { + self.orderedChannels.filter { !self.channelEnabled($0) } + } + + func ensureSelection() { + guard let selected = self.selectedChannel else { + self.selectedChannel = self.orderedChannels.first + return + } + if !self.orderedChannels.contains(selected) { + self.selectedChannel = self.orderedChannels.first + } + } + + func channelEnabled(_ channel: ChannelItem) -> Bool { + let status = self.channelStatusDictionary(channel.id) + let configured = status?["configured"]?.boolValue ?? false + let running = status?["running"]?.boolValue ?? false + let connected = status?["connected"]?.boolValue ?? false + let accountActive = self.store.snapshot?.channelAccounts[channel.id]?.contains( + where: { $0.configured == true || $0.running == true || $0.connected == true }) ?? false + return configured || running || connected || accountActive + } + + @ViewBuilder + func channelSection(_ channel: ChannelItem) -> some View { + if channel.id == "whatsapp" { + self.whatsAppSection + } else { + self.genericChannelSection(channel) + } + } + + func channelTint(_ channel: ChannelItem) -> Color { + switch channel.id { + case "whatsapp": + return self.whatsAppTint + case "telegram": + return self.telegramTint + case "discord": + return self.discordTint + case "googlechat": + return self.googlechatTint + case "signal": + return self.signalTint + case "imessage": + return self.imessageTint + default: + if self.channelHasError(channel) { return .orange } + if self.channelEnabled(channel) { return .green } + return .secondary + } + } + + func channelSummary(_ channel: ChannelItem) -> String { + switch channel.id { + case "whatsapp": + return self.whatsAppSummary + case "telegram": + return self.telegramSummary + case "discord": + return self.discordSummary + case "googlechat": + return self.googlechatSummary + case "signal": + return self.signalSummary + case "imessage": + return self.imessageSummary + default: + if self.channelHasError(channel) { return "Error" } + if self.channelEnabled(channel) { return "Active" } + return "Not configured" + } + } + + func channelDetails(_ channel: ChannelItem) -> String? { + switch channel.id { + case "whatsapp": + return self.whatsAppDetails + case "telegram": + return self.telegramDetails + case "discord": + return self.discordDetails + case "googlechat": + return self.googlechatDetails + case "signal": + return self.signalDetails + case "imessage": + return self.imessageDetails + default: + let status = self.channelStatusDictionary(channel.id) + if let err = status?["lastError"]?.stringValue, !err.isEmpty { + return "Error: \(err)" + } + return nil + } + } + + func channelLastCheckText(_ channel: ChannelItem) -> String { + guard let date = self.channelLastCheck(channel) else { return "never" } + return relativeAge(from: date) + } + + func channelLastCheck(_ channel: ChannelItem) -> Date? { + switch channel.id { + case "whatsapp": + guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) + else { return nil } + return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt) + case "telegram": + return self + .date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)? + .lastProbeAt) + case "discord": + return self + .date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)? + .lastProbeAt) + case "googlechat": + return self + .date(fromMs: self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)? + .lastProbeAt) + case "signal": + return self + .date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt) + case "imessage": + return self + .date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)? + .lastProbeAt) + default: + let status = self.channelStatusDictionary(channel.id) + if let probeAt = status?["lastProbeAt"]?.doubleValue { + return self.date(fromMs: probeAt) + } + if let accounts = self.store.snapshot?.channelAccounts[channel.id] { + let last = accounts.compactMap { $0.lastInboundAt ?? $0.lastOutboundAt }.max() + return self.date(fromMs: last) + } + return nil + } + } + + func channelHasError(_ channel: ChannelItem) -> Bool { + switch channel.id { + case "whatsapp": + guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self) + else { return false } + return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true + case "telegram": + guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self) + else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false + case "discord": + guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) + else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false + case "googlechat": + guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self) + else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false + case "signal": + guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) + else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false + case "imessage": + guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self) + else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false + default: + let status = self.channelStatusDictionary(channel.id) + return status?["lastError"]?.stringValue?.isEmpty == false + } + } + + private func resolveChannelTitle(_ id: String) -> String { + let label = self.store.resolveChannelLabel(id) + if label != id { return label } + return id.prefix(1).uppercased() + id.dropFirst() + } + + private func resolveChannelDetailTitle(_ id: String) -> String { + self.store.resolveChannelDetailLabel(id) + } + + private func resolveChannelSystemImage(_ id: String) -> String { + self.store.resolveChannelSystemImage(id) + } + + private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? { + self.store.snapshot?.channels[id]?.dictionaryValue + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsSettings+Helpers.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsSettings+Helpers.swift new file mode 100644 index 00000000..05b79ca0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsSettings+Helpers.swift @@ -0,0 +1,17 @@ +import AppKit + +extension ChannelsSettings { + func date(fromMs ms: Double?) -> Date? { + guard let ms else { return nil } + return Date(timeIntervalSince1970: ms / 1000) + } + + func qrImage(from dataUrl: String) -> NSImage? { + guard let comma = dataUrl.firstIndex(of: ",") else { return nil } + let header = dataUrl[.. some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 16) { + self.detailHeader(for: channel) + Divider() + self.channelSection(channel) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.vertical, 18) + } + } + + private func sidebarRow(_ channel: ChannelItem) -> some View { + let isSelected = self.selectedChannel == channel + return Button { + self.selectedChannel = channel + } label: { + HStack(spacing: 8) { + Circle() + .fill(self.channelTint(channel)) + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 2) { + Text(channel.title) + Text(self.channelSummary(channel)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background(Color.clear) // ensure full-width hit test area + .contentShape(Rectangle()) + } + .frame(maxWidth: .infinity, alignment: .leading) + .buttonStyle(.plain) + .contentShape(Rectangle()) + } + + private func sidebarSectionHeader(_ title: String) -> some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .padding(.horizontal, 4) + .padding(.top, 2) + } + + private func detailHeader(for channel: ChannelItem) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Label(channel.detailTitle, systemImage: channel.systemImage) + .font(.title3.weight(.semibold)) + self.statusBadge( + self.channelSummary(channel), + color: self.channelTint(channel)) + Spacer() + self.channelHeaderActions(channel) + } + + HStack(spacing: 10) { + Text("Last check \(self.channelLastCheckText(channel))") + .font(.caption) + .foregroundStyle(.secondary) + if self.channelHasError(channel) { + Text("Error") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.red.opacity(0.15)) + .foregroundStyle(.red) + .clipShape(Capsule()) + } + } + + if let details = self.channelDetails(channel) { + Text(details) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func statusBadge(_ text: String, color: Color) -> some View { + Text(text) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(color.opacity(0.16)) + .foregroundStyle(color) + .clipShape(Capsule()) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsSettings.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsSettings.swift new file mode 100644 index 00000000..b1177f00 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsSettings.swift @@ -0,0 +1,19 @@ +import AppKit +import SwiftUI + +struct ChannelsSettings: View { + struct ChannelItem: Identifiable, Hashable { + let id: String + let title: String + let detailTitle: String + let systemImage: String + let sortOrder: Int + } + + @Bindable var store: ChannelsStore + @State var selectedChannel: ChannelItem? + + init(store: ChannelsStore = .shared) { + self.store = store + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift new file mode 100644 index 00000000..703c7efe --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift @@ -0,0 +1,154 @@ +import Foundation +import OpenClawProtocol + +extension ChannelsStore { + func loadConfigSchema() async { + guard !self.configSchemaLoading else { return } + self.configSchemaLoading = true + defer { self.configSchemaLoading = false } + + do { + let res: ConfigSchemaResponse = try await GatewayConnection.shared.requestDecoded( + method: .configSchema, + params: nil, + timeoutMs: 8000) + let schemaValue = res.schema.foundationValue + self.configSchema = ConfigSchemaNode(raw: schemaValue) + let hintValues = res.uihints.mapValues { $0.foundationValue } + self.configUiHints = decodeUiHints(hintValues) + } catch { + self.configStatus = error.localizedDescription + } + } + + func loadConfig() async { + do { + let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded( + method: .configGet, + params: nil, + timeoutMs: 10000) + self.configStatus = snap.valid == false + ? "Config invalid; fix it in ~/.openclaw/openclaw.json." + : nil + self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:] + self.configDraft = cloneConfigValue(self.configRoot) as? [String: Any] ?? self.configRoot + self.configDirty = false + self.configLoaded = true + + self.applyUIConfig(snap) + } catch { + self.configStatus = error.localizedDescription + } + } + + private func applyUIConfig(_ snap: ConfigSnapshot) { + let ui = snap.config?["ui"]?.dictionaryValue + let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam + } + + func channelConfigSchema(for channelId: String) -> ConfigSchemaNode? { + guard let root = self.configSchema else { return nil } + return root.node(at: [.key("channels"), .key(channelId)]) + } + + func configValue(at path: ConfigPath) -> Any? { + if let value = valueAtPath(self.configDraft, path: path) { + return value + } + guard path.count >= 2 else { return nil } + if case .key("channels") = path[0], case .key = path[1] { + let fallbackPath = Array(path.dropFirst()) + return valueAtPath(self.configDraft, path: fallbackPath) + } + return nil + } + + func updateConfigValue(path: ConfigPath, value: Any?) { + var root: Any = self.configDraft + setValue(&root, path: path, value: value) + self.configDraft = root as? [String: Any] ?? self.configDraft + self.configDirty = true + } + + func saveConfigDraft() async { + guard !self.isSavingConfig else { return } + self.isSavingConfig = true + defer { self.isSavingConfig = false } + + do { + try await ConfigStore.save(self.configDraft) + await self.loadConfig() + } catch { + self.configStatus = error.localizedDescription + } + } + + func reloadConfigDraft() async { + await self.loadConfig() + } +} + +private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? { + var current: Any? = root + for segment in path { + switch segment { + case let .key(key): + guard let dict = current as? [String: Any] else { return nil } + current = dict[key] + case let .index(index): + guard let array = current as? [Any], array.indices.contains(index) else { return nil } + current = array[index] + } + } + return current +} + +private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) { + guard let segment = path.first else { return } + switch segment { + case let .key(key): + var dict = root as? [String: Any] ?? [:] + if path.count == 1 { + if let value { + dict[key] = value + } else { + dict.removeValue(forKey: key) + } + root = dict + return + } + var child = dict[key] ?? [:] + setValue(&child, path: Array(path.dropFirst()), value: value) + dict[key] = child + root = dict + case let .index(index): + var array = root as? [Any] ?? [] + if index >= array.count { + array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1)) + } + if path.count == 1 { + if let value { + array[index] = value + } else if array.indices.contains(index) { + array.remove(at: index) + } + root = array + return + } + var child = array[index] + setValue(&child, path: Array(path.dropFirst()), value: value) + array[index] = child + root = array + } +} + +private func cloneConfigValue(_ value: Any) -> Any { + guard JSONSerialization.isValidJSONObject(value) else { return value } + do { + let data = try JSONSerialization.data(withJSONObject: value, options: []) + return try JSONSerialization.jsonObject(with: data, options: []) + } catch { + return value + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift new file mode 100644 index 00000000..fd516480 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift @@ -0,0 +1,163 @@ +import Foundation +import OpenClawProtocol + +extension ChannelsStore { + func start() { + guard !self.isPreview else { return } + guard self.pollTask == nil else { return } + self.pollTask = Task.detached { [weak self] in + guard let self else { return } + await self.refresh(probe: true) + await self.loadConfigSchema() + await self.loadConfig() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) + await self.refresh(probe: false) + } + } + } + + func stop() { + self.pollTask?.cancel() + self.pollTask = nil + } + + func refresh(probe: Bool) async { + guard !self.isRefreshing else { return } + self.isRefreshing = true + defer { self.isRefreshing = false } + + do { + let params: [String: AnyCodable] = [ + "probe": AnyCodable(probe), + "timeoutMs": AnyCodable(8000), + ] + let snap: ChannelsStatusSnapshot = try await GatewayConnection.shared.requestDecoded( + method: .channelsStatus, + params: params, + timeoutMs: 12000) + self.snapshot = snap + self.lastSuccess = Date() + self.lastError = nil + } catch { + self.lastError = error.localizedDescription + } + } + + func startWhatsAppLogin(force: Bool, autoWait: Bool = true) async { + guard !self.whatsappBusy else { return } + self.whatsappBusy = true + defer { self.whatsappBusy = false } + var shouldAutoWait = false + do { + let params: [String: AnyCodable] = [ + "force": AnyCodable(force), + "timeoutMs": AnyCodable(30000), + ] + let result: WhatsAppLoginStartResult = try await GatewayConnection.shared.requestDecoded( + method: .webLoginStart, + params: params, + timeoutMs: 35000) + self.whatsappLoginMessage = result.message + self.whatsappLoginQrDataUrl = result.qrDataUrl + self.whatsappLoginConnected = nil + shouldAutoWait = autoWait && result.qrDataUrl != nil + } catch { + self.whatsappLoginMessage = error.localizedDescription + self.whatsappLoginQrDataUrl = nil + self.whatsappLoginConnected = nil + } + await self.refresh(probe: true) + if shouldAutoWait { + Task { await self.waitWhatsAppLogin() } + } + } + + func waitWhatsAppLogin(timeoutMs: Int = 120_000) async { + guard !self.whatsappBusy else { return } + self.whatsappBusy = true + defer { self.whatsappBusy = false } + do { + let params: [String: AnyCodable] = [ + "timeoutMs": AnyCodable(timeoutMs), + ] + let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded( + method: .webLoginWait, + params: params, + timeoutMs: Double(timeoutMs) + 5000) + self.whatsappLoginMessage = result.message + self.whatsappLoginConnected = result.connected + if result.connected { + self.whatsappLoginQrDataUrl = nil + } + } catch { + self.whatsappLoginMessage = error.localizedDescription + } + await self.refresh(probe: true) + } + + func logoutWhatsApp() async { + guard !self.whatsappBusy else { return } + self.whatsappBusy = true + defer { self.whatsappBusy = false } + do { + let params: [String: AnyCodable] = [ + "channel": AnyCodable("whatsapp"), + ] + let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded( + method: .channelsLogout, + params: params, + timeoutMs: 15000) + self.whatsappLoginMessage = result.cleared + ? "Logged out and cleared credentials." + : "No WhatsApp session found." + self.whatsappLoginQrDataUrl = nil + } catch { + self.whatsappLoginMessage = error.localizedDescription + } + await self.refresh(probe: true) + } + + func logoutTelegram() async { + guard !self.telegramBusy else { return } + self.telegramBusy = true + defer { self.telegramBusy = false } + do { + let params: [String: AnyCodable] = [ + "channel": AnyCodable("telegram"), + ] + let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded( + method: .channelsLogout, + params: params, + timeoutMs: 15000) + if result.envToken == true { + self.configStatus = "Telegram token still set via env; config cleared." + } else { + self.configStatus = result.cleared + ? "Telegram token cleared." + : "No Telegram token configured." + } + await self.loadConfig() + } catch { + self.configStatus = error.localizedDescription + } + await self.refresh(probe: true) + } +} + +private struct WhatsAppLoginStartResult: Codable { + let qrDataUrl: String? + let message: String +} + +private struct WhatsAppLoginWaitResult: Codable { + let connected: Bool + let message: String +} + +private struct ChannelLogoutResult: Codable { + let channel: String? + let accountId: String? + let cleared: Bool + let envToken: Bool? +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsStore.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsStore.swift new file mode 100644 index 00000000..09b9b75a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ChannelsStore.swift @@ -0,0 +1,296 @@ +import Foundation +import Observation +import OpenClawProtocol + +struct ChannelsStatusSnapshot: Codable { + struct WhatsAppSelf: Codable { + let e164: String? + let jid: String? + } + + struct WhatsAppDisconnect: Codable { + let at: Double + let status: Int? + let error: String? + let loggedOut: Bool? + } + + struct WhatsAppStatus: Codable { + let configured: Bool + let linked: Bool + let authAgeMs: Double? + let `self`: WhatsAppSelf? + let running: Bool + let connected: Bool + let lastConnectedAt: Double? + let lastDisconnect: WhatsAppDisconnect? + let reconnectAttempts: Int + let lastMessageAt: Double? + let lastEventAt: Double? + let lastError: String? + } + + struct TelegramBot: Codable { + let id: Int? + let username: String? + } + + struct TelegramWebhook: Codable { + let url: String? + let hasCustomCert: Bool? + } + + struct TelegramProbe: Codable { + let ok: Bool + let status: Int? + let error: String? + let elapsedMs: Double? + let bot: TelegramBot? + let webhook: TelegramWebhook? + } + + struct TelegramStatus: Codable { + let configured: Bool + let tokenSource: String? + let running: Bool + let mode: String? + let lastStartAt: Double? + let lastStopAt: Double? + let lastError: String? + let probe: TelegramProbe? + let lastProbeAt: Double? + } + + struct DiscordBot: Codable { + let id: String? + let username: String? + } + + struct DiscordProbe: Codable { + let ok: Bool + let status: Int? + let error: String? + let elapsedMs: Double? + let bot: DiscordBot? + } + + struct DiscordStatus: Codable { + let configured: Bool + let tokenSource: String? + let running: Bool + let lastStartAt: Double? + let lastStopAt: Double? + let lastError: String? + let probe: DiscordProbe? + let lastProbeAt: Double? + } + + struct GoogleChatProbe: Codable { + let ok: Bool + let status: Int? + let error: String? + let elapsedMs: Double? + } + + struct GoogleChatStatus: Codable { + let configured: Bool + let credentialSource: String? + let audienceType: String? + let audience: String? + let webhookPath: String? + let webhookUrl: String? + let running: Bool + let lastStartAt: Double? + let lastStopAt: Double? + let lastError: String? + let probe: GoogleChatProbe? + let lastProbeAt: Double? + } + + struct SignalProbe: Codable { + let ok: Bool + let status: Int? + let error: String? + let elapsedMs: Double? + let version: String? + } + + struct SignalStatus: Codable { + let configured: Bool + let baseUrl: String + let running: Bool + let lastStartAt: Double? + let lastStopAt: Double? + let lastError: String? + let probe: SignalProbe? + let lastProbeAt: Double? + } + + struct IMessageProbe: Codable { + let ok: Bool + let error: String? + } + + struct IMessageStatus: Codable { + let configured: Bool + let running: Bool + let lastStartAt: Double? + let lastStopAt: Double? + let lastError: String? + let cliPath: String? + let dbPath: String? + let probe: IMessageProbe? + let lastProbeAt: Double? + } + + struct ChannelAccountSnapshot: Codable { + let accountId: String + let name: String? + let enabled: Bool? + let configured: Bool? + let linked: Bool? + let running: Bool? + let connected: Bool? + let reconnectAttempts: Int? + let lastConnectedAt: Double? + let lastError: String? + let lastStartAt: Double? + let lastStopAt: Double? + let lastInboundAt: Double? + let lastOutboundAt: Double? + let lastProbeAt: Double? + let mode: String? + let dmPolicy: String? + let allowFrom: [String]? + let tokenSource: String? + let botTokenSource: String? + let appTokenSource: String? + let baseUrl: String? + let allowUnmentionedGroups: Bool? + let cliPath: String? + let dbPath: String? + let port: Int? + let probe: AnyCodable? + let audit: AnyCodable? + let application: AnyCodable? + } + + struct ChannelUiMetaEntry: Codable { + let id: String + let label: String + let detailLabel: String + let systemImage: String? + } + + let ts: Double + let channelOrder: [String] + let channelLabels: [String: String] + let channelDetailLabels: [String: String]? + let channelSystemImages: [String: String]? + let channelMeta: [ChannelUiMetaEntry]? + let channels: [String: AnyCodable] + let channelAccounts: [String: [ChannelAccountSnapshot]] + let channelDefaultAccountId: [String: String] + + func decodeChannel(_ id: String, as type: T.Type) -> T? { + guard let value = self.channels[id] else { return nil } + do { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(type, from: data) + } catch { + return nil + } + } +} + +struct ConfigSnapshot: Codable { + struct Issue: Codable { + let path: String + let message: String + } + + let path: String? + let exists: Bool? + let raw: String? + let hash: String? + let parsed: AnyCodable? + let valid: Bool? + let config: [String: AnyCodable]? + let issues: [Issue]? +} + +@MainActor +@Observable +final class ChannelsStore { + static let shared = ChannelsStore() + + var snapshot: ChannelsStatusSnapshot? + var lastError: String? + var lastSuccess: Date? + var isRefreshing = false + + var whatsappLoginMessage: String? + var whatsappLoginQrDataUrl: String? + var whatsappLoginConnected: Bool? + var whatsappBusy = false + var telegramBusy = false + + var configStatus: String? + var isSavingConfig = false + var configSchemaLoading = false + var configSchema: ConfigSchemaNode? + var configUiHints: [String: ConfigUiHint] = [:] + var configDraft: [String: Any] = [:] + var configDirty = false + + let interval: TimeInterval = 45 + let isPreview: Bool + var pollTask: Task? + var configRoot: [String: Any] = [:] + var configLoaded = false + + func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? { + self.snapshot?.channelMeta?.first(where: { $0.id == id }) + } + + func resolveChannelLabel(_ id: String) -> String { + if let meta = self.channelMetaEntry(id), !meta.label.isEmpty { + return meta.label + } + if let label = self.snapshot?.channelLabels[id], !label.isEmpty { + return label + } + return id + } + + func resolveChannelDetailLabel(_ id: String) -> String { + if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty { + return meta.detailLabel + } + if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty { + return detail + } + return self.resolveChannelLabel(id) + } + + func resolveChannelSystemImage(_ id: String) -> String { + if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty { + return symbol + } + if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty { + return symbol + } + return "message" + } + + func orderedChannelIds() -> [String] { + if let meta = self.snapshot?.channelMeta, !meta.isEmpty { + return meta.map(\.id) + } + return self.snapshot?.channelOrder ?? [] + } + + init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { + self.isPreview = isPreview + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift new file mode 100644 index 00000000..f9e38d81 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift @@ -0,0 +1,110 @@ +import CoreServices +import Foundation + +final class CoalescingFSEventsWatcher: @unchecked Sendable { + private let queue: DispatchQueue + private var stream: FSEventStreamRef? + private var pending = false + + private let paths: [String] + private let shouldNotify: (Int, UnsafeMutableRawPointer?) -> Bool + private let onChange: () -> Void + private let coalesceDelay: TimeInterval + + init( + paths: [String], + queueLabel: String, + coalesceDelay: TimeInterval = 0.12, + shouldNotify: @escaping (Int, UnsafeMutableRawPointer?) -> Bool = { _, _ in true }, + onChange: @escaping () -> Void) + { + self.paths = paths + self.queue = DispatchQueue(label: queueLabel) + self.coalesceDelay = coalesceDelay + self.shouldNotify = shouldNotify + self.onChange = onChange + } + + deinit { + self.stop() + } + + func start() { + guard self.stream == nil else { return } + + let retainedSelf = Unmanaged.passRetained(self) + var context = FSEventStreamContext( + version: 0, + info: retainedSelf.toOpaque(), + retain: nil, + release: { pointer in + guard let pointer else { return } + Unmanaged.fromOpaque(pointer).release() + }, + copyDescription: nil) + + let paths = self.paths as CFArray + let flags = FSEventStreamCreateFlags( + kFSEventStreamCreateFlagFileEvents | + kFSEventStreamCreateFlagUseCFTypes | + kFSEventStreamCreateFlagNoDefer) + + guard let stream = FSEventStreamCreate( + kCFAllocatorDefault, + Self.callback, + &context, + paths, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 0.05, + flags) + else { + retainedSelf.release() + return + } + + self.stream = stream + FSEventStreamSetDispatchQueue(stream, self.queue) + if FSEventStreamStart(stream) == false { + self.stream = nil + FSEventStreamSetDispatchQueue(stream, nil) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } + } + + func stop() { + guard let stream = self.stream else { return } + self.stream = nil + FSEventStreamStop(stream) + FSEventStreamSetDispatchQueue(stream, nil) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } +} + +extension CoalescingFSEventsWatcher { + private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in + guard let info else { return } + let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() + watcher.handleEvents(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags) + } + + private func handleEvents( + numEvents: Int, + eventPaths: UnsafeMutableRawPointer?, + eventFlags: UnsafePointer?) + { + guard numEvents > 0 else { return } + guard eventFlags != nil else { return } + guard self.shouldNotify(numEvents, eventPaths) else { return } + + // Coalesce rapid changes (common during builds/atomic saves). + if self.pending { return } + self.pending = true + self.queue.asyncAfter(deadline: .now() + self.coalesceDelay) { [weak self] in + guard let self else { return } + self.pending = false + self.onChange() + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CommandResolver.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CommandResolver.swift new file mode 100644 index 00000000..cacfac2f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CommandResolver.swift @@ -0,0 +1,578 @@ +import Foundation + +enum CommandResolver { + private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath" + private static let helperName = "openclaw" + + static func gatewayEntrypoint(in root: URL) -> String? { + let distEntry = root.appendingPathComponent("dist/index.js").path + if FileManager().isReadableFile(atPath: distEntry) { return distEntry } + let openclawEntry = root.appendingPathComponent("openclaw.mjs").path + if FileManager().isReadableFile(atPath: openclawEntry) { return openclawEntry } + let binEntry = root.appendingPathComponent("bin/openclaw.js").path + if FileManager().isReadableFile(atPath: binEntry) { return binEntry } + return nil + } + + static func runtimeResolution() -> Result { + RuntimeLocator.resolve(searchPaths: self.preferredPaths()) + } + + static func runtimeResolution(searchPaths: [String]?) -> Result { + RuntimeLocator.resolve(searchPaths: searchPaths ?? self.preferredPaths()) + } + + static func makeRuntimeCommand( + runtime: RuntimeResolution, + entrypoint: String, + subcommand: String, + extraArgs: [String]) -> [String] + { + [runtime.path, entrypoint, subcommand] + extraArgs + } + + static func runtimeErrorCommand(_ error: RuntimeResolutionError) -> [String] { + let message = RuntimeLocator.describeFailure(error) + return self.errorCommand(with: message) + } + + static func errorCommand(with message: String) -> [String] { + let script = """ + cat <<'__OPENCLAW_ERR__' >&2 + \(message) + __OPENCLAW_ERR__ + exit 1 + """ + return ["/bin/sh", "-c", script] + } + + static func projectRoot() -> URL { + if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey), + let url = self.expandPath(stored), + FileManager().fileExists(atPath: url.path) + { + return url + } + let fallback = FileManager().homeDirectoryForCurrentUser + .appendingPathComponent("Projects/openclaw") + if FileManager().fileExists(atPath: fallback.path) { + return fallback + } + return FileManager().homeDirectoryForCurrentUser + } + + static func setProjectRoot(_ path: String) { + UserDefaults.standard.set(path, forKey: self.projectRootDefaultsKey) + } + + static func projectRootPath() -> String { + self.projectRoot().path + } + + static func preferredPaths() -> [String] { + let current = ProcessInfo.processInfo.environment["PATH"]? + .split(separator: ":").map(String.init) ?? [] + let home = FileManager().homeDirectoryForCurrentUser + let projectRoot = self.projectRoot() + return self.preferredPaths(home: home, current: current, projectRoot: projectRoot) + } + + static func preferredPaths(home: URL, current: [String], projectRoot: URL) -> [String] { + var extras = [ + home.appendingPathComponent("Library/pnpm").path, + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + ] + #if DEBUG + // Dev-only convenience. Avoid project-local PATH hijacking in release builds. + extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0) + #endif + let openclawPaths = self.openclawManagedPaths(home: home) + if !openclawPaths.isEmpty { + extras.insert(contentsOf: openclawPaths, at: 1) + } + extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1 + openclawPaths.count) + var seen = Set() + // Preserve order while stripping duplicates so PATH lookups remain deterministic. + return (extras + current).filter { seen.insert($0).inserted } + } + + private static func openclawManagedPaths(home: URL) -> [String] { + let bases = [ + home.appendingPathComponent(".openclaw"), + ] + var paths: [String] = [] + for base in bases { + let bin = base.appendingPathComponent("bin") + let nodeBin = base.appendingPathComponent("tools/node/bin") + if FileManager().fileExists(atPath: bin.path) { + paths.append(bin.path) + } + if FileManager().fileExists(atPath: nodeBin.path) { + paths.append(nodeBin.path) + } + } + return paths + } + + private static func nodeManagerBinPaths(home: URL) -> [String] { + var bins: [String] = [] + + // Volta + let volta = home.appendingPathComponent(".volta/bin") + if FileManager().fileExists(atPath: volta.path) { + bins.append(volta.path) + } + + // asdf + let asdf = home.appendingPathComponent(".asdf/shims") + if FileManager().fileExists(atPath: asdf.path) { + bins.append(asdf.path) + } + + // fnm + bins.append(contentsOf: self.versionedNodeBinPaths( + base: home.appendingPathComponent(".local/share/fnm/node-versions"), + suffix: "installation/bin")) + + // nvm + bins.append(contentsOf: self.versionedNodeBinPaths( + base: home.appendingPathComponent(".nvm/versions/node"), + suffix: "bin")) + + return bins + } + + private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] { + guard FileManager().fileExists(atPath: base.path) else { return [] } + let entries: [String] + do { + entries = try FileManager().contentsOfDirectory(atPath: base.path) + } catch { + return [] + } + + func parseVersion(_ name: String) -> [Int] { + let trimmed = name.hasPrefix("v") ? String(name.dropFirst()) : name + return trimmed.split(separator: ".").compactMap { Int($0) } + } + + let sorted = entries.sorted { a, b in + let va = parseVersion(a) + let vb = parseVersion(b) + let maxCount = max(va.count, vb.count) + for i in 0.. bi } + } + // If identical numerically, keep stable ordering. + return a > b + } + + var paths: [String] = [] + for entry in sorted { + let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix) + let node = binDir.appendingPathComponent("node") + if FileManager().isExecutableFile(atPath: node.path) { + paths.append(binDir.path) + } + } + return paths + } + + static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? { + for dir in searchPaths ?? self.preferredPaths() { + let candidate = (dir as NSString).appendingPathComponent(name) + if FileManager().isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + static func openclawExecutable(searchPaths: [String]? = nil) -> String? { + self.findExecutable(named: self.helperName, searchPaths: searchPaths) + } + + static func projectOpenClawExecutable(projectRoot: URL? = nil) -> String? { + #if DEBUG + let root = projectRoot ?? self.projectRoot() + let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path + return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil + #else + return nil + #endif + } + + static func nodeCliPath() -> String? { + let root = self.projectRoot() + let candidates = [ + root.appendingPathComponent("openclaw.mjs").path, + root.appendingPathComponent("bin/openclaw.js").path, + ] + for candidate in candidates where FileManager().isReadableFile(atPath: candidate) { + return candidate + } + return nil + } + + static func hasAnyOpenClawInvoker(searchPaths: [String]? = nil) -> Bool { + if self.openclawExecutable(searchPaths: searchPaths) != nil { return true } + if self.findExecutable(named: "pnpm", searchPaths: searchPaths) != nil { return true } + if self.findExecutable(named: "node", searchPaths: searchPaths) != nil, + self.nodeCliPath() != nil + { + return true + } + return false + } + + static func openclawNodeCommand( + subcommand: String, + extraArgs: [String] = [], + defaults: UserDefaults = .standard, + configRoot: [String: Any]? = nil, + searchPaths: [String]? = nil) -> [String] + { + let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot) + if settings.mode == .remote, let ssh = self.sshNodeCommand( + subcommand: subcommand, + extraArgs: extraArgs, + settings: settings) + { + return ssh + } + + let root = self.projectRoot() + if let openclawPath = self.projectOpenClawExecutable(projectRoot: root) { + return [openclawPath, subcommand] + extraArgs + } + if let openclawPath = self.openclawExecutable(searchPaths: searchPaths) { + return [openclawPath, subcommand] + extraArgs + } + + let runtimeResult = self.runtimeResolution(searchPaths: searchPaths) + switch runtimeResult { + case let .success(runtime): + if let entry = self.gatewayEntrypoint(in: root) { + return self.makeRuntimeCommand( + runtime: runtime, + entrypoint: entry, + subcommand: subcommand, + extraArgs: extraArgs) + } + case .failure: + break + } + + if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) { + // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. + return [pnpm, "--silent", "openclaw", subcommand] + extraArgs + } + + switch runtimeResult { + case .success: + let missingEntry = """ + openclaw entrypoint missing (looked for dist/index.js or openclaw.mjs); run pnpm build. + """ + return self.errorCommand(with: missingEntry) + case let .failure(error): + return self.runtimeErrorCommand(error) + } + } + + static func openclawCommand( + subcommand: String, + extraArgs: [String] = [], + defaults: UserDefaults = .standard, + configRoot: [String: Any]? = nil, + searchPaths: [String]? = nil) -> [String] + { + self.openclawNodeCommand( + subcommand: subcommand, + extraArgs: extraArgs, + defaults: defaults, + configRoot: configRoot, + searchPaths: searchPaths) + } + + // MARK: - SSH helpers + + private static func sshNodeCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? { + guard !settings.target.isEmpty else { return nil } + guard let parsed = self.parseSSHTarget(settings.target) else { return nil } + + // Run the real openclaw CLI on the remote host. + let exportedPath = [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + "$HOME/Library/pnpm", + "$PATH", + ].joined(separator: ":") + let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ") + let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines) + let userCLI = settings.cliPath.trimmingCharacters(in: .whitespacesAndNewlines) + + let projectSection = if userPRJ.isEmpty { + """ + DEFAULT_PRJ="$HOME/Projects/openclaw" + if [ -d "$DEFAULT_PRJ" ]; then + PRJ="$DEFAULT_PRJ" + cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; } + fi + """ + } else { + """ + PRJ=\(self.shellQuote(userPRJ)) + cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; } + """ + } + + let cliSection = if userCLI.isEmpty { + "" + } else { + """ + CLI_HINT=\(self.shellQuote(userCLI)) + if [ -n "$CLI_HINT" ]; then + if [ -x "$CLI_HINT" ]; then + CLI="$CLI_HINT" + "$CLI_HINT" \(quotedArgs); + exit $?; + elif [ -f "$CLI_HINT" ]; then + if command -v node >/dev/null 2>&1; then + CLI="node $CLI_HINT" + node "$CLI_HINT" \(quotedArgs); + exit $?; + fi + fi + fi + """ + } + + let scriptBody = """ + PATH=\(exportedPath); + CLI=""; + \(cliSection) + \(projectSection) + if command -v openclaw >/dev/null 2>&1; then + CLI="$(command -v openclaw)" + openclaw \(quotedArgs); + elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/dist/index.js" ]; then + if command -v node >/dev/null 2>&1; then + CLI="node $PRJ/dist/index.js" + node "$PRJ/dist/index.js" \(quotedArgs); + else + echo "Node >=22 required on remote host"; exit 127; + fi + elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/openclaw.mjs" ]; then + if command -v node >/dev/null 2>&1; then + CLI="node $PRJ/openclaw.mjs" + node "$PRJ/openclaw.mjs" \(quotedArgs); + else + echo "Node >=22 required on remote host"; exit 127; + fi + elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/bin/openclaw.js" ]; then + if command -v node >/dev/null 2>&1; then + CLI="node $PRJ/bin/openclaw.js" + node "$PRJ/bin/openclaw.js" \(quotedArgs); + else + echo "Node >=22 required on remote host"; exit 127; + fi + elif command -v pnpm >/dev/null 2>&1; then + CLI="pnpm --silent openclaw" + pnpm --silent openclaw \(quotedArgs); + else + echo "openclaw CLI missing on remote host"; exit 127; + fi + """ + let options: [String] = [ + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "UpdateHostKeys=yes", + ] + let args = self.sshArguments( + target: parsed, + identity: settings.identity, + options: options, + remoteCommand: ["/bin/sh", "-c", scriptBody]) + return ["/usr/bin/ssh"] + args + } + + struct RemoteSettings { + let mode: AppState.ConnectionMode + let target: String + let identity: String + let projectRoot: String + let cliPath: String + } + + static func connectionSettings( + defaults: UserDefaults = .standard, + configRoot: [String: Any]? = nil) -> RemoteSettings + { + let root = configRoot ?? OpenClawConfigFile.loadDict() + let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode + let target = defaults.string(forKey: remoteTargetKey) ?? "" + let identity = defaults.string(forKey: remoteIdentityKey) ?? "" + let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? "" + let cliPath = defaults.string(forKey: remoteCliPathKey) ?? "" + return RemoteSettings( + mode: mode, + target: self.sanitizedTarget(target), + identity: identity, + projectRoot: projectRoot, + cliPath: cliPath) + } + + static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool { + self.connectionSettings(defaults: defaults).mode == .remote + } + + private static func sanitizedTarget(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("ssh ") { + return trimmed.replacingOccurrences(of: "ssh ", with: "").trimmingCharacters(in: .whitespacesAndNewlines) + } + return trimmed + } + + struct SSHParsedTarget { + let user: String? + let host: String + let port: Int + } + + static func parseSSHTarget(_ target: String) -> SSHParsedTarget? { + let trimmed = self.normalizeSSHTargetInput(target) + guard !trimmed.isEmpty else { return nil } + if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil { + return nil + } + let userHostPort: String + let user: String? + if let atRange = trimmed.range(of: "@") { + user = String(trimmed[.. 0, parsedPort <= 65535 else { + return nil + } + port = parsedPort + } else { + host = userHostPort + port = 22 + } + + return self.makeSSHTarget(user: user, host: host, port: port) + } + + static func sshTargetValidationMessage(_ target: String) -> String? { + let trimmed = self.normalizeSSHTargetInput(target) + guard !trimmed.isEmpty else { return nil } + if trimmed.hasPrefix("-") { + return "SSH target cannot start with '-'" + } + if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil { + return "SSH target cannot contain spaces" + } + if self.parseSSHTarget(trimmed) == nil { + return "SSH target must look like user@host[:port]" + } + return nil + } + + private static func shellQuote(_ text: String) -> String { + if text.isEmpty { return "''" } + let escaped = text.replacingOccurrences(of: "'", with: "'\\''") + return "'\(escaped)'" + } + + private static func expandPath(_ path: String) -> URL? { + var expanded = path + if expanded.hasPrefix("~") { + let home = FileManager().homeDirectoryForCurrentUser.path + expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home) + } + return URL(fileURLWithPath: expanded) + } + + private static func normalizeSSHTargetInput(_ target: String) -> String { + var trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("ssh ") { + trimmed = trimmed.replacingOccurrences(of: "ssh ", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + return trimmed + } + + private static func isValidSSHComponent(_ value: String, allowLeadingDash: Bool = false) -> Bool { + if value.isEmpty { return false } + if !allowLeadingDash, value.hasPrefix("-") { return false } + let invalid = CharacterSet.whitespacesAndNewlines.union(.controlCharacters) + return value.rangeOfCharacter(from: invalid) == nil + } + + static func makeSSHTarget(user: String?, host: String, port: Int) -> SSHParsedTarget? { + let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard self.isValidSSHComponent(trimmedHost) else { return nil } + let trimmedUser = user?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedUser: String? + if let trimmedUser { + guard self.isValidSSHComponent(trimmedUser) else { return nil } + normalizedUser = trimmedUser.isEmpty ? nil : trimmedUser + } else { + normalizedUser = nil + } + guard port > 0, port <= 65535 else { return nil } + return SSHParsedTarget(user: normalizedUser, host: trimmedHost, port: port) + } + + private static func sshTargetString(_ target: SSHParsedTarget) -> String { + target.user.map { "\($0)@\(target.host)" } ?? target.host + } + + static func sshArguments( + target: SSHParsedTarget, + identity: String, + options: [String], + remoteCommand: [String] = []) -> [String] + { + var args = options + if target.port > 0 { + args.append(contentsOf: ["-p", String(target.port)]) + } + let trimmedIdentity = identity.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedIdentity.isEmpty { + // Only use IdentitiesOnly when an explicit identity file is provided. + // This allows 1Password SSH agent and other SSH agents to provide keys. + args.append(contentsOf: ["-o", "IdentitiesOnly=yes"]) + args.append(contentsOf: ["-i", trimmedIdentity]) + } + args.append("--") + args.append(self.sshTargetString(target)) + args.append(contentsOf: remoteCommand) + return args + } + + #if SWIFT_PACKAGE + static func _testNodeManagerBinPaths(home: URL) -> [String] { + self.nodeManagerBinPaths(home: home) + } + #endif +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift new file mode 100644 index 00000000..44344434 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift @@ -0,0 +1,45 @@ +import Foundation + +final class ConfigFileWatcher: @unchecked Sendable { + private let url: URL + private let watchedDir: URL + private let targetPath: String + private let targetName: String + private let watcher: CoalescingFSEventsWatcher + + init(url: URL, onChange: @escaping () -> Void) { + self.url = url + self.watchedDir = url.deletingLastPathComponent() + self.targetPath = url.path + self.targetName = url.lastPathComponent + let watchedDirPath = self.watchedDir.path + let targetPath = self.targetPath + let targetName = self.targetName + self.watcher = CoalescingFSEventsWatcher( + paths: [watchedDirPath], + queueLabel: "ai.openclaw.configwatcher", + shouldNotify: { _, eventPaths in + guard let eventPaths else { return true } + let paths = unsafeBitCast(eventPaths, to: NSArray.self) + for case let path as String in paths { + if path == targetPath { return true } + if path.hasSuffix("/\(targetName)") { return true } + if path == watchedDirPath { return true } + } + return false + }, + onChange: onChange) + } + + deinit { + self.stop() + } + + func start() { + self.watcher.start() + } + + func stop() { + self.watcher.stop() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift new file mode 100644 index 00000000..406d908d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConfigSchemaSupport.swift @@ -0,0 +1,219 @@ +import Foundation + +enum ConfigPathSegment: Hashable { + case key(String) + case index(Int) +} + +typealias ConfigPath = [ConfigPathSegment] + +struct ConfigUiHint { + let label: String? + let help: String? + let order: Double? + let advanced: Bool? + let sensitive: Bool? + let placeholder: String? + + init(raw: [String: Any]) { + self.label = raw["label"] as? String + self.help = raw["help"] as? String + if let order = raw["order"] as? Double { + self.order = order + } else if let orderInt = raw["order"] as? Int { + self.order = Double(orderInt) + } else { + self.order = nil + } + self.advanced = raw["advanced"] as? Bool + self.sensitive = raw["sensitive"] as? Bool + self.placeholder = raw["placeholder"] as? String + } +} + +struct ConfigSchemaNode { + let raw: [String: Any] + + init?(raw: Any) { + guard let dict = raw as? [String: Any] else { return nil } + self.raw = dict + } + + var title: String? { + self.raw["title"] as? String + } + + var description: String? { + self.raw["description"] as? String + } + + var enumValues: [Any]? { + self.raw["enum"] as? [Any] + } + + var constValue: Any? { + self.raw["const"] + } + + var explicitDefault: Any? { + self.raw["default"] + } + + var requiredKeys: Set { + Set((self.raw["required"] as? [String]) ?? []) + } + + var typeList: [String] { + if let type = self.raw["type"] as? String { return [type] } + if let types = self.raw["type"] as? [String] { return types } + return [] + } + + var schemaType: String? { + let filtered = self.typeList.filter { $0 != "null" } + if let first = filtered.first { return first } + return self.typeList.first + } + + var isNullSchema: Bool { + let types = self.typeList + return types.count == 1 && types.first == "null" + } + + var properties: [String: ConfigSchemaNode] { + guard let props = self.raw["properties"] as? [String: Any] else { return [:] } + return props.compactMapValues { ConfigSchemaNode(raw: $0) } + } + + var anyOf: [ConfigSchemaNode] { + guard let raw = self.raw["anyOf"] as? [Any] else { return [] } + return raw.compactMap { ConfigSchemaNode(raw: $0) } + } + + var oneOf: [ConfigSchemaNode] { + guard let raw = self.raw["oneOf"] as? [Any] else { return [] } + return raw.compactMap { ConfigSchemaNode(raw: $0) } + } + + var literalValue: Any? { + if let constValue { return constValue } + if let enumValues, enumValues.count == 1 { return enumValues[0] } + return nil + } + + var items: ConfigSchemaNode? { + if let items = self.raw["items"] as? [Any], let first = items.first { + return ConfigSchemaNode(raw: first) + } + if let items = self.raw["items"] { + return ConfigSchemaNode(raw: items) + } + return nil + } + + var additionalProperties: ConfigSchemaNode? { + if let additional = self.raw["additionalProperties"] as? [String: Any] { + return ConfigSchemaNode(raw: additional) + } + return nil + } + + var allowsAdditionalProperties: Bool { + if let allow = self.raw["additionalProperties"] as? Bool { return allow } + return self.additionalProperties != nil + } + + var defaultValue: Any { + if let value = self.raw["default"] { return value } + switch self.schemaType { + case "object": + return [String: Any]() + case "array": + return [Any]() + case "boolean": + return false + case "integer": + return 0 + case "number": + return 0.0 + case "string": + return "" + default: + return "" + } + } + + func node(at path: ConfigPath) -> ConfigSchemaNode? { + var current: ConfigSchemaNode? = self + for segment in path { + guard let node = current else { return nil } + switch segment { + case let .key(key): + if node.schemaType == "object" { + if let next = node.properties[key] { + current = next + continue + } + if let additional = node.additionalProperties { + current = additional + continue + } + return nil + } + return nil + case .index: + guard node.schemaType == "array" else { return nil } + current = node.items + } + } + return current + } +} + +func decodeUiHints(_ raw: [String: Any]) -> [String: ConfigUiHint] { + raw.reduce(into: [:]) { result, entry in + if let hint = entry.value as? [String: Any] { + result[entry.key] = ConfigUiHint(raw: hint) + } + } +} + +func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiHint? { + let key = pathKey(path) + if let direct = hints[key] { return direct } + let segments = key.split(separator: ".").map(String.init) + for (hintKey, hint) in hints { + guard hintKey.contains("*") else { continue } + let hintSegments = hintKey.split(separator: ".").map(String.init) + guard hintSegments.count == segments.count else { continue } + var match = true + for (index, seg) in segments.enumerated() { + let hintSegment = hintSegments[index] + if hintSegment != "*", hintSegment != seg { + match = false + break + } + } + if match { return hint } + } + return nil +} + +func isSensitivePath(_ path: ConfigPath) -> Bool { + let key = pathKey(path).lowercased() + return key.contains("token") + || key.contains("password") + || key.contains("secret") + || key.contains("apikey") + || key.hasSuffix("key") +} + +func pathKey(_ path: ConfigPath) -> String { + path.compactMap { segment -> String? in + switch segment { + case let .key(key): return key + case .index: return nil + } + } + .joined(separator: ".") +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConfigSettings.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConfigSettings.swift new file mode 100644 index 00000000..096ae3f7 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConfigSettings.swift @@ -0,0 +1,395 @@ +import SwiftUI + +@MainActor +struct ConfigSettings: View { + private let isPreview = ProcessInfo.processInfo.isPreview + private let isNixMode = ProcessInfo.processInfo.isNixMode + @Bindable var store: ChannelsStore + @State private var hasLoaded = false + @State private var activeSectionKey: String? + @State private var activeSubsection: SubsectionSelection? + + init(store: ChannelsStore = .shared) { + self.store = store + } + + var body: some View { + HStack(spacing: 16) { + self.sidebar + self.detail + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .task { + guard !self.hasLoaded else { return } + guard !self.isPreview else { return } + self.hasLoaded = true + await self.store.loadConfigSchema() + await self.store.loadConfig() + } + .onAppear { self.ensureSelection() } + .onChange(of: self.store.configSchemaLoading) { _, loading in + if !loading { self.ensureSelection() } + } + } +} + +extension ConfigSettings { + private enum SubsectionSelection: Hashable { + case all + case key(String) + } + + private struct ConfigSection: Identifiable { + let key: String + let label: String + let help: String? + let node: ConfigSchemaNode + + var id: String { + self.key + } + } + + private struct ConfigSubsection: Identifiable { + let key: String + let label: String + let help: String? + let node: ConfigSchemaNode + let path: ConfigPath + + var id: String { + self.key + } + } + + private var sections: [ConfigSection] { + guard let schema = self.store.configSchema else { return [] } + return self.resolveSections(schema) + } + + private var activeSection: ConfigSection? { + self.sections.first { $0.key == self.activeSectionKey } + } + + private var sidebar: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + if self.sections.isEmpty { + Text("No config sections available.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 4) + } else { + ForEach(self.sections) { section in + self.sidebarRow(section) + } + } + } + .padding(.vertical, 10) + .padding(.horizontal, 10) + } + .frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor))) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private var detail: some View { + VStack(alignment: .leading, spacing: 16) { + if self.store.configSchemaLoading { + ProgressView().controlSize(.small) + } else if let section = self.activeSection { + self.sectionDetail(section) + } else if self.store.configSchema != nil { + self.emptyDetail + } else { + Text("Schema unavailable.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var emptyDetail: some View { + VStack(alignment: .leading, spacing: 8) { + self.header + Text("Select a config section to view settings.") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 24) + .padding(.vertical, 18) + } + + private func sectionDetail(_ section: ConfigSection) -> some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 16) { + self.header + if let status = self.store.configStatus { + Text(status) + .font(.callout) + .foregroundStyle(.secondary) + } + self.actionRow + self.sectionHeader(section) + self.subsectionNav(section) + self.sectionForm(section) + if self.store.configDirty, !self.isNixMode { + Text("Unsaved changes") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.vertical, 18) + .groupBoxStyle(PlainSettingsGroupBoxStyle()) + } + } + + @ViewBuilder + private var header: some View { + Text("Config") + .font(.title3.weight(.semibold)) + Text(self.isNixMode + ? "This tab is read-only in Nix mode. Edit config via Nix and rebuild." + : "Edit ~/.openclaw/openclaw.json using the schema-driven form.") + .font(.callout) + .foregroundStyle(.secondary) + } + + private func sectionHeader(_ section: ConfigSection) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(section.label) + .font(.title3.weight(.semibold)) + if let help = section.help { + Text(help) + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + + private var actionRow: some View { + HStack(spacing: 10) { + Button("Reload") { + Task { await self.store.reloadConfigDraft() } + } + .disabled(!self.store.configLoaded) + + Button(self.store.isSavingConfig ? "Saving…" : "Save") { + Task { await self.store.saveConfigDraft() } + } + .disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configDirty) + } + .buttonStyle(.bordered) + } + + private func sidebarRow(_ section: ConfigSection) -> some View { + let isSelected = self.activeSectionKey == section.key + return Button { + self.selectSection(section) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(section.label) + if let help = section.help { + Text(help) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background(Color.clear) + .contentShape(Rectangle()) + } + .frame(maxWidth: .infinity, alignment: .leading) + .buttonStyle(.plain) + .contentShape(Rectangle()) + } + + @ViewBuilder + private func subsectionNav(_ section: ConfigSection) -> some View { + let subsections = self.resolveSubsections(for: section) + if subsections.isEmpty { + EmptyView() + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + self.subsectionButton( + title: "All", + isSelected: self.activeSubsection == .all) + { + self.activeSubsection = .all + } + ForEach(subsections) { subsection in + self.subsectionButton( + title: subsection.label, + isSelected: self.activeSubsection == .key(subsection.key)) + { + self.activeSubsection = .key(subsection.key) + } + } + } + .padding(.vertical, 2) + } + } + } + + private func subsectionButton( + title: String, + isSelected: Bool, + action: @escaping () -> Void) -> some View + { + Button(action: action) { + Text(title) + .font(.callout.weight(.semibold)) + .foregroundStyle(isSelected ? Color.accentColor : .primary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + private func sectionForm(_ section: ConfigSection) -> some View { + let subsection = self.activeSubsection + let defaultPath: ConfigPath = [.key(section.key)] + let subsections = self.resolveSubsections(for: section) + let resolved: (ConfigSchemaNode, ConfigPath) = { + if case let .key(key) = subsection, + let match = subsections.first(where: { $0.key == key }) + { + return (match.node, match.path) + } + return (self.resolvedSchemaNode(section.node), defaultPath) + }() + + return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1) + .disabled(self.isNixMode) + } + + private func ensureSelection() { + guard let schema = self.store.configSchema else { return } + let sections = self.resolveSections(schema) + guard !sections.isEmpty else { return } + + let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0] + if self.activeSectionKey != active.key { + self.activeSectionKey = active.key + } + self.ensureSubsection(for: active) + } + + private func ensureSubsection(for section: ConfigSection) { + let subsections = self.resolveSubsections(for: section) + guard !subsections.isEmpty else { + self.activeSubsection = nil + return + } + + switch self.activeSubsection { + case .all: + return + case let .key(key): + if subsections.contains(where: { $0.key == key }) { return } + case .none: + break + } + + if let first = subsections.first { + self.activeSubsection = .key(first.key) + } + } + + private func selectSection(_ section: ConfigSection) { + guard self.activeSectionKey != section.key else { return } + self.activeSectionKey = section.key + let subsections = self.resolveSubsections(for: section) + if let first = subsections.first { + self.activeSubsection = .key(first.key) + } else { + self.activeSubsection = nil + } + } + + private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] { + let node = self.resolvedSchemaNode(root) + let hints = self.store.configUiHints + let keys = node.properties.keys.sorted { lhs, rhs in + let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0 + let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0 + if orderA != orderB { return orderA < orderB } + return lhs < rhs + } + + return keys.compactMap { key in + guard let child = node.properties[key] else { return nil } + let path: ConfigPath = [.key(key)] + let hint = hintForPath(path, hints: hints) + let label = hint?.label + ?? child.title + ?? self.humanize(key) + let help = hint?.help ?? child.description + return ConfigSection(key: key, label: label, help: help, node: child) + } + } + + private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] { + let node = self.resolvedSchemaNode(section.node) + guard node.schemaType == "object" else { return [] } + let hints = self.store.configUiHints + let keys = node.properties.keys.sorted { lhs, rhs in + let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0 + let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0 + if orderA != orderB { return orderA < orderB } + return lhs < rhs + } + + return keys.compactMap { key in + guard let child = node.properties[key] else { return nil } + let path: ConfigPath = [.key(section.key), .key(key)] + let hint = hintForPath(path, hints: hints) + let label = hint?.label + ?? child.title + ?? self.humanize(key) + let help = hint?.help ?? child.description + return ConfigSubsection( + key: key, + label: label, + help: help, + node: child, + path: path) + } + } + + private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode { + let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf + if !variants.isEmpty { + let nonNull = variants.filter { !$0.isNullSchema } + if nonNull.count == 1, let only = nonNull.first { return only } + } + return node + } + + private func humanize(_ key: String) -> String { + key.replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .capitalized + } +} + +struct ConfigSettings_Previews: PreviewProvider { + static var previews: some View { + ConfigSettings() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConfigStore.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConfigStore.swift new file mode 100644 index 00000000..8fd779c6 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConfigStore.swift @@ -0,0 +1,117 @@ +import Foundation +import OpenClawProtocol + +enum ConfigStore { + struct Overrides: Sendable { + var isRemoteMode: (@Sendable () async -> Bool)? + var loadLocal: (@MainActor @Sendable () -> [String: Any])? + var saveLocal: (@MainActor @Sendable ([String: Any]) -> Void)? + var loadRemote: (@MainActor @Sendable () async -> [String: Any])? + var saveRemote: (@MainActor @Sendable ([String: Any]) async throws -> Void)? + } + + private actor OverrideStore { + var overrides = Overrides() + + func setOverride(_ overrides: Overrides) { + self.overrides = overrides + } + } + + private static let overrideStore = OverrideStore() + @MainActor private static var lastHash: String? + + private static func isRemoteMode() async -> Bool { + let overrides = await self.overrideStore.overrides + if let override = overrides.isRemoteMode { + return await override() + } + return await MainActor.run { AppStateStore.shared.connectionMode == .remote } + } + + @MainActor + static func load() async -> [String: Any] { + let overrides = await self.overrideStore.overrides + if await self.isRemoteMode() { + if let override = overrides.loadRemote { + return await override() + } + return await self.loadFromGateway() ?? [:] + } + if let override = overrides.loadLocal { + return override() + } + if let gateway = await self.loadFromGateway() { + return gateway + } + return OpenClawConfigFile.loadDict() + } + + @MainActor + static func save(_ root: sending [String: Any]) async throws { + let overrides = await self.overrideStore.overrides + if await self.isRemoteMode() { + if let override = overrides.saveRemote { + try await override(root) + } else { + try await self.saveToGateway(root) + } + } else { + if let override = overrides.saveLocal { + override(root) + } else { + do { + try await self.saveToGateway(root) + } catch { + OpenClawConfigFile.saveDict(root) + } + } + } + } + + @MainActor + private static func loadFromGateway() async -> [String: Any]? { + do { + let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded( + method: .configGet, + params: nil, + timeoutMs: 8000) + self.lastHash = snap.hash + return snap.config?.mapValues { $0.foundationValue } ?? [:] + } catch { + return nil + } + } + + @MainActor + private static func saveToGateway(_ root: [String: Any]) async throws { + if self.lastHash == nil { + _ = await self.loadFromGateway() + } + let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) + guard let raw = String(data: data, encoding: .utf8) else { + throw NSError(domain: "ConfigStore", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode config.", + ]) + } + var params: [String: AnyCodable] = ["raw": AnyCodable(raw)] + if let baseHash = self.lastHash { + params["baseHash"] = AnyCodable(baseHash) + } + _ = try await GatewayConnection.shared.requestRaw( + method: .configSet, + params: params, + timeoutMs: 10000) + _ = await self.loadFromGateway() + } + + #if DEBUG + static func _testSetOverrides(_ overrides: Overrides) async { + await self.overrideStore.setOverride(overrides) + } + + static func _testClearOverrides() async { + await self.overrideStore.setOverride(.init()) + } + #endif +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConnectionModeCoordinator.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConnectionModeCoordinator.swift new file mode 100644 index 00000000..b1c5eab1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConnectionModeCoordinator.swift @@ -0,0 +1,79 @@ +import Foundation +import OSLog + +@MainActor +final class ConnectionModeCoordinator { + static let shared = ConnectionModeCoordinator() + + private let logger = Logger(subsystem: "ai.openclaw", category: "connection") + private var lastMode: AppState.ConnectionMode? + + /// Apply the requested connection mode by starting/stopping local gateway, + /// managing the control-channel SSH tunnel, and cleaning up chat windows/panels. + func apply(mode: AppState.ConnectionMode, paused: Bool) async { + if let lastMode = self.lastMode, lastMode != mode { + GatewayProcessManager.shared.clearLastFailure() + NodesStore.shared.lastError = nil + } + self.lastMode = mode + switch mode { + case .unconfigured: + _ = await NodeServiceManager.stop() + NodesStore.shared.lastError = nil + await RemoteTunnelManager.shared.stopAll() + WebChatManager.shared.resetTunnels() + GatewayProcessManager.shared.stop() + await GatewayConnection.shared.shutdown() + await ControlChannel.shared.disconnect() + Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) } + + case .local: + _ = await NodeServiceManager.stop() + NodesStore.shared.lastError = nil + await RemoteTunnelManager.shared.stopAll() + WebChatManager.shared.resetTunnels() + let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused) + if shouldStart { + GatewayProcessManager.shared.setActive(true) + if GatewayAutostartPolicy.shouldEnsureLaunchAgent( + mode: .local, + paused: paused) + { + Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() } + } + _ = await GatewayProcessManager.shared.waitForGatewayReady() + } else { + GatewayProcessManager.shared.stop() + } + do { + try await ControlChannel.shared.configure(mode: .local) + } catch { + // Control channel will mark itself degraded; nothing else to do here. + self.logger.error( + "control channel local configure failed: \(error.localizedDescription, privacy: .public)") + } + Task.detached { await PortGuardian.shared.sweep(mode: .local) } + + case .remote: + // Never run a local gateway in remote mode. + GatewayProcessManager.shared.stop() + WebChatManager.shared.resetTunnels() + + do { + NodesStore.shared.lastError = nil + if let error = await NodeServiceManager.start() { + NodesStore.shared.lastError = "Node service start failed: \(error)" + } + _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() + let settings = CommandResolver.connectionSettings() + try await ControlChannel.shared.configure(mode: .remote( + target: settings.target, + identity: settings.identity)) + } catch { + self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)") + } + + Task.detached { await PortGuardian.shared.sweep(mode: .remote) } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift new file mode 100644 index 00000000..60c6fab9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift @@ -0,0 +1,49 @@ +import Foundation + +enum EffectiveConnectionModeSource: Sendable, Equatable { + case configMode + case configRemoteURL + case userDefaults + case onboarding +} + +struct EffectiveConnectionMode: Sendable, Equatable { + let mode: AppState.ConnectionMode + let source: EffectiveConnectionModeSource +} + +enum ConnectionModeResolver { + static func resolve( + root: [String: Any], + defaults: UserDefaults = .standard) -> EffectiveConnectionMode + { + let gateway = root["gateway"] as? [String: Any] + let configModeRaw = (gateway?["mode"] as? String) ?? "" + let configMode = configModeRaw + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + switch configMode { + case "local": + return EffectiveConnectionMode(mode: .local, source: .configMode) + case "remote": + return EffectiveConnectionMode(mode: .remote, source: .configMode) + default: + break + } + + let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? "" + let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines) + if !remoteURL.isEmpty { + return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL) + } + + if let storedModeRaw = defaults.string(forKey: connectionModeKey) { + let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local + return EffectiveConnectionMode(mode: storedMode, source: .userDefaults) + } + + let seen = defaults.bool(forKey: "openclaw.onboardingSeen") + return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Constants.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Constants.swift new file mode 100644 index 00000000..7065702d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Constants.swift @@ -0,0 +1,48 @@ +import Foundation + +// Stable identifier used for both the macOS LaunchAgent label and Nix-managed defaults suite. +// nix-openclaw writes app defaults into this suite to survive app bundle identifier churn. +let launchdLabel = "ai.openclaw.mac" +let gatewayLaunchdLabel = "ai.openclaw.gateway" +let onboardingVersionKey = "openclaw.onboardingVersion" +let onboardingSeenKey = "openclaw.onboardingSeen" +let currentOnboardingVersion = 7 +let pauseDefaultsKey = "openclaw.pauseEnabled" +let iconAnimationsEnabledKey = "openclaw.iconAnimationsEnabled" +let swabbleEnabledKey = "openclaw.swabbleEnabled" +let swabbleTriggersKey = "openclaw.swabbleTriggers" +let voiceWakeTriggerChimeKey = "openclaw.voiceWakeTriggerChime" +let voiceWakeSendChimeKey = "openclaw.voiceWakeSendChime" +let showDockIconKey = "openclaw.showDockIcon" +let defaultVoiceWakeTriggers = ["openclaw"] +let voiceWakeMaxWords = 32 +let voiceWakeMaxWordLength = 64 +let voiceWakeMicKey = "openclaw.voiceWakeMicID" +let voiceWakeMicNameKey = "openclaw.voiceWakeMicName" +let voiceWakeLocaleKey = "openclaw.voiceWakeLocaleID" +let voiceWakeAdditionalLocalesKey = "openclaw.voiceWakeAdditionalLocaleIDs" +let voicePushToTalkEnabledKey = "openclaw.voicePushToTalkEnabled" +let talkEnabledKey = "openclaw.talkEnabled" +let iconOverrideKey = "openclaw.iconOverride" +let connectionModeKey = "openclaw.connectionMode" +let remoteTargetKey = "openclaw.remoteTarget" +let remoteIdentityKey = "openclaw.remoteIdentity" +let remoteProjectRootKey = "openclaw.remoteProjectRoot" +let remoteCliPathKey = "openclaw.remoteCliPath" +let canvasEnabledKey = "openclaw.canvasEnabled" +let cameraEnabledKey = "openclaw.cameraEnabled" +let systemRunPolicyKey = "openclaw.systemRunPolicy" +let systemRunAllowlistKey = "openclaw.systemRunAllowlist" +let systemRunEnabledKey = "openclaw.systemRunEnabled" +let locationModeKey = "openclaw.locationMode" +let locationPreciseKey = "openclaw.locationPreciseEnabled" +let peekabooBridgeEnabledKey = "openclaw.peekabooBridgeEnabled" +let deepLinkKeyKey = "openclaw.deepLinkKey" +let modelCatalogPathKey = "openclaw.modelCatalogPath" +let modelCatalogReloadKey = "openclaw.modelCatalogReload" +let cliInstallPromptedVersionKey = "openclaw.cliInstallPromptedVersion" +let heartbeatsEnabledKey = "openclaw.heartbeatsEnabled" +let debugPaneEnabledKey = "openclaw.debugPaneEnabled" +let debugFileLogEnabledKey = "openclaw.debug.fileLogEnabled" +let appLogLevelKey = "openclaw.debug.appLogLevel" +let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift new file mode 100644 index 00000000..f9a11b9e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift @@ -0,0 +1,120 @@ +import Foundation +import SwiftUI + +/// Context usage card shown at the top of the menubar menu. +struct ContextMenuCardView: View { + private let rows: [SessionRow] + private let statusText: String? + private let isLoading: Bool + private let paddingTop: CGFloat = 8 + private let paddingBottom: CGFloat = 8 + private let paddingTrailing: CGFloat = 10 + private let paddingLeading: CGFloat = 20 + private let barHeight: CGFloat = 3 + + init( + rows: [SessionRow], + statusText: String? = nil, + isLoading: Bool = false) + { + self.rows = rows + self.statusText = statusText + self.isLoading = isLoading + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text("Context") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 10) + Text(self.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let statusText { + Text(statusText) + .font(.caption) + .foregroundStyle(.secondary) + } else if self.rows.isEmpty, !self.isLoading { + Text("No active sessions") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 12) { + if self.rows.isEmpty, self.isLoading { + ForEach(0..<2, id: \.self) { _ in + self.placeholderRow + } + } else { + ForEach(self.rows) { row in + self.sessionRow(row) + } + } + } + } + } + .padding(.top, self.paddingTop) + .padding(.bottom, self.paddingBottom) + .padding(.leading, self.paddingLeading) + .padding(.trailing, self.paddingTrailing) + .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) + .transaction { txn in txn.animation = nil } + } + + private var subtitle: String { + let count = self.rows.count + if count == 1 { return "1 session · 24h" } + return "\(count) sessions · 24h" + } + + private func sessionRow(_ row: SessionRow) -> some View { + VStack(alignment: .leading, spacing: 5) { + ContextUsageBar( + usedTokens: row.tokens.total, + contextTokens: row.tokens.contextTokens, + height: self.barHeight) + + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(row.label) + .font(.caption.weight(row.key == "main" ? .semibold : .regular)) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) + Spacer(minLength: 8) + Text(row.tokens.contextSummaryShort) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) + } + } + .padding(.vertical, 2) + } + + private var placeholderRow: some View { + VStack(alignment: .leading, spacing: 5) { + ContextUsageBar( + usedTokens: 0, + contextTokens: 200_000, + height: self.barHeight) + + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("main") + .font(.caption.weight(.semibold)) + .lineLimit(1) + .layoutPriority(1) + Spacer(minLength: 8) + Text("000k/000k") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) + } + .redacted(reason: .placeholder) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ContextUsageBar.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ContextUsageBar.swift new file mode 100644 index 00000000..f5bfa053 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ContextUsageBar.swift @@ -0,0 +1,93 @@ +import SwiftUI + +struct ContextUsageBar: View { + let usedTokens: Int + let contextTokens: Int + var width: CGFloat? + var height: CGFloat = 6 + + private static let okGreen: NSColor = .init(name: nil) { appearance in + let base = NSColor.systemGreen + let match = appearance.bestMatch(from: [.aqua, .darkAqua]) + if match == .darkAqua { return base } + return base.blended(withFraction: 0.24, of: .black) ?? base + } + + private static let trackFill: NSColor = .init(name: nil) { appearance in + let match = appearance.bestMatch(from: [.aqua, .darkAqua]) + if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) } + return NSColor.black.withAlphaComponent(0.12) + } + + private static let trackStroke: NSColor = .init(name: nil) { appearance in + let match = appearance.bestMatch(from: [.aqua, .darkAqua]) + if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) } + return NSColor.black.withAlphaComponent(0.2) + } + + private var clampedFractionUsed: Double { + guard self.contextTokens > 0 else { return 0 } + return min(1, max(0, Double(self.usedTokens) / Double(self.contextTokens))) + } + + private var percentUsed: Int? { + guard self.contextTokens > 0, self.usedTokens > 0 else { return nil } + return min(100, Int(round(self.clampedFractionUsed * 100))) + } + + private var tint: Color { + guard let pct = self.percentUsed else { return .secondary } + if pct >= 95 { return Color(nsColor: .systemRed) } + if pct >= 80 { return Color(nsColor: .systemOrange) } + if pct >= 60 { return Color(nsColor: .systemYellow) } + return Color(nsColor: Self.okGreen) + } + + var body: some View { + let fraction = self.clampedFractionUsed + Group { + if let width = self.width, width > 0 { + self.barBody(width: width, fraction: fraction) + .frame(width: width, height: self.height) + } else { + GeometryReader { proxy in + self.barBody(width: proxy.size.width, fraction: fraction) + .frame(width: proxy.size.width, height: self.height) + } + .frame(height: self.height) + } + } + .accessibilityLabel("Context usage") + .accessibilityValue(self.accessibilityValue) + } + + private var accessibilityValue: String { + if self.contextTokens <= 0 { return "Unknown context window" } + let pct = Int(round(self.clampedFractionUsed * 100)) + return "\(pct) percent used" + } + + @ViewBuilder + private func barBody(width: CGFloat, fraction: Double) -> some View { + let radius = self.height / 2 + let trackFill = Color(nsColor: Self.trackFill) + let trackStroke = Color(nsColor: Self.trackStroke) + let fillWidth = max(1, floor(width * CGFloat(fraction))) + + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: radius, style: .continuous) + .fill(trackFill) + .overlay { + RoundedRectangle(cornerRadius: radius, style: .continuous) + .strokeBorder(trackStroke, lineWidth: 0.75) + } + + RoundedRectangle(cornerRadius: radius, style: .continuous) + .fill(self.tint) + .frame(width: fillWidth) + .mask { + RoundedRectangle(cornerRadius: radius, style: .continuous) + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ControlChannel.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ControlChannel.swift new file mode 100644 index 00000000..16b4d6d3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ControlChannel.swift @@ -0,0 +1,428 @@ +import Foundation +import Observation +import OpenClawKit +import OpenClawProtocol +import SwiftUI + +struct ControlHeartbeatEvent: Codable { + let ts: Double + let status: String + let to: String? + let preview: String? + let durationMs: Double? + let hasMedia: Bool? + let reason: String? +} + +struct ControlAgentEvent: Codable, Sendable, Identifiable { + var id: String { + "\(self.runId)-\(self.seq)" + } + + let runId: String + let seq: Int + let stream: String + let ts: Double + let data: [String: OpenClawProtocol.AnyCodable] + let summary: String? +} + +enum ControlChannelError: Error, LocalizedError { + case disconnected + case badResponse(String) + + var errorDescription: String? { + switch self { + case .disconnected: "Control channel disconnected" + case let .badResponse(msg): msg + } + } +} + +@MainActor +@Observable +final class ControlChannel { + static let shared = ControlChannel() + + enum Mode { + case local + case remote(target: String, identity: String) + } + + enum ConnectionState: Equatable { + case disconnected + case connecting + case connected + case degraded(String) + } + + private(set) var state: ConnectionState = .disconnected { + didSet { + CanvasManager.shared.refreshDebugStatus() + guard oldValue != self.state else { return } + switch self.state { + case .connected: + self.logger.info("control channel state -> connected") + case .connecting: + self.logger.info("control channel state -> connecting") + case .disconnected: + self.logger.info("control channel state -> disconnected") + self.scheduleRecovery(reason: "disconnected") + case let .degraded(message): + let detail = message.isEmpty ? "degraded" : "degraded: \(message)" + self.logger.info("control channel state -> \(detail, privacy: .public)") + self.scheduleRecovery(reason: message) + } + } + } + + private(set) var lastPingMs: Double? + private(set) var authSourceLabel: String? + + private let logger = Logger(subsystem: "ai.openclaw", category: "control") + + private var eventTask: Task? + private var recoveryTask: Task? + private var lastRecoveryAt: Date? + + private init() { + self.startEventStream() + } + + func configure() async { + self.logger.info("control channel configure mode=local") + await self.refreshEndpoint(reason: "configure") + } + + func configure(mode: Mode = .local) async throws { + switch mode { + case .local: + await self.configure() + case let .remote(target, identity): + do { + _ = (target, identity) + let idSet = !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + self.logger.info( + "control channel configure mode=remote " + + "target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)") + self.state = .connecting + _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() + await self.refreshEndpoint(reason: "configure") + } catch { + self.state = .degraded(error.localizedDescription) + throw error + } + } + } + + func refreshEndpoint(reason: String) async { + self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)") + self.state = .connecting + do { + try await self.establishGatewayConnection() + self.state = .connected + PresenceReporter.shared.sendImmediate(reason: "connect") + } catch { + let message = self.friendlyGatewayMessage(error) + self.state = .degraded(message) + } + } + + func disconnect() async { + await GatewayConnection.shared.shutdown() + self.state = .disconnected + self.lastPingMs = nil + self.authSourceLabel = nil + } + + func health(timeout: TimeInterval? = nil) async throws -> Data { + do { + let start = Date() + var params: [String: AnyHashable]? + if let timeout { + params = ["timeout": AnyHashable(Int(timeout * 1000))] + } + let timeoutMs = (timeout ?? 15) * 1000 + let payload = try await self.request(method: "health", params: params, timeoutMs: timeoutMs) + let ms = Date().timeIntervalSince(start) * 1000 + self.lastPingMs = ms + self.state = .connected + return payload + } catch { + let message = self.friendlyGatewayMessage(error) + self.state = .degraded(message) + throw ControlChannelError.badResponse(message) + } + } + + func lastHeartbeat() async throws -> ControlHeartbeatEvent? { + let data = try await self.request(method: "last-heartbeat") + return try JSONDecoder().decode(ControlHeartbeatEvent?.self, from: data) + } + + func request( + method: String, + params: [String: AnyHashable]? = nil, + timeoutMs: Double? = nil) async throws -> Data + { + do { + let rawParams = params?.reduce(into: [String: OpenClawKit.AnyCodable]()) { + $0[$1.key] = OpenClawKit.AnyCodable($1.value.base) + } + let data = try await GatewayConnection.shared.request( + method: method, + params: rawParams, + timeoutMs: timeoutMs) + self.state = .connected + return data + } catch { + let message = self.friendlyGatewayMessage(error) + self.state = .degraded(message) + throw ControlChannelError.badResponse(message) + } + } + + private func friendlyGatewayMessage(_ error: Error) -> String { + // Map URLSession/WS errors into user-facing, actionable text. + if let ctrlErr = error as? ControlChannelError, let desc = ctrlErr.errorDescription { + return desc + } + + // If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it. + if let urlErr = error as? URLError, + urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures + { + let reason = urlErr.failureURLString ?? urlErr.localizedDescription + let tokenKey = CommandResolver.connectionModeIsRemote() + ? "gateway.remote.token" + : "gateway.auth.token" + return + "Gateway rejected token; set \(tokenKey) or clear it on the gateway. Reason: \(reason)" + } + + // Common misfire: we connected to the configured localhost port but it is occupied + // by some other process (e.g. a local dev gateway or a stuck SSH forward). + // The gateway handshake returns something we can't parse, which currently + // surfaces as "hello failed (unexpected response)". Give the user a pointer + // to free the port instead of a vague message. + let nsError = error as NSError + if nsError.domain == "Gateway", + nsError.localizedDescription.contains("hello failed (unexpected response)") + { + let port = GatewayEnvironment.gatewayPort() + return """ + Gateway handshake got non-gateway data on localhost:\(port). + Another process is using that port or the SSH forward failed. + Stop the local gateway/port-forward on \(port) and retry Remote mode. + """ + } + + if let urlError = error as? URLError { + let port = GatewayEnvironment.gatewayPort() + switch urlError.code { + case .cancelled: + return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry." + case .cannotFindHost, .cannotConnectToHost: + let isRemote = CommandResolver.connectionModeIsRemote() + if isRemote { + return """ + Cannot reach gateway at localhost:\(port). + Remote mode uses an SSH tunnel—check the SSH target and that the tunnel is running. + """ + } + return "Cannot reach gateway at localhost:\(port); ensure the gateway is running." + case .networkConnectionLost: + return "Gateway connection dropped; gateway likely restarted—retry." + case .timedOut: + return "Gateway request timed out; check gateway on localhost:\(port)." + case .notConnectedToInternet: + return "No network connectivity; cannot reach gateway." + default: + break + } + } + + if nsError.domain == "Gateway", nsError.code == 5 { + let port = GatewayEnvironment.gatewayPort() + return "Gateway request timed out; check the gateway process on localhost:\(port)." + } + + let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription + let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("gateway error:") { return trimmed } + return "Gateway error: \(trimmed)" + } + + private func scheduleRecovery(reason: String) { + let now = Date() + if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return } + guard self.recoveryTask == nil else { return } + self.lastRecoveryAt = now + + self.recoveryTask = Task { [weak self] in + guard let self else { return } + let mode = await MainActor.run { AppStateStore.shared.connectionMode } + guard mode != .unconfigured else { + self.recoveryTask = nil + return + } + + let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) + let reasonText = trimmedReason.isEmpty ? "unknown" : trimmedReason + self.logger.info( + "control channel recovery starting " + + "mode=\(String(describing: mode), privacy: .public) " + + "reason=\(reasonText, privacy: .public)") + if mode == .local { + GatewayProcessManager.shared.setActive(true) + } + if mode == .remote { + do { + let port = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() + self.logger.info("control channel recovery ensured SSH tunnel port=\(port, privacy: .public)") + } catch { + self.logger.error( + "control channel recovery tunnel failed \(error.localizedDescription, privacy: .public)") + } + } + + await self.refreshEndpoint(reason: "recovery:\(reasonText)") + if case .connected = self.state { + self.logger.info("control channel recovery finished") + } else if case let .degraded(message) = self.state { + self.logger.error("control channel recovery failed \(message, privacy: .public)") + } + + self.recoveryTask = nil + } + } + + private func establishGatewayConnection(timeoutMs: Int = 5000) async throws { + try await GatewayConnection.shared.refresh() + let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) + if ok == false { + throw NSError( + domain: "Gateway", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"]) + } + await self.refreshAuthSourceLabel() + } + + private func refreshAuthSourceLabel() async { + let isRemote = CommandResolver.connectionModeIsRemote() + let authSource = await GatewayConnection.shared.authSource() + self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote) + } + + private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? { + guard let source else { return nil } + switch source { + case .deviceToken: + return "Auth: device token (paired device)" + case .sharedToken: + return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))" + case .password: + return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))" + case .none: + return "Auth: none" + } + } + + func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws { + var merged = params + merged["text"] = AnyHashable(text) + _ = try await self.request(method: "system-event", params: merged) + } + + private func startEventStream() { + self.eventTask?.cancel() + self.eventTask = Task { [weak self] in + guard let self else { return } + let stream = await GatewayConnection.shared.subscribe() + for await push in stream { + if Task.isCancelled { return } + await MainActor.run { [weak self] in + self?.handle(push: push) + } + } + } + } + + private func handle(push: GatewayPush) { + switch push { + case let .event(evt) where evt.event == "agent": + if let payload = evt.payload, + let agent = try? GatewayPayloadDecoding.decode(payload, as: ControlAgentEvent.self) + { + AgentEventStore.shared.append(agent) + self.routeWorkActivity(from: agent) + } + case let .event(evt) where evt.event == "heartbeat": + if let payload = evt.payload, + let heartbeat = try? GatewayPayloadDecoding.decode(payload, as: ControlHeartbeatEvent.self), + let data = try? JSONEncoder().encode(heartbeat) + { + NotificationCenter.default.post(name: .controlHeartbeat, object: data) + } + case let .event(evt) where evt.event == "shutdown": + self.state = .degraded("gateway shutdown") + case .snapshot: + self.state = .connected + default: + break + } + } + + private func routeWorkActivity(from event: ControlAgentEvent) { + // We currently treat VoiceWake as the "main" session for UI purposes. + // In the future, the gateway can include a sessionKey to distinguish runs. + let sessionKey = (event.data["sessionKey"]?.value as? String) ?? "main" + + switch event.stream.lowercased() { + case "job": + if let state = event.data["state"]?.value as? String { + WorkActivityStore.shared.handleJob(sessionKey: sessionKey, state: state) + } + case "tool": + let phase = event.data["phase"]?.value as? String ?? "" + let name = event.data["name"]?.value as? String + let meta = event.data["meta"]?.value as? String + let args = Self.bridgeToProtocolArgs(event.data["args"]) + WorkActivityStore.shared.handleTool( + sessionKey: sessionKey, + phase: phase, + name: name, + meta: meta, + args: args) + default: + break + } + } + + private static func bridgeToProtocolArgs( + _ value: OpenClawProtocol.AnyCodable?) -> [String: OpenClawProtocol.AnyCodable]? + { + guard let value else { return nil } + if let dict = value.value as? [String: OpenClawProtocol.AnyCodable] { + return dict + } + if let dict = value.value as? [String: OpenClawKit.AnyCodable], + let data = try? JSONEncoder().encode(dict), + let decoded = try? JSONDecoder().decode([String: OpenClawProtocol.AnyCodable].self, from: data) + { + return decoded + } + if let data = try? JSONEncoder().encode(value), + let decoded = try? JSONDecoder().decode([String: OpenClawProtocol.AnyCodable].self, from: data) + { + return decoded + } + return nil + } +} + +extension Notification.Name { + static let controlHeartbeat = Notification.Name("openclaw.control.heartbeat") + static let controlAgentEvent = Notification.Name("openclaw.control.agent") +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CostUsageMenuView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CostUsageMenuView.swift new file mode 100644 index 00000000..c94a4de3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CostUsageMenuView.swift @@ -0,0 +1,99 @@ +import Charts +import SwiftUI + +struct CostUsageHistoryMenuView: View { + let summary: GatewayCostUsageSummary + let width: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + self.header + self.chart + self.footer + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(width: max(1, self.width), alignment: .leading) + } + + private var header: some View { + let todayKey = CostUsageMenuDateParser.format(Date()) + let todayEntry = self.summary.daily.first { $0.date == todayKey } + let todayCost = CostUsageFormatting.formatUsd(todayEntry?.totalCost) ?? "n/a" + let totalCost = CostUsageFormatting.formatUsd(self.summary.totals.totalCost) ?? "n/a" + + return HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Today") + .font(.caption2) + .foregroundStyle(.secondary) + Text(todayCost) + .font(.system(size: 14, weight: .semibold)) + } + VStack(alignment: .leading, spacing: 2) { + Text("Last \(self.summary.days)d") + .font(.caption2) + .foregroundStyle(.secondary) + Text(totalCost) + .font(.system(size: 14, weight: .semibold)) + } + Spacer() + } + } + + private var chart: some View { + let entries = self.summary.daily.compactMap { entry -> (Date, Double)? in + guard let date = CostUsageMenuDateParser.parse(entry.date) else { return nil } + return (date, entry.totalCost) + } + + return Chart(entries, id: \.0) { entry in + BarMark( + x: .value("Day", entry.0), + y: .value("Cost", entry.1)) + .foregroundStyle(Color.accentColor) + .cornerRadius(3) + } + .chartXAxis { + AxisMarks(values: .stride(by: .day, count: 7)) { + AxisGridLine().foregroundStyle(.clear) + AxisValueLabel(format: .dateTime.month().day()) + } + } + .chartYAxis { + AxisMarks(position: .leading) { + AxisGridLine() + AxisValueLabel() + } + } + .frame(height: 110) + } + + private var footer: some View { + if self.summary.totals.missingCostEntries == 0 { + return AnyView(EmptyView()) + } + return AnyView( + Text("Partial: \(self.summary.totals.missingCostEntries) entries missing cost") + .font(.caption2) + .foregroundStyle(.secondary)) + } +} + +private enum CostUsageMenuDateParser { + static let formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + return formatter + }() + + static func parse(_ value: String) -> Date? { + self.formatter.date(from: value) + } + + static func format(_ date: Date) -> String { + self.formatter.string(from: date) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CritterIconRenderer.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CritterIconRenderer.swift new file mode 100644 index 00000000..03094619 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CritterIconRenderer.swift @@ -0,0 +1,387 @@ +import AppKit + +enum CritterIconRenderer { + private static let size = NSSize(width: 18, height: 18) + + struct Badge { + let symbolName: String + let prominence: IconState.BadgeProminence + } + + private struct Canvas { + let w: CGFloat + let h: CGFloat + let stepX: CGFloat + let stepY: CGFloat + let snapX: (CGFloat) -> CGFloat + let snapY: (CGFloat) -> CGFloat + let context: CGContext + } + + private struct Geometry { + let bodyRect: CGRect + let bodyCorner: CGFloat + let leftEarRect: CGRect + let rightEarRect: CGRect + let earCorner: CGFloat + let earW: CGFloat + let earH: CGFloat + let legW: CGFloat + let legH: CGFloat + let legSpacing: CGFloat + let legStartX: CGFloat + let legYBase: CGFloat + let legLift: CGFloat + let legHeightScale: CGFloat + let eyeW: CGFloat + let eyeY: CGFloat + let eyeOffset: CGFloat + + init(canvas: Canvas, legWiggle: CGFloat, earWiggle: CGFloat, earScale: CGFloat) { + let w = canvas.w + let h = canvas.h + let snapX = canvas.snapX + let snapY = canvas.snapY + + let bodyW = snapX(w * 0.78) + let bodyH = snapY(h * 0.58) + let bodyX = snapX((w - bodyW) / 2) + let bodyY = snapY(h * 0.36) + let bodyCorner = snapX(w * 0.09) + + let earW = snapX(w * 0.22) + let earH = snapY(bodyH * 0.54 * earScale * (1 - 0.08 * abs(earWiggle))) + let earCorner = snapX(earW * 0.24) + let leftEarRect = CGRect( + x: snapX(bodyX - earW * 0.55 + earWiggle), + y: snapY(bodyY + bodyH * 0.08 + earWiggle * 0.4), + width: earW, + height: earH) + let rightEarRect = CGRect( + x: snapX(bodyX + bodyW - earW * 0.45 - earWiggle), + y: snapY(bodyY + bodyH * 0.08 - earWiggle * 0.4), + width: earW, + height: earH) + + let legW = snapX(w * 0.11) + let legH = snapY(h * 0.26) + let legSpacing = snapX(w * 0.085) + let legsWidth = snapX(4 * legW + 3 * legSpacing) + let legStartX = snapX((w - legsWidth) / 2) + let legLift = snapY(legH * 0.35 * legWiggle) + let legYBase = snapY(bodyY - legH + h * 0.05) + let legHeightScale = 1 - 0.12 * legWiggle + + let eyeW = snapX(bodyW * 0.2) + let eyeY = snapY(bodyY + bodyH * 0.56) + let eyeOffset = snapX(bodyW * 0.24) + + self.bodyRect = CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH) + self.bodyCorner = bodyCorner + self.leftEarRect = leftEarRect + self.rightEarRect = rightEarRect + self.earCorner = earCorner + self.earW = earW + self.earH = earH + self.legW = legW + self.legH = legH + self.legSpacing = legSpacing + self.legStartX = legStartX + self.legYBase = legYBase + self.legLift = legLift + self.legHeightScale = legHeightScale + self.eyeW = eyeW + self.eyeY = eyeY + self.eyeOffset = eyeOffset + } + } + + private struct FaceOptions { + let blink: CGFloat + let earHoles: Bool + let earScale: CGFloat + let eyesClosedLines: Bool + } + + static func makeIcon( + blink: CGFloat, + legWiggle: CGFloat = 0, + earWiggle: CGFloat = 0, + earScale: CGFloat = 1, + earHoles: Bool = false, + eyesClosedLines: Bool = false, + badge: Badge? = nil) -> NSImage + { + guard let rep = self.makeBitmapRep() else { + return NSImage(size: self.size) + } + rep.size = self.size + + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + + guard let context = NSGraphicsContext(bitmapImageRep: rep) else { + return NSImage(size: self.size) + } + NSGraphicsContext.current = context + context.imageInterpolation = .none + context.cgContext.setShouldAntialias(false) + + let canvas = self.makeCanvas(for: rep, context: context) + let geometry = Geometry(canvas: canvas, legWiggle: legWiggle, earWiggle: earWiggle, earScale: earScale) + + self.drawBody(in: canvas, geometry: geometry) + let face = FaceOptions( + blink: blink, + earHoles: earHoles, + earScale: earScale, + eyesClosedLines: eyesClosedLines) + self.drawFace(in: canvas, geometry: geometry, options: face) + + if let badge { + self.drawBadge(badge, canvas: canvas) + } + + let image = NSImage(size: size) + image.addRepresentation(rep) + image.isTemplate = true + return image + } + + private static func makeBitmapRep() -> NSBitmapImageRep? { + // Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina. + let pixelsWide = 36 + let pixelsHigh = 36 + return NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: pixelsWide, + pixelsHigh: pixelsHigh, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bitmapFormat: [], + bytesPerRow: 0, + bitsPerPixel: 0) + } + + private static func makeCanvas(for rep: NSBitmapImageRep, context: NSGraphicsContext) -> Canvas { + let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1) + let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1) + let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX } + let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY } + + let w = snapX(size.width) + let h = snapY(size.height) + + return Canvas( + w: w, + h: h, + stepX: stepX, + stepY: stepY, + snapX: snapX, + snapY: snapY, + context: context.cgContext) + } + + private static func drawBody(in canvas: Canvas, geometry: Geometry) { + canvas.context.setFillColor(NSColor.labelColor.cgColor) + + canvas.context.addPath(CGPath( + roundedRect: geometry.bodyRect, + cornerWidth: geometry.bodyCorner, + cornerHeight: geometry.bodyCorner, + transform: nil)) + canvas.context.addPath(CGPath( + roundedRect: geometry.leftEarRect, + cornerWidth: geometry.earCorner, + cornerHeight: geometry.earCorner, + transform: nil)) + canvas.context.addPath(CGPath( + roundedRect: geometry.rightEarRect, + cornerWidth: geometry.earCorner, + cornerHeight: geometry.earCorner, + transform: nil)) + + for i in 0..<4 { + let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing) + let lift = i % 2 == 0 ? geometry.legLift : -geometry.legLift + let rect = CGRect( + x: x, + y: geometry.legYBase + lift, + width: geometry.legW, + height: geometry.legH * geometry.legHeightScale) + canvas.context.addPath(CGPath( + roundedRect: rect, + cornerWidth: geometry.legW * 0.34, + cornerHeight: geometry.legW * 0.34, + transform: nil)) + } + canvas.context.fillPath() + } + + private static func drawFace( + in canvas: Canvas, + geometry: Geometry, + options: FaceOptions) + { + canvas.context.saveGState() + canvas.context.setBlendMode(.clear) + + let leftCenter = CGPoint( + x: canvas.snapX(canvas.w / 2 - geometry.eyeOffset), + y: canvas.snapY(geometry.eyeY)) + let rightCenter = CGPoint( + x: canvas.snapX(canvas.w / 2 + geometry.eyeOffset), + y: canvas.snapY(geometry.eyeY)) + + if options.earHoles || options.earScale > 1.05 { + let holeW = canvas.snapX(geometry.earW * 0.6) + let holeH = canvas.snapY(geometry.earH * 0.46) + let holeCorner = canvas.snapX(holeW * 0.34) + let leftHoleRect = CGRect( + x: canvas.snapX(geometry.leftEarRect.midX - holeW / 2), + y: canvas.snapY(geometry.leftEarRect.midY - holeH / 2 + geometry.earH * 0.04), + width: holeW, + height: holeH) + let rightHoleRect = CGRect( + x: canvas.snapX(geometry.rightEarRect.midX - holeW / 2), + y: canvas.snapY(geometry.rightEarRect.midY - holeH / 2 + geometry.earH * 0.04), + width: holeW, + height: holeH) + + canvas.context.addPath(CGPath( + roundedRect: leftHoleRect, + cornerWidth: holeCorner, + cornerHeight: holeCorner, + transform: nil)) + canvas.context.addPath(CGPath( + roundedRect: rightHoleRect, + cornerWidth: holeCorner, + cornerHeight: holeCorner, + transform: nil)) + } + + if options.eyesClosedLines { + let lineW = canvas.snapX(geometry.eyeW * 0.95) + let lineH = canvas.snapY(max(canvas.stepY * 2, geometry.bodyRect.height * 0.06)) + let corner = canvas.snapX(lineH * 0.6) + let leftRect = CGRect( + x: canvas.snapX(leftCenter.x - lineW / 2), + y: canvas.snapY(leftCenter.y - lineH / 2), + width: lineW, + height: lineH) + let rightRect = CGRect( + x: canvas.snapX(rightCenter.x - lineW / 2), + y: canvas.snapY(rightCenter.y - lineH / 2), + width: lineW, + height: lineH) + canvas.context.addPath(CGPath( + roundedRect: leftRect, + cornerWidth: corner, + cornerHeight: corner, + transform: nil)) + canvas.context.addPath(CGPath( + roundedRect: rightRect, + cornerWidth: corner, + cornerHeight: corner, + transform: nil)) + } else { + let eyeOpen = max(0.05, 1 - options.blink) + let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen) + + let left = CGMutablePath() + left.move(to: CGPoint( + x: canvas.snapX(leftCenter.x - geometry.eyeW / 2), + y: canvas.snapY(leftCenter.y - eyeH))) + left.addLine(to: CGPoint( + x: canvas.snapX(leftCenter.x + geometry.eyeW / 2), + y: canvas.snapY(leftCenter.y))) + left.addLine(to: CGPoint( + x: canvas.snapX(leftCenter.x - geometry.eyeW / 2), + y: canvas.snapY(leftCenter.y + eyeH))) + left.closeSubpath() + + let right = CGMutablePath() + right.move(to: CGPoint( + x: canvas.snapX(rightCenter.x + geometry.eyeW / 2), + y: canvas.snapY(rightCenter.y - eyeH))) + right.addLine(to: CGPoint( + x: canvas.snapX(rightCenter.x - geometry.eyeW / 2), + y: canvas.snapY(rightCenter.y))) + right.addLine(to: CGPoint( + x: canvas.snapX(rightCenter.x + geometry.eyeW / 2), + y: canvas.snapY(rightCenter.y + eyeH))) + right.closeSubpath() + + canvas.context.addPath(left) + canvas.context.addPath(right) + } + + canvas.context.fillPath() + canvas.context.restoreGState() + } + + private static func drawBadge(_ badge: Badge, canvas: Canvas) { + let strength: CGFloat = switch badge.prominence { + case .primary: 1.0 + case .secondary: 0.58 + case .overridden: 0.85 + } + + // Bigger, higher-contrast badge: + // - Increase diameter so tool activity is noticeable. + // - Draw a filled "puck", then knock out the symbol shape (transparent hole). + // This reads better in template-rendered menu bar icons than tiny monochrome glyphs. + let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~9–10pt on an 18pt canvas + let margin = canvas.snapX(max(0.45, canvas.w * 0.03)) + let rect = CGRect( + x: canvas.snapX(canvas.w - diameter - margin), + y: canvas.snapY(margin), + width: diameter, + height: diameter) + + canvas.context.saveGState() + canvas.context.setShouldAntialias(true) + + // Clear the underlying pixels so the badge stays readable over the critter. + canvas.context.saveGState() + canvas.context.setBlendMode(.clear) + canvas.context.addEllipse(in: rect.insetBy(dx: -1.0, dy: -1.0)) + canvas.context.fillPath() + canvas.context.restoreGState() + + let fillAlpha: CGFloat = min(1.0, 0.36 + 0.24 * strength) + let strokeAlpha: CGFloat = min(1.0, 0.78 + 0.22 * strength) + + canvas.context.setFillColor(NSColor.labelColor.withAlphaComponent(fillAlpha).cgColor) + canvas.context.addEllipse(in: rect) + canvas.context.fillPath() + + canvas.context.setStrokeColor(NSColor.labelColor.withAlphaComponent(strokeAlpha).cgColor) + canvas.context.setLineWidth(max(1.25, canvas.snapX(canvas.w * 0.075))) + canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45)) + + if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) { + let pointSize = max(7.0, diameter * 0.82) + let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .black) + let symbol = base.withSymbolConfiguration(config) ?? base + symbol.isTemplate = true + + let symbolRect = rect.insetBy(dx: diameter * 0.17, dy: diameter * 0.17) + canvas.context.saveGState() + canvas.context.setBlendMode(.clear) + symbol.draw( + in: symbolRect, + from: .zero, + operation: .sourceOver, + fraction: 1, + respectFlipped: true, + hints: nil) + canvas.context.restoreGState() + } + + canvas.context.restoreGState() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift new file mode 100644 index 00000000..e1145c4e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift @@ -0,0 +1,305 @@ +import AppKit +import SwiftUI + +extension CritterStatusLabel { + private var isWorkingNow: Bool { + self.iconState.isWorking || self.isWorking + } + + private var effectiveAnimationsEnabled: Bool { + self.animationsEnabled && !self.isSleeping + } + + var body: some View { + ZStack(alignment: .topTrailing) { + self.iconImage + .frame(width: 18, height: 18) + .rotationEffect(.degrees(self.wiggleAngle), anchor: .center) + .offset(x: self.wiggleOffset) + // Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks + // triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead. + .task(id: self.tickTaskID) { + guard self.effectiveAnimationsEnabled, !self.earBoostActive else { + await MainActor.run { self.resetMotion() } + return + } + + while !Task.isCancelled { + let now = Date() + await MainActor.run { self.tick(now) } + try? await Task.sleep(nanoseconds: 350_000_000) + } + } + .onChange(of: self.isPaused) { _, _ in self.resetMotion() } + .onChange(of: self.blinkTick) { _, _ in + guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return } + self.blink() + } + .onChange(of: self.sendCelebrationTick) { _, _ in + guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return } + self.wiggleLegs() + } + .onChange(of: self.animationsEnabled) { _, enabled in + if enabled, !self.isSleeping { + self.scheduleRandomTimers(from: Date()) + } else { + self.resetMotion() + } + } + .onChange(of: self.isSleeping) { _, _ in + self.resetMotion() + } + .onChange(of: self.earBoostActive) { _, active in + if active { + self.resetMotion() + } else if self.effectiveAnimationsEnabled { + self.scheduleRandomTimers(from: Date()) + } + } + + if self.gatewayNeedsAttention { + Circle() + .fill(self.gatewayBadgeColor) + .frame(width: 6, height: 6) + .padding(1) + } + } + .frame(width: 18, height: 18) + } + + private var tickTaskID: Int { + // Ensure SwiftUI restarts (and cancels) the task when these change. + (self.effectiveAnimationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0) + } + + private func tick(_ now: Date) { + guard self.effectiveAnimationsEnabled, !self.earBoostActive else { + self.resetMotion() + return + } + + if now >= self.nextBlink { + self.blink() + self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5)) + } + + if now >= self.nextWiggle { + self.wiggle() + self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14)) + } + + if now >= self.nextLegWiggle { + self.wiggleLegs() + self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0)) + } + + if now >= self.nextEarWiggle { + self.wiggleEars() + self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0)) + } + + if self.isWorkingNow { + self.scurry() + } + } + + private var iconImage: Image { + let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused { + CritterIconRenderer.Badge( + symbolName: self.iconState.badgeSymbolName, + prominence: prominence) + } else { + nil + } + + if self.isPaused { + return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil)) + } + + if self.isSleeping { + return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, eyesClosedLines: true, badge: nil)) + } + + return Image(nsImage: CritterIconRenderer.makeIcon( + blink: self.blinkAmount, + legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0), + earWiggle: self.earWiggle, + earScale: self.earBoostActive ? 1.9 : 1.0, + earHoles: self.earBoostActive, + badge: badge)) + } + + private func resetMotion() { + self.blinkAmount = 0 + self.wiggleAngle = 0 + self.wiggleOffset = 0 + self.legWiggle = 0 + self.earWiggle = 0 + } + + private func blink() { + withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 160_000_000) + withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 } + } + } + + private func wiggle() { + let targetAngle = Double.random(in: -4.5...4.5) + let targetOffset = CGFloat.random(in: -0.5...0.5) + withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) { + self.wiggleAngle = targetAngle + self.wiggleOffset = targetOffset + } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 360_000_000) + withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) { + self.wiggleAngle = 0 + self.wiggleOffset = 0 + } + } + } + + private func wiggleLegs() { + let target = CGFloat.random(in: 0.35...0.9) + withAnimation(.easeInOut(duration: 0.14)) { + self.legWiggle = target + } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 220_000_000) + withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 } + } + } + + private func scurry() { + let target = CGFloat.random(in: 0.7...1.0) + withAnimation(.easeInOut(duration: 0.12)) { + self.legWiggle = target + self.wiggleOffset = CGFloat.random(in: -0.6...0.6) + } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 180_000_000) + withAnimation(.easeOut(duration: 0.16)) { + self.legWiggle = 0.25 + self.wiggleOffset = 0 + } + } + } + + private func wiggleEars() { + let target = CGFloat.random(in: -1.2...1.2) + withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { + self.earWiggle = target + } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 320_000_000) + withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) { + self.earWiggle = 0 + } + } + } + + private func scheduleRandomTimers(from date: Date) { + self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5)) + self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14)) + self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0)) + self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0)) + } + + private var gatewayNeedsAttention: Bool { + if self.isSleeping { return false } + switch self.gatewayStatus { + case .failed, .stopped: + return !self.isPaused + case .starting, .running, .attachedExisting: + return false + } + } + + private var gatewayBadgeColor: Color { + switch self.gatewayStatus { + case .failed: .red + case .stopped: .orange + default: .clear + } + } +} + +#if DEBUG +@MainActor +extension CritterStatusLabel { + static func exerciseForTesting() async { + var label = CritterStatusLabel( + isPaused: false, + isSleeping: false, + isWorking: true, + earBoostActive: false, + blinkTick: 1, + sendCelebrationTick: 1, + gatewayStatus: .running(details: nil), + animationsEnabled: true, + iconState: .workingMain(.tool(.bash))) + + _ = label.body + _ = label.iconImage + _ = label.tickTaskID + label.tick(Date()) + label.resetMotion() + label.blink() + label.wiggle() + label.wiggleLegs() + label.wiggleEars() + label.scurry() + label.scheduleRandomTimers(from: Date()) + _ = label.gatewayNeedsAttention + _ = label.gatewayBadgeColor + + label.isPaused = true + _ = label.iconImage + + label.isPaused = false + label.isSleeping = true + _ = label.iconImage + + label.isSleeping = false + label.iconState = .idle + _ = label.iconImage + + let failed = CritterStatusLabel( + isPaused: false, + isSleeping: false, + isWorking: false, + earBoostActive: false, + blinkTick: 0, + sendCelebrationTick: 0, + gatewayStatus: .failed("boom"), + animationsEnabled: false, + iconState: .idle) + _ = failed.gatewayNeedsAttention + _ = failed.gatewayBadgeColor + + let stopped = CritterStatusLabel( + isPaused: false, + isSleeping: false, + isWorking: false, + earBoostActive: false, + blinkTick: 0, + sendCelebrationTick: 0, + gatewayStatus: .stopped, + animationsEnabled: false, + iconState: .idle) + _ = stopped.gatewayNeedsAttention + _ = stopped.gatewayBadgeColor + + _ = CritterIconRenderer.makeIcon( + blink: 0.6, + legWiggle: 0.8, + earWiggle: 0.4, + earScale: 1.4, + earHoles: true, + eyesClosedLines: true, + badge: .init(symbolName: "gearshape.fill", prominence: .secondary)) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift new file mode 100644 index 00000000..beeffdf8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct CritterStatusLabel: View { + var isPaused: Bool + var isSleeping: Bool + var isWorking: Bool + var earBoostActive: Bool + var blinkTick: Int + var sendCelebrationTick: Int + var gatewayStatus: GatewayProcessManager.Status + var animationsEnabled: Bool + var iconState: IconState + + @State var blinkAmount: CGFloat = 0 + @State var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5)) + @State var wiggleAngle: Double = 0 + @State var wiggleOffset: CGFloat = 0 + @State var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5...14)) + @State var legWiggle: CGFloat = 0 + @State var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0)) + @State var earWiggle: CGFloat = 0 + @State var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0)) +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift new file mode 100644 index 00000000..6b3fc85a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift @@ -0,0 +1,271 @@ +import Foundation +import OpenClawProtocol +import SwiftUI + +extension CronJobEditor { + func gridLabel(_ text: String) -> some View { + Text(text) + .foregroundStyle(.secondary) + .frame(width: self.labelColumnWidth, alignment: .leading) + } + + func hydrateFromJob() { + guard let job else { return } + self.name = job.name + self.description = job.description ?? "" + self.agentId = job.agentId ?? "" + self.enabled = job.enabled + self.deleteAfterRun = job.deleteAfterRun ?? false + self.sessionTarget = job.sessionTarget + self.wakeMode = job.wakeMode + + switch job.schedule { + case let .at(at): + self.scheduleKind = .at + if let date = CronSchedule.parseAtDate(at) { + self.atDate = date + } + case let .every(everyMs, _): + self.scheduleKind = .every + self.everyText = self.formatDuration(ms: everyMs) + case let .cron(expr, tz): + self.scheduleKind = .cron + self.cronExpr = expr + self.cronTz = tz ?? "" + } + + switch job.payload { + case let .systemEvent(text): + self.payloadKind = .systemEvent + self.systemEventText = text + case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _): + self.payloadKind = .agentTurn + self.agentMessage = message + self.thinking = thinking ?? "" + self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" + } + + if let delivery = job.delivery { + self.deliveryMode = delivery.mode == .announce ? .announce : .none + let trimmed = (delivery.channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + self.channel = trimmed.isEmpty ? "last" : trimmed + self.to = delivery.to ?? "" + self.bestEffortDeliver = delivery.bestEffort ?? false + } else if self.sessionTarget == .isolated { + self.deliveryMode = .announce + } + } + + func save() { + do { + self.error = nil + let payload = try self.buildPayload() + self.onSave(payload) + } catch { + self.error = error.localizedDescription + } + } + + func buildPayload() throws -> [String: AnyCodable] { + let name = try self.requireName() + let description = self.trimmed(self.description) + let agentId = self.trimmed(self.agentId) + let schedule = try self.buildSchedule() + let payload = try self.buildSelectedPayload() + + try self.validateSessionTarget(payload) + try self.validatePayloadRequiredFields(payload) + + var root: [String: Any] = [ + "name": name, + "enabled": self.enabled, + "schedule": schedule, + "sessionTarget": self.sessionTarget.rawValue, + "wakeMode": self.wakeMode.rawValue, + "payload": payload, + ] + self.applyDeleteAfterRun(to: &root) + if !description.isEmpty { root["description"] = description } + if !agentId.isEmpty { + root["agentId"] = agentId + } else if self.job?.agentId != nil { + root["agentId"] = NSNull() + } + + if self.sessionTarget == .isolated { + root["delivery"] = self.buildDelivery() + } + + return root.mapValues { AnyCodable($0) } + } + + func buildDelivery() -> [String: Any] { + let mode = self.deliveryMode == .announce ? "announce" : "none" + var delivery: [String: Any] = ["mode": mode] + if self.deliveryMode == .announce { + let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines) + delivery["channel"] = trimmed.isEmpty ? "last" : trimmed + let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) + if !to.isEmpty { delivery["to"] = to } + if self.bestEffortDeliver { + delivery["bestEffort"] = true + } else if self.job?.delivery?.bestEffort == true { + delivery["bestEffort"] = false + } + } + return delivery + } + + func trimmed(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func requireName() throws -> String { + let name = self.trimmed(self.name) + if name.isEmpty { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Name is required."]) + } + return name + } + + func buildSchedule() throws -> [String: Any] { + switch self.scheduleKind { + case .at: + return ["kind": "at", "at": CronSchedule.formatIsoDate(self.atDate)] + case .every: + guard let ms = Self.parseDurationMs(self.everyText) else { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."]) + } + return ["kind": "every", "everyMs": ms] + case .cron: + let expr = self.trimmed(self.cronExpr) + if expr.isEmpty { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."]) + } + let tz = self.trimmed(self.cronTz) + if tz.isEmpty { + return ["kind": "cron", "expr": expr] + } + return ["kind": "cron", "expr": expr, "tz": tz] + } + } + + func buildSelectedPayload() throws -> [String: Any] { + if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() } + switch self.payloadKind { + case .systemEvent: + let text = self.trimmed(self.systemEventText) + return ["kind": "systemEvent", "text": text] + case .agentTurn: + return self.buildAgentTurnPayload() + } + } + + func validateSessionTarget(_ payload: [String: Any]) throws { + if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [ + NSLocalizedDescriptionKey: + "Main session jobs require systemEvent payloads (switch Session target to isolated).", + ]) + } + + if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."]) + } + } + + func validatePayloadRequiredFields(_ payload: [String: Any]) throws { + if payload["kind"] as? String == "systemEvent" { + if (payload["text"] as? String ?? "").isEmpty { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "System event text is required."]) + } + } + if payload["kind"] as? String == "agentTurn" { + if (payload["message"] as? String ?? "").isEmpty { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Agent message is required."]) + } + } + } + + func applyDeleteAfterRun( + to root: inout [String: Any], + scheduleKind: ScheduleKind? = nil, + deleteAfterRun: Bool? = nil) + { + let resolvedSchedule = scheduleKind ?? self.scheduleKind + let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun + if resolvedSchedule == .at { + root["deleteAfterRun"] = resolvedDelete + } else if self.job?.deleteAfterRun != nil { + root["deleteAfterRun"] = false + } + } + + func buildAgentTurnPayload() -> [String: Any] { + let msg = self.agentMessage.trimmingCharacters(in: .whitespacesAndNewlines) + var payload: [String: Any] = ["kind": "agentTurn", "message": msg] + let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines) + if !thinking.isEmpty { payload["thinking"] = thinking } + if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } + return payload + } + + static func parseDurationMs(_ input: String) -> Int? { + let raw = input.trimmingCharacters(in: .whitespacesAndNewlines) + if raw.isEmpty { return nil } + + let rx = try? NSRegularExpression(pattern: "^(\\d+(?:\\.\\d+)?)(ms|s|m|h|d)$", options: [.caseInsensitive]) + guard let match = rx?.firstMatch(in: raw, range: NSRange(location: 0, length: raw.utf16.count)) else { + return nil + } + func group(_ idx: Int) -> String { + let range = match.range(at: idx) + guard let r = Range(range, in: raw) else { return "" } + return String(raw[r]) + } + let n = Double(group(1)) ?? 0 + if !n.isFinite || n <= 0 { return nil } + let unit = group(2).lowercased() + let factor: Double = switch unit { + case "ms": 1 + case "s": 1000 + case "m": 60000 + case "h": 3_600_000 + default: 86_400_000 + } + return Int(floor(n * factor)) + } + + func formatDuration(ms: Int) -> String { + if ms < 1000 { return "\(ms)ms" } + let s = Double(ms) / 1000.0 + if s < 60 { return "\(Int(round(s)))s" } + let m = s / 60.0 + if m < 60 { return "\(Int(round(m)))m" } + let h = m / 60.0 + if h < 48 { return "\(Int(round(h)))h" } + let d = h / 24.0 + return "\(Int(round(d)))d" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronJobEditor+Testing.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronJobEditor+Testing.swift new file mode 100644 index 00000000..83b5923e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronJobEditor+Testing.swift @@ -0,0 +1,28 @@ +#if DEBUG +extension CronJobEditor { + mutating func exerciseForTesting() { + self.name = "Test job" + self.description = "Test description" + self.agentId = "ops" + self.enabled = true + self.sessionTarget = .isolated + self.wakeMode = .now + + self.scheduleKind = .every + self.everyText = "15m" + + self.payloadKind = .agentTurn + self.agentMessage = "Run diagnostic" + self.deliveryMode = .announce + self.channel = "last" + self.to = "+15551230000" + self.thinking = "low" + self.timeoutSeconds = "90" + self.bestEffortDeliver = true + + _ = self.buildAgentTurnPayload() + _ = try? self.buildPayload() + _ = self.formatDuration(ms: 45000) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronJobEditor.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronJobEditor.swift new file mode 100644 index 00000000..a7d88a4f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronJobEditor.swift @@ -0,0 +1,362 @@ +import Observation +import OpenClawProtocol +import SwiftUI + +struct CronJobEditor: View { + let job: CronJob? + @Binding var isSaving: Bool + @Binding var error: String? + @Bindable var channelsStore: ChannelsStore + let onCancel: () -> Void + let onSave: ([String: AnyCodable]) -> Void + + let labelColumnWidth: CGFloat = 160 + static let introText = + "Create a schedule that wakes OpenClaw via the Gateway. " + + "Use an isolated session for agent turns so your main chat stays clean." + static let sessionTargetNote = + "Main jobs post a system event into the current main session. " + + "Isolated jobs run OpenClaw in a dedicated session and can announce results to a channel." + static let scheduleKindNote = + "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." + static let isolatedPayloadNote = + "Isolated jobs always run an agent turn. Announce sends a short summary to a channel." + static let mainPayloadNote = + "System events are injected into the current main session. Agent turns require an isolated session target." + + @State var name: String = "" + @State var description: String = "" + @State var agentId: String = "" + @State var enabled: Bool = true + @State var sessionTarget: CronSessionTarget = .main + @State var wakeMode: CronWakeMode = .now + @State var deleteAfterRun: Bool = false + + enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { + rawValue + } } + @State var scheduleKind: ScheduleKind = .every + @State var atDate: Date = .init().addingTimeInterval(60 * 5) + @State var everyText: String = "1h" + @State var cronExpr: String = "0 9 * * 3" + @State var cronTz: String = "" + + enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String { + rawValue + } } + @State var payloadKind: PayloadKind = .systemEvent + @State var systemEventText: String = "" + @State var agentMessage: String = "" + enum DeliveryChoice: String, CaseIterable, Identifiable { case announce, none; var id: String { + rawValue + } } + @State var deliveryMode: DeliveryChoice = .announce + @State var channel: String = "last" + @State var to: String = "" + @State var thinking: String = "" + @State var timeoutSeconds: String = "" + @State var bestEffortDeliver: Bool = false + + var channelOptions: [String] { + let ordered = self.channelsStore.orderedChannelIds() + var options = ["last"] + ordered + let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, !options.contains(trimmed) { + options.append(trimmed) + } + var seen = Set() + return options.filter { seen.insert($0).inserted } + } + + func channelLabel(for id: String) -> String { + if id == "last" { return "last" } + return self.channelsStore.resolveChannelLabel(id) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(self.job == nil ? "New cron job" : "Edit cron job") + .font(.title3.weight(.semibold)) + Text(Self.introText) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 14) { + GroupBox("Basics") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Name") + TextField("Required (e.g. “Daily summary”)", text: self.$name) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + } + GridRow { + self.gridLabel("Description") + TextField("Optional notes", text: self.$description) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + } + GridRow { + self.gridLabel("Agent ID") + TextField("Optional (default agent)", text: self.$agentId) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + } + GridRow { + self.gridLabel("Enabled") + Toggle("", isOn: self.$enabled) + .labelsHidden() + .toggleStyle(.switch) + } + GridRow { + self.gridLabel("Session target") + Picker("", selection: self.$sessionTarget) { + Text("main").tag(CronSessionTarget.main) + Text("isolated").tag(CronSessionTarget.isolated) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: .infinity, alignment: .leading) + } + GridRow { + self.gridLabel("Wake mode") + Picker("", selection: self.$wakeMode) { + Text("now").tag(CronWakeMode.now) + Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: .infinity, alignment: .leading) + } + GridRow { + Color.clear + .frame(width: self.labelColumnWidth, height: 1) + Text( + Self.sessionTargetNote) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + GroupBox("Schedule") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Kind") + Picker("", selection: self.$scheduleKind) { + Text("at").tag(ScheduleKind.at) + Text("every").tag(ScheduleKind.every) + Text("cron").tag(ScheduleKind.cron) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: .infinity) + } + GridRow { + Color.clear + .frame(width: self.labelColumnWidth, height: 1) + Text( + Self.scheduleKindNote) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + switch self.scheduleKind { + case .at: + GridRow { + self.gridLabel("At") + DatePicker( + "", + selection: self.$atDate, + displayedComponents: [.date, .hourAndMinute]) + .labelsHidden() + .frame(maxWidth: .infinity, alignment: .leading) + } + GridRow { + self.gridLabel("Auto-delete") + Toggle("Delete after successful run", isOn: self.$deleteAfterRun) + .toggleStyle(.switch) + } + case .every: + GridRow { + self.gridLabel("Every") + TextField("10m, 1h, 1d", text: self.$everyText) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + } + case .cron: + GridRow { + self.gridLabel("Expression") + TextField("e.g. 0 9 * * 3", text: self.$cronExpr) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + } + GridRow { + self.gridLabel("Timezone") + TextField("Optional (e.g. America/Los_Angeles)", text: self.$cronTz) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + } + } + } + } + + GroupBox("Payload") { + VStack(alignment: .leading, spacing: 10) { + if self.sessionTarget == .isolated { + Text(Self.isolatedPayloadNote) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + self.agentTurnEditor + } else { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Kind") + Picker("", selection: self.$payloadKind) { + Text("systemEvent").tag(PayloadKind.systemEvent) + Text("agentTurn").tag(PayloadKind.agentTurn) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: .infinity) + } + GridRow { + Color.clear + .frame(width: self.labelColumnWidth, height: 1) + Text( + Self.mainPayloadNote) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + switch self.payloadKind { + case .systemEvent: + TextField("System event text", text: self.$systemEventText, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(3...7) + .frame(maxWidth: .infinity) + case .agentTurn: + self.agentTurnEditor + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 2) + } + + if let error, !error.isEmpty { + Text(error) + .font(.footnote) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Button("Cancel") { self.onCancel() } + .keyboardShortcut(.cancelAction) + .buttonStyle(.bordered) + Spacer() + Button { + self.save() + } label: { + if self.isSaving { + ProgressView().controlSize(.small) + } else { + Text("Save") + } + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(self.isSaving) + } + } + .padding(24) + .frame(minWidth: 720, minHeight: 640) + .onAppear { self.hydrateFromJob() } + .onChange(of: self.payloadKind) { _, newValue in + if newValue == .agentTurn, self.sessionTarget == .main { + self.sessionTarget = .isolated + } + } + .onChange(of: self.sessionTarget) { _, newValue in + if newValue == .isolated { + self.payloadKind = .agentTurn + } else if newValue == .main, self.payloadKind == .agentTurn { + self.payloadKind = .systemEvent + } + } + } + + var agentTurnEditor: some View { + VStack(alignment: .leading, spacing: 10) { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Message") + TextField("What should OpenClaw do?", text: self.$agentMessage, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(3...7) + .frame(maxWidth: .infinity) + } + GridRow { + self.gridLabel("Thinking") + TextField("Optional (e.g. low)", text: self.$thinking) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + } + GridRow { + self.gridLabel("Timeout") + TextField("Seconds (optional)", text: self.$timeoutSeconds) + .textFieldStyle(.roundedBorder) + .frame(width: 180, alignment: .leading) + } + GridRow { + self.gridLabel("Delivery") + Picker("", selection: self.$deliveryMode) { + Text("Announce summary").tag(DeliveryChoice.announce) + Text("None").tag(DeliveryChoice.none) + } + .labelsHidden() + .pickerStyle(.segmented) + } + } + + if self.deliveryMode == .announce { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Channel") + Picker("", selection: self.$channel) { + ForEach(self.channelOptions, id: \.self) { channel in + Text(self.channelLabel(for: channel)).tag(channel) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: .infinity, alignment: .leading) + } + GridRow { + self.gridLabel("To") + TextField("Optional override (phone number / chat id / Discord channel)", text: self.$to) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + } + GridRow { + self.gridLabel("Best-effort") + Toggle("Do not fail the job if announce fails", isOn: self.$bestEffortDeliver) + .toggleStyle(.switch) + } + } + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronJobsStore.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronJobsStore.swift new file mode 100644 index 00000000..21c70ded --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronJobsStore.swift @@ -0,0 +1,200 @@ +import Foundation +import Observation +import OpenClawKit +import OpenClawProtocol +import OSLog + +@MainActor +@Observable +final class CronJobsStore { + static let shared = CronJobsStore() + + var jobs: [CronJob] = [] + var selectedJobId: String? + var runEntries: [CronRunLogEntry] = [] + + var schedulerEnabled: Bool? + var schedulerStorePath: String? + var schedulerNextWakeAtMs: Int? + + var isLoadingJobs = false + var isLoadingRuns = false + var lastError: String? + var statusMessage: String? + + private let logger = Logger(subsystem: "ai.openclaw", category: "cron.ui") + private var refreshTask: Task? + private var runsTask: Task? + private var eventTask: Task? + private var pollTask: Task? + + private let interval: TimeInterval = 30 + private let isPreview: Bool + + init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { + self.isPreview = isPreview + } + + func start() { + guard !self.isPreview else { return } + guard self.eventTask == nil else { return } + self.startGatewaySubscription() + self.pollTask = Task.detached { [weak self] in + guard let self else { return } + await self.refreshJobs() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) + await self.refreshJobs() + } + } + } + + func stop() { + self.refreshTask?.cancel() + self.refreshTask = nil + self.runsTask?.cancel() + self.runsTask = nil + self.eventTask?.cancel() + self.eventTask = nil + self.pollTask?.cancel() + self.pollTask = nil + } + + func refreshJobs() async { + guard !self.isLoadingJobs else { return } + self.isLoadingJobs = true + self.lastError = nil + self.statusMessage = nil + defer { self.isLoadingJobs = false } + + do { + if let status = try? await GatewayConnection.shared.cronStatus() { + self.schedulerEnabled = status.enabled + self.schedulerStorePath = status.storePath + self.schedulerNextWakeAtMs = status.nextWakeAtMs + } + self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true) + if self.jobs.isEmpty { + self.statusMessage = "No cron jobs yet." + } + } catch { + self.logger.error("cron.list failed \(error.localizedDescription, privacy: .public)") + self.lastError = error.localizedDescription + } + } + + func refreshRuns(jobId: String, limit: Int = 200) async { + guard !self.isLoadingRuns else { return } + self.isLoadingRuns = true + defer { self.isLoadingRuns = false } + + do { + self.runEntries = try await GatewayConnection.shared.cronRuns(jobId: jobId, limit: limit) + } catch { + self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)") + self.lastError = error.localizedDescription + } + } + + func runJob(id: String, force: Bool = true) async { + do { + try await GatewayConnection.shared.cronRun(jobId: id, force: force) + } catch { + self.lastError = error.localizedDescription + } + } + + func removeJob(id: String) async { + do { + try await GatewayConnection.shared.cronRemove(jobId: id) + await self.refreshJobs() + if self.selectedJobId == id { + self.selectedJobId = nil + self.runEntries = [] + } + } catch { + self.lastError = error.localizedDescription + } + } + + func setJobEnabled(id: String, enabled: Bool) async { + do { + try await GatewayConnection.shared.cronUpdate( + jobId: id, + patch: ["enabled": AnyCodable(enabled)]) + await self.refreshJobs() + } catch { + self.lastError = error.localizedDescription + } + } + + func upsertJob( + id: String?, + payload: [String: AnyCodable]) async throws + { + if let id { + try await GatewayConnection.shared.cronUpdate(jobId: id, patch: payload) + } else { + try await GatewayConnection.shared.cronAdd(payload: payload) + } + await self.refreshJobs() + } + + // MARK: - Gateway events + + private func startGatewaySubscription() { + self.eventTask?.cancel() + self.eventTask = Task { [weak self] in + guard let self else { return } + let stream = await GatewayConnection.shared.subscribe() + for await push in stream { + if Task.isCancelled { return } + await MainActor.run { [weak self] in + self?.handle(push: push) + } + } + } + } + + private func handle(push: GatewayPush) { + switch push { + case let .event(evt) where evt.event == "cron": + guard let payload = evt.payload else { return } + if let cronEvt = try? GatewayPayloadDecoding.decode(payload, as: CronEvent.self) { + self.handle(cronEvent: cronEvt) + } + case .seqGap: + self.scheduleRefresh() + default: + break + } + } + + private func handle(cronEvent evt: CronEvent) { + // Keep UI in sync with the gateway scheduler. + self.scheduleRefresh(delayMs: 250) + if evt.action == "finished", let selected = self.selectedJobId, selected == evt.jobId { + self.scheduleRunsRefresh(jobId: selected, delayMs: 200) + } + } + + private func scheduleRefresh(delayMs: Int = 250) { + self.refreshTask?.cancel() + self.refreshTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) + await self.refreshJobs() + } + } + + private func scheduleRunsRefresh(jobId: String, delayMs: Int = 200) { + self.runsTask?.cancel() + self.runsTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) + await self.refreshRuns(jobId: jobId) + } + } + + // MARK: - (no additional RPC helpers) +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronModels.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronModels.swift new file mode 100644 index 00000000..cbfbc061 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronModels.swift @@ -0,0 +1,271 @@ +import Foundation + +enum CronSessionTarget: String, CaseIterable, Identifiable, Codable { + case main + case isolated + + var id: String { + self.rawValue + } +} + +enum CronWakeMode: String, CaseIterable, Identifiable, Codable { + case now + case nextHeartbeat = "next-heartbeat" + + var id: String { + self.rawValue + } +} + +enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable { + case none + case announce + case webhook + + var id: String { + self.rawValue + } +} + +struct CronDelivery: Codable, Equatable { + var mode: CronDeliveryMode + var channel: String? + var to: String? + var bestEffort: Bool? +} + +enum CronSchedule: Codable, Equatable { + case at(at: String) + case every(everyMs: Int, anchorMs: Int?) + case cron(expr: String, tz: String?) + + enum CodingKeys: String, CodingKey { case kind, at, atMs, everyMs, anchorMs, expr, tz } + + var kind: String { + switch self { + case .at: "at" + case .every: "every" + case .cron: "cron" + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + switch kind { + case "at": + if let at = try container.decodeIfPresent(String.self, forKey: .at), + !at.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + self = .at(at: at) + return + } + if let atMs = try container.decodeIfPresent(Int.self, forKey: .atMs) { + let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000) + self = .at(at: Self.formatIsoDate(date)) + return + } + throw DecodingError.dataCorruptedError( + forKey: .at, + in: container, + debugDescription: "Missing schedule.at") + case "every": + self = try .every( + everyMs: container.decode(Int.self, forKey: .everyMs), + anchorMs: container.decodeIfPresent(Int.self, forKey: .anchorMs)) + case "cron": + self = try .cron( + expr: container.decode(String.self, forKey: .expr), + tz: container.decodeIfPresent(String.self, forKey: .tz)) + default: + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: container, + debugDescription: "Unknown schedule kind: \(kind)") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.kind, forKey: .kind) + switch self { + case let .at(at): + try container.encode(at, forKey: .at) + case let .every(everyMs, anchorMs): + try container.encode(everyMs, forKey: .everyMs) + try container.encodeIfPresent(anchorMs, forKey: .anchorMs) + case let .cron(expr, tz): + try container.encode(expr, forKey: .expr) + try container.encodeIfPresent(tz, forKey: .tz) + } + } + + static func parseAtDate(_ value: String) -> Date? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + if let date = makeIsoFormatter(withFractional: true).date(from: trimmed) { return date } + return self.makeIsoFormatter(withFractional: false).date(from: trimmed) + } + + static func formatIsoDate(_ date: Date) -> String { + self.makeIsoFormatter(withFractional: false).string(from: date) + } + + private static func makeIsoFormatter(withFractional: Bool) -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = withFractional + ? [.withInternetDateTime, .withFractionalSeconds] + : [.withInternetDateTime] + return formatter + } +} + +enum CronPayload: Codable, Equatable { + case systemEvent(text: String) + case agentTurn( + message: String, + thinking: String?, + timeoutSeconds: Int?, + deliver: Bool?, + channel: String?, + to: String?, + bestEffortDeliver: Bool?) + + enum CodingKeys: String, CodingKey { + case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver + } + + var kind: String { + switch self { + case .systemEvent: "systemEvent" + case .agentTurn: "agentTurn" + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + switch kind { + case "systemEvent": + self = try .systemEvent(text: container.decode(String.self, forKey: .text)) + case "agentTurn": + self = try .agentTurn( + message: container.decode(String.self, forKey: .message), + thinking: container.decodeIfPresent(String.self, forKey: .thinking), + timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds), + deliver: container.decodeIfPresent(Bool.self, forKey: .deliver), + channel: container.decodeIfPresent(String.self, forKey: .channel) + ?? container.decodeIfPresent(String.self, forKey: .provider), + to: container.decodeIfPresent(String.self, forKey: .to), + bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)) + default: + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: container, + debugDescription: "Unknown payload kind: \(kind)") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.kind, forKey: .kind) + switch self { + case let .systemEvent(text): + try container.encode(text, forKey: .text) + case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): + try container.encode(message, forKey: .message) + try container.encodeIfPresent(thinking, forKey: .thinking) + try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds) + try container.encodeIfPresent(deliver, forKey: .deliver) + try container.encodeIfPresent(channel, forKey: .channel) + try container.encodeIfPresent(to, forKey: .to) + try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver) + } + } +} + +struct CronJobState: Codable, Equatable { + var nextRunAtMs: Int? + var runningAtMs: Int? + var lastRunAtMs: Int? + var lastStatus: String? + var lastError: String? + var lastDurationMs: Int? +} + +struct CronJob: Identifiable, Codable, Equatable { + let id: String + let agentId: String? + var name: String + var description: String? + var enabled: Bool + var deleteAfterRun: Bool? + let createdAtMs: Int + let updatedAtMs: Int + let schedule: CronSchedule + let sessionTarget: CronSessionTarget + let wakeMode: CronWakeMode + let payload: CronPayload + let delivery: CronDelivery? + let state: CronJobState + + var displayName: String { + let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Untitled job" : trimmed + } + + var nextRunDate: Date? { + guard let ms = self.state.nextRunAtMs else { return nil } + return Date(timeIntervalSince1970: TimeInterval(ms) / 1000) + } + + var lastRunDate: Date? { + guard let ms = self.state.lastRunAtMs else { return nil } + return Date(timeIntervalSince1970: TimeInterval(ms) / 1000) + } +} + +struct CronEvent: Codable, Sendable { + let jobId: String + let action: String + let runAtMs: Int? + let durationMs: Int? + let status: String? + let error: String? + let summary: String? + let nextRunAtMs: Int? +} + +struct CronRunLogEntry: Codable, Identifiable, Sendable { + var id: String { + "\(self.jobId)-\(self.ts)" + } + + let ts: Int + let jobId: String + let action: String + let status: String? + let error: String? + let summary: String? + let runAtMs: Int? + let durationMs: Int? + let nextRunAtMs: Int? + + var date: Date { + Date(timeIntervalSince1970: TimeInterval(self.ts) / 1000) + } + + var runDate: Date? { + guard let runAtMs else { return nil } + return Date(timeIntervalSince1970: TimeInterval(runAtMs) / 1000) + } +} + +struct CronListResponse: Codable { + let jobs: [CronJob] +} + +struct CronRunsResponse: Codable { + let entries: [CronRunLogEntry] +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Actions.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Actions.swift new file mode 100644 index 00000000..3fffaf90 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Actions.swift @@ -0,0 +1,23 @@ +import Foundation +import OpenClawProtocol + +extension CronSettings { + func save(payload: [String: AnyCodable]) async { + guard !self.isSaving else { return } + self.isSaving = true + self.editorError = nil + do { + try await self.store.upsertJob(id: self.editingJob?.id, payload: payload) + await MainActor.run { + self.isSaving = false + self.showEditor = false + self.editingJob = nil + } + } catch { + await MainActor.run { + self.isSaving = false + self.editorError = error.localizedDescription + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift new file mode 100644 index 00000000..c638e4c8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift @@ -0,0 +1,56 @@ +import SwiftUI + +extension CronSettings { + var selectedJob: CronJob? { + guard let id = self.store.selectedJobId else { return nil } + return self.store.jobs.first(where: { $0.id == id }) + } + + func statusTint(_ status: String?) -> Color { + switch (status ?? "").lowercased() { + case "ok": .green + case "error": .red + case "skipped": .orange + default: .secondary + } + } + + func scheduleSummary(_ schedule: CronSchedule) -> String { + switch schedule { + case let .at(at): + if let date = CronSchedule.parseAtDate(at) { + return "at \(date.formatted(date: .abbreviated, time: .standard))" + } + return "at \(at)" + case let .every(everyMs, _): + return "every \(self.formatDuration(ms: everyMs))" + case let .cron(expr, tz): + if let tz, !tz.isEmpty { return "cron \(expr) (\(tz))" } + return "cron \(expr)" + } + } + + func formatDuration(ms: Int) -> String { + if ms < 1000 { return "\(ms)ms" } + let s = Double(ms) / 1000.0 + if s < 60 { return "\(Int(round(s)))s" } + let m = s / 60.0 + if m < 60 { return "\(Int(round(m)))m" } + let h = m / 60.0 + if h < 48 { return "\(Int(round(h)))h" } + let d = h / 24.0 + return "\(Int(round(d)))d" + } + + func nextRunLabel(_ date: Date, now: Date = .init()) -> String { + let delta = date.timeIntervalSince(now) + if delta <= 0 { return "due" } + if delta < 60 { return "in <1m" } + let minutes = Int(round(delta / 60)) + if minutes < 60 { return "in \(minutes)m" } + let hours = Int(round(Double(minutes) / 60)) + if hours < 48 { return "in \(hours)h" } + let days = Int(round(Double(hours) / 24)) + return "in \(days)d" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Layout.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Layout.swift new file mode 100644 index 00000000..11c7c0a0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Layout.swift @@ -0,0 +1,179 @@ +import SwiftUI + +extension CronSettings { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + self.header + self.schedulerBanner + self.content + Spacer(minLength: 0) + } + .onAppear { + self.store.start() + self.channelsStore.start() + } + .onDisappear { + self.store.stop() + self.channelsStore.stop() + } + .sheet(isPresented: self.$showEditor) { + CronJobEditor( + job: self.editingJob, + isSaving: self.$isSaving, + error: self.$editorError, + channelsStore: self.channelsStore, + onCancel: { + self.showEditor = false + self.editingJob = nil + }, + onSave: { payload in + Task { + await self.save(payload: payload) + } + }) + } + .alert("Delete cron job?", isPresented: Binding( + get: { self.confirmDelete != nil }, + set: { if !$0 { self.confirmDelete = nil } })) + { + Button("Cancel", role: .cancel) { self.confirmDelete = nil } + Button("Delete", role: .destructive) { + if let job = self.confirmDelete { + Task { await self.store.removeJob(id: job.id) } + } + self.confirmDelete = nil + } + } message: { + if let job = self.confirmDelete { + Text(job.displayName) + } + } + .onChange(of: self.store.selectedJobId) { _, newValue in + guard let newValue else { return } + Task { await self.store.refreshRuns(jobId: newValue) } + } + } + + var schedulerBanner: some View { + Group { + if self.store.schedulerEnabled == false { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Cron scheduler is disabled") + .font(.headline) + Spacer() + } + Text( + "Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " + + "and the Gateway restarts.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let storePath = self.store.schedulerStorePath, !storePath.isEmpty { + Text(storePath) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.orange.opacity(0.10)) + .cornerRadius(8) + } + } + } + + var header: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("Cron") + .font(.headline) + Text("Manage Gateway cron jobs (main session vs isolated runs) and inspect run history.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + HStack(spacing: 8) { + Button { + Task { await self.store.refreshJobs() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .disabled(self.store.isLoadingJobs) + + Button { + self.editorError = nil + self.editingJob = nil + self.showEditor = true + } label: { + Label("New Job", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + } + } + } + + var content: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + if let err = self.store.lastError { + Text("Error: \(err)") + .font(.footnote) + .foregroundStyle(.red) + } else if let msg = self.store.statusMessage { + Text(msg) + .font(.footnote) + .foregroundStyle(.secondary) + } + + List(selection: self.$store.selectedJobId) { + ForEach(self.store.jobs) { job in + self.jobRow(job) + .tag(job.id) + .contextMenu { self.jobContextMenu(job) } + } + } + .listStyle(.inset) + } + .frame(width: 250) + + Divider() + + self.detail + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + + @ViewBuilder + var detail: some View { + if let selected = self.selectedJob { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 12) { + self.detailHeader(selected) + self.detailCard(selected) + self.runHistoryCard(selected) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Select a job to inspect details and run history.") + .font(.callout) + .foregroundStyle(.secondary) + Text("Tip: use ‘New Job’ to add one, or enable cron in your gateway config.") + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.top, 8) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift new file mode 100644 index 00000000..69655bdc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift @@ -0,0 +1,246 @@ +import SwiftUI + +extension CronSettings { + func jobRow(_ job: CronJob) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(job.displayName) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + if !job.enabled { + StatusPill(text: "disabled", tint: .secondary) + } else if let next = job.nextRunDate { + StatusPill(text: self.nextRunLabel(next), tint: .secondary) + } else { + StatusPill(text: "no next run", tint: .secondary) + } + } + HStack(spacing: 6) { + StatusPill(text: job.sessionTarget.rawValue, tint: .secondary) + StatusPill(text: job.wakeMode.rawValue, tint: .secondary) + if let agentId = job.agentId, !agentId.isEmpty { + StatusPill(text: "agent \(agentId)", tint: .secondary) + } + if let status = job.state.lastStatus { + StatusPill(text: status, tint: status == "ok" ? .green : .orange) + } + } + } + .padding(.vertical, 6) + } + + @ViewBuilder + func jobContextMenu(_ job: CronJob) -> some View { + Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } } + if job.sessionTarget == .isolated { + Button("Open transcript") { + WebChatManager.shared.show(sessionKey: "cron:\(job.id)") + } + } + Divider() + Button(job.enabled ? "Disable" : "Enable") { + Task { await self.store.setJobEnabled(id: job.id, enabled: !job.enabled) } + } + Button("Edit…") { + self.editingJob = job + self.editorError = nil + self.showEditor = true + } + Divider() + Button("Delete…", role: .destructive) { + self.confirmDelete = job + } + } + + func detailHeader(_ job: CronJob) -> some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 4) { + Text(job.displayName) + .font(.title3.weight(.semibold)) + Text(job.id) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + HStack(spacing: 8) { + Toggle("Enabled", isOn: Binding( + get: { job.enabled }, + set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } })) + .toggleStyle(.switch) + .labelsHidden() + Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } } + .buttonStyle(.borderedProminent) + if job.sessionTarget == .isolated { + Button("Transcript") { + WebChatManager.shared.show(sessionKey: "cron:\(job.id)") + } + .buttonStyle(.bordered) + } + Button("Edit") { + self.editingJob = job + self.editorError = nil + self.showEditor = true + } + .buttonStyle(.bordered) + } + } + } + + func detailCard(_ job: CronJob) -> some View { + VStack(alignment: .leading, spacing: 10) { + LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) } + if case .at = job.schedule, job.deleteAfterRun == true { + LabeledContent("Auto-delete") { Text("after success") } + } + if let desc = job.description, !desc.isEmpty { + LabeledContent("Description") { Text(desc).font(.callout) } + } + if let agentId = job.agentId, !agentId.isEmpty { + LabeledContent("Agent") { Text(agentId) } + } + LabeledContent("Session") { Text(job.sessionTarget.rawValue) } + LabeledContent("Wake") { Text(job.wakeMode.rawValue) } + LabeledContent("Next run") { + if let date = job.nextRunDate { + Text(date.formatted(date: .abbreviated, time: .standard)) + } else { + Text("—").foregroundStyle(.secondary) + } + } + LabeledContent("Last run") { + if let date = job.lastRunDate { + Text("\(date.formatted(date: .abbreviated, time: .standard)) · \(relativeAge(from: date))") + } else { + Text("—").foregroundStyle(.secondary) + } + } + if let status = job.state.lastStatus { + LabeledContent("Last status") { Text(status) } + } + if let err = job.state.lastError, !err.isEmpty { + Text(err) + .font(.footnote) + .foregroundStyle(.orange) + .textSelection(.enabled) + } + self.payloadSummary(job) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.06)) + .cornerRadius(8) + } + + func runHistoryCard(_ job: CronJob) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Run history") + .font(.headline) + Spacer() + Button { + Task { await self.store.refreshRuns(jobId: job.id) } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .disabled(self.store.isLoadingRuns) + } + + if self.store.isLoadingRuns { + ProgressView().controlSize(.small) + } + + if self.store.runEntries.isEmpty { + Text("No run log entries yet.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(self.store.runEntries) { entry in + self.runRow(entry) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.06)) + .cornerRadius(8) + } + + func runRow(_ entry: CronRunLogEntry) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + StatusPill(text: entry.status ?? "unknown", tint: self.statusTint(entry.status)) + Text(entry.date.formatted(date: .abbreviated, time: .standard)) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if let ms = entry.durationMs { + Text("\(ms)ms") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + if let summary = entry.summary, !summary.isEmpty { + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(2) + } + if let error = entry.error, !error.isEmpty { + Text(error) + .font(.caption) + .foregroundStyle(.orange) + .textSelection(.enabled) + .lineLimit(2) + } + } + .padding(.vertical, 4) + } + + func payloadSummary(_ job: CronJob) -> some View { + let payload = job.payload + return VStack(alignment: .leading, spacing: 6) { + Text("Payload") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + switch payload { + case let .systemEvent(text): + Text(text) + .font(.callout) + .textSelection(.enabled) + case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _): + VStack(alignment: .leading, spacing: 4) { + Text(message) + .font(.callout) + .textSelection(.enabled) + HStack(spacing: 8) { + if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) } + if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) } + if job.sessionTarget == .isolated { + let delivery = job.delivery + if let delivery { + if delivery.mode == .announce { + StatusPill(text: "announce", tint: .secondary) + if let channel = delivery.channel, !channel.isEmpty { + StatusPill(text: channel, tint: .secondary) + } + if let to = delivery.to, !to.isEmpty { StatusPill(text: to, tint: .secondary) } + } else { + StatusPill(text: "no delivery", tint: .secondary) + } + } + } + } + } + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift new file mode 100644 index 00000000..4b51a4a9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings+Testing.swift @@ -0,0 +1,121 @@ +import SwiftUI + +#if DEBUG +struct CronSettings_Previews: PreviewProvider { + static var previews: some View { + let store = CronJobsStore(isPreview: true) + store.jobs = [ + CronJob( + id: "job-1", + agentId: "ops", + name: "Daily summary", + description: nil, + enabled: true, + deleteAfterRun: nil, + createdAtMs: 0, + updatedAtMs: 0, + schedule: .every(everyMs: 86_400_000, anchorMs: nil), + sessionTarget: .isolated, + wakeMode: .now, + payload: .agentTurn( + message: "Summarize inbox", + thinking: "low", + timeoutSeconds: 600, + deliver: nil, + channel: nil, + to: nil, + bestEffortDeliver: nil), + delivery: CronDelivery(mode: .announce, channel: "last", to: nil, bestEffort: true), + state: CronJobState( + nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), + runningAtMs: nil, + lastRunAtMs: nil, + lastStatus: nil, + lastError: nil, + lastDurationMs: nil)), + ] + store.selectedJobId = "job-1" + store.runEntries = [ + CronRunLogEntry( + ts: Int(Date().timeIntervalSince1970 * 1000), + jobId: "job-1", + action: "finished", + status: "ok", + error: nil, + summary: "All good.", + runAtMs: nil, + durationMs: 1234, + nextRunAtMs: nil), + ] + return CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true)) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} + +@MainActor +extension CronSettings { + static func exerciseForTesting() { + let store = CronJobsStore(isPreview: true) + store.schedulerEnabled = false + store.schedulerStorePath = "/tmp/openclaw-cron-store.json" + + let job = CronJob( + id: "job-1", + agentId: "ops", + name: "Daily summary", + description: "Summary job", + enabled: true, + deleteAfterRun: nil, + createdAtMs: 1_700_000_000_000, + updatedAtMs: 1_700_000_100_000, + schedule: .cron(expr: "0 8 * * *", tz: "UTC"), + sessionTarget: .isolated, + wakeMode: .nextHeartbeat, + payload: .agentTurn( + message: "Summarize", + thinking: "low", + timeoutSeconds: 120, + deliver: nil, + channel: nil, + to: nil, + bestEffortDeliver: nil), + delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true), + state: CronJobState( + nextRunAtMs: 1_700_000_200_000, + runningAtMs: nil, + lastRunAtMs: 1_700_000_050_000, + lastStatus: "ok", + lastError: nil, + lastDurationMs: 1200)) + + let run = CronRunLogEntry( + ts: 1_700_000_050_000, + jobId: job.id, + action: "finished", + status: "ok", + error: nil, + summary: "done", + runAtMs: 1_700_000_050_000, + durationMs: 1200, + nextRunAtMs: 1_700_000_200_000) + + store.jobs = [job] + store.selectedJobId = job.id + store.runEntries = [run] + + let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true)) + _ = view.body + _ = view.jobRow(job) + _ = view.jobContextMenu(job) + _ = view.detailHeader(job) + _ = view.detailCard(job) + _ = view.runHistoryCard(job) + _ = view.runRow(run) + _ = view.payloadSummary(job) + _ = view.scheduleSummary(job.schedule) + _ = view.statusTint(job.state.lastStatus) + _ = view.nextRunLabel(Date()) + _ = view.formatDuration(ms: 1234) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings.swift new file mode 100644 index 00000000..999712a5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/CronSettings.swift @@ -0,0 +1,17 @@ +import Observation +import SwiftUI + +struct CronSettings: View { + @Bindable var store: CronJobsStore + @Bindable var channelsStore: ChannelsStore + @State var showEditor = false + @State var editingJob: CronJob? + @State var editorError: String? + @State var isSaving = false + @State var confirmDelete: CronJob? + + init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared) { + self.store = store + self.channelsStore = channelsStore + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DebugActions.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DebugActions.swift new file mode 100644 index 00000000..706d9cc2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DebugActions.swift @@ -0,0 +1,265 @@ +import AppKit +import Foundation +import SwiftUI + +enum DebugActions { + private static let verboseDefaultsKey = "openclaw.debug.verboseMain" + private static let sessionMenuLimit = 12 + private static let onboardingSeenKey = "openclaw.onboardingSeen" + + @MainActor + static func openAgentEventsWindow() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 620, height: 420), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false) + window.title = "Agent Events" + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: AgentEventsWindow()) + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + @MainActor + static func openLog() { + let path = self.pinoLogPath() + let url = URL(fileURLWithPath: path) + guard FileManager().fileExists(atPath: path) else { + let alert = NSAlert() + alert.messageText = "Log file not found" + alert.informativeText = path + alert.runModal() + return + } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + @MainActor + static func openConfigFolder() { + let url = OpenClawPaths.stateDirURL + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + @MainActor + static func openSessionStore() { + if AppStateStore.shared.connectionMode == .remote { + let alert = NSAlert() + alert.messageText = "Remote mode" + alert.informativeText = "Session store lives on the gateway host in remote mode." + alert.runModal() + return + } + let path = self.resolveSessionStorePath() + let url = URL(fileURLWithPath: path) + if FileManager().fileExists(atPath: path) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } else { + NSWorkspace.shared.open(url.deletingLastPathComponent()) + } + } + + static func sendTestNotification() async { + _ = await NotificationManager().send(title: "OpenClaw", body: "Test notification", sound: nil) + } + + static func sendDebugVoice() async -> Result { + let message = """ + This is a debug test from the Mac app. Reply with "Debug test works (and a funny pun)" \ + if you received that. + """ + let result = await VoiceWakeForwarder.forward(transcript: message) + switch result { + case .success: + return .success("Sent. Await reply.") + case let .failure(error): + let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return .failure(.message("Send failed: \(detail)")) + } + } + + static func restartGateway() { + Task { @MainActor in + switch AppStateStore.shared.connectionMode { + case .local: + GatewayProcessManager.shared.stop() + // Kick the control channel + health check so the UI recovers immediately. + await GatewayConnection.shared.shutdown() + try? await Task.sleep(nanoseconds: 300_000_000) + GatewayProcessManager.shared.setActive(true) + Task { try? await ControlChannel.shared.configure(mode: .local) } + Task { await HealthStore.shared.refresh(onDemand: true) } + + case .remote: + // In remote mode, there is no local gateway to restart. "Restart Gateway" should + // reset the SSH control tunnel + reconnect so the menu recovers. + await RemoteTunnelManager.shared.stopAll() + await GatewayConnection.shared.shutdown() + do { + _ = try await RemoteTunnelManager.shared.ensureControlTunnel() + let settings = CommandResolver.connectionSettings() + try await ControlChannel.shared.configure(mode: .remote( + target: settings.target, + identity: settings.identity)) + } catch { + // ControlChannel will surface a degraded state; also refresh health to update the menu text. + Task { await HealthStore.shared.refresh(onDemand: true) } + } + + case .unconfigured: + await GatewayConnection.shared.shutdown() + await ControlChannel.shared.disconnect() + } + } + } + + static func resetGatewayTunnel() async -> Result { + let mode = CommandResolver.connectionSettings().mode + guard mode == .remote else { + return .failure(.message("Remote mode is not enabled.")) + } + await RemoteTunnelManager.shared.stopAll() + await GatewayConnection.shared.shutdown() + do { + _ = try await RemoteTunnelManager.shared.ensureControlTunnel() + let settings = CommandResolver.connectionSettings() + try await ControlChannel.shared.configure(mode: .remote( + target: settings.target, + identity: settings.identity)) + await HealthStore.shared.refresh(onDemand: true) + return .success("SSH tunnel reset.") + } catch { + Task { await HealthStore.shared.refresh(onDemand: true) } + return .failure(.message(error.localizedDescription)) + } + } + + static func pinoLogPath() -> String { + LogLocator.bestLogFile()?.path ?? LogLocator.launchdLogPath + } + + @MainActor + static func runHealthCheckNow() async { + await HealthStore.shared.refresh(onDemand: true) + } + + static func sendTestHeartbeat() async -> Result { + do { + _ = await GatewayConnection.shared.setHeartbeatsEnabled(true) + await ControlChannel.shared.configure() + let data = try await ControlChannel.shared.request(method: "last-heartbeat") + if let evt = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) { + return .success(evt) + } + return .success(nil) + } catch { + return .failure(error) + } + } + + static var verboseLoggingEnabledMain: Bool { + UserDefaults.standard.bool(forKey: self.verboseDefaultsKey) + } + + static func toggleVerboseLoggingMain() async -> Bool { + let newValue = !self.verboseLoggingEnabledMain + UserDefaults.standard.set(newValue, forKey: self.verboseDefaultsKey) + _ = try? await ControlChannel.shared.request( + method: "system-event", + params: ["text": AnyHashable("verbose-main:\(newValue ? "on" : "off")")]) + return newValue + } + + @MainActor + static func restartApp() { + let url = Bundle.main.bundleURL + let task = Process() + // Relaunch shortly after this instance exits so we get a true restart even in debug. + task.launchPath = "/bin/sh" + task.arguments = ["-c", "sleep 0.2; open -n \"$1\"", "_", url.path] + try? task.run() + NSApp.terminate(nil) + } + + @MainActor + static func restartOnboarding() { + UserDefaults.standard.set(false, forKey: self.onboardingSeenKey) + UserDefaults.standard.set(0, forKey: onboardingVersionKey) + AppStateStore.shared.onboardingSeen = false + OnboardingController.shared.restart() + } + + @MainActor + private static func resolveSessionStorePath() -> String { + let defaultPath = SessionLoader.defaultStorePath + let configURL = OpenClawPaths.configURL + guard + let data = try? Data(contentsOf: configURL), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let session = parsed["session"] as? [String: Any], + let path = session["store"] as? String, + !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return defaultPath + } + return path + } + + // MARK: - Sessions (thinking / verbose) + + static func recentSessions(limit: Int = sessionMenuLimit) async -> [SessionRow] { + guard let snapshot = try? await SessionLoader.loadSnapshot(limit: limit) else { return [] } + return Array(snapshot.rows.prefix(limit)) + } + + static func updateSession( + key: String, + thinking: String?, + verbose: String?) async throws + { + var params: [String: AnyHashable] = ["key": AnyHashable(key)] + params["thinkingLevel"] = thinking.map(AnyHashable.init) ?? AnyHashable(NSNull()) + params["verboseLevel"] = verbose.map(AnyHashable.init) ?? AnyHashable(NSNull()) + _ = try await ControlChannel.shared.request(method: "sessions.patch", params: params) + } + + // MARK: - Port diagnostics + + typealias PortListener = PortGuardian.ReportListener + typealias PortReport = PortGuardian.PortReport + + static func checkGatewayPorts() async -> [PortReport] { + let mode = CommandResolver.connectionSettings().mode + return await PortGuardian.shared.diagnose(mode: mode) + } + + static func killProcess(_ pid: Int) async -> Result { + let primary = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2) + if primary.ok { return .success(()) } + let force = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2) + if force.ok { return .success(()) } + let detail = force.message ?? primary.message ?? "kill failed" + return .failure(.message(detail)) + } + + @MainActor + static func openSessionStoreInCode() { + let path = SessionLoader.defaultStorePath + let proc = Process() + proc.launchPath = "/usr/bin/env" + proc.arguments = ["code", path] + try? proc.run() + } +} + +enum DebugActionError: LocalizedError { + case message(String) + + var errorDescription: String? { + switch self { + case let .message(text): + text + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DebugSettings.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DebugSettings.swift new file mode 100644 index 00000000..678ffc9e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DebugSettings.swift @@ -0,0 +1,1026 @@ +import AppKit +import Observation +import SwiftUI +import UniformTypeIdentifiers + +struct DebugSettings: View { + @Bindable var state: AppState + private let isPreview = ProcessInfo.processInfo.isPreview + private let labelColumnWidth: CGFloat = 140 + @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath + @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 + @AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue + @AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true + @State private var modelsCount: Int? + @State private var modelsLoading = false + @State private var modelsError: String? + private let gatewayManager = GatewayProcessManager.shared + private let healthStore = HealthStore.shared + @State private var launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() + @State private var launchAgentWriteError: String? + @State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath() + @State private var sessionStorePath: String = SessionLoader.defaultStorePath + @State private var sessionStoreSaveError: String? + @State private var debugSendInFlight = false + @State private var debugSendStatus: String? + @State private var debugSendError: String? + @State private var portCheckInFlight = false + @State private var portReports: [DebugActions.PortReport] = [] + @State private var portKillStatus: String? + @State private var tunnelResetInFlight = false + @State private var tunnelResetStatus: String? + @State private var pendingKill: DebugActions.PortListener? + @AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false + @AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue + + @State private var canvasSessionKey: String = "main" + @State private var canvasStatus: String? + @State private var canvasError: String? + @State private var canvasEvalJS: String = "document.title" + @State private var canvasEvalResult: String? + @State private var canvasSnapshotPath: String? + + init(state: AppState = AppStateStore.shared) { + self.state = state + } + + var body: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 14) { + self.header + + self.launchdSection + self.appInfoSection + self.gatewaySection + self.logsSection + self.portsSection + self.pathsSection + self.quickActionsSection + self.canvasSection + self.experimentsSection + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.vertical, 18) + .groupBoxStyle(PlainSettingsGroupBoxStyle()) + } + .task { + guard !self.isPreview else { return } + await self.reloadModels() + self.loadSessionStorePath() + } + .alert(item: self.$pendingKill) { listener in + Alert( + title: Text("Kill \(listener.command) (\(listener.pid))?"), + message: Text("This process looks expected for the current mode. Kill anyway?"), + primaryButton: .destructive(Text("Kill")) { + Task { await self.killConfirmed(listener.pid) } + }, + secondaryButton: .cancel()) + } + } + + private var launchdSection: some View { + GroupBox("Gateway startup") { + VStack(alignment: .leading, spacing: 8) { + Toggle("Attach only (skip launchd install)", isOn: self.$launchAgentWriteDisabled) + .onChange(of: self.launchAgentWriteDisabled) { _, newValue in + self.launchAgentWriteError = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(newValue) + if self.launchAgentWriteError != nil { + self.launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() + return + } + if newValue { + Task { + _ = await GatewayLaunchAgentManager.set( + enabled: false, + bundlePath: Bundle.main.bundlePath, + port: GatewayEnvironment.gatewayPort()) + } + } + } + + Text( + "When enabled, OpenClaw won't install or manage \(gatewayLaunchdLabel). " + + "It will only attach to an existing Gateway.") + .font(.caption) + .foregroundStyle(.secondary) + + if let launchAgentWriteError { + Text(launchAgentWriteError) + .font(.caption) + .foregroundStyle(.red) + } + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Debug") + .font(.title3.weight(.semibold)) + Text("Tools for diagnosing local issues (Gateway, ports, logs, Canvas).") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + private func gridLabel(_ text: String) -> some View { + Text(text) + .foregroundStyle(.secondary) + .frame(width: self.labelColumnWidth, alignment: .leading) + } + + private var appInfoSection: some View { + GroupBox("App") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Health") + HStack(spacing: 8) { + Circle().fill(self.healthStore.state.tint).frame(width: 10, height: 10) + Text(self.healthStore.summaryLine) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + GridRow { + self.gridLabel("CLI") + let loc = CLIInstaller.installedLocation() + Text(loc ?? "missing") + .font(.caption.monospaced()) + .foregroundStyle(loc == nil ? Color.red : Color.secondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + GridRow { + self.gridLabel("PID") + Text("\(ProcessInfo.processInfo.processIdentifier)") + } + GridRow { + self.gridLabel("Binary path") + Text(Bundle.main.bundlePath) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + } + + private var gatewaySection: some View { + GroupBox("Gateway") { + VStack(alignment: .leading, spacing: 10) { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Status") + HStack(spacing: 8) { + Text(self.gatewayManager.status.label) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + let key = DeepLinkHandler.currentKey() + HStack(spacing: 8) { + Text("Key") + .foregroundStyle(.secondary) + .frame(width: self.labelColumnWidth, alignment: .leading) + Text(key) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(key, forType: .string) + } + .buttonStyle(.bordered) + Button("Copy sample URL") { + let msg = "Hello from deep link" + let encoded = msg.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? msg + let url = "openclaw://agent?message=\(encoded)&key=\(key)" + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url, forType: .string) + } + .buttonStyle(.bordered) + Spacer(minLength: 0) + } + + Text("Deep links (openclaw://…) are always enabled; the key controls unattended runs.") + .font(.caption2) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + Text("Stdout / stderr") + .font(.caption.weight(.semibold)) + ScrollView { + Text(self.gatewayManager.log.isEmpty ? "—" : self.gatewayManager.log) + .font(.caption.monospaced()) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(height: 180) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2))) + + HStack(spacing: 8) { + if self.canRestartGateway { + Button("Restart Gateway") { DebugActions.restartGateway() } + } + Button("Clear log") { GatewayProcessManager.shared.clearLog() } + Spacer(minLength: 0) + } + .buttonStyle(.bordered) + } + } + } + } + + private var logsSection: some View { + GroupBox("Logs") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Pino log") + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Button("Open") { DebugActions.openLog() } + .buttonStyle(.bordered) + Text(DebugActions.pinoLogPath()) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + + GridRow { + self.gridLabel("App logging") + VStack(alignment: .leading, spacing: 8) { + Picker("Verbosity", selection: self.$appLogLevelRaw) { + ForEach(AppLogLevel.allCases) { level in + Text(level.title).tag(level.rawValue) + } + } + .pickerStyle(.menu) + .labelsHidden() + .help("Controls the macOS app log verbosity.") + + Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled) + .toggleStyle(.checkbox) + .help( + "Writes a rotating, local-only log under ~/Library/Logs/OpenClaw/. " + + "Enable only while actively debugging.") + + HStack(spacing: 8) { + Button("Open folder") { + NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL()) + } + .buttonStyle(.bordered) + Button("Clear") { + Task { try? await DiagnosticsFileLog.shared.clear() } + } + .buttonStyle(.bordered) + } + Text(DiagnosticsFileLog.logFileURL().path) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + } + } + + private var portsSection: some View { + GroupBox("Ports") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Port diagnostics") + .font(.caption.weight(.semibold)) + if self.portCheckInFlight { ProgressView().controlSize(.small) } + Spacer() + Button("Check gateway ports") { + Task { await self.runPortCheck() } + } + .buttonStyle(.borderedProminent) + .disabled(self.portCheckInFlight) + Button("Reset SSH tunnel") { + Task { await self.resetGatewayTunnel() } + } + .buttonStyle(.bordered) + .disabled(self.tunnelResetInFlight || !self.isRemoteMode) + } + + if let portKillStatus { + Text(portKillStatus) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if let tunnelResetStatus { + Text(tunnelResetStatus) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if self.portReports.isEmpty, !self.portCheckInFlight { + Text("Check which process owns \(GatewayEnvironment.gatewayPort()) and suggest fixes.") + .font(.caption2) + .foregroundStyle(.secondary) + } else { + ForEach(self.portReports) { report in + VStack(alignment: .leading, spacing: 4) { + Text("Port \(report.port)") + .font(.footnote.weight(.semibold)) + Text(report.summary) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + ForEach(report.listeners) { listener in + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text("\(listener.command) (\(listener.pid))") + .font(.caption.monospaced()) + .foregroundStyle(listener.expected ? .secondary : Color.red) + .lineLimit(1) + Spacer() + Button("Kill") { + self.requestKill(listener) + } + .buttonStyle(.bordered) + } + Text(listener.fullCommand) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.middle) + } + .padding(6) + .background(Color.secondary.opacity(0.05)) + .cornerRadius(4) + } + } + .padding(8) + .background(Color.secondary.opacity(0.08)) + .cornerRadius(6) + } + } + } + } + } + + private var pathsSection: some View { + GroupBox("Paths") { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text("OpenClaw project root") + .font(.caption.weight(.semibold)) + HStack(spacing: 8) { + TextField("Path to openclaw repo", text: self.$gatewayRootInput) + .textFieldStyle(.roundedBorder) + .font(.caption.monospaced()) + .onSubmit { self.saveRelayRoot() } + Button("Save") { self.saveRelayRoot() } + .buttonStyle(.borderedProminent) + Button("Reset") { + let def = FileManager().homeDirectoryForCurrentUser + .appendingPathComponent("Projects/openclaw").path + self.gatewayRootInput = def + self.saveRelayRoot() + } + .buttonStyle(.bordered) + } + Text("Used for pnpm/node fallback and PATH population when launching the gateway.") + .font(.caption2) + .foregroundStyle(.secondary) + } + + Divider() + + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Session store") + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + TextField("Path", text: self.$sessionStorePath) + .textFieldStyle(.roundedBorder) + .font(.caption.monospaced()) + .frame(width: 360) + Button("Save") { self.saveSessionStorePath() } + .buttonStyle(.borderedProminent) + } + if let sessionStoreSaveError { + Text(sessionStoreSaveError) + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Text("Used by the CLI session loader; stored in ~/.openclaw/openclaw.json.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + GridRow { + self.gridLabel("Model catalog") + VStack(alignment: .leading, spacing: 6) { + Text(self.modelCatalogPath) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + HStack(spacing: 8) { + Button { + self.chooseCatalogFile() + } label: { + Label("Choose models.generated.ts…", systemImage: "folder") + } + .buttonStyle(.bordered) + + Button { + Task { await self.reloadModels() } + } label: { + Label( + self.modelsLoading ? "Reloading…" : "Reload models", + systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .disabled(self.modelsLoading) + } + if let modelsError { + Text(modelsError) + .font(.footnote) + .foregroundStyle(.secondary) + } else if let modelsCount { + Text("Loaded \(modelsCount) models") + .font(.footnote) + .foregroundStyle(.secondary) + } + Text("Local fallback for model picker when gateway models.list is unavailable.") + .font(.footnote) + .foregroundStyle(.tertiary) + } + } + } + } + } + } + + private var quickActionsSection: some View { + GroupBox("Quick actions") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Button("Send Test Notification") { + Task { await DebugActions.sendTestNotification() } + } + .buttonStyle(.bordered) + + Button("Open Agent Events") { + DebugActions.openAgentEventsWindow() + } + .buttonStyle(.borderedProminent) + + Spacer(minLength: 0) + } + + VStack(alignment: .leading, spacing: 6) { + Button { + Task { await self.sendVoiceDebug() } + } label: { + Label( + self.debugSendInFlight ? "Sending debug voice…" : "Send debug voice", + systemImage: self.debugSendInFlight ? "bolt.horizontal.circle" : "waveform") + } + .buttonStyle(.borderedProminent) + .disabled(self.debugSendInFlight) + + if !self.debugSendInFlight { + if let debugSendStatus { + Text(debugSendStatus) + .font(.caption) + .foregroundStyle(.secondary) + } else if let debugSendError { + Text(debugSendError) + .font(.caption) + .foregroundStyle(.red) + } else { + Text( + """ + Uses the Voice Wake path: forwards over SSH when configured, + otherwise runs locally via rpc. + """) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + VStack(alignment: .leading, spacing: 6) { + Text( + "Note: macOS may require restarting OpenClaw after enabling Accessibility or Screen Recording.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Button { + LaunchdManager.startOpenClaw() + } label: { + Label("Restart OpenClaw", systemImage: "arrow.counterclockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + HStack(spacing: 8) { + Button("Restart app") { DebugActions.restartApp() } + Button("Restart onboarding") { DebugActions.restartOnboarding() } + Button("Reveal app in Finder") { self.revealApp() } + Spacer(minLength: 0) + } + .buttonStyle(.bordered) + } + } + } + + private var canvasSection: some View { + GroupBox("Canvas") { + VStack(alignment: .leading, spacing: 10) { + Text("Enable/disable Canvas in General settings.") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + TextField("Session", text: self.$canvasSessionKey) + .textFieldStyle(.roundedBorder) + .font(.caption.monospaced()) + .frame(width: 160) + Button("Show panel") { + Task { await self.canvasPresent() } + } + .buttonStyle(.borderedProminent) + Button("Hide panel") { + CanvasManager.shared.hideAll() + self.canvasStatus = "hidden" + self.canvasError = nil + } + .buttonStyle(.bordered) + Button("Write sample page") { + Task { await self.canvasWriteSamplePage() } + } + .buttonStyle(.bordered) + Spacer(minLength: 0) + } + + HStack(spacing: 8) { + TextField("Eval JS", text: self.$canvasEvalJS) + .textFieldStyle(.roundedBorder) + .font(.caption.monospaced()) + .frame(maxWidth: 520) + Button("Eval") { + Task { await self.canvasEval() } + } + .buttonStyle(.bordered) + Button("Snapshot") { + Task { await self.canvasSnapshot() } + } + .buttonStyle(.bordered) + Spacer(minLength: 0) + } + + if let canvasStatus { + Text(canvasStatus) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if let canvasEvalResult { + Text("eval → \(canvasEvalResult)") + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } + if let canvasSnapshotPath { + HStack(spacing: 8) { + Text("snapshot → \(canvasSnapshotPath)") + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + Button("Reveal") { + NSWorkspace.shared + .activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)]) + } + .buttonStyle(.bordered) + Spacer(minLength: 0) + } + } + if let canvasError { + Text(canvasError) + .font(.caption2) + .foregroundStyle(.red) + } else { + Text("Tip: the session directory is returned by “Show panel”.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + } + + private var experimentsSection: some View { + GroupBox("Experiments") { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Icon override") + Picker("", selection: self.bindingOverride) { + ForEach(IconOverrideSelection.allCases) { option in + Text(option.label).tag(option.rawValue) + } + } + .labelsHidden() + .frame(maxWidth: 280, alignment: .leading) + } + GridRow { + self.gridLabel("Chat") + Text("Native SwiftUI") + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + } + + @MainActor + private func runPortCheck() async { + self.portCheckInFlight = true + self.portKillStatus = nil + let reports = await DebugActions.checkGatewayPorts() + self.portReports = reports + self.portCheckInFlight = false + } + + @MainActor + private func resetGatewayTunnel() async { + self.tunnelResetInFlight = true + self.tunnelResetStatus = nil + let result = await DebugActions.resetGatewayTunnel() + switch result { + case let .success(message): + self.tunnelResetStatus = message + case let .failure(err): + self.tunnelResetStatus = err.localizedDescription + } + await self.runPortCheck() + self.tunnelResetInFlight = false + } + + @MainActor + private func requestKill(_ listener: DebugActions.PortListener) { + if listener.expected { + self.pendingKill = listener + } else { + Task { await self.killConfirmed(listener.pid) } + } + } + + @MainActor + private func killConfirmed(_ pid: Int32) async { + let result = await DebugActions.killProcess(Int(pid)) + switch result { + case .success: + self.portKillStatus = "Sent kill to \(pid)." + await self.runPortCheck() + case let .failure(err): + self.portKillStatus = "Kill \(pid) failed: \(err.localizedDescription)" + } + } + + private func chooseCatalogFile() { + let panel = NSOpenPanel() + panel.title = "Select models.generated.ts" + let tsType = UTType(filenameExtension: "ts") + ?? UTType(tag: "ts", tagClass: .filenameExtension, conformingTo: .sourceCode) + ?? .item + panel.allowedContentTypes = [tsType] + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: self.modelCatalogPath).deletingLastPathComponent() + if panel.runModal() == .OK, let url = panel.url { + self.modelCatalogPath = url.path + self.modelCatalogReloadBump += 1 + Task { await self.reloadModels() } + } + } + + private func reloadModels() async { + guard !self.modelsLoading else { return } + self.modelsLoading = true + self.modelsError = nil + self.modelCatalogReloadBump += 1 + defer { self.modelsLoading = false } + do { + let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath) + self.modelsCount = loaded.count + } catch { + self.modelsCount = nil + self.modelsError = error.localizedDescription + } + } + + private func sendVoiceDebug() async { + await MainActor.run { + self.debugSendInFlight = true + self.debugSendError = nil + self.debugSendStatus = nil + } + + let result = await DebugActions.sendDebugVoice() + + await MainActor.run { + self.debugSendInFlight = false + switch result { + case let .success(message): + self.debugSendStatus = message + self.debugSendError = nil + case let .failure(error): + self.debugSendStatus = nil + self.debugSendError = error.localizedDescription + } + } + } + + private func revealApp() { + let url = Bundle.main.bundleURL + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + private func saveRelayRoot() { + GatewayProcessManager.shared.setProjectRoot(path: self.gatewayRootInput) + } + + private func loadSessionStorePath() { + let url = self.configURL() + guard + let data = try? Data(contentsOf: url), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let session = parsed["session"] as? [String: Any], + let path = session["store"] as? String + else { + self.sessionStorePath = SessionLoader.defaultStorePath + return + } + self.sessionStorePath = path + } + + private func saveSessionStorePath() { + let trimmed = self.sessionStorePath.trimmingCharacters(in: .whitespacesAndNewlines) + var root: [String: Any] = [:] + let url = self.configURL() + if let data = try? Data(contentsOf: url), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + { + root = parsed + } + + var session = root["session"] as? [String: Any] ?? [:] + session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed + root["session"] = session + + do { + let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) + try FileManager().createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + self.sessionStoreSaveError = nil + } catch { + self.sessionStoreSaveError = error.localizedDescription + } + } + + private var bindingOverride: Binding { + Binding { + self.iconOverrideRaw + } set: { newValue in + self.iconOverrideRaw = newValue + if let selection = IconOverrideSelection(rawValue: newValue) { + Task { @MainActor in + AppStateStore.shared.iconOverride = selection + WorkActivityStore.shared.resolveIconState(override: selection) + } + } + } + } + + private var isRemoteMode: Bool { + CommandResolver.connectionSettings().mode == .remote + } + + private var canRestartGateway: Bool { + self.state.connectionMode == .local + } + + private func configURL() -> URL { + OpenClawPaths.configURL + } +} + +extension DebugSettings { + // MARK: - Canvas debug actions + + @MainActor + private func canvasPresent() async { + self.canvasError = nil + let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + do { + let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/") + self.canvasStatus = "dir: \(dir)" + } catch { + self.canvasError = error.localizedDescription + } + } + + @MainActor + private func canvasWriteSamplePage() async { + self.canvasError = nil + let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + do { + let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/") + let url = URL(fileURLWithPath: dir).appendingPathComponent("index.html", isDirectory: false) + let now = ISO8601DateFormatter().string(from: Date()) + let html = """ + + + + + + Canvas Debug + + + +
+
+
Canvas Debug
+
generated: \(now)
+
userAgent:
+ +
count: 0
+
+
+
This is a local file served by the WKURLSchemeHandler.
+
+
+
+
+
+
+ + + + """ + try html.write(to: url, atomically: true, encoding: .utf8) + self.canvasStatus = "wrote: \(url.path)" + _ = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/") + } catch { + self.canvasError = error.localizedDescription + } + } + + @MainActor + private func canvasEval() async { + self.canvasError = nil + self.canvasEvalResult = nil + do { + let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + let result = try await CanvasManager.shared.eval( + sessionKey: session.isEmpty ? "main" : session, + javaScript: self.canvasEvalJS) + self.canvasEvalResult = result + } catch { + self.canvasError = error.localizedDescription + } + } + + @MainActor + private func canvasSnapshot() async { + self.canvasError = nil + self.canvasSnapshotPath = nil + do { + let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + let path = try await CanvasManager.shared.snapshot( + sessionKey: session.isEmpty ? "main" : session, + outPath: nil) + self.canvasSnapshotPath = path + } catch { + self.canvasError = error.localizedDescription + } + } +} + +struct PlainSettingsGroupBoxStyle: GroupBoxStyle { + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 10) { + configuration.label + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + configuration.content + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#if DEBUG +struct DebugSettings_Previews: PreviewProvider { + static var previews: some View { + DebugSettings(state: .preview) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} + +@MainActor +extension DebugSettings { + static func exerciseForTesting() async { + let view = DebugSettings(state: .preview) + view.modelsCount = 3 + view.modelsLoading = false + view.modelsError = "Failed to load models" + view.gatewayRootInput = "/tmp/openclaw" + view.sessionStorePath = "/tmp/sessions.json" + view.sessionStoreSaveError = "Save failed" + view.debugSendInFlight = true + view.debugSendStatus = "Sent" + view.debugSendError = "Failed" + view.portCheckInFlight = true + view.portReports = [ + DebugActions.PortReport( + port: GatewayEnvironment.gatewayPort(), + expected: "Gateway websocket (node/tsx)", + status: .missing("Missing"), + listeners: []), + ] + view.portKillStatus = "Killed" + view.pendingKill = DebugActions.PortListener( + pid: 1, + command: "node", + fullCommand: "node", + user: nil, + expected: true) + view.canvasSessionKey = "main" + view.canvasStatus = "Canvas ok" + view.canvasError = "Canvas error" + view.canvasEvalJS = "document.title" + view.canvasEvalResult = "Canvas" + view.canvasSnapshotPath = "/tmp/snapshot.png" + + _ = view.body + _ = view.header + _ = view.appInfoSection + _ = view.gatewaySection + _ = view.logsSection + _ = view.portsSection + _ = view.pathsSection + _ = view.quickActionsSection + _ = view.canvasSection + _ = view.experimentsSection + _ = view.gridLabel("Test") + + view.loadSessionStorePath() + await view.reloadModels() + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DeepLinks.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DeepLinks.swift new file mode 100644 index 00000000..d11d4d52 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DeepLinks.swift @@ -0,0 +1,199 @@ +import AppKit +import Foundation +import OpenClawKit +import OSLog +import Security + +private let deepLinkLogger = Logger(subsystem: "ai.openclaw", category: "DeepLink") + +enum DeepLinkAgentPolicy { + static let maxMessageChars = 20000 + static let maxUnkeyedConfirmChars = 240 + + enum ValidationError: Error, Equatable, LocalizedError { + case messageTooLongForConfirmation(max: Int, actual: Int) + + var errorDescription: String? { + switch self { + case let .messageTooLongForConfirmation(max, actual): + "Message is too long to confirm safely (\(actual) chars; max \(max) without key)." + } + } + } + + static func validateMessageForHandle(message: String, allowUnattended: Bool) -> Result { + if !allowUnattended, message.count > self.maxUnkeyedConfirmChars { + return .failure(.messageTooLongForConfirmation(max: self.maxUnkeyedConfirmChars, actual: message.count)) + } + return .success(()) + } + + static func effectiveDelivery( + link: AgentDeepLink, + allowUnattended: Bool) -> (deliver: Bool, to: String?, channel: GatewayAgentChannel) + { + if !allowUnattended { + // Without the unattended key, ignore delivery/routing knobs to reduce exfiltration risk. + return (deliver: false, to: nil, channel: .last) + } + let channel = GatewayAgentChannel(raw: link.channel) + let deliver = channel.shouldDeliver(link.deliver) + let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + return (deliver: deliver, to: to, channel: channel) + } +} + +@MainActor +final class DeepLinkHandler { + static let shared = DeepLinkHandler() + + private var lastPromptAt: Date = .distantPast + + /// Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas. + /// This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt: + /// outside callers can't know this randomly generated key. + private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey() + + func handle(url: URL) async { + guard let route = DeepLinkParser.parse(url) else { + deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)") + return + } + guard !AppStateStore.shared.isPaused else { + self.presentAlert(title: "OpenClaw is paused", message: "Unpause OpenClaw to run agent actions.") + return + } + + switch route { + case let .agent(link): + await self.handleAgent(link: link, originalURL: url) + case .gateway: + break + } + } + + private func handleAgent(link: AgentDeepLink, originalURL: URL) async { + let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines) + if messagePreview.count > DeepLinkAgentPolicy.maxMessageChars { + self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.") + return + } + + let allowUnattended = link.key == Self.canvasUnattendedKey || link.key == Self.expectedKey() + if !allowUnattended { + if Date().timeIntervalSince(self.lastPromptAt) < 1.0 { + deepLinkLogger.debug("throttling deep link prompt") + return + } + self.lastPromptAt = Date() + + if case let .failure(error) = DeepLinkAgentPolicy.validateMessageForHandle( + message: messagePreview, + allowUnattended: allowUnattended) + { + self.presentAlert(title: "Deep link blocked", message: error.localizedDescription) + return + } + + let urlText = originalURL.absoluteString + let urlPreview = urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText + let body = + "Run the agent with this message?\n\n\(messagePreview)\n\nURL:\n\(urlPreview)" + guard self.confirm(title: "Run OpenClaw agent?", message: body) else { return } + } + + if AppStateStore.shared.connectionMode == .local { + GatewayProcessManager.shared.setActive(true) + } + + do { + let effectiveDelivery = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: allowUnattended) + let explicitSessionKey = link.sessionKey? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty + let resolvedSessionKey: String = if let explicitSessionKey { + explicitSessionKey + } else { + await GatewayConnection.shared.mainSessionKey() + } + let invocation = GatewayAgentInvocation( + message: messagePreview, + sessionKey: resolvedSessionKey, + thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty, + deliver: effectiveDelivery.deliver, + to: effectiveDelivery.to, + channel: effectiveDelivery.channel, + timeoutSeconds: link.timeoutSeconds, + idempotencyKey: UUID().uuidString) + + let res = await GatewayConnection.shared.sendAgent(invocation) + if !res.ok { + throw NSError( + domain: "DeepLink", + code: 1, + userInfo: [NSLocalizedDescriptionKey: res.error ?? "agent request failed"]) + } + } catch { + self.presentAlert(title: "Agent request failed", message: error.localizedDescription) + } + } + + // MARK: - Auth + + static func currentKey() -> String { + self.expectedKey() + } + + static func currentCanvasKey() -> String { + self.canvasUnattendedKey + } + + private static func expectedKey() -> String { + let defaults = UserDefaults.standard + if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty { + return key + } + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let data = Data(bytes) + let key = data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + defaults.set(key, forKey: deepLinkKeyKey) + return key + } + + private nonisolated static func generateRandomKey() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let data = Data(bytes) + return data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + // MARK: - UI + + private func confirm(title: String, message: String) -> Bool { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "Run") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + return alert.runModal() == .alertFirstButtonReturn + } + + private func presentAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.alertStyle = .informational + alert.runModal() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DeviceModelCatalog.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DeviceModelCatalog.swift new file mode 100644 index 00000000..ce6dd10c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DeviceModelCatalog.swift @@ -0,0 +1,188 @@ +import Foundation + +struct DevicePresentation: Sendable { + let title: String + let symbol: String? +} + +enum DeviceModelCatalog { + private static let modelIdentifierToName: [String: String] = loadModelIdentifierToName() + private static let resourceBundle: Bundle? = locateResourceBundle() + private static let resourceSubdirectory = "DeviceModels" + + static func presentation(deviceFamily: String?, modelIdentifier: String?) -> DevicePresentation? { + let family = (deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let model = (modelIdentifier ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + + let friendlyName = model.isEmpty ? nil : self.modelIdentifierToName[model] + let symbol = self.symbol(deviceFamily: family, modelIdentifier: model, friendlyName: friendlyName) + + let title = if let friendlyName, !friendlyName.isEmpty { + friendlyName + } else if !family.isEmpty, !model.isEmpty { + "\(family) (\(model))" + } else if !family.isEmpty { + family + } else if !model.isEmpty { + model + } else { + "" + } + + if title.isEmpty { return nil } + return DevicePresentation(title: title, symbol: symbol) + } + + static func symbol( + deviceFamily familyRaw: String, + modelIdentifier modelIdentifierRaw: String, + friendlyName: String?) -> String? + { + let family = familyRaw.trimmingCharacters(in: .whitespacesAndNewlines) + let modelIdentifier = modelIdentifierRaw.trimmingCharacters(in: .whitespacesAndNewlines) + + return self.symbolFor(modelIdentifier: modelIdentifier, friendlyName: friendlyName) + ?? self.fallbackSymbol(for: family, modelIdentifier: modelIdentifier) + } + + private static func symbolFor(modelIdentifier rawModelIdentifier: String, friendlyName: String?) -> String? { + let modelIdentifier = rawModelIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + guard !modelIdentifier.isEmpty else { return nil } + + let lower = modelIdentifier.lowercased() + if lower.hasPrefix("ipad") { return "ipad" } + if lower.hasPrefix("iphone") { return "iphone" } + if lower.hasPrefix("ipod") { return "iphone" } + if lower.hasPrefix("watch") { return "applewatch" } + if lower.hasPrefix("appletv") { return "appletv" } + if lower.hasPrefix("audio") || lower.hasPrefix("homepod") { return "speaker" } + + if lower.hasPrefix("macbook") || lower.hasPrefix("macbookpro") || lower.hasPrefix("macbookair") { + return "laptopcomputer" + } + if lower.hasPrefix("macstudio") { return "macstudio" } + if lower.hasPrefix("macmini") { return "macmini" } + if lower.hasPrefix("imac") || lower.hasPrefix("macpro") { return "desktopcomputer" } + + if lower.hasPrefix("mac"), let friendlyNameLower = friendlyName?.lowercased() { + if friendlyNameLower.contains("macbook") { return "laptopcomputer" } + if friendlyNameLower.contains("imac") { return "desktopcomputer" } + if friendlyNameLower.contains("mac mini") { return "macmini" } + if friendlyNameLower.contains("mac studio") { return "macstudio" } + if friendlyNameLower.contains("mac pro") { return "desktopcomputer" } + } + + return nil + } + + private static func fallbackSymbol(for familyRaw: String, modelIdentifier: String) -> String? { + let family = familyRaw.trimmingCharacters(in: .whitespacesAndNewlines) + if family.isEmpty { return nil } + switch family.lowercased() { + case "ipad": + return "ipad" + case "iphone": + return "iphone" + case "mac": + return "laptopcomputer" + case "android": + return "android" + case "linux": + return "cpu" + default: + return "cpu" + } + } + + private static func loadModelIdentifierToName() -> [String: String] { + var combined: [String: String] = [:] + combined.merge( + self.loadMapping(resourceName: "ios-device-identifiers"), + uniquingKeysWith: { current, _ in current }) + combined.merge( + self.loadMapping(resourceName: "mac-device-identifiers"), + uniquingKeysWith: { current, _ in current }) + return combined + } + + private static func loadMapping(resourceName: String) -> [String: String] { + guard let url = self.resourceBundle?.url( + forResource: resourceName, + withExtension: "json", + subdirectory: self.resourceSubdirectory) + else { return [:] } + + do { + let data = try Data(contentsOf: url) + let decoded = try JSONDecoder().decode([String: NameValue].self, from: data) + return decoded.compactMapValues { $0.normalizedName } + } catch { + return [:] + } + } + + private static func locateResourceBundle() -> Bundle? { + // Prefer main bundle (packaged app), then module bundle (SwiftPM/tests). + // Accessing Bundle.module in the packaged app can crash if the bundle isn't where SwiftPM expects it. + if let bundle = self.bundleIfContainsDeviceModels(Bundle.main) { + return bundle + } + + if let bundle = self.bundleIfContainsDeviceModels(Bundle.module) { + return bundle + } + return nil + } + + private static func bundleIfContainsDeviceModels(_ bundle: Bundle) -> Bundle? { + if bundle.url( + forResource: "ios-device-identifiers", + withExtension: "json", + subdirectory: self.resourceSubdirectory) != nil + { + return bundle + } + if bundle.url( + forResource: "mac-device-identifiers", + withExtension: "json", + subdirectory: self.resourceSubdirectory) != nil + { + return bundle + } + return nil + } + + private enum NameValue: Decodable { + case string(String) + case stringArray([String]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let s = try? container.decode(String.self) { + self = .string(s) + return + } + if let arr = try? container.decode([String].self) { + self = .stringArray(arr) + return + } + throw DecodingError.typeMismatch( + String.self, + .init(codingPath: decoder.codingPath, debugDescription: "Expected string or string array")) + } + + var normalizedName: String? { + switch self { + case let .string(s): + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + case let .stringArray(arr): + let values = arr + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !values.isEmpty else { return nil } + return values.joined(separator: " / ") + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift new file mode 100644 index 00000000..f85e8d1a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift @@ -0,0 +1,307 @@ +import AppKit +import Foundation +import Observation +import OpenClawKit +import OpenClawProtocol +import OSLog + +@MainActor +@Observable +final class DevicePairingApprovalPrompter { + static let shared = DevicePairingApprovalPrompter() + + private let logger = Logger(subsystem: "ai.openclaw", category: "device-pairing") + private var task: Task? + private var isStopping = false + private var isPresenting = false + private var queue: [PendingRequest] = [] + var pendingCount: Int = 0 + var pendingRepairCount: Int = 0 + private var activeAlert: NSAlert? + private var activeRequestId: String? + private var alertHostWindow: NSWindow? + private var resolvedByRequestId: Set = [] + + private struct PairingList: Codable { + let pending: [PendingRequest] + let paired: [PairedDevice]? + } + + private struct PairedDevice: Codable, Equatable { + let deviceId: String + let approvedAtMs: Double? + let displayName: String? + let platform: String? + let remoteIp: String? + } + + private struct PendingRequest: Codable, Equatable, Identifiable { + let requestId: String + let deviceId: String + let publicKey: String + let displayName: String? + let platform: String? + let clientId: String? + let clientMode: String? + let role: String? + let scopes: [String]? + let remoteIp: String? + let silent: Bool? + let isRepair: Bool? + let ts: Double + + var id: String { + self.requestId + } + } + + private struct PairingResolvedEvent: Codable { + let requestId: String + let deviceId: String + let decision: String + let ts: Double + } + + private enum PairingResolution: String { + case approved + case rejected + } + + func start() { + guard self.task == nil else { return } + self.isStopping = false + self.task = Task { [weak self] in + guard let self else { return } + _ = try? await GatewayConnection.shared.refresh() + await self.loadPendingRequestsFromGateway() + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) + for await push in stream { + if Task.isCancelled { return } + await MainActor.run { [weak self] in self?.handle(push: push) } + } + } + } + + func stop() { + self.isStopping = true + self.endActiveAlert() + self.task?.cancel() + self.task = nil + self.queue.removeAll(keepingCapacity: false) + self.updatePendingCounts() + self.isPresenting = false + self.activeRequestId = nil + self.alertHostWindow?.orderOut(nil) + self.alertHostWindow?.close() + self.alertHostWindow = nil + self.resolvedByRequestId.removeAll(keepingCapacity: false) + } + + private func loadPendingRequestsFromGateway() async { + do { + let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList) + await self.apply(list: list) + } catch { + self.logger.error("failed to load device pairing requests: \(error.localizedDescription, privacy: .public)") + } + } + + private func apply(list: PairingList) async { + self.queue = list.pending.sorted(by: { $0.ts > $1.ts }) + self.updatePendingCounts() + self.presentNextIfNeeded() + } + + private func updatePendingCounts() { + self.pendingCount = self.queue.count + self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true }) + } + + private func presentNextIfNeeded() { + guard !self.isStopping else { return } + guard !self.isPresenting else { return } + guard let next = self.queue.first else { return } + self.isPresenting = true + self.presentAlert(for: next) + } + + private func presentAlert(for req: PendingRequest) { + self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)") + NSApp.activate(ignoringOtherApps: true) + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Allow device to connect?" + alert.informativeText = Self.describe(req) + alert.addButton(withTitle: "Later") + alert.addButton(withTitle: "Approve") + alert.addButton(withTitle: "Reject") + if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { + alert.buttons[2].hasDestructiveAction = true + } + + self.activeAlert = alert + self.activeRequestId = req.requestId + let hostWindow = self.requireAlertHostWindow() + + let sheetSize = alert.window.frame.size + if let screen = hostWindow.screen ?? NSScreen.main { + let bounds = screen.visibleFrame + let x = bounds.midX - (sheetSize.width / 2) + let sheetOriginY = bounds.midY - (sheetSize.height / 2) + let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height + hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY)) + } else { + hostWindow.center() + } + + hostWindow.makeKeyAndOrderFront(nil) + alert.beginSheetModal(for: hostWindow) { [weak self] response in + Task { @MainActor [weak self] in + guard let self else { return } + self.activeRequestId = nil + self.activeAlert = nil + await self.handleAlertResponse(response, request: req) + hostWindow.orderOut(nil) + } + } + } + + private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { + var shouldRemove = response != .alertFirstButtonReturn + defer { + if shouldRemove { + if self.queue.first == request { + self.queue.removeFirst() + } else { + self.queue.removeAll { $0 == request } + } + } + self.updatePendingCounts() + self.isPresenting = false + self.presentNextIfNeeded() + } + + guard !self.isStopping else { return } + + if self.resolvedByRequestId.remove(request.requestId) != nil { + return + } + + switch response { + case .alertFirstButtonReturn: + shouldRemove = false + if let idx = self.queue.firstIndex(of: request) { + self.queue.remove(at: idx) + } + self.queue.append(request) + return + case .alertSecondButtonReturn: + _ = await self.approve(requestId: request.requestId) + case .alertThirdButtonReturn: + await self.reject(requestId: request.requestId) + default: + return + } + } + + private func approve(requestId: String) async -> Bool { + do { + try await GatewayConnection.shared.devicePairApprove(requestId: requestId) + self.logger.info("approved device pairing requestId=\(requestId, privacy: .public)") + return true + } catch { + self.logger.error("approve failed requestId=\(requestId, privacy: .public)") + self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)") + return false + } + } + + private func reject(requestId: String) async { + do { + try await GatewayConnection.shared.devicePairReject(requestId: requestId) + self.logger.info("rejected device pairing requestId=\(requestId, privacy: .public)") + } catch { + self.logger.error("reject failed requestId=\(requestId, privacy: .public)") + self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func endActiveAlert() { + PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId) + } + + private func requireAlertHostWindow() -> NSWindow { + PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow) + } + + private func handle(push: GatewayPush) { + switch push { + case let .event(evt) where evt.event == "device.pair.requested": + guard let payload = evt.payload else { return } + do { + let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self) + self.enqueue(req) + } catch { + self.logger + .error("failed to decode device pairing request: \(error.localizedDescription, privacy: .public)") + } + case let .event(evt) where evt.event == "device.pair.resolved": + guard let payload = evt.payload else { return } + do { + let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self) + self.handleResolved(resolved) + } catch { + self.logger + .error( + "failed to decode device pairing resolution: \(error.localizedDescription, privacy: .public)") + } + default: + break + } + } + + private func enqueue(_ req: PendingRequest) { + guard !self.queue.contains(req) else { return } + self.queue.append(req) + self.updatePendingCounts() + self.presentNextIfNeeded() + } + + private func handleResolved(_ resolved: PairingResolvedEvent) { + let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution + .approved : .rejected + if let activeRequestId, activeRequestId == resolved.requestId { + self.resolvedByRequestId.insert(resolved.requestId) + self.endActiveAlert() + let decision = resolution.rawValue + self.logger.info( + "device pairing resolved while active requestId=\(resolved.requestId, privacy: .public) " + + "decision=\(decision, privacy: .public)") + return + } + self.queue.removeAll { $0.requestId == resolved.requestId } + self.updatePendingCounts() + } + + private static func describe(_ req: PendingRequest) -> String { + var lines: [String] = [] + lines.append("Device: \(req.displayName ?? req.deviceId)") + if let platform = req.platform { + lines.append("Platform: \(platform)") + } + if let role = req.role { + lines.append("Role: \(role)") + } + if let scopes = req.scopes, !scopes.isEmpty { + lines.append("Scopes: \(scopes.joined(separator: ", "))") + } + if let remoteIp = req.remoteIp { + lines.append("IP: \(remoteIp)") + } + if req.isRepair == true { + lines.append("Repair: yes") + } + return lines.joined(separator: "\n") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift new file mode 100644 index 00000000..44baa738 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift @@ -0,0 +1,133 @@ +import Foundation + +actor DiagnosticsFileLog { + static let shared = DiagnosticsFileLog() + + private let fileName = "diagnostics.jsonl" + private let maxBytes: Int64 = 5 * 1024 * 1024 + private let maxBackups = 5 + + struct Record: Codable, Sendable { + let ts: String + let pid: Int32 + let category: String + let event: String + let fields: [String: String]? + } + + nonisolated static func isEnabled() -> Bool { + UserDefaults.standard.bool(forKey: debugFileLogEnabledKey) + } + + nonisolated static func logDirectoryURL() -> URL { + let library = FileManager().urls(for: .libraryDirectory, in: .userDomainMask).first + ?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true) + return library + .appendingPathComponent("Logs", isDirectory: true) + .appendingPathComponent("OpenClaw", isDirectory: true) + } + + nonisolated static func logFileURL() -> URL { + self.logDirectoryURL().appendingPathComponent("diagnostics.jsonl", isDirectory: false) + } + + nonisolated func log(category: String, event: String, fields: [String: String]? = nil) { + guard Self.isEnabled() else { return } + let record = Record( + ts: ISO8601DateFormatter().string(from: Date()), + pid: ProcessInfo.processInfo.processIdentifier, + category: category, + event: event, + fields: fields) + Task { await self.write(record: record) } + } + + func clear() throws { + let fm = FileManager() + let base = Self.logFileURL() + if fm.fileExists(atPath: base.path) { + try fm.removeItem(at: base) + } + for idx in 1...self.maxBackups { + let url = self.rotatedURL(index: idx) + if fm.fileExists(atPath: url.path) { + try fm.removeItem(at: url) + } + } + } + + private func write(record: Record) { + do { + try self.ensureDirectory() + try self.rotateIfNeeded() + try self.append(record: record) + } catch { + // Best-effort only: never crash or block the app on logging. + } + } + + private func ensureDirectory() throws { + try FileManager().createDirectory( + at: Self.logDirectoryURL(), + withIntermediateDirectories: true) + } + + private func append(record: Record) throws { + let url = Self.logFileURL() + let data = try JSONEncoder().encode(record) + var line = Data() + line.append(data) + line.append(0x0A) // newline + + let fm = FileManager() + if !fm.fileExists(atPath: url.path) { + fm.createFile(atPath: url.path, contents: nil) + } + + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: line) + } + + private func rotateIfNeeded() throws { + let url = Self.logFileURL() + guard let attrs = try? FileManager().attributesOfItem(atPath: url.path), + let size = attrs[.size] as? NSNumber + else { return } + + if size.int64Value < self.maxBytes { return } + + let fm = FileManager() + + let oldest = self.rotatedURL(index: self.maxBackups) + if fm.fileExists(atPath: oldest.path) { + try fm.removeItem(at: oldest) + } + + if self.maxBackups > 1 { + for idx in stride(from: self.maxBackups - 1, through: 1, by: -1) { + let src = self.rotatedURL(index: idx) + let dst = self.rotatedURL(index: idx + 1) + if fm.fileExists(atPath: src.path) { + if fm.fileExists(atPath: dst.path) { + try fm.removeItem(at: dst) + } + try fm.moveItem(at: src, to: dst) + } + } + } + + let first = self.rotatedURL(index: 1) + if fm.fileExists(atPath: first.path) { + try fm.removeItem(at: first) + } + if fm.fileExists(atPath: url.path) { + try fm.moveItem(at: url, to: first) + } + } + + private func rotatedURL(index: Int) -> URL { + Self.logDirectoryURL().appendingPathComponent("\(self.fileName).\(index)", isDirectory: false) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DockIconManager.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DockIconManager.swift new file mode 100644 index 00000000..98201393 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/DockIconManager.swift @@ -0,0 +1,116 @@ +import AppKit + +/// Central manager for Dock icon visibility. +/// Shows the Dock icon while any windows are visible, regardless of user preference. +final class DockIconManager: NSObject, @unchecked Sendable { + static let shared = DockIconManager() + + private var windowsObservation: NSKeyValueObservation? + private let logger = Logger(subsystem: "ai.openclaw", category: "DockIconManager") + + override private init() { + super.init() + self.setupObservers() + Task { @MainActor in + self.updateDockVisibility() + } + } + + deinit { + self.windowsObservation?.invalidate() + NotificationCenter.default.removeObserver(self) + } + + func updateDockVisibility() { + Task { @MainActor in + guard NSApp != nil else { + self.logger.warning("NSApp not ready, skipping Dock visibility update") + return + } + + let userWantsDockHidden = !UserDefaults.standard.bool(forKey: showDockIconKey) + let visibleWindows = NSApp?.windows.filter { window in + window.isVisible && + window.frame.width > 1 && + window.frame.height > 1 && + !window.isKind(of: NSPanel.self) && + "\(type(of: window))" != "NSPopupMenuWindow" && + window.contentViewController != nil + } ?? [] + + let hasVisibleWindows = !visibleWindows.isEmpty + if !userWantsDockHidden || hasVisibleWindows { + NSApp?.setActivationPolicy(.regular) + } else { + NSApp?.setActivationPolicy(.accessory) + } + } + } + + func temporarilyShowDock() { + Task { @MainActor in + guard NSApp != nil else { + self.logger.warning("NSApp not ready, cannot show Dock icon") + return + } + NSApp.setActivationPolicy(.regular) + } + } + + private func setupObservers() { + Task { @MainActor in + guard let app = NSApp else { + self.logger.warning("NSApp not ready, delaying Dock observers") + try? await Task.sleep(for: .milliseconds(200)) + self.setupObservers() + return + } + + self.windowsObservation = app.observe(\.windows, options: [.new]) { [weak self] _, _ in + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(50)) + self?.updateDockVisibility() + } + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.windowVisibilityChanged), + name: NSWindow.didBecomeKeyNotification, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.windowVisibilityChanged), + name: NSWindow.didResignKeyNotification, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.windowVisibilityChanged), + name: NSWindow.willCloseNotification, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.dockPreferenceChanged), + name: UserDefaults.didChangeNotification, + object: nil) + } + } + + @objc + private func windowVisibilityChanged(_: Notification) { + Task { @MainActor in + self.updateDockVisibility() + } + } + + @objc + private func dockPreferenceChanged(_ notification: Notification) { + guard let userDefaults = notification.object as? UserDefaults, + userDefaults == UserDefaults.standard + else { return } + + Task { @MainActor in + self.updateDockVisibility() + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift new file mode 100644 index 00000000..ad40d2c3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift @@ -0,0 +1,79 @@ +import Foundation + +enum ExecAllowlistMatcher { + static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? { + guard let resolution, !entries.isEmpty else { return nil } + let rawExecutable = resolution.rawExecutable + let resolvedPath = resolution.resolvedPath + + for entry in entries { + switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) { + case let .valid(pattern): + let target = resolvedPath ?? rawExecutable + if self.matches(pattern: pattern, target: target) { return entry } + case .invalid: + continue + } + } + return nil + } + + static func matchAll( + entries: [ExecAllowlistEntry], + resolutions: [ExecCommandResolution]) -> [ExecAllowlistEntry] + { + guard !entries.isEmpty, !resolutions.isEmpty else { return [] } + var matches: [ExecAllowlistEntry] = [] + matches.reserveCapacity(resolutions.count) + for resolution in resolutions { + guard let match = self.match(entries: entries, resolution: resolution) else { + return [] + } + matches.append(match) + } + return matches + } + + private static func matches(pattern: String, target: String) -> Bool { + let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed + let normalizedPattern = self.normalizeMatchTarget(expanded) + let normalizedTarget = self.normalizeMatchTarget(target) + guard let regex = self.regex(for: normalizedPattern) else { return false } + let range = NSRange(location: 0, length: normalizedTarget.utf16.count) + return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil + } + + private static func normalizeMatchTarget(_ value: String) -> String { + value.replacingOccurrences(of: "\\\\", with: "/").lowercased() + } + + private static func regex(for pattern: String) -> NSRegularExpression? { + var regex = "^" + var idx = pattern.startIndex + while idx < pattern.endIndex { + let ch = pattern[idx] + if ch == "*" { + let next = pattern.index(after: idx) + if next < pattern.endIndex, pattern[next] == "*" { + regex += ".*" + idx = pattern.index(after: next) + } else { + regex += "[^/]*" + idx = next + } + continue + } + if ch == "?" { + regex += "." + idx = pattern.index(after: idx) + continue + } + regex += NSRegularExpression.escapedPattern(for: String(ch)) + idx = pattern.index(after: idx) + } + regex += "$" + return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive]) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift new file mode 100644 index 00000000..c7d9d092 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift @@ -0,0 +1,68 @@ +import Foundation + +struct ExecApprovalEvaluation { + let command: [String] + let displayCommand: String + let agentId: String? + let security: ExecSecurity + let ask: ExecAsk + let env: [String: String] + let resolution: ExecCommandResolution? + let allowlistResolutions: [ExecCommandResolution] + let allowlistMatches: [ExecAllowlistEntry] + let allowlistSatisfied: Bool + let allowlistMatch: ExecAllowlistEntry? + let skillAllow: Bool +} + +enum ExecApprovalEvaluator { + static func evaluate( + command: [String], + rawCommand: String?, + cwd: String?, + envOverrides: [String: String]?, + agentId: String?) async -> ExecApprovalEvaluation + { + let trimmedAgent = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedAgentId = (trimmedAgent?.isEmpty == false) ? trimmedAgent : nil + let approvals = ExecApprovalsStore.resolve(agentId: normalizedAgentId) + let security = approvals.agent.security + let ask = approvals.agent.ask + let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper + let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper) + let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand) + let allowlistResolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: rawCommand, + cwd: cwd, + env: env) + let allowlistMatches = security == .allowlist + ? ExecAllowlistMatcher.matchAll(entries: approvals.allowlist, resolutions: allowlistResolutions) + : [] + let allowlistSatisfied = security == .allowlist && + !allowlistResolutions.isEmpty && + allowlistMatches.count == allowlistResolutions.count + + let skillAllow: Bool + if approvals.agent.autoAllowSkills, !allowlistResolutions.isEmpty { + let bins = await SkillBinsCache.shared.currentBins() + skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) } + } else { + skillAllow = false + } + + return ExecApprovalEvaluation( + command: command, + displayCommand: displayCommand, + agentId: normalizedAgentId, + security: security, + ask: ask, + env: env, + resolution: allowlistResolutions.first, + allowlistResolutions: allowlistResolutions, + allowlistMatches: allowlistMatches, + allowlistSatisfied: allowlistSatisfied, + allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil, + skillAllow: skillAllow) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecApprovals.swift new file mode 100644 index 00000000..73aa3899 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -0,0 +1,794 @@ +import CryptoKit +import Foundation +import OSLog +import Security + +enum ExecSecurity: String, CaseIterable, Codable, Identifiable { + case deny + case allowlist + case full + + var id: String { + self.rawValue + } + + var title: String { + switch self { + case .deny: "Deny" + case .allowlist: "Allowlist" + case .full: "Always Allow" + } + } +} + +enum ExecApprovalQuickMode: String, CaseIterable, Identifiable { + case deny + case ask + case allow + + var id: String { + self.rawValue + } + + var title: String { + switch self { + case .deny: "Deny" + case .ask: "Always Ask" + case .allow: "Always Allow" + } + } + + var security: ExecSecurity { + switch self { + case .deny: .deny + case .ask: .allowlist + case .allow: .full + } + } + + var ask: ExecAsk { + switch self { + case .deny: .off + case .ask: .onMiss + case .allow: .off + } + } + + static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode { + switch security { + case .deny: + .deny + case .full: + .allow + case .allowlist: + .ask + } + } +} + +enum ExecAsk: String, CaseIterable, Codable, Identifiable { + case off + case onMiss = "on-miss" + case always + + var id: String { + self.rawValue + } + + var title: String { + switch self { + case .off: "Never Ask" + case .onMiss: "Ask on Allowlist Miss" + case .always: "Always Ask" + } + } +} + +enum ExecApprovalDecision: String, Codable, Sendable { + case allowOnce = "allow-once" + case allowAlways = "allow-always" + case deny +} + +enum ExecAllowlistPatternValidationReason: String, Codable, Sendable, Equatable { + case empty + case missingPathComponent + + var message: String { + switch self { + case .empty: + "Pattern cannot be empty." + case .missingPathComponent: + "Path patterns only. Include '/', '~', or '\\\\'." + } + } +} + +enum ExecAllowlistPatternValidation: Sendable, Equatable { + case valid(String) + case invalid(ExecAllowlistPatternValidationReason) +} + +struct ExecAllowlistRejectedEntry: Sendable, Equatable { + let id: UUID + let pattern: String + let reason: ExecAllowlistPatternValidationReason +} + +struct ExecAllowlistEntry: Codable, Hashable, Identifiable { + var id: UUID + var pattern: String + var lastUsedAt: Double? + var lastUsedCommand: String? + var lastResolvedPath: String? + + init( + id: UUID = UUID(), + pattern: String, + lastUsedAt: Double? = nil, + lastUsedCommand: String? = nil, + lastResolvedPath: String? = nil) + { + self.id = id + self.pattern = pattern + self.lastUsedAt = lastUsedAt + self.lastUsedCommand = lastUsedCommand + self.lastResolvedPath = lastResolvedPath + } + + private enum CodingKeys: String, CodingKey { + case id + case pattern + case lastUsedAt + case lastUsedCommand + case lastResolvedPath + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.pattern = try container.decode(String.self, forKey: .pattern) + self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt) + self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand) + self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.pattern, forKey: .pattern) + try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt) + try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand) + try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath) + } +} + +struct ExecApprovalsDefaults: Codable { + var security: ExecSecurity? + var ask: ExecAsk? + var askFallback: ExecSecurity? + var autoAllowSkills: Bool? +} + +struct ExecApprovalsAgent: Codable { + var security: ExecSecurity? + var ask: ExecAsk? + var askFallback: ExecSecurity? + var autoAllowSkills: Bool? + var allowlist: [ExecAllowlistEntry]? + + var isEmpty: Bool { + self.security == nil && self.ask == nil && self.askFallback == nil && self + .autoAllowSkills == nil && (self.allowlist?.isEmpty ?? true) + } +} + +struct ExecApprovalsSocketConfig: Codable { + var path: String? + var token: String? +} + +struct ExecApprovalsFile: Codable { + var version: Int + var socket: ExecApprovalsSocketConfig? + var defaults: ExecApprovalsDefaults? + var agents: [String: ExecApprovalsAgent]? +} + +struct ExecApprovalsSnapshot: Codable { + var path: String + var exists: Bool + var hash: String + var file: ExecApprovalsFile +} + +struct ExecApprovalsResolved { + let url: URL + let socketPath: String + let token: String + let defaults: ExecApprovalsResolvedDefaults + let agent: ExecApprovalsResolvedDefaults + let allowlist: [ExecAllowlistEntry] + var file: ExecApprovalsFile +} + +struct ExecApprovalsResolvedDefaults { + var security: ExecSecurity + var ask: ExecAsk + var askFallback: ExecSecurity + var autoAllowSkills: Bool +} + +enum ExecApprovalsStore { + private static let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals") + private static let defaultAgentId = "main" + private static let defaultSecurity: ExecSecurity = .deny + private static let defaultAsk: ExecAsk = .onMiss + private static let defaultAskFallback: ExecSecurity = .deny + private static let defaultAutoAllowSkills = false + + static func fileURL() -> URL { + OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.json") + } + + static func socketPath() -> String { + OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path + } + + static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile { + let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + var agents = file.agents ?? [:] + if let legacyDefault = agents["default"] { + if let main = agents[self.defaultAgentId] { + agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault) + } else { + agents[self.defaultAgentId] = legacyDefault + } + agents.removeValue(forKey: "default") + } + if !agents.isEmpty { + var normalizedAgents: [String: ExecApprovalsAgent] = [:] + normalizedAgents.reserveCapacity(agents.count) + for (key, var agent) in agents { + if let allowlist = agent.allowlist { + let normalized = self.normalizeAllowlistEntries(allowlist, dropInvalid: false).entries + agent.allowlist = normalized.isEmpty ? nil : normalized + } + normalizedAgents[key] = agent + } + agents = normalizedAgents + } + return ExecApprovalsFile( + version: 1, + socket: ExecApprovalsSocketConfig( + path: socketPath.isEmpty ? nil : socketPath, + token: token.isEmpty ? nil : token), + defaults: file.defaults, + agents: agents.isEmpty ? nil : agents) + } + + static func readSnapshot() -> ExecApprovalsSnapshot { + let url = self.fileURL() + guard FileManager().fileExists(atPath: url.path) else { + return ExecApprovalsSnapshot( + path: url.path, + exists: false, + hash: self.hashRaw(nil), + file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])) + } + let raw = try? String(contentsOf: url, encoding: .utf8) + let data = raw.flatMap { $0.data(using: .utf8) } + let decoded: ExecApprovalsFile = { + if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 { + return file + } + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + }() + return ExecApprovalsSnapshot( + path: url.path, + exists: true, + hash: self.hashRaw(raw), + file: decoded) + } + + static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile { + let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if socketPath.isEmpty { + return ExecApprovalsFile( + version: file.version, + socket: nil, + defaults: file.defaults, + agents: file.agents) + } + return ExecApprovalsFile( + version: file.version, + socket: ExecApprovalsSocketConfig(path: socketPath, token: nil), + defaults: file.defaults, + agents: file.agents) + } + + static func loadFile() -> ExecApprovalsFile { + let url = self.fileURL() + guard FileManager().fileExists(atPath: url.path) else { + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + } + do { + let data = try Data(contentsOf: url) + let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) + if decoded.version != 1 { + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + } + return decoded + } catch { + self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)") + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + } + } + + static func saveFile(_ file: ExecApprovalsFile) { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(file) + let url = self.fileURL() + try FileManager().createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } catch { + self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)") + } + } + + static func ensureFile() -> ExecApprovalsFile { + let url = self.fileURL() + let existed = FileManager().fileExists(atPath: url.path) + let loaded = self.loadFile() + let loadedHash = self.hashFile(loaded) + + var file = self.normalizeIncoming(loaded) + if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) } + let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if path.isEmpty { + file.socket?.path = self.socketPath() + } + let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if token.isEmpty { + file.socket?.token = self.generateToken() + } + if file.agents == nil { file.agents = [:] } + if !existed || loadedHash != self.hashFile(file) { + self.saveFile(file) + } + return file + } + + static func resolve(agentId: String?) -> ExecApprovalsResolved { + let file = self.ensureFile() + let defaults = file.defaults ?? ExecApprovalsDefaults() + let resolvedDefaults = ExecApprovalsResolvedDefaults( + security: defaults.security ?? self.defaultSecurity, + ask: defaults.ask ?? self.defaultAsk, + askFallback: defaults.askFallback ?? self.defaultAskFallback, + autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) + let key = self.agentKey(agentId) + let agentEntry = file.agents?[key] ?? ExecApprovalsAgent() + let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent() + let resolvedAgent = ExecApprovalsResolvedDefaults( + security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security, + ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask, + askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback + ?? resolvedDefaults.askFallback, + autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills + ?? resolvedDefaults.autoAllowSkills) + let allowlist = self.normalizeAllowlistEntries( + (wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []), + dropInvalid: true).entries + let socketPath = self.expandPath(file.socket?.path ?? self.socketPath()) + let token = file.socket?.token ?? "" + return ExecApprovalsResolved( + url: self.fileURL(), + socketPath: socketPath, + token: token, + defaults: resolvedDefaults, + agent: resolvedAgent, + allowlist: allowlist, + file: file) + } + + static func resolveDefaults() -> ExecApprovalsResolvedDefaults { + let file = self.ensureFile() + let defaults = file.defaults ?? ExecApprovalsDefaults() + return ExecApprovalsResolvedDefaults( + security: defaults.security ?? self.defaultSecurity, + ask: defaults.ask ?? self.defaultAsk, + askFallback: defaults.askFallback ?? self.defaultAskFallback, + autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) + } + + static func saveDefaults(_ defaults: ExecApprovalsDefaults) { + self.updateFile { file in + file.defaults = defaults + } + } + + static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) { + self.updateFile { file in + var defaults = file.defaults ?? ExecApprovalsDefaults() + mutate(&defaults) + file.defaults = defaults + } + } + + static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) { + self.updateFile { file in + var agents = file.agents ?? [:] + let key = self.agentKey(agentId) + if agent.isEmpty { + agents.removeValue(forKey: key) + } else { + agents[key] = agent + } + file.agents = agents.isEmpty ? nil : agents + } + } + + @discardableResult + static func addAllowlistEntry(agentId: String?, pattern: String) -> ExecAllowlistPatternValidationReason? { + let normalizedPattern: String + switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { + case let .valid(validPattern): + normalizedPattern = validPattern + case let .invalid(reason): + return reason + } + + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + var allowlist = entry.allowlist ?? [] + if allowlist.contains(where: { $0.pattern == normalizedPattern }) { return } + allowlist.append(ExecAllowlistEntry( + pattern: normalizedPattern, + lastUsedAt: Date().timeIntervalSince1970 * 1000)) + entry.allowlist = allowlist + agents[key] = entry + file.agents = agents + } + return nil + } + + static func recordAllowlistUse( + agentId: String?, + pattern: String, + command: String, + resolvedPath: String?) + { + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in + guard item.pattern == pattern else { return item } + return ExecAllowlistEntry( + id: item.id, + pattern: item.pattern, + lastUsedAt: Date().timeIntervalSince1970 * 1000, + lastUsedCommand: command, + lastResolvedPath: resolvedPath) + } + entry.allowlist = allowlist + agents[key] = entry + file.agents = agents + } + } + + @discardableResult + static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) -> [ExecAllowlistRejectedEntry] { + var rejected: [ExecAllowlistRejectedEntry] = [] + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + let normalized = self.normalizeAllowlistEntries(allowlist, dropInvalid: true) + rejected = normalized.rejected + let cleaned = normalized.entries + entry.allowlist = cleaned + agents[key] = entry + file.agents = agents + } + return rejected + } + + static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) { + self.updateFile { file in + let key = self.agentKey(agentId) + var agents = file.agents ?? [:] + var entry = agents[key] ?? ExecApprovalsAgent() + mutate(&entry) + if entry.isEmpty { + agents.removeValue(forKey: key) + } else { + agents[key] = entry + } + file.agents = agents.isEmpty ? nil : agents + } + } + + private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) { + var file = self.ensureFile() + mutate(&file) + self.saveFile(file) + } + + private static func generateToken() -> String { + var bytes = [UInt8](repeating: 0, count: 24) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + if status == errSecSuccess { + return Data(bytes) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return UUID().uuidString + } + + private static func hashRaw(_ raw: String?) -> String { + let data = Data((raw ?? "").utf8) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func hashFile(_ file: ExecApprovalsFile) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = (try? encoder.encode(file)) ?? Data() + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func expandPath(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == "~" { + return FileManager().homeDirectoryForCurrentUser.path + } + if trimmed.hasPrefix("~/") { + let suffix = trimmed.dropFirst(2) + return FileManager().homeDirectoryForCurrentUser + .appendingPathComponent(String(suffix)).path + } + return trimmed + } + + private static func agentKey(_ agentId: String?) -> String { + let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? self.defaultAgentId : trimmed + } + + private static func normalizedPattern(_ pattern: String?) -> String? { + switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { + case let .valid(normalized): + return normalized.lowercased() + case .invalid(.empty): + return nil + case .invalid: + let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed.lowercased() + } + } + + private static func migrateLegacyPattern(_ entry: ExecAllowlistEntry) -> ExecAllowlistEntry { + let trimmedPattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedResolved = entry.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved + + switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { + case let .valid(pattern): + return ExecAllowlistEntry( + id: entry.id, + pattern: pattern, + lastUsedAt: entry.lastUsedAt, + lastUsedCommand: entry.lastUsedCommand, + lastResolvedPath: normalizedResolved) + case .invalid: + switch ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved) { + case let .valid(migratedPattern): + return ExecAllowlistEntry( + id: entry.id, + pattern: migratedPattern, + lastUsedAt: entry.lastUsedAt, + lastUsedCommand: entry.lastUsedCommand, + lastResolvedPath: normalizedResolved) + case .invalid: + return ExecAllowlistEntry( + id: entry.id, + pattern: trimmedPattern, + lastUsedAt: entry.lastUsedAt, + lastUsedCommand: entry.lastUsedCommand, + lastResolvedPath: normalizedResolved) + } + } + } + + private static func normalizeAllowlistEntries( + _ entries: [ExecAllowlistEntry], + dropInvalid: Bool) -> (entries: [ExecAllowlistEntry], rejected: [ExecAllowlistRejectedEntry]) + { + var normalized: [ExecAllowlistEntry] = [] + normalized.reserveCapacity(entries.count) + var rejected: [ExecAllowlistRejectedEntry] = [] + + for entry in entries { + let migrated = self.migrateLegacyPattern(entry) + let trimmedPattern = migrated.pattern.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedResolvedPath = migrated.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedResolvedPath = trimmedResolvedPath.isEmpty ? nil : trimmedResolvedPath + + switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { + case let .valid(pattern): + normalized.append( + ExecAllowlistEntry( + id: migrated.id, + pattern: pattern, + lastUsedAt: migrated.lastUsedAt, + lastUsedCommand: migrated.lastUsedCommand, + lastResolvedPath: normalizedResolvedPath)) + case let .invalid(reason): + if dropInvalid { + rejected.append( + ExecAllowlistRejectedEntry( + id: migrated.id, + pattern: trimmedPattern, + reason: reason)) + } else if reason != .empty { + normalized.append( + ExecAllowlistEntry( + id: migrated.id, + pattern: trimmedPattern, + lastUsedAt: migrated.lastUsedAt, + lastUsedCommand: migrated.lastUsedCommand, + lastResolvedPath: normalizedResolvedPath)) + } + } + } + + return (normalized, rejected) + } + + private static func mergeAgents( + current: ExecApprovalsAgent, + legacy: ExecApprovalsAgent) -> ExecApprovalsAgent + { + let currentAllowlist = self.normalizeAllowlistEntries(current.allowlist ?? [], dropInvalid: false).entries + let legacyAllowlist = self.normalizeAllowlistEntries(legacy.allowlist ?? [], dropInvalid: false).entries + var seen = Set() + var allowlist: [ExecAllowlistEntry] = [] + func append(_ entry: ExecAllowlistEntry) { + guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else { + return + } + seen.insert(key) + allowlist.append(entry) + } + for entry in currentAllowlist { + append(entry) + } + for entry in legacyAllowlist { + append(entry) + } + + return ExecApprovalsAgent( + security: current.security ?? legacy.security, + ask: current.ask ?? legacy.ask, + askFallback: current.askFallback ?? legacy.askFallback, + autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills, + allowlist: allowlist.isEmpty ? nil : allowlist) + } +} + +enum ExecApprovalHelpers { + static func validateAllowlistPattern(_ pattern: String?) -> ExecAllowlistPatternValidation { + let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return .invalid(.empty) } + guard self.containsPathComponent(trimmed) else { return .invalid(.missingPathComponent) } + return .valid(trimmed) + } + + static func isPathPattern(_ pattern: String?) -> Bool { + switch self.validateAllowlistPattern(pattern) { + case .valid: + true + case .invalid: + false + } + } + + static func parseDecision(_ raw: String?) -> ExecApprovalDecision? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + return ExecApprovalDecision(rawValue: trimmed) + } + + static func requiresAsk( + ask: ExecAsk, + security: ExecSecurity, + allowlistMatch: ExecAllowlistEntry?, + skillAllow: Bool) -> Bool + { + if ask == .always { return true } + if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true } + return false + } + + static func allowlistPattern(command: [String], resolution: ExecCommandResolution?) -> String? { + let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? "" + return pattern.isEmpty ? nil : pattern + } + + private static func containsPathComponent(_ pattern: String) -> Bool { + pattern.contains("/") || pattern.contains("~") || pattern.contains("\\") + } +} + +struct ExecEventPayload: Codable, Sendable { + var sessionKey: String + var runId: String + var host: String + var command: String? + var exitCode: Int? + var timedOut: Bool? + var success: Bool? + var output: String? + var reason: String? + + static func truncateOutput(_ raw: String, maxChars: Int = 20000) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.count <= maxChars { return trimmed } + let suffix = trimmed.suffix(maxChars) + return "... (truncated) \(suffix)" + } +} + +actor SkillBinsCache { + static let shared = SkillBinsCache() + + private var bins: Set = [] + private var lastRefresh: Date? + private let refreshInterval: TimeInterval = 90 + + func currentBins(force: Bool = false) async -> Set { + if force || self.isStale() { + await self.refresh() + } + return self.bins + } + + func refresh() async { + do { + let report = try await GatewayConnection.shared.skillsStatus() + var next = Set() + for skill in report.skills { + for bin in skill.requirements.bins { + let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { next.insert(trimmed) } + } + } + self.bins = next + self.lastRefresh = Date() + } catch { + if self.lastRefresh == nil { + self.bins = [] + } + } + } + + private func isStale() -> Bool { + guard let lastRefresh else { return true } + return Date().timeIntervalSince(lastRefresh) > self.refreshInterval + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift new file mode 100644 index 00000000..670fa891 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift @@ -0,0 +1,123 @@ +import CoreGraphics +import Foundation +import OpenClawKit +import OpenClawProtocol +import OSLog + +@MainActor +final class ExecApprovalsGatewayPrompter { + static let shared = ExecApprovalsGatewayPrompter() + + private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.gateway") + private var task: Task? + + struct GatewayApprovalRequest: Codable, Sendable { + var id: String + var request: ExecApprovalPromptRequest + var createdAtMs: Int + var expiresAtMs: Int + } + + func start() { + guard self.task == nil else { return } + self.task = Task { [weak self] in + await self?.run() + } + } + + func stop() { + self.task?.cancel() + self.task = nil + } + + private func run() async { + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) + for await push in stream { + if Task.isCancelled { return } + await self.handle(push: push) + } + } + + private func handle(push: GatewayPush) async { + guard case let .event(evt) = push else { return } + guard evt.event == "exec.approval.requested" else { return } + guard let payload = evt.payload else { return } + do { + let data = try JSONEncoder().encode(payload) + let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data) + guard self.shouldPresent(request: request) else { return } + let decision = ExecApprovalsPromptPresenter.prompt(request.request) + try await GatewayConnection.shared.requestVoid( + method: .execApprovalResolve, + params: [ + "id": AnyCodable(request.id), + "decision": AnyCodable(decision.rawValue), + ], + timeoutMs: 10000) + } catch { + self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)") + } + } + + private func shouldPresent(request: GatewayApprovalRequest) -> Bool { + let mode = AppStateStore.shared.connectionMode + let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) + let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) + return Self.shouldPresent( + mode: mode, + activeSession: activeSession, + requestSession: requestSession, + lastInputSeconds: Self.lastInputSeconds(), + thresholdSeconds: 120) + } + + private static func shouldPresent( + mode: AppState.ConnectionMode, + activeSession: String?, + requestSession: String?, + lastInputSeconds: Int?, + thresholdSeconds: Int) -> Bool + { + let active = activeSession?.trimmingCharacters(in: .whitespacesAndNewlines) + let requested = requestSession?.trimmingCharacters(in: .whitespacesAndNewlines) + let recentlyActive = lastInputSeconds.map { $0 <= thresholdSeconds } ?? (mode == .local) + + if let session = requested, !session.isEmpty { + if let active, !active.isEmpty { + return active == session + } + return recentlyActive + } + + if let active, !active.isEmpty { + return true + } + return mode == .local + } + + private static func lastInputSeconds() -> Int? { + let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null + let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) + if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } + return Int(seconds.rounded()) + } +} + +#if DEBUG +extension ExecApprovalsGatewayPrompter { + static func _testShouldPresent( + mode: AppState.ConnectionMode, + activeSession: String?, + requestSession: String?, + lastInputSeconds: Int?, + thresholdSeconds: Int = 120) -> Bool + { + self.shouldPresent( + mode: mode, + activeSession: activeSession, + requestSession: requestSession, + lastInputSeconds: lastInputSeconds, + thresholdSeconds: thresholdSeconds) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift new file mode 100644 index 00000000..1417589a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -0,0 +1,787 @@ +import AppKit +import CryptoKit +import Darwin +import Foundation +import OpenClawKit +import OSLog + +struct ExecApprovalPromptRequest: Codable, Sendable { + var command: String + var cwd: String? + var host: String? + var security: String? + var ask: String? + var agentId: String? + var resolvedPath: String? + var sessionKey: String? +} + +private struct ExecApprovalSocketRequest: Codable { + var type: String + var token: String + var id: String + var request: ExecApprovalPromptRequest +} + +private struct ExecApprovalSocketDecision: Codable { + var type: String + var id: String + var decision: ExecApprovalDecision +} + +private struct ExecHostSocketRequest: Codable { + var type: String + var id: String + var nonce: String + var ts: Int + var hmac: String + var requestJson: String +} + +struct ExecHostRequest: Codable { + var command: [String] + var rawCommand: String? + var cwd: String? + var env: [String: String]? + var timeoutMs: Int? + var needsScreenRecording: Bool? + var agentId: String? + var sessionKey: String? + var approvalDecision: ExecApprovalDecision? +} + +private struct ExecHostRunResult: Codable { + var exitCode: Int? + var timedOut: Bool + var success: Bool + var stdout: String + var stderr: String + var error: String? +} + +struct ExecHostError: Codable, Error { + var code: String + var message: String + var reason: String? +} + +private struct ExecHostResponse: Codable { + var type: String + var id: String + var ok: Bool + var payload: ExecHostRunResult? + var error: ExecHostError? +} + +enum ExecApprovalsSocketClient { + private struct TimeoutError: LocalizedError { + var message: String + var errorDescription: String? { + self.message + } + } + + static func requestDecision( + socketPath: String, + token: String, + request: ExecApprovalPromptRequest, + timeoutMs: Int = 15000) async -> ExecApprovalDecision? + { + let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil } + do { + return try await AsyncTimeout.withTimeoutMs( + timeoutMs: timeoutMs, + onTimeout: { + TimeoutError(message: "exec approvals socket timeout") + }, + operation: { + try await Task.detached { + try self.requestDecisionSync( + socketPath: trimmedPath, + token: trimmedToken, + request: request) + }.value + }) + } catch { + return nil + } + } + + private static func requestDecisionSync( + socketPath: String, + token: String, + request: ExecApprovalPromptRequest) throws -> ExecApprovalDecision? + { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: "ExecApprovals", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "socket create failed", + ]) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + if socketPath.utf8.count >= maxLen { + throw NSError(domain: "ExecApprovals", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "socket path too long", + ]) + } + socketPath.withCString { cstr in + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self) + strncpy(raw, cstr, maxLen - 1) + } + } + let size = socklen_t(MemoryLayout.size(ofValue: addr)) + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in + connect(fd, rebound, size) + } + } + if result != 0 { + throw NSError(domain: "ExecApprovals", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "socket connect failed", + ]) + } + + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + + let message = ExecApprovalSocketRequest( + type: "request", + token: token, + id: UUID().uuidString, + request: request) + let data = try JSONEncoder().encode(message) + var payload = data + payload.append(0x0A) + try handle.write(contentsOf: payload) + + guard let line = try self.readLine(from: handle, maxBytes: 256_000), + let lineData = line.data(using: .utf8) + else { return nil } + let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData) + return response.decision + } + + private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? { + var buffer = Data() + while buffer.count < maxBytes { + let chunk = try handle.read(upToCount: 4096) ?? Data() + if chunk.isEmpty { break } + buffer.append(chunk) + if buffer.contains(0x0A) { break } + } + guard let newlineIndex = buffer.firstIndex(of: 0x0A) else { + guard !buffer.isEmpty else { return nil } + return String(data: buffer, encoding: .utf8) + } + let lineData = buffer.subdata(in: 0.. ExecApprovalDecision { + NSApp.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Allow this command?" + alert.informativeText = "Review the command details before allowing." + alert.accessoryView = self.buildAccessoryView(request) + + alert.addButton(withTitle: "Allow Once") + alert.addButton(withTitle: "Always Allow") + alert.addButton(withTitle: "Don't Allow") + if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { + alert.buttons[2].hasDestructiveAction = true + } + + switch alert.runModal() { + case .alertFirstButtonReturn: + return .allowOnce + case .alertSecondButtonReturn: + return .allowAlways + default: + return .deny + } + } + + @MainActor + private static func buildAccessoryView(_ request: ExecApprovalPromptRequest) -> NSView { + let stack = NSStackView() + stack.orientation = .vertical + stack.spacing = 8 + stack.alignment = .leading + stack.translatesAutoresizingMaskIntoConstraints = false + stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 380).isActive = true + + let commandTitle = NSTextField(labelWithString: "Command") + commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) + stack.addArrangedSubview(commandTitle) + + let commandText = NSTextView() + commandText.isEditable = false + commandText.isSelectable = true + commandText.drawsBackground = true + commandText.backgroundColor = NSColor.textBackgroundColor + commandText.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + commandText.string = request.command + commandText.textContainerInset = NSSize(width: 6, height: 6) + commandText.textContainer?.lineFragmentPadding = 0 + commandText.textContainer?.widthTracksTextView = true + commandText.isHorizontallyResizable = false + commandText.isVerticallyResizable = true + + let commandScroll = NSScrollView() + commandScroll.borderType = .lineBorder + commandScroll.hasVerticalScroller = true + commandScroll.hasHorizontalScroller = false + commandScroll.autohidesScrollers = true + commandScroll.documentView = commandText + commandScroll.translatesAutoresizingMaskIntoConstraints = false + commandScroll.widthAnchor.constraint(greaterThanOrEqualToConstant: 380).isActive = true + commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true + commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true + commandScroll.heightAnchor.constraint(lessThanOrEqualToConstant: 120).isActive = true + stack.addArrangedSubview(commandScroll) + + let contextTitle = NSTextField(labelWithString: "Context") + contextTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) + stack.addArrangedSubview(contextTitle) + + let contextStack = NSStackView() + contextStack.orientation = .vertical + contextStack.spacing = 4 + contextStack.alignment = .leading + + let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedCwd.isEmpty { + self.addDetailRow(title: "Working directory", value: trimmedCwd, to: contextStack) + } + let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedAgent.isEmpty { + self.addDetailRow(title: "Agent", value: trimmedAgent, to: contextStack) + } + let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedPath.isEmpty { + self.addDetailRow(title: "Executable", value: trimmedPath, to: contextStack) + } + let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedHost.isEmpty { + self.addDetailRow(title: "Host", value: trimmedHost, to: contextStack) + } + if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty { + self.addDetailRow(title: "Security", value: security, to: contextStack) + } + if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty { + self.addDetailRow(title: "Ask mode", value: ask, to: contextStack) + } + + if contextStack.arrangedSubviews.isEmpty { + let empty = NSTextField(labelWithString: "No additional context provided.") + empty.textColor = NSColor.secondaryLabelColor + empty.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + contextStack.addArrangedSubview(empty) + } + + stack.addArrangedSubview(contextStack) + + let footer = NSTextField(labelWithString: "This runs on this machine.") + footer.textColor = NSColor.secondaryLabelColor + footer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + stack.addArrangedSubview(footer) + + return stack + } + + @MainActor + private static func addDetailRow(title: String, value: String, to stack: NSStackView) { + let row = NSStackView() + row.orientation = .horizontal + row.spacing = 6 + row.alignment = .firstBaseline + + let titleLabel = NSTextField(labelWithString: "\(title):") + titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold) + titleLabel.textColor = NSColor.secondaryLabelColor + + let valueLabel = NSTextField(labelWithString: value) + valueLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + valueLabel.lineBreakMode = .byTruncatingMiddle + valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + row.addArrangedSubview(titleLabel) + row.addArrangedSubview(valueLabel) + stack.addArrangedSubview(row) + } +} + +@MainActor +private enum ExecHostExecutor { + private typealias ExecApprovalContext = ExecApprovalEvaluation + + static func handle(_ request: ExecHostRequest) async -> ExecHostResponse { + let validatedRequest: ExecHostValidatedRequest + switch ExecHostRequestEvaluator.validateRequest(request) { + case .success(let request): + validatedRequest = request + case .failure(let error): + return self.errorResponse(error) + } + + let context = await self.buildContext( + request: request, + command: validatedRequest.command, + rawCommand: validatedRequest.displayCommand) + + switch ExecHostRequestEvaluator.evaluate( + context: context, + approvalDecision: request.approvalDecision) + { + case .deny(let error): + return self.errorResponse(error) + case .allow: + break + case .requiresPrompt: + let decision = ExecApprovalsPromptPresenter.prompt( + ExecApprovalPromptRequest( + command: context.displayCommand, + cwd: request.cwd, + host: "node", + security: context.security.rawValue, + ask: context.ask.rawValue, + agentId: context.agentId, + resolvedPath: context.resolution?.resolvedPath, + sessionKey: request.sessionKey)) + + let followupDecision: ExecApprovalDecision + switch decision { + case .deny: + followupDecision = .deny + case .allowAlways: + followupDecision = .allowAlways + self.persistAllowlistEntry(decision: decision, context: context) + case .allowOnce: + followupDecision = .allowOnce + } + + switch ExecHostRequestEvaluator.evaluate( + context: context, + approvalDecision: followupDecision) + { + case .deny(let error): + return self.errorResponse(error) + case .allow: + break + case .requiresPrompt: + return self.errorResponse( + code: "INVALID_REQUEST", + message: "unexpected approval state", + reason: "invalid") + } + } + + self.persistAllowlistEntry(decision: request.approvalDecision, context: context) + + if context.allowlistSatisfied { + var seenPatterns = Set() + for (idx, match) in context.allowlistMatches.enumerated() { + if !seenPatterns.insert(match.pattern).inserted { + continue + } + let resolvedPath = idx < context.allowlistResolutions.count + ? context.allowlistResolutions[idx].resolvedPath + : nil + ExecApprovalsStore.recordAllowlistUse( + agentId: context.agentId, + pattern: match.pattern, + command: context.displayCommand, + resolvedPath: resolvedPath) + } + } + + if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) { + return errorResponse + } + + return await self.runCommand( + command: validatedRequest.command, + cwd: request.cwd, + env: context.env, + timeoutMs: request.timeoutMs) + } + + private static func buildContext( + request: ExecHostRequest, + command: [String], + rawCommand: String?) async -> ExecApprovalContext + { + await ExecApprovalEvaluator.evaluate( + command: command, + rawCommand: rawCommand, + cwd: request.cwd, + envOverrides: request.env, + agentId: request.agentId) + } + + private static func persistAllowlistEntry( + decision: ExecApprovalDecision?, + context: ExecApprovalContext) + { + guard decision == .allowAlways, context.security == .allowlist else { return } + var seenPatterns = Set() + for candidate in context.allowlistResolutions { + guard let pattern = ExecApprovalHelpers.allowlistPattern( + command: context.command, + resolution: candidate) + else { + continue + } + if seenPatterns.insert(pattern).inserted { + ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern) + } + } + } + + private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? { + guard needsScreenRecording == true else { return nil } + let authorized = await PermissionManager + .status([.screenRecording])[.screenRecording] ?? false + if authorized { return nil } + return self.errorResponse( + code: "UNAVAILABLE", + message: "PERMISSION_MISSING: screenRecording", + reason: "permission:screenRecording") + } + + private static func runCommand( + command: [String], + cwd: String?, + env: [String: String]?, + timeoutMs: Int?) async -> ExecHostResponse + { + let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 } + let result = await Task.detached { () -> ShellExecutor.ShellResult in + await ShellExecutor.runDetailed( + command: command, + cwd: cwd, + env: env, + timeout: timeoutSec) + }.value + let payload = ExecHostRunResult( + exitCode: result.exitCode, + timedOut: result.timedOut, + success: result.success, + stdout: result.stdout, + stderr: result.stderr, + error: result.errorMessage) + return self.successResponse(payload) + } + + private static func errorResponse( + _ error: ExecHostError) -> ExecHostResponse + { + ExecHostResponse( + type: "response", + id: UUID().uuidString, + ok: false, + payload: nil, + error: error) + } + + private static func errorResponse( + code: String, + message: String, + reason: String?) -> ExecHostResponse + { + ExecHostResponse( + type: "exec-res", + id: UUID().uuidString, + ok: false, + payload: nil, + error: ExecHostError(code: code, message: message, reason: reason)) + } + + private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse { + ExecHostResponse( + type: "exec-res", + id: UUID().uuidString, + ok: true, + payload: payload, + error: nil) + } +} + +private final class ExecApprovalsSocketServer: @unchecked Sendable { + private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.socket") + private let socketPath: String + private let token: String + private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision + private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse + private var socketFD: Int32 = -1 + private var acceptTask: Task? + private var isRunning = false + + init( + socketPath: String, + token: String, + onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision, + onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse) + { + self.socketPath = socketPath + self.token = token + self.onPrompt = onPrompt + self.onExec = onExec + } + + func start() { + guard !self.isRunning else { return } + self.isRunning = true + self.acceptTask = Task.detached { [weak self] in + await self?.runAcceptLoop() + } + } + + func stop() { + self.isRunning = false + self.acceptTask?.cancel() + self.acceptTask = nil + if self.socketFD >= 0 { + close(self.socketFD) + self.socketFD = -1 + } + if !self.socketPath.isEmpty { + unlink(self.socketPath) + } + } + + private func runAcceptLoop() async { + let fd = self.openSocket() + guard fd >= 0 else { + self.isRunning = false + return + } + self.socketFD = fd + while self.isRunning { + var addr = sockaddr_un() + var len = socklen_t(MemoryLayout.size(ofValue: addr)) + let client = withUnsafeMutablePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in + accept(fd, rebound, &len) + } + } + if client < 0 { + if errno == EINTR { continue } + break + } + Task.detached { [weak self] in + await self?.handleClient(fd: client) + } + } + } + + private func openSocket() -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + self.logger.error("exec approvals socket create failed") + return -1 + } + unlink(self.socketPath) + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + if self.socketPath.utf8.count >= maxLen { + self.logger.error("exec approvals socket path too long") + close(fd) + return -1 + } + self.socketPath.withCString { cstr in + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self) + memset(raw, 0, maxLen) + strncpy(raw, cstr, maxLen - 1) + } + } + let size = socklen_t(MemoryLayout.size(ofValue: addr)) + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in + bind(fd, rebound, size) + } + } + if result != 0 { + self.logger.error("exec approvals socket bind failed") + close(fd) + return -1 + } + if listen(fd, 16) != 0 { + self.logger.error("exec approvals socket listen failed") + close(fd) + return -1 + } + chmod(self.socketPath, 0o600) + self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)") + return fd + } + + private func handleClient(fd: Int32) async { + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + do { + guard self.isAllowedPeer(fd: fd) else { + try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny) + return + } + guard let line = try self.readLine(from: handle, maxBytes: 256_000), + let data = line.data(using: .utf8) + else { + return + } + guard + let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = envelope["type"] as? String + else { + return + } + + if type == "request" { + let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data) + guard request.token == self.token else { + try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny) + return + } + let decision = await self.onPrompt(request.request) + try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision) + return + } + + if type == "exec" { + let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data) + let response = await self.handleExecRequest(request) + try self.sendExecResponse(handle: handle, response: response) + return + } + } catch { + self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? { + var buffer = Data() + while buffer.count < maxBytes { + let chunk = try handle.read(upToCount: 4096) ?? Data() + if chunk.isEmpty { break } + buffer.append(chunk) + if buffer.contains(0x0A) { break } + } + guard let newlineIndex = buffer.firstIndex(of: 0x0A) else { + guard !buffer.isEmpty else { return nil } + return String(data: buffer, encoding: .utf8) + } + let lineData = buffer.subdata(in: 0.. Bool { + var uid = uid_t(0) + var gid = gid_t(0) + if getpeereid(fd, &uid, &gid) != 0 { + return false + } + return uid == geteuid() + } + + private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse { + let nowMs = Int(Date().timeIntervalSince1970 * 1000) + if abs(nowMs - request.ts) > 10000 { + return ExecHostResponse( + type: "exec-res", + id: request.id, + ok: false, + payload: nil, + error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl")) + } + let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson) + if expected != request.hmac { + return ExecHostResponse( + type: "exec-res", + id: request.id, + ok: false, + payload: nil, + error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac")) + } + guard let requestData = request.requestJson.data(using: .utf8), + let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData) + else { + return ExecHostResponse( + type: "exec-res", + id: request.id, + ok: false, + payload: nil, + error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json")) + } + let response = await self.onExec(payload) + return ExecHostResponse( + type: "exec-res", + id: request.id, + ok: response.ok, + payload: response.payload, + error: response.error) + } + + private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String { + let key = SymmetricKey(data: Data(self.token.utf8)) + let message = "\(nonce):\(ts):\(requestJson)" + let mac = HMAC.authenticationCode(for: Data(message.utf8), using: key) + return mac.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift new file mode 100644 index 00000000..843062b2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -0,0 +1,265 @@ +import Foundation + +struct ExecCommandResolution: Sendable { + let rawExecutable: String + let resolvedPath: String? + let executableName: String + let cwd: String? + + static func resolve( + command: [String], + rawCommand: String?, + cwd: String?, + env: [String: String]?) -> ExecCommandResolution? + { + let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) { + return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) + } + return self.resolve(command: command, cwd: cwd, env: env) + } + + static func resolveForAllowlist( + command: [String], + rawCommand: String?, + cwd: String?, + env: [String: String]?) -> [ExecCommandResolution] + { + let shell = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand) + if shell.isWrapper { + guard let shellCommand = shell.command, + let segments = self.splitShellCommandChain(shellCommand) + else { + // Fail closed: if we cannot safely parse a shell wrapper payload, + // treat this as an allowlist miss and require approval. + return [] + } + var resolutions: [ExecCommandResolution] = [] + resolutions.reserveCapacity(segments.count) + for segment in segments { + guard let token = self.parseFirstToken(segment), + let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) + else { + return [] + } + resolutions.append(resolution) + } + return resolutions + } + + guard let resolution = self.resolve(command: command, rawCommand: rawCommand, cwd: cwd, env: env) else { + return [] + } + return [resolution] + } + + static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { + let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command) + guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) + } + + private static func resolveExecutable( + rawExecutable: String, + cwd: String?, + env: [String: String]?) -> ExecCommandResolution? + { + let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable + let hasPathSeparator = expanded.contains("/") || expanded.contains("\\") + let resolvedPath: String? = { + if hasPathSeparator { + if expanded.hasPrefix("/") { + return expanded + } + let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) + let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath + return URL(fileURLWithPath: root).appendingPathComponent(expanded).path + } + let searchPaths = self.searchPaths(from: env) + return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths) + }() + let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded + return ExecCommandResolution( + rawExecutable: expanded, + resolvedPath: resolvedPath, + executableName: name, + cwd: cwd) + } + + private static func parseFirstToken(_ command: String) -> String? { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let first = trimmed.first else { return nil } + if first == "\"" || first == "'" { + let rest = trimmed.dropFirst() + if let end = rest.firstIndex(of: first) { + return String(rest[..", next: "("), + ], + .doubleQuoted: [ + ShellFailClosedRule(token: "`", next: nil), + ShellFailClosedRule(token: "$", next: "("), + ], + ] + + private static func splitShellCommandChain(_ command: String) -> [String]? { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + var segments: [String] = [] + var current = "" + var inSingle = false + var inDouble = false + var escaped = false + let chars = Array(trimmed) + var idx = 0 + + func appendCurrent() -> Bool { + let segment = current.trimmingCharacters(in: .whitespacesAndNewlines) + guard !segment.isEmpty else { return false } + segments.append(segment) + current.removeAll(keepingCapacity: true) + return true + } + + while idx < chars.count { + let ch = chars[idx] + let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil + + if escaped { + current.append(ch) + escaped = false + idx += 1 + continue + } + + if ch == "\\", !inSingle { + current.append(ch) + escaped = true + idx += 1 + continue + } + + if ch == "'", !inDouble { + inSingle.toggle() + current.append(ch) + idx += 1 + continue + } + + if ch == "\"", !inSingle { + inDouble.toggle() + current.append(ch) + idx += 1 + continue + } + + if !inSingle, self.shouldFailClosedForShell(ch: ch, next: next, inDouble: inDouble) { + // Fail closed on command/process substitution in allowlist mode, + // including command substitution inside double-quoted shell strings. + return nil + } + + if !inSingle, !inDouble { + let prev: Character? = idx > 0 ? chars[idx - 1] : nil + if let delimiterStep = self.chainDelimiterStep(ch: ch, prev: prev, next: next) { + guard appendCurrent() else { return nil } + idx += delimiterStep + continue + } + } + + current.append(ch) + idx += 1 + } + + if escaped || inSingle || inDouble { return nil } + guard appendCurrent() else { return nil } + return segments + } + + private static func shouldFailClosedForShell(ch: Character, next: Character?, inDouble: Bool) -> Bool { + let context: ShellTokenContext = inDouble ? .doubleQuoted : .unquoted + guard let rules = self.shellFailClosedRules[context] else { + return false + } + for rule in rules { + if ch == rule.token, rule.next == nil || next == rule.next { + return true + } + } + return false + } + + private static func chainDelimiterStep(ch: Character, prev: Character?, next: Character?) -> Int? { + if ch == ";" || ch == "\n" { + return 1 + } + if ch == "&" { + if next == "&" { + return 2 + } + // Keep fd redirections like 2>&1 or &>file intact. + let prevIsRedirect = prev == ">" + let nextIsRedirect = next == ">" + return (!prevIsRedirect && !nextIsRedirect) ? 1 : nil + } + if ch == "|" { + if next == "|" || next == "&" { + return 2 + } + return 1 + } + return nil + } + + private static func searchPaths(from env: [String: String]?) -> [String] { + let raw = env?["PATH"] + if let raw, !raw.isEmpty { + return raw.split(separator: ":").map(String.init) + } + return CommandResolver.preferredPaths() + } +} + +enum ExecCommandFormatter { + static func displayString(for argv: [String]) -> String { + argv.map { arg in + let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "\"\"" } + let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" } + if !needsQuotes { return trimmed } + let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + }.joined(separator: " ") + } + + static func displayString(for argv: [String], rawCommand: String?) -> String { + let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { return trimmed } + return self.displayString(for: argv) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift new file mode 100644 index 00000000..ebb8965e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift @@ -0,0 +1,108 @@ +import Foundation + +enum ExecCommandToken { + static func basenameLower(_ token: String) -> String { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + let normalized = trimmed.replacingOccurrences(of: "\\", with: "/") + return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased() + } +} + +enum ExecEnvInvocationUnwrapper { + static let maxWrapperDepth = 4 + + private static let optionsWithValue = Set([ + "-u", + "--unset", + "-c", + "--chdir", + "-s", + "--split-string", + "--default-signal", + "--ignore-signal", + "--block-signal", + ]) + private static let flagOptions = Set(["-i", "--ignore-environment", "-0", "--null"]) + + private static func isEnvAssignment(_ token: String) -> Bool { + let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"# + return token.range(of: pattern, options: .regularExpression) != nil + } + + static func unwrap(_ command: [String]) -> [String]? { + var idx = 1 + var expectsOptionValue = false + while idx < command.count { + let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + if expectsOptionValue { + expectsOptionValue = false + idx += 1 + continue + } + if token == "--" || token == "-" { + idx += 1 + break + } + if self.isEnvAssignment(token) { + idx += 1 + continue + } + if token.hasPrefix("-"), token != "-" { + let lower = token.lowercased() + let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower + if self.flagOptions.contains(flag) { + idx += 1 + continue + } + if self.optionsWithValue.contains(flag) { + if !lower.contains("=") { + expectsOptionValue = true + } + idx += 1 + continue + } + if lower.hasPrefix("-u") || + lower.hasPrefix("-c") || + lower.hasPrefix("-s") || + lower.hasPrefix("--unset=") || + lower.hasPrefix("--chdir=") || + lower.hasPrefix("--split-string=") || + lower.hasPrefix("--default-signal=") || + lower.hasPrefix("--ignore-signal=") || + lower.hasPrefix("--block-signal=") + { + idx += 1 + continue + } + return nil + } + break + } + guard idx < command.count else { return nil } + return Array(command[idx...]) + } + + static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] { + var current = command + var depth = 0 + while depth < self.maxWrapperDepth { + guard let token = current.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else { + break + } + guard ExecCommandToken.basenameLower(token) == "env" else { + break + } + guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else { + break + } + current = unwrapped + depth += 1 + } + return current + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift new file mode 100644 index 00000000..fe38d7ea --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift @@ -0,0 +1,84 @@ +import Foundation + +struct ExecHostValidatedRequest { + let command: [String] + let displayCommand: String +} + +enum ExecHostPolicyDecision { + case deny(ExecHostError) + case requiresPrompt + case allow(approvedByAsk: Bool) +} + +enum ExecHostRequestEvaluator { + static func validateRequest(_ request: ExecHostRequest) -> Result { + let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !command.isEmpty else { + return .failure( + ExecHostError( + code: "INVALID_REQUEST", + message: "command required", + reason: "invalid")) + } + + let validatedCommand = ExecSystemRunCommandValidator.resolve( + command: command, + rawCommand: request.rawCommand) + switch validatedCommand { + case .ok(let resolved): + return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand)) + case .invalid(let message): + return .failure( + ExecHostError( + code: "INVALID_REQUEST", + message: message, + reason: "invalid")) + } + } + + static func evaluate( + context: ExecApprovalEvaluation, + approvalDecision: ExecApprovalDecision?) -> ExecHostPolicyDecision + { + if context.security == .deny { + return .deny( + ExecHostError( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DISABLED: security=deny", + reason: "security=deny")) + } + + if approvalDecision == .deny { + return .deny( + ExecHostError( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DENIED: user denied", + reason: "user-denied")) + } + + let approvedByAsk = approvalDecision != nil + let requiresPrompt = ExecApprovalHelpers.requiresAsk( + ask: context.ask, + security: context.security, + allowlistMatch: context.allowlistMatch, + skillAllow: context.skillAllow) && approvalDecision == nil + if requiresPrompt { + return .requiresPrompt + } + + if context.security == .allowlist, + !context.allowlistSatisfied, + !context.skillAllow, + !approvedByAsk + { + return .deny( + ExecHostError( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DENIED: allowlist miss", + reason: "allowlist-miss")) + } + + return .allow(approvedByAsk: approvedByAsk) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift new file mode 100644 index 00000000..06851a7d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift @@ -0,0 +1,108 @@ +import Foundation + +enum ExecShellWrapperParser { + struct ParsedShellWrapper { + let isWrapper: Bool + let command: String? + + static let notWrapper = ParsedShellWrapper(isWrapper: false, command: nil) + } + + private enum Kind { + case posix + case cmd + case powershell + } + + private struct WrapperSpec { + let kind: Kind + let names: Set + } + + private static let posixInlineFlags = Set(["-lc", "-c", "--command"]) + private static let powershellInlineFlags = Set(["-c", "-command", "--command"]) + + private static let wrapperSpecs: [WrapperSpec] = [ + WrapperSpec(kind: .posix, names: ["ash", "sh", "bash", "zsh", "dash", "ksh", "fish"]), + WrapperSpec(kind: .cmd, names: ["cmd.exe", "cmd"]), + WrapperSpec(kind: .powershell, names: ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]), + ] + + static func extract(command: [String], rawCommand: String?) -> ParsedShellWrapper { + let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw + return self.extract(command: command, preferredRaw: preferredRaw, depth: 0) + } + + private static func extract(command: [String], preferredRaw: String?, depth: Int) -> ParsedShellWrapper { + guard depth < ExecEnvInvocationUnwrapper.maxWrapperDepth else { + return .notWrapper + } + guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else { + return .notWrapper + } + + let base0 = ExecCommandToken.basenameLower(token0) + if base0 == "env" { + guard let unwrapped = ExecEnvInvocationUnwrapper.unwrap(command) else { + return .notWrapper + } + return self.extract(command: unwrapped, preferredRaw: preferredRaw, depth: depth + 1) + } + + guard let spec = self.wrapperSpecs.first(where: { $0.names.contains(base0) }) else { + return .notWrapper + } + guard let payload = self.extractPayload(command: command, spec: spec) else { + return .notWrapper + } + let normalized = preferredRaw ?? payload + return ParsedShellWrapper(isWrapper: true, command: normalized) + } + + private static func extractPayload(command: [String], spec: WrapperSpec) -> String? { + switch spec.kind { + case .posix: + self.extractPosixInlineCommand(command) + case .cmd: + self.extractCmdInlineCommand(command) + case .powershell: + self.extractPowerShellInlineCommand(command) + } + } + + private static func extractPosixInlineCommand(_ command: [String]) -> String? { + let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : "" + guard self.posixInlineFlags.contains(flag.lowercased()) else { + return nil + } + let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : "" + return payload.isEmpty ? nil : payload + } + + private static func extractCmdInlineCommand(_ command: [String]) -> String? { + guard let idx = command + .firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) + else { + return nil + } + let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ") + let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines) + return payload.isEmpty ? nil : payload + } + + private static func extractPowerShellInlineCommand(_ command: [String]) -> String? { + for idx in 1.. String? { + let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func trimmedNonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func normalizeExecutableToken(_ token: String) -> String { + let base = ExecCommandToken.basenameLower(token) + if base.hasSuffix(".exe") { + return String(base.dropLast(4)) + } + return base + } + + private static func isEnvAssignment(_ token: String) -> Bool { + token.range(of: #"^[A-Za-z_][A-Za-z0-9_]*=.*"#, options: .regularExpression) != nil + } + + private static func hasEnvInlineValuePrefix(_ lowerToken: String) -> Bool { + self.envInlineValuePrefixes.contains { lowerToken.hasPrefix($0) } + } + + private static func unwrapEnvInvocationWithMetadata(_ argv: [String]) -> EnvUnwrapResult? { + var idx = 1 + var expectsOptionValue = false + var usesModifiers = false + + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + if expectsOptionValue { + expectsOptionValue = false + usesModifiers = true + idx += 1 + continue + } + if token == "--" || token == "-" { + idx += 1 + break + } + if self.isEnvAssignment(token) { + usesModifiers = true + idx += 1 + continue + } + if !token.hasPrefix("-") || token == "-" { + break + } + + let lower = token.lowercased() + let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower + if self.envFlagOptions.contains(flag) { + usesModifiers = true + idx += 1 + continue + } + if self.envOptionsWithValue.contains(flag) { + usesModifiers = true + if !lower.contains("=") { + expectsOptionValue = true + } + idx += 1 + continue + } + if self.hasEnvInlineValuePrefix(lower) { + usesModifiers = true + idx += 1 + continue + } + return nil + } + + if expectsOptionValue { + return nil + } + guard idx < argv.count else { + return nil + } + return EnvUnwrapResult(argv: Array(argv[idx...]), usesModifiers: usesModifiers) + } + + private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? { + guard let token0 = self.trimmedNonEmpty(argv.first) else { + return nil + } + let wrapper = self.normalizeExecutableToken(token0) + guard self.shellMultiplexerWrapperNames.contains(wrapper) else { + return nil + } + + var appletIndex = 1 + if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" { + appletIndex += 1 + } + guard appletIndex < argv.count else { + return nil + } + let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) + guard !applet.isEmpty else { + return nil + } + let normalizedApplet = self.normalizeExecutableToken(applet) + guard self.shellWrapperNames.contains(normalizedApplet) else { + return nil + } + return Array(argv[appletIndex...]) + } + + private static func hasEnvManipulationBeforeShellWrapper( + _ argv: [String], + depth: Int = 0, + envManipulationSeen: Bool = false) -> Bool + { + if depth >= ExecEnvInvocationUnwrapper.maxWrapperDepth { + return false + } + guard let token0 = self.trimmedNonEmpty(argv.first) else { + return false + } + + let normalized = self.normalizeExecutableToken(token0) + if normalized == "env" { + guard let envUnwrap = self.unwrapEnvInvocationWithMetadata(argv) else { + return false + } + return self.hasEnvManipulationBeforeShellWrapper( + envUnwrap.argv, + depth: depth + 1, + envManipulationSeen: envManipulationSeen || envUnwrap.usesModifiers) + } + + if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(argv) { + return self.hasEnvManipulationBeforeShellWrapper( + shellMultiplexer, + depth: depth + 1, + envManipulationSeen: envManipulationSeen) + } + + guard self.shellWrapperNames.contains(normalized) else { + return false + } + guard self.extractShellInlinePayload(argv, normalizedWrapper: normalized) != nil else { + return false + } + return envManipulationSeen + } + + private static func hasTrailingPositionalArgvAfterInlineCommand(_ argv: [String]) -> Bool { + let wrapperArgv = self.unwrapShellWrapperArgv(argv) + guard let token0 = self.trimmedNonEmpty(wrapperArgv.first) else { + return false + } + let wrapper = self.normalizeExecutableToken(token0) + guard self.posixOrPowerShellInlineWrapperNames.contains(wrapper) else { + return false + } + + let inlineCommandIndex: Int? = if wrapper == "powershell" || wrapper == "pwsh" { + self.resolveInlineCommandTokenIndex( + wrapperArgv, + flags: self.powershellInlineCommandFlags, + allowCombinedC: false) + } else { + self.resolveInlineCommandTokenIndex( + wrapperArgv, + flags: self.posixInlineCommandFlags, + allowCombinedC: true) + } + guard let inlineCommandIndex else { + return false + } + let start = inlineCommandIndex + 1 + guard start < wrapperArgv.count else { + return false + } + return wrapperArgv[start...].contains { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + + private static func unwrapShellWrapperArgv(_ argv: [String]) -> [String] { + var current = argv + for _ in 0.., + allowCombinedC: Bool) -> Int? + { + var idx = 1 + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + let lower = token.lowercased() + if lower == "--" { + break + } + if flags.contains(lower) { + return idx + 1 < argv.count ? idx + 1 : nil + } + if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) { + let inline = String(token.dropFirst(inlineOffset)) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !inline.isEmpty { + return idx + } + return idx + 1 < argv.count ? idx + 1 : nil + } + idx += 1 + } + return nil + } + + private static func combinedCommandInlineOffset(_ token: String) -> Int? { + let chars = Array(token.lowercased()) + guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else { + return nil + } + if chars.dropFirst().contains("-") { + return nil + } + guard let commandIndex = chars.firstIndex(of: "c"), commandIndex > 0 else { + return nil + } + return commandIndex + 1 + } + + private static func extractShellInlinePayload( + _ argv: [String], + normalizedWrapper: String) -> String? + { + if normalizedWrapper == "cmd" { + return self.extractCmdInlineCommand(argv) + } + if normalizedWrapper == "powershell" || normalizedWrapper == "pwsh" { + return self.extractInlineCommandByFlags( + argv, + flags: self.powershellInlineCommandFlags, + allowCombinedC: false) + } + return self.extractInlineCommandByFlags( + argv, + flags: self.posixInlineCommandFlags, + allowCombinedC: true) + } + + private static func extractInlineCommandByFlags( + _ argv: [String], + flags: Set, + allowCombinedC: Bool) -> String? + { + var idx = 1 + while idx < argv.count { + let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + let lower = token.lowercased() + if lower == "--" { + break + } + if flags.contains(lower) { + return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil) + } + if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) { + let inline = String(token.dropFirst(inlineOffset)) + if let inlineValue = self.trimmedNonEmpty(inline) { + return inlineValue + } + return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil) + } + idx += 1 + } + return nil + } + + private static func extractCmdInlineCommand(_ argv: [String]) -> String? { + guard let idx = argv.firstIndex(where: { + let token = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return token == "/c" || token == "/k" + }) else { + return nil + } + let tailIndex = idx + 1 + guard tailIndex < argv.count else { + return nil + } + let payload = argv[tailIndex...].joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + return payload.isEmpty ? nil : payload + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/FileHandle+SafeRead.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/FileHandle+SafeRead.swift new file mode 100644 index 00000000..7cd16096 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/FileHandle+SafeRead.swift @@ -0,0 +1,28 @@ +import Foundation + +extension FileHandle { + /// Reads until EOF using the throwing FileHandle API and returns empty `Data` on failure. + /// + /// Important: Avoid legacy, non-throwing FileHandle read APIs (e.g. `readDataToEndOfFile()` and + /// `availableData`). They can raise Objective-C exceptions when the handle is closed/invalid, which + /// will abort the process. + func readToEndSafely() -> Data { + do { + return try self.readToEnd() ?? Data() + } catch { + return Data() + } + } + + /// Reads up to `count` bytes using the throwing FileHandle API and returns empty `Data` on failure/EOF. + /// + /// Important: Use this instead of `availableData` in callbacks like `readabilityHandler` to avoid + /// Objective-C exceptions terminating the process. + func readSafely(upToCount count: Int) -> Data { + do { + return try self.read(upToCount: count) ?? Data() + } catch { + return Data() + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayAutostartPolicy.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayAutostartPolicy.swift new file mode 100644 index 00000000..27f60aba --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayAutostartPolicy.swift @@ -0,0 +1,14 @@ +import Foundation + +enum GatewayAutostartPolicy { + static func shouldStartGateway(mode: AppState.ConnectionMode, paused: Bool) -> Bool { + mode == .local && !paused + } + + static func shouldEnsureLaunchAgent( + mode: AppState.ConnectionMode, + paused: Bool) -> Bool + { + self.shouldStartGateway(mode: mode, paused: paused) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayConnection.swift new file mode 100644 index 00000000..0d7d582d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -0,0 +1,742 @@ +import Foundation +import OpenClawChatUI +import OpenClawKit +import OpenClawProtocol +import OSLog + +private let gatewayConnectionLogger = Logger(subsystem: "ai.openclaw", category: "gateway.connection") + +enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { + case last + case whatsapp + case telegram + case discord + case googlechat + case slack + case signal + case imessage + case msteams + case bluebubbles + case webchat + + init(raw: String?) { + let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + self = GatewayAgentChannel(rawValue: normalized) ?? .last + } + + var isDeliverable: Bool { + self != .webchat + } + + func shouldDeliver(_ deliver: Bool) -> Bool { + deliver && self.isDeliverable + } +} + +struct GatewayAgentInvocation: Sendable { + var message: String + var sessionKey: String = "main" + var thinking: String? + var deliver: Bool = false + var to: String? + var channel: GatewayAgentChannel = .last + var timeoutSeconds: Int? + var idempotencyKey: String = UUID().uuidString +} + +/// Single, shared Gateway websocket connection for the whole app. +/// +/// This owns exactly one `GatewayChannelActor` and reuses it across all callers +/// (ControlChannel, debug actions, SwiftUI WebChat, etc.). +actor GatewayConnection { + static let shared = GatewayConnection() + + typealias Config = (url: URL, token: String?, password: String?) + + enum Method: String, Sendable { + case agent + case status + case setHeartbeats = "set-heartbeats" + case systemEvent = "system-event" + case health + case channelsStatus = "channels.status" + case configGet = "config.get" + case configSet = "config.set" + case configPatch = "config.patch" + case configSchema = "config.schema" + case wizardStart = "wizard.start" + case wizardNext = "wizard.next" + case wizardCancel = "wizard.cancel" + case wizardStatus = "wizard.status" + case talkConfig = "talk.config" + case talkMode = "talk.mode" + case webLoginStart = "web.login.start" + case webLoginWait = "web.login.wait" + case channelsLogout = "channels.logout" + case modelsList = "models.list" + case chatHistory = "chat.history" + case sessionsPreview = "sessions.preview" + case chatSend = "chat.send" + case chatAbort = "chat.abort" + case skillsStatus = "skills.status" + case skillsInstall = "skills.install" + case skillsUpdate = "skills.update" + case voicewakeGet = "voicewake.get" + case voicewakeSet = "voicewake.set" + case nodePairApprove = "node.pair.approve" + case nodePairReject = "node.pair.reject" + case devicePairList = "device.pair.list" + case devicePairApprove = "device.pair.approve" + case devicePairReject = "device.pair.reject" + case execApprovalResolve = "exec.approval.resolve" + case cronList = "cron.list" + case cronRuns = "cron.runs" + case cronRun = "cron.run" + case cronRemove = "cron.remove" + case cronUpdate = "cron.update" + case cronAdd = "cron.add" + case cronStatus = "cron.status" + } + + private let configProvider: @Sendable () async throws -> Config + private let sessionBox: WebSocketSessionBox? + private let decoder = JSONDecoder() + + private var client: GatewayChannelActor? + private var configuredURL: URL? + private var configuredToken: String? + private var configuredPassword: String? + + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + private var lastSnapshot: HelloOk? + + init( + configProvider: @escaping @Sendable () async throws -> Config = GatewayConnection.defaultConfigProvider, + sessionBox: WebSocketSessionBox? = nil) + { + self.configProvider = configProvider + self.sessionBox = sessionBox + } + + // MARK: - Low-level request + + func request( + method: String, + params: [String: AnyCodable]?, + timeoutMs: Double? = nil) async throws -> Data + { + let cfg = try await self.configProvider() + await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) + guard let client else { + throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) + } + + do { + return try await client.request(method: method, params: params, timeoutMs: timeoutMs) + } catch { + if error is GatewayResponseError || error is GatewayDecodingError { + throw error + } + + // Auto-recover in local mode by spawning/attaching a gateway and retrying a few times. + // Canvas interactions should "just work" even if the local gateway isn't running yet. + let mode = await MainActor.run { AppStateStore.shared.connectionMode } + switch mode { + case .local: + await MainActor.run { GatewayProcessManager.shared.setActive(true) } + + var lastError: Error = error + for delayMs in [150, 400, 900] { + try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) + do { + return try await client.request(method: method, params: params, timeoutMs: timeoutMs) + } catch { + lastError = error + } + } + + let nsError = lastError as NSError + if nsError.domain == URLError.errorDomain, + let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url) + { + await self.configure(url: fallback.url, token: fallback.token, password: fallback.password) + for delayMs in [150, 400, 900] { + try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) + do { + guard let client = self.client else { + throw NSError( + domain: "Gateway", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) + } + return try await client.request(method: method, params: params, timeoutMs: timeoutMs) + } catch { + lastError = error + } + } + } + + throw lastError + case .remote: + let nsError = error as NSError + guard nsError.domain == URLError.errorDomain else { throw error } + + var lastError: Error = error + await RemoteTunnelManager.shared.stopAll() + do { + _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() + } catch { + lastError = error + } + + for delayMs in [150, 400, 900] { + try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) + do { + let cfg = try await self.configProvider() + await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) + guard let client = self.client else { + throw NSError( + domain: "Gateway", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) + } + return try await client.request(method: method, params: params, timeoutMs: timeoutMs) + } catch { + lastError = error + } + } + + throw lastError + case .unconfigured: + throw error + } + } + } + + func requestRaw( + method: Method, + params: [String: AnyCodable]? = nil, + timeoutMs: Double? = nil) async throws -> Data + { + try await self.request(method: method.rawValue, params: params, timeoutMs: timeoutMs) + } + + func requestRaw( + method: String, + params: [String: AnyCodable]? = nil, + timeoutMs: Double? = nil) async throws -> Data + { + try await self.request(method: method, params: params, timeoutMs: timeoutMs) + } + + func requestDecoded( + method: Method, + params: [String: AnyCodable]? = nil, + timeoutMs: Double? = nil) async throws -> T + { + let data = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs) + do { + return try self.decoder.decode(T.self, from: data) + } catch { + throw GatewayDecodingError(method: method.rawValue, message: error.localizedDescription) + } + } + + func requestVoid( + method: Method, + params: [String: AnyCodable]? = nil, + timeoutMs: Double? = nil) async throws + { + _ = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs) + } + + /// Ensure the underlying socket is configured (and replaced if config changed). + func refresh() async throws { + let cfg = try await self.configProvider() + await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) + } + + func authSource() async -> GatewayAuthSource? { + guard let client else { return nil } + return await client.authSource() + } + + func shutdown() async { + if let client { + await client.shutdown() + } + self.client = nil + self.configuredURL = nil + self.configuredToken = nil + self.lastSnapshot = nil + } + + func canvasHostUrl() async -> String? { + guard let snapshot = self.lastSnapshot else { return nil } + let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private func sessionDefaultString(_ defaults: [String: OpenClawProtocol.AnyCodable]?, key: String) -> String { + let raw = defaults?[key]?.value as? String + return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + } + + func cachedMainSessionKey() -> String? { + guard let snapshot = self.lastSnapshot else { return nil } + let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey") + return trimmed.isEmpty ? nil : trimmed + } + + func cachedGatewayVersion() -> String? { + guard let snapshot = self.lastSnapshot else { return nil } + let raw = snapshot.server["version"]?.value as? String + let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + func snapshotPaths() -> (configPath: String?, stateDir: String?) { + guard let snapshot = self.lastSnapshot else { return (nil, nil) } + let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines) + let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines) + return ( + configPath?.isEmpty == false ? configPath : nil, + stateDir?.isEmpty == false ? stateDir : nil) + } + + func subscribe(bufferingNewest: Int = 100) -> AsyncStream { + let id = UUID() + let snapshot = self.lastSnapshot + let connection = self + return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in + if let snapshot { + continuation.yield(.snapshot(snapshot)) + } + self.subscribers[id] = continuation + continuation.onTermination = { @Sendable _ in + Task { await connection.removeSubscriber(id) } + } + } + } + + private func removeSubscriber(_ id: UUID) { + self.subscribers[id] = nil + } + + private func broadcast(_ push: GatewayPush) { + if case let .snapshot(snapshot) = push { + self.lastSnapshot = snapshot + if let mainSessionKey = self.cachedMainSessionKey() { + Task { @MainActor in + WorkActivityStore.shared.setMainSessionKey(mainSessionKey) + } + } + } + for (_, continuation) in self.subscribers { + continuation.yield(push) + } + } + + private func canonicalizeSessionKey(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed } + let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey") + guard !mainSessionKey.isEmpty else { return trimmed } + let mainKey = self.sessionDefaultString(defaults, key: "mainKey") + let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId") + let isMainAlias = + trimmed == "main" || + (!mainKey.isEmpty && trimmed == mainKey) || + trimmed == mainSessionKey || + (!defaultAgentId.isEmpty && + (trimmed == "agent:\(defaultAgentId):main" || + (mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)"))) + return isMainAlias ? mainSessionKey : trimmed + } + + private func configure(url: URL, token: String?, password: String?) async { + if self.client != nil, self.configuredURL == url, self.configuredToken == token, + self.configuredPassword == password + { + return + } + if let client { + await client.shutdown() + } + self.lastSnapshot = nil + self.client = GatewayChannelActor( + url: url, + token: token, + password: password, + session: self.sessionBox, + pushHandler: { [weak self] push in + await self?.handle(push: push) + }) + self.configuredURL = url + self.configuredToken = token + self.configuredPassword = password + } + + private func handle(push: GatewayPush) { + self.broadcast(push) + } + + private static func defaultConfigProvider() async throws -> Config { + try await GatewayEndpointStore.shared.requireConfig() + } +} + +// MARK: - Typed gateway API + +extension GatewayConnection { + struct ConfigGetSnapshot: Decodable, Sendable { + struct SnapshotConfig: Decodable, Sendable { + struct Session: Decodable, Sendable { + let mainKey: String? + let scope: String? + } + + let session: Session? + } + + let config: SnapshotConfig? + } + + static func mainSessionKey(fromConfigGetData data: Data) throws -> String { + let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data) + let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines) + if scope == "global" { + return "global" + } + return "main" + } + + func mainSessionKey(timeoutMs: Double = 15000) async -> String { + if let cached = self.cachedMainSessionKey() { + return cached + } + do { + let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs) + return try Self.mainSessionKey(fromConfigGetData: data) + } catch { + return "main" + } + } + + func status() async -> (ok: Bool, error: String?) { + do { + _ = try await self.requestRaw(method: .status) + return (true, nil) + } catch { + return (false, error.localizedDescription) + } + } + + func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool { + do { + try await self.requestVoid(method: .setHeartbeats, params: ["enabled": AnyCodable(enabled)]) + return true + } catch { + gatewayConnectionLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)") + return false + } + } + + func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) { + let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return (false, "message empty") } + let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey) + + var params: [String: AnyCodable] = [ + "message": AnyCodable(trimmed), + "sessionKey": AnyCodable(sessionKey), + "thinking": AnyCodable(invocation.thinking ?? "default"), + "deliver": AnyCodable(invocation.deliver), + "to": AnyCodable(invocation.to ?? ""), + "channel": AnyCodable(invocation.channel.rawValue), + "idempotencyKey": AnyCodable(invocation.idempotencyKey), + ] + if let timeout = invocation.timeoutSeconds { + params["timeout"] = AnyCodable(timeout) + } + + do { + try await self.requestVoid(method: .agent, params: params) + return (true, nil) + } catch { + return (false, error.localizedDescription) + } + } + + func sendAgent( + message: String, + thinking: String?, + sessionKey: String, + deliver: Bool, + to: String?, + channel: GatewayAgentChannel = .last, + timeoutSeconds: Int? = nil, + idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) + { + await self.sendAgent(GatewayAgentInvocation( + message: message, + sessionKey: sessionKey, + thinking: thinking, + deliver: deliver, + to: to, + channel: channel, + timeoutSeconds: timeoutSeconds, + idempotencyKey: idempotencyKey)) + } + + func sendSystemEvent(_ params: [String: AnyCodable]) async { + do { + try await self.requestVoid(method: .systemEvent, params: params) + } catch { + // Best-effort only. + } + } + + // MARK: - Health + + func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot { + let data = try await self.requestRaw(method: .health, timeoutMs: timeoutMs) + if let snap = decodeHealthSnapshot(from: data) { return snap } + throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot") + } + + func healthOK(timeoutMs: Int = 8000) async throws -> Bool { + let data = try await self.requestRaw(method: .health, timeoutMs: Double(timeoutMs)) + return (try? self.decoder.decode(OpenClawGatewayHealthOK.self, from: data))?.ok ?? true + } + + // MARK: - Skills + + func skillsStatus() async throws -> SkillsStatusReport { + try await self.requestDecoded(method: .skillsStatus) + } + + func skillsInstall( + name: String, + installId: String, + timeoutMs: Int? = nil) async throws -> SkillInstallResult + { + var params: [String: AnyCodable] = [ + "name": AnyCodable(name), + "installId": AnyCodable(installId), + ] + if let timeoutMs { + params["timeoutMs"] = AnyCodable(timeoutMs) + } + return try await self.requestDecoded(method: .skillsInstall, params: params) + } + + func skillsUpdate( + skillKey: String, + enabled: Bool? = nil, + apiKey: String? = nil, + env: [String: String]? = nil) async throws -> SkillUpdateResult + { + var params: [String: AnyCodable] = [ + "skillKey": AnyCodable(skillKey), + ] + if let enabled { params["enabled"] = AnyCodable(enabled) } + if let apiKey { params["apiKey"] = AnyCodable(apiKey) } + if let env, !env.isEmpty { params["env"] = AnyCodable(env) } + return try await self.requestDecoded(method: .skillsUpdate, params: params) + } + + // MARK: - Sessions + + func sessionsPreview( + keys: [String], + limit: Int? = nil, + maxChars: Int? = nil, + timeoutMs: Int? = nil) async throws -> OpenClawSessionsPreviewPayload + { + let resolvedKeys = keys + .map { self.canonicalizeSessionKey($0) } + .filter { !$0.isEmpty } + if resolvedKeys.isEmpty { + return OpenClawSessionsPreviewPayload(ts: 0, previews: []) + } + var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)] + if let limit { params["limit"] = AnyCodable(limit) } + if let maxChars { params["maxChars"] = AnyCodable(maxChars) } + let timeout = timeoutMs.map { Double($0) } + return try await self.requestDecoded( + method: .sessionsPreview, + params: params, + timeoutMs: timeout) + } + + // MARK: - Chat + + func chatHistory( + sessionKey: String, + limit: Int? = nil, + timeoutMs: Int? = nil) async throws -> OpenClawChatHistoryPayload + { + let resolvedKey = self.canonicalizeSessionKey(sessionKey) + var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)] + if let limit { params["limit"] = AnyCodable(limit) } + let timeout = timeoutMs.map { Double($0) } + return try await self.requestDecoded( + method: .chatHistory, + params: params, + timeoutMs: timeout) + } + + func chatSend( + sessionKey: String, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [OpenClawChatAttachmentPayload], + timeoutMs: Int = 30000) async throws -> OpenClawChatSendResponse + { + let resolvedKey = self.canonicalizeSessionKey(sessionKey) + var params: [String: AnyCodable] = [ + "sessionKey": AnyCodable(resolvedKey), + "message": AnyCodable(message), + "thinking": AnyCodable(thinking), + "idempotencyKey": AnyCodable(idempotencyKey), + "timeoutMs": AnyCodable(timeoutMs), + ] + + if !attachments.isEmpty { + let encoded = attachments.map { att in + [ + "type": att.type, + "mimeType": att.mimeType, + "fileName": att.fileName, + "content": att.content, + ] + } + params["attachments"] = AnyCodable(encoded) + } + + return try await self.requestDecoded( + method: .chatSend, + params: params, + timeoutMs: Double(timeoutMs)) + } + + func chatAbort(sessionKey: String, runId: String) async throws -> Bool { + let resolvedKey = self.canonicalizeSessionKey(sessionKey) + struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? } + let res: AbortResponse = try await self.requestDecoded( + method: .chatAbort, + params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)]) + return res.aborted ?? false + } + + func talkMode(enabled: Bool, phase: String? = nil) async { + var params: [String: AnyCodable] = ["enabled": AnyCodable(enabled)] + if let phase { params["phase"] = AnyCodable(phase) } + try? await self.requestVoid(method: .talkMode, params: params) + } + + // MARK: - VoiceWake + + func voiceWakeGetTriggers() async throws -> [String] { + struct VoiceWakePayload: Decodable { let triggers: [String] } + let payload: VoiceWakePayload = try await self.requestDecoded(method: .voicewakeGet) + return payload.triggers + } + + func voiceWakeSetTriggers(_ triggers: [String]) async { + do { + try await self.requestVoid( + method: .voicewakeSet, + params: ["triggers": AnyCodable(triggers)], + timeoutMs: 10000) + } catch { + // Best-effort only. + } + } + + // MARK: - Node pairing + + func nodePairApprove(requestId: String) async throws { + try await self.requestVoid( + method: .nodePairApprove, + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + } + + func nodePairReject(requestId: String) async throws { + try await self.requestVoid( + method: .nodePairReject, + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + } + + // MARK: - Device pairing + + func devicePairApprove(requestId: String) async throws { + try await self.requestVoid( + method: .devicePairApprove, + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + } + + func devicePairReject(requestId: String) async throws { + try await self.requestVoid( + method: .devicePairReject, + params: ["requestId": AnyCodable(requestId)], + timeoutMs: 10000) + } + + // MARK: - Cron + + struct CronSchedulerStatus: Decodable, Sendable { + let enabled: Bool + let storePath: String + let jobs: Int + let nextWakeAtMs: Int? + } + + func cronStatus() async throws -> CronSchedulerStatus { + try await self.requestDecoded(method: .cronStatus) + } + + func cronList(includeDisabled: Bool = true) async throws -> [CronJob] { + let res: CronListResponse = try await self.requestDecoded( + method: .cronList, + params: ["includeDisabled": AnyCodable(includeDisabled)]) + return res.jobs + } + + func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] { + let res: CronRunsResponse = try await self.requestDecoded( + method: .cronRuns, + params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)]) + return res.entries + } + + func cronRun(jobId: String, force: Bool = true) async throws { + try await self.requestVoid( + method: .cronRun, + params: [ + "id": AnyCodable(jobId), + "mode": AnyCodable(force ? "force" : "due"), + ], + timeoutMs: 20000) + } + + func cronRemove(jobId: String) async throws { + try await self.requestVoid(method: .cronRemove, params: ["id": AnyCodable(jobId)]) + } + + func cronUpdate(jobId: String, patch: [String: AnyCodable]) async throws { + try await self.requestVoid( + method: .cronUpdate, + params: ["id": AnyCodable(jobId), "patch": AnyCodable(patch)]) + } + + func cronAdd(payload: [String: AnyCodable]) async throws { + try await self.requestVoid(method: .cronAdd, params: payload) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayConnectivityCoordinator.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayConnectivityCoordinator.swift new file mode 100644 index 00000000..aeb1ebb9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayConnectivityCoordinator.swift @@ -0,0 +1,63 @@ +import Foundation +import Observation +import OSLog + +@MainActor +@Observable +final class GatewayConnectivityCoordinator { + static let shared = GatewayConnectivityCoordinator() + + private let logger = Logger(subsystem: "ai.openclaw", category: "gateway.connectivity") + private var endpointTask: Task? + private var lastResolvedURL: URL? + + private(set) var endpointState: GatewayEndpointState? + private(set) var resolvedURL: URL? + private(set) var resolvedMode: AppState.ConnectionMode? + private(set) var resolvedHostLabel: String? + + private init() { + self.start() + } + + func start() { + guard self.endpointTask == nil else { return } + self.endpointTask = Task { [weak self] in + guard let self else { return } + let stream = await GatewayEndpointStore.shared.subscribe() + for await state in stream { + await MainActor.run { self.handleEndpointState(state) } + } + } + } + + var localEndpointHostLabel: String? { + guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil } + return Self.hostLabel(for: url) + } + + private func handleEndpointState(_ state: GatewayEndpointState) { + self.endpointState = state + switch state { + case let .ready(mode, url, _, _): + self.resolvedMode = mode + self.resolvedURL = url + self.resolvedHostLabel = Self.hostLabel(for: url) + let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString + if urlChanged { + self.lastResolvedURL = url + Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") } + } + case let .connecting(mode, _): + self.resolvedMode = mode + case let .unavailable(mode, _): + self.resolvedMode = mode + } + } + + private static func hostLabel(for url: URL) -> String { + let host = url.host ?? url.absoluteString + if let port = url.port { return "\(host):\(port)" } + return host + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift new file mode 100644 index 00000000..81383efa --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift @@ -0,0 +1,77 @@ +import Foundation +import OpenClawDiscovery + +enum GatewayDiscoveryHelpers { + static func resolvedServiceHost( + for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? + { + self.resolvedServiceHost(gateway.serviceHost) + } + + static func resolvedServiceHost(_ host: String?) -> String? { + guard let host = self.trimmed(host), !host.isEmpty else { return nil } + return host + } + + static func serviceEndpoint( + for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (host: String, port: Int)? + { + self.serviceEndpoint(serviceHost: gateway.serviceHost, servicePort: gateway.servicePort) + } + + static func serviceEndpoint( + serviceHost: String?, + servicePort: Int?) -> (host: String, port: Int)? + { + guard let host = self.resolvedServiceHost(serviceHost) else { return nil } + guard let port = servicePort, port > 0, port <= 65535 else { return nil } + return (host, port) + } + + static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { + guard let host = self.resolvedServiceHost(for: gateway) else { return nil } + let user = NSUserName() + var target = "\(user)@\(host)" + if gateway.sshPort != 22 { + target += ":\(gateway.sshPort)" + } + return target + } + + static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { + self.directGatewayUrl( + serviceHost: gateway.serviceHost, + servicePort: gateway.servicePort) + } + + static func directGatewayUrl( + serviceHost: String?, + servicePort: Int?) -> String? + { + // Security: do not route using unauthenticated TXT hints (tailnetDns/lanHost/gatewayPort). + // Prefer the resolved service endpoint (SRV + A/AAAA). + guard let endpoint = self.serviceEndpoint(serviceHost: serviceHost, servicePort: servicePort) else { + return nil + } + // Security: for non-loopback hosts, force TLS to avoid plaintext credential/session leakage. + let scheme = self.isLoopbackHost(endpoint.host) ? "ws" : "wss" + let portSuffix = endpoint.port == 443 ? "" : ":\(endpoint.port)" + return "\(scheme)://\(endpoint.host)\(portSuffix)" + } + + private static func trimmed(_ value: String?) -> String? { + value?.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func isLoopbackHost(_ rawHost: String) -> Bool { + let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !host.isEmpty else { return false } + if host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" { + return true + } + if host.hasPrefix("::ffff:127.") { + return true + } + return host.hasPrefix("127.") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayDiscoveryMenu.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayDiscoveryMenu.swift new file mode 100644 index 00000000..babab586 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayDiscoveryMenu.swift @@ -0,0 +1,139 @@ +import OpenClawDiscovery +import SwiftUI + +struct GatewayDiscoveryInlineList: View { + var discovery: GatewayDiscoveryModel + var currentTarget: String? + var currentUrl: String? + var transport: AppState.RemoteTransport + var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void + @State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID? + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "dot.radiowaves.left.and.right") + .font(.caption) + .foregroundStyle(.secondary) + Text(self.discovery.statusText) + .font(.caption) + .foregroundStyle(.secondary) + } + + if self.discovery.gateways.isEmpty { + Text("No gateways found yet.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(self.discovery.gateways.prefix(6)) { gateway in + let display = self.displayInfo(for: gateway) + let selected = display.selected + + Button { + withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { + self.onSelect(gateway) + } + } label: { + HStack(alignment: .center, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(gateway.displayName) + .font(.callout.weight(.semibold)) + .lineLimit(1) + .truncationMode(.tail) + Text(display.label) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 0) + if selected { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.accentColor) + } else { + Image(systemName: "arrow.right.circle") + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(self.rowBackground( + selected: selected, + hovered: self.hoveredGatewayID == gateway.id))) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder( + selected ? Color.accentColor.opacity(0.45) : Color.clear, + lineWidth: 1)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + self.hoveredGatewayID = hovering ? gateway + .id : (self.hoveredGatewayID == gateway.id ? nil : self.hoveredGatewayID) + } + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor))) + } + } + .help(self.transport == .direct + ? "Click a discovered gateway to fill the gateway URL." + : "Click a discovered gateway to fill the SSH target.") + } + + private func displayInfo( + for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool) + { + switch self.transport { + case .direct: + let url = GatewayDiscoveryHelpers.directUrl(for: gateway) + let label = url ?? "Gateway pairing only" + let selected = url != nil && self.trimmed(self.currentUrl) == url + return (label, selected) + case .ssh: + let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) + let label = target ?? "Gateway pairing only" + let selected = target != nil && self.trimmed(self.currentTarget) == target + return (label, selected) + } + } + + private func rowBackground(selected: Bool, hovered: Bool) -> Color { + if selected { return Color.accentColor.opacity(0.12) } + if hovered { return Color.secondary.opacity(0.08) } + return Color.clear + } + + private func trimmed(_ value: String?) -> String { + value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } +} + +struct GatewayDiscoveryMenu: View { + var discovery: GatewayDiscoveryModel + var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void + + var body: some View { + Menu { + if self.discovery.gateways.isEmpty { + Button(self.discovery.statusText) {} + .disabled(true) + } else { + ForEach(self.discovery.gateways) { gateway in + Button(gateway.displayName) { self.onSelect(gateway) } + } + } + } label: { + Image(systemName: "dot.radiowaves.left.and.right") + } + .help("Discover OpenClaw gateways on your LAN") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayDiscoveryPreferences.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayDiscoveryPreferences.swift new file mode 100644 index 00000000..d725fdba --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayDiscoveryPreferences.swift @@ -0,0 +1,25 @@ +import Foundation + +enum GatewayDiscoveryPreferences { + private static let preferredStableIDKey = "gateway.preferredStableID" + private static let legacyPreferredStableIDKey = "bridge.preferredStableID" + + static func preferredStableID() -> String? { + let defaults = UserDefaults.standard + let raw = defaults.string(forKey: self.preferredStableIDKey) + ?? defaults.string(forKey: self.legacyPreferredStableIDKey) + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + + static func setPreferredStableID(_ stableID: String?) { + let trimmed = stableID?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { + UserDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey) + UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey) + } else { + UserDefaults.standard.removeObject(forKey: self.preferredStableIDKey) + UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift new file mode 100644 index 00000000..0edb2e65 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -0,0 +1,728 @@ +import ConcurrencyExtras +import Foundation +import OSLog + +enum GatewayEndpointState: Sendable, Equatable { + case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?) + case connecting(mode: AppState.ConnectionMode, detail: String) + case unavailable(mode: AppState.ConnectionMode, reason: String) +} + +/// Single place to resolve (and publish) the effective gateway control endpoint. +/// +/// This is intentionally separate from `GatewayConnection`: +/// - `GatewayConnection` consumes the resolved endpoint (no tunnel side-effects). +/// - The endpoint store owns observation + explicit "ensure tunnel" actions. +actor GatewayEndpointStore { + static let shared = GatewayEndpointStore() + private static let supportedBindModes: Set = [ + "loopback", + "tailnet", + "lan", + "auto", + "custom", + ] + private static let remoteConnectingDetail = "Connecting to remote gateway…" + private static let staticLogger = Logger(subsystem: "ai.openclaw", category: "gateway-endpoint") + private enum EnvOverrideWarningKind: Sendable { + case token + case password + } + + private static let envOverrideWarnings = LockIsolated((token: false, password: false)) + + struct Deps: Sendable { + let mode: @Sendable () async -> AppState.ConnectionMode + let token: @Sendable () -> String? + let password: @Sendable () -> String? + let localPort: @Sendable () -> Int + let localHost: @Sendable () async -> String + let remotePortIfRunning: @Sendable () async -> UInt16? + let ensureRemoteTunnel: @Sendable () async throws -> UInt16 + + static let live = Deps( + mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, + token: { + let root = OpenClawConfigFile.loadDict() + let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote + return GatewayEndpointStore.resolveGatewayToken( + isRemote: isRemote, + root: root, + env: ProcessInfo.processInfo.environment, + launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) + }, + password: { + let root = OpenClawConfigFile.loadDict() + let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote + return GatewayEndpointStore.resolveGatewayPassword( + isRemote: isRemote, + root: root, + env: ProcessInfo.processInfo.environment, + launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot()) + }, + localPort: { GatewayEnvironment.gatewayPort() }, + localHost: { + let root = OpenClawConfigFile.loadDict() + let bind = GatewayEndpointStore.resolveGatewayBindMode( + root: root, + env: ProcessInfo.processInfo.environment) + let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root) + let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } + ?? TailscaleService.fallbackTailnetIPv4() + return GatewayEndpointStore.resolveLocalGatewayHost( + bindMode: bind, + customBindHost: customBindHost, + tailscaleIP: tailscaleIP) + }, + remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, + ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() }) + } + + private static func resolveGatewayPassword( + isRemote: Bool, + root: [String: Any], + env: [String: String], + launchdSnapshot: LaunchAgentPlistSnapshot?) -> String? + { + let raw = env["OPENCLAW_GATEWAY_PASSWORD"] ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root), + !configPassword.isEmpty + { + self.warnEnvOverrideOnce( + kind: .password, + envVar: "OPENCLAW_GATEWAY_PASSWORD", + configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password") + } + return trimmed + } + if isRemote { + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let password = remote["password"] as? String + { + let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) + if !pw.isEmpty { + return pw + } + } + return nil + } + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let password = auth["password"] as? String + { + let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) + if !pw.isEmpty { + return pw + } + } + if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines), + !password.isEmpty + { + return password + } + return nil + } + + private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? { + if isRemote { + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let password = remote["password"] as? String + { + return password.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let password = auth["password"] as? String + { + return password.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + private static func resolveGatewayToken( + isRemote: Bool, + root: [String: Any], + env: [String: String], + launchdSnapshot: LaunchAgentPlistSnapshot?) -> String? + { + let raw = env["OPENCLAW_GATEWAY_TOKEN"] ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), + !configToken.isEmpty, + configToken != trimmed + { + self.warnEnvOverrideOnce( + kind: .token, + envVar: "OPENCLAW_GATEWAY_TOKEN", + configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token") + } + return trimmed + } + + if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), + !configToken.isEmpty + { + return configToken + } + + if isRemote { + return nil + } + + if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty + { + return token + } + + return nil + } + + private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? { + if isRemote { + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let token = remote["token"] as? String + { + return token.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let token = auth["token"] as? String + { + return token.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + private static func warnEnvOverrideOnce( + kind: EnvOverrideWarningKind, + envVar: String, + configKey: String) + { + let shouldWarn = Self.envOverrideWarnings.withValue { state in + switch kind { + case .token: + guard !state.token else { return false } + state.token = true + return true + case .password: + guard !state.password else { return false } + state.password = true + return true + } + } + guard shouldWarn else { return } + Self.staticLogger.warning( + "\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " + + "If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)") + } + + private let deps: Deps + private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-endpoint") + + private var state: GatewayEndpointState + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + private var remoteEnsure: (token: UUID, task: Task)? + + init(deps: Deps = .live) { + self.deps = deps + let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey) + let initialMode: AppState.ConnectionMode + if let modeRaw { + initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local + } else { + let seen = UserDefaults.standard.bool(forKey: "openclaw.onboardingSeen") + initialMode = seen ? .local : .unconfigured + } + + let port = deps.localPort() + let bind = GatewayEndpointStore.resolveGatewayBindMode( + root: OpenClawConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) + let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: OpenClawConfigFile.loadDict()) + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: OpenClawConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) + let host = GatewayEndpointStore.resolveLocalGatewayHost( + bindMode: bind, + customBindHost: customBindHost, + tailscaleIP: nil) + let token = deps.token() + let password = deps.password() + switch initialMode { + case .local: + self.state = .ready( + mode: .local, + url: URL(string: "\(scheme)://\(host):\(port)")!, + token: token, + password: password) + case .remote: + self.state = .connecting(mode: .remote, detail: Self.remoteConnectingDetail) + Task { await self.setMode(.remote) } + case .unconfigured: + self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured") + } + } + + func subscribe(bufferingNewest: Int = 1) -> AsyncStream { + let id = UUID() + let initial = self.state + let store = self + return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in + continuation.yield(initial) + self.subscribers[id] = continuation + continuation.onTermination = { @Sendable _ in + Task { await store.removeSubscriber(id) } + } + } + } + + func refresh() async { + let mode = await self.deps.mode() + await self.setMode(mode) + } + + func setMode(_ mode: AppState.ConnectionMode) async { + let token = self.deps.token() + let password = self.deps.password() + switch mode { + case .local: + self.cancelRemoteEnsure() + let port = self.deps.localPort() + let host = await self.deps.localHost() + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: OpenClawConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) + self.setState(.ready( + mode: .local, + url: URL(string: "\(scheme)://\(host):\(port)")!, + token: token, + password: password)) + case .remote: + let root = OpenClawConfigFile.loadDict() + if GatewayRemoteConfig.resolveTransport(root: root) == .direct { + guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { + self.cancelRemoteEnsure() + self.setState(.unavailable( + mode: .remote, + reason: "gateway.remote.url missing or invalid for direct transport")) + return + } + self.cancelRemoteEnsure() + self.setState(.ready(mode: .remote, url: url, token: token, password: password)) + return + } + let port = await self.deps.remotePortIfRunning() + guard let port else { + self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail)) + self.kickRemoteEnsureIfNeeded(detail: Self.remoteConnectingDetail) + return + } + self.cancelRemoteEnsure() + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: OpenClawConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) + self.setState(.ready( + mode: .remote, + url: URL(string: "\(scheme)://127.0.0.1:\(Int(port))")!, + token: token, + password: password)) + case .unconfigured: + self.cancelRemoteEnsure() + self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) + } + } + + /// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint. + func ensureRemoteControlTunnel() async throws -> UInt16 { + let mode = await self.deps.mode() + guard mode == .remote else { + throw NSError( + domain: "RemoteTunnel", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) + } + let root = OpenClawConfigFile.loadDict() + if GatewayRemoteConfig.resolveTransport(root: root) == .direct { + guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { + throw NSError( + domain: "GatewayEndpoint", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"]) + } + guard let port = GatewayRemoteConfig.defaultPort(for: url), + let portInt = UInt16(exactly: port) + else { + throw NSError( + domain: "GatewayEndpoint", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"]) + } + self.logger.info("remote transport direct; skipping SSH tunnel") + return portInt + } + let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) + guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else { + throw NSError( + domain: "GatewayEndpoint", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"]) + } + return port + } + + func requireConfig() async throws -> GatewayConnection.Config { + await self.refresh() + switch self.state { + case let .ready(_, url, token, password): + return (url, token, password) + case let .connecting(mode, _): + guard mode == .remote else { + throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) + } + return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) + case let .unavailable(mode, reason): + guard mode == .remote else { + throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason]) + } + + // Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet), + // recreate it on demand so callers can recover without a manual reconnect. + self.logger.info( + "endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)") + return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) + } + } + + private func cancelRemoteEnsure() { + self.remoteEnsure?.task.cancel() + self.remoteEnsure = nil + } + + private func kickRemoteEnsureIfNeeded(detail: String) { + if self.remoteEnsure != nil { + self.setState(.connecting(mode: .remote, detail: detail)) + return + } + + let deps = self.deps + let token = UUID() + let task = Task.detached(priority: .utility) { try await deps.ensureRemoteTunnel() } + self.remoteEnsure = (token: token, task: task) + self.setState(.connecting(mode: .remote, detail: detail)) + } + + private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config { + let mode = await self.deps.mode() + guard mode == .remote else { + throw NSError( + domain: "RemoteTunnel", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) + } + + let root = OpenClawConfigFile.loadDict() + if GatewayRemoteConfig.resolveTransport(root: root) == .direct { + guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else { + throw NSError( + domain: "GatewayEndpoint", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"]) + } + let token = self.deps.token() + let password = self.deps.password() + self.cancelRemoteEnsure() + self.setState(.ready(mode: .remote, url: url, token: token, password: password)) + return (url, token, password) + } + + self.kickRemoteEnsureIfNeeded(detail: detail) + guard let ensure = self.remoteEnsure else { + throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"]) + } + + do { + let forwarded = try await ensure.task.value + let stillRemote = await self.deps.mode() == .remote + guard stillRemote else { + throw NSError( + domain: "RemoteTunnel", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) + } + + if self.remoteEnsure?.token == ensure.token { + self.remoteEnsure = nil + } + + let token = self.deps.token() + let password = self.deps.password() + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: OpenClawConfigFile.loadDict(), + env: ProcessInfo.processInfo.environment) + let url = URL(string: "\(scheme)://127.0.0.1:\(Int(forwarded))")! + self.setState(.ready(mode: .remote, url: url, token: token, password: password)) + return (url, token, password) + } catch let err as CancellationError { + if self.remoteEnsure?.token == ensure.token { + self.remoteEnsure = nil + } + throw err + } catch { + if self.remoteEnsure?.token == ensure.token { + self.remoteEnsure = nil + } + let msg = "Remote control tunnel failed (\(error.localizedDescription))" + self.setState(.unavailable(mode: .remote, reason: msg)) + self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)") + throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg]) + } + } + + private func removeSubscriber(_ id: UUID) { + self.subscribers[id] = nil + } + + private func setState(_ next: GatewayEndpointState) { + guard next != self.state else { return } + self.state = next + for (_, continuation) in self.subscribers { + continuation.yield(next) + } + switch next { + case let .ready(mode, url, _, _): + let modeDesc = String(describing: mode) + let urlDesc = url.absoluteString + self.logger + .debug( + "resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)") + case let .connecting(mode, detail): + let modeDesc = String(describing: mode) + self.logger + .debug( + "endpoint connecting mode=\(modeDesc, privacy: .public) detail=\(detail, privacy: .public)") + case let .unavailable(mode, reason): + let modeDesc = String(describing: mode) + self.logger + .debug( + "endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)") + } + } + + func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? { + let mode = await self.deps.mode() + guard mode == .local else { return nil } + + let root = OpenClawConfigFile.loadDict() + let bind = GatewayEndpointStore.resolveGatewayBindMode( + root: root, + env: ProcessInfo.processInfo.environment) + guard bind == "tailnet" else { return nil } + + let currentHost = currentURL.host?.lowercased() ?? "" + guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil } + + let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP } + ?? TailscaleService.fallbackTailnetIPv4() + guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil } + + let scheme = GatewayEndpointStore.resolveGatewayScheme( + root: root, + env: ProcessInfo.processInfo.environment) + let port = self.deps.localPort() + let token = self.deps.token() + let password = self.deps.password() + let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")! + + self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)") + self.setState(.ready(mode: .local, url: url, token: token, password: password)) + return (url, token, password) + } + + private static func resolveGatewayBindMode( + root: [String: Any], + env: [String: String]) -> String? + { + if let envBind = env["OPENCLAW_GATEWAY_BIND"] { + let trimmed = envBind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if self.supportedBindModes.contains(trimmed) { + return trimmed + } + } + if let gateway = root["gateway"] as? [String: Any], + let bind = gateway["bind"] as? String + { + let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if self.supportedBindModes.contains(trimmed) { + return trimmed + } + } + return nil + } + + private static func resolveGatewayCustomBindHost(root: [String: Any]) -> String? { + if let gateway = root["gateway"] as? [String: Any], + let customBindHost = gateway["customBindHost"] as? String + { + let trimmed = customBindHost.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + return nil + } + + private static func resolveGatewayScheme( + root: [String: Any], + env: [String: String]) -> String + { + if let envValue = env["OPENCLAW_GATEWAY_TLS"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !envValue.isEmpty + { + return (envValue == "1" || envValue.lowercased() == "true") ? "wss" : "ws" + } + if let gateway = root["gateway"] as? [String: Any], + let tls = gateway["tls"] as? [String: Any], + let enabled = tls["enabled"] as? Bool + { + return enabled ? "wss" : "ws" + } + return "ws" + } + + private static func resolveLocalGatewayHost( + bindMode: String?, + customBindHost: String?, + tailscaleIP: String?) -> String + { + switch bindMode { + case "tailnet": + tailscaleIP ?? "127.0.0.1" + case "auto": + "127.0.0.1" + case "custom": + customBindHost ?? "127.0.0.1" + default: + "127.0.0.1" + } + } +} + +extension GatewayEndpointStore { + private static func normalizeDashboardPath(_ rawPath: String?) -> String { + let trimmed = (rawPath ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "/" } + let withLeadingSlash = trimmed.hasPrefix("/") ? trimmed : "/" + trimmed + guard withLeadingSlash != "/" else { return "/" } + return withLeadingSlash.hasSuffix("/") ? withLeadingSlash : withLeadingSlash + "/" + } + + private static func localControlUiBasePath() -> String { + let root = OpenClawConfigFile.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let controlUi = gateway["controlUi"] as? [String: Any] + else { + return "/" + } + return self.normalizeDashboardPath(controlUi["basePath"] as? String) + } + + static func dashboardURL( + for config: GatewayConnection.Config, + mode: AppState.ConnectionMode, + localBasePath: String? = nil) throws -> URL + { + guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else { + throw NSError(domain: "Dashboard", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Invalid gateway URL", + ]) + } + switch components.scheme?.lowercased() { + case "ws": + components.scheme = "http" + case "wss": + components.scheme = "https" + default: + components.scheme = "http" + } + + let urlPath = self.normalizeDashboardPath(components.path) + if urlPath != "/" { + components.path = urlPath + } else if mode == .local { + let fallbackPath = localBasePath ?? self.localControlUiBasePath() + components.path = self.normalizeDashboardPath(fallbackPath) + } else { + components.path = "/" + } + + var queryItems: [URLQueryItem] = [] + if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty + { + queryItems.append(URLQueryItem(name: "token", value: token)) + } + if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines), + !password.isEmpty + { + queryItems.append(URLQueryItem(name: "password", value: password)) + } + components.queryItems = queryItems.isEmpty ? nil : queryItems + guard let url = components.url else { + throw NSError(domain: "Dashboard", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Failed to build dashboard URL", + ]) + } + return url + } +} + +#if DEBUG +extension GatewayEndpointStore { + static func _testResolveGatewayPassword( + isRemote: Bool, + root: [String: Any], + env: [String: String], + launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String? + { + self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot) + } + + static func _testResolveGatewayToken( + isRemote: Bool, + root: [String: Any], + env: [String: String], + launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String? + { + self.resolveGatewayToken(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot) + } + + static func _testResolveGatewayBindMode( + root: [String: Any], + env: [String: String]) -> String? + { + self.resolveGatewayBindMode(root: root, env: env) + } + + static func _testResolveLocalGatewayHost( + bindMode: String?, + tailscaleIP: String?, + customBindHost: String? = nil) -> String + { + self.resolveLocalGatewayHost( + bindMode: bindMode, + customBindHost: customBindHost, + tailscaleIP: tailscaleIP) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift new file mode 100644 index 00000000..059eb4da --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift @@ -0,0 +1,344 @@ +import Foundation +import OpenClawIPC +import OSLog + +/// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks. +struct Semver: Comparable, CustomStringConvertible, Sendable { + let major: Int + let minor: Int + let patch: Int + + var description: String { + "\(self.major).\(self.minor).\(self.patch)" + } + + static func < (lhs: Semver, rhs: Semver) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } + + static func parse(_ raw: String?) -> Semver? { + guard let raw, !raw.isEmpty else { return nil } + let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "^v", with: "", options: .regularExpression) + let parts = cleaned.split(separator: ".") + guard parts.count >= 3, + let major = Int(parts[0]), + let minor = Int(parts[1]) + else { return nil } + // Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5") + let patchRaw = String(parts[2]) + guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first, + let patchNumeric = Int(patchToken) + else { + return nil + } + return Semver(major: major, minor: minor, patch: patchNumeric) + } + + func compatible(with required: Semver) -> Bool { + // Same major and not older than required. + self.major == required.major && self >= required + } +} + +enum GatewayEnvironmentKind: Equatable { + case checking + case ok + case missingNode + case missingGateway + case incompatible(found: String, required: String) + case error(String) +} + +struct GatewayEnvironmentStatus: Equatable { + let kind: GatewayEnvironmentKind + let nodeVersion: String? + let gatewayVersion: String? + let requiredGateway: String? + let message: String + + static var checking: Self { + .init(kind: .checking, nodeVersion: nil, gatewayVersion: nil, requiredGateway: nil, message: "Checking…") + } +} + +struct GatewayCommandResolution { + let status: GatewayEnvironmentStatus + let command: [String]? +} + +enum GatewayEnvironment { + private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.env") + private static let supportedBindModes: Set = ["loopback", "tailnet", "lan", "auto"] + + static func gatewayPort() -> Int { + if let raw = ProcessInfo.processInfo.environment["OPENCLAW_GATEWAY_PORT"] { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if let parsed = Int(trimmed), parsed > 0 { return parsed } + } + if let configPort = OpenClawConfigFile.gatewayPort(), configPort > 0 { + return configPort + } + let stored = UserDefaults.standard.integer(forKey: "gatewayPort") + return stored > 0 ? stored : 18789 + } + + static func expectedGatewayVersion() -> Semver? { + Semver.parse(self.expectedGatewayVersionString()) + } + + static func expectedGatewayVersionString() -> String? { + let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed?.isEmpty == false) ? trimmed : nil + } + + /// Exposed for tests so we can inject fake version checks without rewriting bundle metadata. + static func expectedGatewayVersion(from versionString: String?) -> Semver? { + Semver.parse(versionString) + } + + static func check() -> GatewayEnvironmentStatus { + let start = Date() + defer { + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) + if elapsedMs > 500 { + self.logger.warning("gateway env check slow (\(elapsedMs, privacy: .public)ms)") + } else { + self.logger.debug("gateway env check ok (\(elapsedMs, privacy: .public)ms)") + } + } + let expected = self.expectedGatewayVersion() + let expectedString = self.expectedGatewayVersionString() + + let projectRoot = CommandResolver.projectRoot() + let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) + + switch RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) { + case let .failure(err): + return GatewayEnvironmentStatus( + kind: .missingNode, + nodeVersion: nil, + gatewayVersion: nil, + requiredGateway: expectedString, + message: RuntimeLocator.describeFailure(err)) + case let .success(runtime): + let gatewayBin = CommandResolver.openclawExecutable() + + if gatewayBin == nil, projectEntrypoint == nil { + return GatewayEnvironmentStatus( + kind: .missingGateway, + nodeVersion: runtime.version.description, + gatewayVersion: nil, + requiredGateway: expectedString, + message: "openclaw CLI not found in PATH; install the CLI.") + } + + let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) } + ?? self.readLocalGatewayVersion(projectRoot: projectRoot) + + if let expected, let installed, !installed.compatible(with: expected) { + let expectedText = expectedString ?? expected.description + return GatewayEnvironmentStatus( + kind: .incompatible(found: installed.description, required: expectedText), + nodeVersion: runtime.version.description, + gatewayVersion: installed.description, + requiredGateway: expectedText, + message: """ + Gateway version \(installed.description) is incompatible with app \(expectedText); + install or update the global package. + """) + } + + let gatewayLabel = gatewayBin != nil ? "global" : "local" + let gatewayVersionText = installed?.description ?? "unknown" + // Avoid repeating "(local)" twice; if using the local entrypoint, show the path once. + let localPathHint = gatewayBin == nil && projectEntrypoint != nil + ? " (local: \(projectEntrypoint ?? "unknown"))" + : "" + let gatewayLabelText = gatewayBin != nil + ? "(\(gatewayLabel))" + : localPathHint.isEmpty ? "(\(gatewayLabel))" : localPathHint + return GatewayEnvironmentStatus( + kind: .ok, + nodeVersion: runtime.version.description, + gatewayVersion: gatewayVersionText, + requiredGateway: expectedString, + message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)") + } + } + + static func resolveGatewayCommand() -> GatewayCommandResolution { + let start = Date() + defer { + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) + if elapsedMs > 500 { + self.logger.warning("gateway command resolve slow (\(elapsedMs, privacy: .public)ms)") + } else { + self.logger.debug("gateway command resolve ok (\(elapsedMs, privacy: .public)ms)") + } + } + let projectRoot = CommandResolver.projectRoot() + let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) + let status = self.check() + let gatewayBin = CommandResolver.openclawExecutable() + let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) + + guard case .ok = status.kind else { + return GatewayCommandResolution(status: status, command: nil) + } + + let port = self.gatewayPort() + if let gatewayBin { + let bind = self.preferredGatewayBind() ?? "loopback" + let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind] + return GatewayCommandResolution(status: status, command: cmd) + } + + if let entry = projectEntrypoint, + case let .success(resolvedRuntime) = runtime + { + let bind = self.preferredGatewayBind() ?? "loopback" + let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind] + return GatewayCommandResolution(status: status, command: cmd) + } + + return GatewayCommandResolution(status: status, command: nil) + } + + private static func preferredGatewayBind() -> String? { + if CommandResolver.connectionModeIsRemote() { + return nil + } + if let env = ProcessInfo.processInfo.environment["OPENCLAW_GATEWAY_BIND"] { + let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if self.supportedBindModes.contains(trimmed) { + return trimmed + } + } + + let root = OpenClawConfigFile.loadDict() + if let gateway = root["gateway"] as? [String: Any], + let bind = gateway["bind"] as? String + { + let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if self.supportedBindModes.contains(trimmed) { + return trimmed + } + } + + return nil + } + + static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async { + await self.installGlobal(versionString: version?.description, statusHandler: statusHandler) + } + + static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async { + let preferred = CommandResolver.preferredPaths().joined(separator: ":") + let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines) + let target: String = if let trimmed, !trimmed.isEmpty { + trimmed + } else { + "latest" + } + let npm = CommandResolver.findExecutable(named: "npm") + let pnpm = CommandResolver.findExecutable(named: "pnpm") + let bun = CommandResolver.findExecutable(named: "bun") + let (label, cmd): (String, [String]) = + if let npm { + ("npm", [npm, "install", "-g", "openclaw@\(target)"]) + } else if let pnpm { + ("pnpm", [pnpm, "add", "-g", "openclaw@\(target)"]) + } else if let bun { + ("bun", [bun, "add", "-g", "openclaw@\(target)"]) + } else { + ("npm", ["npm", "install", "-g", "openclaw@\(target)"]) + } + + statusHandler("Installing openclaw@\(target) via \(label)…") + + func summarize(_ text: String) -> String? { + let lines = text + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard let last = lines.last else { return nil } + let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized + } + + let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300) + if response.success { + statusHandler("Installed openclaw@\(target)") + } else { + if response.timedOut { + statusHandler("Install failed: timed out. Check your internet connection and try again.") + return + } + + let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") + let detail = summarize(response.stderr) ?? summarize(response.stdout) + if let detail { + statusHandler("Install failed (\(exit)): \(detail)") + } else { + statusHandler("Install failed (\(exit))") + } + } + } + + // MARK: - Internals + + private static func readGatewayVersion(binary: String) -> Semver? { + let start = Date() + let process = Process() + process.executableURL = URL(fileURLWithPath: binary) + process.arguments = ["--version"] + process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + let data = try process.runAndReadToEnd(from: pipe) + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) + if elapsedMs > 500 { + self.logger.warning( + """ + gateway --version slow (\(elapsedMs, privacy: .public)ms) \ + bin=\(binary, privacy: .public) + """) + } else { + self.logger.debug( + """ + gateway --version ok (\(elapsedMs, privacy: .public)ms) \ + bin=\(binary, privacy: .public) + """) + } + let raw = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return Semver.parse(raw) + } catch { + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) + self.logger.error( + """ + gateway --version failed (\(elapsedMs, privacy: .public)ms) \ + bin=\(binary, privacy: .public) \ + err=\(error.localizedDescription, privacy: .public) + """) + return nil + } + } + + private static func readLocalGatewayVersion(projectRoot: URL) -> Semver? { + let pkg = projectRoot.appendingPathComponent("package.json") + guard let data = try? Data(contentsOf: pkg) else { return nil } + guard + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = json["version"] as? String + else { return nil } + return Semver.parse(version) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift new file mode 100644 index 00000000..98743fec --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift @@ -0,0 +1,204 @@ +import Foundation + +enum GatewayLaunchAgentManager { + private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.launchd") + private static let disableLaunchAgentMarker = ".openclaw/disable-launchagent" + + private static var disableLaunchAgentMarkerURL: URL { + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent(self.disableLaunchAgentMarker) + } + + private static var plistURL: URL { + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") + } + + static func isLaunchAgentWriteDisabled() -> Bool { + if FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path) { return true } + return false + } + + static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? { + let marker = self.disableLaunchAgentMarkerURL + if disabled { + do { + try FileManager().createDirectory( + at: marker.deletingLastPathComponent(), + withIntermediateDirectories: true) + if !FileManager().fileExists(atPath: marker.path) { + FileManager().createFile(atPath: marker.path, contents: nil) + } + } catch { + return error.localizedDescription + } + return nil + } + + if FileManager().fileExists(atPath: marker.path) { + do { + try FileManager().removeItem(at: marker) + } catch { + return error.localizedDescription + } + } + return nil + } + + static func isLoaded() async -> Bool { + guard let loaded = await self.readDaemonLoaded() else { return false } + return loaded + } + + static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { + _ = bundlePath + guard !CommandResolver.connectionModeIsRemote() else { + self.logger.info("launchd change skipped (remote mode)") + return nil + } + if enabled, self.isLaunchAgentWriteDisabled() { + self.logger.info("launchd enable skipped (disable marker set)") + return nil + } + + if enabled { + self.logger.info("launchd enable requested via CLI port=\(port)") + return await self.runDaemonCommand([ + "install", + "--force", + "--port", + "\(port)", + "--runtime", + "node", + ]) + } + + self.logger.info("launchd disable requested via CLI") + return await self.runDaemonCommand(["uninstall"]) + } + + static func kickstart() async { + _ = await self.runDaemonCommand(["restart"], timeout: 20) + } + + static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? { + LaunchAgentPlist.snapshot(url: self.plistURL) + } + + static func launchdGatewayLogPath() -> String { + let snapshot = self.launchdConfigSnapshot() + if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !stdout.isEmpty + { + return stdout + } + if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !stderr.isEmpty + { + return stderr + } + return LogLocator.launchdGatewayLogPath + } +} + +extension GatewayLaunchAgentManager { + private static func readDaemonLoaded() async -> Bool? { + let result = await self.runDaemonCommandResult( + ["status", "--json", "--no-probe"], + timeout: 15, + quiet: true) + guard result.success, let payload = result.payload else { return nil } + guard + let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], + let service = json["service"] as? [String: Any], + let loaded = service["loaded"] as? Bool + else { + return nil + } + return loaded + } + + private struct CommandResult { + let success: Bool + let payload: Data? + let message: String? + } + + private struct ParsedDaemonJson { + let text: String + let object: [String: Any] + } + + private static func runDaemonCommand( + _ args: [String], + timeout: Double = 15, + quiet: Bool = false) async -> String? + { + let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet) + if result.success { return nil } + return result.message ?? "Gateway daemon command failed" + } + + private static func runDaemonCommandResult( + _ args: [String], + timeout: Double, + quiet: Bool) async -> CommandResult + { + let command = CommandResolver.openclawCommand( + subcommand: "gateway", + extraArgs: self.withJsonFlag(args), + // Launchd management must always run locally, even if remote mode is configured. + configRoot: ["gateway": ["mode": "local"]]) + var env = ProcessInfo.processInfo.environment + env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") + let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) + let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr) + let ok = parsed?.object["ok"] as? Bool + let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String) + let payload = parsed?.text.data(using: .utf8) + ?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8) + let success = ok ?? response.success + if success { + return CommandResult(success: true, payload: payload, message: nil) + } + + if quiet { + return CommandResult(success: false, payload: payload, message: message) + } + + let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout) + let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") + let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" } + ?? "Gateway daemon command failed (\(exit))" + self.logger.error("\(fullMessage, privacy: .public)") + return CommandResult(success: false, payload: payload, message: detail) + } + + private static func withJsonFlag(_ args: [String]) -> [String] { + if args.contains("--json") { return args } + return args + ["--json"] + } + + private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let start = trimmed.firstIndex(of: "{"), + let end = trimmed.lastIndex(of: "}") + else { + return nil + } + let jsonText = String(trimmed[start...end]) + guard let data = jsonText.data(using: .utf8) else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return ParsedDaemonJson(text: jsonText, object: object) + } + + private static func summarize(_ text: String) -> String? { + let lines = text + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard let last = lines.last else { return nil } + let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift new file mode 100644 index 00000000..e3d5263e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift @@ -0,0 +1,432 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class GatewayProcessManager { + static let shared = GatewayProcessManager() + + enum Status: Equatable { + case stopped + case starting + case running(details: String?) + case attachedExisting(details: String?) + case failed(String) + + var label: String { + switch self { + case .stopped: return "Stopped" + case .starting: return "Starting…" + case let .running(details): + if let details, !details.isEmpty { return "Running (\(details))" } + return "Running" + case let .attachedExisting(details): + if let details, !details.isEmpty { + return "Using existing gateway (\(details))" + } + return "Using existing gateway" + case let .failed(reason): return "Failed: \(reason)" + } + } + } + + private(set) var status: Status = .stopped { + didSet { CanvasManager.shared.refreshDebugStatus() } + } + + private(set) var log: String = "" + private(set) var environmentStatus: GatewayEnvironmentStatus = .checking + private(set) var existingGatewayDetails: String? + private(set) var lastFailureReason: String? + private var desiredActive = false + private var environmentRefreshTask: Task? + private var lastEnvironmentRefresh: Date? + private var logRefreshTask: Task? + #if DEBUG + private var testingConnection: GatewayConnection? + #endif + private let logger = Logger(subsystem: "ai.openclaw", category: "gateway.process") + + private let logLimit = 20000 // characters to keep in-memory + private let environmentRefreshMinInterval: TimeInterval = 30 + private var connection: GatewayConnection { + #if DEBUG + return self.testingConnection ?? .shared + #else + return .shared + #endif + } + + func setActive(_ active: Bool) { + // Remote mode should never spawn a local gateway; treat as stopped. + if CommandResolver.connectionModeIsRemote() { + self.desiredActive = false + self.stop() + self.status = .stopped + self.appendLog("[gateway] remote mode active; skipping local gateway\n") + self.logger.info("gateway process skipped: remote mode active") + return + } + self.logger.debug("gateway active requested active=\(active)") + self.desiredActive = active + self.refreshEnvironmentStatus() + if active { + self.startIfNeeded() + } else { + self.stop() + } + } + + func ensureLaunchAgentEnabledIfNeeded() async { + guard !CommandResolver.connectionModeIsRemote() else { return } + if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() { + self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n") + self.logger.info("gateway launchd auto-enable skipped (disable marker set)") + return + } + let enabled = await GatewayLaunchAgentManager.isLoaded() + guard !enabled else { return } + let bundlePath = Bundle.main.bundleURL.path + let port = GatewayEnvironment.gatewayPort() + self.appendLog("[gateway] auto-enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") + let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) + if let err { + self.appendLog("[gateway] launchd auto-enable failed: \(err)\n") + } + } + + func startIfNeeded() { + guard self.desiredActive else { return } + // Do not spawn in remote mode (the gateway should run on the remote host). + guard !CommandResolver.connectionModeIsRemote() else { + self.status = .stopped + return + } + // Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks). + // Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port. + switch self.status { + case .starting, .running, .attachedExisting: + return + case .stopped, .failed: + break + } + self.status = .starting + self.logger.debug("gateway start requested") + + // First try to latch onto an already-running gateway to avoid spawning a duplicate. + Task { [weak self] in + guard let self else { return } + if await self.attachExistingGatewayIfAvailable() { + return + } + await self.enableLaunchdGateway() + } + } + + func stop() { + self.desiredActive = false + self.existingGatewayDetails = nil + self.lastFailureReason = nil + self.status = .stopped + self.logger.info("gateway stop requested") + if CommandResolver.connectionModeIsRemote() { + return + } + let bundlePath = Bundle.main.bundleURL.path + Task { + _ = await GatewayLaunchAgentManager.set( + enabled: false, + bundlePath: bundlePath, + port: GatewayEnvironment.gatewayPort()) + } + } + + func clearLastFailure() { + self.lastFailureReason = nil + } + + func refreshEnvironmentStatus(force: Bool = false) { + let now = Date() + if !force { + if self.environmentRefreshTask != nil { return } + if let last = self.lastEnvironmentRefresh, + now.timeIntervalSince(last) < self.environmentRefreshMinInterval + { + return + } + } + self.lastEnvironmentRefresh = now + self.environmentRefreshTask = Task { [weak self] in + let status = await Task.detached(priority: .utility) { + GatewayEnvironment.check() + }.value + await MainActor.run { + guard let self else { return } + self.environmentStatus = status + self.environmentRefreshTask = nil + } + } + } + + func refreshLog() { + guard self.logRefreshTask == nil else { return } + let path = GatewayLaunchAgentManager.launchdGatewayLogPath() + let limit = self.logLimit + self.logRefreshTask = Task { [weak self] in + let log = await Task.detached(priority: .utility) { + Self.readGatewayLog(path: path, limit: limit) + }.value + await MainActor.run { + guard let self else { return } + if !log.isEmpty { + self.log = log + } + self.logRefreshTask = nil + } + } + } + + // MARK: - Internals + + /// Attempt to connect to an already-running gateway on the configured port. + /// If successful, mark status as attached and skip spawning a new process. + private func attachExistingGatewayIfAvailable() async -> Bool { + let port = GatewayEnvironment.gatewayPort() + let instance = await PortGuardian.shared.describe(port: port) + let instanceText = instance.map { self.describe(instance: $0) } + let hasListener = instance != nil + + let attemptAttach = { + try await self.connection.requestRaw(method: .health, timeoutMs: 2000) + } + + for attempt in 0..<(hasListener ? 3 : 1) { + do { + let data = try await attemptAttach() + let snap = decodeHealthSnapshot(from: data) + let details = self.describe(details: instanceText, port: port, snap: snap) + self.existingGatewayDetails = details + self.clearLastFailure() + self.status = .attachedExisting(details: details) + self.appendLog("[gateway] using existing instance: \(details)\n") + self.logger.info("gateway using existing instance details=\(details)") + self.refreshControlChannelIfNeeded(reason: "attach existing") + self.refreshLog() + return true + } catch { + if attempt < 2, hasListener { + try? await Task.sleep(nanoseconds: 250_000_000) + continue + } + + if hasListener { + let reason = self.describeAttachFailure(error, port: port, instance: instance) + self.existingGatewayDetails = instanceText + self.status = .failed(reason) + self.lastFailureReason = reason + self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n") + self.logger.warning("gateway attach failed reason=\(reason)") + return true + } + + // No reachable gateway (and no listener) — fall through to spawn. + self.existingGatewayDetails = nil + return false + } + } + + self.existingGatewayDetails = nil + return false + } + + private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String { + let instanceText = instance ?? "pid unknown" + if let snap { + let order = snap.channelOrder ?? Array(snap.channels.keys) + let linkId = order.first(where: { snap.channels[$0]?.linked == true }) + ?? order.first(where: { snap.channels[$0]?.linked != nil }) + guard let linkId else { + return "port \(port), health probe succeeded, \(instanceText)" + } + let linked = snap.channels[linkId]?.linked ?? false + let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age" + let label = + snap.channelLabels?[linkId] ?? + linkId.capitalized + let linkText = linked ? "linked" : "not linked" + return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)" + } + return "port \(port), health probe succeeded, \(instanceText)" + } + + private func describe(instance: PortGuardian.Descriptor) -> String { + let path = instance.executablePath ?? "path unknown" + return "pid \(instance.pid) \(instance.command) @ \(path)" + } + + private func describeAttachFailure(_ error: Error, port: Int, instance: PortGuardian.Descriptor?) -> String { + let ns = error as NSError + let message = ns.localizedDescription.isEmpty ? "unknown error" : ns.localizedDescription + let lower = message.lowercased() + if self.isGatewayAuthFailure(error) { + return """ + Gateway on port \(port) rejected auth. Set gateway.auth.token to match the running gateway \ + (or clear it on the gateway) and retry. + """ + } + if lower.contains("protocol mismatch") { + return "Gateway on port \(port) is incompatible (protocol mismatch). Update the app/gateway." + } + if lower.contains("unexpected response") || lower.contains("invalid response") { + return "Port \(port) returned non-gateway data; another process is using it." + } + if let instance { + let instanceText = self.describe(instance: instance) + return "Gateway listener found on port \(port) (\(instanceText)) but health check failed: \(message)" + } + return "Gateway listener found on port \(port) but health check failed: \(message)" + } + + private func isGatewayAuthFailure(_ error: Error) -> Bool { + if let urlError = error as? URLError, urlError.code == .dataNotAllowed { + return true + } + let ns = error as NSError + if ns.domain == "Gateway", ns.code == 1008 { return true } + let lower = ns.localizedDescription.lowercased() + return lower.contains("unauthorized") || lower.contains("auth") + } + + private func enableLaunchdGateway() async { + self.existingGatewayDetails = nil + let resolution = await Task.detached(priority: .utility) { + GatewayEnvironment.resolveGatewayCommand() + }.value + await MainActor.run { self.environmentStatus = resolution.status } + guard resolution.command != nil else { + await MainActor.run { + self.status = .failed(resolution.status.message) + } + self.logger.error("gateway command resolve failed: \(resolution.status.message)") + return + } + + if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() { + let message = "Launchd disabled; start the Gateway manually or disable attach-only." + self.status = .failed(message) + self.lastFailureReason = "launchd disabled" + self.appendLog("[gateway] launchd disabled; skipping auto-start\n") + self.logger.info("gateway launchd enable skipped (disable marker set)") + return + } + + let bundlePath = Bundle.main.bundleURL.path + let port = GatewayEnvironment.gatewayPort() + self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") + self.logger.info("gateway enabling launchd port=\(port)") + let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) + if let err { + self.status = .failed(err) + self.lastFailureReason = err + self.logger.error("gateway launchd enable failed: \(err)") + return + } + + // Best-effort: wait for the gateway to accept connections. + let deadline = Date().addingTimeInterval(6) + while Date() < deadline { + if !self.desiredActive { return } + do { + _ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) + let instance = await PortGuardian.shared.describe(port: port) + let details = instance.map { "pid \($0.pid)" } + self.clearLastFailure() + self.status = .running(details: details) + self.logger.info("gateway started details=\(details ?? "ok")") + self.refreshControlChannelIfNeeded(reason: "gateway started") + self.refreshLog() + return + } catch { + try? await Task.sleep(nanoseconds: 400_000_000) + } + } + + self.status = .failed("Gateway did not start in time") + self.lastFailureReason = "launchd start timeout" + self.logger.warning("gateway start timed out") + } + + private func appendLog(_ chunk: String) { + self.log.append(chunk) + if self.log.count > self.logLimit { + self.log = String(self.log.suffix(self.logLimit)) + } + } + + private func refreshControlChannelIfNeeded(reason: String) { + switch ControlChannel.shared.state { + case .connected, .connecting: + return + case .disconnected, .degraded: + break + } + self.appendLog("[gateway] refreshing control channel (\(reason))\n") + self.logger.debug("gateway control channel refresh reason=\(reason)") + Task { await ControlChannel.shared.configure() } + } + + func waitForGatewayReady(timeout: TimeInterval = 6) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !self.desiredActive { return false } + do { + _ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500) + self.clearLastFailure() + return true + } catch { + try? await Task.sleep(nanoseconds: 300_000_000) + } + } + self.appendLog("[gateway] readiness wait timed out\n") + self.logger.warning("gateway readiness wait timed out") + return false + } + + func clearLog() { + self.log = "" + try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath()) + self.logger.debug("gateway log cleared") + } + + func setProjectRoot(path: String) { + CommandResolver.setProjectRoot(path) + } + + func projectRootPath() -> String { + CommandResolver.projectRootPath() + } + + private nonisolated static func readGatewayLog(path: String, limit: Int) -> String { + guard FileManager().fileExists(atPath: path) else { return "" } + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" } + let text = String(data: data, encoding: .utf8) ?? "" + if text.count <= limit { return text } + return String(text.suffix(limit)) + } +} + +#if DEBUG +extension GatewayProcessManager { + func setTestingConnection(_ connection: GatewayConnection?) { + self.testingConnection = connection + } + + func setTestingDesiredActive(_ active: Bool) { + self.desiredActive = active + } + + func setTestingLastFailureReason(_ reason: String?) { + self.lastFailureReason = reason + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift new file mode 100644 index 00000000..64a6f92d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift @@ -0,0 +1,102 @@ +import Foundation +import Network + +enum GatewayRemoteConfig { + private static func isLoopbackHost(_ rawHost: String) -> Bool { + var host = rawHost + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + if host.hasSuffix(".") { + host.removeLast() + } + if let zoneIndex = host.firstIndex(of: "%") { + host = String(host[.. AppState.RemoteTransport { + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let raw = remote["transport"] as? String + else { + return .ssh + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh + } + + static func resolveUrlString(root: [String: Any]) -> String? { + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let urlRaw = remote["url"] as? String + else { + return nil + } + let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + static func resolveGatewayUrl(root: [String: Any]) -> URL? { + guard let raw = self.resolveUrlString(root: root) else { return nil } + return self.normalizeGatewayUrl(raw) + } + + static func normalizeGatewayUrlString(_ raw: String) -> String? { + self.normalizeGatewayUrl(raw)?.absoluteString + } + + static func normalizeGatewayUrl(_ raw: String) -> URL? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } + let scheme = url.scheme?.lowercased() ?? "" + guard scheme == "ws" || scheme == "wss" else { return nil } + let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !host.isEmpty else { return nil } + if scheme == "ws", !self.isLoopbackHost(host) { + return nil + } + if scheme == "ws", url.port == nil { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url + } + components.port = 18789 + return components.url + } + return url + } + + static func defaultPort(for url: URL) -> Int? { + if let port = url.port { return port } + let scheme = url.scheme?.lowercased() ?? "" + switch scheme { + case "wss": + return 443 + case "ws": + return 18789 + default: + return nil + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GeneralSettings.swift new file mode 100644 index 00000000..4dae8587 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -0,0 +1,741 @@ +import AppKit +import Observation +import OpenClawDiscovery +import OpenClawIPC +import OpenClawKit +import SwiftUI + +struct GeneralSettings: View { + @Bindable var state: AppState + @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false + private let healthStore = HealthStore.shared + private let gatewayManager = GatewayProcessManager.shared + @State private var gatewayDiscovery = GatewayDiscoveryModel( + localDisplayName: InstanceIdentity.displayName) + @State private var gatewayStatus: GatewayEnvironmentStatus = .checking + @State private var remoteStatus: RemoteStatus = .idle + @State private var showRemoteAdvanced = false + private let isPreview = ProcessInfo.processInfo.isPreview + private var isNixMode: Bool { + ProcessInfo.processInfo.isNixMode + } + + private var remoteLabelWidth: CGFloat { + 88 + } + + var body: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 12) { + SettingsToggleRow( + title: "OpenClaw active", + subtitle: "Pause to stop the OpenClaw gateway; no messages will be processed.", + binding: self.activeBinding) + + self.connectionSection + + Divider() + + SettingsToggleRow( + title: "Launch at login", + subtitle: "Automatically start OpenClaw after you sign in.", + binding: self.$state.launchAtLogin) + + SettingsToggleRow( + title: "Show Dock icon", + subtitle: "Keep OpenClaw visible in the Dock instead of menu-bar-only mode.", + binding: self.$state.showDockIcon) + + SettingsToggleRow( + title: "Play menu bar icon animations", + subtitle: "Enable idle blinks and wiggles on the status icon.", + binding: self.$state.iconAnimationsEnabled) + + SettingsToggleRow( + title: "Allow Canvas", + subtitle: "Allow the agent to show and control the Canvas panel.", + binding: self.$state.canvasEnabled) + + SettingsToggleRow( + title: "Allow Camera", + subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", + binding: self.$cameraEnabled) + + SettingsToggleRow( + title: "Enable Peekaboo Bridge", + subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.", + binding: self.$state.peekabooBridgeEnabled) + + SettingsToggleRow( + title: "Enable debug tools", + subtitle: "Show the Debug tab with development utilities.", + binding: self.$state.debugPaneEnabled) + } + + Spacer(minLength: 12) + HStack { + Spacer() + Button("Quit OpenClaw") { NSApp.terminate(nil) } + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 22) + .padding(.bottom, 16) + } + .onAppear { + guard !self.isPreview else { return } + self.refreshGatewayStatus() + } + .onChange(of: self.state.canvasEnabled) { _, enabled in + if !enabled { + CanvasManager.shared.hideAll() + } + } + } + + private var activeBinding: Binding { + Binding( + get: { !self.state.isPaused }, + set: { self.state.isPaused = !$0 }) + } + + private var connectionSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("OpenClaw runs") + .font(.title3.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + + Picker("Mode", selection: self.$state.connectionMode) { + Text("Not configured").tag(AppState.ConnectionMode.unconfigured) + Text("Local (this Mac)").tag(AppState.ConnectionMode.local) + Text("Remote (another host)").tag(AppState.ConnectionMode.remote) + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 260, alignment: .leading) + + if self.state.connectionMode == .unconfigured { + Text("Pick Local or Remote to start the Gateway.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if self.state.connectionMode == .local { + // In Nix mode, gateway is managed declaratively - no install buttons. + if !self.isNixMode { + self.gatewayInstallerCard + } + TailscaleIntegrationSection( + connectionMode: self.state.connectionMode, + isPaused: self.state.isPaused) + self.healthRow + } + + if self.state.connectionMode == .remote { + self.remoteCard + } + } + } + + private var remoteCard: some View { + VStack(alignment: .leading, spacing: 10) { + self.remoteTransportRow + + if self.state.remoteTransport == .ssh { + self.remoteSshRow + } else { + self.remoteDirectRow + } + + GatewayDiscoveryInlineList( + discovery: self.gatewayDiscovery, + currentTarget: self.state.remoteTarget, + currentUrl: self.state.remoteUrl, + transport: self.state.remoteTransport) + { gateway in + self.applyDiscoveredGateway(gateway) + } + .padding(.leading, self.remoteLabelWidth + 10) + + self.remoteStatusView + .padding(.leading, self.remoteLabelWidth + 10) + + if self.state.remoteTransport == .ssh { + DisclosureGroup(isExpanded: self.$showRemoteAdvanced) { + VStack(alignment: .leading, spacing: 8) { + LabeledContent("Identity file") { + TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) + .textFieldStyle(.roundedBorder) + .frame(width: 280) + } + LabeledContent("Project root") { + TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot) + .textFieldStyle(.roundedBorder) + .frame(width: 280) + } + LabeledContent("CLI path") { + TextField("/Applications/OpenClaw.app/.../openclaw", text: self.$state.remoteCliPath) + .textFieldStyle(.roundedBorder) + .frame(width: 280) + } + } + .padding(.top, 4) + } label: { + Text("Advanced") + .font(.callout.weight(.semibold)) + } + } + + // Diagnostics + VStack(alignment: .leading, spacing: 4) { + Text("Control channel") + .font(.caption.weight(.semibold)) + if !self.isControlStatusDuplicate || ControlChannel.shared.lastPingMs != nil { + let status = self.isControlStatusDuplicate ? nil : self.controlStatusLine + let ping = ControlChannel.shared.lastPingMs.map { "Ping \(Int($0)) ms" } + let line = [status, ping].compactMap(\.self).joined(separator: " · ") + if !line.isEmpty { + Text(line) + .font(.caption) + .foregroundStyle(.secondary) + } + } + if let hb = HeartbeatStore.shared.lastEvent { + let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000)) + Text("Last heartbeat: \(hb.status) · \(ageText)") + .font(.caption) + .foregroundStyle(.secondary) + } + if let authLabel = ControlChannel.shared.authSourceLabel { + Text(authLabel) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if self.state.remoteTransport == .ssh { + Text("Tip: enable Tailscale for stable remote access.") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } else { + Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .transition(.opacity) + .onAppear { self.gatewayDiscovery.start() } + .onDisappear { self.gatewayDiscovery.stop() } + } + + private var remoteTransportRow: some View { + HStack(alignment: .center, spacing: 10) { + Text("Transport") + .font(.callout.weight(.semibold)) + .frame(width: self.remoteLabelWidth, alignment: .leading) + Picker("Transport", selection: self.$state.remoteTransport) { + Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) + Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) + } + .pickerStyle(.segmented) + .frame(maxWidth: 320) + } + } + + private var remoteSshRow: some View { + let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines) + let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget) + let canTest = !trimmedTarget.isEmpty && validationMessage == nil + + return VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 10) { + Text("SSH target") + .font(.callout.weight(.semibold)) + .frame(width: self.remoteLabelWidth, alignment: .leading) + TextField("user@host[:22]", text: self.$state.remoteTarget) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + Button { + Task { await self.testRemote() } + } label: { + if self.remoteStatus == .checking { + ProgressView().controlSize(.small) + } else { + Text("Test remote") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.remoteStatus == .checking || !canTest) + } + if let validationMessage { + Text(validationMessage) + .font(.caption) + .foregroundStyle(.red) + .padding(.leading, self.remoteLabelWidth + 10) + } + } + } + + private var remoteDirectRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 10) { + Text("Gateway") + .font(.callout.weight(.semibold)) + .frame(width: self.remoteLabelWidth, alignment: .leading) + TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + Button { + Task { await self.testRemote() } + } label: { + if self.remoteStatus == .checking { + ProgressView().controlSize(.small) + } else { + Text("Test remote") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.remoteStatus == .checking || self.state.remoteUrl + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + Text( + "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, self.remoteLabelWidth + 10) + } + } + + private var controlStatusLine: String { + switch ControlChannel.shared.state { + case .connected: "Connected" + case .connecting: "Connecting…" + case .disconnected: "Disconnected" + case let .degraded(msg): msg + } + } + + @ViewBuilder + private var remoteStatusView: some View { + switch self.remoteStatus { + case .idle: + EmptyView() + case .checking: + Text("Testing…") + .font(.caption) + .foregroundStyle(.secondary) + case .ok: + Label("Ready", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + case let .failed(message): + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + + private var isControlStatusDuplicate: Bool { + guard case let .failed(message) = self.remoteStatus else { return false } + return message == self.controlStatusLine + } + + private var gatewayInstallerCard: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Circle() + .fill(self.gatewayStatusColor) + .frame(width: 10, height: 10) + Text(self.gatewayStatus.message) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let gatewayVersion = self.gatewayStatus.gatewayVersion, + let required = self.gatewayStatus.requiredGateway, + gatewayVersion != required + { + Text("Installed: \(gatewayVersion) · Required: \(required)") + .font(.caption) + .foregroundStyle(.secondary) + } else if let gatewayVersion = self.gatewayStatus.gatewayVersion { + Text("Gateway \(gatewayVersion) detected") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let node = self.gatewayStatus.nodeVersion { + Text("Node \(node)") + .font(.caption) + .foregroundStyle(.secondary) + } + + if case let .attachedExisting(details) = self.gatewayManager.status { + Text(details ?? "Using existing gateway instance") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let failure = self.gatewayManager.lastFailureReason { + Text("Last failure: \(failure)") + .font(.caption) + .foregroundStyle(.red) + } + + Button("Recheck") { self.refreshGatewayStatus() } + .buttonStyle(.bordered) + + Text("Gateway auto-starts in local mode via launchd (\(gatewayLaunchdLabel)).") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(12) + .background(Color.gray.opacity(0.08)) + .cornerRadius(10) + } + + private func refreshGatewayStatus() { + Task { + let status = await Task.detached(priority: .utility) { + GatewayEnvironment.check() + }.value + self.gatewayStatus = status + } + } + + private var gatewayStatusColor: Color { + switch self.gatewayStatus.kind { + case .ok: .green + case .checking: .secondary + case .missingNode, .missingGateway, .incompatible, .error: .orange + } + } + + private var healthCard: some View { + let snapshot = self.healthStore.snapshot + return VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Circle() + .fill(self.healthStore.state.tint) + .frame(width: 10, height: 10) + Text(self.healthStore.summaryLine) + .font(.callout.weight(.semibold)) + } + + if let snap = snapshot { + let linkId = snap.channelOrder?.first(where: { + if let summary = snap.channels[$0] { return summary.linked != nil } + return false + }) ?? snap.channels.keys.first(where: { + if let summary = snap.channels[$0] { return summary.linked != nil } + return false + }) + let linkLabel = + linkId.flatMap { snap.channelLabels?[$0] } ?? + linkId?.capitalized ?? + "Link channel" + let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs } + Text("\(linkLabel) auth age: \(healthAgeString(linkAge))") + .font(.caption) + .foregroundStyle(.secondary) + Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)") + .font(.caption) + .foregroundStyle(.secondary) + if let recent = snap.sessions.recent.first { + let lastActivity = recent.updatedAt != nil + ? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000)) + : "unknown" + Text("Last activity: \(recent.key) \(lastActivity)") + .font(.caption) + .foregroundStyle(.secondary) + } + Text("Last check: \(relativeAge(from: self.healthStore.lastSuccess))") + .font(.caption) + .foregroundStyle(.secondary) + } else if let error = self.healthStore.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } else { + Text("Health check pending…") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 12) { + Button { + Task { await self.healthStore.refresh(onDemand: true) } + } label: { + if self.healthStore.isRefreshing { + ProgressView().controlSize(.small) + } else { + Label("Run Health Check", systemImage: "arrow.clockwise") + } + } + .disabled(self.healthStore.isRefreshing) + + Divider().frame(height: 18) + + Button { + self.revealLogs() + } label: { + Label("Reveal Logs", systemImage: "doc.text.magnifyingglass") + } + } + } + .padding(12) + .background(Color.gray.opacity(0.08)) + .cornerRadius(10) + } +} + +private enum RemoteStatus: Equatable { + case idle + case checking + case ok + case failed(String) +} + +extension GeneralSettings { + private var healthRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Circle() + .fill(self.healthStore.state.tint) + .frame(width: 10, height: 10) + Text(self.healthStore.summaryLine) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let detail = self.healthStore.detailLine { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 10) { + Button("Retry now") { + Task { await HealthStore.shared.refresh(onDemand: true) } + } + .disabled(self.healthStore.isRefreshing) + + Button("Open logs") { self.revealLogs() } + .buttonStyle(.link) + .foregroundStyle(.secondary) + } + .font(.caption) + } + } + + @MainActor + func testRemote() async { + self.remoteStatus = .checking + let settings = CommandResolver.connectionSettings() + if self.state.remoteTransport == .direct { + let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedUrl.isEmpty else { + self.remoteStatus = .failed("Set a gateway URL first") + return + } + guard Self.isValidWsUrl(trimmedUrl) else { + self.remoteStatus = .failed( + "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)") + return + } + } else { + guard !settings.target.isEmpty else { + self.remoteStatus = .failed("Set an SSH target first") + return + } + + // Step 1: basic SSH reachability check + guard let sshCommand = Self.sshCheckCommand( + target: settings.target, + identity: settings.identity) + else { + self.remoteStatus = .failed("SSH target is invalid") + return + } + let sshResult = await ShellExecutor.run( + command: sshCommand, + cwd: nil, + env: nil, + timeout: 8) + + guard sshResult.ok else { + self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target)) + return + } + } + + // Step 2: control channel health check + let originalMode = AppStateStore.shared.connectionMode + do { + try await ControlChannel.shared.configure(mode: .remote( + target: settings.target, + identity: settings.identity)) + let data = try await ControlChannel.shared.health(timeout: 10) + if decodeHealthSnapshot(from: data) != nil { + self.remoteStatus = .ok + } else { + self.remoteStatus = .failed("Control channel returned invalid health JSON") + } + } catch { + self.remoteStatus = .failed(error.localizedDescription) + } + + // Restore original mode if we temporarily switched + switch originalMode { + case .remote: + break + case .local: + try? await ControlChannel.shared.configure(mode: .local) + case .unconfigured: + await ControlChannel.shared.disconnect() + } + } + + private static func isValidWsUrl(_ raw: String) -> Bool { + GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil + } + + private static func sshCheckCommand(target: String, identity: String) -> [String]? { + guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil } + let options = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "UpdateHostKeys=yes", + ] + let args = CommandResolver.sshArguments( + target: parsed, + identity: identity, + options: options, + remoteCommand: ["echo", "ok"]) + return ["/usr/bin/ssh"] + args + } + + private func formatSSHFailure(_ response: Response, target: String) -> String { + let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) } + let trimmed = payload? + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(whereSeparator: \.isNewline) + .joined(separator: " ") + if let trimmed, + trimmed.localizedCaseInsensitiveContains("host key verification failed") + { + let host = CommandResolver.parseSSHTarget(target)?.host ?? target + return "SSH check failed: Host key verification failed. Remove the old key with " + + "`ssh-keygen -R \(host)` and try again." + } + if let trimmed, !trimmed.isEmpty { + if let message = response.message, message.hasPrefix("exit ") { + return "SSH check failed: \(trimmed) (\(message))" + } + return "SSH check failed: \(trimmed)" + } + if let message = response.message { + return "SSH check failed (\(message))" + } + return "SSH check failed" + } + + private func revealLogs() { + let target = LogLocator.bestLogFile() + + if let target { + NSWorkspace.shared.selectFile( + target.path, + inFileViewerRootedAtPath: target.deletingLastPathComponent().path) + return + } + + let alert = NSAlert() + alert.messageText = "Log file not found" + alert.informativeText = """ + Looked for openclaw logs in /tmp/openclaw/. + Run a health check or send a message to generate activity, then try again. + """ + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } + + private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { + MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) + + if self.state.remoteTransport == .direct { + self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" + } else { + self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" + } + if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { + OpenClawConfigFile.setRemoteGatewayUrl( + host: endpoint.host, + port: endpoint.port) + } else { + OpenClawConfigFile.clearRemoteGatewayUrl() + } + } +} + +private func healthAgeString(_ ms: Double?) -> String { + guard let ms else { return "unknown" } + return msToAge(ms) +} + +#if DEBUG +struct GeneralSettings_Previews: PreviewProvider { + static var previews: some View { + GeneralSettings(state: .preview) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + .environment(TailscaleService.shared) + } +} + +@MainActor +extension GeneralSettings { + static func exerciseForTesting() { + let state = AppState(preview: true) + state.connectionMode = .remote + state.remoteTransport = .ssh + state.remoteTarget = "user@host:2222" + state.remoteUrl = "wss://gateway.example.ts.net" + state.remoteIdentity = "/tmp/id_ed25519" + state.remoteProjectRoot = "/tmp/openclaw" + state.remoteCliPath = "/tmp/openclaw" + + let view = GeneralSettings(state: state) + view.gatewayStatus = GatewayEnvironmentStatus( + kind: .ok, + nodeVersion: "1.0.0", + gatewayVersion: "1.0.0", + requiredGateway: nil, + message: "Gateway ready") + view.remoteStatus = .failed("SSH failed") + view.showRemoteAdvanced = true + _ = view.body + + state.connectionMode = .unconfigured + _ = view.body + + state.connectionMode = .local + view.gatewayStatus = GatewayEnvironmentStatus( + kind: .error("Gateway offline"), + nodeVersion: nil, + gatewayVersion: nil, + requiredGateway: nil, + message: "Gateway offline") + _ = view.body + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/HealthStore.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/HealthStore.swift new file mode 100644 index 00000000..22c1409f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/HealthStore.swift @@ -0,0 +1,301 @@ +import Foundation +import Network +import Observation +import SwiftUI + +struct HealthSnapshot: Codable, Sendable { + struct ChannelSummary: Codable, Sendable { + struct Probe: Codable, Sendable { + struct Bot: Codable, Sendable { + let username: String? + } + + struct Webhook: Codable, Sendable { + let url: String? + } + + let ok: Bool? + let status: Int? + let error: String? + let elapsedMs: Double? + let bot: Bot? + let webhook: Webhook? + } + + let configured: Bool? + let linked: Bool? + let authAgeMs: Double? + let probe: Probe? + let lastProbeAt: Double? + } + + struct SessionInfo: Codable, Sendable { + let key: String + let updatedAt: Double? + let age: Double? + } + + struct Sessions: Codable, Sendable { + let path: String + let count: Int + let recent: [SessionInfo] + } + + let ok: Bool? + let ts: Double + let durationMs: Double + let channels: [String: ChannelSummary] + let channelOrder: [String]? + let channelLabels: [String: String]? + let heartbeatSeconds: Int? + let sessions: Sessions +} + +enum HealthState: Equatable { + case unknown + case ok + case linkingNeeded + case degraded(String) + + var tint: Color { + switch self { + case .ok: .green + case .linkingNeeded: .red + case .degraded: .orange + case .unknown: .secondary + } + } +} + +@MainActor +@Observable +final class HealthStore { + static let shared = HealthStore() + + private static let logger = Logger(subsystem: "ai.openclaw", category: "health") + + private(set) var snapshot: HealthSnapshot? + private(set) var lastSuccess: Date? + private(set) var lastError: String? + private(set) var isRefreshing = false + + private var loopTask: Task? + private let refreshInterval: TimeInterval = 60 + + private init() { + // Avoid background health polling in SwiftUI previews and tests. + if !ProcessInfo.processInfo.isPreview, !ProcessInfo.processInfo.isRunningTests { + self.start() + } + } + + /// Test-only escape hatch: the HealthStore is a process-wide singleton but + /// state derivation is pure from `snapshot` + `lastError`. + func __setSnapshotForTest(_ snapshot: HealthSnapshot?, lastError: String? = nil) { + self.snapshot = snapshot + self.lastError = lastError + } + + func start() { + guard self.loopTask == nil else { return } + self.loopTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + await self.refresh() + try? await Task.sleep(nanoseconds: UInt64(self.refreshInterval * 1_000_000_000)) + } + } + } + + func stop() { + self.loopTask?.cancel() + self.loopTask = nil + } + + func refresh(onDemand: Bool = false) async { + guard !self.isRefreshing else { return } + self.isRefreshing = true + defer { self.isRefreshing = false } + let previousError = self.lastError + + do { + let data = try await ControlChannel.shared.health(timeout: 15) + if let decoded = decodeHealthSnapshot(from: data) { + self.snapshot = decoded + self.lastSuccess = Date() + self.lastError = nil + if previousError != nil { + Self.logger.info("health refresh recovered") + } + } else { + self.lastError = "health output not JSON" + if onDemand { self.snapshot = nil } + if previousError != self.lastError { + Self.logger.warning("health refresh failed: output not JSON") + } + } + } catch { + let desc = error.localizedDescription + self.lastError = desc + if onDemand { self.snapshot = nil } + if previousError != desc { + Self.logger.error("health refresh failed \(desc, privacy: .public)") + } + } + } + + private static func isChannelHealthy(_ summary: HealthSnapshot.ChannelSummary) -> Bool { + guard summary.configured == true else { return false } + // If probe is missing, treat it as "configured but unknown health" (not a hard fail). + return summary.probe?.ok ?? true + } + + private static func describeProbeFailure(_ probe: HealthSnapshot.ChannelSummary.Probe) -> String { + let elapsed = probe.elapsedMs.map { "\(Int($0))ms" } + if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil { + if let elapsed { return "Health check timed out (\(elapsed))" } + return "Health check timed out" + } + let code = probe.status.map { "status \($0)" } ?? "status unknown" + let reason = probe.error?.isEmpty == false ? probe.error! : "health probe failed" + if let elapsed { return "\(reason) (\(code), \(elapsed))" } + return "\(reason) (\(code))" + } + + private func resolveLinkChannel( + _ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)? + { + let order = snap.channelOrder ?? Array(snap.channels.keys) + for id in order { + if let summary = snap.channels[id], summary.linked == true { + return (id: id, summary: summary) + } + } + for id in order { + if let summary = snap.channels[id], summary.linked != nil { + return (id: id, summary: summary) + } + } + return nil + } + + private func resolveFallbackChannel( + _ snap: HealthSnapshot, + excluding id: String?) -> (id: String, summary: HealthSnapshot.ChannelSummary)? + { + let order = snap.channelOrder ?? Array(snap.channels.keys) + for channelId in order { + if channelId == id { continue } + guard let summary = snap.channels[channelId] else { continue } + if Self.isChannelHealthy(summary) { + return (id: channelId, summary: summary) + } + } + return nil + } + + var state: HealthState { + if let error = self.lastError, !error.isEmpty { + return .degraded(error) + } + guard let snap = self.snapshot else { return .unknown } + guard let link = self.resolveLinkChannel(snap) else { return .unknown } + if link.summary.linked != true { + // Linking is optional if any other channel is healthy; don't paint the whole app red. + let fallback = self.resolveFallbackChannel(snap, excluding: link.id) + return fallback != nil ? .degraded("Not linked") : .linkingNeeded + } + // A channel can be "linked" but still unhealthy (failed probe / cannot connect). + if let probe = link.summary.probe, probe.ok == false { + return .degraded(Self.describeProbeFailure(probe)) + } + return .ok + } + + var summaryLine: String { + if self.isRefreshing { return "Health check running…" } + if let error = self.lastError { return "Health check failed: \(error)" } + guard let snap = self.snapshot else { return "Health check pending" } + guard let link = self.resolveLinkChannel(snap) else { return "Health check pending" } + if link.summary.linked != true { + if let fallback = self.resolveFallbackChannel(snap, excluding: link.id) { + let fallbackLabel = snap.channelLabels?[fallback.id] ?? fallback.id.capitalized + let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded" + return "\(fallbackLabel) \(fallbackState) · Not linked — run openclaw login" + } + return "Not linked — run openclaw login" + } + let auth = link.summary.authAgeMs.map { msToAge($0) } ?? "unknown" + if let probe = link.summary.probe, probe.ok == false { + let status = probe.status.map(String.init) ?? "?" + let suffix = probe.status == nil ? "probe degraded" : "probe degraded · status \(status)" + return "linked · auth \(auth) · \(suffix)" + } + return "linked · auth \(auth)" + } + + /// Short, human-friendly detail for the last failure, used in the UI. + var detailLine: String? { + if let error = self.lastError, !error.isEmpty { + let lower = error.lowercased() + if lower.contains("connection refused") { + let port = GatewayEnvironment.gatewayPort() + let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)" + return "The gateway control port (\(host)) isn’t listening — restart OpenClaw to bring it back." + } + if lower.contains("timeout") { + return "Timed out waiting for the control server; the gateway may be crashed or still starting." + } + return error + } + return nil + } + + func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String { + if let link = self.resolveLinkChannel(snap), link.summary.linked != true { + return "Not linked — run openclaw login" + } + if let link = self.resolveLinkChannel(snap), let probe = link.summary.probe, probe.ok == false { + return Self.describeProbeFailure(probe) + } + if let fallback, !fallback.isEmpty { + return fallback + } + return "health probe failed" + } + + var degradedSummary: String? { + guard case let .degraded(reason) = self.state else { return nil } + if reason == "[object Object]" || reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let snap = self.snapshot + { + return self.describeFailure(from: snap, fallback: reason) + } + return reason + } +} + +func msToAge(_ ms: Double) -> String { + let minutes = Int(round(ms / 60000)) + if minutes < 1 { return "just now" } + if minutes < 60 { return "\(minutes)m" } + let hours = Int(round(Double(minutes) / 60)) + if hours < 48 { return "\(hours)h" } + let days = Int(round(Double(hours) / 24)) + return "\(days)d" +} + +/// Decode a health snapshot, tolerating stray log lines before/after the JSON blob. +func decodeHealthSnapshot(from data: Data) -> HealthSnapshot? { + let decoder = JSONDecoder() + if let snap = try? decoder.decode(HealthSnapshot.self, from: data) { + return snap + } + guard let text = String(data: data, encoding: .utf8) else { return nil } + guard let firstBrace = text.firstIndex(of: "{"), let lastBrace = text.lastIndex(of: "}") else { + return nil + } + let slice = text[firstBrace...lastBrace] + let cleaned = Data(slice.utf8) + return try? decoder.decode(HealthSnapshot.self, from: cleaned) +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/HeartbeatStore.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/HeartbeatStore.swift new file mode 100644 index 00000000..6bd7bb52 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/HeartbeatStore.swift @@ -0,0 +1,39 @@ +import Foundation +import Observation +import SwiftUI + +@MainActor +@Observable +final class HeartbeatStore { + static let shared = HeartbeatStore() + + private(set) var lastEvent: ControlHeartbeatEvent? + + private var observer: NSObjectProtocol? + + private init() { + self.observer = NotificationCenter.default.addObserver( + forName: .controlHeartbeat, + object: nil, + queue: .main) + { [weak self] note in + guard let data = note.object as? Data else { return } + if let decoded = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) { + Task { @MainActor in self?.lastEvent = decoded } + } + } + + Task { + if self.lastEvent == nil { + if let evt = try? await ControlChannel.shared.lastHeartbeat() { + self.lastEvent = evt + } + } + } + } + + @MainActor + deinit { + if let observer { NotificationCenter.default.removeObserver(observer) } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift new file mode 100644 index 00000000..b9b99329 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift @@ -0,0 +1,91 @@ +import Foundation + +enum HostEnvSanitizer { + /// Keep in sync with src/infra/host-env-security-policy.json. + /// Parity is validated by src/infra/host-env-security.policy-parity.test.ts. + private static let blockedKeys: Set = [ + "NODE_OPTIONS", + "NODE_PATH", + "PYTHONHOME", + "PYTHONPATH", + "PERL5LIB", + "PERL5OPT", + "RUBYLIB", + "RUBYOPT", + "BASH_ENV", + "ENV", + "SHELL", + "SHELLOPTS", + "PS4", + "GCONV_PATH", + "IFS", + "SSLKEYLOGFILE", + ] + + private static let blockedPrefixes: [String] = [ + "DYLD_", + "LD_", + "BASH_FUNC_", + ] + private static let blockedOverrideKeys: Set = [ + "HOME", + "ZDOTDIR", + ] + private static let shellWrapperAllowedOverrideKeys: Set = [ + "TERM", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LC_MESSAGES", + "COLORTERM", + "NO_COLOR", + "FORCE_COLOR", + ] + + private static func isBlocked(_ upperKey: String) -> Bool { + if self.blockedKeys.contains(upperKey) { return true } + return self.blockedPrefixes.contains(where: { upperKey.hasPrefix($0) }) + } + + private static func filterOverridesForShellWrapper(_ overrides: [String: String]?) -> [String: String]? { + guard let overrides else { return nil } + var filtered: [String: String] = [:] + for (rawKey, value) in overrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + if self.shellWrapperAllowedOverrideKeys.contains(key.uppercased()) { + filtered[key] = value + } + } + return filtered.isEmpty ? nil : filtered + } + + static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] { + var merged: [String: String] = [:] + for (rawKey, value) in ProcessInfo.processInfo.environment { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + let upper = key.uppercased() + if self.isBlocked(upper) { continue } + merged[key] = value + } + + let effectiveOverrides = shellWrapper + ? self.filterOverridesForShellWrapper(overrides) + : overrides + + guard let effectiveOverrides else { return merged } + for (rawKey, value) in effectiveOverrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + let upper = key.uppercased() + // PATH is part of the security boundary (command resolution + safe-bin checks). Never + // allow request-scoped PATH overrides from agents/gateways. + if upper == "PATH" { continue } + if self.blockedOverrideKeys.contains(upper) { continue } + if self.isBlocked(upper) { continue } + merged[key] = value + } + return merged + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/HoverHUD.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/HoverHUD.swift new file mode 100644 index 00000000..d3482362 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/HoverHUD.swift @@ -0,0 +1,311 @@ +import AppKit +import Observation +import QuartzCore +import SwiftUI + +/// Hover-only HUD anchored to the menu bar item. Click expands into full Web Chat. +@MainActor +@Observable +final class HoverHUDController { + static let shared = HoverHUDController() + + struct Model { + var isVisible: Bool = false + var isSuppressed: Bool = false + var hoveringStatusItem: Bool = false + var hoveringPanel: Bool = false + } + + private(set) var model = Model() + + private var window: NSPanel? + private var hostingView: NSHostingView? + private var dismissMonitor: Any? + private var dismissTask: Task? + private var showTask: Task? + private var anchorProvider: (() -> NSRect?)? + + private let width: CGFloat = 360 + private let height: CGFloat = 74 + private let padding: CGFloat = 8 + private let hoverShowDelay: TimeInterval = 0.18 + + func setSuppressed(_ suppressed: Bool) { + self.model.isSuppressed = suppressed + if suppressed { + self.showTask?.cancel() + self.showTask = nil + self.dismiss(reason: "suppressed") + } + } + + func statusItemHoverChanged(inside: Bool, anchorProvider: @escaping () -> NSRect?) { + self.model.hoveringStatusItem = inside + self.anchorProvider = anchorProvider + + guard !self.model.isSuppressed else { return } + + if inside { + self.dismissTask?.cancel() + self.dismissTask = nil + self.showTask?.cancel() + self.showTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(self.hoverShowDelay * 1_000_000_000)) + await MainActor.run { [weak self] in + guard let self else { return } + guard !Task.isCancelled else { return } + guard self.model.hoveringStatusItem else { return } + guard !self.model.isSuppressed else { return } + self.present() + } + } + } else { + self.showTask?.cancel() + self.showTask = nil + self.scheduleDismiss() + } + } + + func panelHoverChanged(inside: Bool) { + self.model.hoveringPanel = inside + if inside { + self.dismissTask?.cancel() + self.dismissTask = nil + } else if !self.model.hoveringStatusItem { + self.scheduleDismiss() + } + } + + func openChat() { + guard let anchorProvider = self.anchorProvider else { return } + self.dismiss(reason: "openChat") + Task { @MainActor in + let sessionKey = await WebChatManager.shared.preferredSessionKey() + WebChatManager.shared.togglePanel(sessionKey: sessionKey, anchorProvider: anchorProvider) + } + } + + func dismiss(reason: String = "explicit") { + self.dismissTask?.cancel() + self.dismissTask = nil + self.removeDismissMonitor() + guard let window else { + self.model.isVisible = false + return + } + + if !self.model.isVisible { + window.orderOut(nil) + return + } + + let target = window.frame.offsetBy(dx: 0, dy: 6) + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.14 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(target, display: true) + window.animator().alphaValue = 0 + } completionHandler: { + Task { @MainActor in + window.orderOut(nil) + self.model.isVisible = false + } + } + } + + // MARK: - Private + + private func scheduleDismiss() { + self.dismissTask?.cancel() + self.dismissTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 250_000_000) + await MainActor.run { + guard let self else { return } + if self.model.hoveringStatusItem || self.model.hoveringPanel { return } + self.dismiss(reason: "hoverExit") + } + } + } + + private func present() { + guard !self.model.isSuppressed else { return } + self.ensureWindow() + self.hostingView?.rootView = HoverHUDView(controller: self) + let target = self.targetFrame() + + guard let window else { return } + self.installDismissMonitor() + + if !self.model.isVisible { + self.model.isVisible = true + let start = target.offsetBy(dx: 0, dy: 8) + window.setFrame(start, display: true) + window.alphaValue = 0 + window.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(target, display: true) + window.animator().alphaValue = 1 + } + } else { + window.orderFrontRegardless() + self.updateWindowFrame(animate: true) + } + } + + private func ensureWindow() { + if self.window != nil { return } + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height), + styleMask: [.nonactivatingPanel, .borderless], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .statusBar + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + panel.hidesOnDeactivate = false + panel.isMovable = false + panel.isFloatingPanel = true + panel.becomesKeyOnlyIfNeeded = true + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + + let host = NSHostingView(rootView: HoverHUDView(controller: self)) + host.translatesAutoresizingMaskIntoConstraints = false + panel.contentView = host + self.hostingView = host + self.window = panel + } + + private func targetFrame() -> NSRect { + guard let anchor = self.anchorProvider?() else { + return WindowPlacement.topRightFrame( + size: NSSize(width: self.width, height: self.height), + padding: self.padding) + } + + let screen = NSScreen.screens.first { screen in + screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) + } ?? NSScreen.main + + let bounds = (screen?.visibleFrame ?? .zero).insetBy(dx: self.padding, dy: self.padding) + return WindowPlacement.anchoredBelowFrame( + size: NSSize(width: self.width, height: self.height), + anchor: anchor, + padding: self.padding, + in: bounds) + } + + private func updateWindowFrame(animate: Bool = false) { + guard let window else { return } + let frame = self.targetFrame() + if animate { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(frame, display: true) + } + } else { + window.setFrame(frame, display: true) + } + } + + private func installDismissMonitor() { + if ProcessInfo.processInfo.isRunningTests { return } + guard self.dismissMonitor == nil, let window else { return } + self.dismissMonitor = NSEvent.addGlobalMonitorForEvents(matching: [ + .leftMouseDown, + .rightMouseDown, + .otherMouseDown, + ]) { [weak self] _ in + guard let self, self.model.isVisible else { return } + let pt = NSEvent.mouseLocation + if !window.frame.contains(pt) { + Task { @MainActor in self.dismiss(reason: "outsideClick") } + } + } + } + + private func removeDismissMonitor() { + if let monitor = self.dismissMonitor { + NSEvent.removeMonitor(monitor) + self.dismissMonitor = nil + } + } +} + +private struct HoverHUDView: View { + var controller: HoverHUDController + private let activityStore = WorkActivityStore.shared + + private var statusTitle: String { + if self.activityStore.iconState.isWorking { return "Working" } + return "Idle" + } + + private var detail: String { + if let current = self.activityStore.current?.label, !current.isEmpty { return current } + if let last = self.activityStore.lastToolLabel, !last.isEmpty { return last } + return "No recent activity" + } + + private var symbolName: String { + if self.activityStore.iconState.isWorking { + return self.activityStore.iconState.badgeSymbolName + } + return "moon.zzz.fill" + } + + private var dotColor: Color { + if self.activityStore.iconState.isWorking { + return Color(nsColor: NSColor.systemGreen.withAlphaComponent(0.7)) + } + return .secondary + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(self.dotColor) + .frame(width: 7, height: 7) + .padding(.top, 5) + + VStack(alignment: .leading, spacing: 4) { + Text(self.statusTitle) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + Text(self.detail) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.middle) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 8) + + Image(systemName: self.symbolName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.top, 1) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.regularMaterial)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.black.opacity(0.10), lineWidth: 1)) + .contentShape(Rectangle()) + .onHover { inside in + self.controller.panelHoverChanged(inside: inside) + } + .onTapGesture { + self.controller.openChat() + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/IconState.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/IconState.swift new file mode 100644 index 00000000..c2eab0e5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/IconState.swift @@ -0,0 +1,113 @@ +import Foundation +import SwiftUI + +enum SessionRole { + case main + case other +} + +enum ToolKind: String, Codable { + case bash, read, write, edit, attach, other +} + +enum ActivityKind: Codable, Equatable { + case job + case tool(ToolKind) +} + +enum IconState: Equatable { + case idle + case workingMain(ActivityKind) + case workingOther(ActivityKind) + case overridden(ActivityKind) + + enum BadgeProminence: Equatable { + case primary + case secondary + case overridden + } + + var badgeSymbolName: String { + switch self.activity { + case .tool(.bash): "chevron.left.slash.chevron.right" + case .tool(.read): "doc" + case .tool(.write): "pencil" + case .tool(.edit): "pencil.tip" + case .tool(.attach): "paperclip" + case .tool(.other), .job: "gearshape.fill" + } + } + + var badgeProminence: BadgeProminence? { + switch self { + case .idle: nil + case .workingMain: .primary + case .workingOther: .secondary + case .overridden: .overridden + } + } + + var isWorking: Bool { + switch self { + case .idle: false + default: true + } + } + + private var activity: ActivityKind { + switch self { + case let .workingMain(kind), + let .workingOther(kind), + let .overridden(kind): + kind + case .idle: + .job + } + } +} + +enum IconOverrideSelection: String, CaseIterable, Identifiable { + case system + case idle + case mainBash, mainRead, mainWrite, mainEdit, mainOther + case otherBash, otherRead, otherWrite, otherEdit, otherOther + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .system: "System (auto)" + case .idle: "Idle" + case .mainBash: "Working main – bash" + case .mainRead: "Working main – read" + case .mainWrite: "Working main – write" + case .mainEdit: "Working main – edit" + case .mainOther: "Working main – other" + case .otherBash: "Working other – bash" + case .otherRead: "Working other – read" + case .otherWrite: "Working other – write" + case .otherEdit: "Working other – edit" + case .otherOther: "Working other – other" + } + } + + func toIconState() -> IconState { + let map: (ToolKind) -> ActivityKind = { .tool($0) } + switch self { + case .system: return .idle + case .idle: return .idle + case .mainBash: return .workingMain(map(.bash)) + case .mainRead: return .workingMain(map(.read)) + case .mainWrite: return .workingMain(map(.write)) + case .mainEdit: return .workingMain(map(.edit)) + case .mainOther: return .workingMain(map(.other)) + case .otherBash: return .workingOther(map(.bash)) + case .otherRead: return .workingOther(map(.read)) + case .otherWrite: return .workingOther(map(.write)) + case .otherEdit: return .workingOther(map(.edit)) + case .otherOther: return .workingOther(map(.other)) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/InstancesSettings.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/InstancesSettings.swift new file mode 100644 index 00000000..0c992c69 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/InstancesSettings.swift @@ -0,0 +1,479 @@ +import AppKit +import SwiftUI + +struct InstancesSettings: View { + var store: InstancesStore + + init(store: InstancesStore = .shared) { + self.store = store + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + self.header + if let err = store.lastError { + Text("Error: \(err)") + .foregroundStyle(.red) + } else if let info = store.statusMessage { + Text(info) + .foregroundStyle(.secondary) + } + if self.store.instances.isEmpty { + Text("No instances reported yet.") + .foregroundStyle(.secondary) + } else { + List(self.store.instances) { inst in + self.instanceRow(inst) + } + .listStyle(.inset) + } + Spacer() + } + .onAppear { self.store.start() } + .onDisappear { self.store.stop() } + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Connected Instances") + .font(.headline) + Text("Latest presence beacons from OpenClaw nodes. Updated periodically.") + .font(.footnote) + .foregroundStyle(.secondary) + } + Spacer() + if self.store.isLoading { + ProgressView() + } else { + Button { + Task { await self.store.refresh() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .help("Refresh") + } + } + } + + @ViewBuilder + private func instanceRow(_ inst: InstanceInfo) -> some View { + let isGateway = (inst.mode ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "gateway" + let prettyPlatform = inst.platform.flatMap { self.prettyPlatform($0) } + let device = DeviceModelCatalog.presentation( + deviceFamily: inst.deviceFamily, + modelIdentifier: inst.modelIdentifier) + + HStack(alignment: .top, spacing: 12) { + self.leadingDeviceIcon(inst, device: device) + .frame(width: 28, height: 28, alignment: .center) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(inst.host ?? "unknown host").font(.subheadline.bold()) + self.presenceIndicator(inst) + if let ip = inst.ip { Text("(") + Text(ip).monospaced() + Text(")") } + } + + HStack(spacing: 8) { + if let version = inst.version { + self.label(icon: "shippingbox", text: version) + } + + if let device { + // Avoid showing generic "Mac"/"iPhone"/etc; prefer the concrete model name. + let family = (inst.deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let isGeneric = !family.isEmpty && device.title == family + if !isGeneric { + if let prettyPlatform { + self.label(icon: device.symbol, text: "\(device.title) · \(prettyPlatform)") + } else { + self.label(icon: device.symbol, text: device.title) + } + } else if let prettyPlatform, let platform = inst.platform { + self.label(icon: self.platformIcon(platform), text: prettyPlatform) + } + } else if let prettyPlatform, let platform = inst.platform { + self.label(icon: self.platformIcon(platform), text: prettyPlatform) + } + + if let mode = inst.mode { self.label(icon: "network", text: mode) } + } + .layoutPriority(1) + + if !isGateway, self.shouldShowUpdateRow(inst) { + HStack(spacing: 8) { + Spacer(minLength: 0) + + // Last local input is helpful for interactive nodes, but noisy/meaningless for the gateway. + if let secs = inst.lastInputSeconds { + self.label(icon: "clock", text: "\(secs)s ago") + } + + if let update = self.updateSummaryText(inst, isGateway: isGateway) { + self.label(icon: "arrow.clockwise", text: update) + .help(self.presenceUpdateSourceHelp(inst.reason ?? "")) + } + } + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 6) + .help(inst.text) + .contextMenu { + Button("Copy Debug Summary") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(inst.text, forType: .string) + } + } + } + + private func label(icon: String?, text: String) -> some View { + HStack(spacing: 4) { + if let icon { + if icon == Self.androidSymbolToken { + AndroidMark() + .foregroundStyle(.secondary) + .frame(width: 12, height: 12, alignment: .center) + } else if self.isSystemSymbolAvailable(icon) { + Image(systemName: icon).foregroundStyle(.secondary).font(.caption) + } + } + Text(text) + } + .font(.footnote) + } + + private func presenceIndicator(_ inst: InstanceInfo) -> some View { + let status = self.presenceStatus(for: inst) + return HStack(spacing: 4) { + Circle() + .fill(status.color) + .frame(width: 6, height: 6) + .accessibilityHidden(true) + Text(status.label) + .foregroundStyle(.secondary) + } + .font(.caption) + .help("Presence updated \(inst.ageDescription).") + .accessibilityLabel("\(status.label) presence") + } + + private func presenceStatus(for inst: InstanceInfo) -> (label: String, color: Color) { + let nowMs = Date().timeIntervalSince1970 * 1000 + let ageSeconds = max(0, Int((nowMs - inst.ts) / 1000)) + if ageSeconds <= 120 { return ("Active", .green) } + if ageSeconds <= 300 { return ("Idle", .yellow) } + return ("Stale", .gray) + } + + @ViewBuilder + private func leadingDeviceIcon(_ inst: InstanceInfo, device: DevicePresentation?) -> some View { + let symbol = self.leadingDeviceSymbol(inst, device: device) + if symbol == Self.androidSymbolToken { + AndroidMark() + .foregroundStyle(.secondary) + .frame(width: 24, height: 24, alignment: .center) + .accessibilityHidden(true) + } else { + Image(systemName: symbol) + .font(.system(size: 26, weight: .regular)) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + } + + private static let androidSymbolToken = "android" + + private func leadingDeviceSymbol(_ inst: InstanceInfo, device: DevicePresentation?) -> String { + let family = (inst.deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if family == "android" { + return Self.androidSymbolToken + } + + if let title = device?.title.lowercased() { + if title.contains("mac studio") { + return self.safeSystemSymbol("macstudio", fallback: "desktopcomputer") + } + if title.contains("macbook") { + return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") + } + if title.contains("ipad") { + return self.safeSystemSymbol("ipad", fallback: "ipad") + } + if title.contains("iphone") { + return self.safeSystemSymbol("iphone", fallback: "iphone") + } + } + + if let symbol = device?.symbol { + return self.safeSystemSymbol(symbol, fallback: "cpu") + } + + if let platform = inst.platform { + return self.safeSystemSymbol(self.platformIcon(platform), fallback: "cpu") + } + + return "cpu" + } + + private func shouldShowUpdateRow(_ inst: InstanceInfo) -> Bool { + if inst.lastInputSeconds != nil { return true } + if self.updateSummaryText(inst, isGateway: false) != nil { return true } + return false + } + + private func safeSystemSymbol(_ preferred: String, fallback: String) -> String { + if self.isSystemSymbolAvailable(preferred) { return preferred } + return fallback + } + + private func isSystemSymbolAvailable(_ name: String) -> Bool { + NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil + } + + private struct AndroidMark: View { + var body: some View { + GeometryReader { geo in + let w = geo.size.width + let h = geo.size.height + let headHeight = h * 0.68 + let headWidth = w * 0.92 + let headY = h * 0.18 + let corner = headHeight * 0.28 + + ZStack { + RoundedRectangle(cornerRadius: corner, style: .continuous) + .frame(width: headWidth, height: headHeight) + .position(x: w / 2, y: headY + headHeight / 2) + + Circle() + .frame(width: max(1, w * 0.1), height: max(1, w * 0.1)) + .position(x: w * 0.38, y: headY + headHeight * 0.55) + .blendMode(.destinationOut) + + Circle() + .frame(width: max(1, w * 0.1), height: max(1, w * 0.1)) + .position(x: w * 0.62, y: headY + headHeight * 0.55) + .blendMode(.destinationOut) + + Rectangle() + .frame(width: max(1, w * 0.08), height: max(1, h * 0.18)) + .rotationEffect(.degrees(-25)) + .position(x: w * 0.34, y: h * 0.12) + + Rectangle() + .frame(width: max(1, w * 0.08), height: max(1, h * 0.18)) + .rotationEffect(.degrees(25)) + .position(x: w * 0.66, y: h * 0.12) + } + .compositingGroup() + } + } + } + + private func platformIcon(_ raw: String) -> String { + let (prefix, _) = self.parsePlatform(raw) + switch prefix { + case "macos": + return "laptopcomputer" + case "ios": + return "iphone" + case "ipados": + return "ipad" + case "tvos": + return "appletv" + case "watchos": + return "applewatch" + default: + return "cpu" + } + } + + private func prettyPlatform(_ raw: String) -> String? { + let (prefix, version) = self.parsePlatform(raw) + if prefix.isEmpty { return nil } + let name: String = switch prefix { + case "macos": "macOS" + case "ios": "iOS" + case "ipados": "iPadOS" + case "tvos": "tvOS" + case "watchos": "watchOS" + default: prefix.prefix(1).uppercased() + prefix.dropFirst() + } + guard let version, !version.isEmpty else { return name } + let parts = version.split(separator: ".").map(String.init) + if parts.count >= 2 { + return "\(name) \(parts[0]).\(parts[1])" + } + return "\(name) \(version)" + } + + private func parsePlatform(_ raw: String) -> (prefix: String, version: String?) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return ("", nil) } + let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) + let prefix = parts.first?.lowercased() ?? "" + let versionToken = parts.dropFirst().first + return (prefix, versionToken) + } + + private func presenceUpdateSourceShortText(_ reason: String) -> String? { + let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + switch trimmed { + case "self": + return "Self" + case "connect": + return "Connect" + case "disconnect": + return "Disconnect" + case "node-connected": + return "Node connect" + case "node-disconnected": + return "Node disconnect" + case "launch": + return "Launch" + case "periodic": + return "Heartbeat" + case "instances-refresh": + return "Instances" + case "seq gap": + return "Resync" + default: + return trimmed + } + } + + private func updateSummaryText(_ inst: InstanceInfo, isGateway: Bool) -> String? { + // For gateway rows, omit the "updated via/by" provenance entirely. + if isGateway { + return nil + } + + let age = inst.ageDescription.trimmingCharacters(in: .whitespacesAndNewlines) + guard !age.isEmpty else { return nil } + + let source = self.presenceUpdateSourceShortText(inst.reason ?? "") + if let source, !source.isEmpty { + return "\(age) · \(source)" + } + return age + } + + private func presenceUpdateSourceHelp(_ reason: String) -> String { + let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return "Why this presence entry was last updated (debug marker)." + } + return "Why this presence entry was last updated (debug marker). Raw: \(trimmed)" + } +} + +#if DEBUG +extension InstancesSettings { + static func exerciseForTesting() { + let view = InstancesSettings(store: InstancesStore(isPreview: true)) + let mac = InstanceInfo( + id: "mac", + host: "studio", + ip: "10.0.0.2", + version: "1.2.3", + platform: "macOS 14.2", + deviceFamily: "Mac", + modelIdentifier: "Mac14,10", + lastInputSeconds: 12, + mode: "local", + reason: "self", + text: "Mac Studio", + ts: 1_700_000_000_000) + let genericIOS = InstanceInfo( + id: "iphone", + host: "phone", + ip: "10.0.0.3", + version: "2.0.0", + platform: "iOS 18.0", + deviceFamily: "iPhone", + modelIdentifier: nil, + lastInputSeconds: 35, + mode: "node", + reason: "connect", + text: "iPhone node", + ts: 1_700_000_100_000) + let android = InstanceInfo( + id: "android", + host: "pixel", + ip: nil, + version: "3.1.0", + platform: "Android 14", + deviceFamily: "Android", + modelIdentifier: nil, + lastInputSeconds: 90, + mode: "node", + reason: "seq gap", + text: "Android node", + ts: 1_700_000_200_000) + let gateway = InstanceInfo( + id: "gateway", + host: "gateway", + ip: "10.0.0.9", + version: "4.0.0", + platform: "Linux", + deviceFamily: nil, + modelIdentifier: nil, + lastInputSeconds: nil, + mode: "gateway", + reason: "periodic", + text: "Gateway", + ts: 1_700_000_300_000) + + _ = view.instanceRow(mac) + _ = view.instanceRow(genericIOS) + _ = view.instanceRow(android) + _ = view.instanceRow(gateway) + + _ = view.leadingDeviceSymbol( + mac, + device: DevicePresentation(title: "Mac Studio", symbol: "macstudio")) + _ = view.leadingDeviceSymbol( + mac, + device: DevicePresentation(title: "MacBook Pro", symbol: "laptopcomputer")) + _ = view.leadingDeviceSymbol(android, device: nil) + _ = view.platformIcon("tvOS 17.1") + _ = view.platformIcon("watchOS 10") + _ = view.platformIcon("unknown 1.0") + _ = view.prettyPlatform("macOS 14.2") + _ = view.prettyPlatform("iOS 18") + _ = view.prettyPlatform("ipados 17.1") + _ = view.prettyPlatform("linux") + _ = view.prettyPlatform(" ") + _ = view.parsePlatform("macOS 14.1") + _ = view.parsePlatform(" ") + _ = view.presenceUpdateSourceShortText("self") + _ = view.presenceUpdateSourceShortText("instances-refresh") + _ = view.presenceUpdateSourceShortText("seq gap") + _ = view.presenceUpdateSourceShortText("custom") + _ = view.presenceUpdateSourceShortText(" ") + _ = view.updateSummaryText(mac, isGateway: false) + _ = view.updateSummaryText(gateway, isGateway: true) + _ = view.presenceUpdateSourceHelp("") + _ = view.presenceUpdateSourceHelp("connect") + _ = view.safeSystemSymbol("not-a-symbol", fallback: "cpu") + _ = view.isSystemSymbolAvailable("sparkles") + _ = view.label(icon: "android", text: "Android") + _ = view.label(icon: "sparkles", text: "Sparkles") + _ = view.label(icon: nil, text: "Plain") + _ = AndroidMark().body + } +} + +struct InstancesSettings_Previews: PreviewProvider { + static var previews: some View { + InstancesSettings(store: .preview()) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/InstancesStore.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/InstancesStore.swift new file mode 100644 index 00000000..56634033 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/InstancesStore.swift @@ -0,0 +1,349 @@ +import Cocoa +import Foundation +import Observation +import OpenClawKit +import OpenClawProtocol +import OSLog + +struct InstanceInfo: Identifiable, Codable { + let id: String + let host: String? + let ip: String? + let version: String? + let platform: String? + let deviceFamily: String? + let modelIdentifier: String? + let lastInputSeconds: Int? + let mode: String? + let reason: String? + let text: String + let ts: Double + + var ageDescription: String { + let date = Date(timeIntervalSince1970: ts / 1000) + return age(from: date) + } + + var lastInputDescription: String { + guard let secs = lastInputSeconds else { return "unknown" } + return "\(secs)s ago" + } +} + +@MainActor +@Observable +final class InstancesStore { + static let shared = InstancesStore() + let isPreview: Bool + + var instances: [InstanceInfo] = [] + var lastError: String? + var statusMessage: String? + var isLoading = false + + private let logger = Logger(subsystem: "ai.openclaw", category: "instances") + private var task: Task? + private let interval: TimeInterval = 30 + private var eventTask: Task? + private var startCount = 0 + private var lastPresenceById: [String: InstanceInfo] = [:] + private var lastLoginNotifiedAtMs: [String: Double] = [:] + + private struct PresenceEventPayload: Codable { + let presence: [PresenceEntry] + } + + init(isPreview: Bool = false) { + self.isPreview = isPreview + } + + func start() { + guard !self.isPreview else { return } + self.startCount += 1 + guard self.startCount == 1 else { return } + guard self.task == nil else { return } + self.startGatewaySubscription() + self.task = Task.detached { [weak self] in + guard let self else { return } + await self.refresh() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) + await self.refresh() + } + } + } + + func stop() { + guard !self.isPreview else { return } + guard self.startCount > 0 else { return } + self.startCount -= 1 + guard self.startCount == 0 else { return } + self.task?.cancel() + self.task = nil + self.eventTask?.cancel() + self.eventTask = nil + } + + private func startGatewaySubscription() { + self.eventTask?.cancel() + self.eventTask = Task { [weak self] in + guard let self else { return } + let stream = await GatewayConnection.shared.subscribe() + for await push in stream { + if Task.isCancelled { return } + await MainActor.run { [weak self] in + self?.handle(push: push) + } + } + } + } + + private func handle(push: GatewayPush) { + switch push { + case let .event(evt) where evt.event == "presence": + if let payload = evt.payload { + self.handlePresenceEventPayload(payload) + } + case .seqGap: + Task { await self.refresh() } + case let .snapshot(hello): + self.applyPresence(hello.snapshot.presence) + default: + break + } + } + + func refresh() async { + if self.isLoading { return } + self.statusMessage = nil + self.isLoading = true + defer { self.isLoading = false } + do { + PresenceReporter.shared.sendImmediate(reason: "instances-refresh") + let data = try await ControlChannel.shared.request(method: "system-presence") + self.lastPayload = data + if data.isEmpty { + self.logger.error("instances fetch returned empty payload") + self.instances = [self.localFallbackInstance(reason: "no presence payload")] + self.lastError = nil + self.statusMessage = "No presence payload from gateway; showing local fallback + health probe." + await self.probeHealthIfNeeded(reason: "no payload") + return + } + let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data) + let withIDs = self.normalizePresence(decoded) + if withIDs.isEmpty { + self.instances = [self.localFallbackInstance(reason: "no presence entries")] + self.lastError = nil + self.statusMessage = "Presence list was empty; showing local fallback + health probe." + await self.probeHealthIfNeeded(reason: "empty list") + } else { + self.instances = withIDs + self.lastError = nil + self.statusMessage = nil + } + } catch { + self.logger.error( + """ + instances fetch failed: \(error.localizedDescription, privacy: .public) \ + len=\(self.lastPayload?.count ?? 0, privacy: .public) \ + utf8=\(self.snippet(self.lastPayload), privacy: .public) + """) + self.instances = [self.localFallbackInstance(reason: "presence decode failed")] + self.lastError = nil + self.statusMessage = "Presence data invalid; showing local fallback + health probe." + await self.probeHealthIfNeeded(reason: "decode failed") + } + } + + private func localFallbackInstance(reason: String) -> InstanceInfo { + let host = Host.current().localizedName ?? "this-mac" + let ip = SystemPresenceInfo.primaryIPv4Address() + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" + let text = "Local node: \(host)\(ip.map { " (\($0))" } ?? "") · app \(version ?? "dev")" + let ts = Date().timeIntervalSince1970 * 1000 + return InstanceInfo( + id: "local-\(host)", + host: host, + ip: ip, + version: version, + platform: platform, + deviceFamily: "Mac", + modelIdentifier: InstanceIdentity.modelIdentifier, + lastInputSeconds: SystemPresenceInfo.lastInputSeconds(), + mode: "local", + reason: reason, + text: text, + ts: ts) + } + + // MARK: - Helpers + + /// Keep the last raw payload for logging. + private var lastPayload: Data? + + private func snippet(_ data: Data?, limit: Int = 256) -> String { + guard let data else { return "" } + if data.isEmpty { return "" } + let prefix = data.prefix(limit) + if let asString = String(data: prefix, encoding: .utf8) { + return asString.replacingOccurrences(of: "\n", with: " ") + } + return "<\(data.count) bytes non-utf8>" + } + + private func probeHealthIfNeeded(reason: String? = nil) async { + do { + let data = try await ControlChannel.shared.health(timeout: 8) + guard let snap = decodeHealthSnapshot(from: data) else { return } + let linkId = snap.channelOrder?.first(where: { + if let summary = snap.channels[$0] { return summary.linked != nil } + return false + }) ?? snap.channels.keys.first(where: { + if let summary = snap.channels[$0] { return summary.linked != nil } + return false + }) + let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false + let linkLabel = + linkId.flatMap { snap.channelLabels?[$0] } ?? + linkId?.capitalized ?? + "channel" + let entry = InstanceInfo( + id: "health-\(snap.ts)", + host: "gateway (health)", + ip: nil, + version: nil, + platform: nil, + deviceFamily: nil, + modelIdentifier: nil, + lastInputSeconds: nil, + mode: "health", + reason: "health probe", + text: "Health ok · \(linkLabel) linked=\(linked)", + ts: snap.ts) + if !self.instances.contains(where: { $0.id == entry.id }) { + self.instances.insert(entry, at: 0) + } + self.lastError = nil + self.statusMessage = + "Presence unavailable (\(reason ?? "refresh")); showing health probe + local fallback." + } catch { + self.logger.error("instances health probe failed: \(error.localizedDescription, privacy: .public)") + if let reason { + self.statusMessage = + "Presence unavailable (\(reason)), health probe failed: \(error.localizedDescription)" + } + } + } + + private func decodeAndApplyPresenceData(_ data: Data) { + do { + let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data) + self.applyPresence(decoded) + } catch { + self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)") + self.lastError = error.localizedDescription + } + } + + func handlePresenceEventPayload(_ payload: OpenClawProtocol.AnyCodable) { + do { + let wrapper = try GatewayPayloadDecoding.decode(payload, as: PresenceEventPayload.self) + self.applyPresence(wrapper.presence) + } catch { + self.logger.error("presence event decode failed: \(error.localizedDescription, privacy: .public)") + self.lastError = error.localizedDescription + } + } + + private func normalizePresence(_ entries: [PresenceEntry]) -> [InstanceInfo] { + entries.map { entry -> InstanceInfo in + let key = entry.instanceid ?? entry.host ?? entry.ip ?? entry.text ?? "entry-\(entry.ts)" + return InstanceInfo( + id: key, + host: entry.host, + ip: entry.ip, + version: entry.version, + platform: entry.platform, + deviceFamily: entry.devicefamily, + modelIdentifier: entry.modelidentifier, + lastInputSeconds: entry.lastinputseconds, + mode: entry.mode, + reason: entry.reason, + text: entry.text ?? "Unnamed node", + ts: Double(entry.ts)) + } + } + + private func applyPresence(_ entries: [PresenceEntry]) { + let withIDs = self.normalizePresence(entries) + self.notifyOnNodeLogin(withIDs) + self.lastPresenceById = Dictionary(uniqueKeysWithValues: withIDs.map { ($0.id, $0) }) + self.instances = withIDs + self.statusMessage = nil + self.lastError = nil + } + + private func notifyOnNodeLogin(_ instances: [InstanceInfo]) { + for inst in instances { + guard let reason = inst.reason?.trimmingCharacters(in: .whitespacesAndNewlines) else { continue } + guard reason == "node-connected" else { continue } + if let mode = inst.mode?.lowercased(), mode == "local" { continue } + + let previous = self.lastPresenceById[inst.id] + if previous?.reason == "node-connected", previous?.ts == inst.ts { continue } + + let lastNotified = self.lastLoginNotifiedAtMs[inst.id] ?? 0 + if inst.ts <= lastNotified { continue } + self.lastLoginNotifiedAtMs[inst.id] = inst.ts + + let name = inst.host?.trimmingCharacters(in: .whitespacesAndNewlines) + let device = name?.isEmpty == false ? name! : inst.id + Task { @MainActor in + _ = await NotificationManager().send( + title: "Node connected", + body: device, + sound: nil, + priority: .active) + } + } + } +} + +extension InstancesStore { + static func preview(instances: [InstanceInfo] = [ + InstanceInfo( + id: "local", + host: "steipete-mac", + ip: "10.0.0.12", + version: "1.2.3", + platform: "macos 26.2.0", + deviceFamily: "Mac", + modelIdentifier: "Mac16,6", + lastInputSeconds: 12, + mode: "local", + reason: "preview", + text: "Local node: steipete-mac (10.0.0.12) · app 1.2.3", + ts: Date().timeIntervalSince1970 * 1000), + InstanceInfo( + id: "gateway", + host: "gateway", + ip: "100.64.0.2", + version: "1.2.3", + platform: "linux 6.6.0", + deviceFamily: "Linux", + modelIdentifier: "x86_64", + lastInputSeconds: 45, + mode: "remote", + reason: "preview", + text: "Gateway node · tunnel ok", + ts: Date().timeIntervalSince1970 * 1000 - 45000), + ]) -> InstancesStore { + let store = InstancesStore(isPreview: true) + store.instances = instances + store.statusMessage = "Preview data" + return store + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift new file mode 100644 index 00000000..af318b33 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift @@ -0,0 +1,78 @@ +import Foundation + +enum LaunchAgentManager { + private static var plistURL: URL { + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents/ai.openclaw.mac.plist") + } + + static func status() async -> Bool { + guard FileManager().fileExists(atPath: self.plistURL.path) else { return false } + let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"]) + return result == 0 + } + + static func set(enabled: Bool, bundlePath: String) async { + if enabled { + self.writePlist(bundlePath: bundlePath) + _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) + _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) + _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"]) + } else { + // Disable autostart going forward but leave the current app running. + // bootout would terminate the launchd job immediately (and crash the app if launched via agent). + try? FileManager().removeItem(at: self.plistURL) + } + } + + private static func writePlist(bundlePath: String) { + let plist = """ + + + + + Label + ai.openclaw.mac + ProgramArguments + + \(bundlePath)/Contents/MacOS/OpenClaw + + WorkingDirectory + \(FileManager().homeDirectoryForCurrentUser.path) + RunAtLoad + + KeepAlive + + EnvironmentVariables + + PATH + \(CommandResolver.preferredPaths().joined(separator: ":")) + + StandardOutPath + \(LogLocator.launchdLogPath) + StandardErrorPath + \(LogLocator.launchdLogPath) + + + """ + try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8) + } + + @discardableResult + private static func runLaunchctl(_ args: [String]) async -> Int32 { + await Task.detached(priority: .utility) { () -> Int32 in + let process = Process() + process.launchPath = "/bin/launchctl" + process.arguments = args + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + _ = try process.runAndReadToEnd(from: pipe) + return process.terminationStatus + } catch { + return -1 + } + }.value + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Launchctl.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Launchctl.swift new file mode 100644 index 00000000..cc50fd48 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Launchctl.swift @@ -0,0 +1,87 @@ +import Foundation + +enum Launchctl { + struct Result: Sendable { + let status: Int32 + let output: String + } + + @discardableResult + static func run(_ args: [String]) async -> Result { + await Task.detached(priority: .utility) { () -> Result in + let process = Process() + process.launchPath = "/bin/launchctl" + process.arguments = args + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + let data = try process.runAndReadToEnd(from: pipe) + let output = String(data: data, encoding: .utf8) ?? "" + return Result(status: process.terminationStatus, output: output) + } catch { + return Result(status: -1, output: error.localizedDescription) + } + }.value + } +} + +struct LaunchAgentPlistSnapshot: Equatable, Sendable { + let programArguments: [String] + let environment: [String: String] + let stdoutPath: String? + let stderrPath: String? + + let port: Int? + let bind: String? + let token: String? + let password: String? +} + +enum LaunchAgentPlist { + static func snapshot(url: URL) -> LaunchAgentPlistSnapshot? { + guard let data = try? Data(contentsOf: url) else { return nil } + let rootAny: Any + do { + rootAny = try PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil) + } catch { + return nil + } + guard let root = rootAny as? [String: Any] else { return nil } + let programArguments = root["ProgramArguments"] as? [String] ?? [] + let env = root["EnvironmentVariables"] as? [String: String] ?? [:] + let stdoutPath = (root["StandardOutPath"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + let stderrPath = (root["StandardErrorPath"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + let port = Self.extractFlagInt(programArguments, flag: "--port") + let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased() + let token = env["OPENCLAW_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + let password = env["OPENCLAW_GATEWAY_PASSWORD"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + return LaunchAgentPlistSnapshot( + programArguments: programArguments, + environment: env, + stdoutPath: stdoutPath, + stderrPath: stderrPath, + port: port, + bind: bind, + token: token, + password: password) + } + + private static func extractFlagInt(_ args: [String], flag: String) -> Int? { + guard let raw = self.extractFlagString(args, flag: flag) else { return nil } + return Int(raw) + } + + private static func extractFlagString(_ args: [String], flag: String) -> String? { + guard let idx = args.firstIndex(of: flag) else { return nil } + let valueIdx = args.index(after: idx) + guard valueIdx < args.endIndex else { return nil } + let token = args[valueIdx].trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty ? nil : token + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/LaunchdManager.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/LaunchdManager.swift new file mode 100644 index 00000000..961246f1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/LaunchdManager.swift @@ -0,0 +1,20 @@ +import Foundation + +enum LaunchdManager { + private static func runLaunchctl(_ args: [String]) { + let process = Process() + process.launchPath = "/bin/launchctl" + process.arguments = args + try? process.run() + } + + static func startOpenClaw() { + let userTarget = "gui/\(getuid())/\(launchdLabel)" + self.runLaunchctl(["kickstart", "-k", userTarget]) + } + + static func stopOpenClaw() { + let userTarget = "gui/\(getuid())/\(launchdLabel)" + self.runLaunchctl(["stop", userTarget]) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/LogLocator.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/LogLocator.swift new file mode 100644 index 00000000..b504ab02 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/LogLocator.swift @@ -0,0 +1,59 @@ +import Foundation + +enum LogLocator { + private static var logDir: URL { + if let override = ProcessInfo.processInfo.environment["OPENCLAW_LOG_DIR"], + !override.isEmpty + { + return URL(fileURLWithPath: override) + } + return URL(fileURLWithPath: "/tmp/openclaw") + } + + private static var stdoutLog: URL { + logDir.appendingPathComponent("openclaw-stdout.log") + } + + private static var gatewayLog: URL { + logDir.appendingPathComponent("openclaw-gateway.log") + } + + private static func ensureLogDirExists() { + try? FileManager().createDirectory(at: self.logDir, withIntermediateDirectories: true) + } + + private static func modificationDate(for url: URL) -> Date { + (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + } + + /// Returns the newest log file under /tmp/openclaw/ (rolling or stdout), or nil if none exist. + static func bestLogFile() -> URL? { + self.ensureLogDirExists() + let fm = FileManager() + let files = (try? fm.contentsOfDirectory( + at: self.logDir, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles])) ?? [] + + let prefixes = ["openclaw"] + return files + .filter { file in + prefixes.contains { file.lastPathComponent.hasPrefix($0) } && file.pathExtension == "log" + } + .max { lhs, rhs in + self.modificationDate(for: lhs) < self.modificationDate(for: rhs) + } + } + + /// Path to use for launchd stdout/err. + static var launchdLogPath: String { + self.ensureLogDirExists() + return stdoutLog.path + } + + /// Path to use for the Gateway launchd job stdout/err. + static var launchdGatewayLogPath: String { + self.ensureLogDirExists() + return gatewayLog.path + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift new file mode 100644 index 00000000..7692887e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift @@ -0,0 +1,232 @@ +import Foundation +@_exported import Logging +import os +import OSLog + +typealias Logger = Logging.Logger + +enum AppLogSettings { + static let logLevelKey = appLogLevelKey + + static func logLevel() -> Logger.Level { + if let raw = UserDefaults.standard.string(forKey: self.logLevelKey), + let level = Logger.Level(rawValue: raw) + { + return level + } + return .info + } + + static func setLogLevel(_ level: Logger.Level) { + UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey) + } + + static func fileLoggingEnabled() -> Bool { + UserDefaults.standard.bool(forKey: debugFileLogEnabledKey) + } +} + +enum AppLogLevel: String, CaseIterable, Identifiable { + case trace + case debug + case info + case notice + case warning + case error + case critical + + static let `default`: AppLogLevel = .info + + var id: String { + self.rawValue + } + + var title: String { + switch self { + case .trace: "Trace" + case .debug: "Debug" + case .info: "Info" + case .notice: "Notice" + case .warning: "Warning" + case .error: "Error" + case .critical: "Critical" + } + } +} + +enum OpenClawLogging { + private static let labelSeparator = "::" + + private static let didBootstrap: Void = { + LoggingSystem.bootstrap { label in + let (subsystem, category) = Self.parseLabel(label) + let osHandler = OpenClawOSLogHandler(subsystem: subsystem, category: category) + let fileHandler = OpenClawFileLogHandler(label: label) + return MultiplexLogHandler([osHandler, fileHandler]) + } + }() + + static func bootstrapIfNeeded() { + _ = self.didBootstrap + } + + static func makeLabel(subsystem: String, category: String) -> String { + "\(subsystem)\(self.labelSeparator)\(category)" + } + + static func parseLabel(_ label: String) -> (String, String) { + guard let range = label.range(of: labelSeparator) else { + return ("ai.openclaw", label) + } + let subsystem = String(label[.. Logger.Metadata.Value? { + get { self.metadata[key] } + set { self.metadata[key] = newValue } + } + + func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt) + { + let merged = Self.mergeMetadata(self.metadata, metadata) + let rendered = Self.renderMessage(message, metadata: merged) + self.osLogger.log(level: Self.osLogType(for: level), "\(rendered, privacy: .public)") + } + + private static func osLogType(for level: Logger.Level) -> OSLogType { + switch level { + case .trace, .debug: + .debug + case .info, .notice: + .info + case .warning: + .default + case .error: + .error + case .critical: + .fault + } + } + + private static func mergeMetadata( + _ base: Logger.Metadata, + _ extra: Logger.Metadata?) -> Logger.Metadata + { + guard let extra else { return base } + return base.merging(extra, uniquingKeysWith: { _, new in new }) + } + + private static func renderMessage(_ message: Logger.Message, metadata: Logger.Metadata) -> String { + guard !metadata.isEmpty else { return message.description } + let meta = metadata + .sorted(by: { $0.key < $1.key }) + .map { "\($0.key)=\(self.stringify($0.value))" } + .joined(separator: " ") + return "\(message.description) [\(meta)]" + } + + private static func stringify(_ value: Logger.Metadata.Value) -> String { + switch value { + case let .string(text): + text + case let .stringConvertible(value): + String(describing: value) + case let .array(values): + "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]" + case let .dictionary(entries): + "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}" + } + } +} + +struct OpenClawFileLogHandler: LogHandler { + let label: String + var metadata: Logger.Metadata = [:] + + var logLevel: Logger.Level { + get { AppLogSettings.logLevel() } + set { AppLogSettings.setLogLevel(newValue) } + } + + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { self.metadata[key] } + set { self.metadata[key] = newValue } + } + + func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt) + { + guard AppLogSettings.fileLoggingEnabled() else { return } + let (subsystem, category) = OpenClawLogging.parseLabel(self.label) + var fields: [String: String] = [ + "subsystem": subsystem, + "category": category, + "level": level.rawValue, + "source": source, + "file": file, + "function": function, + "line": "\(line)", + ] + let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new }) + for (key, value) in merged { + fields["meta.\(key)"] = Self.stringify(value) + } + DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields) + } + + private static func stringify(_ value: Logger.Metadata.Value) -> String { + switch value { + case let .string(text): + text + case let .stringConvertible(value): + String(describing: value) + case let .array(values): + "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]" + case let .dictionary(entries): + "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}" + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuBar.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuBar.swift new file mode 100644 index 00000000..d7ab72ce --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -0,0 +1,474 @@ +import AppKit +import Darwin +import Foundation +import MenuBarExtraAccess +import Observation +import OSLog +import Security +import SwiftUI + +@main +struct OpenClawApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate + @State private var state: AppState + private static let logger = Logger(subsystem: "ai.openclaw", category: "app") + private let gatewayManager = GatewayProcessManager.shared + private let controlChannel = ControlChannel.shared + private let activityStore = WorkActivityStore.shared + private let connectivityCoordinator = GatewayConnectivityCoordinator.shared + @State private var statusItem: NSStatusItem? + @State private var isMenuPresented = false + @State private var isPanelVisible = false + @State private var tailscaleService = TailscaleService.shared + + @MainActor + private func updateStatusHighlight() { + self.statusItem?.button?.highlight(self.isPanelVisible) + } + + @MainActor + private func updateHoverHUDSuppression() { + HoverHUDController.shared.setSuppressed(self.isMenuPresented || self.isPanelVisible) + } + + init() { + OpenClawLogging.bootstrapIfNeeded() + + Self.applyAttachOnlyOverrideIfNeeded() + _state = State(initialValue: AppStateStore.shared) + } + + var body: some Scene { + MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: { + CritterStatusLabel( + isPaused: self.state.isPaused, + isSleeping: self.isGatewaySleeping, + isWorking: self.state.isWorking, + earBoostActive: self.state.earBoostActive, + blinkTick: self.state.blinkTick, + sendCelebrationTick: self.state.sendCelebrationTick, + gatewayStatus: self.gatewayManager.status, + animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping, + iconState: self.effectiveIconState) + } + .menuBarExtraStyle(.menu) + .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in + self.statusItem = item + MenuSessionsInjector.shared.install(into: item) + self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) + self.installStatusItemMouseHandler(for: item) + self.updateHoverHUDSuppression() + } + .onChange(of: self.state.isPaused) { _, paused in + self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping) + if self.state.connectionMode == .local { + self.gatewayManager.setActive(!paused) + } else { + self.gatewayManager.stop() + } + } + .onChange(of: self.controlChannel.state) { _, _ in + self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) + } + .onChange(of: self.gatewayManager.status) { _, _ in + self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) + } + .onChange(of: self.state.connectionMode) { _, mode in + Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) } + CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode") + } + + Settings { + SettingsRootView(state: self.state, updater: self.delegate.updaterController) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) + .environment(self.tailscaleService) + } + .defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + .windowResizability(.contentSize) + .onChange(of: self.isMenuPresented) { _, _ in + self.updateStatusHighlight() + self.updateHoverHUDSuppression() + } + } + + private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) { + self.statusItem?.button?.appearsDisabled = paused || sleeping + } + + private static func applyAttachOnlyOverrideIfNeeded() { + let args = CommandLine.arguments + guard args.contains("--attach-only") || args.contains("--no-launchd") else { return } + if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) { + Self.logger.error("attach-only flag failed: \(error, privacy: .public)") + return + } + Task { + _ = await GatewayLaunchAgentManager.set( + enabled: false, + bundlePath: Bundle.main.bundlePath, + port: GatewayEnvironment.gatewayPort()) + } + Self.logger.info("attach-only flag enabled") + } + + private var isGatewaySleeping: Bool { + if self.state.isPaused { return false } + switch self.state.connectionMode { + case .unconfigured: + return true + case .remote: + if case .connected = self.controlChannel.state { return false } + return true + case .local: + switch self.gatewayManager.status { + case .running, .starting, .attachedExisting: + if case .connected = self.controlChannel.state { return false } + return true + case .failed, .stopped: + return true + } + } + } + + @MainActor + private func installStatusItemMouseHandler(for item: NSStatusItem) { + guard let button = item.button else { return } + if button.subviews.contains(where: { $0 is StatusItemMouseHandlerView }) { return } + + WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in + self.isPanelVisible = visible + self.updateStatusHighlight() + self.updateHoverHUDSuppression() + } + CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in + self.state.canvasPanelVisible = visible + } + CanvasManager.shared.defaultAnchorProvider = { [self] in self.statusButtonScreenFrame() } + + let handler = StatusItemMouseHandlerView() + handler.translatesAutoresizingMaskIntoConstraints = false + handler.onLeftClick = { [self] in + HoverHUDController.shared.dismiss(reason: "statusItemClick") + self.toggleWebChatPanel() + } + handler.onRightClick = { [self] in + HoverHUDController.shared.dismiss(reason: "statusItemRightClick") + WebChatManager.shared.closePanel() + self.isMenuPresented = true + self.updateStatusHighlight() + } + handler.onHoverChanged = { [self] inside in + HoverHUDController.shared.statusItemHoverChanged( + inside: inside, + anchorProvider: { [self] in self.statusButtonScreenFrame() }) + } + + button.addSubview(handler) + NSLayoutConstraint.activate([ + handler.leadingAnchor.constraint(equalTo: button.leadingAnchor), + handler.trailingAnchor.constraint(equalTo: button.trailingAnchor), + handler.topAnchor.constraint(equalTo: button.topAnchor), + handler.bottomAnchor.constraint(equalTo: button.bottomAnchor), + ]) + } + + @MainActor + private func toggleWebChatPanel() { + HoverHUDController.shared.setSuppressed(true) + self.isMenuPresented = false + Task { @MainActor in + let sessionKey = await WebChatManager.shared.preferredSessionKey() + WebChatManager.shared.togglePanel( + sessionKey: sessionKey, + anchorProvider: { [self] in self.statusButtonScreenFrame() }) + } + } + + @MainActor + private func statusButtonScreenFrame() -> NSRect? { + guard let button = self.statusItem?.button, let window = button.window else { return nil } + let inWindow = button.convert(button.bounds, to: nil) + return window.convertToScreen(inWindow) + } + + private var effectiveIconState: IconState { + let selection = self.state.iconOverride + if selection == .system { + return self.activityStore.iconState + } + let overrideState = selection.toIconState() + switch overrideState { + case let .workingMain(kind): return .overridden(kind) + case let .workingOther(kind): return .overridden(kind) + case .idle: return .idle + case let .overridden(kind): return .overridden(kind) + } + } +} + +/// Transparent overlay that intercepts clicks without stealing MenuBarExtra ownership. +private final class StatusItemMouseHandlerView: NSView { + var onLeftClick: (() -> Void)? + var onRightClick: (() -> Void)? + var onHoverChanged: ((Bool) -> Void)? + private var tracking: NSTrackingArea? + + override func mouseDown(with event: NSEvent) { + if let onLeftClick { + onLeftClick() + } else { + super.mouseDown(with: event) + } + } + + override func rightMouseDown(with event: NSEvent) { + self.onRightClick?() + // Do not call super; menu will be driven by isMenuPresented binding. + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let tracking { + self.removeTrackingArea(tracking) + } + let options: NSTrackingArea.Options = [ + .mouseEnteredAndExited, + .activeAlways, + .inVisibleRect, + ] + let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil) + self.addTrackingArea(area) + self.tracking = area + } + + override func mouseEntered(with event: NSEvent) { + self.onHoverChanged?(true) + } + + override func mouseExited(with event: NSEvent) { + self.onHoverChanged?(false) + } +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private var state: AppState? + private let webChatAutoLogger = Logger(subsystem: "ai.openclaw", category: "Chat") + let updaterController: UpdaterProviding = makeUpdaterController() + + func application(_: NSApplication, open urls: [URL]) { + Task { @MainActor in + for url in urls { + await DeepLinkHandler.shared.handle(url: url) + } + } + } + + @MainActor + func applicationDidFinishLaunching(_ notification: Notification) { + if self.isDuplicateInstance() { + NSApp.terminate(nil) + return + } + self.state = AppStateStore.shared + AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false) + if let state { + Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) } + } + TerminationSignalWatcher.shared.start() + NodePairingApprovalPrompter.shared.start() + DevicePairingApprovalPrompter.shared.start() + ExecApprovalsPromptServer.shared.start() + ExecApprovalsGatewayPrompter.shared.start() + MacNodeModeCoordinator.shared.start() + VoiceWakeGlobalSettingsSync.shared.start() + Task { PresenceReporter.shared.start() } + Task { await HealthStore.shared.refresh(onDemand: true) } + Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) } + Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) } + self.scheduleFirstRunOnboardingIfNeeded() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch") + } + + // Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat). + if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") { + self.webChatAutoLogger.debug("Auto-opening chat via CLI flag") + Task { @MainActor in + let sessionKey = await WebChatManager.shared.preferredSessionKey() + WebChatManager.shared.show(sessionKey: sessionKey) + } + } + } + + func applicationWillTerminate(_ notification: Notification) { + PresenceReporter.shared.stop() + NodePairingApprovalPrompter.shared.stop() + DevicePairingApprovalPrompter.shared.stop() + ExecApprovalsPromptServer.shared.stop() + ExecApprovalsGatewayPrompter.shared.stop() + MacNodeModeCoordinator.shared.stop() + TerminationSignalWatcher.shared.stop() + VoiceWakeGlobalSettingsSync.shared.stop() + WebChatManager.shared.close() + WebChatManager.shared.resetTunnels() + Task { await RemoteTunnelManager.shared.stopAll() } + Task { await GatewayConnection.shared.shutdown() } + Task { await PeekabooBridgeHostCoordinator.shared.stop() } + } + + @MainActor + private func scheduleFirstRunOnboardingIfNeeded() { + let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey) + let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen + guard shouldShow else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + OnboardingController.shared.show() + } + } + + private func isDuplicateInstance() -> Bool { + guard let bundleID = Bundle.main.bundleIdentifier else { return false } + let running = NSWorkspace.shared.runningApplications.filter { $0.bundleIdentifier == bundleID } + return running.count > 1 + } +} + +// MARK: - Sparkle updater (disabled for unsigned/dev builds) + +@MainActor +protocol UpdaterProviding: AnyObject { + var automaticallyChecksForUpdates: Bool { get set } + var automaticallyDownloadsUpdates: Bool { get set } + var isAvailable: Bool { get } + var updateStatus: UpdateStatus { get } + func checkForUpdates(_ sender: Any?) +} + +/// No-op updater used for debug/dev runs to suppress Sparkle dialogs. +final class DisabledUpdaterController: UpdaterProviding { + var automaticallyChecksForUpdates: Bool = false + var automaticallyDownloadsUpdates: Bool = false + let isAvailable: Bool = false + let updateStatus = UpdateStatus() + func checkForUpdates(_: Any?) {} +} + +@MainActor +@Observable +final class UpdateStatus { + static let disabled = UpdateStatus() + var isUpdateReady: Bool + + init(isUpdateReady: Bool = false) { + self.isUpdateReady = isUpdateReady + } +} + +#if canImport(Sparkle) +import Sparkle + +@MainActor +final class SparkleUpdaterController: NSObject, UpdaterProviding { + private lazy var controller = SPUStandardUpdaterController( + startingUpdater: false, + updaterDelegate: self, + userDriverDelegate: nil) + let updateStatus = UpdateStatus() + + init(savedAutoUpdate: Bool) { + super.init() + let updater = self.controller.updater + updater.automaticallyChecksForUpdates = savedAutoUpdate + updater.automaticallyDownloadsUpdates = savedAutoUpdate + self.controller.startUpdater() + } + + var automaticallyChecksForUpdates: Bool { + get { self.controller.updater.automaticallyChecksForUpdates } + set { self.controller.updater.automaticallyChecksForUpdates = newValue } + } + + var automaticallyDownloadsUpdates: Bool { + get { self.controller.updater.automaticallyDownloadsUpdates } + set { self.controller.updater.automaticallyDownloadsUpdates = newValue } + } + + var isAvailable: Bool { + true + } + + func checkForUpdates(_ sender: Any?) { + self.controller.checkForUpdates(sender) + } + + func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { + self.updateStatus.isUpdateReady = true + } + + func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { + self.updateStatus.isUpdateReady = false + } + + func userDidCancelDownload(_ updater: SPUUpdater) { + self.updateStatus.isUpdateReady = false + } + + func updater( + _ updater: SPUUpdater, + userDidMakeChoice choice: SPUUserUpdateChoice, + forUpdate updateItem: SUAppcastItem, + state: SPUUserUpdateState) + { + switch choice { + case .install, .skip: + self.updateStatus.isUpdateReady = false + case .dismiss: + self.updateStatus.isUpdateReady = (state.stage == .downloaded) + @unknown default: + self.updateStatus.isUpdateReady = false + } + } +} + +extension SparkleUpdaterController: SPUUpdaterDelegate {} + +private func isDeveloperIDSigned(bundleURL: URL) -> Bool { + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess, + let code = staticCode + else { return false } + + var infoCF: CFDictionary? + guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess, + let info = infoCF as? [String: Any], + let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate], + let leaf = certs.first + else { + return false + } + + if let summary = SecCertificateCopySubjectSummary(leaf) as String? { + return summary.hasPrefix("Developer ID Application:") + } + return false +} + +@MainActor +private func makeUpdaterController() -> UpdaterProviding { + let bundleURL = Bundle.main.bundleURL + let isBundledApp = bundleURL.pathExtension == "app" + guard isBundledApp, isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController() } + + let defaults = UserDefaults.standard + let autoUpdateKey = "autoUpdateEnabled" + // Default to true; honor the user's last choice otherwise. + let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true + return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate) +} +#else +@MainActor +private func makeUpdaterController() -> UpdaterProviding { + DisabledUpdaterController() +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuContentView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuContentView.swift new file mode 100644 index 00000000..3416d23f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuContentView.swift @@ -0,0 +1,596 @@ +import AppKit +import AVFoundation +import Foundation +import Observation +import SwiftUI + +/// Menu contents for the OpenClaw menu bar extra. +struct MenuContent: View { + @Bindable var state: AppState + let updater: UpdaterProviding? + @Bindable private var updateStatus: UpdateStatus + private let gatewayManager = GatewayProcessManager.shared + private let healthStore = HealthStore.shared + private let heartbeatStore = HeartbeatStore.shared + private let controlChannel = ControlChannel.shared + private let activityStore = WorkActivityStore.shared + @Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared + @Bindable private var devicePairingPrompter = DevicePairingApprovalPrompter.shared + @Environment(\.openSettings) private var openSettings + @State private var availableMics: [AudioInputDevice] = [] + @State private var loadingMics = false + @State private var micObserver = AudioInputDeviceObserver() + @State private var micRefreshTask: Task? + @State private var browserControlEnabled = true + @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false + @AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue + @AppStorage(debugFileLogEnabledKey) private var appFileLoggingEnabled: Bool = false + + init(state: AppState, updater: UpdaterProviding?) { + self._state = Bindable(wrappedValue: state) + self.updater = updater + self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled) + } + + private var execApprovalModeBinding: Binding { + Binding( + get: { self.state.execApprovalMode }, + set: { self.state.execApprovalMode = $0 }) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: self.activeBinding) { + VStack(alignment: .leading, spacing: 2) { + Text(self.connectionLabel) + self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color) + if self.pairingPrompter.pendingCount > 0 { + let repairCount = self.pairingPrompter.pendingRepairCount + let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : "" + self.statusLine( + label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))\(repairSuffix)", + color: .orange) + } + if self.devicePairingPrompter.pendingCount > 0 { + let repairCount = self.devicePairingPrompter.pendingRepairCount + let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : "" + self.statusLine( + label: "Device pairing pending (\(self.devicePairingPrompter.pendingCount))\(repairSuffix)", + color: .orange) + } + } + } + .disabled(self.state.connectionMode == .unconfigured) + + Divider() + Toggle(isOn: self.heartbeatsBinding) { + HStack(spacing: 8) { + Label("Send Heartbeats", systemImage: "waveform.path.ecg") + Spacer(minLength: 0) + self.statusLine(label: self.heartbeatStatus.label, color: self.heartbeatStatus.color) + } + } + Toggle( + isOn: Binding( + get: { self.browserControlEnabled }, + set: { enabled in + self.browserControlEnabled = enabled + Task { await self.saveBrowserControlEnabled(enabled) } + })) { + Label("Browser Control", systemImage: "globe") + } + Toggle(isOn: self.$cameraEnabled) { + Label("Allow Camera", systemImage: "camera") + } + Picker(selection: self.execApprovalModeBinding) { + ForEach(ExecApprovalQuickMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } label: { + Label("Exec Approvals", systemImage: "terminal") + } + Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) { + Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis") + } + .onChange(of: self.state.canvasEnabled) { _, enabled in + if !enabled { + CanvasManager.shared.hideAll() + } + } + Toggle(isOn: self.voiceWakeBinding) { + Label("Voice Wake", systemImage: "mic.fill") + } + .disabled(!voiceWakeSupported) + .opacity(voiceWakeSupported ? 1 : 0.5) + if self.showVoiceWakeMicPicker { + self.voiceWakeMicMenu + } + Divider() + Button { + Task { @MainActor in + await self.openDashboard() + } + } label: { + Label("Open Dashboard", systemImage: "gauge") + } + Button { + Task { @MainActor in + let sessionKey = await WebChatManager.shared.preferredSessionKey() + WebChatManager.shared.show(sessionKey: sessionKey) + } + } label: { + Label("Open Chat", systemImage: "bubble.left.and.bubble.right") + } + if self.state.canvasEnabled { + Button { + Task { @MainActor in + if self.state.canvasPanelVisible { + CanvasManager.shared.hideAll() + } else { + let sessionKey = await GatewayConnection.shared.mainSessionKey() + // Don't force a navigation on re-open: preserve the current web view state. + _ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil) + } + } + } label: { + Label( + self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas", + systemImage: "rectangle.inset.filled.on.rectangle") + } + } + Button { + Task { await self.state.setTalkEnabled(!self.state.talkEnabled) } + } label: { + Label(self.state.talkEnabled ? "Stop Talk Mode" : "Talk Mode", systemImage: "waveform.circle.fill") + } + .disabled(!voiceWakeSupported) + .opacity(voiceWakeSupported ? 1 : 0.5) + Divider() + Button("Settings…") { self.open(tab: .general) } + .keyboardShortcut(",", modifiers: [.command]) + self.debugMenu + Button("About OpenClaw") { self.open(tab: .about) } + if let updater, updater.isAvailable, self.updateStatus.isUpdateReady { + Button("Update ready, restart now?") { updater.checkForUpdates(nil) } + } + Button("Quit") { NSApplication.shared.terminate(nil) } + } + .task(id: self.state.swabbleEnabled) { + if self.state.swabbleEnabled { + await self.loadMicrophones(force: true) + } + } + .task { + VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled) + } + .onChange(of: self.state.voicePushToTalkEnabled) { _, enabled in + VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && enabled) + } + .task(id: self.state.connectionMode) { + await self.loadBrowserControlEnabled() + } + .onAppear { + self.startMicObserver() + } + .onDisappear { + self.micRefreshTask?.cancel() + self.micRefreshTask = nil + self.micObserver.stop() + } + .task { @MainActor in + SettingsWindowOpener.shared.register(openSettings: self.openSettings) + } + } + + private var connectionLabel: String { + switch self.state.connectionMode { + case .unconfigured: + "OpenClaw Not Configured" + case .remote: + "Remote OpenClaw Active" + case .local: + "OpenClaw Active" + } + } + + private func loadBrowserControlEnabled() async { + let root = await ConfigStore.load() + let browser = root["browser"] as? [String: Any] + let enabled = browser?["enabled"] as? Bool ?? true + await MainActor.run { self.browserControlEnabled = enabled } + } + + private func saveBrowserControlEnabled(_ enabled: Bool) async { + let (success, _) = await MenuContent.buildAndSaveBrowserEnabled(enabled) + + if !success { + await self.loadBrowserControlEnabled() + } + } + + @MainActor + private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool, ()) { + var root = await ConfigStore.load() + var browser = root["browser"] as? [String: Any] ?? [:] + browser["enabled"] = enabled + root["browser"] = browser + do { + try await ConfigStore.save(root) + return (true, ()) + } catch { + return (false, ()) + } + } + + @ViewBuilder + private var debugMenu: some View { + if self.state.debugPaneEnabled { + Menu("Debug") { + Button { + DebugActions.openConfigFolder() + } label: { + Label("Open Config Folder", systemImage: "folder") + } + Button { + Task { await DebugActions.runHealthCheckNow() } + } label: { + Label("Run Health Check Now", systemImage: "stethoscope") + } + Button { + Task { _ = await DebugActions.sendTestHeartbeat() } + } label: { + Label("Send Test Heartbeat", systemImage: "waveform.path.ecg") + } + if self.state.connectionMode == .remote { + Button { + Task { @MainActor in + let result = await DebugActions.resetGatewayTunnel() + self.presentDebugResult(result, title: "Remote Tunnel") + } + } label: { + Label("Reset Remote Tunnel", systemImage: "arrow.triangle.2.circlepath") + } + } + Button { + Task { _ = await DebugActions.toggleVerboseLoggingMain() } + } label: { + Label( + DebugActions.verboseLoggingEnabledMain + ? "Verbose Logging (Main): On" + : "Verbose Logging (Main): Off", + systemImage: "text.alignleft") + } + Menu { + Picker("Verbosity", selection: self.$appLogLevelRaw) { + ForEach(AppLogLevel.allCases) { level in + Text(level.title).tag(level.rawValue) + } + } + Toggle(isOn: self.$appFileLoggingEnabled) { + Label( + self.appFileLoggingEnabled + ? "File Logging: On" + : "File Logging: Off", + systemImage: "doc.text.magnifyingglass") + } + } label: { + Label("App Logging", systemImage: "doc.text") + } + Button { + DebugActions.openSessionStore() + } label: { + Label("Open Session Store", systemImage: "externaldrive") + } + Divider() + Button { + DebugActions.openAgentEventsWindow() + } label: { + Label("Open Agent Events…", systemImage: "bolt.horizontal.circle") + } + Button { + DebugActions.openLog() + } label: { + Label("Open Log", systemImage: "doc.text.magnifyingglass") + } + Button { + Task { _ = await DebugActions.sendDebugVoice() } + } label: { + Label("Send Debug Voice Text", systemImage: "waveform.circle") + } + Button { + Task { await DebugActions.sendTestNotification() } + } label: { + Label("Send Test Notification", systemImage: "bell") + } + Divider() + if self.state.connectionMode == .local { + Button { + DebugActions.restartGateway() + } label: { + Label("Restart Gateway", systemImage: "arrow.clockwise") + } + } + Button { + DebugActions.restartOnboarding() + } label: { + Label("Restart Onboarding", systemImage: "arrow.counterclockwise") + } + Button { + DebugActions.restartApp() + } label: { + Label("Restart App", systemImage: "arrow.triangle.2.circlepath") + } + } + } + } + + private func open(tab: SettingsTab) { + SettingsTabRouter.request(tab) + NSApp.activate(ignoringOtherApps: true) + self.openSettings() + DispatchQueue.main.async { + NotificationCenter.default.post(name: .openclawSelectSettingsTab, object: tab) + } + } + + @MainActor + private func openDashboard() async { + do { + let config = try await GatewayEndpointStore.shared.requireConfig() + let url = try GatewayEndpointStore.dashboardURL(for: config, mode: self.state.connectionMode) + NSWorkspace.shared.open(url) + } catch { + let alert = NSAlert() + alert.messageText = "Dashboard unavailable" + alert.informativeText = error.localizedDescription + alert.runModal() + } + } + + private var healthStatus: (label: String, color: Color) { + if let activity = self.activityStore.current { + let color: Color = activity.role == .main ? .accentColor : .gray + let roleLabel = activity.role == .main ? "Main" : "Other" + let text = "\(roleLabel) · \(activity.label)" + return (text, color) + } + + let health = self.healthStore.state + let isRefreshing = self.healthStore.isRefreshing + let lastAge = self.healthStore.lastSuccess.map { age(from: $0) } + + if isRefreshing { + return ("Health check running…", health.tint) + } + + switch health { + case .ok: + let ageText = lastAge.map { " · checked \($0)" } ?? "" + return ("Health ok\(ageText)", .green) + case .linkingNeeded: + return ("Health: login required", .red) + case let .degraded(reason): + let detail = HealthStore.shared.degradedSummary ?? reason + let ageText = lastAge.map { " · checked \($0)" } ?? "" + return ("\(detail)\(ageText)", .orange) + case .unknown: + return ("Health pending", .secondary) + } + } + + private var heartbeatStatus: (label: String, color: Color) { + if case .degraded = self.controlChannel.state { + return ("Control channel disconnected", .red) + } else if let evt = self.heartbeatStore.lastEvent { + let ageText = age(from: Date(timeIntervalSince1970: evt.ts / 1000)) + switch evt.status { + case "sent": + return ("Last heartbeat sent · \(ageText)", .blue) + case "ok-empty", "ok-token": + return ("Heartbeat ok · \(ageText)", .green) + case "skipped": + return ("Heartbeat skipped · \(ageText)", .secondary) + case "failed": + return ("Heartbeat failed · \(ageText)", .red) + default: + return ("Heartbeat · \(ageText)", .secondary) + } + } else { + return ("No heartbeat yet", .secondary) + } + } + + private func statusLine(label: String, color: Color) -> some View { + HStack(spacing: 6) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + } + .padding(.top, 2) + } + + private var activeBinding: Binding { + Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 }) + } + + private var heartbeatsBinding: Binding { + Binding(get: { self.state.heartbeatsEnabled }, set: { self.state.heartbeatsEnabled = $0 }) + } + + private var voiceWakeBinding: Binding { + Binding( + get: { self.state.swabbleEnabled }, + set: { newValue in + Task { await self.state.setVoiceWakeEnabled(newValue) } + }) + } + + private var showVoiceWakeMicPicker: Bool { + voiceWakeSupported && self.state.swabbleEnabled + } + + private var voiceWakeMicMenu: some View { + Menu { + self.microphoneMenuItems + + if self.loadingMics { + Divider() + Label("Refreshing microphones…", systemImage: "arrow.triangle.2.circlepath") + .labelStyle(.titleOnly) + .foregroundStyle(.secondary) + .disabled(true) + } + } label: { + HStack { + Text("Microphone") + Spacer() + Text(self.selectedMicLabel) + .foregroundStyle(.secondary) + } + } + .task { await self.loadMicrophones() } + } + + private var selectedMicLabel: String { + if self.state.voiceWakeMicID.isEmpty { return self.defaultMicLabel } + if let match = self.availableMics.first(where: { $0.uid == self.state.voiceWakeMicID }) { + return match.name + } + if !self.state.voiceWakeMicName.isEmpty { return self.state.voiceWakeMicName } + return "Unavailable" + } + + private var microphoneMenuItems: some View { + Group { + if self.isSelectedMicUnavailable { + Label("Disconnected (using System default)", systemImage: "exclamationmark.triangle") + .labelStyle(.titleAndIcon) + .foregroundStyle(.secondary) + .disabled(true) + Divider() + } + Button { + self.state.voiceWakeMicID = "" + self.state.voiceWakeMicName = "" + } label: { + Label(self.defaultMicLabel, systemImage: self.state.voiceWakeMicID.isEmpty ? "checkmark" : "") + .labelStyle(.titleAndIcon) + } + .buttonStyle(.plain) + + ForEach(self.availableMics) { mic in + Button { + self.state.voiceWakeMicID = mic.uid + self.state.voiceWakeMicName = mic.name + } label: { + Label(mic.name, systemImage: self.state.voiceWakeMicID == mic.uid ? "checkmark" : "") + .labelStyle(.titleAndIcon) + } + .buttonStyle(.plain) + } + } + } + + private var isSelectedMicUnavailable: Bool { + let selected = self.state.voiceWakeMicID + guard !selected.isEmpty else { return false } + return !self.availableMics.contains(where: { $0.uid == selected }) + } + + private var defaultMicLabel: String { + if let host = Host.current().localizedName, !host.isEmpty { + return "Auto-detect (\(host))" + } + return "System default" + } + + @MainActor + private func presentDebugResult(_ result: Result, title: String) { + let alert = NSAlert() + alert.messageText = title + switch result { + case let .success(message): + alert.informativeText = message + alert.alertStyle = .informational + case let .failure(error): + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + } + alert.runModal() + } + + @MainActor + private func loadMicrophones(force: Bool = false) async { + guard self.showVoiceWakeMicPicker else { + self.availableMics = [] + self.loadingMics = false + return + } + if !force, !self.availableMics.isEmpty { return } + self.loadingMics = true + let discovery = AVCaptureDevice.DiscoverySession( + deviceTypes: [.external, .microphone], + mediaType: .audio, + position: .unspecified) + let connectedDevices = discovery.devices.filter(\.isConnected) + self.availableMics = connectedDevices + .sorted { lhs, rhs in + lhs.localizedName.localizedCaseInsensitiveCompare(rhs.localizedName) == .orderedAscending + } + .map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) } + self.availableMics = self.filterAliveInputs(self.availableMics) + self.updateSelectedMicName() + self.loadingMics = false + } + + private func startMicObserver() { + self.micObserver.start { + Task { @MainActor in + self.scheduleMicRefresh() + } + } + } + + @MainActor + private func scheduleMicRefresh() { + self.micRefreshTask?.cancel() + self.micRefreshTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await self.loadMicrophones(force: true) + } + } + + private func filterAliveInputs(_ inputs: [AudioInputDevice]) -> [AudioInputDevice] { + let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs() + guard !aliveUIDs.isEmpty else { return inputs } + return inputs.filter { aliveUIDs.contains($0.uid) } + } + + @MainActor + private func updateSelectedMicName() { + let selected = self.state.voiceWakeMicID + if selected.isEmpty { + self.state.voiceWakeMicName = "" + return + } + if let match = self.availableMics.first(where: { $0.uid == selected }) { + self.state.voiceWakeMicName = match.name + } + } + + private struct AudioInputDevice: Identifiable, Equatable { + let uid: String + let name: String + var id: String { + self.uid + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuContextCardInjector.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuContextCardInjector.swift new file mode 100644 index 00000000..f469ca34 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuContextCardInjector.swift @@ -0,0 +1,228 @@ +import AppKit +import SwiftUI + +@MainActor +final class MenuContextCardInjector: NSObject, NSMenuDelegate { + static let shared = MenuContextCardInjector() + + private let tag = 9_415_227 + private let fallbackCardWidth: CGFloat = 320 + private var lastKnownMenuWidth: CGFloat? + private weak var originalDelegate: NSMenuDelegate? + private var loadTask: Task? + private var warmTask: Task? + private var cachedRows: [SessionRow] = [] + private var cacheErrorText: String? + private var cacheUpdatedAt: Date? + private let activeWindowSeconds: TimeInterval = 24 * 60 * 60 + private let refreshIntervalSeconds: TimeInterval = 15 + private var isMenuOpen = false + + func install(into statusItem: NSStatusItem) { + // SwiftUI owns the menu, but we can inject a custom NSMenuItem.view right before display. + guard let menu = statusItem.menu else { return } + // Preserve SwiftUI's internal NSMenuDelegate, otherwise it may stop populating menu items. + if menu.delegate !== self { + self.originalDelegate = menu.delegate + menu.delegate = self + } + + if self.warmTask == nil { + self.warmTask = Task { await self.refreshCache(force: true) } + } + } + + func menuWillOpen(_ menu: NSMenu) { + self.originalDelegate?.menuWillOpen?(menu) + self.isMenuOpen = true + + // Remove any previous injected card items. + for item in menu.items where item.tag == self.tag { + menu.removeItem(item) + } + + guard let insertIndex = self.findInsertIndex(in: menu) else { return } + + self.loadTask?.cancel() + + let initialRows = self.cachedRows + let initialIsLoading = initialRows.isEmpty + let initialStatusText = initialIsLoading ? self.cacheErrorText : nil + let initialWidth = self.initialCardWidth(for: menu) + + let initial = AnyView(ContextMenuCardView( + rows: initialRows, + statusText: initialStatusText, + isLoading: initialIsLoading)) + + let hosting = NSHostingView(rootView: initial) + hosting.frame.size.width = max(1, initialWidth) + let size = hosting.fittingSize + hosting.frame = NSRect( + origin: .zero, + size: NSSize(width: initialWidth, height: size.height)) + + let item = NSMenuItem() + item.tag = self.tag + item.view = hosting + item.isEnabled = false + + menu.insertItem(item, at: insertIndex) + + // Capture the menu window width for next open, but do not mutate widths while the menu is visible. + DispatchQueue.main.async { [weak self, weak hosting] in + guard let self, let hosting else { return } + self.captureMenuWidthIfAvailable(for: menu, hosting: hosting) + } + + if initialIsLoading { + self.loadTask = Task { [weak hosting] in + await self.refreshCache(force: true) + guard let hosting else { return } + let view = self.cachedView() + await MainActor.run { + hosting.rootView = view + hosting.invalidateIntrinsicContentSize() + self.captureMenuWidthIfAvailable(for: menu, hosting: hosting) + hosting.frame.size.width = max(1, initialWidth) + let size = hosting.fittingSize + hosting.frame.size.height = size.height + } + } + } else { + // Keep the menu stable while it's open; refresh in the background for next open. + self.loadTask = Task { await self.refreshCache(force: false) } + } + } + + func menuDidClose(_ menu: NSMenu) { + self.originalDelegate?.menuDidClose?(menu) + self.isMenuOpen = false + self.loadTask?.cancel() + } + + func menuNeedsUpdate(_ menu: NSMenu) { + self.originalDelegate?.menuNeedsUpdate?(menu) + } + + func confinementRect(for menu: NSMenu, on screen: NSScreen?) -> NSRect { + if let rect = self.originalDelegate?.confinementRect?(for: menu, on: screen) { + return rect + } + return NSRect.zero + } + + private func refreshCache(force: Bool) async { + if !force, let cacheUpdatedAt, Date().timeIntervalSince(cacheUpdatedAt) < self.refreshIntervalSeconds { + return + } + + do { + let rows = try await self.loadCurrentRows() + self.cachedRows = rows + self.cacheErrorText = nil + self.cacheUpdatedAt = Date() + } catch { + if self.cachedRows.isEmpty { + let raw = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + self.cacheErrorText = "Could not load sessions" + } else { + // Keep the menu readable: one line, short. + let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed + self.cacheErrorText = firstLine.count > 90 ? "\(firstLine.prefix(87))…" : firstLine + } + } + self.cacheUpdatedAt = Date() + } + } + + private func cachedView() -> AnyView { + let rows = self.cachedRows + let isLoading = rows.isEmpty && self.cacheErrorText == nil + return AnyView(ContextMenuCardView(rows: rows, statusText: self.cacheErrorText, isLoading: isLoading)) + } + + private func loadCurrentRows() async throws -> [SessionRow] { + let snapshot = try await SessionLoader.loadSnapshot() + let loaded = snapshot.rows + let now = Date() + let current = loaded.filter { row in + if row.key == "main" { return true } + guard let updatedAt = row.updatedAt else { return false } + return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds + } + + return current.sorted { lhs, rhs in + if lhs.key == "main" { return true } + if rhs.key == "main" { return false } + return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) + } + } + + private func findInsertIndex(in menu: NSMenu) -> Int? { + // Prefer inserting before the first separator (so the card sits right below the Active toggle). + if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { + // SwiftUI menus typically include a separator right after the first toggle; insert before it so the + // separator appears below the context card. + if let sepIdx = menu.items[..= 1 { return 1 } + return menu.items.count + } + + private func initialCardWidth(for menu: NSMenu) -> CGFloat { + let widthCandidates: [CGFloat] = [ + menu.minimumWidth, + self.lastKnownMenuWidth ?? 0, + self.fallbackCardWidth, + ] + let resolved = widthCandidates.max() ?? self.fallbackCardWidth + return max(300, resolved) + } + + private func captureMenuWidthIfAvailable(for menu: NSMenu, hosting: NSHostingView) { + let targetWidth: CGFloat? = { + if let contentWidth = hosting.window?.contentView?.bounds.width, contentWidth > 0 { return contentWidth } + if let superWidth = hosting.superview?.bounds.width, superWidth > 0 { return superWidth } + let minimumWidth = menu.minimumWidth + if minimumWidth > 0 { return minimumWidth } + return nil + }() + + guard let targetWidth else { return } + self.lastKnownMenuWidth = max(300, targetWidth) + } +} + +#if DEBUG +extension MenuContextCardInjector { + func _testSetCache(rows: [SessionRow], errorText: String?, updatedAt: Date?) { + self.cachedRows = rows + self.cacheErrorText = errorText + self.cacheUpdatedAt = updatedAt + } + + func _testFindInsertIndex(in menu: NSMenu) -> Int? { + self.findInsertIndex(in: menu) + } + + func _testInitialCardWidth(for menu: NSMenu) -> CGFloat { + self.initialCardWidth(for: menu) + } + + func _testCachedView() -> AnyView { + self.cachedView() + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift new file mode 100644 index 00000000..71079469 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift @@ -0,0 +1,104 @@ +import AppKit +import SwiftUI + +final class HighlightedMenuItemHostView: NSView { + private var baseView: AnyView + private let hosting: NSHostingView + private var targetWidth: CGFloat + private var tracking: NSTrackingArea? + private var hovered = false { + didSet { self.updateHighlight() } + } + + init(rootView: AnyView, width: CGFloat) { + self.baseView = rootView + self.hosting = NSHostingView(rootView: AnyView(rootView.environment(\.menuItemHighlighted, false))) + self.targetWidth = max(1, width) + super.init(frame: .zero) + + self.addSubview(self.hosting) + self.hosting.autoresizingMask = [.width, .height] + self.updateSizing() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + let size = self.hosting.fittingSize + return NSSize(width: self.targetWidth, height: size.height) + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let tracking { + self.removeTrackingArea(tracking) + } + let options: NSTrackingArea.Options = [ + .mouseEnteredAndExited, + .activeAlways, + .inVisibleRect, + ] + let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil) + self.addTrackingArea(area) + self.tracking = area + } + + override func mouseEntered(with event: NSEvent) { + _ = event + self.hovered = true + } + + override func mouseExited(with event: NSEvent) { + _ = event + self.hovered = false + } + + override func layout() { + super.layout() + self.hosting.frame = self.bounds + } + + override func draw(_ dirtyRect: NSRect) { + if self.hovered { + NSColor.selectedContentBackgroundColor.setFill() + self.bounds.fill() + } + super.draw(dirtyRect) + } + + func update(rootView: AnyView, width: CGFloat) { + self.baseView = rootView + self.targetWidth = max(1, width) + self.updateHighlight() + } + + private func updateHighlight() { + self.hosting.rootView = AnyView(self.baseView.environment(\.menuItemHighlighted, self.hovered)) + self.updateSizing() + self.needsDisplay = true + } + + private func updateSizing() { + let width = max(1, self.targetWidth) + self.hosting.frame.size.width = width + let size = self.hosting.fittingSize + self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + self.invalidateIntrinsicContentSize() + } +} + +struct MenuHostedHighlightedItem: NSViewRepresentable { + let width: CGFloat + let rootView: AnyView + + func makeNSView(context _: Context) -> HighlightedMenuItemHostView { + HighlightedMenuItemHostView(rootView: self.rootView, width: self.width) + } + + func updateNSView(_ nsView: HighlightedMenuItemHostView, context _: Context) { + nsView.update(rootView: self.rootView, width: self.width) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuHostedItem.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuHostedItem.swift new file mode 100644 index 00000000..c5a2b73c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuHostedItem.swift @@ -0,0 +1,29 @@ +import AppKit +import SwiftUI + +/// Hosts arbitrary SwiftUI content as an AppKit view so it can be embedded in a native `NSMenuItem.view`. +/// +/// SwiftUI `MenuBarExtraStyle.menu` aggressively simplifies many view hierarchies into a title + image. +/// Wrapping the content in an `NSViewRepresentable` forces AppKit-backed menu item rendering. +struct MenuHostedItem: NSViewRepresentable { + let width: CGFloat + let rootView: AnyView + + func makeNSView(context _: Context) -> NSHostingView { + let hosting = NSHostingView(rootView: self.rootView) + self.applySizing(to: hosting) + return hosting + } + + func updateNSView(_ nsView: NSHostingView, context _: Context) { + nsView.rootView = self.rootView + self.applySizing(to: nsView) + } + + private func applySizing(to hosting: NSHostingView) { + let width = max(1, self.width) + hosting.frame.size.width = width + let fitting = hosting.fittingSize + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: fitting.height)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift new file mode 100644 index 00000000..e96cea53 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct MenuSessionsHeaderView: View { + let count: Int + let statusText: String? + + private let paddingTop: CGFloat = 8 + private let paddingBottom: CGFloat = 6 + private let paddingTrailing: CGFloat = 10 + private let paddingLeading: CGFloat = 20 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text("Context") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 10) + Text(self.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let statusText, !statusText.isEmpty { + Text(statusText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + .padding(.top, self.paddingTop) + .padding(.bottom, self.paddingBottom) + .padding(.leading, self.paddingLeading) + .padding(.trailing, self.paddingTrailing) + .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) + .transaction { txn in txn.animation = nil } + } + + private var subtitle: String { + if self.count == 1 { return "1 session · 24h" } + return "\(self.count) sessions · 24h" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift new file mode 100644 index 00000000..eb6271d0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift @@ -0,0 +1,1240 @@ +import AppKit +import Foundation +import Observation +import SwiftUI + +@MainActor +final class MenuSessionsInjector: NSObject, NSMenuDelegate { + static let shared = MenuSessionsInjector() + + private let tag = 9_415_557 + private let nodesTag = 9_415_558 + private let fallbackWidth: CGFloat = 320 + private let activeWindowSeconds: TimeInterval = 24 * 60 * 60 + + private weak var originalDelegate: NSMenuDelegate? + private weak var statusItem: NSStatusItem? + private var loadTask: Task? + private var nodesLoadTask: Task? + private var previewTasks: [Task] = [] + private var isMenuOpen = false + private var lastKnownMenuWidth: CGFloat? + private var menuOpenWidth: CGFloat? + private var isObservingControlChannel = false + + private var cachedSnapshot: SessionStoreSnapshot? + private var cachedErrorText: String? + private var cacheUpdatedAt: Date? + private let refreshIntervalSeconds: TimeInterval = 12 + private var cachedUsageSummary: GatewayUsageSummary? + private var cachedUsageErrorText: String? + private var usageCacheUpdatedAt: Date? + private let usageRefreshIntervalSeconds: TimeInterval = 30 + private var cachedCostSummary: GatewayCostUsageSummary? + private var cachedCostErrorText: String? + private var costCacheUpdatedAt: Date? + private let costRefreshIntervalSeconds: TimeInterval = 45 + private let nodesStore = NodesStore.shared + #if DEBUG + private var testControlChannelConnected: Bool? + #endif + + func install(into statusItem: NSStatusItem) { + self.statusItem = statusItem + guard let menu = statusItem.menu else { return } + + // Preserve SwiftUI's internal NSMenuDelegate, otherwise it may stop populating menu items. + if menu.delegate !== self { + self.originalDelegate = menu.delegate + menu.delegate = self + } + + if self.loadTask == nil { + self.loadTask = Task { await self.refreshCache(force: true) } + } + + self.startControlChannelObservation() + self.nodesStore.start() + } + + func menuWillOpen(_ menu: NSMenu) { + self.originalDelegate?.menuWillOpen?(menu) + self.isMenuOpen = true + self.menuOpenWidth = self.currentMenuWidth(for: menu) + + self.inject(into: menu) + self.injectNodes(into: menu) + + // Refresh in background for the next open; keep width stable while open. + self.loadTask?.cancel() + let forceRefresh = self.cachedSnapshot == nil || self.cachedErrorText != nil + self.loadTask = Task { [weak self] in + guard let self else { return } + await self.refreshCache(force: forceRefresh) + await self.refreshUsageCache(force: forceRefresh) + await self.refreshCostUsageCache(force: forceRefresh) + await MainActor.run { + guard self.isMenuOpen else { return } + self.inject(into: menu) + self.injectNodes(into: menu) + } + } + + self.nodesLoadTask?.cancel() + self.nodesLoadTask = Task { [weak self] in + guard let self else { return } + await self.nodesStore.refresh() + await MainActor.run { + guard self.isMenuOpen else { return } + self.injectNodes(into: menu) + } + } + } + + func menuDidClose(_ menu: NSMenu) { + self.originalDelegate?.menuDidClose?(menu) + self.isMenuOpen = false + self.menuOpenWidth = nil + self.loadTask?.cancel() + self.nodesLoadTask?.cancel() + self.cancelPreviewTasks() + } + + private func startControlChannelObservation() { + guard !self.isObservingControlChannel else { return } + self.isObservingControlChannel = true + self.observeControlChannelState() + } + + private func observeControlChannelState() { + withObservationTracking { + _ = ControlChannel.shared.state + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.handleControlChannelStateChange() + self.observeControlChannelState() + } + } + } + + private func handleControlChannelStateChange() { + guard self.isMenuOpen, let menu = self.statusItem?.menu else { return } + self.loadTask?.cancel() + self.loadTask = Task { [weak self, weak menu] in + guard let self, let menu else { return } + await self.refreshCache(force: true) + await self.refreshUsageCache(force: true) + await self.refreshCostUsageCache(force: true) + await MainActor.run { + guard self.isMenuOpen else { return } + self.inject(into: menu) + self.injectNodes(into: menu) + } + } + + self.nodesLoadTask?.cancel() + self.nodesLoadTask = Task { [weak self, weak menu] in + guard let self, let menu else { return } + await self.nodesStore.refresh() + await MainActor.run { + guard self.isMenuOpen else { return } + self.injectNodes(into: menu) + } + } + } + + func menuNeedsUpdate(_ menu: NSMenu) { + self.originalDelegate?.menuNeedsUpdate?(menu) + } + + func confinementRect(for menu: NSMenu, on screen: NSScreen?) -> NSRect { + if let rect = self.originalDelegate?.confinementRect?(for: menu, on: screen) { + return rect + } + return NSRect.zero + } +} + +extension MenuSessionsInjector { + // MARK: - Injection + + private var mainSessionKey: String { + WorkActivityStore.shared.mainSessionKey + } + + private func inject(into menu: NSMenu) { + self.cancelPreviewTasks() + // Remove any previous injected items. + for item in menu.items where item.tag == self.tag { + menu.removeItem(item) + } + + guard let insertIndex = self.findInsertIndex(in: menu) else { return } + let width = self.initialWidth(for: menu) + let isConnected = self.isControlChannelConnected + let channelState = ControlChannel.shared.state + + var cursor = insertIndex + var headerView: NSView? + + if let snapshot = self.cachedSnapshot { + let now = Date() + let mainKey = self.mainSessionKey + let rows = snapshot.rows.filter { row in + if row.key == "main", mainKey != "main" { return false } + if row.key == mainKey { return true } + guard let updatedAt = row.updatedAt else { return false } + return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds + }.sorted { lhs, rhs in + if lhs.key == mainKey { return true } + if rhs.key == mainKey { return false } + return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) + } + if !rows.isEmpty { + let previewKeys = rows.prefix(20).map(\.key) + let task = Task { + await SessionMenuPreviewLoader.prewarm(sessionKeys: previewKeys, maxItems: 10) + } + self.previewTasks.append(task) + } + + let headerItem = NSMenuItem() + headerItem.tag = self.tag + headerItem.isEnabled = false + let statusText = self + .cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState)) + let hosted = self.makeHostedView( + rootView: AnyView(MenuSessionsHeaderView( + count: rows.count, + statusText: statusText)), + width: width, + highlighted: false) + headerItem.view = hosted + headerView = hosted + menu.insertItem(headerItem, at: cursor) + cursor += 1 + + if rows.isEmpty { + menu.insertItem( + self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width), + at: cursor) + cursor += 1 + } else { + for row in rows { + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = true + item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath) + item.view = self.makeHostedView( + rootView: AnyView(SessionMenuLabelView(row: row, width: width)), + width: width, + highlighted: true) + menu.insertItem(item, at: cursor) + cursor += 1 + } + } + } else { + let headerItem = NSMenuItem() + headerItem.tag = self.tag + headerItem.isEnabled = false + let statusText = isConnected + ? (self.cachedErrorText ?? "Loading sessions…") + : self.controlChannelStatusText(for: channelState) + let hosted = self.makeHostedView( + rootView: AnyView(MenuSessionsHeaderView( + count: 0, + statusText: statusText)), + width: width, + highlighted: false) + headerItem.view = hosted + headerView = hosted + menu.insertItem(headerItem, at: cursor) + cursor += 1 + + if !isConnected { + menu.insertItem( + self.makeMessageItem( + text: "Connect the gateway to see sessions", + symbolName: "bolt.slash", + width: width), + at: cursor) + cursor += 1 + } + } + + cursor = self.insertUsageSection(into: menu, at: cursor, width: width) + cursor = self.insertCostUsageSection(into: menu, at: cursor, width: width) + + DispatchQueue.main.async { [weak self, weak headerView] in + guard let self, let headerView else { return } + self.captureMenuWidthIfAvailable(from: headerView) + } + } + + private func injectNodes(into menu: NSMenu) { + for item in menu.items where item.tag == self.nodesTag { + menu.removeItem(item) + } + + guard let insertIndex = self.findNodesInsertIndex(in: menu) else { return } + let width = self.initialWidth(for: menu) + var cursor = insertIndex + + let entries = self.sortedNodeEntries() + let topSeparator = NSMenuItem.separator() + topSeparator.tag = self.nodesTag + menu.insertItem(topSeparator, at: cursor) + cursor += 1 + + if let gatewayEntry = self.gatewayEntry() { + let gatewayItem = self.makeNodeItem(entry: gatewayEntry, width: width) + menu.insertItem(gatewayItem, at: cursor) + cursor += 1 + } + + if case .connecting = ControlChannel.shared.state { + menu.insertItem( + self.makeMessageItem(text: "Connecting…", symbolName: "circle.dashed", width: width), + at: cursor) + cursor += 1 + return + } + + guard self.isControlChannelConnected else { return } + + if let error = self.nodesStore.lastError?.nonEmpty { + menu.insertItem( + self.makeMessageItem( + text: "Error: \(error)", + symbolName: "exclamationmark.triangle", + width: width), + at: cursor) + cursor += 1 + } else if let status = self.nodesStore.statusMessage?.nonEmpty { + menu.insertItem( + self.makeMessageItem(text: status, symbolName: "info.circle", width: width), + at: cursor) + cursor += 1 + } + + if entries.isEmpty { + let title = self.nodesStore.isLoading ? "Loading devices..." : "No devices yet" + menu.insertItem( + self.makeMessageItem(text: title, symbolName: "circle.dashed", width: width), + at: cursor) + cursor += 1 + } else { + for entry in entries.prefix(8) { + let item = self.makeNodeItem(entry: entry, width: width) + menu.insertItem(item, at: cursor) + cursor += 1 + } + + if entries.count > 8 { + let moreItem = NSMenuItem() + moreItem.tag = self.nodesTag + moreItem.title = "More Devices..." + moreItem.image = NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: nil) + let overflow = Array(entries.dropFirst(8)) + moreItem.submenu = self.buildNodesOverflowMenu(entries: overflow, width: width) + menu.insertItem(moreItem, at: cursor) + cursor += 1 + } + } + + _ = cursor + } + + private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int { + let rows = self.usageRows + if rows.isEmpty { + return cursor + } + + var cursor = cursor + + if cursor > 0, !menu.items[cursor - 1].isSeparatorItem { + let separator = NSMenuItem.separator() + separator.tag = self.tag + menu.insertItem(separator, at: cursor) + cursor += 1 + } + + let headerItem = NSMenuItem() + headerItem.tag = self.tag + headerItem.isEnabled = false + headerItem.view = self.makeHostedView( + rootView: AnyView(MenuUsageHeaderView( + count: rows.count)), + width: width, + highlighted: false) + menu.insertItem(headerItem, at: cursor) + cursor += 1 + + if let selectedProvider = self.selectedUsageProviderId, + let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }), + rows.count > 1 + { + let others = rows.filter { $0.providerId.lowercased() != selectedProvider } + + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = true + if !others.isEmpty { + item.submenu = self.buildUsageOverflowMenu(rows: others, width: width) + } + item.view = self.makeHostedView( + rootView: AnyView(UsageMenuLabelView(row: primary, width: width, showsChevron: !others.isEmpty)), + width: width, + highlighted: true) + menu.insertItem(item, at: cursor) + cursor += 1 + + return cursor + } + + for row in rows { + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = false + item.view = self.makeHostedView( + rootView: AnyView(UsageMenuLabelView(row: row, width: width)), + width: width, + highlighted: false) + menu.insertItem(item, at: cursor) + cursor += 1 + } + + return cursor + } + + private func insertCostUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int { + guard self.isControlChannelConnected else { return cursor } + guard let submenu = self.buildCostUsageSubmenu(width: width) else { return cursor } + var cursor = cursor + + if cursor > 0, !menu.items[cursor - 1].isSeparatorItem { + let separator = NSMenuItem.separator() + separator.tag = self.tag + menu.insertItem(separator, at: cursor) + cursor += 1 + } + + let item = NSMenuItem(title: "Usage cost (30 days)", action: nil, keyEquivalent: "") + item.tag = self.tag + item.isEnabled = true + item.image = NSImage(systemSymbolName: "chart.bar.xaxis", accessibilityDescription: nil) + item.submenu = submenu + menu.insertItem(item, at: cursor) + cursor += 1 + return cursor + } + + private var selectedUsageProviderId: String? { + guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil } + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + guard let slash = trimmed.firstIndex(of: "/") else { return nil } + let provider = trimmed[.. NSMenu { + let menu = NSMenu() + // Keep submenu delegate nil: reusing the status-menu delegate here causes + // recursive reinjection whenever this submenu is opened. + for row in rows { + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = false + item.view = self.makeHostedView( + rootView: AnyView(UsageMenuLabelView(row: row, width: width)), + width: width, + highlighted: false) + menu.addItem(item) + } + return menu + } + + private var isControlChannelConnected: Bool { + #if DEBUG + if let override = self.testControlChannelConnected { return override } + #endif + if case .connected = ControlChannel.shared.state { return true } + return false + } + + private func controlChannelStatusText(for state: ControlChannel.ConnectionState) -> String { + switch state { + case .connected: + "Loading sessions…" + case .connecting: + "Connecting…" + case let .degraded(message): + message.nonEmpty ?? "Gateway disconnected" + case .disconnected: + "Gateway disconnected" + } + } + + private func buildCostUsageSubmenu(width: CGFloat) -> NSMenu? { + if let error = self.cachedCostErrorText, !error.isEmpty, self.cachedCostSummary == nil { + let menu = NSMenu() + let item = NSMenuItem(title: error, action: nil, keyEquivalent: "") + item.isEnabled = false + menu.addItem(item) + return menu + } + + guard let summary = self.cachedCostSummary else { return nil } + guard !summary.daily.isEmpty else { return nil } + + let menu = NSMenu() + + let chartView = CostUsageHistoryMenuView(summary: summary, width: width) + let hosting = NSHostingView(rootView: AnyView(chartView)) + let controller = NSHostingController(rootView: AnyView(chartView)) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = "costUsageChart" + menu.addItem(chartItem) + + return menu + } + + private func gatewayEntry() -> NodeInfo? { + let mode = AppStateStore.shared.connectionMode + let isConnected = self.isControlChannelConnected + let port = GatewayEnvironment.gatewayPort() + var host: String? + var platform: String? + + switch mode { + case .remote: + platform = "remote" + if AppStateStore.shared.remoteTransport == .direct { + let trimmedUrl = AppStateStore.shared.remoteUrl + .trimmingCharacters(in: .whitespacesAndNewlines) + if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty { + if let port = url.port { + host = "\(urlHost):\(port)" + } else { + host = urlHost + } + } else { + host = trimmedUrl.nonEmpty + } + } else { + let target = AppStateStore.shared.remoteTarget + if let parsed = CommandResolver.parseSSHTarget(target) { + host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)" + } else { + host = target.nonEmpty + } + } + case .local: + platform = "local" + host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)" + case .unconfigured: + platform = nil + host = nil + } + + return NodeInfo( + nodeId: "gateway", + displayName: "Gateway", + platform: platform, + version: nil, + coreVersion: nil, + uiVersion: nil, + deviceFamily: nil, + modelIdentifier: nil, + remoteIp: host, + caps: nil, + commands: nil, + permissions: nil, + paired: nil, + connected: isConnected) + } + + private func makeNodeItem(entry: NodeInfo, width: CGFloat) -> NSMenuItem { + let item = NSMenuItem() + item.tag = self.nodesTag + item.target = self + item.action = #selector(self.copyNodeSummary(_:)) + item.representedObject = NodeMenuEntryFormatter.summaryText(entry) + item.view = HighlightedMenuItemHostView( + rootView: AnyView(NodeMenuRowView(entry: entry, width: width)), + width: width) + item.submenu = self.buildNodeSubmenu(entry: entry, width: width) + return item + } + + private func makeSessionPreviewItem( + sessionKey: String, + title: String, + width: CGFloat, + maxLines: Int) -> NSMenuItem + { + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = false + let view = AnyView( + SessionMenuPreviewView( + width: width, + maxLines: maxLines, + title: title, + items: [], + status: .loading) + .environment(\.isEnabled, true)) + let hosted = HighlightedMenuItemHostView(rootView: view, width: width) + item.view = hosted + + let task = Task { [weak hosted, weak item] in + let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10) + guard !Task.isCancelled else { return } + + await MainActor.run { + let nextView = AnyView( + SessionMenuPreviewView( + width: width, + maxLines: maxLines, + title: title, + items: snapshot.items, + status: snapshot.status) + .environment(\.isEnabled, true)) + + if let item { + item.view = HighlightedMenuItemHostView(rootView: nextView, width: width) + return + } + + guard let hosted else { return } + hosted.update(rootView: nextView, width: width) + } + } + self.previewTasks.append(task) + return item + } + + private func cancelPreviewTasks() { + for task in self.previewTasks { + task.cancel() + } + self.previewTasks.removeAll() + } + + private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem { + let view = AnyView( + HStack(alignment: .top, spacing: 8) { + Image(systemName: symbolName) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 14, alignment: .leading) + .padding(.top, 1) + + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(maxLines) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer(minLength: 0) + } + .padding(.leading, 18) + .padding(.trailing, 12) + .padding(.vertical, 6) + .frame(width: max(1, width), alignment: .leading)) + + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = false + item.view = self.makeHostedView(rootView: view, width: width, highlighted: false) + return item + } +} + +extension MenuSessionsInjector { + // MARK: - Cache + + private func refreshCache(force: Bool) async { + if !force, let updated = self.cacheUpdatedAt, Date().timeIntervalSince(updated) < self.refreshIntervalSeconds { + return + } + + guard self.isControlChannelConnected else { + if self.cachedSnapshot != nil { + self.cachedErrorText = "Gateway disconnected (showing cached)" + } else { + self.cachedErrorText = nil + } + self.cacheUpdatedAt = Date() + return + } + + do { + self.cachedSnapshot = try await SessionLoader.loadSnapshot(limit: 32) + self.cachedErrorText = nil + self.cacheUpdatedAt = Date() + } catch { + self.cachedSnapshot = nil + self.cachedErrorText = self.compactError(error) + self.cacheUpdatedAt = Date() + } + } + + private func refreshUsageCache(force: Bool) async { + if !force, + let updated = self.usageCacheUpdatedAt, + Date().timeIntervalSince(updated) < self.usageRefreshIntervalSeconds + { + return + } + + guard self.isControlChannelConnected else { + self.usageCacheUpdatedAt = Date() + return + } + + do { + self.cachedUsageSummary = try await UsageLoader.loadSummary() + } catch { + self.cachedUsageSummary = nil + self.cachedUsageErrorText = nil + } + self.usageCacheUpdatedAt = Date() + } + + private func refreshCostUsageCache(force: Bool) async { + if !force, + let updated = self.costCacheUpdatedAt, + Date().timeIntervalSince(updated) < self.costRefreshIntervalSeconds + { + return + } + + guard self.isControlChannelConnected else { + self.costCacheUpdatedAt = Date() + return + } + + do { + self.cachedCostSummary = try await CostUsageLoader.loadSummary() + self.cachedCostErrorText = nil + } catch { + self.cachedCostSummary = nil + self.cachedCostErrorText = self.compactUsageError(error) + } + self.costCacheUpdatedAt = Date() + } + + private func compactUsageError(_ error: Error) -> String { + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + if message.isEmpty { return "Usage unavailable" } + if message.count > 90 { return "\(message.prefix(87))…" } + return message + } + + private func compactError(_ error: Error) -> String { + if let loadError = error as? SessionLoadError { + switch loadError { + case .gatewayUnavailable: + return "No connection to gateway" + case .decodeFailed: + return "Sessions unavailable" + } + } + return "Sessions unavailable" + } +} + +extension MenuSessionsInjector { + // MARK: - Submenus + + private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu { + let menu = NSMenu() + let width = self.submenuWidth() + + menu.addItem(self.makeSessionPreviewItem( + sessionKey: row.key, + title: "Recent messages (last 10)", + width: width, + maxLines: 3)) + + let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "") + morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width) + menu.addItem(morePreview) + + menu.addItem(NSMenuItem.separator()) + + let thinking = NSMenuItem(title: "Thinking", action: nil, keyEquivalent: "") + thinking.submenu = self.buildThinkingMenu(for: row) + menu.addItem(thinking) + + let verbose = NSMenuItem(title: "Verbose", action: nil, keyEquivalent: "") + verbose.submenu = self.buildVerboseMenu(for: row) + menu.addItem(verbose) + + if AppStateStore.shared.debugPaneEnabled, + AppStateStore.shared.connectionMode == .local, + let sessionId = row.sessionId, + !sessionId.isEmpty + { + menu.addItem(NSMenuItem.separator()) + let openLog = NSMenuItem( + title: "Open Session Log", + action: #selector(self.openSessionLog(_:)), + keyEquivalent: "") + openLog.target = self + openLog.representedObject = [ + "sessionId": sessionId, + "storePath": storePath, + ] + menu.addItem(openLog) + } + + menu.addItem(NSMenuItem.separator()) + + let reset = NSMenuItem(title: "Reset Session", action: #selector(self.resetSession(_:)), keyEquivalent: "") + reset.target = self + reset.representedObject = row.key + menu.addItem(reset) + + let compact = NSMenuItem( + title: "Compact Session Log", + action: #selector(self.compactSession(_:)), + keyEquivalent: "") + compact.target = self + compact.representedObject = row.key + menu.addItem(compact) + + if row.key != self.mainSessionKey, row.key != "global" { + let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "") + del.target = self + del.representedObject = row.key + del.isAlternate = false + del.keyEquivalentModifierMask = [] + menu.addItem(del) + } + + return menu + } + + private func buildThinkingMenu(for row: SessionRow) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + menu.showsStateColumn = true + let levels: [String] = ["off", "minimal", "low", "medium", "high"] + let current = levels.contains(row.thinkingLevel ?? "") ? row.thinkingLevel ?? "off" : "off" + for level in levels { + let title = level.capitalized + let item = NSMenuItem(title: title, action: #selector(self.patchThinking(_:)), keyEquivalent: "") + item.target = self + item.representedObject = [ + "key": row.key, + "value": level as Any, + ] + item.state = (current == level) ? .on : .off + menu.addItem(item) + } + return menu + } + + private func buildVerboseMenu(for row: SessionRow) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + menu.showsStateColumn = true + let levels: [String] = ["on", "off"] + let current = levels.contains(row.verboseLevel ?? "") ? row.verboseLevel ?? "off" : "off" + for level in levels { + let title = level.capitalized + let item = NSMenuItem(title: title, action: #selector(self.patchVerbose(_:)), keyEquivalent: "") + item.target = self + item.representedObject = [ + "key": row.key, + "value": level as Any, + ] + item.state = (current == level) ? .on : .off + menu.addItem(item) + } + return menu + } + + private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu { + let menu = NSMenu() + menu.addItem(self.makeSessionPreviewItem( + sessionKey: sessionKey, + title: "Recent messages (expanded)", + width: width, + maxLines: 8)) + return menu + } + + private func buildNodesOverflowMenu(entries: [NodeInfo], width: CGFloat) -> NSMenu { + let menu = NSMenu() + for entry in entries { + let item = NSMenuItem() + item.target = self + item.action = #selector(self.copyNodeSummary(_:)) + item.representedObject = NodeMenuEntryFormatter.summaryText(entry) + item.view = HighlightedMenuItemHostView( + rootView: AnyView(NodeMenuRowView(entry: entry, width: width)), + width: width) + item.submenu = self.buildNodeSubmenu(entry: entry, width: width) + menu.addItem(item) + } + return menu + } + + private func buildNodeSubmenu(entry: NodeInfo, width: CGFloat) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + + menu.addItem(self.makeNodeCopyItem(label: "Node ID", value: entry.nodeId)) + + if let name = entry.displayName?.nonEmpty { + menu.addItem(self.makeNodeCopyItem(label: "Name", value: name)) + } + + if let ip = entry.remoteIp?.nonEmpty { + menu.addItem(self.makeNodeCopyItem(label: "IP", value: ip)) + } + + menu.addItem(self.makeNodeCopyItem(label: "Status", value: NodeMenuEntryFormatter.roleText(entry))) + + if let platform = NodeMenuEntryFormatter.platformText(entry) { + menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform)) + } + + if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty { + menu.addItem(self.makeNodeCopyItem(label: "Version", value: version)) + } + + menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No")) + menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No")) + + if let caps = entry.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), + !caps.isEmpty + { + menu.addItem(self.makeNodeCopyItem(label: "Caps", value: caps.joined(separator: ", "))) + } + + if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), + !commands.isEmpty + { + menu.addItem(self.makeNodeMultilineItem( + label: "Commands", + value: commands.joined(separator: ", "), + width: width)) + } + + return menu + } + + private func makeNodeDetailItem(label: String, value: String) -> NSMenuItem { + let item = NSMenuItem(title: "\(label): \(value)", action: nil, keyEquivalent: "") + item.isEnabled = false + return item + } + + private func makeNodeCopyItem(label: String, value: String) -> NSMenuItem { + let item = NSMenuItem(title: "\(label): \(value)", action: #selector(self.copyNodeValue(_:)), keyEquivalent: "") + item.target = self + item.representedObject = value + return item + } + + private func makeNodeMultilineItem(label: String, value: String, width: CGFloat) -> NSMenuItem { + let item = NSMenuItem() + item.target = self + item.action = #selector(self.copyNodeValue(_:)) + item.representedObject = value + item.view = HighlightedMenuItemHostView( + rootView: AnyView(NodeMenuMultilineView(label: label, value: value, width: width)), + width: width) + return item + } + + private func formatVersionLabel(_ version: String) -> String { + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return version } + if trimmed.hasPrefix("v") { return trimmed } + if let first = trimmed.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) { + return "v\(trimmed)" + } + return trimmed + } + + @objc + private func patchThinking(_ sender: NSMenuItem) { + guard let dict = sender.representedObject as? [String: Any], + let key = dict["key"] as? String + else { return } + let value = dict["value"] as? String + Task { + do { + try await SessionActions.patchSession(key: key, thinking: .some(value)) + await self.refreshCache(force: true) + } catch { + await MainActor.run { + SessionActions.presentError(title: "Update thinking failed", error: error) + } + } + } + } + + @objc + private func patchVerbose(_ sender: NSMenuItem) { + guard let dict = sender.representedObject as? [String: Any], + let key = dict["key"] as? String + else { return } + let value = dict["value"] as? String + Task { + do { + try await SessionActions.patchSession(key: key, verbose: .some(value)) + await self.refreshCache(force: true) + } catch { + await MainActor.run { + SessionActions.presentError(title: "Update verbose failed", error: error) + } + } + } + } + + @objc + private func openSessionLog(_ sender: NSMenuItem) { + guard let dict = sender.representedObject as? [String: String], + let sessionId = dict["sessionId"], + let storePath = dict["storePath"] + else { return } + SessionActions.openSessionLogInCode(sessionId: sessionId, storePath: storePath) + } + + @objc + private func resetSession(_ sender: NSMenuItem) { + guard let key = sender.representedObject as? String else { return } + Task { @MainActor in + guard SessionActions.confirmDestructiveAction( + title: "Reset session?", + message: "Starts a new session id for “\(key)”.", + action: "Reset") + else { return } + + do { + try await SessionActions.resetSession(key: key) + await self.refreshCache(force: true) + } catch { + SessionActions.presentError(title: "Reset failed", error: error) + } + } + } + + @objc + private func compactSession(_ sender: NSMenuItem) { + guard let key = sender.representedObject as? String else { return } + Task { @MainActor in + guard SessionActions.confirmDestructiveAction( + title: "Compact session log?", + message: "Keeps the last 400 lines; archives the old file.", + action: "Compact") + else { return } + + do { + try await SessionActions.compactSession(key: key, maxLines: 400) + await self.refreshCache(force: true) + } catch { + SessionActions.presentError(title: "Compact failed", error: error) + } + } + } + + @objc + private func deleteSession(_ sender: NSMenuItem) { + guard let key = sender.representedObject as? String else { return } + Task { @MainActor in + guard SessionActions.confirmDestructiveAction( + title: "Delete session?", + message: "Deletes the “\(key)” entry and archives its transcript.", + action: "Delete") + else { return } + + do { + try await SessionActions.deleteSession(key: key) + await self.refreshCache(force: true) + } catch { + SessionActions.presentError(title: "Delete failed", error: error) + } + } + } + + @objc + private func copyNodeSummary(_ sender: NSMenuItem) { + guard let summary = sender.representedObject as? String else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(summary, forType: .string) + } + + @objc + private func copyNodeValue(_ sender: NSMenuItem) { + guard let value = sender.representedObject as? String else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } +} + +extension MenuSessionsInjector { + // MARK: - Width + placement + + private func findInsertIndex(in menu: NSMenu) -> Int? { + // Insert right before the separator above "Send Heartbeats". + if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { + if let sepIdx = menu.items[..= 1 { return 1 } + return menu.items.count + } + + private func findNodesInsertIndex(in menu: NSMenu) -> Int? { + if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { + if let sepIdx = menu.items[..= 1 { return 1 } + return menu.items.count + } + + private func initialWidth(for menu: NSMenu) -> CGFloat { + if let openWidth = self.menuOpenWidth { + return max(300, openWidth) + } + return self.currentMenuWidth(for: menu) + } + + private func submenuWidth() -> CGFloat { + if let openWidth = self.menuOpenWidth { + return max(300, openWidth) + } + if let cached = self.lastKnownMenuWidth { + return max(300, cached) + } + return self.fallbackWidth + } + + private func menuWindowWidth(for menu: NSMenu) -> CGFloat? { + var menuWindow: NSWindow? + for item in menu.items { + if let window = item.view?.window { + menuWindow = window + break + } + } + guard let width = menuWindow?.contentView?.bounds.width, width > 0 else { return nil } + return width + } + + private func sortedNodeEntries() -> [NodeInfo] { + let entries = self.nodesStore.nodes.filter(\.isConnected) + return entries.sorted { lhs, rhs in + if lhs.isConnected != rhs.isConnected { return lhs.isConnected } + if lhs.isPaired != rhs.isPaired { return lhs.isPaired } + let lhsName = NodeMenuEntryFormatter.primaryName(lhs).lowercased() + let rhsName = NodeMenuEntryFormatter.primaryName(rhs).lowercased() + if lhsName == rhsName { return lhs.nodeId < rhs.nodeId } + return lhsName < rhsName + } + } +} + +extension MenuSessionsInjector { + // MARK: - Views + + private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView { + if highlighted { + return HighlightedMenuItemHostView(rootView: rootView, width: width) + } + + let hosting = NSHostingView(rootView: rootView) + hosting.frame.size.width = max(1, width) + let size = hosting.fittingSize + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + return hosting + } + + private func captureMenuWidthIfAvailable(from view: NSView) { + guard !self.isMenuOpen else { return } + guard let width = view.window?.contentView?.bounds.width, width > 0 else { return } + self.lastKnownMenuWidth = max(300, width) + } + + private func currentMenuWidth(for menu: NSMenu) -> CGFloat { + if let width = self.menuWindowWidth(for: menu) { + return max(300, width) + } + let candidates: [CGFloat] = [ + menu.size.width, + menu.minimumWidth, + self.lastKnownMenuWidth ?? 0, + self.fallbackWidth, + ] + let resolved = candidates.max() ?? self.fallbackWidth + return max(300, resolved) + } +} + +#if DEBUG +extension MenuSessionsInjector { + func setTestingControlChannelConnected(_ connected: Bool?) { + self.testControlChannelConnected = connected + } + + func setTestingSnapshot(_ snapshot: SessionStoreSnapshot?, errorText: String? = nil) { + self.cachedSnapshot = snapshot + self.cachedErrorText = errorText + self.cacheUpdatedAt = Date() + } + + func setTestingUsageSummary(_ summary: GatewayUsageSummary?, errorText: String? = nil) { + self.cachedUsageSummary = summary + self.cachedUsageErrorText = errorText + self.usageCacheUpdatedAt = Date() + } + + func setTestingCostUsageSummary(_ summary: GatewayCostUsageSummary?, errorText: String? = nil) { + self.cachedCostSummary = summary + self.cachedCostErrorText = errorText + self.costCacheUpdatedAt = Date() + } + + func injectForTesting(into menu: NSMenu) { + self.inject(into: menu) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift new file mode 100644 index 00000000..dbb717d6 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct MenuUsageHeaderView: View { + let count: Int + + private let paddingTop: CGFloat = 8 + private let paddingBottom: CGFloat = 6 + private let paddingTrailing: CGFloat = 10 + private let paddingLeading: CGFloat = 20 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text("Usage") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 10) + Text(self.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.top, self.paddingTop) + .padding(.bottom, self.paddingBottom) + .padding(.leading, self.paddingLeading) + .padding(.trailing, self.paddingTrailing) + .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) + .transaction { txn in txn.animation = nil } + } + + private var subtitle: String { + if self.count == 1 { return "1 provider" } + return "\(self.count) providers" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift new file mode 100644 index 00000000..81e06abd --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift @@ -0,0 +1,103 @@ +import AVFoundation +import OSLog +import SwiftUI + +actor MicLevelMonitor { + private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.meter") + private var engine: AVAudioEngine? + private var update: (@Sendable (Double) -> Void)? + private var running = false + private var smoothedLevel: Double = 0 + + func start(onLevel: @Sendable @escaping (Double) -> Void) async throws { + self.update = onLevel + if self.running { return } + self.logger.info( + "mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))") + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.engine = nil + throw NSError( + domain: "MicLevelMonitor", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + let engine = AVAudioEngine() + self.engine = engine + let input = engine.inputNode + let format = input.outputFormat(forBus: 0) + guard format.channelCount > 0, format.sampleRate > 0 else { + self.engine = nil + throw NSError( + domain: "MicLevelMonitor", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) + } + input.removeTap(onBus: 0) + input.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in + guard let self else { return } + let level = Self.normalizedLevel(from: buffer) + Task { await self.push(level: level) } + } + engine.prepare() + try engine.start() + self.running = true + } + + func stop() { + guard self.running else { return } + if let engine { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + } + self.engine = nil + self.running = false + } + + private func push(level: Double) { + self.smoothedLevel = (self.smoothedLevel * 0.45) + (level * 0.55) + guard let update else { return } + let value = self.smoothedLevel + Task { @MainActor in update(value) } + } + + private static func normalizedLevel(from buffer: AVAudioPCMBuffer) -> Double { + guard let channel = buffer.floatChannelData?[0] else { return 0 } + let frameCount = Int(buffer.frameLength) + guard frameCount > 0 else { return 0 } + var sum: Float = 0 + for i in 0.. Double(idx) + RoundedRectangle(cornerRadius: 2) + .fill(fill ? self.segmentColor(for: idx) : Color.gray.opacity(0.35)) + .frame(width: 14, height: 10) + } + } + .padding(4) + .background( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.gray.opacity(0.25), lineWidth: 1)) + } + + private func segmentColor(for idx: Int) -> Color { + let fraction = Double(idx + 1) / Double(self.segments) + if fraction < 0.65 { return .green } + if fraction < 0.85 { return .yellow } + return .red + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift new file mode 100644 index 00000000..b320c84d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift @@ -0,0 +1,159 @@ +import Foundation +import JavaScriptCore + +enum ModelCatalogLoader { + static var defaultPath: String { + self.resolveDefaultPath() + } + + private static let logger = Logger(subsystem: "ai.openclaw", category: "models") + private nonisolated static let appSupportDir: URL = { + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("OpenClaw", isDirectory: true) + }() + + private static var cachePath: URL { + self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false) + } + + static func load(from path: String) async throws -> [ModelChoice] { + let expanded = (path as NSString).expandingTildeInPath + guard let resolved = self.resolvePath(preferred: expanded) else { + self.logger.error("model catalog load failed: file not found") + throw NSError( + domain: "ModelCatalogLoader", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"]) + } + self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)") + let source = try String(contentsOfFile: resolved.path, encoding: .utf8) + let sanitized = self.sanitize(source: source) + + let ctx = JSContext() + ctx?.exceptionHandler = { _, exception in + if let exception { + self.logger.warning("model catalog JS exception: \(exception)") + } + } + ctx?.evaluateScript(sanitized) + guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else { + self.logger.error("model catalog parse failed: MODELS missing") + throw NSError( + domain: "ModelCatalogLoader", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse models.generated.ts"]) + } + + var choices: [ModelChoice] = [] + for (provider, value) in rawModels { + guard let models = value as? [String: Any] else { continue } + for (id, payload) in models { + guard let dict = payload as? [String: Any] else { continue } + let name = dict["name"] as? String ?? id + let ctxWindow = dict["contextWindow"] as? Int + choices.append(ModelChoice(id: id, name: name, provider: provider, contextWindow: ctxWindow)) + } + } + + let sorted = choices.sorted { lhs, rhs in + if lhs.provider == rhs.provider { + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending + } + self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)") + if resolved.shouldCache { + self.cacheCatalog(sourcePath: resolved.path) + } + return sorted + } + + private static func resolveDefaultPath() -> String { + let cache = self.cachePath.path + if FileManager().isReadableFile(atPath: cache) { return cache } + if let bundlePath = self.bundleCatalogPath() { return bundlePath } + if let nodePath = self.nodeModulesCatalogPath() { return nodePath } + return cache + } + + private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? { + if FileManager().isReadableFile(atPath: preferred) { + return (preferred, preferred != self.cachePath.path) + } + + if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred { + self.logger.warning("model catalog path missing; falling back to bundled catalog") + return (bundlePath, true) + } + + let cache = self.cachePath.path + if cache != preferred, FileManager().isReadableFile(atPath: cache) { + self.logger.warning("model catalog path missing; falling back to cached catalog") + return (cache, false) + } + + if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred { + self.logger.warning("model catalog path missing; falling back to node_modules catalog") + return (nodePath, true) + } + + return nil + } + + private static func bundleCatalogPath() -> String? { + guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else { + return nil + } + return url.path + } + + private static func nodeModulesCatalogPath() -> String? { + let roots = [ + URL(fileURLWithPath: CommandResolver.projectRootPath()), + URL(fileURLWithPath: FileManager().currentDirectoryPath), + ] + for root in roots { + let candidate = root + .appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js") + if FileManager().isReadableFile(atPath: candidate.path) { + return candidate.path + } + } + return nil + } + + private static func cacheCatalog(sourcePath: String) { + let destination = self.cachePath + do { + try FileManager().createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true) + if FileManager().fileExists(atPath: destination.path) { + try FileManager().removeItem(at: destination) + } + try FileManager().copyItem(atPath: sourcePath, toPath: destination.path) + self.logger.debug("model catalog cached file=\(destination.lastPathComponent)") + } catch { + self.logger.warning("model catalog cache failed: \(error.localizedDescription)") + } + } + + private static func sanitize(source: String) -> String { + guard let exportRange = source.range(of: "export const MODELS"), + let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"), + let lastBrace = source.lastIndex(of: "}") + else { + return "var MODELS = {}" + } + var body = String(source[firstBrace...lastBrace]) + body = body.replacingOccurrences( + of: #"(?m)\bsatisfies\s+[^,}\n]+"#, + with: "", + options: .regularExpression) + body = body.replacingOccurrences( + of: #"(?m)\bas\s+[^;,\n]+"#, + with: "", + options: .regularExpression) + return "var MODELS = \(body);" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NSAttributedString+VoiceWake.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NSAttributedString+VoiceWake.swift new file mode 100644 index 00000000..cb4be425 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NSAttributedString+VoiceWake.swift @@ -0,0 +1,9 @@ +import Foundation + +extension NSAttributedString { + func strippingForegroundColor() -> NSAttributedString { + let mutable = NSMutableAttributedString(attributedString: self) + mutable.removeAttribute(.foregroundColor, range: NSRange(location: 0, length: mutable.length)) + return mutable + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift new file mode 100644 index 00000000..bd4df512 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift @@ -0,0 +1,139 @@ +import CoreLocation +import Foundation +import OpenClawKit + +@MainActor +final class MacNodeLocationService: NSObject, CLLocationManagerDelegate { + enum Error: Swift.Error { + case timeout + case unavailable + } + + private let manager = CLLocationManager() + private var locationContinuation: CheckedContinuation? + + override init() { + super.init() + self.manager.delegate = self + self.manager.desiredAccuracy = kCLLocationAccuracyBest + } + + func authorizationStatus() -> CLAuthorizationStatus { + self.manager.authorizationStatus + } + + func accuracyAuthorization() -> CLAccuracyAuthorization { + if #available(macOS 11.0, *) { + return self.manager.accuracyAuthorization + } + return .fullAccuracy + } + + func currentLocation( + desiredAccuracy: OpenClawLocationAccuracy, + maxAgeMs: Int?, + timeoutMs: Int?) async throws -> CLLocation + { + guard CLLocationManager.locationServicesEnabled() else { + throw Error.unavailable + } + + let now = Date() + if let maxAgeMs, + let cached = self.manager.location, + now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs) + { + return cached + } + + self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy) + let timeout = max(0, timeoutMs ?? 10000) + return try await self.withTimeout(timeoutMs: timeout) { + try await self.requestLocation() + } + } + + private func requestLocation() async throws -> CLLocation { + try await withCheckedThrowingContinuation { cont in + self.locationContinuation = cont + self.manager.requestLocation() + } + } + + private func withTimeout( + timeoutMs: Int, + operation: @escaping () async throws -> T) async throws -> T + { + if timeoutMs == 0 { + return try await operation() + } + + return try await withCheckedThrowingContinuation { continuation in + var didFinish = false + + func finish(returning value: T) { + guard !didFinish else { return } + didFinish = true + continuation.resume(returning: value) + } + + func finish(throwing error: Swift.Error) { + guard !didFinish else { return } + didFinish = true + continuation.resume(throwing: error) + } + + let timeoutItem = DispatchWorkItem { + finish(throwing: Error.timeout) + } + DispatchQueue.main.asyncAfter( + deadline: .now() + .milliseconds(timeoutMs), + execute: timeoutItem) + + Task { @MainActor in + do { + let value = try await operation() + timeoutItem.cancel() + finish(returning: value) + } catch { + timeoutItem.cancel() + finish(throwing: error) + } + } + } + } + + private static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy { + switch accuracy { + case .coarse: + kCLLocationAccuracyKilometer + case .balanced: + kCLLocationAccuracyHundredMeters + case .precise: + kCLLocationAccuracyBest + } + } + + // MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility) + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + Task { @MainActor in + guard let cont = self.locationContinuation else { return } + self.locationContinuation = nil + if let latest = locations.last { + cont.resume(returning: latest) + } else { + cont.resume(throwing: Error.unavailable) + } + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) { + let errorCopy = error // Capture error for Sendable compliance + Task { @MainActor in + guard let cont = self.locationContinuation else { return } + self.locationContinuation = nil + cont.resume(throwing: errorCopy) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift new file mode 100644 index 00000000..af46788c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -0,0 +1,171 @@ +import Foundation +import OpenClawKit +import OSLog + +@MainActor +final class MacNodeModeCoordinator { + static let shared = MacNodeModeCoordinator() + + private let logger = Logger(subsystem: "ai.openclaw", category: "mac-node") + private var task: Task? + private let runtime = MacNodeRuntime() + private let session = GatewayNodeSession() + + func start() { + guard self.task == nil else { return } + self.task = Task { [weak self] in + await self?.run() + } + } + + func stop() { + self.task?.cancel() + self.task = nil + Task { await self.session.disconnect() } + } + + func setPreferredGatewayStableID(_ stableID: String?) { + GatewayDiscoveryPreferences.setPreferredStableID(stableID) + Task { await self.session.disconnect() } + } + + private func run() async { + var retryDelay: UInt64 = 1_000_000_000 + var lastCameraEnabled: Bool? + let defaults = UserDefaults.standard + + while !Task.isCancelled { + if await MainActor.run(body: { AppStateStore.shared.isPaused }) { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } + + let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false + if lastCameraEnabled == nil { + lastCameraEnabled = cameraEnabled + } else if lastCameraEnabled != cameraEnabled { + lastCameraEnabled = cameraEnabled + await self.session.disconnect() + try? await Task.sleep(nanoseconds: 200_000_000) + } + + do { + let config = try await GatewayEndpointStore.shared.requireConfig() + let caps = self.currentCaps() + let commands = self.currentCommands(caps: caps) + let permissions = await self.currentPermissions() + let connectOptions = GatewayConnectOptions( + role: "node", + scopes: [], + caps: caps, + commands: commands, + permissions: permissions, + clientId: "openclaw-macos", + clientMode: "node", + clientDisplayName: InstanceIdentity.displayName) + let sessionBox = self.buildSessionBox(url: config.url) + + try await self.session.connect( + url: config.url, + token: config.token, + password: config.password, + connectOptions: connectOptions, + sessionBox: sessionBox, + onConnected: { [weak self] in + guard let self else { return } + self.logger.info("mac node connected to gateway") + let mainSessionKey = await GatewayConnection.shared.mainSessionKey() + await self.runtime.updateMainSessionKey(mainSessionKey) + await self.runtime.setEventSender { [weak self] event, payload in + guard let self else { return } + await self.session.sendEvent(event: event, payloadJSON: payload) + } + }, + onDisconnected: { [weak self] reason in + guard let self else { return } + await self.runtime.setEventSender(nil) + self.logger.error("mac node disconnected: \(reason, privacy: .public)") + }, + onInvoke: { [weak self] req in + guard let self else { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready")) + } + return await self.runtime.handleInvoke(req) + }) + + retryDelay = 1_000_000_000 + try? await Task.sleep(nanoseconds: 1_000_000_000) + } catch { + self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)") + try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000)) + retryDelay = min(retryDelay * 2, 10_000_000_000) + } + } + } + + private func currentCaps() -> [String] { + var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue] + if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false { + caps.append(OpenClawCapability.camera.rawValue) + } + let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" + if OpenClawLocationMode(rawValue: rawLocationMode) != .off { + caps.append(OpenClawCapability.location.rawValue) + } + return caps + } + + private func currentPermissions() async -> [String: Bool] { + let statuses = await PermissionManager.status() + return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) }) + } + + private func currentCommands(caps: [String]) -> [String] { + var commands: [String] = [ + OpenClawCanvasCommand.present.rawValue, + OpenClawCanvasCommand.hide.rawValue, + OpenClawCanvasCommand.navigate.rawValue, + OpenClawCanvasCommand.evalJS.rawValue, + OpenClawCanvasCommand.snapshot.rawValue, + OpenClawCanvasA2UICommand.push.rawValue, + OpenClawCanvasA2UICommand.pushJSONL.rawValue, + OpenClawCanvasA2UICommand.reset.rawValue, + MacNodeScreenCommand.record.rawValue, + OpenClawSystemCommand.notify.rawValue, + OpenClawSystemCommand.which.rawValue, + OpenClawSystemCommand.run.rawValue, + OpenClawSystemCommand.execApprovalsGet.rawValue, + OpenClawSystemCommand.execApprovalsSet.rawValue, + ] + + let capsSet = Set(caps) + if capsSet.contains(OpenClawCapability.camera.rawValue) { + commands.append(OpenClawCameraCommand.list.rawValue) + commands.append(OpenClawCameraCommand.snap.rawValue) + commands.append(OpenClawCameraCommand.clip.rawValue) + } + if capsSet.contains(OpenClawCapability.location.rawValue) { + commands.append(OpenClawLocationCommand.get.rawValue) + } + + return commands + } + + private func buildSessionBox(url: URL) -> WebSocketSessionBox? { + guard url.scheme?.lowercased() == "wss" else { return nil } + let host = url.host ?? "gateway" + let port = url.port ?? 443 + let stableID = "\(host):\(port)" + let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) + let params = GatewayTLSParams( + required: true, + expectedFingerprint: stored, + allowTOFU: stored == nil, + storeKey: stableID) + let session = GatewayTLSPinningSession(params: params) + return WebSocketSessionBox(session: session) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift new file mode 100644 index 00000000..cda8ca60 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -0,0 +1,1002 @@ +import AppKit +import Foundation +import OpenClawIPC +import OpenClawKit + +actor MacNodeRuntime { + private let cameraCapture = CameraCaptureService() + private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices + private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)? + private var mainSessionKey: String = "main" + private var eventSender: (@Sendable (String, String?) async -> Void)? + + init( + makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = { + await MainActor.run { LiveMacNodeRuntimeMainActorServices() } + }) + { + self.makeMainActorServices = makeMainActorServices + } + + func updateMainSessionKey(_ sessionKey: String) { + let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + self.mainSessionKey = trimmed + } + + func setEventSender(_ sender: (@Sendable (String, String?) async -> Void)?) { + self.eventSender = sender + } + + func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { + let command = req.command + if self.isCanvasCommand(command), !Self.canvasEnabled() { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "CANVAS_DISABLED: enable Canvas in Settings")) + } + do { + switch command { + case OpenClawCanvasCommand.present.rawValue, + OpenClawCanvasCommand.hide.rawValue, + OpenClawCanvasCommand.navigate.rawValue, + OpenClawCanvasCommand.evalJS.rawValue, + OpenClawCanvasCommand.snapshot.rawValue: + return try await self.handleCanvasInvoke(req) + case OpenClawCanvasA2UICommand.reset.rawValue, + OpenClawCanvasA2UICommand.push.rawValue, + OpenClawCanvasA2UICommand.pushJSONL.rawValue: + return try await self.handleA2UIInvoke(req) + case OpenClawCameraCommand.snap.rawValue, + OpenClawCameraCommand.clip.rawValue, + OpenClawCameraCommand.list.rawValue: + return try await self.handleCameraInvoke(req) + case OpenClawLocationCommand.get.rawValue: + return try await self.handleLocationInvoke(req) + case MacNodeScreenCommand.record.rawValue: + return try await self.handleScreenRecordInvoke(req) + case OpenClawSystemCommand.run.rawValue: + return try await self.handleSystemRun(req) + case OpenClawSystemCommand.which.rawValue: + return try await self.handleSystemWhich(req) + case OpenClawSystemCommand.notify.rawValue: + return try await self.handleSystemNotify(req) + case OpenClawSystemCommand.execApprovalsGet.rawValue: + return try await self.handleSystemExecApprovalsGet(req) + case OpenClawSystemCommand.execApprovalsSet.rawValue: + return try await self.handleSystemExecApprovalsSet(req) + default: + return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") + } + } catch { + return Self.errorResponse(req, code: .unavailable, message: error.localizedDescription) + } + } + + private func isCanvasCommand(_ command: String) -> Bool { + command.hasPrefix("canvas.") || command.hasPrefix("canvas.a2ui.") + } + + private func handleCanvasInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + switch req.command { + case OpenClawCanvasCommand.present.rawValue: + let params = (try? Self.decodeParams(OpenClawCanvasPresentParams.self, from: req.paramsJSON)) ?? + OpenClawCanvasPresentParams() + let urlTrimmed = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let url = urlTrimmed.isEmpty ? nil : urlTrimmed + let placement = params.placement.map { + CanvasPlacement(x: $0.x, y: $0.y, width: $0.width, height: $0.height) + } + let sessionKey = self.mainSessionKey + try await MainActor.run { + _ = try CanvasManager.shared.showDetailed( + sessionKey: sessionKey, + target: url, + placement: placement) + } + return BridgeInvokeResponse(id: req.id, ok: true) + case OpenClawCanvasCommand.hide.rawValue: + let sessionKey = self.mainSessionKey + await MainActor.run { + CanvasManager.shared.hide(sessionKey: sessionKey) + } + return BridgeInvokeResponse(id: req.id, ok: true) + case OpenClawCanvasCommand.navigate.rawValue: + let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON) + let sessionKey = self.mainSessionKey + try await MainActor.run { + _ = try CanvasManager.shared.show(sessionKey: sessionKey, path: params.url) + } + return BridgeInvokeResponse(id: req.id, ok: true) + case OpenClawCanvasCommand.evalJS.rawValue: + let params = try Self.decodeParams(OpenClawCanvasEvalParams.self, from: req.paramsJSON) + let sessionKey = self.mainSessionKey + let result = try await CanvasManager.shared.eval( + sessionKey: sessionKey, + javaScript: params.javaScript) + let payload = try Self.encodePayload(["result": result] as [String: String]) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + case OpenClawCanvasCommand.snapshot.rawValue: + let params = try? Self.decodeParams(OpenClawCanvasSnapshotParams.self, from: req.paramsJSON) + let format = params?.format ?? .jpeg + let maxWidth: Int? = { + if let raw = params?.maxWidth, raw > 0 { return raw } + return switch format { + case .png: 900 + case .jpeg: 1600 + } + }() + let quality = params?.quality ?? 0.9 + + let sessionKey = self.mainSessionKey + let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil) + defer { try? FileManager().removeItem(atPath: path) } + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + guard let image = NSImage(data: data) else { + return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed") + } + let encoded = try Self.encodeCanvasSnapshot( + image: image, + format: format, + maxWidth: maxWidth, + quality: quality) + let payload = try Self.encodePayload([ + "format": format == .jpeg ? "jpeg" : "png", + "base64": encoded.base64EncodedString(), + ]) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + default: + return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") + } + } + + private func handleA2UIInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + switch req.command { + case OpenClawCanvasA2UICommand.reset.rawValue: + try await self.handleA2UIReset(req) + case OpenClawCanvasA2UICommand.push.rawValue, + OpenClawCanvasA2UICommand.pushJSONL.rawValue: + try await self.handleA2UIPush(req) + default: + Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") + } + } + + private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + guard Self.cameraEnabled() else { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "CAMERA_DISABLED: enable Camera in Settings")) + } + switch req.command { + case OpenClawCameraCommand.snap.rawValue: + let params = (try? Self.decodeParams(OpenClawCameraSnapParams.self, from: req.paramsJSON)) ?? + OpenClawCameraSnapParams() + let delayMs = min(10000, max(0, params.delayMs ?? 2000)) + let res = try await self.cameraCapture.snap( + facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front, + maxWidth: params.maxWidth, + quality: params.quality, + deviceId: params.deviceId, + delayMs: delayMs) + struct SnapPayload: Encodable { + var format: String + var base64: String + var width: Int + var height: Int + } + let payload = try Self.encodePayload(SnapPayload( + format: (params.format ?? .jpg).rawValue, + base64: res.data.base64EncodedString(), + width: Int(res.size.width), + height: Int(res.size.height))) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + case OpenClawCameraCommand.clip.rawValue: + let params = (try? Self.decodeParams(OpenClawCameraClipParams.self, from: req.paramsJSON)) ?? + OpenClawCameraClipParams() + let res = try await self.cameraCapture.clip( + facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front, + durationMs: params.durationMs, + includeAudio: params.includeAudio ?? true, + deviceId: params.deviceId, + outPath: nil) + defer { try? FileManager().removeItem(atPath: res.path) } + let data = try Data(contentsOf: URL(fileURLWithPath: res.path)) + struct ClipPayload: Encodable { + var format: String + var base64: String + var durationMs: Int + var hasAudio: Bool + } + let payload = try Self.encodePayload(ClipPayload( + format: (params.format ?? .mp4).rawValue, + base64: data.base64EncodedString(), + durationMs: res.durationMs, + hasAudio: res.hasAudio)) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + case OpenClawCameraCommand.list.rawValue: + let devices = await self.cameraCapture.listDevices() + let payload = try Self.encodePayload(["devices": devices]) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + default: + return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") + } + } + + private func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let mode = Self.locationMode() + guard mode != .off else { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "LOCATION_DISABLED: enable Location in Settings")) + } + let params = (try? Self.decodeParams(OpenClawLocationGetParams.self, from: req.paramsJSON)) ?? + OpenClawLocationGetParams() + let desired = params.desiredAccuracy ?? + (Self.locationPreciseEnabled() ? .precise : .balanced) + let services = await self.mainActorServices() + let status = await services.locationAuthorizationStatus() + let hasPermission = switch mode { + case .always: + status == .authorizedAlways + case .whileUsing: + status == .authorizedAlways + case .off: + false + } + if !hasPermission { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "LOCATION_PERMISSION_REQUIRED: grant Location permission")) + } + do { + let location = try await services.currentLocation( + desiredAccuracy: desired, + maxAgeMs: params.maxAgeMs, + timeoutMs: params.timeoutMs) + let isPrecise = await services.locationAccuracyAuthorization() == .fullAccuracy + let payload = OpenClawLocationPayload( + lat: location.coordinate.latitude, + lon: location.coordinate.longitude, + accuracyMeters: location.horizontalAccuracy, + altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil, + speedMps: location.speed >= 0 ? location.speed : nil, + headingDeg: location.course >= 0 ? location.course : nil, + timestamp: ISO8601DateFormatter().string(from: location.timestamp), + isPrecise: isPrecise, + source: nil) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + } catch MacNodeLocationService.Error.timeout { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "LOCATION_TIMEOUT: no fix in time")) + } catch { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "LOCATION_UNAVAILABLE: \(error.localizedDescription)")) + } + } + + private func handleScreenRecordInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let params = (try? Self.decodeParams(MacNodeScreenRecordParams.self, from: req.paramsJSON)) ?? + MacNodeScreenRecordParams() + if let format = params.format?.lowercased(), !format.isEmpty, format != "mp4" { + return Self.errorResponse( + req, + code: .invalidRequest, + message: "INVALID_REQUEST: screen format must be mp4") + } + let services = await self.mainActorServices() + let res = try await services.recordScreen( + screenIndex: params.screenIndex, + durationMs: params.durationMs, + fps: params.fps, + includeAudio: params.includeAudio, + outPath: nil) + defer { try? FileManager().removeItem(atPath: res.path) } + let data = try Data(contentsOf: URL(fileURLWithPath: res.path)) + struct ScreenPayload: Encodable { + var format: String + var base64: String + var durationMs: Int? + var fps: Double? + var screenIndex: Int? + var hasAudio: Bool + } + let payload = try Self.encodePayload(ScreenPayload( + format: "mp4", + base64: data.base64EncodedString(), + durationMs: params.durationMs, + fps: params.fps, + screenIndex: params.screenIndex, + hasAudio: res.hasAudio)) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + } + + private func mainActorServices() async -> any MacNodeRuntimeMainActorServices { + if let cachedMainActorServices { return cachedMainActorServices } + let services = await self.makeMainActorServices() + self.cachedMainActorServices = services + return services + } + + private func handleA2UIReset(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + try await self.ensureA2UIHost() + + let sessionKey = self.mainSessionKey + let json = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """ + (() => { + const host = globalThis.openclawA2UI; + if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" }); + return JSON.stringify(host.reset()); + })() + """) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + } + + private func handleA2UIPush(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let command = req.command + let messages: [OpenClawKit.AnyCodable] + if command == OpenClawCanvasA2UICommand.pushJSONL.rawValue { + let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON) + messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl) + } else { + do { + let params = try Self.decodeParams(OpenClawCanvasA2UIPushParams.self, from: req.paramsJSON) + messages = params.messages + } catch { + let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON) + messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl) + } + } + + try await self.ensureA2UIHost() + + let messagesJSON = try OpenClawCanvasA2UIJSONL.encodeMessagesJSONArray(messages) + let js = """ + (() => { + try { + const host = globalThis.openclawA2UI; + if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" }); + const messages = \(messagesJSON); + return JSON.stringify(host.applyMessages(messages)); + } catch (e) { + return JSON.stringify({ ok: false, error: String(e?.message ?? e) }); + } + })() + """ + let sessionKey = self.mainSessionKey + let resultJSON = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: js) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON) + } + + private func ensureA2UIHost() async throws { + if await self.isA2UIReady() { return } + guard let a2uiUrl = await self.resolveA2UIHostUrl() else { + throw NSError(domain: "Canvas", code: 30, userInfo: [ + NSLocalizedDescriptionKey: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host", + ]) + } + let sessionKey = self.mainSessionKey + _ = try await MainActor.run { + try CanvasManager.shared.show(sessionKey: sessionKey, path: a2uiUrl) + } + if await self.isA2UIReady(poll: true) { return } + throw NSError(domain: "Canvas", code: 31, userInfo: [ + NSLocalizedDescriptionKey: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable", + ]) + } + + private func resolveA2UIHostUrl() async -> String? { + guard let raw = await GatewayConnection.shared.canvasHostUrl() else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let baseUrl = URL(string: trimmed) else { return nil } + return baseUrl.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=macos" + } + + private func isA2UIReady(poll: Bool = false) async -> Bool { + let deadline = poll ? Date().addingTimeInterval(6.0) : Date() + while true { + do { + let sessionKey = self.mainSessionKey + let ready = try await CanvasManager.shared.eval(sessionKey: sessionKey, javaScript: """ + (() => { + const host = globalThis.openclawA2UI; + return String(Boolean(host)); + })() + """) + let trimmed = ready.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == "true" { return true } + } catch { + // Ignore transient eval failures while the page is loading. + } + + guard poll, Date() < deadline else { return false } + try? await Task.sleep(nanoseconds: 120_000_000) + } + } + + private func handleSystemRun(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let params = try Self.decodeParams(OpenClawSystemRunParams.self, from: req.paramsJSON) + let command = params.command + guard !command.isEmpty else { + return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required") + } + let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines) + : self.mainSessionKey + let runId = UUID().uuidString + let evaluation = await ExecApprovalEvaluator.evaluate( + command: command, + rawCommand: params.rawCommand, + cwd: params.cwd, + envOverrides: params.env, + agentId: params.agentId) + + if evaluation.security == .deny { + await self.emitExecEvent( + "exec.denied", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: evaluation.displayCommand, + reason: "security=deny")) + return Self.errorResponse( + req, + code: .unavailable, + message: "SYSTEM_RUN_DISABLED: security=deny") + } + + let approval = await self.resolveSystemRunApproval( + req: req, + params: params, + context: ExecRunContext( + displayCommand: evaluation.displayCommand, + security: evaluation.security, + ask: evaluation.ask, + agentId: evaluation.agentId, + resolution: evaluation.resolution, + allowlistMatch: evaluation.allowlistMatch, + skillAllow: evaluation.skillAllow, + sessionKey: sessionKey, + runId: runId)) + if let response = approval.response { return response } + let approvedByAsk = approval.approvedByAsk + let persistAllowlist = approval.persistAllowlist + self.persistAllowlistPatterns( + persistAllowlist: persistAllowlist, + security: evaluation.security, + agentId: evaluation.agentId, + command: command, + allowlistResolutions: evaluation.allowlistResolutions) + + if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk { + await self.emitExecEvent( + "exec.denied", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: evaluation.displayCommand, + reason: "allowlist-miss")) + return Self.errorResponse( + req, + code: .unavailable, + message: "SYSTEM_RUN_DENIED: allowlist miss") + } + + self.recordAllowlistMatches( + security: evaluation.security, + allowlistSatisfied: evaluation.allowlistSatisfied, + agentId: evaluation.agentId, + allowlistMatches: evaluation.allowlistMatches, + allowlistResolutions: evaluation.allowlistResolutions, + displayCommand: evaluation.displayCommand) + + if let permissionResponse = await self.validateScreenRecordingIfNeeded( + req: req, + needsScreenRecording: params.needsScreenRecording, + sessionKey: sessionKey, + runId: runId, + displayCommand: evaluation.displayCommand) + { + return permissionResponse + } + + return try await self.executeSystemRun( + req: req, + params: params, + command: command, + env: evaluation.env, + sessionKey: sessionKey, + runId: runId, + displayCommand: evaluation.displayCommand) + } + + private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let params = try Self.decodeParams(OpenClawSystemWhichParams.self, from: req.paramsJSON) + let bins = params.bins + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !bins.isEmpty else { + return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required") + } + + let searchPaths = CommandResolver.preferredPaths() + var matches: [String] = [] + var paths: [String: String] = [:] + for bin in bins { + if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) { + matches.append(bin) + paths[bin] = path + } + } + + struct WhichPayload: Encodable { + let bins: [String] + let paths: [String: String] + } + let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths)) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + } + + private struct ExecApprovalOutcome { + var approvedByAsk: Bool + var persistAllowlist: Bool + var response: BridgeInvokeResponse? + } + + private struct ExecRunContext { + var displayCommand: String + var security: ExecSecurity + var ask: ExecAsk + var agentId: String? + var resolution: ExecCommandResolution? + var allowlistMatch: ExecAllowlistEntry? + var skillAllow: Bool + var sessionKey: String + var runId: String + } + + private func resolveSystemRunApproval( + req: BridgeInvokeRequest, + params: OpenClawSystemRunParams, + context: ExecRunContext) async -> ExecApprovalOutcome + { + let requiresAsk = ExecApprovalHelpers.requiresAsk( + ask: context.ask, + security: context.security, + allowlistMatch: context.allowlistMatch, + skillAllow: context.skillAllow) + + let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision) + var approvedByAsk = params.approved == true || decisionFromParams != nil + var persistAllowlist = decisionFromParams == .allowAlways + if decisionFromParams == .deny { + await self.emitExecEvent( + "exec.denied", + payload: ExecEventPayload( + sessionKey: context.sessionKey, + runId: context.runId, + host: "node", + command: context.displayCommand, + reason: "user-denied")) + return ExecApprovalOutcome( + approvedByAsk: approvedByAsk, + persistAllowlist: persistAllowlist, + response: Self.errorResponse( + req, + code: .unavailable, + message: "SYSTEM_RUN_DENIED: user denied")) + } + + if requiresAsk, !approvedByAsk { + let decision = await MainActor.run { + ExecApprovalsPromptPresenter.prompt( + ExecApprovalPromptRequest( + command: context.displayCommand, + cwd: params.cwd, + host: "node", + security: context.security.rawValue, + ask: context.ask.rawValue, + agentId: context.agentId, + resolvedPath: context.resolution?.resolvedPath, + sessionKey: context.sessionKey)) + } + switch decision { + case .deny: + await self.emitExecEvent( + "exec.denied", + payload: ExecEventPayload( + sessionKey: context.sessionKey, + runId: context.runId, + host: "node", + command: context.displayCommand, + reason: "user-denied")) + return ExecApprovalOutcome( + approvedByAsk: approvedByAsk, + persistAllowlist: persistAllowlist, + response: Self.errorResponse( + req, + code: .unavailable, + message: "SYSTEM_RUN_DENIED: user denied")) + case .allowAlways: + approvedByAsk = true + persistAllowlist = true + case .allowOnce: + approvedByAsk = true + } + } + + return ExecApprovalOutcome( + approvedByAsk: approvedByAsk, + persistAllowlist: persistAllowlist, + response: nil) + } + + private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + _ = ExecApprovalsStore.ensureFile() + let snapshot = ExecApprovalsStore.readSnapshot() + let redacted = ExecApprovalsSnapshot( + path: snapshot.path, + exists: snapshot.exists, + hash: snapshot.hash, + file: ExecApprovalsStore.redactForSnapshot(snapshot.file)) + let payload = try Self.encodePayload(redacted) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + } + + private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + struct SetParams: Decodable { + var file: ExecApprovalsFile + var baseHash: String? + } + + let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON) + let current = ExecApprovalsStore.ensureFile() + let snapshot = ExecApprovalsStore.readSnapshot() + if snapshot.exists { + if snapshot.hash.isEmpty { + return Self.errorResponse( + req, + code: .invalidRequest, + message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry") + } + let baseHash = params.baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if baseHash.isEmpty { + return Self.errorResponse( + req, + code: .invalidRequest, + message: "INVALID_REQUEST: exec approvals base hash required; reload and retry") + } + if baseHash != snapshot.hash { + return Self.errorResponse( + req, + code: .invalidRequest, + message: "INVALID_REQUEST: exec approvals changed; reload and retry") + } + } + + var normalized = ExecApprovalsStore.normalizeIncoming(params.file) + let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) + let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedPath = (socketPath?.isEmpty == false) + ? socketPath! + : current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? + ExecApprovalsStore.socketPath() + let resolvedToken = (token?.isEmpty == false) + ? token! + : current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken) + + ExecApprovalsStore.saveFile(normalized) + let nextSnapshot = ExecApprovalsStore.readSnapshot() + let redacted = ExecApprovalsSnapshot( + path: nextSnapshot.path, + exists: nextSnapshot.exists, + hash: nextSnapshot.hash, + file: ExecApprovalsStore.redactForSnapshot(nextSnapshot.file)) + let payload = try Self.encodePayload(redacted) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + } + + private func emitExecEvent(_ event: String, payload: ExecEventPayload) async { + guard let sender = self.eventSender else { return } + guard let data = try? JSONEncoder().encode(payload), + let json = String(data: data, encoding: .utf8) + else { + return + } + await sender(event, json) + } + + private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + let params = try Self.decodeParams(OpenClawSystemNotifyParams.self, from: req.paramsJSON) + let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + if title.isEmpty, body.isEmpty { + return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: empty notification") + } + + let priority = params.priority.flatMap { NotificationPriority(rawValue: $0.rawValue) } + let delivery = params.delivery.flatMap { NotificationDelivery(rawValue: $0.rawValue) } ?? .system + let manager = NotificationManager() + + switch delivery { + case .system: + let ok = await manager.send( + title: title, + body: body, + sound: params.sound, + priority: priority) + return ok + ? BridgeInvokeResponse(id: req.id, ok: true) + : Self.errorResponse(req, code: .unavailable, message: "NOT_AUTHORIZED: notifications") + case .overlay: + await NotifyOverlayController.shared.present(title: title, body: body) + return BridgeInvokeResponse(id: req.id, ok: true) + case .auto: + let ok = await manager.send( + title: title, + body: body, + sound: params.sound, + priority: priority) + if ok { + return BridgeInvokeResponse(id: req.id, ok: true) + } + await NotifyOverlayController.shared.present(title: title, body: body) + return BridgeInvokeResponse(id: req.id, ok: true) + } + } +} + +extension MacNodeRuntime { + private func persistAllowlistPatterns( + persistAllowlist: Bool, + security: ExecSecurity, + agentId: String?, + command: [String], + allowlistResolutions: [ExecCommandResolution]) + { + guard persistAllowlist, security == .allowlist else { return } + var seenPatterns = Set() + for candidate in allowlistResolutions { + guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else { + continue + } + if seenPatterns.insert(pattern).inserted { + ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern) + } + } + } + + private func recordAllowlistMatches( + security: ExecSecurity, + allowlistSatisfied: Bool, + agentId: String?, + allowlistMatches: [ExecAllowlistEntry], + allowlistResolutions: [ExecCommandResolution], + displayCommand: String) + { + guard security == .allowlist, allowlistSatisfied else { return } + var seenPatterns = Set() + for (idx, match) in allowlistMatches.enumerated() { + if !seenPatterns.insert(match.pattern).inserted { + continue + } + let resolvedPath = idx < allowlistResolutions.count ? allowlistResolutions[idx].resolvedPath : nil + ExecApprovalsStore.recordAllowlistUse( + agentId: agentId, + pattern: match.pattern, + command: displayCommand, + resolvedPath: resolvedPath) + } + } + + private func validateScreenRecordingIfNeeded( + req: BridgeInvokeRequest, + needsScreenRecording: Bool?, + sessionKey: String, + runId: String, + displayCommand: String) async -> BridgeInvokeResponse? + { + guard needsScreenRecording == true else { return nil } + let authorized = await PermissionManager + .status([.screenRecording])[.screenRecording] ?? false + if authorized { + return nil + } + await self.emitExecEvent( + "exec.denied", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: displayCommand, + reason: "permission:screenRecording")) + return Self.errorResponse( + req, + code: .unavailable, + message: "PERMISSION_MISSING: screenRecording") + } + + private func executeSystemRun( + req: BridgeInvokeRequest, + params: OpenClawSystemRunParams, + command: [String], + env: [String: String], + sessionKey: String, + runId: String, + displayCommand: String) async throws -> BridgeInvokeResponse + { + let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 } + await self.emitExecEvent( + "exec.started", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: displayCommand)) + let result = await ShellExecutor.runDetailed( + command: command, + cwd: params.cwd, + env: env, + timeout: timeoutSec) + let combined = [result.stdout, result.stderr, result.errorMessage] + .compactMap(\.self) + .filter { !$0.isEmpty } + .joined(separator: "\n") + await self.emitExecEvent( + "exec.finished", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: displayCommand, + exitCode: result.exitCode, + timedOut: result.timedOut, + success: result.success, + output: ExecEventPayload.truncateOutput(combined))) + + struct RunPayload: Encodable { + var exitCode: Int? + var timedOut: Bool + var success: Bool + var stdout: String + var stderr: String + var error: String? + } + let runPayload = RunPayload( + exitCode: result.exitCode, + timedOut: result.timedOut, + success: result.success, + stdout: result.stdout, + stderr: result.stderr, + error: result.errorMessage) + let payload = try Self.encodePayload(runPayload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + } + + private static func decodeParams(_ type: T.Type, from json: String?) throws -> T { + guard let json, let data = json.data(using: .utf8) else { + throw NSError(domain: "Gateway", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required", + ]) + } + return try JSONDecoder().decode(type, from: data) + } + + private static func encodePayload(_ obj: some Encodable) throws -> String { + let data = try JSONEncoder().encode(obj) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "Node", code: 21, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8", + ]) + } + return json + } + + private nonisolated static func canvasEnabled() -> Bool { + UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true + } + + private nonisolated static func cameraEnabled() -> Bool { + UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false + } + + private nonisolated static func locationMode() -> OpenClawLocationMode { + let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" + return OpenClawLocationMode(rawValue: raw) ?? .off + } + + private nonisolated static func locationPreciseEnabled() -> Bool { + if UserDefaults.standard.object(forKey: locationPreciseKey) == nil { return true } + return UserDefaults.standard.bool(forKey: locationPreciseKey) + } + + private static func errorResponse( + _ req: BridgeInvokeRequest, + code: OpenClawNodeErrorCode, + message: String) -> BridgeInvokeResponse + { + BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: code, message: message)) + } + + private static func encodeCanvasSnapshot( + image: NSImage, + format: OpenClawCanvasSnapshotFormat, + maxWidth: Int?, + quality: Double) throws -> Data + { + let source = Self.scaleImage(image, maxWidth: maxWidth) ?? image + guard let tiff = source.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff) + else { + throw NSError(domain: "Canvas", code: 22, userInfo: [ + NSLocalizedDescriptionKey: "snapshot encode failed", + ]) + } + + switch format { + case .png: + guard let data = rep.representation(using: .png, properties: [:]) else { + throw NSError(domain: "Canvas", code: 23, userInfo: [ + NSLocalizedDescriptionKey: "png encode failed", + ]) + } + return data + case .jpeg: + let clamped = min(1.0, max(0.05, quality)) + guard let data = rep.representation( + using: .jpeg, + properties: [.compressionFactor: clamped]) + else { + throw NSError(domain: "Canvas", code: 24, userInfo: [ + NSLocalizedDescriptionKey: "jpeg encode failed", + ]) + } + return data + } + } + + private static func scaleImage(_ image: NSImage, maxWidth: Int?) -> NSImage? { + guard let maxWidth, maxWidth > 0 else { return image } + let size = image.size + guard size.width > 0, size.width > CGFloat(maxWidth) else { return image } + let scale = CGFloat(maxWidth) / size.width + let target = NSSize(width: CGFloat(maxWidth), height: size.height * scale) + + let out = NSImage(size: target) + out.lockFocus() + image.draw( + in: NSRect(origin: .zero, size: target), + from: NSRect(origin: .zero, size: size), + operation: .copy, + fraction: 1.0) + out.unlockFocus() + return out + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntimeMainActorServices.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntimeMainActorServices.swift new file mode 100644 index 00000000..733410b1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntimeMainActorServices.swift @@ -0,0 +1,60 @@ +import CoreLocation +import Foundation +import OpenClawKit + +@MainActor +protocol MacNodeRuntimeMainActorServices: Sendable { + func recordScreen( + screenIndex: Int?, + durationMs: Int?, + fps: Double?, + includeAudio: Bool?, + outPath: String?) async throws -> (path: String, hasAudio: Bool) + + func locationAuthorizationStatus() -> CLAuthorizationStatus + func locationAccuracyAuthorization() -> CLAccuracyAuthorization + func currentLocation( + desiredAccuracy: OpenClawLocationAccuracy, + maxAgeMs: Int?, + timeoutMs: Int?) async throws -> CLLocation +} + +@MainActor +final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable { + private let screenRecorder = ScreenRecordService() + private let locationService = MacNodeLocationService() + + func recordScreen( + screenIndex: Int?, + durationMs: Int?, + fps: Double?, + includeAudio: Bool?, + outPath: String?) async throws -> (path: String, hasAudio: Bool) + { + try await self.screenRecorder.record( + screenIndex: screenIndex, + durationMs: durationMs, + fps: fps, + includeAudio: includeAudio, + outPath: outPath) + } + + func locationAuthorizationStatus() -> CLAuthorizationStatus { + self.locationService.authorizationStatus() + } + + func locationAccuracyAuthorization() -> CLAccuracyAuthorization { + self.locationService.accuracyAuthorization() + } + + func currentLocation( + desiredAccuracy: OpenClawLocationAccuracy, + maxAgeMs: Int?, + timeoutMs: Int?) async throws -> CLLocation + { + try await self.locationService.currentLocation( + desiredAccuracy: desiredAccuracy, + maxAgeMs: maxAgeMs, + timeoutMs: timeoutMs) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeScreenCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeScreenCommands.swift new file mode 100644 index 00000000..6f849fdf --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeMode/MacNodeScreenCommands.swift @@ -0,0 +1,13 @@ +import Foundation + +enum MacNodeScreenCommand: String, Codable, Sendable { + case record = "screen.record" +} + +struct MacNodeScreenRecordParams: Codable, Sendable, Equatable { + var screenIndex: Int? + var durationMs: Int? + var fps: Double? + var format: String? + var includeAudio: Bool? +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift new file mode 100644 index 00000000..10598d7f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift @@ -0,0 +1,682 @@ +import AppKit +import Foundation +import Observation +import OpenClawDiscovery +import OpenClawIPC +import OpenClawKit +import OpenClawProtocol +import OSLog +import UserNotifications + +enum NodePairingReconcilePolicy { + static let activeIntervalMs: UInt64 = 15000 + static let resyncDelayMs: UInt64 = 250 + + static func shouldPoll(pendingCount: Int, isPresenting: Bool) -> Bool { + pendingCount > 0 || isPresenting + } +} + +@MainActor +@Observable +final class NodePairingApprovalPrompter { + static let shared = NodePairingApprovalPrompter() + + private let logger = Logger(subsystem: "ai.openclaw", category: "node-pairing") + private var task: Task? + private var reconcileTask: Task? + private var reconcileOnceTask: Task? + private var reconcileInFlight = false + private var isStopping = false + private var isPresenting = false + private var queue: [PendingRequest] = [] + var pendingCount: Int = 0 + var pendingRepairCount: Int = 0 + private var activeAlert: NSAlert? + private var activeRequestId: String? + private var alertHostWindow: NSWindow? + private var remoteResolutionsByRequestId: [String: PairingResolution] = [:] + private var autoApproveAttempts: Set = [] + + private struct PairingList: Codable { + let pending: [PendingRequest] + let paired: [PairedNode]? + } + + private struct PairedNode: Codable, Equatable { + let nodeId: String + let approvedAtMs: Double? + let displayName: String? + let platform: String? + let version: String? + let remoteIp: String? + } + + private struct PendingRequest: Codable, Equatable, Identifiable { + let requestId: String + let nodeId: String + let displayName: String? + let platform: String? + let version: String? + let remoteIp: String? + let isRepair: Bool? + let silent: Bool? + let ts: Double + + var id: String { + self.requestId + } + } + + private struct PairingResolvedEvent: Codable { + let requestId: String + let nodeId: String + let decision: String + let ts: Double + } + + private enum PairingResolution: String { + case approved + case rejected + } + + func start() { + guard self.task == nil else { return } + self.isStopping = false + self.reconcileTask?.cancel() + self.reconcileTask = nil + self.task = Task { [weak self] in + guard let self else { return } + _ = try? await GatewayConnection.shared.refresh() + await self.loadPendingRequestsFromGateway() + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) + for await push in stream { + if Task.isCancelled { return } + await MainActor.run { [weak self] in self?.handle(push: push) } + } + } + } + + func stop() { + self.isStopping = true + self.endActiveAlert() + self.task?.cancel() + self.task = nil + self.reconcileTask?.cancel() + self.reconcileTask = nil + self.reconcileOnceTask?.cancel() + self.reconcileOnceTask = nil + self.queue.removeAll(keepingCapacity: false) + self.updatePendingCounts() + self.isPresenting = false + self.activeRequestId = nil + self.alertHostWindow?.orderOut(nil) + self.alertHostWindow?.close() + self.alertHostWindow = nil + self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false) + self.autoApproveAttempts.removeAll(keepingCapacity: false) + } + + private func loadPendingRequestsFromGateway() async { + // The gateway process may start slightly after the app. Retry a bit so + // pending pairing prompts are still shown on launch. + var delayMs: UInt64 = 200 + for attempt in 1...8 { + if Task.isCancelled { return } + do { + let data = try await GatewayConnection.shared.request( + method: "node.pair.list", + params: nil, + timeoutMs: 6000) + guard !data.isEmpty else { return } + let list = try JSONDecoder().decode(PairingList.self, from: data) + let pendingCount = list.pending.count + guard pendingCount > 0 else { return } + self.logger.info( + "loaded \(pendingCount, privacy: .public) pending node pairing request(s) on startup") + await self.apply(list: list) + return + } catch { + if attempt == 8 { + self.logger + .error( + "failed to load pending pairing requests: \(error.localizedDescription, privacy: .public)") + return + } + try? await Task.sleep(nanoseconds: delayMs * 1_000_000) + delayMs = min(delayMs * 2, 2000) + } + } + } + + private func reconcileLoop() async { + // Reconcile requests periodically so multiple running apps stay in sync + // (e.g. close dialogs + notify if another machine approves/rejects via app or CLI). + while !Task.isCancelled { + if self.isStopping { break } + if !self.shouldPoll { + self.reconcileTask = nil + return + } + await self.reconcileOnce(timeoutMs: 2500) + try? await Task.sleep( + nanoseconds: NodePairingReconcilePolicy.activeIntervalMs * 1_000_000) + } + self.reconcileTask = nil + } + + private func fetchPairingList(timeoutMs: Double) async throws -> PairingList { + let data = try await GatewayConnection.shared.request( + method: "node.pair.list", + params: nil, + timeoutMs: timeoutMs) + return try JSONDecoder().decode(PairingList.self, from: data) + } + + private func apply(list: PairingList) async { + if self.isStopping { return } + + let pendingById = Dictionary( + uniqueKeysWithValues: list.pending.map { ($0.requestId, $0) }) + + // Enqueue any missing requests (covers missed pushes while reconnecting). + for req in list.pending.sorted(by: { $0.ts < $1.ts }) { + self.enqueue(req) + } + + // Detect resolved requests (approved/rejected elsewhere). + let queued = self.queue + for req in queued { + if pendingById[req.requestId] != nil { continue } + let resolution = self.inferResolution(for: req, list: list) + + if self.activeRequestId == req.requestId, self.activeAlert != nil { + self.remoteResolutionsByRequestId[req.requestId] = resolution + self.logger.info( + """ + pairing request resolved elsewhere; closing dialog \ + requestId=\(req.requestId, privacy: .public) \ + resolution=\(resolution.rawValue, privacy: .public) + """) + self.endActiveAlert() + continue + } + + self.logger.info( + """ + pairing request resolved elsewhere requestId=\(req.requestId, privacy: .public) \ + resolution=\(resolution.rawValue, privacy: .public) + """) + self.queue.removeAll { $0 == req } + Task { @MainActor in + await self.notify(resolution: resolution, request: req, via: "remote") + } + } + + if self.queue.isEmpty { + self.isPresenting = false + } + self.presentNextIfNeeded() + self.updateReconcileLoop() + } + + private func inferResolution(for request: PendingRequest, list: PairingList) -> PairingResolution { + let paired = list.paired ?? [] + guard let node = paired.first(where: { $0.nodeId == request.nodeId }) else { + return .rejected + } + if request.isRepair == true, let approvedAtMs = node.approvedAtMs { + return approvedAtMs >= request.ts ? .approved : .rejected + } + return .approved + } + + private func endActiveAlert() { + PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId) + } + + private func requireAlertHostWindow() -> NSWindow { + PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow) + } + + private func handle(push: GatewayPush) { + switch push { + case let .event(evt) where evt.event == "node.pair.requested": + guard let payload = evt.payload else { return } + do { + let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self) + self.enqueue(req) + } catch { + self.logger + .error("failed to decode pairing request: \(error.localizedDescription, privacy: .public)") + } + case let .event(evt) where evt.event == "node.pair.resolved": + guard let payload = evt.payload else { return } + do { + let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self) + self.handleResolved(resolved) + } catch { + self.logger + .error( + "failed to decode pairing resolution: \(error.localizedDescription, privacy: .public)") + } + case .snapshot: + self.scheduleReconcileOnce(delayMs: 0) + case .seqGap: + self.scheduleReconcileOnce() + default: + return + } + } + + private func enqueue(_ req: PendingRequest) { + if self.queue.contains(req) { return } + self.queue.append(req) + self.updatePendingCounts() + self.presentNextIfNeeded() + self.updateReconcileLoop() + } + + private func presentNextIfNeeded() { + guard !self.isStopping else { return } + guard !self.isPresenting else { return } + guard let next = self.queue.first else { return } + self.isPresenting = true + Task { @MainActor [weak self] in + guard let self else { return } + if await self.trySilentApproveIfPossible(next) { + return + } + self.presentAlert(for: next) + } + } + + private func presentAlert(for req: PendingRequest) { + self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)") + NSApp.activate(ignoringOtherApps: true) + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Allow node to connect?" + alert.informativeText = Self.describe(req) + // Fail-safe ordering: if the dialog can't be presented, default to "Later". + alert.addButton(withTitle: "Later") + alert.addButton(withTitle: "Approve") + alert.addButton(withTitle: "Reject") + if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { + alert.buttons[2].hasDestructiveAction = true + } + + self.activeAlert = alert + self.activeRequestId = req.requestId + let hostWindow = self.requireAlertHostWindow() + + // Position the hidden host window so the sheet appears centered on screen. + // (Sheets attach to the top edge of their parent window; if the parent is tiny, it looks "anchored".) + let sheetSize = alert.window.frame.size + if let screen = hostWindow.screen ?? NSScreen.main { + let bounds = screen.visibleFrame + let x = bounds.midX - (sheetSize.width / 2) + let sheetOriginY = bounds.midY - (sheetSize.height / 2) + let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height + hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY)) + } else { + hostWindow.center() + } + + hostWindow.makeKeyAndOrderFront(nil) + alert.beginSheetModal(for: hostWindow) { [weak self] response in + Task { @MainActor [weak self] in + guard let self else { return } + self.activeRequestId = nil + self.activeAlert = nil + await self.handleAlertResponse(response, request: req) + hostWindow.orderOut(nil) + } + } + } + + private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { + defer { + if self.queue.first == request { + self.queue.removeFirst() + } else { + self.queue.removeAll { $0 == request } + } + self.updatePendingCounts() + self.isPresenting = false + self.presentNextIfNeeded() + self.updateReconcileLoop() + } + + // Never approve/reject while shutting down (alerts can get dismissed during app termination). + guard !self.isStopping else { return } + + if let resolved = self.remoteResolutionsByRequestId.removeValue(forKey: request.requestId) { + await self.notify(resolution: resolved, request: request, via: "remote") + return + } + + switch response { + case .alertFirstButtonReturn: + // Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL. + return + case .alertSecondButtonReturn: + _ = await self.approve(requestId: request.requestId) + await self.notify(resolution: .approved, request: request, via: "local") + case .alertThirdButtonReturn: + await self.reject(requestId: request.requestId) + await self.notify(resolution: .rejected, request: request, via: "local") + default: + return + } + } + + private func approve(requestId: String) async -> Bool { + do { + try await GatewayConnection.shared.nodePairApprove(requestId: requestId) + self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)") + return true + } catch { + self.logger.error("approve failed requestId=\(requestId, privacy: .public)") + self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)") + return false + } + } + + private func reject(requestId: String) async { + do { + try await GatewayConnection.shared.nodePairReject(requestId: requestId) + self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)") + } catch { + self.logger.error("reject failed requestId=\(requestId, privacy: .public)") + self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)") + } + } + + private static func describe(_ req: PendingRequest) -> String { + let name = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) + let platform = self.prettyPlatform(req.platform) + let version = req.version?.trimmingCharacters(in: .whitespacesAndNewlines) + let ip = self.prettyIP(req.remoteIp) + + var lines: [String] = [] + lines.append("Name: \(name?.isEmpty == false ? name! : "Unknown")") + lines.append("Node ID: \(req.nodeId)") + if let platform, !platform.isEmpty { lines.append("Platform: \(platform)") } + if let version, !version.isEmpty { lines.append("App: \(version)") } + if let ip, !ip.isEmpty { lines.append("IP: \(ip)") } + if req.isRepair == true { lines.append("Note: Repair request (token will rotate).") } + return lines.joined(separator: "\n") + } + + private static func prettyIP(_ ip: String?) -> String? { + let trimmed = ip?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let trimmed, !trimmed.isEmpty else { return nil } + return trimmed.replacingOccurrences(of: "::ffff:", with: "") + } + + private static func prettyPlatform(_ platform: String?) -> String? { + let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let raw, !raw.isEmpty else { return nil } + if raw.lowercased() == "ios" { return "iOS" } + if raw.lowercased() == "macos" { return "macOS" } + return raw + } + + private func notify(resolution: PairingResolution, request: PendingRequest, via: String) async { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + guard settings.authorizationStatus == .authorized || + settings.authorizationStatus == .provisional + else { + return + } + + let title = resolution == .approved ? "Node pairing approved" : "Node pairing rejected" + let name = request.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) + let device = name?.isEmpty == false ? name! : request.nodeId + let body = "\(device)\n(via \(via))" + + _ = await NotificationManager().send( + title: title, + body: body, + sound: nil, + priority: .active) + } + + private struct SSHTarget { + let host: String + let port: Int + } + + private func trySilentApproveIfPossible(_ req: PendingRequest) async -> Bool { + guard req.silent == true else { return false } + if self.autoApproveAttempts.contains(req.requestId) { return false } + self.autoApproveAttempts.insert(req.requestId) + + guard let target = await self.resolveSSHTarget() else { + self.logger.info("silent pairing skipped (no ssh target) requestId=\(req.requestId, privacy: .public)") + return false + } + + let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines) + guard !user.isEmpty else { + self.logger.info("silent pairing skipped (missing local user) requestId=\(req.requestId, privacy: .public)") + return false + } + + let ok = await Self.probeSSH(user: user, host: target.host, port: target.port) + if !ok { + self.logger.info("silent pairing probe failed requestId=\(req.requestId, privacy: .public)") + return false + } + + guard await self.approve(requestId: req.requestId) else { + self.logger.info("silent pairing approve failed requestId=\(req.requestId, privacy: .public)") + return false + } + + await self.notify(resolution: .approved, request: req, via: "silent-ssh") + if self.queue.first == req { + self.queue.removeFirst() + } else { + self.queue.removeAll { $0 == req } + } + + self.updatePendingCounts() + self.isPresenting = false + self.presentNextIfNeeded() + self.updateReconcileLoop() + return true + } + + private func resolveSSHTarget() async -> SSHTarget? { + let settings = CommandResolver.connectionSettings() + if !settings.target.isEmpty, let parsed = CommandResolver.parseSSHTarget(settings.target) { + let user = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines) + if let targetUser = parsed.user, + !targetUser.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + targetUser != user + { + self.logger.info("silent pairing skipped (ssh user mismatch)") + return nil + } + let host = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { return nil } + let port = parsed.port > 0 ? parsed.port : 22 + return SSHTarget(host: host, port: port) + } + + let model = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) + model.start() + defer { model.stop() } + + let deadline = Date().addingTimeInterval(5.0) + while model.gateways.isEmpty, Date() < deadline { + try? await Task.sleep(nanoseconds: 200_000_000) + } + + let preferred = GatewayDiscoveryPreferences.preferredStableID() + let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first + guard let gateway else { return nil } + guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway), + let parsed = CommandResolver.parseSSHTarget(target) + else { + return nil + } + return SSHTarget(host: parsed.host, port: parsed.port) + } + + private static func probeSSH(user: String, host: String, port: Int) async -> Bool { + await Task.detached(priority: .utility) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + + let options = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + "-o", "NumberOfPasswordPrompts=0", + "-o", "PreferredAuthentications=publickey", + "-o", "StrictHostKeyChecking=accept-new", + ] + guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else { + return false + } + let args = CommandResolver.sshArguments( + target: target, + identity: "", + options: options, + remoteCommand: ["/usr/bin/true"]) + process.arguments = args + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + _ = try process.runAndReadToEnd(from: pipe) + } catch { + return false + } + return process.terminationStatus == 0 + }.value + } + + private var shouldPoll: Bool { + NodePairingReconcilePolicy.shouldPoll( + pendingCount: self.queue.count, + isPresenting: self.isPresenting) + } + + private func updateReconcileLoop() { + guard !self.isStopping else { return } + if self.shouldPoll { + if self.reconcileTask == nil { + self.reconcileTask = Task { [weak self] in + await self?.reconcileLoop() + } + } + } else { + self.reconcileTask?.cancel() + self.reconcileTask = nil + } + } + + private func updatePendingCounts() { + // Keep a cheap observable summary for the menu bar status line. + self.pendingCount = self.queue.count + self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true }) + } + + private func reconcileOnce(timeoutMs: Double) async { + if self.isStopping { return } + if self.reconcileInFlight { return } + self.reconcileInFlight = true + defer { self.reconcileInFlight = false } + do { + let list = try await self.fetchPairingList(timeoutMs: timeoutMs) + await self.apply(list: list) + } catch { + // best effort: ignore transient connectivity failures + } + } + + private func scheduleReconcileOnce(delayMs: UInt64 = NodePairingReconcilePolicy.resyncDelayMs) { + self.reconcileOnceTask?.cancel() + self.reconcileOnceTask = Task { [weak self] in + guard let self else { return } + if delayMs > 0 { + try? await Task.sleep(nanoseconds: delayMs * 1_000_000) + } + await self.reconcileOnce(timeoutMs: 2500) + } + } + + private func handleResolved(_ resolved: PairingResolvedEvent) { + let resolution: PairingResolution = + resolved.decision == PairingResolution.approved.rawValue ? .approved : .rejected + + if self.activeRequestId == resolved.requestId, self.activeAlert != nil { + self.remoteResolutionsByRequestId[resolved.requestId] = resolution + self.logger.info( + """ + pairing request resolved elsewhere; closing dialog \ + requestId=\(resolved.requestId, privacy: .public) \ + resolution=\(resolution.rawValue, privacy: .public) + """) + self.endActiveAlert() + return + } + + guard let request = self.queue.first(where: { $0.requestId == resolved.requestId }) else { + return + } + self.queue.removeAll { $0.requestId == resolved.requestId } + self.updatePendingCounts() + Task { @MainActor in + await self.notify(resolution: resolution, request: request, via: "remote") + } + if self.queue.isEmpty { + self.isPresenting = false + } + self.presentNextIfNeeded() + self.updateReconcileLoop() + } +} + +#if DEBUG +@MainActor +extension NodePairingApprovalPrompter { + static func exerciseForTesting() async { + let prompter = NodePairingApprovalPrompter() + let pending = PendingRequest( + requestId: "req-1", + nodeId: "node-1", + displayName: "Node One", + platform: "macos", + version: "1.0.0", + remoteIp: "127.0.0.1", + isRepair: false, + silent: true, + ts: 1_700_000_000_000) + let paired = PairedNode( + nodeId: "node-1", + approvedAtMs: 1_700_000_000_000, + displayName: "Node One", + platform: "macOS", + version: "1.0.0", + remoteIp: "127.0.0.1") + let list = PairingList(pending: [pending], paired: [paired]) + + _ = Self.describe(pending) + _ = Self.prettyIP(pending.remoteIp) + _ = Self.prettyPlatform(pending.platform) + _ = prompter.inferResolution(for: pending, list: list) + + prompter.queue = [pending] + _ = prompter.shouldPoll + _ = await prompter.trySilentApproveIfPossible(pending) + prompter.queue.removeAll() + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeServiceManager.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeServiceManager.swift new file mode 100644 index 00000000..38d0aa30 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodeServiceManager.swift @@ -0,0 +1,150 @@ +import Foundation +import OSLog + +enum NodeServiceManager { + private static let logger = Logger(subsystem: "ai.openclaw", category: "node.service") + + static func start() async -> String? { + let result = await self.runServiceCommandResult( + ["node", "start"], + timeout: 20, + quiet: false) + if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) { + self.logger.error("node service start failed: \(error, privacy: .public)") + return error + } + return nil + } + + static func stop() async -> String? { + let result = await self.runServiceCommandResult( + ["node", "stop"], + timeout: 15, + quiet: false) + if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) { + self.logger.error("node service stop failed: \(error, privacy: .public)") + return error + } + return nil + } +} + +extension NodeServiceManager { + private struct CommandResult { + let success: Bool + let payload: Data? + let message: String? + let parsed: ParsedServiceJson? + } + + private struct ParsedServiceJson { + let text: String + let object: [String: Any] + let ok: Bool? + let result: String? + let message: String? + let error: String? + let hints: [String] + } + + private static func runServiceCommandResult( + _ args: [String], + timeout: Double, + quiet: Bool) async -> CommandResult + { + let command = CommandResolver.openclawCommand( + subcommand: "service", + extraArgs: self.withJsonFlag(args), + // Service management must always run locally, even if remote mode is configured. + configRoot: ["gateway": ["mode": "local"]]) + var env = ProcessInfo.processInfo.environment + env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") + let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) + let parsed = self.parseServiceJson(from: response.stdout) ?? self.parseServiceJson(from: response.stderr) + let ok = parsed?.ok + let message = parsed?.error ?? parsed?.message + let payload = parsed?.text.data(using: .utf8) + ?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8) + let success = ok ?? response.success + if success { + return CommandResult(success: true, payload: payload, message: nil, parsed: parsed) + } + + if quiet { + return CommandResult(success: false, payload: payload, message: message, parsed: parsed) + } + + let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout) + let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed") + let fullMessage = detail.map { "Node service command failed (\(exit)): \($0)" } + ?? "Node service command failed (\(exit))" + self.logger.error("\(fullMessage, privacy: .public)") + return CommandResult(success: false, payload: payload, message: detail, parsed: parsed) + } + + private static func errorMessage(from result: CommandResult, treatNotLoadedAsError: Bool) -> String? { + if !result.success { + return result.message ?? "Node service command failed" + } + guard let parsed = result.parsed else { return nil } + if parsed.ok == false { + return self.mergeHints(message: parsed.error ?? parsed.message, hints: parsed.hints) + } + if treatNotLoadedAsError, parsed.result == "not-loaded" { + let base = parsed.message ?? "Node service not loaded." + return self.mergeHints(message: base, hints: parsed.hints) + } + return nil + } + + private static func withJsonFlag(_ args: [String]) -> [String] { + if args.contains("--json") { return args } + return args + ["--json"] + } + + private static func parseServiceJson(from raw: String) -> ParsedServiceJson? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let start = trimmed.firstIndex(of: "{"), + let end = trimmed.lastIndex(of: "}") + else { + return nil + } + let jsonText = String(trimmed[start...end]) + guard let data = jsonText.data(using: .utf8) else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + let ok = object["ok"] as? Bool + let result = object["result"] as? String + let message = object["message"] as? String + let error = object["error"] as? String + let hints = (object["hints"] as? [String]) ?? [] + return ParsedServiceJson( + text: jsonText, + object: object, + ok: ok, + result: result, + message: message, + error: error, + hints: hints) + } + + private static func mergeHints(message: String?, hints: [String]) -> String? { + let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines) + let nonEmpty = trimmed?.isEmpty == false ? trimmed : nil + guard !hints.isEmpty else { return nonEmpty } + let hintText = hints.prefix(2).joined(separator: " · ") + if let nonEmpty { + return "\(nonEmpty) (\(hintText))" + } + return hintText + } + + private static func summarize(_ text: String) -> String? { + let lines = text + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard let last = lines.last else { return nil } + let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodesMenu.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodesMenu.swift new file mode 100644 index 00000000..f88177d8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodesMenu.swift @@ -0,0 +1,333 @@ +import AppKit +import SwiftUI + +struct NodeMenuEntryFormatter { + static func isGateway(_ entry: NodeInfo) -> Bool { + entry.nodeId == "gateway" + } + + static func isConnected(_ entry: NodeInfo) -> Bool { + entry.isConnected + } + + static func primaryName(_ entry: NodeInfo) -> String { + if self.isGateway(entry) { + return entry.displayName?.nonEmpty ?? "Gateway" + } + return entry.displayName?.nonEmpty ?? entry.nodeId + } + + static func summaryText(_ entry: NodeInfo) -> String { + if self.isGateway(entry) { + let role = self.roleText(entry) + let name = self.primaryName(entry) + var parts = ["\(name) · \(role)"] + if let ip = entry.remoteIp?.nonEmpty { parts.append("host \(ip)") } + if let platform = self.platformText(entry) { parts.append(platform) } + return parts.joined(separator: " · ") + } + let name = self.primaryName(entry) + var prefix = "Node: \(name)" + if let ip = entry.remoteIp?.nonEmpty { + prefix += " (\(ip))" + } + var parts = [prefix] + if let platform = self.platformText(entry) { + parts.append("platform \(platform)") + } + let versionLabels = self.versionLabels(entry) + if !versionLabels.isEmpty { + parts.append(versionLabels.joined(separator: " · ")) + } + parts.append("status \(self.roleText(entry))") + return parts.joined(separator: " · ") + } + + static func roleText(_ entry: NodeInfo) -> String { + if entry.isConnected { return "connected" } + if self.isGateway(entry) { return "disconnected" } + if entry.isPaired { return "paired" } + return "unpaired" + } + + static func detailLeft(_ entry: NodeInfo) -> String { + let role = self.roleText(entry) + if let ip = entry.remoteIp?.nonEmpty { return "\(ip) · \(role)" } + return role + } + + static func headlineRight(_ entry: NodeInfo) -> String? { + self.platformText(entry) + } + + static func detailRightVersion(_ entry: NodeInfo) -> String? { + let labels = self.versionLabels(entry, compact: false) + if labels.isEmpty { return nil } + return labels.joined(separator: " · ") + } + + static func platformText(_ entry: NodeInfo) -> String? { + if let raw = entry.platform?.nonEmpty { + return self.prettyPlatform(raw) ?? raw + } + if let family = entry.deviceFamily?.lowercased() { + if family.contains("mac") { return "macOS" } + if family.contains("iphone") { return "iOS" } + if family.contains("ipad") { return "iPadOS" } + if family.contains("android") { return "Android" } + } + return nil + } + + private static func prettyPlatform(_ raw: String) -> String? { + let (prefix, version) = self.parsePlatform(raw) + if prefix.isEmpty { return nil } + let name: String = switch prefix { + case "macos": "macOS" + case "ios": "iOS" + case "ipados": "iPadOS" + case "tvos": "tvOS" + case "watchos": "watchOS" + default: prefix.prefix(1).uppercased() + prefix.dropFirst() + } + guard let version, !version.isEmpty else { return name } + let parts = version.split(separator: ".").map(String.init) + if parts.count >= 2 { + return "\(name) \(parts[0]).\(parts[1])" + } + return "\(name) \(version)" + } + + private static func parsePlatform(_ raw: String) -> (prefix: String, version: String?) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return ("", nil) } + let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) + let prefix = parts.first?.lowercased() ?? "" + let versionToken = parts.dropFirst().first + return (prefix, versionToken) + } + + private static func compactVersion(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + if let range = trimmed.range( + of: #"\s*\([^)]*\d[^)]*\)$"#, + options: .regularExpression) + { + return String(trimmed[.. String { + let compact = self.compactVersion(raw) + if compact.isEmpty { return compact } + if compact.lowercased().hasPrefix("v") { return compact } + if let first = compact.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) { + return "v\(compact)" + } + return compact + } + + private static func versionLabels(_ entry: NodeInfo, compact: Bool = true) -> [String] { + let (core, ui) = self.resolveVersions(entry) + var labels: [String] = [] + if let core { + let label = compact ? self.compactVersion(core) : self.shortVersionLabel(core) + labels.append("core \(label)") + } + if let ui { + let label = compact ? self.compactVersion(ui) : self.shortVersionLabel(ui) + labels.append("ui \(label)") + } + return labels + } + + private static func resolveVersions(_ entry: NodeInfo) -> (core: String?, ui: String?) { + let core = entry.coreVersion?.nonEmpty + let ui = entry.uiVersion?.nonEmpty + if core != nil || ui != nil { + return (core, ui) + } + guard let legacy = entry.version?.nonEmpty else { return (nil, nil) } + if self.isHeadlessPlatform(entry) { + return (legacy, nil) + } + return (nil, legacy) + } + + private static func isHeadlessPlatform(_ entry: NodeInfo) -> Bool { + let raw = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + if raw == "darwin" || raw == "linux" || raw == "win32" || raw == "windows" { return true } + return false + } + + static func leadingSymbol(_ entry: NodeInfo) -> String { + if self.isGateway(entry) { + return self.safeSystemSymbol( + "antenna.radiowaves.left.and.right", + fallback: "dot.radiowaves.left.and.right") + } + if let family = entry.deviceFamily?.lowercased() { + if family.contains("mac") { + return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") + } + if family.contains("iphone") { return self.safeSystemSymbol("iphone", fallback: "iphone") } + if family.contains("ipad") { return self.safeSystemSymbol("ipad", fallback: "ipad") } + } + if let platform = entry.platform?.lowercased() { + if platform.contains("mac") { return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") } + if platform.contains("ios") { return self.safeSystemSymbol("iphone", fallback: "iphone") } + if platform.contains("android") { return self.safeSystemSymbol("cpu", fallback: "cpu") } + } + return "cpu" + } + + static func isAndroid(_ entry: NodeInfo) -> Bool { + let family = entry.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if family == "android" { return true } + let platform = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return platform?.contains("android") == true + } + + private static func safeSystemSymbol(_ preferred: String, fallback: String) -> String { + if NSImage(systemSymbolName: preferred, accessibilityDescription: nil) != nil { return preferred } + return fallback + } +} + +struct NodeMenuRowView: View { + let entry: NodeInfo + let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + + private var primaryColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary + } + + private var secondaryColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary + } + + var body: some View { + HStack(alignment: .center, spacing: 10) { + self.leadingIcon + .frame(width: 22, height: 22, alignment: .center) + + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(NodeMenuEntryFormatter.primaryName(self.entry)) + .font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular)) + .foregroundStyle(self.primaryColor) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) + + Spacer(minLength: 8) + + HStack(alignment: .firstTextBaseline, spacing: 6) { + if let right = NodeMenuEntryFormatter.headlineRight(self.entry) { + Text(right) + .font(.caption.monospacedDigit()) + .foregroundStyle(self.secondaryColor) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(2) + } + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(self.secondaryColor) + .padding(.leading, 2) + } + } + + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(NodeMenuEntryFormatter.detailLeft(self.entry)) + .font(.caption) + .foregroundStyle(self.secondaryColor) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + + if let version = NodeMenuEntryFormatter.detailRightVersion(self.entry) { + Text(version) + .font(.caption.monospacedDigit()) + .foregroundStyle(self.secondaryColor) + .lineLimit(1) + .truncationMode(.middle) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 8) + .padding(.leading, 18) + .padding(.trailing, 12) + .frame(width: max(1, self.width), alignment: .leading) + } + + @ViewBuilder + private var leadingIcon: some View { + if NodeMenuEntryFormatter.isAndroid(self.entry) { + AndroidMark() + .foregroundStyle(self.secondaryColor) + } else { + Image(systemName: NodeMenuEntryFormatter.leadingSymbol(self.entry)) + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(self.secondaryColor) + } + } +} + +struct AndroidMark: View { + var body: some View { + GeometryReader { geo in + let w = geo.size.width + let h = geo.size.height + let headHeight = h * 0.68 + let headWidth = w * 0.92 + let headX = (w - headWidth) * 0.5 + let headY = (h - headHeight) * 0.5 + let corner = min(w, h) * 0.18 + RoundedRectangle(cornerRadius: corner, style: .continuous) + .frame(width: headWidth, height: headHeight) + .position(x: headX + headWidth * 0.5, y: headY + headHeight * 0.5) + } + } +} + +struct NodeMenuMultilineView: View { + let label: String + let value: String + let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + + private var primaryColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary + } + + private var secondaryColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text("\(self.label):") + .font(.caption.weight(.semibold)) + .foregroundStyle(self.secondaryColor) + + Text(self.value) + .font(.caption) + .foregroundStyle(self.primaryColor) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 6) + .padding(.leading, 18) + .padding(.trailing, 12) + .frame(width: max(1, self.width), alignment: .leading) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodesStore.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodesStore.swift new file mode 100644 index 00000000..5cc94858 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NodesStore.swift @@ -0,0 +1,110 @@ +import Foundation +import Observation +import OSLog + +struct NodeInfo: Identifiable, Codable { + let nodeId: String + let displayName: String? + let platform: String? + let version: String? + let coreVersion: String? + let uiVersion: String? + let deviceFamily: String? + let modelIdentifier: String? + let remoteIp: String? + let caps: [String]? + let commands: [String]? + let permissions: [String: Bool]? + let paired: Bool? + let connected: Bool? + + var id: String { + self.nodeId + } + + var isConnected: Bool { + self.connected ?? false + } + + var isPaired: Bool { + self.paired ?? false + } +} + +private struct NodeListResponse: Codable { + let ts: Double? + let nodes: [NodeInfo] +} + +@MainActor +@Observable +final class NodesStore { + static let shared = NodesStore() + + var nodes: [NodeInfo] = [] + var lastError: String? + var statusMessage: String? + var isLoading = false + + private let logger = Logger(subsystem: "ai.openclaw", category: "nodes") + private var task: Task? + private let interval: TimeInterval = 30 + private var startCount = 0 + + func start() { + self.startCount += 1 + guard self.startCount == 1 else { return } + guard self.task == nil else { return } + self.task = Task.detached { [weak self] in + guard let self else { return } + await self.refresh() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) + await self.refresh() + } + } + } + + func stop() { + guard self.startCount > 0 else { return } + self.startCount -= 1 + guard self.startCount == 0 else { return } + self.task?.cancel() + self.task = nil + } + + func refresh() async { + if self.isLoading { return } + self.statusMessage = nil + self.isLoading = true + defer { self.isLoading = false } + do { + let data = try await GatewayConnection.shared.requestRaw(method: "node.list", params: nil, timeoutMs: 8000) + let decoded = try JSONDecoder().decode(NodeListResponse.self, from: data) + self.nodes = decoded.nodes + self.lastError = nil + self.statusMessage = nil + } catch { + if Self.isCancelled(error) { + self.logger.debug("node.list cancelled; keeping last nodes") + if self.nodes.isEmpty { + self.statusMessage = "Refreshing devices…" + } + self.lastError = nil + return + } + self.logger.error("node.list failed \(error.localizedDescription, privacy: .public)") + self.nodes = [] + self.lastError = error.localizedDescription + self.statusMessage = nil + } + } + + private static func isCancelled(_ error: Error) -> Bool { + if error is CancellationError { return true } + if let urlError = error as? URLError, urlError.code == .cancelled { return true } + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return true } + return false + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NotificationManager.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NotificationManager.swift new file mode 100644 index 00000000..b8e6fcdd --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NotificationManager.swift @@ -0,0 +1,66 @@ +import Foundation +import OpenClawIPC +import Security +import UserNotifications + +@MainActor +struct NotificationManager { + private let logger = Logger(subsystem: "ai.openclaw", category: "notifications") + + private static let hasTimeSensitiveEntitlement: Bool = { + guard let task = SecTaskCreateFromSelf(nil) else { return false } + let key = "com.apple.developer.usernotifications.time-sensitive" as CFString + guard let val = SecTaskCopyValueForEntitlement(task, key, nil) else { return false } + return (val as? Bool) == true + }() + + func send(title: String, body: String, sound: String?, priority: NotificationPriority? = nil) async -> Bool { + let center = UNUserNotificationCenter.current() + let status = await center.notificationSettings() + if status.authorizationStatus == .notDetermined { + let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) + if granted != true { + self.logger.warning("notification permission denied (request)") + return false + } + } else if status.authorizationStatus != .authorized { + self.logger.warning("notification permission denied status=\(status.authorizationStatus.rawValue)") + return false + } + + let content = UNMutableNotificationContent() + content.title = title + content.body = body + if let soundName = sound, !soundName.isEmpty { + content.sound = UNNotificationSound(named: UNNotificationSoundName(soundName)) + } + + // Set interruption level based on priority + if let priority { + switch priority { + case .passive: + content.interruptionLevel = .passive + case .active: + content.interruptionLevel = .active + case .timeSensitive: + if Self.hasTimeSensitiveEntitlement { + content.interruptionLevel = .timeSensitive + } else { + self.logger.debug( + "time-sensitive notification requested without entitlement; falling back to active") + content.interruptionLevel = .active + } + } + } + + let req = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + do { + try await center.add(req) + self.logger.debug("notification queued") + return true + } catch { + self.logger.error("notification send failed: \(error.localizedDescription)") + return false + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NotifyOverlay.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NotifyOverlay.swift new file mode 100644 index 00000000..31157b0d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/NotifyOverlay.swift @@ -0,0 +1,192 @@ +import AppKit +import Observation +import QuartzCore +import SwiftUI + +/// Lightweight, borderless panel for in-app "toast" notifications (bypasses macOS Notification Center). +@MainActor +@Observable +final class NotifyOverlayController { + static let shared = NotifyOverlayController() + + private(set) var model = Model() + var isVisible: Bool { + self.model.isVisible + } + + struct Model { + var title: String = "" + var body: String = "" + var isVisible: Bool = false + } + + private var window: NSPanel? + private var hostingView: NSHostingView? + private var dismissTask: Task? + + private let width: CGFloat = 360 + private let padding: CGFloat = 12 + private let maxHeight: CGFloat = 220 + private let minHeight: CGFloat = 64 + + func present(title: String, body: String, autoDismissAfter: TimeInterval = 6) { + self.dismissTask?.cancel() + self.model.title = title + self.model.body = body + self.ensureWindow() + self.hostingView?.rootView = NotifyOverlayView(controller: self) + self.presentWindow() + + if autoDismissAfter > 0 { + self.dismissTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(autoDismissAfter * 1_000_000_000)) + await MainActor.run { self?.dismiss() } + } + } + } + + func dismiss() { + self.dismissTask?.cancel() + self.dismissTask = nil + guard let window else { return } + + let target = window.frame.offsetBy(dx: 8, dy: 6) + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.16 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(target, display: true) + window.animator().alphaValue = 0 + } completionHandler: { + Task { @MainActor in + window.orderOut(nil) + self.model.isVisible = false + } + } + } + + // MARK: - Private + + private func presentWindow() { + self.ensureWindow() + self.hostingView?.rootView = NotifyOverlayView(controller: self) + let target = self.targetFrame() + + guard let window else { return } + if !self.model.isVisible { + self.model.isVisible = true + let start = target.offsetBy(dx: 0, dy: -6) + window.setFrame(start, display: true) + window.alphaValue = 0 + window.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(target, display: true) + window.animator().alphaValue = 1 + } + } else { + self.updateWindowFrame(animate: true) + window.orderFrontRegardless() + } + } + + private func ensureWindow() { + if self.window != nil { return } + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.minHeight), + styleMask: [.nonactivatingPanel, .borderless], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .statusBar + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + panel.hidesOnDeactivate = false + panel.isMovable = false + panel.isFloatingPanel = true + panel.becomesKeyOnlyIfNeeded = true + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + + let host = NSHostingView(rootView: NotifyOverlayView(controller: self)) + host.translatesAutoresizingMaskIntoConstraints = false + panel.contentView = host + self.hostingView = host + self.window = panel + } + + private func targetFrame() -> NSRect { + guard let screen = NSScreen.main else { return .zero } + let height = self.measuredHeight() + let size = NSSize(width: self.width, height: height) + let visible = screen.visibleFrame + let origin = CGPoint(x: visible.maxX - size.width - 8, y: visible.maxY - size.height - 8) + return NSRect(origin: origin, size: size) + } + + private func updateWindowFrame(animate: Bool = false) { + guard let window else { return } + let frame = self.targetFrame() + if animate { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(frame, display: true) + } + } else { + window.setFrame(frame, display: true) + } + } + + private func measuredHeight() -> CGFloat { + let maxWidth = self.width - self.padding * 2 + let titleFont = NSFont.systemFont(ofSize: 13, weight: .semibold) + let bodyFont = NSFont.systemFont(ofSize: 12, weight: .regular) + + let titleRect = (self.model.title as NSString).boundingRect( + with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: titleFont], + context: nil) + + let bodyRect = (self.model.body as NSString).boundingRect( + with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: bodyFont], + context: nil) + + let contentHeight = ceil(titleRect.height + 6 + bodyRect.height) + let total = contentHeight + self.padding * 2 + return max(self.minHeight, min(total, self.maxHeight)) + } +} + +private struct NotifyOverlayView: View { + var controller: NotifyOverlayController + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(self.controller.model.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Text(self.controller.model.body) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.regularMaterial)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.black.opacity(0.08), lineWidth: 1)) + .onTapGesture { + self.controller.dismiss() + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Onboarding.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Onboarding.swift new file mode 100644 index 00000000..b8a6377b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -0,0 +1,196 @@ +import AppKit +import Combine +import Observation +import OpenClawChatUI +import OpenClawDiscovery +import OpenClawIPC +import SwiftUI + +enum UIStrings { + static let welcomeTitle = "Welcome to OpenClaw" +} + +@MainActor +final class OnboardingController { + static let shared = OnboardingController() + private var window: NSWindow? + + func show() { + if ProcessInfo.processInfo.isNixMode { + // Nix mode is fully declarative; onboarding would suggest interactive setup that doesn't apply. + UserDefaults.standard.set(true, forKey: "openclaw.onboardingSeen") + UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) + AppStateStore.shared.onboardingSeen = true + return + } + if let window { + DockIconManager.shared.temporarilyShowDock() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + let hosting = NSHostingController(rootView: OnboardingView()) + let window = NSWindow(contentViewController: hosting) + window.title = UIStrings.welcomeTitle + window.setContentSize(NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight)) + window.styleMask = [.titled, .closable, .fullSizeContentView] + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.isMovableByWindowBackground = true + window.center() + DockIconManager.shared.temporarilyShowDock() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + self.window = window + } + + func close() { + self.window?.close() + self.window = nil + } + + func restart() { + self.close() + self.show() + } +} + +struct OnboardingView: View { + @Environment(\.openSettings) var openSettings + @State var currentPage = 0 + @State var isRequesting = false + @State var installingCLI = false + @State var cliStatus: String? + @State var copied = false + @State var monitoringPermissions = false + @State var monitoringDiscovery = false + @State var cliInstalled = false + @State var cliInstallLocation: String? + @State var workspacePath: String = "" + @State var workspaceStatus: String? + @State var workspaceApplying = false + @State var anthropicAuthPKCE: AnthropicOAuth.PKCE? + @State var anthropicAuthCode: String = "" + @State var anthropicAuthStatus: String? + @State var anthropicAuthBusy = false + @State var anthropicAuthConnected = false + @State var anthropicAuthVerifying = false + @State var anthropicAuthVerified = false + @State var anthropicAuthVerificationAttempted = false + @State var anthropicAuthVerificationFailed = false + @State var anthropicAuthVerifiedAt: Date? + @State var anthropicAuthDetectedStatus: OpenClawOAuthStore.AnthropicOAuthStatus = .missingFile + @State var anthropicAuthAutoDetectClipboard = true + @State var anthropicAuthAutoConnectClipboard = true + @State var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount + @State var monitoringAuth = false + @State var authMonitorTask: Task? + @State var needsBootstrap = false + @State var didAutoKickoff = false + @State var showAdvancedConnection = false + @State var preferredGatewayID: String? + @State var gatewayDiscovery: GatewayDiscoveryModel + @State var onboardingChatModel: OpenClawChatViewModel + @State var onboardingSkillsModel = SkillsSettingsModel() + @State var onboardingWizard = OnboardingWizardModel() + @State var didLoadOnboardingSkills = false + @State var localGatewayProbe: LocalGatewayProbe? + @Bindable var state: AppState + var permissionMonitor: PermissionMonitor + + static let windowWidth: CGFloat = 630 + static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content + + let pageWidth: CGFloat = Self.windowWidth + let contentHeight: CGFloat = 460 + let connectionPageIndex = 1 + let anthropicAuthPageIndex = 2 + let wizardPageIndex = 3 + let onboardingChatPageIndex = 8 + + static let clipboardPoll: AnyPublisher = { + if ProcessInfo.processInfo.isRunningTests { + return Empty(completeImmediately: false).eraseToAnyPublisher() + } + return Timer.publish(every: 0.4, on: .main, in: .common) + .autoconnect() + .eraseToAnyPublisher() + }() + + let permissionsPageIndex = 5 + static func pageOrder( + for mode: AppState.ConnectionMode, + showOnboardingChat: Bool) -> [Int] + { + switch mode { + case .remote: + // Remote setup doesn't need local gateway/CLI/workspace setup pages, + // and WhatsApp/Telegram setup is optional. + showOnboardingChat ? [0, 1, 5, 8, 9] : [0, 1, 5, 9] + case .unconfigured: + showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9] + case .local: + showOnboardingChat ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9] + } + } + + var showOnboardingChat: Bool { + self.state.connectionMode == .local && self.needsBootstrap + } + + var pageOrder: [Int] { + Self.pageOrder(for: self.state.connectionMode, showOnboardingChat: self.showOnboardingChat) + } + + var pageCount: Int { + self.pageOrder.count + } + + var activePageIndex: Int { + self.activePageIndex(for: self.currentPage) + } + + var buttonTitle: String { + self.currentPage == self.pageCount - 1 ? "Finish" : "Next" + } + + var wizardPageOrderIndex: Int? { + self.pageOrder.firstIndex(of: self.wizardPageIndex) + } + + var isWizardBlocking: Bool { + self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete + } + + var canAdvance: Bool { + !self.isWizardBlocking + } + + var devLinkCommand: String { + let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest" + return "npm install -g openclaw@\(version)" + } + + struct LocalGatewayProbe: Equatable { + let port: Int + let pid: Int32 + let command: String + let expected: Bool + } + + init( + state: AppState = AppStateStore.shared, + permissionMonitor: PermissionMonitor = .shared, + discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel( + localDisplayName: InstanceIdentity.displayName, + filterLocalGateways: false)) + { + self.state = state + self.permissionMonitor = permissionMonitor + self._gatewayDiscovery = State(initialValue: discoveryModel) + self._onboardingChatModel = State( + initialValue: OpenClawChatViewModel( + sessionKey: "onboarding", + transport: MacGatewayChatTransport())) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift new file mode 100644 index 00000000..bcd5bd6d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift @@ -0,0 +1,147 @@ +import AppKit +import Foundation +import OpenClawDiscovery +import OpenClawIPC +import SwiftUI + +extension OnboardingView { + func selectLocalGateway() { + self.state.connectionMode = .local + self.preferredGatewayID = nil + self.showAdvancedConnection = false + GatewayDiscoveryPreferences.setPreferredStableID(nil) + } + + func selectUnconfiguredGateway() { + Task { await self.onboardingWizard.cancelIfRunning() } + self.state.connectionMode = .unconfigured + self.preferredGatewayID = nil + self.showAdvancedConnection = false + GatewayDiscoveryPreferences.setPreferredStableID(nil) + } + + func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { + Task { await self.onboardingWizard.cancelIfRunning() } + self.preferredGatewayID = gateway.stableID + GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID) + + if self.state.remoteTransport == .direct { + self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" + } else { + self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" + } + if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { + OpenClawConfigFile.setRemoteGatewayUrl( + host: endpoint.host, + port: endpoint.port) + } else { + OpenClawConfigFile.clearRemoteGatewayUrl() + } + + self.state.connectionMode = .remote + MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) + } + + func openSettings(tab: SettingsTab) { + SettingsTabRouter.request(tab) + self.openSettings() + DispatchQueue.main.async { + NotificationCenter.default.post(name: .openclawSelectSettingsTab, object: tab) + } + } + + func handleBack() { + withAnimation { + self.currentPage = max(0, self.currentPage - 1) + } + } + + func handleNext() { + if self.isWizardBlocking { return } + if self.currentPage < self.pageCount - 1 { + withAnimation { self.currentPage += 1 } + } else { + self.finish() + } + } + + func finish() { + UserDefaults.standard.set(true, forKey: "openclaw.onboardingSeen") + UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) + OnboardingController.shared.close() + } + + func copyToPasteboard(_ text: String) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(text, forType: .string) + self.copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false } + } + + func startAnthropicOAuth() { + guard !self.anthropicAuthBusy else { return } + self.anthropicAuthBusy = true + defer { self.anthropicAuthBusy = false } + + do { + let pkce = try AnthropicOAuth.generatePKCE() + self.anthropicAuthPKCE = pkce + let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce) + NSWorkspace.shared.open(url) + self.anthropicAuthStatus = "Browser opened. After approving, paste the `code#state` value here." + } catch { + self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)" + } + } + + @MainActor + func finishAnthropicOAuth() async { + guard !self.anthropicAuthBusy else { return } + guard let pkce = self.anthropicAuthPKCE else { return } + self.anthropicAuthBusy = true + defer { self.anthropicAuthBusy = false } + + guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else { + self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state." + return + } + + do { + let creds = try await AnthropicOAuth.exchangeCode( + code: parsed.code, + state: parsed.state, + verifier: pkce.verifier) + try OpenClawOAuthStore.saveAnthropicOAuth(creds) + self.refreshAnthropicOAuthStatus() + self.anthropicAuthStatus = "Connected. OpenClaw can now use Claude." + } catch { + self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)" + } + } + + func pollAnthropicClipboardIfNeeded() { + guard self.currentPage == self.anthropicAuthPageIndex else { return } + guard self.anthropicAuthPKCE != nil else { return } + guard !self.anthropicAuthBusy else { return } + guard self.anthropicAuthAutoDetectClipboard else { return } + + let pb = NSPasteboard.general + let changeCount = pb.changeCount + guard changeCount != self.anthropicAuthLastPasteboardChangeCount else { return } + self.anthropicAuthLastPasteboardChangeCount = changeCount + + guard let raw = pb.string(forType: .string), !raw.isEmpty else { return } + guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return } + guard let pkce = self.anthropicAuthPKCE, parsed.state == pkce.verifier else { return } + + let next = "\(parsed.code)#\(parsed.state)" + if self.anthropicAuthCode != next { + self.anthropicAuthCode = next + self.anthropicAuthStatus = "Detected `code#state` from clipboard." + } + + guard self.anthropicAuthAutoConnectClipboard else { return } + Task { await self.finishAnthropicOAuth() } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Chat.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Chat.swift new file mode 100644 index 00000000..f95da4ff --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Chat.swift @@ -0,0 +1,26 @@ +import Foundation + +extension OnboardingView { + func maybeKickoffOnboardingChat(for pageIndex: Int) { + guard pageIndex == self.onboardingChatPageIndex else { return } + guard self.showOnboardingChat else { return } + guard !self.didAutoKickoff else { return } + self.didAutoKickoff = true + + Task { @MainActor in + for _ in 0..<20 { + if !self.onboardingChatModel.isLoading { break } + try? await Task.sleep(nanoseconds: 200_000_000) + } + guard self.onboardingChatModel.messages.isEmpty else { return } + let kickoff = + "Hi! I just installed OpenClaw and you’re my brand‑new agent. " + + "Please start the first‑run ritual from BOOTSTRAP.md, ask one question at a time, " + + "and before we talk about WhatsApp/Telegram, visit soul.md with me to craft SOUL.md: " + + "ask what matters to me and how you should be. Then guide me through choosing " + + "how we should talk (web‑only, WhatsApp, or Telegram)." + self.onboardingChatModel.input = kickoff + self.onboardingChatModel.send() + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift new file mode 100644 index 00000000..ce87e211 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift @@ -0,0 +1,234 @@ +import AppKit +import SwiftUI + +extension OnboardingView { + var body: some View { + VStack(spacing: 0) { + GlowingOpenClawIcon(size: 130, glowIntensity: 0.28) + .offset(y: 10) + .frame(height: 145) + + GeometryReader { _ in + HStack(spacing: 0) { + ForEach(self.pageOrder, id: \.self) { pageIndex in + self.pageView(for: pageIndex) + .frame(width: self.pageWidth) + } + } + .offset(x: CGFloat(-self.currentPage) * self.pageWidth) + .animation( + .interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25), + value: self.currentPage) + .frame(height: self.contentHeight, alignment: .top) + .clipped() + } + .frame(height: self.contentHeight) + + Spacer(minLength: 0) + self.navigationBar + } + .frame(width: self.pageWidth, height: Self.windowHeight) + .background(Color(NSColor.windowBackgroundColor)) + .onAppear { + self.currentPage = 0 + self.updateMonitoring(for: 0) + } + .onChange(of: self.currentPage) { _, newValue in + self.updateMonitoring(for: self.activePageIndex(for: newValue)) + } + .onChange(of: self.state.connectionMode) { _, _ in + let oldActive = self.activePageIndex + self.reconcilePageForModeChange(previousActivePageIndex: oldActive) + self.updateDiscoveryMonitoring(for: self.activePageIndex) + } + .onChange(of: self.needsBootstrap) { _, _ in + if self.currentPage >= self.pageOrder.count { + self.currentPage = max(0, self.pageOrder.count - 1) + } + } + .onChange(of: self.onboardingWizard.isComplete) { _, newValue in + guard newValue, self.activePageIndex == self.wizardPageIndex else { return } + self.handleNext() + } + .onDisappear { + self.stopPermissionMonitoring() + self.stopDiscovery() + self.stopAuthMonitoring() + Task { await self.onboardingWizard.cancelIfRunning() } + } + .task { + await self.refreshPerms() + self.refreshCLIStatus() + await self.loadWorkspaceDefaults() + await self.ensureDefaultWorkspace() + self.refreshAnthropicOAuthStatus() + self.refreshBootstrapStatus() + self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID() + } + } + + func activePageIndex(for pageCursor: Int) -> Int { + guard !self.pageOrder.isEmpty else { return 0 } + let clamped = min(max(0, pageCursor), self.pageOrder.count - 1) + return self.pageOrder[clamped] + } + + func reconcilePageForModeChange(previousActivePageIndex: Int) { + if let exact = self.pageOrder.firstIndex(of: previousActivePageIndex) { + withAnimation { self.currentPage = exact } + return + } + if let next = self.pageOrder.firstIndex(where: { $0 > previousActivePageIndex }) { + withAnimation { self.currentPage = next } + return + } + withAnimation { self.currentPage = max(0, self.pageOrder.count - 1) } + } + + var navigationBar: some View { + let wizardLockIndex = self.wizardPageOrderIndex + return HStack(spacing: 20) { + ZStack(alignment: .leading) { + Button(action: {}, label: { + Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly) + }) + .buttonStyle(.plain) + .opacity(0) + .disabled(true) + + if self.currentPage > 0 { + Button(action: self.handleBack, label: { + Label("Back", systemImage: "chevron.left") + .labelStyle(.iconOnly) + }) + .buttonStyle(.plain) + .foregroundColor(.secondary) + .opacity(0.8) + .transition(.opacity.combined(with: .scale(scale: 0.9))) + } + } + .frame(minWidth: 80, alignment: .leading) + + Spacer() + + HStack(spacing: 8) { + ForEach(0.. (wizardLockIndex ?? 0) + Button { + withAnimation { self.currentPage = index } + } label: { + Circle() + .fill(index == self.currentPage ? Color.accentColor : Color.gray.opacity(0.3)) + .frame(width: 8, height: 8) + } + .buttonStyle(.plain) + .disabled(isLocked) + .opacity(isLocked ? 0.3 : 1) + } + } + + Spacer() + + Button(action: self.handleNext) { + Text(self.buttonTitle) + .frame(minWidth: 88) + } + .keyboardShortcut(.return) + .buttonStyle(.borderedProminent) + .disabled(!self.canAdvance) + } + .padding(.horizontal, 28) + .padding(.bottom, 13) + .frame(minHeight: 60, alignment: .bottom) + } + + func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View { + let scrollIndicatorGutter: CGFloat = 18 + return ScrollView { + VStack(spacing: 16) { + content() + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .top) + .padding(.trailing, scrollIndicatorGutter) + } + .scrollIndicators(.automatic) + .padding(.horizontal, 28) + .frame(width: self.pageWidth, alignment: .top) + } + + func onboardingCard( + spacing: CGFloat = 12, + padding: CGFloat = 16, + @ViewBuilder _ content: () -> some View) -> some View + { + VStack(alignment: .leading, spacing: spacing) { + content() + } + .padding(padding) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor)) + .shadow(color: .black.opacity(0.06), radius: 8, y: 3)) + } + + func onboardingGlassCard( + spacing: CGFloat = 12, + padding: CGFloat = 16, + @ViewBuilder _ content: () -> some View) -> some View + { + let shape = RoundedRectangle(cornerRadius: 16, style: .continuous) + return VStack(alignment: .leading, spacing: spacing) { + content() + } + .padding(padding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.clear) + .clipShape(shape) + .overlay(shape.strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) + } + + func featureRow(title: String, subtitle: String, systemImage: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 26) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline) + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + func featureActionRow( + title: String, + subtitle: String, + systemImage: String, + buttonTitle: String, + action: @escaping () -> Void) -> some View + { + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 26) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline) + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + Button(buttonTitle, action: action) + .buttonStyle(.link) + .padding(.top, 2) + } + Spacer(minLength: 0) + } + .padding(.vertical, 4) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift new file mode 100644 index 00000000..dfbdf91d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift @@ -0,0 +1,178 @@ +import Foundation +import OpenClawIPC + +extension OnboardingView { + @MainActor + func refreshPerms() async { + await self.permissionMonitor.refreshNow() + } + + @MainActor + func request(_ cap: Capability) async { + guard !self.isRequesting else { return } + self.isRequesting = true + defer { isRequesting = false } + _ = await PermissionManager.ensure([cap], interactive: true) + await self.refreshPerms() + } + + func updatePermissionMonitoring(for pageIndex: Int) { + let shouldMonitor = pageIndex == self.permissionsPageIndex + if shouldMonitor, !self.monitoringPermissions { + self.monitoringPermissions = true + PermissionMonitor.shared.register() + } else if !shouldMonitor, self.monitoringPermissions { + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() + } + } + + func updateDiscoveryMonitoring(for pageIndex: Int) { + let isConnectionPage = pageIndex == self.connectionPageIndex + let shouldMonitor = isConnectionPage + if shouldMonitor, !self.monitoringDiscovery { + self.monitoringDiscovery = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 150_000_000) + guard self.monitoringDiscovery else { return } + self.gatewayDiscovery.start() + await self.refreshLocalGatewayProbe() + } + } else if !shouldMonitor, self.monitoringDiscovery { + self.monitoringDiscovery = false + self.gatewayDiscovery.stop() + } + } + + func updateMonitoring(for pageIndex: Int) { + self.updatePermissionMonitoring(for: pageIndex) + self.updateDiscoveryMonitoring(for: pageIndex) + self.updateAuthMonitoring(for: pageIndex) + self.maybeKickoffOnboardingChat(for: pageIndex) + } + + func stopPermissionMonitoring() { + guard self.monitoringPermissions else { return } + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() + } + + func stopDiscovery() { + guard self.monitoringDiscovery else { return } + self.monitoringDiscovery = false + self.gatewayDiscovery.stop() + } + + func updateAuthMonitoring(for pageIndex: Int) { + let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local + if shouldMonitor, !self.monitoringAuth { + self.monitoringAuth = true + self.startAuthMonitoring() + } else if !shouldMonitor, self.monitoringAuth { + self.stopAuthMonitoring() + } + } + + func startAuthMonitoring() { + self.refreshAnthropicOAuthStatus() + self.authMonitorTask?.cancel() + self.authMonitorTask = Task { + while !Task.isCancelled { + await MainActor.run { self.refreshAnthropicOAuthStatus() } + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + } + + func stopAuthMonitoring() { + self.monitoringAuth = false + self.authMonitorTask?.cancel() + self.authMonitorTask = nil + } + + func installCLI() async { + guard !self.installingCLI else { return } + self.installingCLI = true + defer { installingCLI = false } + await CLIInstaller.install { message in + self.cliStatus = message + } + self.refreshCLIStatus() + } + + func refreshCLIStatus() { + let installLocation = CLIInstaller.installedLocation() + self.cliInstallLocation = installLocation + self.cliInstalled = installLocation != nil + } + + func refreshLocalGatewayProbe() async { + let port = GatewayEnvironment.gatewayPort() + let desc = await PortGuardian.shared.describe(port: port) + await MainActor.run { + guard let desc else { + self.localGatewayProbe = nil + return + } + let command = desc.command.trimmingCharacters(in: .whitespacesAndNewlines) + let expectedTokens = ["node", "openclaw", "tsx", "pnpm", "bun"] + let lower = command.lowercased() + let expected = expectedTokens.contains { lower.contains($0) } + self.localGatewayProbe = LocalGatewayProbe( + port: port, + pid: desc.pid, + command: command, + expected: expected) + } + } + + func refreshAnthropicOAuthStatus() { + _ = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded() + let previous = self.anthropicAuthDetectedStatus + let status = OpenClawOAuthStore.anthropicOAuthStatus() + self.anthropicAuthDetectedStatus = status + self.anthropicAuthConnected = status.isConnected + + if previous != status { + self.anthropicAuthVerified = false + self.anthropicAuthVerificationAttempted = false + self.anthropicAuthVerificationFailed = false + self.anthropicAuthVerifiedAt = nil + } + } + + @MainActor + func verifyAnthropicOAuthIfNeeded(force: Bool = false) async { + guard self.state.connectionMode == .local else { return } + guard self.anthropicAuthDetectedStatus.isConnected else { return } + if self.anthropicAuthVerified, !force { return } + if self.anthropicAuthVerifying { return } + if self.anthropicAuthVerificationAttempted, !force { return } + + self.anthropicAuthVerificationAttempted = true + self.anthropicAuthVerifying = true + self.anthropicAuthVerificationFailed = false + defer { self.anthropicAuthVerifying = false } + + guard let refresh = OpenClawOAuthStore.loadAnthropicOAuthRefreshToken(), !refresh.isEmpty else { + self.anthropicAuthStatus = "OAuth verification failed: missing refresh token." + self.anthropicAuthVerificationFailed = true + return + } + + do { + let updated = try await AnthropicOAuth.refresh(refreshToken: refresh) + try OpenClawOAuthStore.saveAnthropicOAuth(updated) + self.refreshAnthropicOAuthStatus() + self.anthropicAuthVerified = true + self.anthropicAuthVerifiedAt = Date() + self.anthropicAuthVerificationFailed = false + self.anthropicAuthStatus = "OAuth detected and verified." + } catch { + self.anthropicAuthVerified = false + self.anthropicAuthVerifiedAt = nil + self.anthropicAuthVerificationFailed = true + self.anthropicAuthStatus = "OAuth verification failed: \(error.localizedDescription)" + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift new file mode 100644 index 00000000..ed40bd2e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -0,0 +1,858 @@ +import AppKit +import OpenClawChatUI +import OpenClawDiscovery +import OpenClawIPC +import SwiftUI + +extension OnboardingView { + @ViewBuilder + func pageView(for pageIndex: Int) -> some View { + switch pageIndex { + case 0: + self.welcomePage() + case 1: + self.connectionPage() + case 2: + self.anthropicAuthPage() + case 3: + self.wizardPage() + case 5: + self.permissionsPage() + case 6: + self.cliPage() + case 8: + self.onboardingChatPage() + case 9: + self.readyPage() + default: + EmptyView() + } + } + + func welcomePage() -> some View { + self.onboardingPage { + VStack(spacing: 22) { + Text("Welcome to OpenClaw") + .font(.largeTitle.weight(.semibold)) + Text("OpenClaw is a powerful personal AI assistant that can connect to WhatsApp or Telegram.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(2) + .frame(maxWidth: 560) + .fixedSize(horizontal: false, vertical: true) + + self.onboardingCard(spacing: 10, padding: 14) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.title3.weight(.semibold)) + .foregroundStyle(Color(nsColor: .systemOrange)) + .frame(width: 22) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 6) { + Text("Security notice") + .font(.headline) + Text( + "The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, " + + "including running commands, reading/writing files, and capturing screenshots — " + + "depending on the permissions you grant.\n\n" + + "Only enable OpenClaw if you understand the risks and trust the prompts and " + + "integrations you use.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .frame(maxWidth: 520) + } + .padding(.top, 16) + } + } + + func connectionPage() -> some View { + self.onboardingPage { + Text("Choose your Gateway") + .font(.largeTitle.weight(.semibold)) + Text( + "OpenClaw uses a single Gateway that stays running. Pick this Mac, " + + "connect to a discovered gateway nearby, or configure later.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(2) + .frame(maxWidth: 520) + .fixedSize(horizontal: false, vertical: true) + + self.onboardingCard(spacing: 12, padding: 14) { + VStack(alignment: .leading, spacing: 10) { + self.connectionChoiceButton( + title: "This Mac", + subtitle: self.localGatewaySubtitle, + selected: self.state.connectionMode == .local) + { + self.selectLocalGateway() + } + + Divider().padding(.vertical, 4) + + self.gatewayDiscoverySection() + + self.connectionChoiceButton( + title: "Configure later", + subtitle: "Don’t start the Gateway yet.", + selected: self.state.connectionMode == .unconfigured) + { + self.selectUnconfiguredGateway() + } + + self.advancedConnectionSection() + } + } + } + } + + private var localGatewaySubtitle: String { + guard let probe = self.localGatewayProbe else { + return "Gateway starts automatically on this Mac." + } + let base = probe.expected + ? "Existing gateway detected" + : "Port \(probe.port) already in use" + let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))" + return "\(base)\(command). Will attach." + } + + @ViewBuilder + private func gatewayDiscoverySection() -> some View { + HStack(spacing: 8) { + Image(systemName: "dot.radiowaves.left.and.right") + .font(.caption) + .foregroundStyle(.secondary) + Text(self.gatewayDiscovery.statusText) + .font(.caption) + .foregroundStyle(.secondary) + if self.gatewayDiscovery.gateways.isEmpty { + ProgressView().controlSize(.small) + Button("Refresh") { + self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0) + } + .buttonStyle(.link) + .help("Retry Tailscale discovery (DNS-SD).") + } + Spacer(minLength: 0) + } + + if self.gatewayDiscovery.gateways.isEmpty { + Text("Searching for nearby gateways…") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 4) + } else { + VStack(alignment: .leading, spacing: 6) { + Text("Nearby gateways") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 4) + ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in + self.connectionChoiceButton( + title: gateway.displayName, + subtitle: self.gatewaySubtitle(for: gateway), + selected: self.isSelectedGateway(gateway)) + { + self.selectRemoteGateway(gateway) + } + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor))) + } + } + + @ViewBuilder + private func advancedConnectionSection() -> some View { + Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") { + withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { + self.showAdvancedConnection.toggle() + } + if self.showAdvancedConnection, self.state.connectionMode != .remote { + self.state.connectionMode = .remote + } + } + .buttonStyle(.link) + + if self.showAdvancedConnection { + let labelWidth: CGFloat = 110 + let fieldWidth: CGFloat = 320 + + VStack(alignment: .leading, spacing: 10) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text("Transport") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + Picker("Transport", selection: self.$state.remoteTransport) { + Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) + Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) + } + .pickerStyle(.segmented) + .frame(width: fieldWidth) + } + if self.state.remoteTransport == .direct { + GridRow { + Text("Gateway URL") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + } + if self.state.remoteTransport == .ssh { + GridRow { + Text("SSH target") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("user@host[:port]", text: self.$state.remoteTarget) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + if let message = CommandResolver + .sshTargetValidationMessage(self.state.remoteTarget) + { + GridRow { + Text("") + .frame(width: labelWidth, alignment: .leading) + Text(message) + .font(.caption) + .foregroundStyle(.red) + .frame(width: fieldWidth, alignment: .leading) + } + } + GridRow { + Text("Identity file") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("Project root") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("CLI path") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField( + "/Applications/OpenClaw.app/.../openclaw", + text: self.$state.remoteCliPath) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + } + } + + Text(self.state.remoteTransport == .direct + ? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert." + : "Tip: keep Tailscale enabled so your gateway stays reachable.") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { + if self.state.remoteTransport == .direct { + return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only" + } + if let target = GatewayDiscoveryHelpers.sshTarget(for: gateway), + let parsed = CommandResolver.parseSSHTarget(target) + { + let portSuffix = parsed.port != 22 ? " · ssh \(parsed.port)" : "" + return "\(parsed.host)\(portSuffix)" + } + return "Gateway pairing only" + } + + func isSelectedGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool { + guard self.state.connectionMode == .remote else { return false } + let preferred = self.preferredGatewayID ?? GatewayDiscoveryPreferences.preferredStableID() + return preferred == gateway.stableID + } + + func connectionChoiceButton( + title: String, + subtitle: String?, + selected: Bool, + action: @escaping () -> Void) -> some View + { + Button { + withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { + action() + } + } label: { + HStack(alignment: .center, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.callout.weight(.semibold)) + .lineLimit(1) + .truncationMode(.tail) + if let subtitle { + Text(subtitle) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + Spacer(minLength: 0) + if selected { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.accentColor) + } else { + Image(systemName: "arrow.right.circle") + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(selected ? Color.accentColor.opacity(0.12) : Color.clear)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder( + selected ? Color.accentColor.opacity(0.45) : Color.clear, + lineWidth: 1)) + } + .buttonStyle(.plain) + } + + func anthropicAuthPage() -> some View { + self.onboardingPage { + Text("Connect Claude") + .font(.largeTitle.weight(.semibold)) + Text("Give your model the token it needs!") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 540) + .fixedSize(horizontal: false, vertical: true) + Text("OpenClaw supports any model — we strongly recommend Opus 4.6 for the best experience.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 540) + .fixedSize(horizontal: false, vertical: true) + + self.onboardingCard(spacing: 12, padding: 16) { + HStack(alignment: .center, spacing: 10) { + Circle() + .fill(self.anthropicAuthVerified ? Color.green : Color.orange) + .frame(width: 10, height: 10) + Text( + self.anthropicAuthConnected + ? (self.anthropicAuthVerified + ? "Claude connected (OAuth) — verified" + : "Claude connected (OAuth)") + : "Not connected yet") + .font(.headline) + Spacer() + } + + if self.anthropicAuthConnected, self.anthropicAuthVerifying { + Text("Verifying OAuth…") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else if !self.anthropicAuthConnected { + Text(self.anthropicAuthDetectedStatus.shortDescription) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else if self.anthropicAuthVerified, let date = self.anthropicAuthVerifiedAt { + Text("Detected working OAuth (\(date.formatted(date: .abbreviated, time: .shortened))).") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Text( + "This lets OpenClaw use Claude immediately. Credentials are stored at " + + "`~/.openclaw/credentials/oauth.json` (owner-only).") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 12) { + Text(OpenClawOAuthStore.oauthURL().path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + Button("Reveal") { + NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()]) + } + .buttonStyle(.bordered) + + Button("Refresh") { + self.refreshAnthropicOAuthStatus() + } + .buttonStyle(.bordered) + } + + Divider().padding(.vertical, 2) + + HStack(spacing: 12) { + if !self.anthropicAuthVerified { + if self.anthropicAuthConnected { + Button("Verify") { + Task { await self.verifyAnthropicOAuthIfNeeded(force: true) } + } + .buttonStyle(.borderedProminent) + .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying) + + if self.anthropicAuthVerificationFailed { + Button("Re-auth (OAuth)") { + self.startAnthropicOAuth() + } + .buttonStyle(.bordered) + .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying) + } + } else { + Button { + self.startAnthropicOAuth() + } label: { + if self.anthropicAuthBusy { + ProgressView() + } else { + Text("Open Claude sign-in (OAuth)") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.anthropicAuthBusy) + } + } + } + + if !self.anthropicAuthVerified, self.anthropicAuthPKCE != nil { + VStack(alignment: .leading, spacing: 8) { + Text("Paste the `code#state` value") + .font(.headline) + TextField("code#state", text: self.$anthropicAuthCode) + .textFieldStyle(.roundedBorder) + + Toggle("Auto-detect from clipboard", isOn: self.$anthropicAuthAutoDetectClipboard) + .font(.caption) + .foregroundStyle(.secondary) + .disabled(self.anthropicAuthBusy) + + Toggle("Auto-connect when detected", isOn: self.$anthropicAuthAutoConnectClipboard) + .font(.caption) + .foregroundStyle(.secondary) + .disabled(self.anthropicAuthBusy) + + Button("Connect") { + Task { await self.finishAnthropicOAuth() } + } + .buttonStyle(.bordered) + .disabled( + self.anthropicAuthBusy || + self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .onReceive(Self.clipboardPoll) { _ in + self.pollAnthropicClipboardIfNeeded() + } + } + + self.onboardingCard(spacing: 8, padding: 12) { + Text("API key (advanced)") + .font(.headline) + Text( + "You can also use an Anthropic API key, but this UI is instructions-only for now " + + "(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .shadow(color: .clear, radius: 0) + .background(Color.clear) + + if let status = self.anthropicAuthStatus { + Text(status) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .task { await self.verifyAnthropicOAuthIfNeeded() } + } + + func permissionsPage() -> some View { + self.onboardingPage { + Text("Grant permissions") + .font(.largeTitle.weight(.semibold)) + Text("These macOS permissions let OpenClaw automate apps and capture context on this Mac.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + .fixedSize(horizontal: false, vertical: true) + + self.onboardingCard(spacing: 8, padding: 12) { + ForEach(Capability.allCases, id: \.self) { cap in + PermissionRow( + capability: cap, + status: self.permissionMonitor.status[cap] ?? false, + compact: true) + { + Task { await self.request(cap) } + } + } + + HStack(spacing: 12) { + Button { + Task { await self.refreshPerms() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Refresh status") + if self.isRequesting { + ProgressView() + .controlSize(.small) + } + } + .padding(.top, 4) + } + } + } + + func cliPage() -> some View { + self.onboardingPage { + Text("Install the CLI") + .font(.largeTitle.weight(.semibold)) + Text("Required for local mode: installs `openclaw` so launchd can run the gateway.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + .fixedSize(horizontal: false, vertical: true) + + self.onboardingCard(spacing: 10) { + HStack(spacing: 12) { + Button { + Task { await self.installCLI() } + } label: { + let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI" + ZStack { + Text(title) + .opacity(self.installingCLI ? 0 : 1) + if self.installingCLI { + ProgressView() + .controlSize(.mini) + } + } + .frame(minWidth: 120) + } + .buttonStyle(.borderedProminent) + .disabled(self.installingCLI) + + Button(self.copied ? "Copied" : "Copy install command") { + self.copyToPasteboard(self.devLinkCommand) + } + .disabled(self.installingCLI) + + if self.cliInstalled, let loc = self.cliInstallLocation { + Label("Installed at \(loc)", systemImage: "checkmark.circle.fill") + .font(.footnote) + .foregroundStyle(.green) + } + } + + if let cliStatus { + Text(cliStatus) + .font(.caption) + .foregroundStyle(.secondary) + } else if !self.cliInstalled, self.cliInstallLocation == nil { + Text( + """ + Installs a user-space Node 22+ runtime and the CLI (no Homebrew). + Rerun anytime to reinstall or update. + """) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + } + + func workspacePage() -> some View { + self.onboardingPage { + Text("Agent workspace") + .font(.largeTitle.weight(.semibold)) + Text( + "OpenClaw runs the agent from a dedicated workspace so it can load `AGENTS.md` " + + "and write files there without mixing into your other projects.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 560) + .fixedSize(horizontal: false, vertical: true) + + self.onboardingCard(spacing: 10) { + if self.state.connectionMode == .remote { + Text("Remote gateway detected") + .font(.headline) + Text( + "Create the workspace on the remote host (SSH in first). " + + "The macOS app can’t write files on your gateway over SSH yet.") + .font(.subheadline) + .foregroundStyle(.secondary) + + Button(self.copied ? "Copied" : "Copy setup command") { + self.copyToPasteboard(self.workspaceBootstrapCommand) + } + .buttonStyle(.bordered) + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Workspace folder") + .font(.headline) + TextField( + AgentWorkspace.displayPath(for: OpenClawConfigFile.defaultWorkspaceURL()), + text: self.$workspacePath) + .textFieldStyle(.roundedBorder) + + HStack(spacing: 12) { + Button { + Task { await self.applyWorkspace() } + } label: { + if self.workspaceApplying { + ProgressView() + } else { + Text("Create workspace") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.workspaceApplying) + + Button("Open folder") { + let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) + NSWorkspace.shared.open(url) + } + .buttonStyle(.bordered) + .disabled(self.workspaceApplying) + + Button("Save in config") { + Task { + let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) + let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url)) + if saved { + self.workspaceStatus = + "Saved to ~/.openclaw/openclaw.json (agents.defaults.workspace)" + } + } + } + .buttonStyle(.bordered) + .disabled(self.workspaceApplying) + } + } + + if let workspaceStatus { + Text(workspaceStatus) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } else { + Text( + "Tip: edit AGENTS.md in this folder to shape the assistant’s behavior. " + + "For backup, make the workspace a private git repo so your agent’s " + + "“memory” is versioned.") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } + } + } + + func onboardingChatPage() -> some View { + VStack(spacing: 16) { + Text("Meet your agent") + .font(.largeTitle.weight(.semibold)) + Text( + "This is a dedicated onboarding chat. Your agent will introduce itself, " + + "learn who you are, and help you connect WhatsApp or Telegram if you want.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + .fixedSize(horizontal: false, vertical: true) + + self.onboardingGlassCard(padding: 8) { + OpenClawChatView(viewModel: self.onboardingChatModel, style: .onboarding) + .frame(maxHeight: .infinity) + } + .frame(maxHeight: .infinity) + } + .padding(.horizontal, 28) + .frame(width: self.pageWidth, height: self.contentHeight, alignment: .top) + } + + func readyPage() -> some View { + self.onboardingPage { + Text("All set") + .font(.largeTitle.weight(.semibold)) + self.onboardingCard { + if self.state.connectionMode == .unconfigured { + self.featureRow( + title: "Configure later", + subtitle: "Pick Local or Remote in Settings → General whenever you’re ready.", + systemImage: "gearshape") + Divider() + .padding(.vertical, 6) + } + if self.state.connectionMode == .remote { + self.featureRow( + title: "Remote gateway checklist", + subtitle: """ + On your gateway host: install/update the `openclaw` package and make sure credentials exist + (typically `~/.openclaw/credentials/oauth.json`). Then connect again if needed. + """, + systemImage: "network") + Divider() + .padding(.vertical, 6) + } + self.featureRow( + title: "Open the menu bar panel", + subtitle: "Click the OpenClaw menu bar icon for quick chat and status.", + systemImage: "bubble.left.and.bubble.right") + self.featureActionRow( + title: "Connect WhatsApp or Telegram", + subtitle: "Open Settings → Channels to link channels and monitor status.", + systemImage: "link", + buttonTitle: "Open Settings → Channels") + { + self.openSettings(tab: .channels) + } + self.featureRow( + title: "Try Voice Wake", + subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.", + systemImage: "waveform.circle") + self.featureRow( + title: "Use the panel + Canvas", + subtitle: "Open the menu bar panel for quick chat; the agent can show previews " + + "and richer visuals in Canvas.", + systemImage: "rectangle.inset.filled.and.person.filled") + self.featureActionRow( + title: "Give your agent more powers", + subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.", + systemImage: "sparkles", + buttonTitle: "Open Settings → Skills") + { + self.openSettings(tab: .skills) + } + self.skillsOverview + Toggle("Launch at login", isOn: self.$state.launchAtLogin) + .onChange(of: self.state.launchAtLogin) { _, newValue in + AppStateStore.updateLaunchAtLogin(enabled: newValue) + } + } + } + .task { await self.maybeLoadOnboardingSkills() } + } + + private func maybeLoadOnboardingSkills() async { + guard !self.didLoadOnboardingSkills else { return } + self.didLoadOnboardingSkills = true + await self.onboardingSkillsModel.refresh() + } + + private var skillsOverview: some View { + VStack(alignment: .leading, spacing: 8) { + Divider() + .padding(.vertical, 6) + + HStack(spacing: 10) { + Text("Skills included") + .font(.headline) + Spacer(minLength: 0) + if self.onboardingSkillsModel.isLoading { + ProgressView() + .controlSize(.small) + } else { + Button("Refresh") { + Task { await self.onboardingSkillsModel.refresh() } + } + .buttonStyle(.link) + } + } + + if let error = self.onboardingSkillsModel.error { + VStack(alignment: .leading, spacing: 4) { + Text("Couldn’t load skills from the Gateway.") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.orange) + Text( + "Make sure the Gateway is running and connected, " + + "then hit Refresh (or open Settings → Skills).") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Text("Details: \(error)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } else if self.onboardingSkillsModel.skills.isEmpty { + Text("No skills reported yet.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(self.onboardingSkillsModel.skills) { skill in + HStack(alignment: .top, spacing: 10) { + Text(skill.emoji ?? "✨") + .font(.callout) + .frame(width: 22, alignment: .leading) + VStack(alignment: .leading, spacing: 2) { + Text(skill.name) + .font(.callout.weight(.semibold)) + Text(skill.description) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(NSColor.windowBackgroundColor))) + } + .frame(maxHeight: 160) + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift new file mode 100644 index 00000000..cf8c3d0c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift @@ -0,0 +1,87 @@ +import OpenClawDiscovery +import SwiftUI + +#if DEBUG +@MainActor +extension OnboardingView { + static func exerciseForTesting() { + let state = AppState(preview: true) + let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) + discovery.statusText = "Searching..." + let gateway = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Test Gateway", + lanHost: "gateway.local", + tailnetDns: "gateway.ts.net", + sshPort: 2222, + gatewayPort: 18789, + cliPath: "/usr/local/bin/openclaw", + stableID: "gateway-1", + debugID: "gateway-1", + isLocal: false) + discovery.gateways = [gateway] + + let view = OnboardingView( + state: state, + permissionMonitor: PermissionMonitor.shared, + discoveryModel: discovery) + view.needsBootstrap = true + view.localGatewayProbe = LocalGatewayProbe( + port: GatewayEnvironment.gatewayPort(), + pid: 123, + command: "openclaw-gateway", + expected: true) + view.showAdvancedConnection = true + view.preferredGatewayID = gateway.stableID + view.cliInstalled = true + view.cliInstallLocation = "/usr/local/bin/openclaw" + view.cliStatus = "Installed" + view.workspacePath = "/tmp/openclaw" + view.workspaceStatus = "Saved workspace" + view.anthropicAuthPKCE = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge") + view.anthropicAuthCode = "code#state" + view.anthropicAuthStatus = "Connected" + view.anthropicAuthDetectedStatus = .connected(expiresAtMs: 1_700_000_000_000) + view.anthropicAuthConnected = true + view.anthropicAuthAutoDetectClipboard = false + view.anthropicAuthAutoConnectClipboard = false + + view.state.connectionMode = .local + _ = view.welcomePage() + _ = view.connectionPage() + _ = view.anthropicAuthPage() + _ = view.wizardPage() + _ = view.permissionsPage() + _ = view.cliPage() + _ = view.workspacePage() + _ = view.onboardingChatPage() + _ = view.readyPage() + + view.selectLocalGateway() + view.selectRemoteGateway(gateway) + view.selectUnconfiguredGateway() + + view.state.connectionMode = .remote + _ = view.connectionPage() + _ = view.workspacePage() + + view.state.connectionMode = .unconfigured + _ = view.connectionPage() + + view.currentPage = 0 + view.handleNext() + view.handleBack() + + _ = view.onboardingPage { Text("Test") } + _ = view.onboardingCard { Text("Card") } + _ = view.featureRow(title: "Feature", subtitle: "Subtitle", systemImage: "sparkles") + _ = view.featureActionRow( + title: "Action", + subtitle: "Action subtitle", + systemImage: "gearshape", + buttonTitle: "Action", + action: {}) + _ = view.gatewaySubtitle(for: gateway) + _ = view.isSelectedGateway(gateway) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift new file mode 100644 index 00000000..0c77f1e3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift @@ -0,0 +1,94 @@ +import Observation +import OpenClawProtocol +import SwiftUI + +extension OnboardingView { + func wizardPage() -> some View { + self.onboardingPage { + VStack(spacing: 16) { + Text("Setup Wizard") + .font(.largeTitle.weight(.semibold)) + Text("Follow the guided setup from the Gateway. This keeps onboarding in sync with the CLI.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 520) + + self.onboardingCard(spacing: 14, padding: 16) { + OnboardingWizardCardContent( + wizard: self.onboardingWizard, + mode: self.state.connectionMode, + workspacePath: self.workspacePath) + } + } + .task { + await self.onboardingWizard.startIfNeeded( + mode: self.state.connectionMode, + workspace: self.workspacePath.isEmpty ? nil : self.workspacePath) + } + } + } +} + +private struct OnboardingWizardCardContent: View { + @Bindable var wizard: OnboardingWizardModel + let mode: AppState.ConnectionMode + let workspacePath: String + + private enum CardState { + case error(String) + case starting + case step(WizardStep) + case complete + case waiting + } + + private var state: CardState { + if let error = wizard.errorMessage { return .error(error) } + if self.wizard.isStarting { return .starting } + if let step = wizard.currentStep { return .step(step) } + if self.wizard.isComplete { return .complete } + return .waiting + } + + var body: some View { + switch self.state { + case let .error(error): + Text("Wizard error") + .font(.headline) + Text(error) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button("Retry") { + self.wizard.reset() + Task { + await self.wizard.startIfNeeded( + mode: self.mode, + workspace: self.workspacePath.isEmpty ? nil : self.workspacePath) + } + } + .buttonStyle(.borderedProminent) + case .starting: + HStack(spacing: 8) { + ProgressView() + Text("Starting wizard…") + .foregroundStyle(.secondary) + } + case let .step(step): + OnboardingWizardStepView( + step: step, + isSubmitting: self.wizard.isSubmitting) + { value in + Task { await self.wizard.submit(step: step, value: value) } + } + .id(step.id) + case .complete: + Text("Wizard complete. Continue to the next step.") + .font(.headline) + case .waiting: + Text("Waiting for wizard…") + .foregroundStyle(.secondary) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift new file mode 100644 index 00000000..7538f846 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift @@ -0,0 +1,116 @@ +import Foundation + +extension OnboardingView { + func loadWorkspaceDefaults() async { + guard self.workspacePath.isEmpty else { return } + let configured = await self.loadAgentWorkspace() + let url = AgentWorkspace.resolveWorkspaceURL(from: configured) + self.workspacePath = AgentWorkspace.displayPath(for: url) + self.refreshBootstrapStatus() + } + + func ensureDefaultWorkspace() async { + guard self.state.connectionMode == .local else { return } + let configured = await self.loadAgentWorkspace() + let url = AgentWorkspace.resolveWorkspaceURL(from: configured) + let safety = AgentWorkspace.bootstrapSafety(for: url) + if let reason = safety.unsafeReason { + self.workspaceStatus = "Workspace not touched: \(reason)" + } else { + do { + _ = try AgentWorkspace.bootstrap(workspaceURL: url) + if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url)) + } + } catch { + self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" + } + } + self.refreshBootstrapStatus() + } + + func refreshBootstrapStatus() { + let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) + self.needsBootstrap = AgentWorkspace.needsBootstrap(workspaceURL: url) + if self.needsBootstrap { + self.didAutoKickoff = false + } + } + + var workspaceBootstrapCommand: String { + let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines) + return """ + mkdir -p ~/.openclaw/workspace + cat > ~/.openclaw/workspace/AGENTS.md <<'EOF' + \(template) + EOF + """ + } + + func applyWorkspace() async { + guard !self.workspaceApplying else { return } + self.workspaceApplying = true + defer { self.workspaceApplying = false } + + do { + let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) + if let reason = AgentWorkspace.bootstrapSafety(for: url).unsafeReason { + self.workspaceStatus = "Workspace not created: \(reason)" + return + } + _ = try AgentWorkspace.bootstrap(workspaceURL: url) + self.workspacePath = AgentWorkspace.displayPath(for: url) + self.workspaceStatus = "Workspace ready at \(self.workspacePath)" + self.refreshBootstrapStatus() + } catch { + self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" + } + } + + private func loadAgentWorkspace() async -> String? { + let root = await ConfigStore.load() + let agents = root["agents"] as? [String: Any] + let defaults = agents?["defaults"] as? [String: Any] + return defaults?["workspace"] as? String + } + + @discardableResult + func saveAgentWorkspace(_ workspace: String?) async -> Bool { + let (success, errorMessage) = await OnboardingView.buildAndSaveWorkspace(workspace) + + if let errorMessage { + self.workspaceStatus = errorMessage + } + return success + } + + @MainActor + private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) { + var root = await ConfigStore.load() + var agents = root["agents"] as? [String: Any] ?? [:] + var defaults = agents["defaults"] as? [String: Any] ?? [:] + let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + defaults.removeValue(forKey: "workspace") + } else { + defaults["workspace"] = trimmed + } + if defaults.isEmpty { + agents.removeValue(forKey: "defaults") + } else { + agents["defaults"] = defaults + } + if agents.isEmpty { + root.removeValue(forKey: "agents") + } else { + root["agents"] = agents + } + do { + try await ConfigStore.save(root) + return (true, nil) + } catch { + let errorMessage = "Failed to save config: \(error.localizedDescription)" + return (false, errorMessage) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift new file mode 100644 index 00000000..58d09ef6 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingWidgets.swift @@ -0,0 +1,65 @@ +import AppKit +import SwiftUI + +struct GlowingOpenClawIcon: View { + @Environment(\.scenePhase) private var scenePhase + + let size: CGFloat + let glowIntensity: Double + let enableFloating: Bool + + @State private var breathe = false + + init(size: CGFloat = 148, glowIntensity: Double = 0.35, enableFloating: Bool = true) { + self.size = size + self.glowIntensity = glowIntensity + self.enableFloating = enableFloating + } + + var body: some View { + let glowBlurRadius: CGFloat = 18 + let glowCanvasSize: CGFloat = self.size + 56 + ZStack { + Circle() + .fill( + LinearGradient( + colors: [ + Color.accentColor.opacity(self.glowIntensity), + Color.blue.opacity(self.glowIntensity * 0.6), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing)) + .frame(width: glowCanvasSize, height: glowCanvasSize) + .padding(glowBlurRadius) + .blur(radius: glowBlurRadius) + .scaleEffect(self.breathe ? 1.08 : 0.96) + .opacity(0.84) + + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: self.size, height: self.size) + .clipShape(RoundedRectangle(cornerRadius: self.size * 0.22, style: .continuous)) + .shadow(color: .black.opacity(0.18), radius: 14, y: 6) + .scaleEffect(self.breathe ? 1.02 : 1.0) + } + .frame( + width: glowCanvasSize + (glowBlurRadius * 2), + height: glowCanvasSize + (glowBlurRadius * 2)) + .onAppear { self.updateBreatheAnimation() } + .onDisappear { self.breathe = false } + .onChange(of: self.scenePhase) { _, _ in + self.updateBreatheAnimation() + } + } + + private func updateBreatheAnimation() { + guard self.enableFloating, self.scenePhase == .active else { + self.breathe = false + return + } + guard !self.breathe else { return } + withAnimation(Animation.easeInOut(duration: 3.6).repeatForever(autoreverses: true)) { + self.breathe = true + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingWizard.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingWizard.swift new file mode 100644 index 00000000..75b9522a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OnboardingWizard.swift @@ -0,0 +1,419 @@ +import Foundation +import Observation +import OpenClawKit +import OpenClawProtocol +import OSLog +import SwiftUI + +private let onboardingWizardLogger = Logger(subsystem: "ai.openclaw", category: "onboarding.wizard") + +// MARK: - Swift 6 AnyCodable Bridging Helpers + +// Bridge between OpenClawProtocol.AnyCodable and the local module to avoid +// Swift 6 strict concurrency type conflicts. + +private typealias ProtocolAnyCodable = OpenClawProtocol.AnyCodable + +private func bridgeToLocal(_ value: ProtocolAnyCodable) -> AnyCodable { + if let data = try? JSONEncoder().encode(value), + let decoded = try? JSONDecoder().decode(AnyCodable.self, from: data) + { + return decoded + } + return AnyCodable(value.value) +} + +private func bridgeToLocal(_ value: ProtocolAnyCodable?) -> AnyCodable? { + value.map(bridgeToLocal) +} + +@MainActor +@Observable +final class OnboardingWizardModel { + private(set) var sessionId: String? + private(set) var currentStep: WizardStep? + private(set) var status: String? + private(set) var errorMessage: String? + var isStarting = false + var isSubmitting = false + private var lastStartMode: AppState.ConnectionMode? + private var lastStartWorkspace: String? + private var restartAttempts = 0 + private let maxRestartAttempts = 1 + + var isComplete: Bool { + self.status == "done" + } + + var isRunning: Bool { + self.status == "running" + } + + func reset() { + self.sessionId = nil + self.currentStep = nil + self.status = nil + self.errorMessage = nil + self.isStarting = false + self.isSubmitting = false + self.restartAttempts = 0 + self.lastStartMode = nil + self.lastStartWorkspace = nil + } + + func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async { + guard self.sessionId == nil, !self.isStarting else { return } + guard mode == .local else { return } + if self.shouldSkipWizard() { + self.sessionId = nil + self.currentStep = nil + self.status = "done" + self.errorMessage = nil + return + } + self.isStarting = true + self.errorMessage = nil + self.lastStartMode = mode + self.lastStartWorkspace = workspace + defer { self.isStarting = false } + + do { + GatewayProcessManager.shared.setActive(true) + if await GatewayProcessManager.shared.waitForGatewayReady(timeout: 12) == false { + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Gateway did not become ready. Check that it is running."]) + } + var params: [String: AnyCodable] = ["mode": AnyCodable("local")] + if let workspace, !workspace.isEmpty { + params["workspace"] = AnyCodable(workspace) + } + let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded( + method: .wizardStart, + params: params) + self.applyStartResult(res) + } catch { + self.status = "error" + self.errorMessage = error.localizedDescription + onboardingWizardLogger.error("start failed: \(error.localizedDescription, privacy: .public)") + } + } + + func submit(step: WizardStep, value: AnyCodable?) async { + guard let sessionId, !self.isSubmitting else { return } + self.isSubmitting = true + self.errorMessage = nil + defer { self.isSubmitting = false } + + do { + var params: [String: AnyCodable] = ["sessionId": AnyCodable(sessionId)] + var answer: [String: AnyCodable] = ["stepId": AnyCodable(step.id)] + if let value { + answer["value"] = value + } + params["answer"] = AnyCodable(answer) + let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded( + method: .wizardNext, + params: params) + self.applyNextResult(res) + } catch { + if self.restartIfSessionLost(error: error) { + return + } + self.status = "error" + self.errorMessage = error.localizedDescription + onboardingWizardLogger.error("submit failed: \(error.localizedDescription, privacy: .public)") + } + } + + func cancelIfRunning() async { + guard let sessionId, self.isRunning else { return } + do { + let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded( + method: .wizardCancel, + params: ["sessionId": AnyCodable(sessionId)]) + self.applyStatusResult(res) + } catch { + self.status = "error" + self.errorMessage = error.localizedDescription + onboardingWizardLogger.error("cancel failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func applyStartResult(_ res: WizardStartResult) { + self.sessionId = res.sessionid + self.status = wizardStatusString(res.status) ?? (res.done ? "done" : "running") + self.errorMessage = res.error + self.currentStep = decodeWizardStep(res.step) + if self.currentStep == nil, res.step != nil { + onboardingWizardLogger.error("wizard step decode failed") + } + if res.done { self.currentStep = nil } + self.restartAttempts = 0 + } + + private func applyNextResult(_ res: WizardNextResult) { + let status = wizardStatusString(res.status) + self.status = status ?? self.status + self.errorMessage = res.error + self.currentStep = decodeWizardStep(res.step) + if self.currentStep == nil, res.step != nil { + onboardingWizardLogger.error("wizard step decode failed") + } + if res.done { self.currentStep = nil } + if res.done || status == "done" || status == "cancelled" || status == "error" { + self.sessionId = nil + } + } + + private func applyStatusResult(_ res: WizardStatusResult) { + self.status = wizardStatusString(res.status) ?? "unknown" + self.errorMessage = res.error + self.currentStep = nil + self.sessionId = nil + } + + private func restartIfSessionLost(error: Error) -> Bool { + guard let gatewayError = error as? GatewayResponseError else { return false } + guard gatewayError.code == ErrorCode.invalidRequest.rawValue else { return false } + let message = gatewayError.message.lowercased() + guard message.contains("wizard not found") || message.contains("wizard not running") else { return false } + guard let mode = self.lastStartMode, self.restartAttempts < self.maxRestartAttempts else { + return false + } + self.restartAttempts += 1 + self.sessionId = nil + self.currentStep = nil + self.status = nil + self.errorMessage = "Wizard session lost. Restarting…" + Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) } + return true + } + + private func shouldSkipWizard() -> Bool { + let root = OpenClawConfigFile.loadDict() + if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty { + return true + } + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any] + { + if let mode = auth["mode"] as? String, + !mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + if let token = auth["token"] as? String, + !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + if let password = auth["password"] as? String, + !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + } + return false + } +} + +struct OnboardingWizardStepView: View { + let step: WizardStep + let isSubmitting: Bool + let onStepSubmit: (AnyCodable?) -> Void + + @State private var textValue: String + @State private var confirmValue: Bool + @State private var selectedIndex: Int + @State private var selectedIndices: Set + + private let optionItems: [WizardOptionItem] + + init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) { + self.step = step + self.isSubmitting = isSubmitting + self.onStepSubmit = onSubmit + let options = parseWizardOptions(step.options).enumerated().map { index, option in + WizardOptionItem(index: index, option: option) + } + self.optionItems = options + let initialText = anyCodableString(step.initialvalue) + let initialConfirm = anyCodableBool(step.initialvalue) + let initialIndex = options.firstIndex(where: { anyCodableEqual($0.option.value, step.initialvalue) }) ?? 0 + let initialMulti = Set( + options.filter { option in + anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) } + }.map(\.index)) + + _textValue = State(initialValue: initialText) + _confirmValue = State(initialValue: initialConfirm) + _selectedIndex = State(initialValue: initialIndex) + _selectedIndices = State(initialValue: initialMulti) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if let title = step.title, !title.isEmpty { + Text(title) + .font(.title2.weight(.semibold)) + } + if let message = step.message, !message.isEmpty { + Text(message) + .font(.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + switch wizardStepType(self.step) { + case "note": + EmptyView() + case "text": + self.textField + case "confirm": + Toggle("", isOn: self.$confirmValue) + .toggleStyle(.switch) + case "select": + self.selectOptions + case "multiselect": + self.multiselectOptions + case "progress": + ProgressView() + .controlSize(.small) + case "action": + EmptyView() + default: + Text("Unsupported step type") + .foregroundStyle(.secondary) + } + + Button(action: self.submit) { + Text(wizardStepType(self.step) == "action" ? "Run" : "Continue") + .frame(minWidth: 120) + } + .buttonStyle(.borderedProminent) + .disabled(self.isSubmitting || self.isBlocked) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var textField: some View { + let isSensitive = self.step.sensitive == true + if isSensitive { + SecureField(self.step.placeholder ?? "", text: self.$textValue) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 360) + } else { + TextField(self.step.placeholder ?? "", text: self.$textValue) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 360) + } + } + + private var selectOptions: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(self.optionItems, id: \.index) { item in + self.selectOptionRow(item) + } + } + } + + private var multiselectOptions: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(self.optionItems, id: \.index) { item in + self.multiselectOptionRow(item) + } + } + } + + private func selectOptionRow(_ item: WizardOptionItem) -> some View { + Button { + self.selectedIndex = item.index + } label: { + HStack(alignment: .top, spacing: 8) { + Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle") + .foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 2) { + Text(item.option.label) + .foregroundStyle(.primary) + if let hint = item.option.hint, !hint.isEmpty { + Text(hint) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .buttonStyle(.plain) + } + + private func multiselectOptionRow(_ item: WizardOptionItem) -> some View { + Toggle(isOn: self.bindingForOption(item)) { + VStack(alignment: .leading, spacing: 2) { + Text(item.option.label) + if let hint = item.option.hint, !hint.isEmpty { + Text(hint) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + private func bindingForOption(_ item: WizardOptionItem) -> Binding { + Binding(get: { + self.selectedIndices.contains(item.index) + }, set: { newValue in + if newValue { + self.selectedIndices.insert(item.index) + } else { + self.selectedIndices.remove(item.index) + } + }) + } + + private var isBlocked: Bool { + let type = wizardStepType(step) + if type == "select" { return self.optionItems.isEmpty } + if type == "multiselect" { return self.optionItems.isEmpty } + return false + } + + private func submit() { + switch wizardStepType(self.step) { + case "note", "progress": + self.onStepSubmit(nil) + case "text": + self.onStepSubmit(AnyCodable(self.textValue)) + case "confirm": + self.onStepSubmit(AnyCodable(self.confirmValue)) + case "select": + guard self.optionItems.indices.contains(self.selectedIndex) else { + self.onStepSubmit(nil) + return + } + let option = self.optionItems[self.selectedIndex].option + self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label)) + case "multiselect": + let values = self.optionItems + .filter { self.selectedIndices.contains($0.index) } + .map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) } + self.onStepSubmit(AnyCodable(values)) + case "action": + self.onStepSubmit(AnyCodable(true)) + default: + self.onStepSubmit(nil) + } + } +} + +private struct WizardOptionItem: Identifiable { + let index: Int + let option: WizardOption + + var id: Int { + self.index + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift new file mode 100644 index 00000000..35744bae --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -0,0 +1,373 @@ +import Foundation +import OpenClawProtocol + +enum OpenClawConfigFile { + private static let logger = Logger(subsystem: "ai.openclaw", category: "config") + private static let configAuditFileName = "config-audit.jsonl" + + static func url() -> URL { + OpenClawPaths.configURL + } + + static func stateDirURL() -> URL { + OpenClawPaths.stateDirURL + } + + static func defaultWorkspaceURL() -> URL { + OpenClawPaths.workspaceURL + } + + static func loadDict() -> [String: Any] { + let url = self.url() + guard FileManager().fileExists(atPath: url.path) else { return [:] } + do { + let data = try Data(contentsOf: url) + guard let root = self.parseConfigData(data) else { + self.logger.warning("config JSON root invalid") + return [:] + } + return root + } catch { + self.logger.warning("config read failed: \(error.localizedDescription)") + return [:] + } + } + + static func saveDict(_ dict: [String: Any]) { + // Nix mode disables config writes in production, but tests rely on saving temp configs. + if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return } + let url = self.url() + let previousData = try? Data(contentsOf: url) + let previousRoot = previousData.flatMap { self.parseConfigData($0) } + let previousBytes = previousData?.count + let hadMetaBefore = self.hasMeta(previousRoot) + let gatewayModeBefore = self.gatewayMode(previousRoot) + + var output = dict + self.stampMeta(&output) + + do { + let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys]) + try FileManager().createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + let nextBytes = data.count + let gatewayModeAfter = self.gatewayMode(output) + let suspicious = self.configWriteSuspiciousReasons( + existsBefore: previousData != nil, + previousBytes: previousBytes, + nextBytes: nextBytes, + hadMetaBefore: hadMetaBefore, + gatewayModeBefore: gatewayModeBefore, + gatewayModeAfter: gatewayModeAfter) + if !suspicious.isEmpty { + self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)") + } + self.appendConfigWriteAudit([ + "result": "success", + "configPath": url.path, + "existsBefore": previousData != nil, + "previousBytes": previousBytes ?? NSNull(), + "nextBytes": nextBytes, + "hasMetaBefore": hadMetaBefore, + "hasMetaAfter": self.hasMeta(output), + "gatewayModeBefore": gatewayModeBefore ?? NSNull(), + "gatewayModeAfter": gatewayModeAfter ?? NSNull(), + "suspicious": suspicious, + ]) + } catch { + self.logger.error("config save failed: \(error.localizedDescription)") + self.appendConfigWriteAudit([ + "result": "failed", + "configPath": url.path, + "existsBefore": previousData != nil, + "previousBytes": previousBytes ?? NSNull(), + "nextBytes": NSNull(), + "hasMetaBefore": hadMetaBefore, + "hasMetaAfter": self.hasMeta(output), + "gatewayModeBefore": gatewayModeBefore ?? NSNull(), + "gatewayModeAfter": self.gatewayMode(output) ?? NSNull(), + "suspicious": [], + "error": error.localizedDescription, + ]) + } + } + + static func loadGatewayDict() -> [String: Any] { + let root = self.loadDict() + return root["gateway"] as? [String: Any] ?? [:] + } + + static func updateGatewayDict(_ mutate: (inout [String: Any]) -> Void) { + var root = self.loadDict() + var gateway = root["gateway"] as? [String: Any] ?? [:] + mutate(&gateway) + if gateway.isEmpty { + root.removeValue(forKey: "gateway") + } else { + root["gateway"] = gateway + } + self.saveDict(root) + } + + static func browserControlEnabled(defaultValue: Bool = true) -> Bool { + let root = self.loadDict() + let browser = root["browser"] as? [String: Any] + return browser?["enabled"] as? Bool ?? defaultValue + } + + static func setBrowserControlEnabled(_ enabled: Bool) { + var root = self.loadDict() + var browser = root["browser"] as? [String: Any] ?? [:] + browser["enabled"] = enabled + root["browser"] = browser + self.saveDict(root) + self.logger.debug("browser control updated enabled=\(enabled)") + } + + static func agentWorkspace() -> String? { + let root = self.loadDict() + let agents = root["agents"] as? [String: Any] + let defaults = agents?["defaults"] as? [String: Any] + return defaults?["workspace"] as? String + } + + static func setAgentWorkspace(_ workspace: String?) { + var root = self.loadDict() + var agents = root["agents"] as? [String: Any] ?? [:] + var defaults = agents["defaults"] as? [String: Any] ?? [:] + let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + defaults.removeValue(forKey: "workspace") + } else { + defaults["workspace"] = trimmed + } + if defaults.isEmpty { + agents.removeValue(forKey: "defaults") + } else { + agents["defaults"] = defaults + } + if agents.isEmpty { + root.removeValue(forKey: "agents") + } else { + root["agents"] = agents + } + self.saveDict(root) + self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)") + } + + static func gatewayPassword() -> String? { + let root = self.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any] + else { + return nil + } + return remote["password"] as? String + } + + static func gatewayPort() -> Int? { + let root = self.loadDict() + guard let gateway = root["gateway"] as? [String: Any] else { return nil } + if let port = gateway["port"] as? Int, port > 0 { return port } + if let number = gateway["port"] as? NSNumber, number.intValue > 0 { + return number.intValue + } + if let raw = gateway["port"] as? String, + let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)), + parsed > 0 + { + return parsed + } + return nil + } + + static func remoteGatewayPort() -> Int? { + guard let url = self.remoteGatewayUrl(), + let port = url.port, + port > 0 + else { return nil } + return port + } + + static func remoteGatewayPort(matchingHost sshHost: String) -> Int? { + let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSshHost.isEmpty, + let url = self.remoteGatewayUrl(), + let port = url.port, + port > 0, + let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), + !urlHost.isEmpty + else { + return nil + } + + let sshKey = Self.hostKey(trimmedSshHost) + let urlKey = Self.hostKey(urlHost) + guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil } + return port + } + + static func setRemoteGatewayUrl(host: String, port: Int?) { + guard let port, port > 0 else { return } + let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedHost.isEmpty else { return } + self.updateGatewayDict { gateway in + var remote = gateway["remote"] as? [String: Any] ?? [:] + let existingUrl = (remote["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let scheme = URL(string: existingUrl)?.scheme ?? "ws" + remote["url"] = "\(scheme)://\(trimmedHost):\(port)" + gateway["remote"] = remote + } + } + + static func clearRemoteGatewayUrl() { + self.updateGatewayDict { gateway in + guard var remote = gateway["remote"] as? [String: Any] else { return } + guard remote["url"] != nil else { return } + remote.removeValue(forKey: "url") + if remote.isEmpty { + gateway.removeValue(forKey: "remote") + } else { + gateway["remote"] = remote + } + } + } + + private static func remoteGatewayUrl() -> URL? { + let root = self.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let raw = remote["url"] as? String + else { + return nil + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } + return url + } + + private static func hostKey(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return "" } + if trimmed.contains(":") { return trimmed } + let digits = CharacterSet(charactersIn: "0123456789.") + if trimmed.rangeOfCharacter(from: digits.inverted) == nil { + return trimmed + } + return trimmed.split(separator: ".").first.map(String.init) ?? trimmed + } + + private static func parseConfigData(_ data: Data) -> [String: Any]? { + if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return root + } + let decoder = JSONDecoder() + if #available(macOS 12.0, *) { + decoder.allowsJSON5 = true + } + if let decoded = try? decoder.decode([String: AnyCodable].self, from: data) { + self.logger.notice("config parsed with JSON5 decoder") + return decoded.mapValues { $0.foundationValue } + } + return nil + } + + private static func stampMeta(_ root: inout [String: Any]) { + var meta = root["meta"] as? [String: Any] ?? [:] + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "macos-app" + meta["lastTouchedVersion"] = version + meta["lastTouchedAt"] = ISO8601DateFormatter().string(from: Date()) + root["meta"] = meta + } + + private static func hasMeta(_ root: [String: Any]?) -> Bool { + guard let root else { return false } + return root["meta"] is [String: Any] + } + + private static func hasMeta(_ root: [String: Any]) -> Bool { + root["meta"] is [String: Any] + } + + private static func gatewayMode(_ root: [String: Any]?) -> String? { + guard let root else { return nil } + return self.gatewayMode(root) + } + + private static func gatewayMode(_ root: [String: Any]) -> String? { + guard let gateway = root["gateway"] as? [String: Any], + let mode = gateway["mode"] as? String + else { return nil } + let trimmed = mode.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func configWriteSuspiciousReasons( + existsBefore: Bool, + previousBytes: Int?, + nextBytes: Int, + hadMetaBefore: Bool, + gatewayModeBefore: String?, + gatewayModeAfter: String?) -> [String] + { + var reasons: [String] = [] + if !existsBefore { + return reasons + } + if let previousBytes, previousBytes >= 512, nextBytes < max(1, previousBytes / 2) { + reasons.append("size-drop:\(previousBytes)->\(nextBytes)") + } + if !hadMetaBefore { + reasons.append("missing-meta-before-write") + } + if gatewayModeBefore != nil, gatewayModeAfter == nil { + reasons.append("gateway-mode-removed") + } + return reasons + } + + private static func configAuditLogURL() -> URL { + self.stateDirURL() + .appendingPathComponent("logs", isDirectory: true) + .appendingPathComponent(self.configAuditFileName, isDirectory: false) + } + + private static func appendConfigWriteAudit(_ fields: [String: Any]) { + var record: [String: Any] = [ + "ts": ISO8601DateFormatter().string(from: Date()), + "source": "macos-openclaw-config-file", + "event": "config.write", + "pid": ProcessInfo.processInfo.processIdentifier, + "argv": Array(ProcessInfo.processInfo.arguments.prefix(8)), + ] + for (key, value) in fields { + record[key] = value is NSNull ? NSNull() : value + } + guard JSONSerialization.isValidJSONObject(record), + let data = try? JSONSerialization.data(withJSONObject: record) + else { + return + } + var line = Data() + line.append(data) + line.append(0x0A) + let logURL = self.configAuditLogURL() + do { + try FileManager().createDirectory( + at: logURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + if !FileManager().fileExists(atPath: logURL.path) { + FileManager().createFile(atPath: logURL.path, contents: nil) + } + let handle = try FileHandle(forWritingTo: logURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: line) + } catch { + // best-effort + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OpenClawPaths.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OpenClawPaths.swift new file mode 100644 index 00000000..206031f9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/OpenClawPaths.swift @@ -0,0 +1,53 @@ +import Foundation + +enum OpenClawEnv { + static func path(_ key: String) -> String? { + // Normalize env overrides once so UI + file IO stay consistent. + guard let raw = getenv(key) else { return nil } + let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty + else { + return nil + } + return value + } +} + +enum OpenClawPaths { + private static let configPathEnv = ["OPENCLAW_CONFIG_PATH"] + private static let stateDirEnv = ["OPENCLAW_STATE_DIR"] + + static var stateDirURL: URL { + for key in self.stateDirEnv { + if let override = OpenClawEnv.path(key) { + return URL(fileURLWithPath: override, isDirectory: true) + } + } + let home = FileManager().homeDirectoryForCurrentUser + return home.appendingPathComponent(".openclaw", isDirectory: true) + } + + private static func resolveConfigCandidate(in dir: URL) -> URL? { + let candidates = [ + dir.appendingPathComponent("openclaw.json"), + ] + return candidates.first(where: { FileManager().fileExists(atPath: $0.path) }) + } + + static var configURL: URL { + for key in self.configPathEnv { + if let override = OpenClawEnv.path(key) { + return URL(fileURLWithPath: override) + } + } + let stateDir = self.stateDirURL + if let existing = self.resolveConfigCandidate(in: stateDir) { + return existing + } + return stateDir.appendingPathComponent("openclaw.json") + } + + static var workspaceURL: URL { + self.stateDirURL.appendingPathComponent("workspace", isDirectory: true) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift new file mode 100644 index 00000000..e8e4428b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift @@ -0,0 +1,46 @@ +import AppKit + +final class PairingAlertHostWindow: NSWindow { + override var canBecomeKey: Bool { + true + } + + override var canBecomeMain: Bool { + true + } +} + +@MainActor +enum PairingAlertSupport { + static func endActiveAlert(activeAlert: inout NSAlert?, activeRequestId: inout String?) { + guard let alert = activeAlert else { return } + if let parent = alert.window.sheetParent { + parent.endSheet(alert.window, returnCode: .abort) + } + activeAlert = nil + activeRequestId = nil + } + + static func requireAlertHostWindow(alertHostWindow: inout NSWindow?) -> NSWindow { + if let alertHostWindow { + return alertHostWindow + } + + let window = PairingAlertHostWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 1), + styleMask: [.borderless], + backing: .buffered, + defer: false) + window.title = "" + window.isReleasedWhenClosed = false + window.level = .floating + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.isOpaque = false + window.hasShadow = false + window.backgroundColor = .clear + window.ignoresMouseEvents = true + + alertHostWindow = window + return window + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift new file mode 100644 index 00000000..9f97650b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift @@ -0,0 +1,137 @@ +import Foundation +import os +import PeekabooAutomationKit +import PeekabooBridge +import PeekabooFoundation +import Security + +@MainActor +final class PeekabooBridgeHostCoordinator { + static let shared = PeekabooBridgeHostCoordinator() + + private let logger = Logger(subsystem: "ai.openclaw", category: "PeekabooBridge") + + private var host: PeekabooBridgeHost? + private var services: OpenClawPeekabooBridgeServices? + private static var openclawSocketPath: String { + let fileManager = FileManager.default + let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") + let directory = base.appendingPathComponent("OpenClaw", isDirectory: true) + return directory.appendingPathComponent(PeekabooBridgeConstants.socketName, isDirectory: false).path + } + + func setEnabled(_ enabled: Bool) async { + if enabled { + await self.startIfNeeded() + } else { + await self.stop() + } + } + + func stop() async { + guard let host else { return } + await host.stop() + self.host = nil + self.services = nil + self.logger.info("PeekabooBridge host stopped") + } + + private func startIfNeeded() async { + guard self.host == nil else { return } + + var allowlistedTeamIDs: Set = ["Y5PE65HELJ"] + if let teamID = Self.currentTeamID() { + allowlistedTeamIDs.insert(teamID) + } + let allowlistedBundles: Set = [] + + let services = OpenClawPeekabooBridgeServices() + let server = PeekabooBridgeServer( + services: services, + hostKind: .gui, + allowlistedTeams: allowlistedTeamIDs, + allowlistedBundles: allowlistedBundles) + + let host = PeekabooBridgeHost( + socketPath: Self.openclawSocketPath, + server: server, + allowedTeamIDs: allowlistedTeamIDs, + requestTimeoutSec: 10) + + self.services = services + self.host = host + + await host.start() + self.logger + .info("PeekabooBridge host started at \(Self.openclawSocketPath, privacy: .public)") + } + + private static func currentTeamID() -> String? { + var code: SecCode? + guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess, + let code + else { + return nil + } + + var staticCode: SecStaticCode? + guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess, + let staticCode + else { + return nil + } + + var infoCF: CFDictionary? + guard SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSSigningInformation), + &infoCF) == errSecSuccess, + let info = infoCF as? [String: Any] + else { + return nil + } + + return info[kSecCodeInfoTeamIdentifier as String] as? String + } +} + +@MainActor +private final class OpenClawPeekabooBridgeServices: PeekabooBridgeServiceProviding { + let permissions: PermissionsService + let screenCapture: any ScreenCaptureServiceProtocol + let automation: any UIAutomationServiceProtocol + let windows: any WindowManagementServiceProtocol + let applications: any ApplicationServiceProtocol + let menu: any MenuServiceProtocol + let dock: any DockServiceProtocol + let dialogs: any DialogServiceProtocol + let snapshots: any SnapshotManagerProtocol + + init() { + let logging = LoggingService(subsystem: "ai.openclaw.peekaboo") + let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient() + + let snapshots = InMemorySnapshotManager(options: .init( + snapshotValidityWindow: 600, + maxSnapshots: 50, + deleteArtifactsOnCleanup: false)) + let applications = ApplicationService(feedbackClient: feedbackClient) + + let screenCapture = ScreenCaptureService(loggingService: logging) + + self.permissions = PermissionsService() + self.snapshots = snapshots + self.applications = applications + self.screenCapture = screenCapture + self.automation = UIAutomationService( + snapshotManager: snapshots, + loggingService: logging, + searchPolicy: .balanced, + feedbackClient: feedbackClient) + self.windows = WindowManagementService(applicationService: applications, feedbackClient: feedbackClient) + self.menu = MenuService(applicationService: applications, feedbackClient: feedbackClient) + self.dock = DockService(feedbackClient: feedbackClient) + self.dialogs = DialogService(feedbackClient: feedbackClient) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PermissionManager.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PermissionManager.swift new file mode 100644 index 00000000..b5bcd167 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PermissionManager.swift @@ -0,0 +1,506 @@ +import AppKit +import ApplicationServices +import AVFoundation +import CoreGraphics +import CoreLocation +import Foundation +import Observation +import OpenClawIPC +import Speech +import UserNotifications + +enum PermissionManager { + static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool { + if requireAlways { return status == .authorizedAlways } + switch status { + case .authorizedAlways, .authorizedWhenInUse: + return true + case .authorized: // deprecated, but still shows up on some macOS versions + return true + default: + return false + } + } + + static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] { + var results: [Capability: Bool] = [:] + for cap in caps { + results[cap] = await self.ensureCapability(cap, interactive: interactive) + } + return results + } + + private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool { + switch cap { + case .notifications: + await self.ensureNotifications(interactive: interactive) + case .appleScript: + await self.ensureAppleScript(interactive: interactive) + case .accessibility: + await self.ensureAccessibility(interactive: interactive) + case .screenRecording: + await self.ensureScreenRecording(interactive: interactive) + case .microphone: + await self.ensureMicrophone(interactive: interactive) + case .speechRecognition: + await self.ensureSpeechRecognition(interactive: interactive) + case .camera: + await self.ensureCamera(interactive: interactive) + case .location: + await self.ensureLocation(interactive: interactive) + } + } + + private static func ensureNotifications(interactive: Bool) async -> Bool { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + case .notDetermined: + guard interactive else { return false } + let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false + let updated = await center.notificationSettings() + return granted && + (updated.authorizationStatus == .authorized || updated.authorizationStatus == .provisional) + case .denied: + if interactive { + NotificationPermissionHelper.openSettings() + } + return false + @unknown default: + return false + } + } + + private static func ensureAppleScript(interactive: Bool) async -> Bool { + let granted = await MainActor.run { AppleScriptPermission.isAuthorized() } + if interactive, !granted { + await AppleScriptPermission.requestAuthorization() + } + return await MainActor.run { AppleScriptPermission.isAuthorized() } + } + + private static func ensureAccessibility(interactive: Bool) async -> Bool { + let trusted = await MainActor.run { AXIsProcessTrusted() } + if interactive, !trusted { + await MainActor.run { + let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true] + _ = AXIsProcessTrustedWithOptions(opts) + } + } + return await MainActor.run { AXIsProcessTrusted() } + } + + private static func ensureScreenRecording(interactive: Bool) async -> Bool { + let granted = ScreenRecordingProbe.isAuthorized() + if interactive, !granted { + await ScreenRecordingProbe.requestAuthorization() + } + return ScreenRecordingProbe.isAuthorized() + } + + private static func ensureMicrophone(interactive: Bool) async -> Bool { + let status = AVCaptureDevice.authorizationStatus(for: .audio) + switch status { + case .authorized: + return true + case .notDetermined: + guard interactive else { return false } + return await AVCaptureDevice.requestAccess(for: .audio) + case .denied, .restricted: + if interactive { + MicrophonePermissionHelper.openSettings() + } + return false + @unknown default: + return false + } + } + + private static func ensureSpeechRecognition(interactive: Bool) async -> Bool { + let status = SFSpeechRecognizer.authorizationStatus() + if status == .notDetermined, interactive { + await withUnsafeContinuation { (cont: UnsafeContinuation) in + SFSpeechRecognizer.requestAuthorization { _ in + DispatchQueue.main.async { cont.resume() } + } + } + } + return SFSpeechRecognizer.authorizationStatus() == .authorized + } + + private static func ensureCamera(interactive: Bool) async -> Bool { + let status = AVCaptureDevice.authorizationStatus(for: .video) + switch status { + case .authorized: + return true + case .notDetermined: + guard interactive else { return false } + return await AVCaptureDevice.requestAccess(for: .video) + case .denied, .restricted: + if interactive { + CameraPermissionHelper.openSettings() + } + return false + @unknown default: + return false + } + } + + private static func ensureLocation(interactive: Bool) async -> Bool { + guard CLLocationManager.locationServicesEnabled() else { + if interactive { + await MainActor.run { LocationPermissionHelper.openSettings() } + } + return false + } + let status = CLLocationManager().authorizationStatus + switch status { + case .authorizedAlways, .authorizedWhenInUse, .authorized: + return true + case .notDetermined: + guard interactive else { return false } + let updated = await LocationPermissionRequester.shared.request(always: false) + return self.isLocationAuthorized(status: updated, requireAlways: false) + case .denied, .restricted: + if interactive { + await MainActor.run { LocationPermissionHelper.openSettings() } + } + return false + @unknown default: + return false + } + } + + static func voiceWakePermissionsGranted() -> Bool { + let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + let speech = SFSpeechRecognizer.authorizationStatus() == .authorized + return mic && speech + } + + static func ensureVoiceWakePermissions(interactive: Bool) async -> Bool { + let results = await self.ensure([.microphone, .speechRecognition], interactive: interactive) + return results[.microphone] == true && results[.speechRecognition] == true + } + + static func status(_ caps: [Capability] = Capability.allCases) async -> [Capability: Bool] { + var results: [Capability: Bool] = [:] + for cap in caps { + switch cap { + case .notifications: + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + results[cap] = settings.authorizationStatus == .authorized + || settings.authorizationStatus == .provisional + + case .appleScript: + results[cap] = await MainActor.run { AppleScriptPermission.isAuthorized() } + + case .accessibility: + results[cap] = await MainActor.run { AXIsProcessTrusted() } + + case .screenRecording: + if #available(macOS 10.15, *) { + results[cap] = CGPreflightScreenCaptureAccess() + } else { + results[cap] = true + } + + case .microphone: + results[cap] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + + case .speechRecognition: + results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized + + case .camera: + results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized + + case .location: + let status = CLLocationManager().authorizationStatus + results[cap] = CLLocationManager.locationServicesEnabled() + && self.isLocationAuthorized(status: status, requireAlways: false) + } + } + return results + } +} + +enum NotificationPermissionHelper { + static func openSettings() { + let candidates = [ + "x-apple.systempreferences:com.apple.Notifications-Settings.extension", + "x-apple.systempreferences:com.apple.preference.notifications", + ] + + for candidate in candidates { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + return + } + } + } +} + +enum MicrophonePermissionHelper { + static func openSettings() { + let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", + "x-apple.systempreferences:com.apple.preference.security", + ] + + for candidate in candidates { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + return + } + } + } +} + +enum CameraPermissionHelper { + static func openSettings() { + let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", + "x-apple.systempreferences:com.apple.preference.security", + ] + + for candidate in candidates { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + return + } + } + } +} + +enum LocationPermissionHelper { + static func openSettings() { + let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices", + "x-apple.systempreferences:com.apple.preference.security", + ] + + for candidate in candidates { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + return + } + } + } +} + +@MainActor +final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate { + static let shared = LocationPermissionRequester() + private let manager = CLLocationManager() + private var continuation: CheckedContinuation? + private var timeoutTask: Task? + + override init() { + super.init() + self.manager.delegate = self + } + + func request(always: Bool) async -> CLAuthorizationStatus { + let current = self.manager.authorizationStatus + if PermissionManager.isLocationAuthorized(status: current, requireAlways: always) { + return current + } + + return await withCheckedContinuation { cont in + self.continuation = cont + self.timeoutTask?.cancel() + self.timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 3_000_000_000) + await MainActor.run { [weak self] in + guard let self else { return } + guard self.continuation != nil else { return } + LocationPermissionHelper.openSettings() + self.finish(status: self.manager.authorizationStatus) + } + } + if always { + self.manager.requestAlwaysAuthorization() + } else { + self.manager.requestWhenInUseAuthorization() + } + + // On macOS, requesting an actual fix makes the prompt more reliable. + self.manager.requestLocation() + } + } + + private func finish(status: CLAuthorizationStatus) { + self.timeoutTask?.cancel() + self.timeoutTask = nil + guard let cont = self.continuation else { return } + self.continuation = nil + cont.resume(returning: status) + } + + /// nonisolated for Swift 6 strict concurrency compatibility + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + Task { @MainActor in + self.finish(status: status) + } + } + + /// Legacy callback (still used on some macOS versions / configurations). + nonisolated func locationManager( + _ manager: CLLocationManager, + didChangeAuthorization status: CLAuthorizationStatus) + { + Task { @MainActor in + self.finish(status: status) + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + let status = manager.authorizationStatus + Task { @MainActor in + if status == .denied || status == .restricted { + LocationPermissionHelper.openSettings() + } + self.finish(status: status) + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + let status = manager.authorizationStatus + Task { @MainActor in + self.finish(status: status) + } + } +} + +enum AppleScriptPermission { + private static let logger = Logger(subsystem: "ai.openclaw", category: "AppleScriptPermission") + + /// Sends a benign AppleScript to Terminal to verify Automation permission. + @MainActor + static func isAuthorized() -> Bool { + let script = """ + tell application "Terminal" + return "openclaw-ok" + end tell + """ + + var error: NSDictionary? + let appleScript = NSAppleScript(source: script) + let result = appleScript?.executeAndReturnError(&error) + + if let error, let code = error["NSAppleScriptErrorNumber"] as? Int { + if code == -1743 { // errAEEventWouldRequireUserConsent + Self.logger.debug("AppleScript permission denied (-1743)") + return false + } + Self.logger.debug("AppleScript check failed with code \(code)") + } + + return result != nil + } + + /// Triggers the TCC prompt and opens System Settings → Privacy & Security → Automation. + @MainActor + static func requestAuthorization() async { + _ = self.isAuthorized() // first attempt triggers the dialog if not granted + + // Open the Automation pane to help the user if the prompt was dismissed. + let urlStrings = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", + "x-apple.systempreferences:com.apple.preference.security", + ] + + for candidate in urlStrings { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + break + } + } + } +} + +@MainActor +@Observable +final class PermissionMonitor { + static let shared = PermissionMonitor() + + private(set) var status: [Capability: Bool] = [:] + + private var monitorTimer: Timer? + private var isChecking = false + private var registrations = 0 + private var lastCheck: Date? + private let minimumCheckInterval: TimeInterval = 0.5 + + func register() { + self.registrations += 1 + if self.registrations == 1 { + self.startMonitoring() + } + } + + func unregister() { + guard self.registrations > 0 else { return } + self.registrations -= 1 + if self.registrations == 0 { + self.stopMonitoring() + } + } + + func refreshNow() async { + await self.checkStatus(force: true) + } + + private func startMonitoring() { + Task { await self.checkStatus(force: true) } + + if ProcessInfo.processInfo.isRunningTests { + return + } + self.monitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + await self.checkStatus(force: false) + } + } + } + + private func stopMonitoring() { + self.monitorTimer?.invalidate() + self.monitorTimer = nil + self.lastCheck = nil + } + + private func checkStatus(force: Bool) async { + if self.isChecking { return } + let now = Date() + if !force, let lastCheck, now.timeIntervalSince(lastCheck) < self.minimumCheckInterval { + return + } + + self.isChecking = true + + let latest = await PermissionManager.status() + if latest != self.status { + self.status = latest + } + self.lastCheck = Date() + + self.isChecking = false + } +} + +enum ScreenRecordingProbe { + static func isAuthorized() -> Bool { + if #available(macOS 10.15, *) { + return CGPreflightScreenCaptureAccess() + } + return true + } + + @MainActor + static func requestAuthorization() async { + if #available(macOS 10.15, *) { + _ = CGRequestScreenCaptureAccess() + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PermissionsSettings.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PermissionsSettings.swift new file mode 100644 index 00000000..de15e5eb --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PermissionsSettings.swift @@ -0,0 +1,229 @@ +import CoreLocation +import OpenClawIPC +import OpenClawKit +import SwiftUI + +struct PermissionsSettings: View { + let status: [Capability: Bool] + let refresh: () async -> Void + let showOnboarding: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + SystemRunSettingsView() + + Text("Allow these so OpenClaw can notify and capture when needed.") + .padding(.top, 4) + + PermissionStatusList(status: self.status, refresh: self.refresh) + .padding(.horizontal, 2) + .padding(.vertical, 6) + + LocationAccessSettings() + + Button("Restart onboarding") { self.showOnboarding() } + .buttonStyle(.bordered) + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + } +} + +private struct LocationAccessSettings: View { + @AppStorage(locationModeKey) private var locationModeRaw: String = OpenClawLocationMode.off.rawValue + @AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true + @State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Location Access") + .font(.body) + + Picker("", selection: self.$locationModeRaw) { + Text("Off").tag(OpenClawLocationMode.off.rawValue) + Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue) + Text("Always").tag(OpenClawLocationMode.always.rawValue) + } + .labelsHidden() + .pickerStyle(.menu) + + Toggle("Precise Location", isOn: self.$locationPreciseEnabled) + .disabled(self.locationMode == .off) + + Text("Always may require System Settings to approve background location.") + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + .onAppear { + self.lastLocationModeRaw = self.locationModeRaw + } + .onChange(of: self.locationModeRaw) { _, newValue in + let previous = self.lastLocationModeRaw + self.lastLocationModeRaw = newValue + guard let mode = OpenClawLocationMode(rawValue: newValue) else { return } + Task { + let granted = await self.requestLocationAuthorization(mode: mode) + if !granted { + await MainActor.run { + self.locationModeRaw = previous + self.lastLocationModeRaw = previous + } + } + } + } + } + + private var locationMode: OpenClawLocationMode { + OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off + } + + private func requestLocationAuthorization(mode: OpenClawLocationMode) async -> Bool { + guard mode != .off else { return true } + guard CLLocationManager.locationServicesEnabled() else { + await MainActor.run { LocationPermissionHelper.openSettings() } + return false + } + + let status = CLLocationManager().authorizationStatus + let requireAlways = mode == .always + if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) { + return true + } + let updated = await LocationPermissionRequester.shared.request(always: requireAlways) + return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways) + } +} + +struct PermissionStatusList: View { + let status: [Capability: Bool] + let refresh: () async -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(Capability.allCases, id: \.self) { cap in + PermissionRow(capability: cap, status: self.status[cap] ?? false) { + Task { await self.handle(cap) } + } + } + Button { + Task { await self.refresh() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + .font(.footnote) + .padding(.top, 2) + .help("Refresh status") + } + } + + @MainActor + private func handle(_ cap: Capability) async { + _ = await PermissionManager.ensure([cap], interactive: true) + await self.refresh() + } +} + +struct PermissionRow: View { + let capability: Capability + let status: Bool + let compact: Bool + let action: () -> Void + + init(capability: Capability, status: Bool, compact: Bool = false, action: @escaping () -> Void) { + self.capability = capability + self.status = status + self.compact = compact + self.action = action + } + + var body: some View { + HStack(spacing: self.compact ? 10 : 12) { + ZStack { + Circle().fill(self.status ? Color.green.opacity(0.2) : Color.gray.opacity(0.15)) + .frame(width: self.iconSize, height: self.iconSize) + Image(systemName: self.icon) + .foregroundStyle(self.status ? Color.green : Color.secondary) + } + VStack(alignment: .leading, spacing: 2) { + Text(self.title).font(.body.weight(.semibold)) + Text(self.subtitle).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if self.status { + Label("Granted", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Button("Grant") { self.action() } + .buttonStyle(.bordered) + } + } + .padding(.vertical, self.compact ? 4 : 6) + } + + private var iconSize: CGFloat { + self.compact ? 28 : 32 + } + + private var title: String { + switch self.capability { + case .appleScript: "Automation (AppleScript)" + case .notifications: "Notifications" + case .accessibility: "Accessibility" + case .screenRecording: "Screen Recording" + case .microphone: "Microphone" + case .speechRecognition: "Speech Recognition" + case .camera: "Camera" + case .location: "Location" + } + } + + private var subtitle: String { + switch self.capability { + case .appleScript: + "Control other apps (e.g. Terminal) for automation actions" + case .notifications: "Show desktop alerts for agent activity" + case .accessibility: "Control UI elements when an action requires it" + case .screenRecording: "Capture the screen for context or screenshots" + case .microphone: "Allow Voice Wake and audio capture" + case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device" + case .camera: "Capture photos and video from the camera" + case .location: "Share location when requested by the agent" + } + } + + private var icon: String { + switch self.capability { + case .appleScript: "applescript" + case .notifications: "bell" + case .accessibility: "hand.raised" + case .screenRecording: "display" + case .microphone: "mic" + case .speechRecognition: "waveform" + case .camera: "camera" + case .location: "location" + } + } +} + +#if DEBUG +struct PermissionsSettings_Previews: PreviewProvider { + static var previews: some View { + PermissionsSettings( + status: [ + .appleScript: true, + .notifications: true, + .accessibility: false, + .screenRecording: false, + .microphone: true, + .speechRecognition: false, + ], + refresh: {}, + showOnboarding: {}) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PointingHandCursor.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PointingHandCursor.swift new file mode 100644 index 00000000..ceb6fb6f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PointingHandCursor.swift @@ -0,0 +1,30 @@ +import AppKit +import SwiftUI + +private struct PointingHandCursorModifier: ViewModifier { + @State private var isHovering = false + + func body(content: Content) -> some View { + content + .onHover { hovering in + guard hovering != self.isHovering else { return } + self.isHovering = hovering + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + .onDisappear { + guard self.isHovering else { return } + self.isHovering = false + NSCursor.pop() + } + } +} + +extension View { + func pointingHandCursor() -> some View { + self.modifier(PointingHandCursorModifier()) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PortGuardian.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PortGuardian.swift new file mode 100644 index 00000000..7ab7e8de --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PortGuardian.swift @@ -0,0 +1,422 @@ +import Foundation +import OSLog +#if canImport(Darwin) +import Darwin +#endif + +actor PortGuardian { + static let shared = PortGuardian() + + struct Record: Codable { + let port: Int + let pid: Int32 + let command: String + let mode: String + let timestamp: TimeInterval + } + + struct Descriptor: Sendable { + let pid: Int32 + let command: String + let executablePath: String? + } + + private var records: [Record] = [] + private let logger = Logger(subsystem: "ai.openclaw", category: "portguard") + private nonisolated static let appSupportDir: URL = { + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("OpenClaw", isDirectory: true) + }() + + private nonisolated static var recordPath: URL { + self.appSupportDir.appendingPathComponent("port-guard.json", isDirectory: false) + } + + init() { + self.records = Self.loadRecords(from: Self.recordPath) + } + + func sweep(mode: AppState.ConnectionMode) async { + self.logger.info("port sweep starting (mode=\(mode.rawValue, privacy: .public))") + guard mode != .unconfigured else { + self.logger.info("port sweep skipped (mode=unconfigured)") + return + } + let ports = [GatewayEnvironment.gatewayPort()] + for port in ports { + let listeners = await self.listeners(on: port) + guard !listeners.isEmpty else { continue } + for listener in listeners { + if self.isExpected(listener, port: port, mode: mode) { + let message = """ + port \(port) already served by expected \(listener.command) + (pid \(listener.pid)) — keeping + """ + self.logger.info("\(message, privacy: .public)") + continue + } + let killed = await self.kill(listener.pid) + if killed { + let message = """ + port \(port) was held by \(listener.command) + (pid \(listener.pid)); terminated + """ + self.logger.error("\(message, privacy: .public)") + } else { + self.logger.error("failed to terminate pid \(listener.pid) on port \(port, privacy: .public)") + } + } + } + self.logger.info("port sweep done") + } + + func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async { + try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true) + self.records.removeAll { $0.pid == pid } + self.records.append( + Record( + port: port, + pid: pid, + command: command, + mode: mode.rawValue, + timestamp: Date().timeIntervalSince1970)) + self.save() + } + + func removeRecord(pid: Int32) { + let before = self.records.count + self.records.removeAll { $0.pid == pid } + if self.records.count != before { + self.save() + } + } + + struct PortReport: Identifiable { + enum Status { + case ok(String) + case missing(String) + case interference(String, offenders: [ReportListener]) + } + + let port: Int + let expected: String + let status: Status + let listeners: [ReportListener] + + var id: Int { + self.port + } + + var offenders: [ReportListener] { + if case let .interference(_, offenders) = self.status { return offenders } + return [] + } + + var summary: String { + switch self.status { + case let .ok(text): text + case let .missing(text): text + case let .interference(text, _): text + } + } + } + + func describe(port: Int) async -> Descriptor? { + guard let listener = await self.listeners(on: port).first else { return nil } + let path = Self.executablePath(for: listener.pid) + return Descriptor(pid: listener.pid, command: listener.command, executablePath: path) + } + + // MARK: - Internals + + private struct Listener { + let pid: Int32 + let command: String + let fullCommand: String + let user: String? + } + + struct ReportListener: Identifiable { + let pid: Int32 + let command: String + let fullCommand: String + let user: String? + let expected: Bool + + var id: Int32 { + self.pid + } + } + + func diagnose(mode: AppState.ConnectionMode) async -> [PortReport] { + if mode == .unconfigured { + return [] + } + let ports = [GatewayEnvironment.gatewayPort()] + var reports: [PortReport] = [] + + for port in ports { + let listeners = await self.listeners(on: port) + let tunnelHealthy = await self.probeGatewayHealthIfNeeded( + port: port, + mode: mode, + listeners: listeners) + reports.append(Self.buildReport( + port: port, + listeners: listeners, + mode: mode, + tunnelHealthy: tunnelHealthy)) + } + + return reports + } + + func probeGatewayHealth(port: Int, timeout: TimeInterval = 2.0) async -> Bool { + let url = URL(string: "http://127.0.0.1:\(port)/")! + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = timeout + config.timeoutIntervalForResource = timeout + let session = URLSession(configuration: config) + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = timeout + do { + let (_, response) = try await session.data(for: request) + return response is HTTPURLResponse + } catch { + return false + } + } + + func isListening(port: Int, pid: Int32? = nil) async -> Bool { + let listeners = await self.listeners(on: port) + if let pid { + return listeners.contains(where: { $0.pid == pid }) + } + return !listeners.isEmpty + } + + private func listeners(on port: Int) async -> [Listener] { + let res = await ShellExecutor.run( + command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"], + cwd: nil, + env: nil, + timeout: 5) + guard res.ok, let data = res.payload, !data.isEmpty else { return [] } + let text = String(data: data, encoding: .utf8) ?? "" + return Self.parseListeners(from: text) + } + + private static func readFullCommand(pid: Int32) -> String? { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/ps") + proc.arguments = ["-p", "\(pid)", "-o", "command="] + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = Pipe() + do { + let data = try proc.runAndReadToEnd(from: pipe) + guard !data.isEmpty else { return nil } + return String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + } catch { + return nil + } + } + + private static func parseListeners(from text: String) -> [Listener] { + var listeners: [Listener] = [] + var currentPid: Int32? + var currentCmd: String? + var currentUser: String? + + func flush() { + if let pid = currentPid, let cmd = currentCmd { + let full = Self.readFullCommand(pid: pid) ?? cmd + listeners.append(Listener(pid: pid, command: cmd, fullCommand: full, user: currentUser)) + } + currentPid = nil + currentCmd = nil + currentUser = nil + } + + for line in text.split(separator: "\n") { + guard let prefix = line.first else { continue } + let value = String(line.dropFirst()) + switch prefix { + case "p": + flush() + currentPid = Int32(value) ?? 0 + case "c": + currentCmd = value + case "u": + currentUser = value + default: + continue + } + } + flush() + return listeners + } + + private static func buildReport( + port: Int, + listeners: [Listener], + mode: AppState.ConnectionMode, + tunnelHealthy: Bool?) -> PortReport + { + let expectedDesc: String + let okPredicate: (Listener) -> Bool + let expectedCommands = ["node", "openclaw", "tsx", "pnpm", "bun"] + + switch mode { + case .remote: + expectedDesc = "SSH tunnel to remote gateway" + okPredicate = { $0.command.lowercased().contains("ssh") } + case .local: + expectedDesc = "Gateway websocket (node/tsx)" + okPredicate = { listener in + let c = listener.command.lowercased() + return expectedCommands.contains { c.contains($0) } + } + case .unconfigured: + expectedDesc = "Gateway not configured" + okPredicate = { _ in false } + } + + if listeners.isEmpty { + let text = "Nothing is listening on \(port) (\(expectedDesc))." + return .init(port: port, expected: expectedDesc, status: .missing(text), listeners: []) + } + + let tunnelUnhealthy = + mode == .remote && port == GatewayEnvironment.gatewayPort() && tunnelHealthy == false + let reportListeners = listeners.map { listener in + var expected = okPredicate(listener) + if tunnelUnhealthy, expected { expected = false } + return ReportListener( + pid: listener.pid, + command: listener.command, + fullCommand: listener.fullCommand, + user: listener.user, + expected: expected) + } + + let offenders = reportListeners.filter { !$0.expected } + if tunnelUnhealthy { + let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") + let reason = "Port \(port) is served by \(list), but the SSH tunnel is unhealthy." + return .init( + port: port, + expected: expectedDesc, + status: .interference(reason, offenders: offenders), + listeners: reportListeners) + } + if offenders.isEmpty { + let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") + let okText = "Port \(port) is served by \(list)." + return .init( + port: port, + expected: expectedDesc, + status: .ok(okText), + listeners: reportListeners) + } + + let list = offenders.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") + let reason = "Port \(port) is held by \(list), expected \(expectedDesc)." + return .init( + port: port, + expected: expectedDesc, + status: .interference(reason, offenders: offenders), + listeners: reportListeners) + } + + private static func executablePath(for pid: Int32) -> String? { + #if canImport(Darwin) + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + let length = proc_pidpath(pid, &buffer, UInt32(buffer.count)) + guard length > 0 else { return nil } + // Drop trailing null and decode as UTF-8. + let trimmed = buffer.prefix { $0 != 0 } + let bytes = trimmed.map { UInt8(bitPattern: $0) } + return String(bytes: bytes, encoding: .utf8) + #else + return nil + #endif + } + + private func kill(_ pid: Int32) async -> Bool { + let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2) + if term.ok { return true } + let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2) + return sigkill.ok + } + + private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool { + let cmd = listener.command.lowercased() + let full = listener.fullCommand.lowercased() + switch mode { + case .remote: + // Remote mode expects an SSH tunnel for the gateway WebSocket port. + if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") } + return false + case .local: + // The gateway daemon may listen as `openclaw` or as its runtime (`node`, `bun`, etc). + if full.contains("gateway-daemon") { return true } + // If args are unavailable, treat a CLI listener as expected. + if cmd.contains("openclaw"), full == cmd { return true } + return false + case .unconfigured: + return false + } + } + + private func probeGatewayHealthIfNeeded( + port: Int, + mode: AppState.ConnectionMode, + listeners: [Listener]) async -> Bool? + { + guard mode == .remote, port == GatewayEnvironment.gatewayPort(), !listeners.isEmpty else { return nil } + let hasSsh = listeners.contains { $0.command.lowercased().contains("ssh") } + guard hasSsh else { return nil } + return await self.probeGatewayHealth(port: port) + } + + private static func loadRecords(from url: URL) -> [Record] { + guard let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode([Record].self, from: data) + else { return [] } + return decoded + } + + private func save() { + guard let data = try? JSONEncoder().encode(self.records) else { return } + try? data.write(to: Self.recordPath, options: [.atomic]) + } +} + +#if DEBUG +extension PortGuardian { + static func _testParseListeners(_ text: String) -> [( + pid: Int32, + command: String, + fullCommand: String, + user: String?)] + { + self.parseListeners(from: text).map { ($0.pid, $0.command, $0.fullCommand, $0.user) } + } + + static func _testBuildReport( + port: Int, + mode: AppState.ConnectionMode, + listeners: [(pid: Int32, command: String, fullCommand: String, user: String?)]) -> PortReport + { + let mapped = listeners.map { Listener( + pid: $0.pid, + command: $0.command, + fullCommand: $0.fullCommand, + user: $0.user) } + return Self.buildReport(port: port, listeners: mapped, mode: mode, tunnelHealthy: nil) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PresenceReporter.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PresenceReporter.swift new file mode 100644 index 00000000..2e7a1d4c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/PresenceReporter.swift @@ -0,0 +1,114 @@ +import Cocoa +import Foundation +import OSLog + +@MainActor +final class PresenceReporter { + static let shared = PresenceReporter() + + private let logger = Logger(subsystem: "ai.openclaw", category: "presence") + private var task: Task? + private let interval: TimeInterval = 180 // a few minutes + private let instanceId: String = InstanceIdentity.instanceId + + func start() { + guard self.task == nil else { return } + self.task = Task.detached { [weak self] in + guard let self else { return } + await self.push(reason: "launch") + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000)) + await self.push(reason: "periodic") + } + } + } + + func stop() { + self.task?.cancel() + self.task = nil + } + + @Sendable + private func push(reason: String) async { + let mode = await MainActor.run { AppStateStore.shared.connectionMode.rawValue } + let host = InstanceIdentity.displayName + let ip = SystemPresenceInfo.primaryIPv4Address() ?? "ip-unknown" + let version = Self.appVersionString() + let platform = Self.platformString() + let lastInput = SystemPresenceInfo.lastInputSeconds() + let text = Self.composePresenceSummary(mode: mode, reason: reason) + var params: [String: AnyHashable] = [ + "instanceId": AnyHashable(self.instanceId), + "host": AnyHashable(host), + "ip": AnyHashable(ip), + "mode": AnyHashable(mode), + "version": AnyHashable(version), + "platform": AnyHashable(platform), + "deviceFamily": AnyHashable("Mac"), + "reason": AnyHashable(reason), + ] + if let model = InstanceIdentity.modelIdentifier { params["modelIdentifier"] = AnyHashable(model) } + if let lastInput { params["lastInputSeconds"] = AnyHashable(lastInput) } + do { + try await ControlChannel.shared.sendSystemEvent(text, params: params) + } catch { + self.logger.error("presence send failed: \(error.localizedDescription, privacy: .public)") + } + } + + /// Fire an immediate presence beacon (e.g., right after connecting). + func sendImmediate(reason: String = "connect") { + Task { await self.push(reason: reason) } + } + + private static func composePresenceSummary(mode: String, reason: String) -> String { + let host = InstanceIdentity.displayName + let ip = SystemPresenceInfo.primaryIPv4Address() ?? "ip-unknown" + let version = Self.appVersionString() + let lastInput = SystemPresenceInfo.lastInputSeconds() + let lastLabel = lastInput.map { "last input \($0)s ago" } ?? "last input unknown" + return "Node: \(host) (\(ip)) · app \(version) · \(lastLabel) · mode \(mode) · reason \(reason)" + } + + private static func appVersionString() -> String { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" + if let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String { + let trimmed = build.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, trimmed != version { + return "\(version) (\(trimmed))" + } + } + return version + } + + private static func platformString() -> String { + let v = ProcessInfo.processInfo.operatingSystemVersion + return "macos \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + } + + // (SystemPresenceInfo) last input + primary IPv4. +} + +#if DEBUG +extension PresenceReporter { + static func _testComposePresenceSummary(mode: String, reason: String) -> String { + self.composePresenceSummary(mode: mode, reason: reason) + } + + static func _testAppVersionString() -> String { + self.appVersionString() + } + + static func _testPlatformString() -> String { + self.platformString() + } + + static func _testLastInputSeconds() -> Int? { + SystemPresenceInfo.lastInputSeconds() + } + + static func _testPrimaryIPv4Address() -> String? { + SystemPresenceInfo.primaryIPv4Address() + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Process+PipeRead.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Process+PipeRead.swift new file mode 100644 index 00000000..7c0f7fe0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Process+PipeRead.swift @@ -0,0 +1,11 @@ +import Foundation + +extension Process { + /// Runs the process and drains the given pipe before waiting to avoid blocking on full buffers. + func runAndReadToEnd(from pipe: Pipe) throws -> Data { + try self.run() + let data = pipe.fileHandleForReading.readToEndSafely() + self.waitUntilExit() + return data + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift new file mode 100644 index 00000000..a219f495 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift @@ -0,0 +1,48 @@ +import Foundation + +extension ProcessInfo { + var isPreview: Bool { + guard let raw = getenv("XCODE_RUNNING_FOR_PREVIEWS") else { return false } + return String(cString: raw) == "1" + } + + /// Nix deployments may write defaults into a stable suite (`ai.openclaw.mac`) even if the shipped + /// app bundle identifier changes (and therefore `UserDefaults.standard` domain changes). + static func resolveNixMode( + environment: [String: String], + standard: UserDefaults, + stableSuite: UserDefaults?, + isAppBundle: Bool) -> Bool + { + if environment["OPENCLAW_NIX_MODE"] == "1" { return true } + if standard.bool(forKey: "openclaw.nixMode") { return true } + + // Only consult the stable suite when running as a .app bundle. + // This avoids local developer machines accidentally influencing unit tests. + if isAppBundle, let stableSuite, stableSuite.bool(forKey: "openclaw.nixMode") { return true } + + return false + } + + var isNixMode: Bool { + let isAppBundle = Bundle.main.bundleURL.pathExtension == "app" + let stableSuite = UserDefaults(suiteName: launchdLabel) + return Self.resolveNixMode( + environment: self.environment, + standard: .standard, + stableSuite: stableSuite, + isAppBundle: isAppBundle) + } + + var isRunningTests: Bool { + // SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not + // guaranteed to be the `.xctest` bundle, so check all loaded bundles. + if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true } + if Bundle.main.bundleURL.pathExtension == "xctest" { return true } + + // Backwards-compatible fallbacks for runners that still set XCTest env vars. + return self.environment["XCTestConfigurationFilePath"] != nil + || self.environment["XCTestBundlePath"] != nil + || self.environment["XCTestSessionIdentifier"] != nil + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift new file mode 100644 index 00000000..6502d2ad --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift @@ -0,0 +1,317 @@ +import Foundation +import Network +import OSLog +#if canImport(Darwin) +import Darwin +#endif + +/// Port forwarding tunnel for remote mode. +/// +/// Uses `ssh -N -L` to forward the remote gateway ports to localhost. +final class RemotePortTunnel { + private static let logger = Logger(subsystem: "ai.openclaw", category: "remote.tunnel") + + let process: Process + let localPort: UInt16? + private let stderrHandle: FileHandle? + + private init(process: Process, localPort: UInt16?, stderrHandle: FileHandle?) { + self.process = process + self.localPort = localPort + self.stderrHandle = stderrHandle + } + + deinit { + Self.cleanupStderr(self.stderrHandle) + let pid = self.process.processIdentifier + self.process.terminate() + Task { await PortGuardian.shared.removeRecord(pid: pid) } + } + + func terminate() { + Self.cleanupStderr(self.stderrHandle) + let pid = self.process.processIdentifier + if self.process.isRunning { + self.process.terminate() + self.process.waitUntilExit() + } + Task { await PortGuardian.shared.removeRecord(pid: pid) } + } + + static func create( + remotePort: Int, + preferredLocalPort: UInt16? = nil, + allowRemoteUrlOverride: Bool = true, + allowRandomLocalPort: Bool = true) async throws -> RemotePortTunnel + { + let settings = CommandResolver.connectionSettings() + guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else { + throw NSError( + domain: "RemotePortTunnel", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Remote mode is not configured"]) + } + + let localPort = try await Self.findPort( + preferred: preferredLocalPort, + allowRandom: allowRandomLocalPort) + let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) + let remotePortOverride = + allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort() + ? Self.resolveRemotePortOverride(for: sshHost) + : nil + let resolvedRemotePort = remotePortOverride ?? remotePort + if let override = remotePortOverride { + Self.logger.info( + "ssh tunnel remote port override " + + "host=\(sshHost, privacy: .public) port=\(override, privacy: .public)") + } else { + Self.logger.debug( + "ssh tunnel using default remote port " + + "host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)") + } + let options: [String] = [ + "-o", "BatchMode=yes", + "-o", "ExitOnForwardFailure=yes", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "UpdateHostKeys=yes", + "-o", "ServerAliveInterval=15", + "-o", "ServerAliveCountMax=3", + "-o", "TCPKeepAlive=yes", + "-N", + "-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)", + ] + let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines) + let args = CommandResolver.sshArguments( + target: parsed, + identity: identity, + options: options) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = args + + let pipe = Pipe() + process.standardError = pipe + let stderrHandle = pipe.fileHandleForReading + + // Consume stderr so ssh cannot block if it logs. + stderrHandle.readabilityHandler = { handle in + let data = handle.readSafely(upToCount: 64 * 1024) + guard !data.isEmpty else { + // EOF (or read failure): stop monitoring to avoid spinning on a closed pipe. + Self.cleanupStderr(handle) + return + } + guard let line = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !line.isEmpty + else { return } + Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)") + } + process.terminationHandler = { _ in + Self.cleanupStderr(stderrHandle) + } + + try process.run() + + // If ssh exits immediately (e.g. local port already in use), surface stderr and ensure we stop monitoring. + try? await Task.sleep(nanoseconds: 150_000_000) // 150ms + if !process.isRunning { + let stderr = Self.drainStderr(stderrHandle) + let msg = stderr.isEmpty ? "ssh tunnel exited immediately" : "ssh tunnel failed: \(stderr)" + throw NSError(domain: "RemotePortTunnel", code: 4, userInfo: [NSLocalizedDescriptionKey: msg]) + } + + // Track tunnel so we can clean up stale listeners on restart. + Task { + await PortGuardian.shared.record( + port: Int(localPort), + pid: process.processIdentifier, + command: process.executableURL?.path ?? "ssh", + mode: CommandResolver.connectionSettings().mode) + } + + return RemotePortTunnel(process: process, localPort: localPort, stderrHandle: stderrHandle) + } + + private static func resolveRemotePortOverride(for sshHost: String) -> Int? { + let root = OpenClawConfigFile.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let urlRaw = remote["url"] as? String + else { + return nil + } + let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: trimmed), let port = url.port else { + return nil + } + guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), + !host.isEmpty + else { + return nil + } + let sshKey = Self.hostKey(sshHost) + let urlKey = Self.hostKey(host) + guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil } + guard sshKey == urlKey else { + Self.logger.debug( + "remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)") + return nil + } + return port + } + + private static func hostKey(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return "" } + if trimmed.contains(":") { return trimmed } + let digits = CharacterSet(charactersIn: "0123456789.") + if trimmed.rangeOfCharacter(from: digits.inverted) == nil { + return trimmed + } + return trimmed.split(separator: ".").first.map(String.init) ?? trimmed + } + + private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 { + if let preferred, self.portIsFree(preferred) { return preferred } + if let preferred, !allowRandom { + throw NSError( + domain: "RemotePortTunnel", + code: 5, + userInfo: [ + NSLocalizedDescriptionKey: "Local port \(preferred) is unavailable", + ]) + } + + return try await withCheckedThrowingContinuation { cont in + let queue = DispatchQueue(label: "ai.openclaw.remote.tunnel.port", qos: .utility) + do { + let listener = try NWListener(using: .tcp, on: .any) + listener.newConnectionHandler = { connection in connection.cancel() } + listener.stateUpdateHandler = { state in + switch state { + case .ready: + if let port = listener.port?.rawValue { + listener.stateUpdateHandler = nil + listener.cancel() + cont.resume(returning: port) + } + case let .failed(error): + listener.stateUpdateHandler = nil + listener.cancel() + cont.resume(throwing: error) + default: + break + } + } + listener.start(queue: queue) + } catch { + cont.resume(throwing: error) + } + } + } + + private static func portIsFree(_ port: UInt16) -> Bool { + #if canImport(Darwin) + // NWListener can succeed even when only one address family is held. Mirror what ssh needs by checking + // both 127.0.0.1 and ::1 for availability. + return self.canBindIPv4(port) && self.canBindIPv6(port) + #else + do { + let listener = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: port)!) + listener.cancel() + return true + } catch { + return false + } + #endif + } + + #if canImport(Darwin) + private static func canBindIPv4(_ port: UInt16) -> Bool { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { _ = Darwin.close(fd) } + + var one: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + Darwin.bind(fd, sa, socklen_t(MemoryLayout.size)) + } + } + return result == 0 + } + + private static func canBindIPv6(_ port: UInt16) -> Bool { + let fd = socket(AF_INET6, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { _ = Darwin.close(fd) } + + var one: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) + + var addr = sockaddr_in6() + addr.sin6_len = UInt8(MemoryLayout.size) + addr.sin6_family = sa_family_t(AF_INET6) + addr.sin6_port = port.bigEndian + var loopback = in6_addr() + _ = withUnsafeMutablePointer(to: &loopback) { ptr in + inet_pton(AF_INET6, "::1", ptr) + } + addr.sin6_addr = loopback + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + Darwin.bind(fd, sa, socklen_t(MemoryLayout.size)) + } + } + return result == 0 + } + #endif + + private static func cleanupStderr(_ handle: FileHandle?) { + guard let handle else { return } + Self.cleanupStderr(handle) + } + + private static func cleanupStderr(_ handle: FileHandle) { + if handle.readabilityHandler != nil { + handle.readabilityHandler = nil + } + try? handle.close() + } + + private static func drainStderr(_ handle: FileHandle) -> String { + handle.readabilityHandler = nil + defer { try? handle.close() } + + do { + let data = try handle.readToEnd() ?? Data() + return String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } catch { + self.logger.debug("Failed to drain ssh stderr: \(error, privacy: .public)") + return "" + } + } + + #if SWIFT_PACKAGE + static func _testPortIsFree(_ port: UInt16) -> Bool { + self.portIsFree(port) + } + + static func _testDrainStderr(_ handle: FileHandle) -> String { + self.drainStderr(handle) + } + #endif +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift new file mode 100644 index 00000000..e8f0da6f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift @@ -0,0 +1,122 @@ +import Foundation +import OSLog + +/// Manages the SSH tunnel that forwards the remote gateway/control port to localhost. +actor RemoteTunnelManager { + static let shared = RemoteTunnelManager() + + private let logger = Logger(subsystem: "ai.openclaw", category: "remote-tunnel") + private var controlTunnel: RemotePortTunnel? + private var restartInFlight = false + private var lastRestartAt: Date? + private let restartBackoffSeconds: TimeInterval = 2.0 + + func controlTunnelPortIfRunning() async -> UInt16? { + if self.restartInFlight { + self.logger.info("control tunnel restart in flight; skipping reuse check") + return nil + } + if let tunnel = self.controlTunnel, + tunnel.process.isRunning, + let local = tunnel.localPort + { + let pid = tunnel.process.processIdentifier + if await PortGuardian.shared.isListening(port: Int(local), pid: pid) { + self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)") + return local + } + self.logger.error( + "active SSH tunnel on port \(local, privacy: .public) is not listening; restarting") + await self.beginRestart() + tunnel.terminate() + self.controlTunnel = nil + } + // If a previous OpenClaw run already has an SSH listener on the expected port (common after restarts), + // reuse it instead of spawning new ssh processes that immediately fail with "Address already in use". + let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) + if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)), + self.isSshProcess(desc) + { + self.logger.info( + "reusing existing SSH tunnel listener " + + "localPort=\(desiredPort, privacy: .public) " + + "pid=\(desc.pid, privacy: .public)") + return desiredPort + } + return nil + } + + /// Ensure an SSH tunnel is running for the gateway control port. + /// Returns the local forwarded port (usually the configured gateway port). + func ensureControlTunnel() async throws -> UInt16 { + let settings = CommandResolver.connectionSettings() + guard settings.mode == .remote else { + throw NSError( + domain: "RemoteTunnel", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) + } + + let identitySet = !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + self.logger.info( + "ensure SSH tunnel target=\(settings.target, privacy: .public) " + + "identitySet=\(identitySet, privacy: .public)") + + if let local = await self.controlTunnelPortIfRunning() { return local } + await self.waitForRestartBackoffIfNeeded() + + let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) + let tunnel = try await RemotePortTunnel.create( + remotePort: GatewayEnvironment.gatewayPort(), + preferredLocalPort: desiredPort, + allowRandomLocalPort: false) + self.controlTunnel = tunnel + self.endRestart() + let resolvedPort = tunnel.localPort ?? desiredPort + self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)") + return tunnel.localPort ?? desiredPort + } + + func stopAll() { + self.controlTunnel?.terminate() + self.controlTunnel = nil + } + + private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool { + let cmd = desc.command.lowercased() + if cmd.contains("ssh") { return true } + if let path = desc.executablePath?.lowercased(), path.contains("/ssh") { return true } + return false + } + + private func beginRestart() async { + guard !self.restartInFlight else { return } + self.restartInFlight = true + self.lastRestartAt = Date() + self.logger.info("control tunnel restart started") + Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(self.restartBackoffSeconds * 1_000_000_000)) + await self.endRestart() + } + } + + private func endRestart() { + if self.restartInFlight { + self.restartInFlight = false + self.logger.info("control tunnel restart finished") + } + } + + private func waitForRestartBackoffIfNeeded() async { + guard let last = self.lastRestartAt else { return } + let elapsed = Date().timeIntervalSince(last) + let remaining = self.restartBackoffSeconds - elapsed + guard remaining > 0 else { return } + self.logger.info( + "control tunnel restart backoff \(remaining, privacy: .public)s") + try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) + } + + // Keep tunnel reuse lightweight; restart only when the listener disappears. +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt new file mode 100644 index 00000000..d1b9e4b3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Kyle Seongwoo Jun + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/DeviceModels/NOTICE.md b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/DeviceModels/NOTICE.md new file mode 100644 index 00000000..664e78d7 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/DeviceModels/NOTICE.md @@ -0,0 +1,9 @@ +# Apple device identifier mappings + +This directory includes model identifier → human-readable name mappings derived from the open-source project: + +- `kyle-seongwoo-jun/apple-device-identifiers` + - iOS mapping pinned to commit `8e7388b29da046183f5d976eb74dbb2f2acda955` + - macOS mapping pinned to commit `98ca75324f7a88c1649eb5edfc266ef47b7b8193` + +See `LICENSE.apple-device-identifiers.txt` for license terms. diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/DeviceModels/ios-device-identifiers.json b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/DeviceModels/ios-device-identifiers.json new file mode 100644 index 00000000..76caa545 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/DeviceModels/ios-device-identifiers.json @@ -0,0 +1,176 @@ +{ + "i386": "iPhone Simulator", + "x86_64": "iPhone Simulator", + "arm64": "iPhone Simulator", + "iPhone1,1": "iPhone", + "iPhone1,2": "iPhone 3G", + "iPhone2,1": "iPhone 3GS", + "iPhone3,1": "iPhone 4", + "iPhone3,2": "iPhone 4", + "iPhone3,3": "iPhone 4", + "iPhone4,1": "iPhone 4s", + "iPhone5,1": "iPhone 5", + "iPhone5,2": "iPhone 5", + "iPhone5,3": "iPhone 5c", + "iPhone5,4": "iPhone 5c", + "iPhone6,1": "iPhone 5s", + "iPhone6,2": "iPhone 5s", + "iPhone7,1": "iPhone 6 Plus", + "iPhone7,2": "iPhone 6", + "iPhone8,1": "iPhone 6s", + "iPhone8,2": "iPhone 6s Plus", + "iPhone8,4": "iPhone SE (1st generation)", + "iPhone9,1": "iPhone 7", + "iPhone9,2": "iPhone 7 Plus", + "iPhone9,3": "iPhone 7", + "iPhone9,4": "iPhone 7 Plus", + "iPhone10,1": "iPhone 8", + "iPhone10,2": "iPhone 8 Plus", + "iPhone10,3": "iPhone X", + "iPhone10,4": "iPhone 8", + "iPhone10,5": "iPhone 8 Plus", + "iPhone10,6": "iPhone X", + "iPhone11,2": "iPhone XS", + "iPhone11,4": "iPhone XS Max", + "iPhone11,6": "iPhone XS Max", + "iPhone11,8": "iPhone XR", + "iPhone12,1": "iPhone 11", + "iPhone12,3": "iPhone 11 Pro", + "iPhone12,5": "iPhone 11 Pro Max", + "iPhone12,8": "iPhone SE (2nd generation)", + "iPhone13,1": "iPhone 12 mini", + "iPhone13,2": "iPhone 12", + "iPhone13,3": "iPhone 12 Pro", + "iPhone13,4": "iPhone 12 Pro Max", + "iPhone14,2": "iPhone 13 Pro", + "iPhone14,3": "iPhone 13 Pro Max", + "iPhone14,4": "iPhone 13 mini", + "iPhone14,5": "iPhone 13", + "iPhone14,6": "iPhone SE (3rd generation)", + "iPhone14,7": "iPhone 14", + "iPhone14,8": "iPhone 14 Plus", + "iPhone15,2": "iPhone 14 Pro", + "iPhone15,3": "iPhone 14 Pro Max", + "iPhone15,4": "iPhone 15", + "iPhone15,5": "iPhone 15 Plus", + "iPhone16,1": "iPhone 15 Pro", + "iPhone16,2": "iPhone 15 Pro Max", + "iPhone17,1": "iPhone 16 Pro", + "iPhone17,2": "iPhone 16 Pro Max", + "iPhone17,3": "iPhone 16", + "iPhone17,4": "iPhone 16 Plus", + "iPhone17,5": "iPhone 16e", + "iPhone18,1": "iPhone 17 Pro", + "iPhone18,2": "iPhone 17 Pro Max", + "iPhone18,3": "iPhone 17", + "iPhone18,4": "iPhone Air", + "iPad1,1": "iPad", + "iPad1,2": "iPad", + "iPad2,1": "iPad 2", + "iPad2,2": "iPad 2", + "iPad2,3": "iPad 2", + "iPad2,4": "iPad 2", + "iPad2,5": "iPad mini", + "iPad2,6": "iPad mini", + "iPad2,7": "iPad mini", + "iPad3,1": "iPad (3rd generation)", + "iPad3,2": "iPad (3rd generation)", + "iPad3,3": "iPad (3rd generation)", + "iPad3,4": "iPad (4th generation)", + "iPad3,5": "iPad (4th generation)", + "iPad3,6": "iPad (4th generation)", + "iPad4,1": "iPad Air", + "iPad4,2": "iPad Air", + "iPad4,3": "iPad Air", + "iPad4,4": "iPad mini 2", + "iPad4,5": "iPad mini 2", + "iPad4,6": "iPad mini 2", + "iPad4,7": "iPad mini 3", + "iPad4,8": "iPad mini 3", + "iPad4,9": "iPad mini 3", + "iPad5,1": "iPad mini 4", + "iPad5,2": "iPad mini 4", + "iPad5,3": "iPad Air 2", + "iPad5,4": "iPad Air 2", + "iPad6,3": "iPad Pro (9.7-inch)", + "iPad6,4": "iPad Pro (9.7-inch)", + "iPad6,7": "iPad Pro (12.9-inch)", + "iPad6,8": "iPad Pro (12.9-inch)", + "iPad6,11": "iPad (5th generation)", + "iPad6,12": "iPad (5th generation)", + "iPad7,1": "iPad Pro (12.9-inch) (2nd generation)", + "iPad7,2": "iPad Pro (12.9-inch) (2nd generation)", + "iPad7,3": "iPad Pro (10.5-inch)", + "iPad7,4": "iPad Pro (10.5-inch)", + "iPad7,5": "iPad (6th generation)", + "iPad7,6": "iPad (6th generation)", + "iPad7,11": "iPad (7th generation)", + "iPad7,12": "iPad (7th generation)", + "iPad8,1": "iPad Pro (11-inch)", + "iPad8,2": "iPad Pro (11-inch)", + "iPad8,3": "iPad Pro (11-inch)", + "iPad8,4": "iPad Pro (11-inch)", + "iPad8,5": "iPad Pro (12.9-inch) (3rd generation)", + "iPad8,6": "iPad Pro (12.9-inch) (3rd generation)", + "iPad8,7": "iPad Pro (12.9-inch) (3rd generation)", + "iPad8,8": "iPad Pro (12.9-inch) (3rd generation)", + "iPad8,9": "iPad Pro (11-inch) (2nd generation)", + "iPad8,10": "iPad Pro (11-inch) (2nd generation)", + "iPad8,11": "iPad Pro (12.9-inch) (4th generation)", + "iPad8,12": "iPad Pro (12.9-inch) (4th generation)", + "iPad11,1": "iPad mini (5th generation)", + "iPad11,2": "iPad mini (5th generation)", + "iPad11,3": "iPad Air (3rd generation)", + "iPad11,4": "iPad Air (3rd generation)", + "iPad11,6": "iPad (8th generation)", + "iPad11,7": "iPad (8th generation)", + "iPad12,1": "iPad (9th generation)", + "iPad12,2": "iPad (9th generation)", + "iPad13,1": "iPad Air (4th generation)", + "iPad13,2": "iPad Air (4th generation)", + "iPad13,4": "iPad Pro (11-inch) (3rd generation)", + "iPad13,5": "iPad Pro (11-inch) (3rd generation)", + "iPad13,6": "iPad Pro (11-inch) (3rd generation)", + "iPad13,7": "iPad Pro (11-inch) (3rd generation)", + "iPad13,8": "iPad Pro (12.9-inch) (5th generation)", + "iPad13,9": "iPad Pro (12.9-inch) (5th generation)", + "iPad13,10": "iPad Pro (12.9-inch) (5th generation)", + "iPad13,11": "iPad Pro (12.9-inch) (5th generation)", + "iPad13,16": "iPad Air (5th generation)", + "iPad13,17": "iPad Air (5th generation)", + "iPad13,18": "iPad (10th generation)", + "iPad13,19": "iPad (10th generation)", + "iPad14,1": "iPad mini (6th generation)", + "iPad14,2": "iPad mini (6th generation)", + "iPad14,3": "iPad Pro (11-inch) (4th generation)", + "iPad14,4": "iPad Pro (11-inch) (4th generation)", + "iPad14,5": "iPad Pro (12.9-inch) (6th generation)", + "iPad14,6": "iPad Pro (12.9-inch) (6th generation)", + "iPad14,8": "iPad Air 11-inch (M2)", + "iPad14,9": "iPad Air 11-inch (M2)", + "iPad14,10": "iPad Air 13-inch (M2)", + "iPad14,11": "iPad Air 13-inch (M2)", + "iPad15,3": "iPad Air 11-inch (M3)", + "iPad15,4": "iPad Air 11-inch (M3)", + "iPad15,5": "iPad Air 13-inch (M3)", + "iPad15,6": "iPad Air 13-inch (M3)", + "iPad15,7": "iPad (A16)", + "iPad15,8": "iPad (A16)", + "iPad16,1": "iPad mini (A17 Pro)", + "iPad16,2": "iPad mini (A17 Pro)", + "iPad16,3": "iPad Pro 11-inch (M4)", + "iPad16,4": "iPad Pro 11-inch (M4)", + "iPad16,5": "iPad Pro 13-inch (M4)", + "iPad16,6": "iPad Pro 13-inch (M4)", + "iPad17,1": "iPad Pro 11-inch (M5)", + "iPad17,2": "iPad Pro 11-inch (M5)", + "iPad17,3": "iPad Pro 13-inch (M5)", + "iPad17,4": "iPad Pro 13-inch (M5)", + "iPod1,1": "iPod touch", + "iPod2,1": "iPod touch (2nd generation)", + "iPod3,1": "iPod touch (3rd generation)", + "iPod4,1": "iPod touch (4th generation)", + "iPod5,1": "iPod touch (5th generation)", + "iPod7,1": "iPod touch (6th generation)", + "iPod9,1": "iPod touch (7th generation)" +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/DeviceModels/mac-device-identifiers.json b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/DeviceModels/mac-device-identifiers.json new file mode 100644 index 00000000..03d5a5ec --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/DeviceModels/mac-device-identifiers.json @@ -0,0 +1,214 @@ +{ + "iMac9,1": [ + "iMac (20-inch, Early 2009)", + "iMac (24-inch, Early 2009)" + ], + "iMac10,1": [ + "iMac (21.5-inch, Late 2009)", + "iMac (27-inch, Late 2009)" + ], + "iMac11,2": "iMac (21.5-inch, Mid 2010)", + "iMac11,3": "iMac (27-inch, Mid 2010)", + "iMac12,1": "iMac (21.5-inch, Mid 2011)", + "iMac12,2": "iMac (27-inch, Mid 2011)", + "iMac13,1": "iMac (21.5-inch, Late 2012)", + "iMac13,2": "iMac (27-inch, Late 2012)", + "iMac14,1": "iMac (21.5-inch, Late 2013)", + "iMac14,2": "iMac (27-inch, Late 2013)", + "iMac14,4": "iMac (21.5-inch, Mid 2014)", + "iMac15,1": [ + "iMac (Retina 5K, 27-inch, Late 2014)", + "iMac (Retina 5K, 27-inch, Mid 2015)" + ], + "iMac16,1": "iMac (21.5-inch, Late 2015)", + "iMac16,2": "iMac (Retina 4K, 21.5-inch, Late 2015)", + "iMac17,1": "iMac (Retina 5K, 27-inch, Late 2015)", + "iMac18,1": "iMac (21.5-inch, 2017)", + "iMac18,2": "iMac (Retina 4K, 21.5-inch, 2017)", + "iMac18,3": "iMac (Retina 5K, 27-inch, 2017)", + "iMac19,1": "iMac (Retina 5K, 27-inch, 2019)", + "iMac19,2": "iMac (Retina 4K, 21.5-inch, 2019)", + "iMac20,1": "iMac (Retina 5K, 27-inch, 2020)", + "iMac20,2": "iMac (Retina 5K, 27-inch, 2020)", + "iMac21,1": "iMac (24-inch, M1, 2021)", + "iMac21,2": "iMac (24-inch, M1, 2021)", + "iMacPro1,1": "iMac Pro (2017)", + "Mac13,1": "Mac Studio (2022)", + "Mac13,2": "Mac Studio (2022)", + "Mac14,2": "MacBook Air (M2, 2022)", + "Mac14,3": "Mac mini (2023)", + "Mac14,5": "MacBook Pro (14-inch, 2023)", + "Mac14,6": "MacBook Pro (16-inch, 2023)", + "Mac14,7": "MacBook Pro (13-inch, M2, 2022)", + "Mac14,8": [ + "Mac Pro (2023)", + "Mac Pro (Rack, 2023)" + ], + "Mac14,9": "MacBook Pro (14-inch, 2023)", + "Mac14,10": "MacBook Pro (16-inch, 2023)", + "Mac14,12": "Mac mini (2023)", + "Mac14,13": "Mac Studio (2023)", + "Mac14,14": "Mac Studio (2023)", + "Mac14,15": "MacBook Air (15-inch, M2, 2023)", + "Mac15,3": "MacBook Pro (14-inch, Nov 2023)", + "Mac15,4": "iMac (24-inch, 2023, Two ports)", + "Mac15,5": "iMac (24-inch, 2023, Four ports)", + "Mac15,6": "MacBook Pro (14-inch, Nov 2023)", + "Mac15,7": "MacBook Pro (16-inch, Nov 2023)", + "Mac15,8": "MacBook Pro (14-inch, Nov 2023)", + "Mac15,9": "MacBook Pro (16-inch, Nov 2023)", + "Mac15,10": "MacBook Pro (14-inch, Nov 2023)", + "Mac15,11": "MacBook Pro (16-inch, Nov 2023)", + "Mac15,12": "MacBook Air (13-inch, M3, 2024)", + "Mac15,13": "MacBook Air (15-inch, M3, 2024)", + "Mac15,14": "Mac Studio (2025)", + "Mac16,1": "MacBook Pro (14-inch, 2024)", + "Mac16,2": "iMac (24-inch, 2024, Two ports)", + "Mac16,3": "iMac (24-inch, 2024, Four ports)", + "Mac16,5": "MacBook Pro (16-inch, 2024)", + "Mac16,6": "MacBook Pro (14-inch, 2024)", + "Mac16,7": "MacBook Pro (16-inch, 2024)", + "Mac16,8": "MacBook Pro (14-inch, 2024)", + "Mac16,9": "Mac Studio (2025)", + "Mac16,10": "Mac mini (2024)", + "Mac16,11": "Mac mini (2024)", + "Mac16,12": "MacBook Air (13-inch, M4, 2025)", + "Mac16,13": "MacBook Air (15-inch, M4, 2025)", + "Mac17,2": "MacBook Pro (14-inch, M5)", + "MacBook5,2": [ + "MacBook (13-inch, Early 2009)", + "MacBook (13-inch, Mid 2009)" + ], + "MacBook6,1": "MacBook (13-inch, Late 2009)", + "MacBook7,1": "MacBook (13-inch, Mid 2010)", + "MacBook8,1": "MacBook (Retina, 12-inch, Early 2015)", + "MacBook9,1": "MacBook (Retina, 12-inch, Early 2016)", + "MacBook10,1": "MacBook (Retina, 12-inch, 2017)", + "MacBookAir2,1": "MacBook Air (Mid 2009)", + "MacBookAir3,1": "MacBook Air (11-inch, Late 2010)", + "MacBookAir3,2": "MacBook Air (13-inch, Late 2010)", + "MacBookAir4,1": "MacBook Air (11-inch, Mid 2011)", + "MacBookAir4,2": "MacBook Air (13-inch, Mid 2011)", + "MacBookAir5,1": "MacBook Air (11-inch, Mid 2012)", + "MacBookAir5,2": "MacBook Air (13-inch, Mid 2012)", + "MacBookAir6,1": [ + "MacBook Air (11-inch, Early 2014)", + "MacBook Air (11-inch, Mid 2013)" + ], + "MacBookAir6,2": [ + "MacBook Air (13-inch, Early 2014)", + "MacBook Air (13-inch, Mid 2013)" + ], + "MacBookAir7,1": "MacBook Air (11-inch, Early 2015)", + "MacBookAir7,2": [ + "MacBook Air (13-inch, 2017)", + "MacBook Air (13-inch, Early 2015)" + ], + "MacBookAir8,1": "MacBook Air (Retina, 13-inch, 2018)", + "MacBookAir8,2": "MacBook Air (Retina, 13-inch, 2019)", + "MacBookAir9,1": "MacBook Air (Retina, 13-inch, 2020)", + "MacBookAir10,1": "MacBook Air (M1, 2020)", + "MacBookPro4,1": [ + "MacBook Pro (15-inch, Early 2008)", + "MacBook Pro (17-inch, Early 2008)" + ], + "MacBookPro5,1": "MacBook Pro (15-inch, Late 2008)", + "MacBookPro5,2": [ + "MacBook Pro (17-inch, Early 2009)", + "MacBook Pro (17-inch, Mid 2009)" + ], + "MacBookPro5,3": [ + "MacBook Pro (15-inch, 2.53GHz, Mid 2009)", + "MacBook Pro (15-inch, Mid 2009)" + ], + "MacBookPro5,5": "MacBook Pro (13-inch, Mid 2009)", + "MacBookPro6,1": "MacBook Pro (17-inch, Mid 2010)", + "MacBookPro6,2": "MacBook Pro (15-inch, Mid 2010)", + "MacBookPro7,1": "MacBook Pro (13-inch, Mid 2010)", + "MacBookPro8,1": [ + "MacBook Pro (13-inch, Early 2011)", + "MacBook Pro (13-inch, Late 2011)" + ], + "MacBookPro8,2": [ + "MacBook Pro (15-inch, Early 2011)", + "MacBook Pro (15-inch, Late 2011)" + ], + "MacBookPro8,3": [ + "MacBook Pro (17-inch, Early 2011)", + "MacBook Pro (17-inch, Late 2011)" + ], + "MacBookPro9,1": "MacBook Pro (15-inch, Mid 2012)", + "MacBookPro9,2": "MacBook Pro (13-inch, Mid 2012)", + "MacBookPro10,1": [ + "MacBook Pro (Retina, 15-inch, Early 2013)", + "MacBook Pro (Retina, 15-inch, Mid 2012)" + ], + "MacBookPro10,2": [ + "MacBook Pro (Retina, 13-inch, Early 2013)", + "MacBook Pro (Retina, 13-inch, Late 2012)" + ], + "MacBookPro11,1": [ + "MacBook Pro (Retina, 13-inch, Late 2013)", + "MacBook Pro (Retina, 13-inch, Mid 2014)" + ], + "MacBookPro11,2": [ + "MacBook Pro (Retina, 15-inch, Late 2013)", + "MacBook Pro (Retina, 15-inch, Mid 2014)" + ], + "MacBookPro11,3": [ + "MacBook Pro (Retina, 15-inch, Late 2013)", + "MacBook Pro (Retina, 15-inch, Mid 2014)" + ], + "MacBookPro11,4": "MacBook Pro (Retina, 15-inch, Mid 2015)", + "MacBookPro11,5": "MacBook Pro (Retina, 15-inch, Mid 2015)", + "MacBookPro12,1": "MacBook Pro (Retina, 13-inch, Early 2015)", + "MacBookPro13,1": "MacBook Pro (13-inch, 2016, Two Thunderbolt 3 ports)", + "MacBookPro13,2": "MacBook Pro (13-inch, 2016, Four Thunderbolt 3 ports)", + "MacBookPro13,3": "MacBook Pro (15-inch, 2016)", + "MacBookPro14,1": "MacBook Pro (13-inch, 2017, Two Thunderbolt 3 ports)", + "MacBookPro14,2": "MacBook Pro (13-inch, 2017, Four Thunderbolt 3 ports)", + "MacBookPro14,3": "MacBook Pro (15-inch, 2017)", + "MacBookPro15,1": [ + "MacBook Pro (15-inch, 2018)", + "MacBook Pro (15-inch, 2019)" + ], + "MacBookPro15,2": [ + "MacBook Pro (13-inch, 2018, Four Thunderbolt 3 ports)", + "MacBook Pro (13-inch, 2019, Four Thunderbolt 3 ports)" + ], + "MacBookPro15,3": "MacBook Pro (15-inch, 2019)", + "MacBookPro15,4": "MacBook Pro (13-inch, 2019, Two Thunderbolt 3 ports)", + "MacBookPro16,1": "MacBook Pro (16-inch, 2019)", + "MacBookPro16,2": "MacBook Pro (13-inch, 2020, Four Thunderbolt 3 ports)", + "MacBookPro16,3": "MacBook Pro (13-inch, 2020, Two Thunderbolt 3 ports)", + "MacBookPro16,4": "MacBook Pro (16-inch, 2019)", + "MacBookPro17,1": "MacBook Pro (13-inch, M1, 2020)", + "MacBookPro18,1": "MacBook Pro (16-inch, 2021)", + "MacBookPro18,2": "MacBook Pro (16-inch, 2021)", + "MacBookPro18,3": "MacBook Pro (14-inch, 2021)", + "MacBookPro18,4": "MacBook Pro (14-inch, 2021)", + "Macmini3,1": [ + "Mac mini (Early 2009)", + "Mac mini (Late 2009)" + ], + "Macmini4,1": "Mac mini (Mid 2010)", + "Macmini5,1": "Mac mini (Mid 2011)", + "Macmini5,2": "Mac mini (Mid 2011)", + "Macmini6,1": "Mac mini (Late 2012)", + "Macmini6,2": "Mac mini (Late 2012)", + "Macmini7,1": "Mac mini (Late 2014)", + "Macmini8,1": "Mac mini (2018)", + "Macmini9,1": "Mac mini (M1, 2020)", + "MacPro4,1": "Mac Pro (Early 2009)", + "MacPro5,1": [ + "Mac Pro (Mid 2010)", + "Mac Pro (Mid 2012)", + "Mac Pro Server (Mid 2010)", + "Mac Pro Server (Mid 2012)" + ], + "MacPro6,1": "Mac Pro (Late 2013)", + "MacPro7,1": [ + "Mac Pro (2019)", + "Mac Pro (Rack, 2019)" + ] +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/Info.plist b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/Info.plist new file mode 100644 index 00000000..5abb959d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -0,0 +1,79 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + OpenClaw + CFBundleIdentifier + ai.openclaw.mac + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + OpenClaw + CFBundlePackageType + APPL + CFBundleShortVersionString + 2026.2.25 + CFBundleVersion + 202602250 + CFBundleIconFile + OpenClaw + CFBundleURLTypes + + + CFBundleURLName + ai.openclaw.mac.deeplink + CFBundleURLSchemes + + openclaw + + + + LSMinimumSystemVersion + 15.0 + LSUIElement + + + OpenClawBuildTimestamp + + OpenClawGitCommit + + + NSUserNotificationUsageDescription + OpenClaw needs notification permission to show alerts for agent actions. + NSScreenCaptureDescription + OpenClaw captures the screen when the agent needs screenshots for context. + NSCameraUsageDescription + OpenClaw can capture photos or short video clips when requested by the agent. + NSLocationUsageDescription + OpenClaw can share your location when requested by the agent. + NSLocationWhenInUseUsageDescription + OpenClaw can share your location when requested by the agent. + NSLocationAlwaysAndWhenInUseUsageDescription + OpenClaw can share your location when requested by the agent. + NSMicrophoneUsageDescription + OpenClaw needs the mic for Voice Wake tests and agent audio capture. + NSSpeechRecognitionUsageDescription + OpenClaw uses speech recognition to detect your Voice Wake trigger phrase. + NSAppleEventsUsageDescription + OpenClaw needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions. + + NSAppTransportSecurity + + NSAllowsArbitraryLoadsInWebContent + + NSExceptionDomains + + 100.100.100.100 + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns new file mode 100644 index 00000000..f317728e Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns differ diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/RuntimeLocator.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/RuntimeLocator.swift new file mode 100644 index 00000000..3112f578 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/RuntimeLocator.swift @@ -0,0 +1,171 @@ +import Foundation +import OSLog + +enum RuntimeKind: String { + case node +} + +struct RuntimeVersion: Comparable, CustomStringConvertible { + let major: Int + let minor: Int + let patch: Int + + var description: String { + "\(self.major).\(self.minor).\(self.patch)" + } + + static func < (lhs: RuntimeVersion, rhs: RuntimeVersion) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } + + static func from(string: String) -> RuntimeVersion? { + // Accept optional leading "v" and ignore trailing metadata. + let pattern = #"(\d+)\.(\d+)\.(\d+)"# + guard let match = string.range(of: pattern, options: .regularExpression) else { return nil } + let versionString = String(string[match]) + let parts = versionString.split(separator: ".") + guard parts.count == 3, + let major = Int(parts[0]), + let minor = Int(parts[1]), + let patch = Int(parts[2]) + else { return nil } + return RuntimeVersion(major: major, minor: minor, patch: patch) + } +} + +struct RuntimeResolution { + let kind: RuntimeKind + let path: String + let version: RuntimeVersion +} + +enum RuntimeResolutionError: Error { + case notFound(searchPaths: [String]) + case unsupported( + kind: RuntimeKind, + found: RuntimeVersion, + required: RuntimeVersion, + path: String, + searchPaths: [String]) + case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String]) +} + +enum RuntimeLocator { + private static let logger = Logger(subsystem: "ai.openclaw", category: "runtime") + private static let minNode = RuntimeVersion(major: 22, minor: 0, patch: 0) + + static func resolve( + searchPaths: [String] = CommandResolver.preferredPaths()) -> Result + { + let pathEnv = searchPaths.joined(separator: ":") + let runtime: RuntimeKind = .node + + guard let binary = findExecutable(named: runtime.binaryName, searchPaths: searchPaths) else { + return .failure(.notFound(searchPaths: searchPaths)) + } + guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else { + return .failure(.versionParse( + kind: runtime, + raw: "(unreadable)", + path: binary, + searchPaths: searchPaths)) + } + guard let parsed = RuntimeVersion.from(string: rawVersion) else { + return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths)) + } + guard parsed >= self.minNode else { + return .failure(.unsupported( + kind: runtime, + found: parsed, + required: self.minNode, + path: binary, + searchPaths: searchPaths)) + } + + return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed)) + } + + static func describeFailure(_ error: RuntimeResolutionError) -> String { + switch error { + case let .notFound(searchPaths): + [ + "openclaw needs Node >=22.0.0 but found no runtime.", + "PATH searched: \(searchPaths.joined(separator: ":"))", + "Install Node: https://nodejs.org/en/download", + ].joined(separator: "\n") + case let .unsupported(kind, found, required, path, searchPaths): + [ + "Found \(kind.rawValue) \(found) at \(path) but need >= \(required).", + "PATH searched: \(searchPaths.joined(separator: ":"))", + "Upgrade Node and rerun openclaw.", + ].joined(separator: "\n") + case let .versionParse(kind, raw, path, searchPaths): + [ + "Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).", + "PATH searched: \(searchPaths.joined(separator: ":"))", + "Try reinstalling or pinning a supported version (Node >=22.0.0).", + ].joined(separator: "\n") + } + } + + // MARK: - Internals + + private static func findExecutable(named name: String, searchPaths: [String]) -> String? { + let fm = FileManager() + for dir in searchPaths { + let candidate = (dir as NSString).appendingPathComponent(name) + if fm.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + private static func readVersion(of binary: String, pathEnv: String) -> String? { + let start = Date() + let process = Process() + process.executableURL = URL(fileURLWithPath: binary) + process.arguments = ["--version"] + process.environment = ["PATH": pathEnv] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + let data = try process.runAndReadToEnd(from: pipe) + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) + if elapsedMs > 500 { + self.logger.warning( + """ + runtime --version slow (\(elapsedMs, privacy: .public)ms) \ + bin=\(binary, privacy: .public) + """) + } else { + self.logger.debug( + """ + runtime --version ok (\(elapsedMs, privacy: .public)ms) \ + bin=\(binary, privacy: .public) + """) + } + return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + } catch { + let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) + self.logger.error( + """ + runtime --version failed (\(elapsedMs, privacy: .public)ms) \ + bin=\(binary, privacy: .public) \ + err=\(error.localizedDescription, privacy: .public) + """) + return nil + } + } +} + +extension RuntimeKind { + fileprivate var binaryName: String { + "node" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ScreenRecordService.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ScreenRecordService.swift new file mode 100644 index 00000000..30d854b1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ScreenRecordService.swift @@ -0,0 +1,266 @@ +import AVFoundation +import Foundation +import OSLog +@preconcurrency import ScreenCaptureKit + +@MainActor +final class ScreenRecordService { + enum ScreenRecordError: LocalizedError { + case noDisplays + case invalidScreenIndex(Int) + case noFramesCaptured + case writeFailed(String) + + var errorDescription: String? { + switch self { + case .noDisplays: + "No displays available for screen recording" + case let .invalidScreenIndex(idx): + "Invalid screen index \(idx)" + case .noFramesCaptured: + "No frames captured" + case let .writeFailed(msg): + msg + } + } + } + + private let logger = Logger(subsystem: "ai.openclaw", category: "screenRecord") + + func record( + screenIndex: Int?, + durationMs: Int?, + fps: Double?, + includeAudio: Bool?, + outPath: String?) async throws -> (path: String, hasAudio: Bool) + { + let durationMs = Self.clampDurationMs(durationMs) + let fps = Self.clampFps(fps) + let includeAudio = includeAudio ?? false + + let outURL: URL = { + if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: outPath) + } + return FileManager().temporaryDirectory + .appendingPathComponent("openclaw-screen-record-\(UUID().uuidString).mp4") + }() + try? FileManager().removeItem(at: outURL) + + let content = try await SCShareableContent.current + let displays = content.displays.sorted { $0.displayID < $1.displayID } + guard !displays.isEmpty else { throw ScreenRecordError.noDisplays } + + let idx = screenIndex ?? 0 + guard idx >= 0, idx < displays.count else { throw ScreenRecordError.invalidScreenIndex(idx) } + let display = displays[idx] + + let filter = SCContentFilter(display: display, excludingWindows: []) + let config = SCStreamConfiguration() + config.width = display.width + config.height = display.height + config.queueDepth = 8 + config.showsCursor = true + config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(fps.rounded())))) + if includeAudio { + config.capturesAudio = true + } + + let recorder = try StreamRecorder( + outputURL: outURL, + width: display.width, + height: display.height, + includeAudio: includeAudio, + logger: self.logger) + + let stream = SCStream(filter: filter, configuration: config, delegate: recorder) + try stream.addStreamOutput(recorder, type: .screen, sampleHandlerQueue: recorder.queue) + if includeAudio { + try stream.addStreamOutput(recorder, type: .audio, sampleHandlerQueue: recorder.queue) + } + + self.logger.info( + "screen record start idx=\(idx) durationMs=\(durationMs) fps=\(fps) out=\(outURL.path, privacy: .public)") + + var started = false + do { + try await stream.startCapture() + started = true + try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000) + try await stream.stopCapture() + } catch { + if started { try? await stream.stopCapture() } + throw error + } + + try await recorder.finish() + return (path: outURL.path, hasAudio: recorder.hasAudio) + } + + private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { + let v = ms ?? 10000 + return min(60000, max(250, v)) + } + + private nonisolated static func clampFps(_ fps: Double?) -> Double { + let v = fps ?? 10 + if !v.isFinite { return 10 } + return min(60, max(1, v)) + } +} + +private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable { + let queue = DispatchQueue(label: "ai.openclaw.screenRecord.writer") + + private let logger: Logger + private let writer: AVAssetWriter + private let input: AVAssetWriterInput + private let audioInput: AVAssetWriterInput? + let hasAudio: Bool + + private var started = false + private var sawFrame = false + private var didFinish = false + private var pendingErrorMessage: String? + + init(outputURL: URL, width: Int, height: Int, includeAudio: Bool, logger: Logger) throws { + self.logger = logger + self.writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) + + let settings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: width, + AVVideoHeightKey: height, + ] + self.input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) + self.input.expectsMediaDataInRealTime = true + + guard self.writer.canAdd(self.input) else { + throw ScreenRecordService.ScreenRecordError.writeFailed("Cannot add video input") + } + self.writer.add(self.input) + + if includeAudio { + let audioSettings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVNumberOfChannelsKey: 1, + AVSampleRateKey: 44100, + AVEncoderBitRateKey: 96000, + ] + let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) + audioInput.expectsMediaDataInRealTime = true + if self.writer.canAdd(audioInput) { + self.writer.add(audioInput) + self.audioInput = audioInput + self.hasAudio = true + } else { + self.audioInput = nil + self.hasAudio = false + } + } else { + self.audioInput = nil + self.hasAudio = false + } + super.init() + } + + func stream(_ stream: SCStream, didStopWithError error: any Error) { + self.queue.async { + let msg = String(describing: error) + self.pendingErrorMessage = msg + self.logger.error("screen record stream stopped with error: \(msg, privacy: .public)") + _ = stream + } + } + + func stream( + _ stream: SCStream, + didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of type: SCStreamOutputType) + { + guard CMSampleBufferDataIsReady(sampleBuffer) else { return } + // Callback runs on `sampleHandlerQueue` (`self.queue`). + switch type { + case .screen: + self.handleVideo(sampleBuffer: sampleBuffer) + case .audio: + self.handleAudio(sampleBuffer: sampleBuffer) + case .microphone: + break + @unknown default: + break + } + _ = stream + } + + private func handleVideo(sampleBuffer: CMSampleBuffer) { + if let msg = self.pendingErrorMessage { + self.logger.error("screen record aborting due to prior error: \(msg, privacy: .public)") + return + } + if self.didFinish { return } + + if !self.started { + guard self.writer.startWriting() else { + self.pendingErrorMessage = self.writer.error?.localizedDescription ?? "Failed to start writer" + return + } + let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + self.writer.startSession(atSourceTime: pts) + self.started = true + } + + self.sawFrame = true + if self.input.isReadyForMoreMediaData { + _ = self.input.append(sampleBuffer) + } + } + + private func handleAudio(sampleBuffer: CMSampleBuffer) { + guard let audioInput else { return } + if let msg = self.pendingErrorMessage { + self.logger.error("screen record audio aborting due to prior error: \(msg, privacy: .public)") + return + } + if self.didFinish || !self.started { return } + if audioInput.isReadyForMoreMediaData { + _ = audioInput.append(sampleBuffer) + } + } + + func finish() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + self.queue.async { + if let msg = self.pendingErrorMessage { + cont.resume(throwing: ScreenRecordService.ScreenRecordError.writeFailed(msg)) + return + } + guard self.started, self.sawFrame else { + cont.resume(throwing: ScreenRecordService.ScreenRecordError.noFramesCaptured) + return + } + if self.didFinish { + cont.resume() + return + } + self.didFinish = true + + self.input.markAsFinished() + self.audioInput?.markAsFinished() + self.writer.finishWriting { + if let err = self.writer.error { + cont + .resume(throwing: ScreenRecordService.ScreenRecordError + .writeFailed(err.localizedDescription)) + } else if self.writer.status != .completed { + cont + .resume(throwing: ScreenRecordService.ScreenRecordError + .writeFailed("Failed to finalize video")) + } else { + cont.resume() + } + } + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ScreenshotSize.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ScreenshotSize.swift new file mode 100644 index 00000000..e1ad915f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ScreenshotSize.swift @@ -0,0 +1,17 @@ +import Foundation +import ImageIO + +enum ScreenshotSize { + struct Size { + let width: Int + let height: Int + } + + static func readPNGSize(data: Data) -> Size? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + guard let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else { return nil } + guard let width = props[kCGImagePropertyPixelWidth] as? Int else { return nil } + guard let height = props[kCGImagePropertyPixelHeight] as? Int else { return nil } + return Size(width: width, height: height) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionActions.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionActions.swift new file mode 100644 index 00000000..10a3c764 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionActions.swift @@ -0,0 +1,91 @@ +import AppKit +import Foundation + +enum SessionActions { + static func patchSession( + key: String, + thinking: String?? = nil, + verbose: String?? = nil) async throws + { + var params: [String: AnyHashable] = ["key": AnyHashable(key)] + + if let thinking { + params["thinkingLevel"] = thinking.map(AnyHashable.init) ?? AnyHashable(NSNull()) + } + if let verbose { + params["verboseLevel"] = verbose.map(AnyHashable.init) ?? AnyHashable(NSNull()) + } + + _ = try await ControlChannel.shared.request(method: "sessions.patch", params: params) + } + + static func resetSession(key: String) async throws { + _ = try await ControlChannel.shared.request( + method: "sessions.reset", + params: ["key": AnyHashable(key)]) + } + + static func deleteSession(key: String) async throws { + _ = try await ControlChannel.shared.request( + method: "sessions.delete", + params: ["key": AnyHashable(key), "deleteTranscript": AnyHashable(true)]) + } + + static func compactSession(key: String, maxLines: Int = 400) async throws { + _ = try await ControlChannel.shared.request( + method: "sessions.compact", + params: ["key": AnyHashable(key), "maxLines": AnyHashable(maxLines)]) + } + + @MainActor + static func confirmDestructiveAction(title: String, message: String, action: String) -> Bool { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: action) + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + return alert.runModal() == .alertFirstButtonReturn + } + + @MainActor + static func presentError(title: String, error: Error) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.runModal() + } + + @MainActor + static func openSessionLogInCode(sessionId: String, storePath: String?) { + let candidates: [URL] = { + var urls: [URL] = [] + if let storePath, !storePath.isEmpty { + let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent() + urls.append(dir.appendingPathComponent("\(sessionId).jsonl")) + } + urls.append(OpenClawPaths.stateDirURL.appendingPathComponent("sessions/\(sessionId).jsonl")) + return urls + }() + + let existing = candidates.first(where: { FileManager().fileExists(atPath: $0.path) }) + guard let url = existing else { + let alert = NSAlert() + alert.messageText = "Session log not found" + alert.informativeText = sessionId + alert.runModal() + return + } + + let proc = Process() + proc.launchPath = "/usr/bin/env" + proc.arguments = ["code", url.path] + if (try? proc.run()) != nil { + return + } + + NSWorkspace.shared.activateFileViewerSelecting([url]) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionData.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionData.swift new file mode 100644 index 00000000..8234cbde --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionData.swift @@ -0,0 +1,346 @@ +import Foundation +import SwiftUI + +struct GatewaySessionDefaultsRecord: Codable { + let model: String? + let contextTokens: Int? +} + +struct GatewaySessionEntryRecord: Codable { + let key: String + let displayName: String? + let provider: String? + let subject: String? + let room: String? + let space: String? + let updatedAt: Double? + let sessionId: String? + let systemSent: Bool? + let abortedLastRun: Bool? + let thinkingLevel: String? + let verboseLevel: String? + let inputTokens: Int? + let outputTokens: Int? + let totalTokens: Int? + let model: String? + let contextTokens: Int? +} + +struct GatewaySessionsListResponse: Codable { + let ts: Double? + let path: String + let count: Int + let defaults: GatewaySessionDefaultsRecord? + let sessions: [GatewaySessionEntryRecord] +} + +struct SessionTokenStats { + let input: Int + let output: Int + let total: Int + let contextTokens: Int + + var contextSummaryShort: String { + "\(Self.formatKTokens(self.total))/\(Self.formatKTokens(self.contextTokens))" + } + + var percentUsed: Int? { + guard self.contextTokens > 0, self.total > 0 else { return nil } + return min(100, Int(round((Double(self.total) / Double(self.contextTokens)) * 100))) + } + + var summary: String { + let parts = ["in \(input)", "out \(output)", "total \(total)"] + var text = parts.joined(separator: " | ") + if let percentUsed { + text += " (\(percentUsed)% of \(self.contextTokens))" + } + return text + } + + static func formatKTokens(_ value: Int) -> String { + if value < 1000 { return "\(value)" } + let thousands = Double(value) / 1000 + let decimals = value >= 10000 ? 0 : 1 + return String(format: "%.\(decimals)fk", thousands) + } +} + +struct SessionRow: Identifiable { + let id: String + let key: String + let kind: SessionKind + let displayName: String? + let provider: String? + let subject: String? + let room: String? + let space: String? + let updatedAt: Date? + let sessionId: String? + let thinkingLevel: String? + let verboseLevel: String? + let systemSent: Bool + let abortedLastRun: Bool + let tokens: SessionTokenStats + let model: String? + + var ageText: String { + relativeAge(from: self.updatedAt) + } + + var label: String { + self.displayName ?? self.key + } + + var flagLabels: [String] { + var flags: [String] = [] + if let thinkingLevel { flags.append("think \(thinkingLevel)") } + if let verboseLevel { flags.append("verbose \(verboseLevel)") } + if self.systemSent { flags.append("system sent") } + if self.abortedLastRun { flags.append("aborted") } + return flags + } +} + +enum SessionKind { + case direct, group, global, unknown + + static func from(key: String) -> SessionKind { + if key == "global" { return .global } + if key.hasPrefix("group:") { return .group } + if key.contains(":group:") { return .group } + if key.contains(":channel:") { return .group } + if key == "unknown" { return .unknown } + return .direct + } + + var label: String { + switch self { + case .direct: "Direct" + case .group: "Group" + case .global: "Global" + case .unknown: "Unknown" + } + } + + var tint: Color { + switch self { + case .direct: .accentColor + case .group: .orange + case .global: .purple + case .unknown: .gray + } + } +} + +struct SessionDefaults { + let model: String + let contextTokens: Int +} + +extension SessionRow { + static var previewRows: [SessionRow] { + [ + SessionRow( + id: "direct-1", + key: "user@example.com", + kind: .direct, + displayName: nil, + provider: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: Date().addingTimeInterval(-90), + sessionId: "sess-direct-1234", + thinkingLevel: "low", + verboseLevel: "info", + systemSent: false, + abortedLastRun: false, + tokens: SessionTokenStats(input: 320, output: 680, total: 1000, contextTokens: 200_000), + model: "claude-3.5-sonnet"), + SessionRow( + id: "group-1", + key: "discord:channel:release-squad", + kind: .group, + displayName: "discord:#release-squad", + provider: "discord", + subject: nil, + room: "#release-squad", + space: nil, + updatedAt: Date().addingTimeInterval(-3600), + sessionId: "sess-group-4321", + thinkingLevel: "medium", + verboseLevel: nil, + systemSent: true, + abortedLastRun: true, + tokens: SessionTokenStats(input: 5000, output: 1200, total: 6200, contextTokens: 200_000), + model: "claude-opus-4-6"), + SessionRow( + id: "global", + key: "global", + kind: .global, + displayName: nil, + provider: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: Date().addingTimeInterval(-86400), + sessionId: nil, + thinkingLevel: nil, + verboseLevel: nil, + systemSent: false, + abortedLastRun: false, + tokens: SessionTokenStats(input: 150, output: 220, total: 370, contextTokens: 200_000), + model: "gpt-4.1-mini"), + ] + } +} + +struct ModelChoice: Identifiable, Hashable, Codable { + let id: String + let name: String + let provider: String + let contextWindow: Int? +} + +extension String? { + var isNilOrEmpty: Bool { + switch self { + case .none: true + case let .some(value): value.isEmpty + } + } +} + +extension [String] { + fileprivate func dedupedPreserveOrder() -> [String] { + var seen = Set() + var result: [String] = [] + for item in self where !seen.contains(item) { + seen.insert(item) + result.append(item) + } + return result + } +} + +enum SessionLoadError: LocalizedError { + case gatewayUnavailable(String) + case decodeFailed(String) + + var errorDescription: String? { + switch self { + case let .gatewayUnavailable(reason): + "Could not reach the gateway for sessions: \(reason)" + + case let .decodeFailed(reason): + "Could not decode gateway session payload: \(reason)" + } + } +} + +struct SessionStoreSnapshot { + let storePath: String + let defaults: SessionDefaults + let rows: [SessionRow] +} + +@MainActor +enum SessionLoader { + static let fallbackModel = "claude-opus-4-6" + static let fallbackContextTokens = 200_000 + + static let defaultStorePath = standardize( + OpenClawPaths.stateDirURL + .appendingPathComponent("sessions/sessions.json").path) + + static func loadSnapshot( + activeMinutes: Int? = nil, + limit: Int? = nil, + includeGlobal: Bool = true, + includeUnknown: Bool = true) async throws -> SessionStoreSnapshot + { + var params: [String: AnyHashable] = [ + "includeGlobal": AnyHashable(includeGlobal), + "includeUnknown": AnyHashable(includeUnknown), + ] + if let activeMinutes { params["activeMinutes"] = AnyHashable(activeMinutes) } + if let limit { params["limit"] = AnyHashable(limit) } + + let data: Data + do { + data = try await ControlChannel.shared.request(method: "sessions.list", params: params) + } catch { + let msg = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + if msg.localizedCaseInsensitiveContains("unknown method: sessions.list") { + throw SessionLoadError.gatewayUnavailable( + "Gateway is too old (missing sessions.list). Restart/update the gateway.") + } + throw SessionLoadError.gatewayUnavailable(msg) + } + + let decoded: GatewaySessionsListResponse + do { + decoded = try JSONDecoder().decode(GatewaySessionsListResponse.self, from: data) + } catch { + throw SessionLoadError.decodeFailed(error.localizedDescription) + } + + let defaults = SessionDefaults( + model: decoded.defaults?.model ?? self.fallbackModel, + contextTokens: decoded.defaults?.contextTokens ?? self.fallbackContextTokens) + + let rows = decoded.sessions.map { entry -> SessionRow in + let updated = entry.updatedAt.map { Date(timeIntervalSince1970: $0 / 1000) } + let input = entry.inputTokens ?? 0 + let output = entry.outputTokens ?? 0 + let total = entry.totalTokens ?? input + output + let context = entry.contextTokens ?? defaults.contextTokens + let model = entry.model ?? defaults.model + + return SessionRow( + id: entry.key, + key: entry.key, + kind: SessionKind.from(key: entry.key), + displayName: entry.displayName, + provider: entry.provider, + subject: entry.subject, + room: entry.room, + space: entry.space, + updatedAt: updated, + sessionId: entry.sessionId, + thinkingLevel: entry.thinkingLevel, + verboseLevel: entry.verboseLevel, + systemSent: entry.systemSent ?? false, + abortedLastRun: entry.abortedLastRun ?? false, + tokens: SessionTokenStats( + input: input, + output: output, + total: total, + contextTokens: context), + model: model) + }.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } + + return SessionStoreSnapshot(storePath: decoded.path, defaults: defaults, rows: rows) + } + + static func loadRows() async throws -> [SessionRow] { + try await self.loadSnapshot().rows + } + + private static func standardize(_ path: String) -> String { + (path as NSString).expandingTildeInPath.replacingOccurrences(of: "//", with: "/") + } +} + +func relativeAge(from date: Date?) -> String { + guard let date else { return "unknown" } + let delta = Date().timeIntervalSince(date) + if delta < 60 { return "just now" } + let minutes = Int(round(delta / 60)) + if minutes < 60 { return "\(minutes)m ago" } + let hours = Int(round(Double(minutes) / 60)) + if hours < 48 { return "\(hours)h ago" } + let days = Int(round(Double(hours) / 24)) + return "\(days)d ago" +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift new file mode 100644 index 00000000..51646e0a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift @@ -0,0 +1,58 @@ +import SwiftUI + +extension EnvironmentValues { + @Entry var menuItemHighlighted: Bool = false +} + +struct SessionMenuLabelView: View { + let row: SessionRow + let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + private let paddingLeading: CGFloat = 22 + private let paddingTrailing: CGFloat = 14 + private let barHeight: CGFloat = 6 + + private var primaryTextColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary + } + + private var secondaryTextColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ContextUsageBar( + usedTokens: self.row.tokens.total, + contextTokens: self.row.tokens.contextTokens, + width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)), + height: self.barHeight) + + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(self.row.label) + .font(.caption.weight(self.row.key == "main" ? .semibold : .regular)) + .foregroundStyle(self.primaryTextColor) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) + + Spacer(minLength: 4) + + Text("\(self.row.tokens.contextSummaryShort) · \(self.row.ageText)") + .font(.caption.monospacedDigit()) + .foregroundStyle(self.secondaryTextColor) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(self.secondaryTextColor) + .padding(.leading, 2) + } + } + .padding(.vertical, 10) + .padding(.leading, self.paddingLeading) + .padding(.trailing, self.paddingTrailing) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift new file mode 100644 index 00000000..8840bce5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift @@ -0,0 +1,495 @@ +import OpenClawChatUI +import OpenClawKit +import OpenClawProtocol +import OSLog +import SwiftUI + +struct SessionPreviewItem: Identifiable, Sendable { + let id: String + let role: PreviewRole + let text: String +} + +enum PreviewRole: String, Sendable { + case user + case assistant + case tool + case system + case other + + var label: String { + switch self { + case .user: "User" + case .assistant: "Agent" + case .tool: "Tool" + case .system: "System" + case .other: "Other" + } + } +} + +actor SessionPreviewCache { + static let shared = SessionPreviewCache() + + private struct CacheEntry { + let snapshot: SessionMenuPreviewSnapshot + let updatedAt: Date + } + + private var entries: [String: CacheEntry] = [:] + + func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? { + guard let entry = self.entries[sessionKey] else { return nil } + guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil } + return entry.snapshot + } + + func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) { + self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date()) + } + + func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? { + self.entries[sessionKey]?.snapshot + } +} + +actor SessionPreviewLimiter { + static let shared = SessionPreviewLimiter(maxConcurrent: 2) + + private let maxConcurrent: Int + private var available: Int + private var waitQueue: [UUID] = [] + private var waiters: [UUID: CheckedContinuation] = [:] + + init(maxConcurrent: Int) { + let normalized = max(1, maxConcurrent) + self.maxConcurrent = normalized + self.available = normalized + } + + func withPermit(_ operation: () async throws -> T) async throws -> T { + await self.acquire() + defer { self.release() } + if Task.isCancelled { throw CancellationError() } + return try await operation() + } + + private func acquire() async { + if self.available > 0 { + self.available -= 1 + return + } + let id = UUID() + await withCheckedContinuation { cont in + self.waitQueue.append(id) + self.waiters[id] = cont + } + } + + private func release() { + if let id = self.waitQueue.first { + self.waitQueue.removeFirst() + if let cont = self.waiters.removeValue(forKey: id) { + cont.resume() + } + return + } + self.available = min(self.available + 1, self.maxConcurrent) + } +} + +#if DEBUG +extension SessionPreviewCache { + func _testSet( + snapshot: SessionMenuPreviewSnapshot, + for sessionKey: String, + updatedAt: Date = Date()) + { + self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt) + } + + func _testReset() { + self.entries = [:] + } +} +#endif + +struct SessionMenuPreviewSnapshot: Sendable { + let items: [SessionPreviewItem] + let status: SessionMenuPreviewView.LoadStatus +} + +struct SessionMenuPreviewView: View { + let width: CGFloat + let maxLines: Int + let title: String + let items: [SessionPreviewItem] + let status: LoadStatus + + @Environment(\.menuItemHighlighted) private var isHighlighted + + enum LoadStatus: Equatable { + case loading + case ready + case empty + case error(String) + } + + private var primaryColor: Color { + if self.isHighlighted { + return Color(nsColor: .selectedMenuItemTextColor) + } + return Color(nsColor: .labelColor) + } + + private var secondaryColor: Color { + if self.isHighlighted { + return Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) + } + return Color(nsColor: .secondaryLabelColor) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(self.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(self.secondaryColor) + Spacer(minLength: 8) + } + + switch self.status { + case .loading: + self.placeholder("Loading preview…") + case .empty: + self.placeholder("No recent messages") + case let .error(message): + self.placeholder(message) + case .ready: + if self.items.isEmpty { + self.placeholder("No recent messages") + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(self.items) { item in + self.previewRow(item) + } + } + } + } + } + .padding(.vertical, 6) + .padding(.leading, 16) + .padding(.trailing, 11) + .frame(width: max(1, self.width), alignment: .leading) + } + + private func previewRow(_ item: SessionPreviewItem) -> some View { + HStack(alignment: .top, spacing: 4) { + Text(item.role.label) + .font(.caption2.monospacedDigit()) + .foregroundStyle(self.roleColor(item.role)) + .frame(width: 50, alignment: .leading) + + Text(item.text) + .font(.caption) + .foregroundStyle(self.primaryColor) + .multilineTextAlignment(.leading) + .lineLimit(self.maxLines) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func roleColor(_ role: PreviewRole) -> Color { + if self.isHighlighted { return Color(nsColor: .selectedMenuItemTextColor).opacity(0.9) } + switch role { + case .user: return .accentColor + case .assistant: return .secondary + case .tool: return .orange + case .system: return .gray + case .other: return .secondary + } + } + + private func placeholder(_ text: String) -> some View { + Text(text) + .font(.caption) + .foregroundStyle(self.primaryColor) + } +} + +enum SessionMenuPreviewLoader { + private static let logger = Logger(subsystem: "ai.openclaw", category: "SessionPreview") + private static let previewTimeoutSeconds: Double = 4 + private static let cacheMaxAgeSeconds: TimeInterval = 30 + private static let previewMaxChars = 240 + + private struct PreviewTimeoutError: LocalizedError { + var errorDescription: String? { + "preview timeout" + } + } + + static func prewarm(sessionKeys: [String], maxItems: Int) async { + let keys = self.uniqueKeys(sessionKeys) + guard !keys.isEmpty else { return } + do { + let payload = try await self.requestPreview(keys: keys, maxItems: maxItems) + await self.cache(payload: payload, maxItems: maxItems) + } catch { + if self.isUnknownMethodError(error) { return } + let errorDescription = String(describing: error) + Self.logger.debug( + "Session preview prewarm failed count=\(keys.count, privacy: .public) " + + "error=\(errorDescription, privacy: .public)") + } + } + + static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot { + if let cached = await SessionPreviewCache.shared.cachedSnapshot( + for: sessionKey, + maxAge: cacheMaxAgeSeconds) + { + return cached + } + + do { + let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems) + await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey) + return snapshot + } catch is CancellationError { + return SessionMenuPreviewSnapshot(items: [], status: .loading) + } catch { + if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) { + return fallback + } + let errorDescription = String(describing: error) + Self.logger.warning( + "Session preview failed session=\(sessionKey, privacy: .public) " + + "error=\(errorDescription, privacy: .public)") + return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable")) + } + } + + private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot { + do { + let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems) + if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first { + return self.snapshot(from: entry, maxItems: maxItems) + } + return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable")) + } catch { + if self.isUnknownMethodError(error) { + return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems) + } + throw error + } + } + + private static func requestPreview( + keys: [String], + maxItems: Int) async throws -> OpenClawSessionsPreviewPayload + { + let boundedItems = self.normalizeMaxItems(maxItems) + let timeoutMs = Int(self.previewTimeoutSeconds * 1000) + return try await SessionPreviewLimiter.shared.withPermit { + try await AsyncTimeout.withTimeout( + seconds: self.previewTimeoutSeconds, + onTimeout: { PreviewTimeoutError() }, + operation: { + try await GatewayConnection.shared.sessionsPreview( + keys: keys, + limit: boundedItems, + maxChars: self.previewMaxChars, + timeoutMs: timeoutMs) + }) + } + } + + private static func fetchHistorySnapshot( + sessionKey: String, + maxItems: Int) async throws -> SessionMenuPreviewSnapshot + { + let timeoutMs = Int(self.previewTimeoutSeconds * 1000) + let payload = try await SessionPreviewLimiter.shared.withPermit { + try await AsyncTimeout.withTimeout( + seconds: self.previewTimeoutSeconds, + onTimeout: { PreviewTimeoutError() }, + operation: { + try await GatewayConnection.shared.chatHistory( + sessionKey: sessionKey, + limit: self.previewLimit(for: maxItems), + timeoutMs: timeoutMs) + }) + } + let built = Self.previewItems(from: payload, maxItems: maxItems) + return Self.snapshot(from: built) + } + + private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot { + SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) + } + + private static func snapshot( + from entry: OpenClawSessionPreviewEntry, + maxItems: Int) -> SessionMenuPreviewSnapshot + { + let items = self.previewItems(from: entry, maxItems: maxItems) + let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "ok": + return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) + case "empty": + return SessionMenuPreviewSnapshot(items: items, status: .empty) + case "missing": + return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing")) + default: + return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable")) + } + } + + private static func cache(payload: OpenClawSessionsPreviewPayload, maxItems: Int) async { + for entry in payload.previews { + let snapshot = self.snapshot(from: entry, maxItems: maxItems) + await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key) + } + } + + private static func previewLimit(for maxItems: Int) -> Int { + let boundedItems = self.normalizeMaxItems(maxItems) + return min(max(boundedItems * 3, 20), 120) + } + + private static func normalizeMaxItems(_ maxItems: Int) -> Int { + max(1, min(maxItems, 50)) + } + + private static func previewItems( + from entry: OpenClawSessionPreviewEntry, + maxItems: Int) -> [SessionPreviewItem] + { + let boundedItems = self.normalizeMaxItems(maxItems) + let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in + let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + let role = self.previewRoleFromRaw(item.role) + return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text) + } + + let trimmed = built.suffix(boundedItems) + return Array(trimmed.reversed()) + } + + private static func previewItems( + from payload: OpenClawChatHistoryPayload, + maxItems: Int) -> [SessionPreviewItem] + { + let boundedItems = self.normalizeMaxItems(maxItems) + let raw: [OpenClawKit.AnyCodable] = payload.messages ?? [] + let messages = self.decodeMessages(raw) + let built = messages.compactMap { message -> SessionPreviewItem? in + guard let text = self.previewText(for: message) else { return nil } + let isTool = self.isToolCall(message) + let role = self.previewRole(message.role, isTool: isTool) + let id = "\(message.timestamp ?? 0)-\(UUID().uuidString)" + return SessionPreviewItem(id: id, role: role, text: text) + } + + let trimmed = built.suffix(boundedItems) + return Array(trimmed.reversed()) + } + + private static func decodeMessages(_ raw: [OpenClawKit.AnyCodable]) -> [OpenClawChatMessage] { + raw.compactMap { item in + guard let data = try? JSONEncoder().encode(item) else { return nil } + return try? JSONDecoder().decode(OpenClawChatMessage.self, from: data) + } + } + + private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole { + if isTool { return .tool } + return self.previewRoleFromRaw(raw) + } + + private static func previewRoleFromRaw(_ raw: String) -> PreviewRole { + switch raw.lowercased() { + case "user": .user + case "assistant": .assistant + case "system": .system + case "tool": .tool + default: .other + } + } + + private static func previewText(for message: OpenClawChatMessage) -> String? { + let text = message.content.compactMap(\.text).joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { return text } + + let toolNames = self.toolNames(for: message) + if !toolNames.isEmpty { + let shown = toolNames.prefix(2) + let overflow = toolNames.count - shown.count + var label = "call \(shown.joined(separator: ", "))" + if overflow > 0 { label += " +\(overflow)" } + return label + } + + if let media = self.mediaSummary(for: message) { + return media + } + + return nil + } + + private static func isToolCall(_ message: OpenClawChatMessage) -> Bool { + if message.toolName?.nonEmpty != nil { return true } + return message.content.contains { $0.name?.nonEmpty != nil || $0.type?.lowercased() == "toolcall" } + } + + private static func toolNames(for message: OpenClawChatMessage) -> [String] { + var names: [String] = [] + for content in message.content { + if let name = content.name?.nonEmpty { + names.append(name) + } + } + if let toolName = message.toolName?.nonEmpty { + names.append(toolName) + } + return Self.dedupePreservingOrder(names) + } + + private static func mediaSummary(for message: OpenClawChatMessage) -> String? { + let types = message.content.compactMap { content -> String? in + let raw = content.type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let raw, !raw.isEmpty else { return nil } + if raw == "text" || raw == "toolcall" { return nil } + return raw + } + guard let first = types.first else { return nil } + return "[\(first)]" + } + + private static func dedupePreservingOrder(_ values: [String]) -> [String] { + var seen = Set() + var result: [String] = [] + for value in values where !seen.contains(value) { + seen.insert(value) + result.append(value) + } + return result + } + + private static func uniqueKeys(_ keys: [String]) -> [String] { + let trimmed = keys.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + return self.dedupePreservingOrder(trimmed.filter { !$0.isEmpty }) + } + + private static func isUnknownMethodError(_ error: Error) -> Bool { + guard let response = error as? GatewayResponseError else { return false } + guard response.code == ErrorCode.invalidRequest.rawValue else { return false } + let message = response.message.lowercased() + return message.contains("unknown method") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionsSettings.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionsSettings.swift new file mode 100644 index 00000000..826f1128 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SessionsSettings.swift @@ -0,0 +1,212 @@ +import AppKit +import SwiftUI + +@MainActor +struct SessionsSettings: View { + private let isPreview: Bool + @State private var rows: [SessionRow] + @State private var errorMessage: String? + @State private var loading = false + @State private var hasLoaded = false + + init(rows: [SessionRow]? = nil, isPreview: Bool = ProcessInfo.processInfo.isPreview) { + self._rows = State(initialValue: rows ?? []) + self.isPreview = isPreview + if isPreview { + self._hasLoaded = State(initialValue: true) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + self.header + self.content + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .task { + guard !self.hasLoaded else { return } + guard !self.isPreview else { return } + self.hasLoaded = true + await self.refresh() + } + } + + private var header: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Sessions") + .font(.headline) + Text("Peek at the stored conversation buckets the CLI reuses for context and rate limits.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + if self.loading { + ProgressView() + } else { + Button { + Task { await self.refresh() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .help("Refresh") + } + } + } + + private var content: some View { + Group { + if self.rows.isEmpty, self.errorMessage == nil { + Text("No sessions yet. They appear after the first inbound message or heartbeat.") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 6) + } else { + List(self.rows) { row in + self.sessionRow(row) + } + .listStyle(.inset) + .overlay(alignment: .topLeading) { + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + .padding(.leading, 4) + .padding(.top, 4) + } + } + // The view already applies horizontal padding; keep the list aligned with the text above. + .padding(.horizontal, -12) + } + } + } + + private func sessionRow(_ row: SessionRow) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(row.label) + .font(.subheadline.bold()) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(row.ageText) + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 6) { + if row.kind != .direct { + SessionKindBadge(kind: row.kind) + } + if !row.flagLabels.isEmpty { + ForEach(row.flagLabels, id: \.self) { flag in + Badge(text: flag) + } + } + } + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text("Context") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + Text(row.tokens.contextSummaryShort) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + ContextUsageBar( + usedTokens: row.tokens.total, + contextTokens: row.tokens.contextTokens, + width: nil) + } + + HStack(spacing: 10) { + if let model = row.model, !model.isEmpty { + self.label(icon: "cpu", text: model) + } + self.label(icon: "arrow.down.left", text: "\(row.tokens.input) in") + self.label(icon: "arrow.up.right", text: "\(row.tokens.output) out") + if let sessionId = row.sessionId, !sessionId.isEmpty { + HStack(spacing: 4) { + Image(systemName: "number").foregroundStyle(.secondary).font(.caption) + Text(sessionId) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .help(sessionId) + } + } + } + .padding(.vertical, 6) + } + + private func label(icon: String, text: String) -> some View { + HStack(spacing: 4) { + Image(systemName: icon).foregroundStyle(.secondary).font(.caption) + Text(text) + } + .font(.footnote) + .foregroundStyle(.secondary) + } + + private func refresh() async { + guard !self.loading else { return } + guard !self.isPreview else { return } + self.loading = true + self.errorMessage = nil + + do { + let snapshot = try await SessionLoader.loadSnapshot() + self.rows = snapshot.rows + } catch { + self.rows = [] + self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + + self.loading = false + } +} + +private struct SessionKindBadge: View { + let kind: SessionKind + + var body: some View { + Text(self.kind.label) + .font(.caption2.weight(.bold)) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .foregroundStyle(self.kind.tint) + .background(self.kind.tint.opacity(0.15)) + .clipShape(Capsule()) + } +} + +private struct Badge: View { + let text: String + + var body: some View { + Text(self.text) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .foregroundStyle(.secondary) + .background(Color.secondary.opacity(0.12)) + .clipShape(Capsule()) + } +} + +#if DEBUG +struct SessionsSettings_Previews: PreviewProvider { + static var previews: some View { + SessionsSettings(rows: SessionRow.previewRows, isPreview: true) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SettingsComponents.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SettingsComponents.swift new file mode 100644 index 00000000..f826fd4e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SettingsComponents.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct SettingsToggleRow: View { + let title: String + let subtitle: String? + @Binding var binding: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Toggle(isOn: self.$binding) { + Text(self.title) + .font(.body) + } + .toggleStyle(.checkbox) + + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SettingsRootView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SettingsRootView.swift new file mode 100644 index 00000000..016e2f3d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SettingsRootView.swift @@ -0,0 +1,243 @@ +import Observation +import SwiftUI + +struct SettingsRootView: View { + @Bindable var state: AppState + private let permissionMonitor = PermissionMonitor.shared + @State private var monitoringPermissions = false + @State private var selectedTab: SettingsTab = .general + @State private var snapshotPaths: (configPath: String?, stateDir: String?) = (nil, nil) + let updater: UpdaterProviding? + private let isPreview = ProcessInfo.processInfo.isPreview + private let isNixMode = ProcessInfo.processInfo.isNixMode + + init(state: AppState, updater: UpdaterProviding?, initialTab: SettingsTab? = nil) { + self.state = state + self.updater = updater + self._selectedTab = State(initialValue: initialTab ?? .general) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if self.isNixMode { + self.nixManagedBanner + } + TabView(selection: self.$selectedTab) { + GeneralSettings(state: self.state) + .tabItem { Label("General", systemImage: "gearshape") } + .tag(SettingsTab.general) + + ChannelsSettings() + .tabItem { Label("Channels", systemImage: "link") } + .tag(SettingsTab.channels) + + VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake) + .tabItem { Label("Voice Wake", systemImage: "waveform.circle") } + .tag(SettingsTab.voiceWake) + + ConfigSettings() + .tabItem { Label("Config", systemImage: "slider.horizontal.3") } + .tag(SettingsTab.config) + + InstancesSettings() + .tabItem { Label("Instances", systemImage: "network") } + .tag(SettingsTab.instances) + + SessionsSettings() + .tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") } + .tag(SettingsTab.sessions) + + CronSettings() + .tabItem { Label("Cron", systemImage: "calendar") } + .tag(SettingsTab.cron) + + SkillsSettings(state: self.state) + .tabItem { Label("Skills", systemImage: "sparkles") } + .tag(SettingsTab.skills) + + PermissionsSettings( + status: self.permissionMonitor.status, + refresh: self.refreshPerms, + showOnboarding: { DebugActions.restartOnboarding() }) + .tabItem { Label("Permissions", systemImage: "lock.shield") } + .tag(SettingsTab.permissions) + + if self.state.debugPaneEnabled { + DebugSettings(state: self.state) + .tabItem { Label("Debug", systemImage: "ant") } + .tag(SettingsTab.debug) + } + + AboutSettings(updater: self.updater) + .tabItem { Label("About", systemImage: "info.circle") } + .tag(SettingsTab.about) + } + } + .padding(.horizontal, 28) + .padding(.vertical, 22) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onReceive(NotificationCenter.default.publisher(for: .openclawSelectSettingsTab)) { note in + if let tab = note.object as? SettingsTab { + withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { + self.selectedTab = tab + } + } + } + .onAppear { + if let pending = SettingsTabRouter.consumePending() { + self.selectedTab = self.validTab(for: pending) + } + self.updatePermissionMonitoring(for: self.selectedTab) + } + .onChange(of: self.state.debugPaneEnabled) { _, enabled in + if !enabled, self.selectedTab == .debug { + self.selectedTab = .general + } + } + .onChange(of: self.selectedTab) { _, newValue in + self.updatePermissionMonitoring(for: newValue) + } + .onDisappear { self.stopPermissionMonitoring() } + .task { + guard !self.isPreview else { return } + await self.refreshPerms() + } + .task(id: self.state.connectionMode) { + guard !self.isPreview else { return } + await self.refreshSnapshotPaths() + } + } + + private var nixManagedBanner: some View { + // Prefer gateway-resolved paths; fall back to local env defaults if disconnected. + let configPath = self.snapshotPaths.configPath ?? OpenClawPaths.configURL.path + let stateDir = self.snapshotPaths.stateDir ?? OpenClawPaths.stateDirURL.path + + return VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: "gearshape.2.fill") + .foregroundStyle(.secondary) + Text("Managed by Nix") + .font(.callout.weight(.semibold)) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Config: \(configPath)") + Text("State: \(stateDir)") + } + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background(Color.gray.opacity(0.12)) + .cornerRadius(10) + } + + private func validTab(for requested: SettingsTab) -> SettingsTab { + if requested == .debug, !self.state.debugPaneEnabled { return .general } + return requested + } + + @MainActor + private func refreshSnapshotPaths() async { + let paths = await GatewayConnection.shared.snapshotPaths() + self.snapshotPaths = paths + } + + @MainActor + private func refreshPerms() async { + guard !self.isPreview else { return } + await self.permissionMonitor.refreshNow() + } + + private func updatePermissionMonitoring(for tab: SettingsTab) { + guard !self.isPreview else { return } + let shouldMonitor = tab == .permissions + if shouldMonitor, !self.monitoringPermissions { + self.monitoringPermissions = true + PermissionMonitor.shared.register() + } else if !shouldMonitor, self.monitoringPermissions { + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() + } + } + + private func stopPermissionMonitoring() { + guard self.monitoringPermissions else { return } + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() + } +} + +enum SettingsTab: CaseIterable { + case general, channels, skills, sessions, cron, config, instances, voiceWake, permissions, debug, about + static let windowWidth: CGFloat = 824 // wider + static let windowHeight: CGFloat = 790 // +10% (more room) + var title: String { + switch self { + case .general: "General" + case .channels: "Channels" + case .skills: "Skills" + case .sessions: "Sessions" + case .cron: "Cron" + case .config: "Config" + case .instances: "Instances" + case .voiceWake: "Voice Wake" + case .permissions: "Permissions" + case .debug: "Debug" + case .about: "About" + } + } + + var systemImage: String { + switch self { + case .general: "gearshape" + case .channels: "link" + case .skills: "sparkles" + case .sessions: "clock.arrow.circlepath" + case .cron: "calendar" + case .config: "slider.horizontal.3" + case .instances: "network" + case .voiceWake: "waveform.circle" + case .permissions: "lock.shield" + case .debug: "ant" + case .about: "info.circle" + } + } +} + +@MainActor +enum SettingsTabRouter { + private static var pending: SettingsTab? + + static func request(_ tab: SettingsTab) { + self.pending = tab + } + + static func consumePending() -> SettingsTab? { + defer { self.pending = nil } + return self.pending + } +} + +extension Notification.Name { + static let openclawSelectSettingsTab = Notification.Name("openclawSelectSettingsTab") +} + +#if DEBUG +struct SettingsRootView_Previews: PreviewProvider { + static var previews: some View { + ForEach(SettingsTab.allCases, id: \.self) { tab in + SettingsRootView(state: .preview, updater: DisabledUpdaterController(), initialTab: tab) + .previewDisplayName(tab.title) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SettingsWindowOpener.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SettingsWindowOpener.swift new file mode 100644 index 00000000..9cc1647b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SettingsWindowOpener.swift @@ -0,0 +1,36 @@ +import AppKit +import SwiftUI + +@objc +private protocol SettingsWindowMenuActions { + @objc(showSettingsWindow:) + optional func showSettingsWindow(_ sender: Any?) + + @objc(showPreferencesWindow:) + optional func showPreferencesWindow(_ sender: Any?) +} + +@MainActor +final class SettingsWindowOpener { + static let shared = SettingsWindowOpener() + + private var openSettingsAction: OpenSettingsAction? + + func register(openSettings: OpenSettingsAction) { + self.openSettingsAction = openSettings + } + + func open() { + NSApp.activate(ignoringOtherApps: true) + if let openSettingsAction { + openSettingsAction() + return + } + + // Fallback path: mimic the built-in Settings menu item action. + let didOpen = NSApp.sendAction(#selector(SettingsWindowMenuActions.showSettingsWindow(_:)), to: nil, from: nil) + if !didOpen { + _ = NSApp.sendAction(#selector(SettingsWindowMenuActions.showPreferencesWindow(_:)), to: nil, from: nil) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ShellExecutor.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ShellExecutor.swift new file mode 100644 index 00000000..ec757441 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ShellExecutor.swift @@ -0,0 +1,101 @@ +import Foundation +import OpenClawIPC + +enum ShellExecutor { + struct ShellResult { + var stdout: String + var stderr: String + var exitCode: Int? + var timedOut: Bool + var success: Bool + var errorMessage: String? + } + + static func runDetailed( + command: [String], + cwd: String?, + env: [String: String]?, + timeout: Double?) async -> ShellResult + { + guard !command.isEmpty else { + return ShellResult( + stdout: "", + stderr: "", + exitCode: nil, + timedOut: false, + success: false, + errorMessage: "empty command") + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = command + if let cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) } + if let env { process.environment = env } + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + } catch { + return ShellResult( + stdout: "", + stderr: "", + exitCode: nil, + timedOut: false, + success: false, + errorMessage: "failed to start: \(error.localizedDescription)") + } + + let outTask = Task { stdoutPipe.fileHandleForReading.readToEndSafely() } + let errTask = Task { stderrPipe.fileHandleForReading.readToEndSafely() } + + let waitTask = Task { () -> ShellResult in + process.waitUntilExit() + let out = await outTask.value + let err = await errTask.value + let status = Int(process.terminationStatus) + return ShellResult( + stdout: String(bytes: out, encoding: .utf8) ?? "", + stderr: String(bytes: err, encoding: .utf8) ?? "", + exitCode: status, + timedOut: false, + success: status == 0, + errorMessage: status == 0 ? nil : "exit \(status)") + } + + if let timeout, timeout > 0 { + let nanos = UInt64(timeout * 1_000_000_000) + return await withTaskGroup(of: ShellResult.self) { group in + group.addTask { await waitTask.value } + group.addTask { + try? await Task.sleep(nanoseconds: nanos) + if process.isRunning { process.terminate() } + _ = await waitTask.value // drain pipes after termination + return ShellResult( + stdout: "", + stderr: "", + exitCode: nil, + timedOut: true, + success: false, + errorMessage: "timeout") + } + let first = await group.next()! + group.cancelAll() + return first + } + } + + return await waitTask.value + } + + static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response { + let result = await self.runDetailed(command: command, cwd: cwd, env: env, timeout: timeout) + let combined = result.stdout.isEmpty ? result.stderr : result.stdout + let payload = combined.isEmpty ? nil : Data(combined.utf8) + return Response(ok: result.success, message: result.errorMessage, payload: payload) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SkillsModels.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SkillsModels.swift new file mode 100644 index 00000000..d143484c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SkillsModels.swift @@ -0,0 +1,74 @@ +import Foundation +import OpenClawProtocol + +struct SkillsStatusReport: Codable { + let workspaceDir: String + let managedSkillsDir: String + let skills: [SkillStatus] +} + +struct SkillStatus: Codable, Identifiable { + let name: String + let description: String + let source: String + let filePath: String + let baseDir: String + let skillKey: String + let primaryEnv: String? + let emoji: String? + let homepage: String? + let always: Bool + let disabled: Bool + let eligible: Bool + let requirements: SkillRequirements + let missing: SkillMissing + let configChecks: [SkillStatusConfigCheck] + let install: [SkillInstallOption] + + var id: String { + self.name + } +} + +struct SkillRequirements: Codable { + let bins: [String] + let env: [String] + let config: [String] +} + +struct SkillMissing: Codable { + let bins: [String] + let env: [String] + let config: [String] +} + +struct SkillStatusConfigCheck: Codable, Identifiable { + let path: String + let value: AnyCodable? + let satisfied: Bool + + var id: String { + self.path + } +} + +struct SkillInstallOption: Codable, Identifiable { + let id: String + let kind: String + let label: String + let bins: [String] +} + +struct SkillInstallResult: Codable { + let ok: Bool + let message: String + let stdout: String? + let stderr: String? + let code: Int? +} + +struct SkillUpdateResult: Codable { + let ok: Bool + let skillKey: String + let config: [String: AnyCodable]? +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SkillsSettings.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SkillsSettings.swift new file mode 100644 index 00000000..02db8495 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SkillsSettings.swift @@ -0,0 +1,621 @@ +import Observation +import OpenClawProtocol +import SwiftUI + +struct SkillsSettings: View { + @Bindable var state: AppState + @State private var model = SkillsSettingsModel() + @State private var envEditor: EnvEditorState? + @State private var filter: SkillsFilter = .all + + init(state: AppState = AppStateStore.shared, model: SkillsSettingsModel = SkillsSettingsModel()) { + self.state = state + self._model = State(initialValue: model) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + self.header + self.statusBanner + self.skillsList + Spacer(minLength: 0) + } + .task { await self.model.refresh() } + .sheet(item: self.$envEditor) { editor in + EnvEditorView(editor: editor) { value in + Task { + await self.model.updateEnv( + skillKey: editor.skillKey, + envKey: editor.envKey, + value: value, + isPrimary: editor.isPrimary) + } + } + } + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Skills") + .font(.headline) + Text("Skills are enabled when requirements are met (binaries, env, config).") + .font(.footnote) + .foregroundStyle(.secondary) + } + Spacer() + if self.model.isLoading { + ProgressView() + } else { + Button { + Task { await self.model.refresh() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .help("Refresh") + } + self.headerFilter + } + } + + @ViewBuilder + private var statusBanner: some View { + if let error = self.model.error { + Text(error) + .font(.footnote) + .foregroundStyle(.orange) + } else if let message = self.model.statusMessage { + Text(message) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var skillsList: some View { + if self.model.skills.isEmpty { + Text("No skills reported yet.") + .foregroundStyle(.secondary) + } else { + List { + ForEach(self.filteredSkills) { skill in + SkillRow( + skill: skill, + isBusy: self.model.isBusy(skill: skill), + connectionMode: self.state.connectionMode, + onToggleEnabled: { enabled in + Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) } + }, + onInstall: { option, target in + Task { await self.model.install(skill: skill, option: option, target: target) } + }, + onSetEnv: { envKey, isPrimary in + self.envEditor = EnvEditorState( + skillKey: skill.skillKey, + skillName: skill.name, + envKey: envKey, + isPrimary: isPrimary) + }) + } + if !self.model.skills.isEmpty, self.filteredSkills.isEmpty { + Text("No skills match this filter.") + .font(.callout) + .foregroundStyle(.secondary) + } + } + .listStyle(.inset) + } + } + + private var headerFilter: some View { + Picker("Filter", selection: self.$filter) { + ForEach(SkillsFilter.allCases) { filter in + Text(filter.title) + .tag(filter) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(width: 160, alignment: .trailing) + } + + private var filteredSkills: [SkillStatus] { + self.model.skills.filter { skill in + switch self.filter { + case .all: + true + case .ready: + !skill.disabled && skill.eligible + case .needsSetup: + !skill.disabled && !skill.eligible + case .disabled: + skill.disabled + } + } + } +} + +private enum SkillsFilter: String, CaseIterable, Identifiable { + case all + case ready + case needsSetup + case disabled + + var id: String { + self.rawValue + } + + var title: String { + switch self { + case .all: + "All" + case .ready: + "Ready" + case .needsSetup: + "Needs Setup" + case .disabled: + "Disabled" + } + } +} + +private enum InstallTarget: String, CaseIterable { + case gateway + case local +} + +private struct SkillRow: View { + let skill: SkillStatus + let isBusy: Bool + let connectionMode: AppState.ConnectionMode + let onToggleEnabled: (Bool) -> Void + let onInstall: (SkillInstallOption, InstallTarget) -> Void + let onSetEnv: (String, Bool) -> Void + + private var missingBins: [String] { + self.skill.missing.bins + } + + private var missingEnv: [String] { + self.skill.missing.env + } + + private var missingConfig: [String] { + self.skill.missing.config + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Text(self.skill.emoji ?? "✨") + .font(.title2) + + VStack(alignment: .leading, spacing: 6) { + Text(self.skill.name) + .font(.headline) + Text(self.skill.description) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + self.metaRow + + if self.skill.disabled { + Text("Disabled in config") + .font(.caption) + .foregroundStyle(.secondary) + } else if !self.requirementsMet, self.shouldShowMissingSummary { + self.missingSummary + } + + if !self.skill.configChecks.isEmpty { + self.configChecksView + } + + if !self.missingEnv.isEmpty { + self.envActionRow + } + } + + Spacer(minLength: 0) + + self.trailingActions + } + .padding(.vertical, 6) + } + + private var sourceLabel: String { + switch self.skill.source { + case "openclaw-bundled": + "Bundled" + case "openclaw-managed": + "Managed" + case "openclaw-workspace": + "Workspace" + case "openclaw-extra": + "Extra" + case "openclaw-plugin": + "Plugin" + default: + self.skill.source + } + } + + private var metaRow: some View { + HStack(spacing: 10) { + SkillTag(text: self.sourceLabel) + if let url = self.homepageUrl { + Link(destination: url) { + Label("Website", systemImage: "link") + .font(.caption2.weight(.semibold)) + } + .buttonStyle(.link) + } + Spacer(minLength: 0) + } + } + + private var homepageUrl: URL? { + guard let raw = self.skill.homepage?.trimmingCharacters(in: .whitespacesAndNewlines) else { + return nil + } + guard !raw.isEmpty else { return nil } + return URL(string: raw) + } + + private var enabledBinding: Binding { + Binding( + get: { !self.skill.disabled }, + set: { self.onToggleEnabled($0) }) + } + + private var missingSummary: some View { + VStack(alignment: .leading, spacing: 4) { + if self.shouldShowMissingBins { + Text("Missing binaries: \(self.missingBins.joined(separator: ", "))") + .font(.caption) + .foregroundStyle(.secondary) + } + if !self.missingEnv.isEmpty { + Text("Missing env: \(self.missingEnv.joined(separator: ", "))") + .font(.caption) + .foregroundStyle(.secondary) + } + if !self.missingConfig.isEmpty { + Text("Requires config: \(self.missingConfig.joined(separator: ", "))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var configChecksView: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(self.skill.configChecks) { check in + HStack(spacing: 6) { + Image(systemName: check.satisfied ? "checkmark.circle" : "xmark.circle") + .foregroundStyle(check.satisfied ? .green : .secondary) + Text(check.path) + .font(.caption) + Text(self.formatConfigValue(check.value)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + private var envActionRow: some View { + HStack(spacing: 8) { + ForEach(self.missingEnv, id: \.self) { envKey in + let isPrimary = envKey == self.skill.primaryEnv + Button(isPrimary ? "Set API Key" : "Set \(envKey)") { + self.onSetEnv(envKey, isPrimary) + } + .buttonStyle(.bordered) + .disabled(self.isBusy) + } + Spacer(minLength: 0) + } + } + + private var trailingActions: some View { + VStack(alignment: .trailing, spacing: 8) { + if !self.installOptions.isEmpty { + ForEach(self.installOptions, id: \.id) { (option: SkillInstallOption) in + HStack(spacing: 6) { + if self.showGatewayInstall { + Button("Install on Gateway") { self.onInstall(option, .gateway) } + .buttonStyle(.borderedProminent) + .disabled(self.isBusy) + } + if self.showGatewayInstall { + Button("Install on This Mac") { self.onInstall(option, .local) } + .buttonStyle(.bordered) + .disabled(self.isBusy) + .help( + self.localInstallNeedsSwitch + ? "Switches to Local mode to install on this Mac." + : "") + } else { + Button("Install on This Mac") { self.onInstall(option, .local) } + .buttonStyle(.borderedProminent) + .disabled(self.isBusy) + .help( + self.localInstallNeedsSwitch + ? "Switches to Local mode to install on this Mac." + : "") + } + } + } + } else { + Toggle("", isOn: self.enabledBinding) + .toggleStyle(.switch) + .labelsHidden() + .disabled(self.isBusy || !self.requirementsMet) + } + + if self.isBusy { + ProgressView() + .controlSize(.small) + } + } + } + + private var installOptions: [SkillInstallOption] { + guard !self.missingBins.isEmpty else { return [] } + let missing = Set(self.missingBins) + return self.skill.install.filter { option in + if option.bins.isEmpty { return true } + return !missing.isDisjoint(with: option.bins) + } + } + + private var requirementsMet: Bool { + self.missingBins.isEmpty && self.missingEnv.isEmpty && self.missingConfig.isEmpty + } + + private var shouldShowMissingBins: Bool { + !self.missingBins.isEmpty && self.installOptions.isEmpty + } + + private var shouldShowMissingSummary: Bool { + self.shouldShowMissingBins || + !self.missingEnv.isEmpty || + !self.missingConfig.isEmpty + } + + private var showGatewayInstall: Bool { + self.connectionMode == .remote + } + + private var localInstallNeedsSwitch: Bool { + self.connectionMode != .local + } + + private func formatConfigValue(_ value: AnyCodable?) -> String { + guard let value else { return "" } + switch value.value { + case let bool as Bool: + return bool ? "true" : "false" + case let int as Int: + return String(int) + case let double as Double: + return String(double) + case let string as String: + return string + default: + return "" + } + } +} + +private struct SkillTag: View { + let text: String + + var body: some View { + Text(self.text) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.12)) + .clipShape(Capsule()) + } +} + +private struct EnvEditorState: Identifiable { + let skillKey: String + let skillName: String + let envKey: String + let isPrimary: Bool + + var id: String { + "\(self.skillKey)::\(self.envKey)" + } +} + +private struct EnvEditorView: View { + let editor: EnvEditorState + let onSave: (String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var value: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(self.title) + .font(.headline) + Text(self.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + SecureField(self.editor.envKey, text: self.$value) + .textFieldStyle(.roundedBorder) + HStack { + Button("Cancel") { self.dismiss() } + Spacer() + Button("Save") { + self.onSave(self.value) + self.dismiss() + } + .buttonStyle(.borderedProminent) + .disabled(self.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(20) + .frame(width: 420) + } + + private var title: String { + self.editor.isPrimary ? "Set API Key" : "Set Environment Variable" + } + + private var subtitle: String { + "Skill: \(self.editor.skillName)" + } +} + +@MainActor +@Observable +final class SkillsSettingsModel { + var skills: [SkillStatus] = [] + var isLoading = false + var error: String? + var statusMessage: String? + private var busySkills: Set = [] + + func isBusy(skill: SkillStatus) -> Bool { + self.busySkills.contains(skill.skillKey) + } + + func refresh() async { + guard !self.isLoading else { return } + self.isLoading = true + self.error = nil + do { + let report = try await GatewayConnection.shared.skillsStatus() + self.skills = report.skills.sorted { $0.name < $1.name } + } catch { + self.error = error.localizedDescription + } + self.isLoading = false + } + + fileprivate func install(skill: SkillStatus, option: SkillInstallOption, target: InstallTarget) async { + await self.withBusy(skill.skillKey) { + do { + if target == .local, AppStateStore.shared.connectionMode != .local { + AppStateStore.shared.connectionMode = .local + self.statusMessage = "Switched to Local mode to install on this Mac" + } + let result = try await GatewayConnection.shared.skillsInstall( + name: skill.name, + installId: option.id, + timeoutMs: 300_000) + self.statusMessage = result.message + } catch { + self.statusMessage = error.localizedDescription + } + await self.refresh() + } + } + + func setEnabled(skillKey: String, enabled: Bool) async { + await self.withBusy(skillKey) { + do { + _ = try await GatewayConnection.shared.skillsUpdate( + skillKey: skillKey, + enabled: enabled) + self.statusMessage = enabled ? "Skill enabled" : "Skill disabled" + } catch { + self.statusMessage = error.localizedDescription + } + await self.refresh() + } + } + + func updateEnv(skillKey: String, envKey: String, value: String, isPrimary: Bool) async { + await self.withBusy(skillKey) { + do { + if isPrimary { + _ = try await GatewayConnection.shared.skillsUpdate( + skillKey: skillKey, + apiKey: value) + self.statusMessage = "Saved API key" + } else { + _ = try await GatewayConnection.shared.skillsUpdate( + skillKey: skillKey, + env: [envKey: value]) + self.statusMessage = "Saved \(envKey)" + } + } catch { + self.statusMessage = error.localizedDescription + } + await self.refresh() + } + } + + private func withBusy(_ id: String, _ work: @escaping () async -> Void) async { + self.busySkills.insert(id) + defer { self.busySkills.remove(id) } + await work() + } +} + +#if DEBUG +struct SkillsSettings_Previews: PreviewProvider { + static var previews: some View { + SkillsSettings(state: .preview) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} + +extension SkillsSettings { + static func exerciseForTesting() { + let skill = SkillStatus( + name: "Test Skill", + description: "Test description", + source: "openclaw-bundled", + filePath: "/tmp/skills/test", + baseDir: "/tmp/skills", + skillKey: "test", + primaryEnv: "API_KEY", + emoji: "🧪", + homepage: "https://example.com", + always: false, + disabled: false, + eligible: false, + requirements: SkillRequirements(bins: ["python3"], env: ["API_KEY"], config: ["skills.test"]), + missing: SkillMissing(bins: ["python3"], env: ["API_KEY"], config: ["skills.test"]), + configChecks: [ + SkillStatusConfigCheck(path: "skills.test", value: AnyCodable(false), satisfied: false), + ], + install: [ + SkillInstallOption(id: "brew", kind: "brew", label: "brew install python", bins: ["python3"]), + ]) + + let row = SkillRow( + skill: skill, + isBusy: false, + connectionMode: .remote, + onToggleEnabled: { _ in }, + onInstall: { _, _ in }, + onSetEnv: { _, _ in }) + _ = row.body + + _ = SkillTag(text: "Bundled").body + + let editor = EnvEditorView( + editor: EnvEditorState( + skillKey: "test", + skillName: "Test Skill", + envKey: "API_KEY", + isPrimary: true), + onSave: { _ in }) + _ = editor.body + } + + mutating func setFilterForTesting(_ rawValue: String) { + guard let filter = SkillsFilter(rawValue: rawValue) else { return } + self.filter = filter + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SoundEffects.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SoundEffects.swift new file mode 100644 index 00000000..37df8455 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SoundEffects.swift @@ -0,0 +1,109 @@ +import AppKit +import Foundation + +enum SoundEffectCatalog { + /// All discoverable system sound names, with "Glass" pinned first. + static var systemOptions: [String] { + var names = Set(Self.discoveredSoundMap.keys).union(Self.fallbackNames) + names.remove("Glass") + let sorted = names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + return ["Glass"] + sorted + } + + static func displayName(for raw: String) -> String { + raw + } + + static func url(for name: String) -> URL? { + self.discoveredSoundMap[name] + } + + // MARK: - Internals + + private static let allowedExtensions: Set = [ + "aif", "aiff", "caf", "wav", "m4a", "mp3", + ] + + private static let fallbackNames: [String] = [ + "Glass", // default + "Ping", + "Pop", + "Frog", + "Submarine", + "Funk", + "Tink", + "Basso", + "Blow", + "Bottle", + "Hero", + "Morse", + "Purr", + "Sosumi", + "Mail Sent", + "New Mail", + "Mail Scheduled", + "Mail Fetch Error", + ] + + private static let searchRoots: [URL] = [ + FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"), + URL(fileURLWithPath: "/Library/Sounds"), + URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh” + URL(fileURLWithPath: "/System/Library/Sounds"), + ] + + private static let discoveredSoundMap: [String: URL] = { + var map: [String: URL] = [:] + for root in Self.searchRoots { + guard let contents = try? FileManager().contentsOfDirectory( + at: root, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + else { continue } + + for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) { + let name = url.deletingPathExtension().lastPathComponent + // Preserve the first match in priority order. + if map[name] == nil { + map[name] = url + } + } + } + return map + }() +} + +@MainActor +enum SoundEffectPlayer { + private static var lastSound: NSSound? + + static func sound(named name: String) -> NSSound? { + if let named = NSSound(named: NSSound.Name(name)) { + return named + } + if let url = SoundEffectCatalog.url(for: name) { + return NSSound(contentsOf: url, byReference: false) + } + return nil + } + + static func sound(from bookmark: Data) -> NSSound? { + var stale = false + guard let url = try? URL( + resolvingBookmarkData: bookmark, + options: [.withoutUI, .withSecurityScope], + bookmarkDataIsStale: &stale) + else { return nil } + + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + return NSSound(contentsOf: url, byReference: false) + } + + static func play(_ sound: NSSound?) { + guard let sound else { return } + self.lastSound = sound + sound.stop() + sound.play() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/StatusPill.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/StatusPill.swift new file mode 100644 index 00000000..846ddd41 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/StatusPill.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct StatusPill: View { + let text: String + let tint: Color + + var body: some View { + Text(self.text) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .foregroundStyle(self.tint == .secondary ? .secondary : self.tint) + .background((self.tint == .secondary ? Color.secondary : self.tint).opacity(0.12)) + .clipShape(Capsule()) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/String+NonEmpty.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/String+NonEmpty.swift new file mode 100644 index 00000000..402e4c2d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/String+NonEmpty.swift @@ -0,0 +1,8 @@ +import Foundation + +extension String { + var nonEmpty: String? { + let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SystemPresenceInfo.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SystemPresenceInfo.swift new file mode 100644 index 00000000..843ed371 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SystemPresenceInfo.swift @@ -0,0 +1,16 @@ +import CoreGraphics +import Foundation +import OpenClawKit + +enum SystemPresenceInfo { + static func lastInputSeconds() -> Int? { + let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null + let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) + if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil } + return Int(seconds.rounded()) + } + + static func primaryIPv4Address() -> String? { + NetworkInterfaces.primaryIPv4Address() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift new file mode 100644 index 00000000..7c047e01 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift @@ -0,0 +1,449 @@ +import Foundation +import Observation +import SwiftUI + +struct SystemRunSettingsView: View { + @State private var model = ExecApprovalsSettingsModel() + @State private var tab: ExecApprovalsSettingsTab = .policy + @State private var newPattern: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 12) { + Text("Exec approvals") + .font(.body) + Spacer(minLength: 0) + Picker("Agent", selection: Binding( + get: { self.model.selectedAgentId }, + set: { self.model.selectAgent($0) })) + { + ForEach(self.model.agentPickerIds, id: \.self) { id in + Text(self.model.label(for: id)).tag(id) + } + } + .pickerStyle(.menu) + .frame(width: 180, alignment: .trailing) + } + + Picker("", selection: self.$tab) { + ForEach(ExecApprovalsSettingsTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .frame(width: 320) + + if self.tab == .policy { + self.policyView + } else { + self.allowlistView + } + } + .task { await self.model.refresh() } + .onChange(of: self.tab) { _, _ in + Task { await self.model.refreshSkillBins() } + } + } + + private var policyView: some View { + VStack(alignment: .leading, spacing: 8) { + Picker("", selection: Binding( + get: { self.model.security }, + set: { self.model.setSecurity($0) })) + { + ForEach(ExecSecurity.allCases) { security in + Text(security.title).tag(security) + } + } + .labelsHidden() + .pickerStyle(.menu) + + Picker("", selection: Binding( + get: { self.model.ask }, + set: { self.model.setAsk($0) })) + { + ForEach(ExecAsk.allCases) { ask in + Text(ask.title).tag(ask) + } + } + .labelsHidden() + .pickerStyle(.menu) + + Picker("", selection: Binding( + get: { self.model.askFallback }, + set: { self.model.setAskFallback($0) })) + { + ForEach(ExecSecurity.allCases) { mode in + Text("Fallback: \(mode.title)").tag(mode) + } + } + .labelsHidden() + .pickerStyle(.menu) + + Text(self.scopeMessage) + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var allowlistView: some View { + VStack(alignment: .leading, spacing: 10) { + Toggle("Auto-allow skill CLIs", isOn: Binding( + get: { self.model.autoAllowSkills }, + set: { self.model.setAutoAllowSkills($0) })) + + if self.model.autoAllowSkills, !self.model.skillBins.isEmpty { + Text("Skill CLIs: \(self.model.skillBins.joined(separator: ", "))") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if self.model.isDefaultsScope { + Text("Allowlists are per-agent. Select an agent to edit its allowlist.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + HStack(spacing: 8) { + TextField("Add allowlist path pattern (case-insensitive globs)", text: self.$newPattern) + .textFieldStyle(.roundedBorder) + Button("Add") { + if self.model.addEntry(self.newPattern) == nil { + self.newPattern = "" + } + } + .buttonStyle(.bordered) + .disabled(!self.model.isPathPattern(self.newPattern)) + } + + Text("Path patterns only. Basename entries like \"echo\" are ignored.") + .font(.footnote) + .foregroundStyle(.secondary) + if let validationMessage = self.model.allowlistValidationMessage { + Text(validationMessage) + .font(.footnote) + .foregroundStyle(.orange) + } + + if self.model.entries.isEmpty { + Text("No allowlisted commands yet.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(self.model.entries, id: \.id) { entry in + ExecAllowlistRow( + entry: Binding( + get: { self.model.entry(for: entry.id) ?? entry }, + set: { self.model.updateEntry($0, id: entry.id) }), + onRemove: { self.model.removeEntry(id: entry.id) }) + } + } + } + } + } + } + + private var scopeMessage: String { + if self.model.isDefaultsScope { + return "Defaults apply when an agent has no overrides. " + + "Ask controls prompt behavior; fallback is used when no companion UI is reachable." + } + return "Security controls whether system.run can execute on this Mac when paired as a node. " + + "Ask controls prompt behavior; fallback is used when no companion UI is reachable." + } +} + +private enum ExecApprovalsSettingsTab: String, CaseIterable, Identifiable { + case policy + case allowlist + + var id: String { + self.rawValue + } + + var title: String { + switch self { + case .policy: "Access" + case .allowlist: "Allowlist" + } + } +} + +struct ExecAllowlistRow: View { + @Binding var entry: ExecAllowlistEntry + let onRemove: () -> Void + @State private var draftPattern: String = "" + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter + }() + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + TextField("Pattern", text: self.patternBinding) + .textFieldStyle(.roundedBorder) + + Button(role: .destructive) { + self.onRemove() + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + + if let lastUsedAt = self.entry.lastUsedAt { + let date = Date(timeIntervalSince1970: lastUsedAt / 1000.0) + Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty { + Text("Last command: \(lastUsedCommand)") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let lastResolvedPath = self.entry.lastResolvedPath, !lastResolvedPath.isEmpty { + Text("Resolved path: \(lastResolvedPath)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .onAppear { + self.draftPattern = self.entry.pattern + } + } + + private var patternBinding: Binding { + Binding( + get: { self.draftPattern.isEmpty ? self.entry.pattern : self.draftPattern }, + set: { newValue in + self.draftPattern = newValue + self.entry.pattern = newValue + }) + } +} + +@MainActor +@Observable +final class ExecApprovalsSettingsModel { + private static let defaultsScopeId = "__defaults__" + var agentIds: [String] = [] + var selectedAgentId: String = "main" + var defaultAgentId: String = "main" + var security: ExecSecurity = .deny + var ask: ExecAsk = .onMiss + var askFallback: ExecSecurity = .deny + var autoAllowSkills = false + var entries: [ExecAllowlistEntry] = [] + var skillBins: [String] = [] + var allowlistValidationMessage: String? + + var agentPickerIds: [String] { + [Self.defaultsScopeId] + self.agentIds + } + + var isDefaultsScope: Bool { + self.selectedAgentId == Self.defaultsScopeId + } + + func label(for id: String) -> String { + if id == Self.defaultsScopeId { return "Defaults" } + return id + } + + func refresh() async { + await self.refreshAgents() + self.loadSettings(for: self.selectedAgentId) + await self.refreshSkillBins() + } + + func refreshAgents() async { + let root = await ConfigStore.load() + let agents = root["agents"] as? [String: Any] + let list = agents?["list"] as? [[String: Any]] ?? [] + var ids: [String] = [] + var seen = Set() + var defaultId: String? + for entry in list { + guard let raw = entry["id"] as? String else { continue } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + if !seen.insert(trimmed).inserted { continue } + ids.append(trimmed) + if (entry["default"] as? Bool) == true, defaultId == nil { + defaultId = trimmed + } + } + if ids.isEmpty { + ids = ["main"] + defaultId = "main" + } else if defaultId == nil { + defaultId = ids.first + } + self.agentIds = ids + self.defaultAgentId = defaultId ?? "main" + if self.selectedAgentId == Self.defaultsScopeId { + return + } + if !self.agentIds.contains(self.selectedAgentId) { + self.selectedAgentId = self.defaultAgentId + } + } + + func selectAgent(_ id: String) { + self.selectedAgentId = id + self.allowlistValidationMessage = nil + self.loadSettings(for: id) + Task { await self.refreshSkillBins() } + } + + func loadSettings(for agentId: String) { + if agentId == Self.defaultsScopeId { + let defaults = ExecApprovalsStore.resolveDefaults() + self.security = defaults.security + self.ask = defaults.ask + self.askFallback = defaults.askFallback + self.autoAllowSkills = defaults.autoAllowSkills + self.entries = [] + self.allowlistValidationMessage = nil + return + } + let resolved = ExecApprovalsStore.resolve(agentId: agentId) + self.security = resolved.agent.security + self.ask = resolved.agent.ask + self.askFallback = resolved.agent.askFallback + self.autoAllowSkills = resolved.agent.autoAllowSkills + self.entries = resolved.allowlist + .sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending } + self.allowlistValidationMessage = nil + } + + func setSecurity(_ security: ExecSecurity) { + self.security = security + if self.isDefaultsScope { + ExecApprovalsStore.updateDefaults { defaults in + defaults.security = security + } + } else { + ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in + entry.security = security + } + } + self.syncQuickMode() + } + + func setAsk(_ ask: ExecAsk) { + self.ask = ask + if self.isDefaultsScope { + ExecApprovalsStore.updateDefaults { defaults in + defaults.ask = ask + } + } else { + ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in + entry.ask = ask + } + } + self.syncQuickMode() + } + + func setAskFallback(_ mode: ExecSecurity) { + self.askFallback = mode + if self.isDefaultsScope { + ExecApprovalsStore.updateDefaults { defaults in + defaults.askFallback = mode + } + } else { + ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in + entry.askFallback = mode + } + } + } + + func setAutoAllowSkills(_ enabled: Bool) { + self.autoAllowSkills = enabled + if self.isDefaultsScope { + ExecApprovalsStore.updateDefaults { defaults in + defaults.autoAllowSkills = enabled + } + } else { + ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in + entry.autoAllowSkills = enabled + } + } + Task { await self.refreshSkillBins(force: enabled) } + } + + @discardableResult + func addEntry(_ pattern: String) -> ExecAllowlistPatternValidationReason? { + guard !self.isDefaultsScope else { return nil } + switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { + case let .valid(normalizedPattern): + self.entries.append(ExecAllowlistEntry(pattern: normalizedPattern, lastUsedAt: nil)) + let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) + self.allowlistValidationMessage = rejected.first?.reason.message + return rejected.first?.reason + case let .invalid(reason): + self.allowlistValidationMessage = reason.message + return reason + } + } + + @discardableResult + func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) -> ExecAllowlistPatternValidationReason? { + guard !self.isDefaultsScope else { return nil } + guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return nil } + var next = entry + switch ExecApprovalHelpers.validateAllowlistPattern(next.pattern) { + case let .valid(normalizedPattern): + next.pattern = normalizedPattern + case let .invalid(reason): + self.allowlistValidationMessage = reason.message + return reason + } + self.entries[index] = next + let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) + self.allowlistValidationMessage = rejected.first?.reason.message + return rejected.first?.reason + } + + func removeEntry(id: UUID) { + guard !self.isDefaultsScope else { return } + guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return } + self.entries.remove(at: index) + let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) + self.allowlistValidationMessage = rejected.first?.reason.message + } + + func entry(for id: UUID) -> ExecAllowlistEntry? { + self.entries.first(where: { $0.id == id }) + } + + func isPathPattern(_ pattern: String) -> Bool { + ExecApprovalHelpers.isPathPattern(pattern) + } + + func refreshSkillBins(force: Bool = false) async { + guard self.autoAllowSkills else { + self.skillBins = [] + return + } + let bins = await SkillBinsCache.shared.currentBins(force: force) + self.skillBins = bins.sorted() + } + + private func syncQuickMode() { + if self.isDefaultsScope { + AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask) + return + } + if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 { + AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift new file mode 100644 index 00000000..c9354d38 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift @@ -0,0 +1,401 @@ +import SwiftUI + +private enum GatewayTailscaleMode: String, CaseIterable, Identifiable { + case off + case serve + case funnel + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .off: "Off" + case .serve: "Tailnet (Serve)" + case .funnel: "Public (Funnel)" + } + } + + var description: String { + switch self { + case .off: + "No automatic Tailscale configuration." + case .serve: + "Tailnet-only HTTPS via Tailscale Serve." + case .funnel: + "Public HTTPS via Tailscale Funnel (requires auth)." + } + } +} + +struct TailscaleIntegrationSection: View { + let connectionMode: AppState.ConnectionMode + let isPaused: Bool + + @Environment(TailscaleService.self) private var tailscaleService + #if DEBUG + private var testingService: TailscaleService? + #endif + + @State private var hasLoaded = false + @State private var tailscaleMode: GatewayTailscaleMode = .serve + @State private var requireCredentialsForServe = false + @State private var password: String = "" + @State private var statusMessage: String? + @State private var validationMessage: String? + @State private var statusTimer: Timer? + + init(connectionMode: AppState.ConnectionMode, isPaused: Bool) { + self.connectionMode = connectionMode + self.isPaused = isPaused + #if DEBUG + self.testingService = nil + #endif + } + + private var effectiveService: TailscaleService { + #if DEBUG + return self.testingService ?? self.tailscaleService + #else + return self.tailscaleService + #endif + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Tailscale (dashboard access)") + .font(.callout.weight(.semibold)) + + self.statusRow + + if !self.effectiveService.isInstalled { + self.installButtons + } else { + self.modePicker + if self.tailscaleMode != .off { + self.accessURLRow + } + if self.tailscaleMode == .serve { + self.serveAuthSection + } + if self.tailscaleMode == .funnel { + self.funnelAuthSection + } + } + + if self.connectionMode != .local { + Text("Local mode required. Update settings on the gateway host.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let validationMessage { + Text(validationMessage) + .font(.caption) + .foregroundStyle(.orange) + } else if let statusMessage { + Text(statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background(Color.gray.opacity(0.08)) + .cornerRadius(10) + .disabled(self.connectionMode != .local) + .task { + guard !self.hasLoaded else { return } + await self.loadConfig() + self.hasLoaded = true + await self.effectiveService.checkTailscaleStatus() + self.startStatusTimer() + } + .onDisappear { + self.stopStatusTimer() + } + .onChange(of: self.tailscaleMode) { _, _ in + Task { await self.applySettings() } + } + .onChange(of: self.requireCredentialsForServe) { _, _ in + Task { await self.applySettings() } + } + } + + private var statusRow: some View { + HStack(spacing: 8) { + Circle() + .fill(self.statusColor) + .frame(width: 10, height: 10) + Text(self.statusText) + .font(.callout) + Spacer() + Button("Refresh") { + Task { await self.effectiveService.checkTailscaleStatus() } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + private var statusColor: Color { + if !self.effectiveService.isInstalled { return .yellow } + if self.effectiveService.isRunning { return .green } + return .orange + } + + private var statusText: String { + if !self.effectiveService.isInstalled { return "Tailscale is not installed" } + if self.effectiveService.isRunning { return "Tailscale is installed and running" } + return "Tailscale is installed but not running" + } + + private var installButtons: some View { + HStack(spacing: 12) { + Button("App Store") { self.effectiveService.openAppStore() } + .buttonStyle(.link) + Button("Direct Download") { self.effectiveService.openDownloadPage() } + .buttonStyle(.link) + Button("Setup Guide") { self.effectiveService.openSetupGuide() } + .buttonStyle(.link) + } + .controlSize(.small) + } + + private var modePicker: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Exposure mode") + .font(.callout.weight(.semibold)) + Picker("Exposure", selection: self.$tailscaleMode) { + ForEach(GatewayTailscaleMode.allCases) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.segmented) + Text(self.tailscaleMode.description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var accessURLRow: some View { + if let host = self.effectiveService.tailscaleHostname { + let url = "https://\(host)/ui/" + HStack(spacing: 8) { + Text("Dashboard URL:") + .font(.caption) + .foregroundStyle(.secondary) + if let link = URL(string: url) { + Link(url, destination: link) + .font(.system(.caption, design: .monospaced)) + } else { + Text(url) + .font(.system(.caption, design: .monospaced)) + } + } + } else if !self.effectiveService.isRunning { + Text("Start Tailscale to get your tailnet hostname.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if self.effectiveService.isInstalled, !self.effectiveService.isRunning { + Button("Start Tailscale") { self.effectiveService.openTailscaleApp() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + + private var serveAuthSection: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle("Require credentials", isOn: self.$requireCredentialsForServe) + .toggleStyle(.checkbox) + if self.requireCredentialsForServe { + self.authFields + } else { + Text("Serve uses Tailscale identity headers; no password required.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var funnelAuthSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Funnel requires authentication.") + .font(.caption) + .foregroundStyle(.secondary) + self.authFields + } + } + + @ViewBuilder + private var authFields: some View { + SecureField("Password", text: self.$password) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 240) + .onSubmit { Task { await self.applySettings() } } + Text("Stored in ~/.openclaw/openclaw.json. Prefer OPENCLAW_GATEWAY_PASSWORD for production.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Update password") { Task { await self.applySettings() } } + .buttonStyle(.bordered) + .controlSize(.small) + } + + private func loadConfig() async { + let root = await ConfigStore.load() + let gateway = root["gateway"] as? [String: Any] ?? [:] + let tailscale = gateway["tailscale"] as? [String: Any] ?? [:] + let modeRaw = (tailscale["mode"] as? String) ?? "serve" + self.tailscaleMode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off + + let auth = gateway["auth"] as? [String: Any] ?? [:] + let authModeRaw = auth["mode"] as? String + let allowTailscale = auth["allowTailscale"] as? Bool + + self.password = auth["password"] as? String ?? "" + + if self.tailscaleMode == .serve { + let usesExplicitAuth = authModeRaw == "password" + if let allowTailscale, allowTailscale == false { + self.requireCredentialsForServe = true + } else { + self.requireCredentialsForServe = usesExplicitAuth + } + } else { + self.requireCredentialsForServe = false + } + } + + private func applySettings() async { + guard self.hasLoaded else { return } + self.validationMessage = nil + self.statusMessage = nil + + let trimmedPassword = self.password.trimmingCharacters(in: .whitespacesAndNewlines) + let requiresPassword = self.tailscaleMode == .funnel + || (self.tailscaleMode == .serve && self.requireCredentialsForServe) + if requiresPassword, trimmedPassword.isEmpty { + self.validationMessage = "Password required for this mode." + return + } + + let (success, errorMessage) = await TailscaleIntegrationSection.buildAndSaveTailscaleConfig( + tailscaleMode: self.tailscaleMode, + requireCredentialsForServe: self.requireCredentialsForServe, + password: trimmedPassword, + connectionMode: self.connectionMode, + isPaused: self.isPaused) + + if !success, let errorMessage { + self.statusMessage = errorMessage + return + } + + if self.connectionMode == .local, !self.isPaused { + self.statusMessage = "Saved to ~/.openclaw/openclaw.json. Restarting gateway…" + } else { + self.statusMessage = "Saved to ~/.openclaw/openclaw.json. Restart the gateway to apply." + } + self.restartGatewayIfNeeded() + } + + @MainActor + private static func buildAndSaveTailscaleConfig( + tailscaleMode: GatewayTailscaleMode, + requireCredentialsForServe: Bool, + password: String, + connectionMode: AppState.ConnectionMode, + isPaused: Bool) async -> (Bool, String?) + { + var root = await ConfigStore.load() + var gateway = root["gateway"] as? [String: Any] ?? [:] + var tailscale = gateway["tailscale"] as? [String: Any] ?? [:] + tailscale["mode"] = tailscaleMode.rawValue + gateway["tailscale"] = tailscale + + if tailscaleMode != .off { + gateway["bind"] = "loopback" + } + + if tailscaleMode == .off { + gateway.removeValue(forKey: "auth") + } else { + var auth = gateway["auth"] as? [String: Any] ?? [:] + if tailscaleMode == .serve, !requireCredentialsForServe { + auth["allowTailscale"] = true + auth.removeValue(forKey: "mode") + auth.removeValue(forKey: "password") + } else { + auth["allowTailscale"] = false + auth["mode"] = "password" + auth["password"] = password + } + + if auth.isEmpty { + gateway.removeValue(forKey: "auth") + } else { + gateway["auth"] = auth + } + } + + if gateway.isEmpty { + root.removeValue(forKey: "gateway") + } else { + root["gateway"] = gateway + } + + do { + try await ConfigStore.save(root) + return (true, nil) + } catch { + return (false, error.localizedDescription) + } + } + + private func restartGatewayIfNeeded() { + guard self.connectionMode == .local, !self.isPaused else { return } + Task { await GatewayLaunchAgentManager.kickstart() } + } + + private func startStatusTimer() { + self.stopStatusTimer() + if ProcessInfo.processInfo.isRunningTests { + return + } + self.statusTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in + Task { await self.effectiveService.checkTailscaleStatus() } + } + } + + private func stopStatusTimer() { + self.statusTimer?.invalidate() + self.statusTimer = nil + } +} + +#if DEBUG +extension TailscaleIntegrationSection { + mutating func setTestingState( + mode: String, + requireCredentials: Bool, + password: String = "secret", + statusMessage: String? = nil, + validationMessage: String? = nil) + { + if let mode = GatewayTailscaleMode(rawValue: mode) { + self.tailscaleMode = mode + } + self.requireCredentialsForServe = requireCredentials + self.password = password + self.statusMessage = statusMessage + self.validationMessage = validationMessage + } + + mutating func setTestingService(_ service: TailscaleService?) { + self.testingService = service + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TailscaleService.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TailscaleService.swift new file mode 100644 index 00000000..2cefa69d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TailscaleService.swift @@ -0,0 +1,182 @@ +import AppKit +import Foundation +import Observation +import OpenClawDiscovery +import os + +/// Manages Tailscale integration and status checking. +@Observable +@MainActor +final class TailscaleService { + static let shared = TailscaleService() + + /// Tailscale local API endpoint. + private static let tailscaleAPIEndpoint = "http://100.100.100.100/api/data" + + /// API request timeout in seconds. + private static let apiTimeoutInterval: TimeInterval = 5.0 + + private let logger = Logger(subsystem: "ai.openclaw", category: "tailscale") + + /// Indicates if the Tailscale app is installed on the system. + private(set) var isInstalled = false + + /// Indicates if Tailscale is currently running. + private(set) var isRunning = false + + /// The Tailscale hostname for this device (e.g., "my-mac.tailnet.ts.net"). + private(set) var tailscaleHostname: String? + + /// The Tailscale IPv4 address for this device. + private(set) var tailscaleIP: String? + + /// Error message if status check fails. + private(set) var statusError: String? + + private init() { + Task { await self.checkTailscaleStatus() } + } + + #if DEBUG + init( + isInstalled: Bool, + isRunning: Bool, + tailscaleHostname: String? = nil, + tailscaleIP: String? = nil, + statusError: String? = nil) + { + self.isInstalled = isInstalled + self.isRunning = isRunning + self.tailscaleHostname = tailscaleHostname + self.tailscaleIP = tailscaleIP + self.statusError = statusError + } + #endif + + func checkAppInstallation() -> Bool { + let installed = FileManager().fileExists(atPath: "/Applications/Tailscale.app") + self.logger.info("Tailscale app installed: \(installed)") + return installed + } + + private struct TailscaleAPIResponse: Codable { + let status: String + let deviceName: String + let tailnetName: String + let iPv4: String? + + private enum CodingKeys: String, CodingKey { + case status = "Status" + case deviceName = "DeviceName" + case tailnetName = "TailnetName" + case iPv4 = "IPv4" + } + } + + private func fetchTailscaleStatus() async -> TailscaleAPIResponse? { + guard let url = URL(string: Self.tailscaleAPIEndpoint) else { + self.logger.error("Invalid Tailscale API URL") + return nil + } + + do { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = Self.apiTimeoutInterval + let session = URLSession(configuration: configuration) + + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { + self.logger.warning("Tailscale API returned non-200 status") + return nil + } + + let decoder = JSONDecoder() + return try decoder.decode(TailscaleAPIResponse.self, from: data) + } catch { + self.logger.debug("Failed to fetch Tailscale status: \(String(describing: error))") + return nil + } + } + + func checkTailscaleStatus() async { + let previousIP = self.tailscaleIP + self.isInstalled = self.checkAppInstallation() + if !self.isInstalled { + self.isRunning = false + self.tailscaleHostname = nil + self.tailscaleIP = nil + self.statusError = "Tailscale is not installed" + } else if let apiResponse = await fetchTailscaleStatus() { + self.isRunning = apiResponse.status.lowercased() == "running" + + if self.isRunning { + let deviceName = apiResponse.deviceName + .lowercased() + .replacingOccurrences(of: " ", with: "-") + let tailnetName = apiResponse.tailnetName + .replacingOccurrences(of: ".ts.net", with: "") + .replacingOccurrences(of: ".tailscale.net", with: "") + + self.tailscaleHostname = "\(deviceName).\(tailnetName).ts.net" + self.tailscaleIP = apiResponse.iPv4 + self.statusError = nil + + self.logger.info( + "Tailscale running host=\(self.tailscaleHostname ?? "nil") ip=\(self.tailscaleIP ?? "nil")") + } else { + self.tailscaleHostname = nil + self.tailscaleIP = nil + self.statusError = "Tailscale is not running" + } + } else { + self.isRunning = false + self.tailscaleHostname = nil + self.tailscaleIP = nil + self.statusError = "Please start the Tailscale app" + self.logger.info("Tailscale API not responding; app likely not running") + } + + if self.tailscaleIP == nil, let fallback = TailscaleNetwork.detectTailnetIPv4() { + self.tailscaleIP = fallback + if !self.isRunning { + self.isRunning = true + } + self.statusError = nil + self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)") + } + + if previousIP != self.tailscaleIP { + await GatewayEndpointStore.shared.refresh() + } + } + + func openTailscaleApp() { + if let url = URL(string: "file:///Applications/Tailscale.app") { + NSWorkspace.shared.open(url) + } + } + + func openAppStore() { + if let url = URL(string: "https://apps.apple.com/us/app/tailscale/id1475387142") { + NSWorkspace.shared.open(url) + } + } + + func openDownloadPage() { + if let url = URL(string: "https://tailscale.com/download/macos") { + NSWorkspace.shared.open(url) + } + } + + func openSetupGuide() { + if let url = URL(string: "https://tailscale.com/kb/1017/install/") { + NSWorkspace.shared.open(url) + } + } + + nonisolated static func fallbackTailnetIPv4() -> String? { + TailscaleNetwork.detectTailnetIPv4() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift new file mode 100644 index 00000000..ae9a0645 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift @@ -0,0 +1,158 @@ +import AVFoundation +import Foundation +import OSLog + +@MainActor +final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate { + static let shared = TalkAudioPlayer() + + private let logger = Logger(subsystem: "ai.openclaw", category: "talk.tts") + private var player: AVAudioPlayer? + private var playback: Playback? + + private final class Playback: @unchecked Sendable { + private let lock = NSLock() + private var finished = false + private var continuation: CheckedContinuation? + private var watchdog: Task? + + func setContinuation(_ continuation: CheckedContinuation) { + self.lock.lock() + defer { self.lock.unlock() } + self.continuation = continuation + } + + func setWatchdog(_ task: Task?) { + self.lock.lock() + let old = self.watchdog + self.watchdog = task + self.lock.unlock() + old?.cancel() + } + + func cancelWatchdog() { + self.setWatchdog(nil) + } + + func finish(_ result: TalkPlaybackResult) { + let continuation: CheckedContinuation? + self.lock.lock() + if self.finished { + continuation = nil + } else { + self.finished = true + continuation = self.continuation + self.continuation = nil + } + self.lock.unlock() + continuation?.resume(returning: result) + } + } + + func play(data: Data) async -> TalkPlaybackResult { + self.stopInternal() + + let playback = Playback() + self.playback = playback + + return await withCheckedContinuation { continuation in + playback.setContinuation(continuation) + do { + let player = try AVAudioPlayer(data: data) + self.player = player + + player.delegate = self + player.prepareToPlay() + + self.armWatchdog(playback: playback) + + let ok = player.play() + if !ok { + self.logger.error("talk audio player refused to play") + self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) + } + } catch { + self.logger.error("talk audio player failed: \(error.localizedDescription, privacy: .public)") + self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) + } + } + } + + func stop() -> Double? { + guard let player else { return nil } + let time = player.currentTime + self.stopInternal(interruptedAt: time) + return time + } + + func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully flag: Bool) { + self.stopInternal(finished: flag) + } + + private func stopInternal(finished: Bool = false, interruptedAt: Double? = nil) { + guard let playback else { return } + let result = TalkPlaybackResult(finished: finished, interruptedAt: interruptedAt) + self.finish(playback: playback, result: result) + } + + private func finish(playback: Playback, result: TalkPlaybackResult) { + playback.cancelWatchdog() + playback.finish(result) + + guard self.playback === playback else { return } + self.playback = nil + self.player?.stop() + self.player = nil + } + + private func stopInternal() { + if let playback = self.playback { + let interruptedAt = self.player?.currentTime + self.finish( + playback: playback, + result: TalkPlaybackResult(finished: false, interruptedAt: interruptedAt)) + return + } + self.player?.stop() + self.player = nil + } + + private func armWatchdog(playback: Playback) { + playback.setWatchdog(Task { @MainActor [weak self] in + guard let self else { return } + + do { + try await Task.sleep(nanoseconds: 650_000_000) + } catch { + return + } + if Task.isCancelled { return } + + guard self.playback === playback else { return } + if self.player?.isPlaying != true { + self.logger.error("talk audio player did not start playing") + self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) + return + } + + let duration = self.player?.duration ?? 0 + let timeoutSeconds = min(max(2.0, duration + 2.0), 5 * 60.0) + do { + try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) + } catch { + return + } + if Task.isCancelled { return } + + guard self.playback === playback else { return } + guard self.player?.isPlaying == true else { return } + self.logger.error("talk audio player watchdog fired") + self.finish(playback: playback, result: TalkPlaybackResult(finished: false, interruptedAt: nil)) + }) + } +} + +struct TalkPlaybackResult: Sendable { + let finished: Bool + let interruptedAt: Double? +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkModeController.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkModeController.swift new file mode 100644 index 00000000..8454e503 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkModeController.swift @@ -0,0 +1,69 @@ +import Observation + +@MainActor +@Observable +final class TalkModeController { + static let shared = TalkModeController() + + private let logger = Logger(subsystem: "ai.openclaw", category: "talk.controller") + + private(set) var phase: TalkModePhase = .idle + private(set) var isPaused: Bool = false + + func setEnabled(_ enabled: Bool) async { + self.logger.info("talk enabled=\(enabled)") + if enabled { + TalkOverlayController.shared.present() + } else { + TalkOverlayController.shared.dismiss() + } + await TalkModeRuntime.shared.setEnabled(enabled) + } + + func updatePhase(_ phase: TalkModePhase) { + self.phase = phase + TalkOverlayController.shared.updatePhase(phase) + let effectivePhase = self.isPaused ? "paused" : phase.rawValue + Task { + await GatewayConnection.shared.talkMode( + enabled: AppStateStore.shared.talkEnabled, + phase: effectivePhase) + } + } + + func updateLevel(_ level: Double) { + TalkOverlayController.shared.updateLevel(level) + } + + func setPaused(_ paused: Bool) { + guard self.isPaused != paused else { return } + self.logger.info("talk paused=\(paused)") + self.isPaused = paused + TalkOverlayController.shared.updatePaused(paused) + let effectivePhase = paused ? "paused" : self.phase.rawValue + Task { + await GatewayConnection.shared.talkMode( + enabled: AppStateStore.shared.talkEnabled, + phase: effectivePhase) + } + Task { await TalkModeRuntime.shared.setPaused(paused) } + } + + func togglePaused() { + self.setPaused(!self.isPaused) + } + + func stopSpeaking(reason: TalkStopReason = .userTap) { + Task { await TalkModeRuntime.shared.stopSpeaking(reason: reason) } + } + + func exitTalkMode() { + Task { await AppStateStore.shared.setTalkEnabled(false) } + } +} + +enum TalkStopReason { + case userTap + case speech + case manual +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift new file mode 100644 index 00000000..a8d8008c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -0,0 +1,1051 @@ +import AVFoundation +import Foundation +import OpenClawChatUI +import OpenClawKit +import OSLog +import Speech + +actor TalkModeRuntime { + static let shared = TalkModeRuntime() + + private let logger = Logger(subsystem: "ai.openclaw", category: "talk.runtime") + private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts") + private static let defaultModelIdFallback = "eleven_v3" + private static let defaultTalkProvider = "elevenlabs" + + private final class RMSMeter: @unchecked Sendable { + private let lock = NSLock() + private var latestRMS: Double = 0 + + func set(_ rms: Double) { + self.lock.lock() + self.latestRMS = rms + self.lock.unlock() + } + + func get() -> Double { + self.lock.lock() + let value = self.latestRMS + self.lock.unlock() + return value + } + } + + private var recognizer: SFSpeechRecognizer? + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var recognitionGeneration: Int = 0 + private var rmsTask: Task? + private let rmsMeter = RMSMeter() + + private var captureTask: Task? + private var silenceTask: Task? + private var phase: TalkModePhase = .idle + private var isEnabled = false + private var isPaused = false + private var lifecycleGeneration: Int = 0 + + private var lastHeard: Date? + private var noiseFloorRMS: Double = 1e-4 + private var lastTranscript: String = "" + private var lastSpeechEnergyAt: Date? + + private var defaultVoiceId: String? + private var currentVoiceId: String? + private var defaultModelId: String? + private var currentModelId: String? + private var voiceOverrideActive = false + private var modelOverrideActive = false + private var defaultOutputFormat: String? + private var interruptOnSpeech: Bool = true + private var lastInterruptedAtSeconds: Double? + private var voiceAliases: [String: String] = [:] + private var lastSpokenText: String? + private var apiKey: String? + private var fallbackVoiceId: String? + private var lastPlaybackWasPCM: Bool = false + + private let silenceWindow: TimeInterval = 0.7 + private let minSpeechRMS: Double = 1e-3 + private let speechBoostFactor: Double = 6.0 + + // MARK: - Lifecycle + + func setEnabled(_ enabled: Bool) async { + guard enabled != self.isEnabled else { return } + self.isEnabled = enabled + self.lifecycleGeneration &+= 1 + if enabled { + await self.start() + } else { + await self.stop() + } + } + + func setPaused(_ paused: Bool) async { + guard paused != self.isPaused else { return } + self.isPaused = paused + await MainActor.run { TalkModeController.shared.updateLevel(0) } + + guard self.isEnabled else { return } + + if paused { + self.lastTranscript = "" + self.lastHeard = nil + self.lastSpeechEnergyAt = nil + await self.stopRecognition() + return + } + + if self.phase == .idle || self.phase == .listening { + await self.startRecognition() + self.phase = .listening + await MainActor.run { TalkModeController.shared.updatePhase(.listening) } + self.startSilenceMonitor() + } + } + + private func isCurrent(_ generation: Int) -> Bool { + generation == self.lifecycleGeneration && self.isEnabled + } + + private func start() async { + let gen = self.lifecycleGeneration + guard voiceWakeSupported else { return } + guard PermissionManager.voiceWakePermissionsGranted() else { + self.logger.debug("talk runtime not starting: permissions missing") + return + } + await self.reloadConfig() + guard self.isCurrent(gen) else { return } + if self.isPaused { + self.phase = .idle + await MainActor.run { + TalkModeController.shared.updateLevel(0) + TalkModeController.shared.updatePhase(.idle) + } + return + } + await self.startRecognition() + guard self.isCurrent(gen) else { return } + self.phase = .listening + await MainActor.run { TalkModeController.shared.updatePhase(.listening) } + self.startSilenceMonitor() + } + + private func stop() async { + self.captureTask?.cancel() + self.captureTask = nil + self.silenceTask?.cancel() + self.silenceTask = nil + + // Stop audio before changing phase (stopSpeaking is gated on .speaking). + await self.stopSpeaking(reason: .manual) + + self.lastTranscript = "" + self.lastHeard = nil + self.lastSpeechEnergyAt = nil + self.phase = .idle + await self.stopRecognition() + await MainActor.run { + TalkModeController.shared.updateLevel(0) + TalkModeController.shared.updatePhase(.idle) + } + } + + // MARK: - Speech recognition + + private struct RecognitionUpdate { + let transcript: String? + let hasConfidence: Bool + let isFinal: Bool + let errorDescription: String? + let generation: Int + } + + private func startRecognition() async { + await self.stopRecognition() + self.recognitionGeneration &+= 1 + let generation = self.recognitionGeneration + + let locale = await MainActor.run { AppStateStore.shared.voiceWakeLocaleID } + self.recognizer = SFSpeechRecognizer(locale: Locale(identifier: locale)) + guard let recognizer, recognizer.isAvailable else { + self.logger.error("talk recognizer unavailable") + return + } + + self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + self.recognitionRequest?.shouldReportPartialResults = true + guard let request = self.recognitionRequest else { return } + + if self.audioEngine == nil { + self.audioEngine = AVAudioEngine() + } + guard let audioEngine = self.audioEngine else { return } + + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + self.logger.error("talk mode: no usable audio input device") + return + } + + let input = audioEngine.inputNode + let format = input.outputFormat(forBus: 0) + input.removeTap(onBus: 0) + let meter = self.rmsMeter + input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request, meter] buffer, _ in + request?.append(buffer) + if let rms = Self.rmsLevel(buffer: buffer) { + meter.set(rms) + } + } + + audioEngine.prepare() + do { + try audioEngine.start() + } catch { + self.logger.error("talk audio engine start failed: \(error.localizedDescription, privacy: .public)") + return + } + + self.startRMSTicker(meter: meter) + + self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in + guard let self else { return } + let segments = result?.bestTranscription.segments ?? [] + let transcript = result?.bestTranscription.formattedString + let update = RecognitionUpdate( + transcript: transcript, + hasConfidence: segments.contains { $0.confidence > 0.6 }, + isFinal: result?.isFinal ?? false, + errorDescription: error?.localizedDescription, + generation: generation) + Task { await self.handleRecognition(update) } + } + } + + private func stopRecognition() async { + self.recognitionGeneration &+= 1 + self.recognitionTask?.cancel() + self.recognitionTask = nil + self.recognitionRequest?.endAudio() + self.recognitionRequest = nil + self.audioEngine?.inputNode.removeTap(onBus: 0) + self.audioEngine?.stop() + self.audioEngine = nil + self.recognizer = nil + self.rmsTask?.cancel() + self.rmsTask = nil + } + + private func startRMSTicker(meter: RMSMeter) { + self.rmsTask?.cancel() + self.rmsTask = Task { [weak self, meter] in + while let self { + try? await Task.sleep(nanoseconds: 50_000_000) + if Task.isCancelled { return } + await self.noteAudioLevel(rms: meter.get()) + } + } + } + + private func handleRecognition(_ update: RecognitionUpdate) async { + guard update.generation == self.recognitionGeneration else { return } + guard !self.isPaused else { return } + if let errorDescription = update.errorDescription { + self.logger.debug("talk recognition error: \(errorDescription, privacy: .public)") + } + guard let transcript = update.transcript else { return } + + let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + if self.phase == .speaking, self.interruptOnSpeech { + if await self.shouldInterrupt(transcript: trimmed, hasConfidence: update.hasConfidence) { + await self.stopSpeaking(reason: .speech) + self.lastTranscript = "" + self.lastHeard = nil + await self.startListening() + } + return + } + + guard self.phase == .listening else { return } + + if !trimmed.isEmpty { + self.lastTranscript = trimmed + self.lastHeard = Date() + } + + if update.isFinal { + self.lastTranscript = trimmed + } + } + + // MARK: - Silence handling + + private func startSilenceMonitor() { + self.silenceTask?.cancel() + self.silenceTask = Task { [weak self] in + await self?.silenceLoop() + } + } + + private func silenceLoop() async { + while self.isEnabled { + try? await Task.sleep(nanoseconds: 200_000_000) + await self.checkSilence() + } + } + + private func checkSilence() async { + guard !self.isPaused else { return } + guard self.phase == .listening else { return } + let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + guard !transcript.isEmpty else { return } + guard let lastHeard else { return } + let elapsed = Date().timeIntervalSince(lastHeard) + guard elapsed >= self.silenceWindow else { return } + await self.finalizeTranscript(transcript) + } + + private func startListening() async { + self.phase = .listening + self.lastTranscript = "" + self.lastHeard = nil + await MainActor.run { + TalkModeController.shared.updatePhase(.listening) + TalkModeController.shared.updateLevel(0) + } + } + + private func finalizeTranscript(_ text: String) async { + self.lastTranscript = "" + self.lastHeard = nil + self.phase = .thinking + await MainActor.run { TalkModeController.shared.updatePhase(.thinking) } + await self.stopRecognition() + await self.sendAndSpeak(text) + } + + // MARK: - Gateway + TTS + + private func sendAndSpeak(_ transcript: String) async { + let gen = self.lifecycleGeneration + await self.reloadConfig() + guard self.isCurrent(gen) else { return } + let prompt = self.buildPrompt(transcript: transcript) + let activeSessionKey = await MainActor.run { WebChatManager.shared.activeSessionKey } + let sessionKey: String = if let activeSessionKey { + activeSessionKey + } else { + await GatewayConnection.shared.mainSessionKey() + } + let runId = UUID().uuidString + let startedAt = Date().timeIntervalSince1970 + self.logger.info( + "talk send start runId=\(runId, privacy: .public) " + + "session=\(sessionKey, privacy: .public) " + + "chars=\(prompt.count, privacy: .public)") + + do { + let response = try await GatewayConnection.shared.chatSend( + sessionKey: sessionKey, + message: prompt, + thinking: "low", + idempotencyKey: runId, + attachments: []) + guard self.isCurrent(gen) else { return } + self.logger.info( + "talk chat.send ok runId=\(response.runId, privacy: .public) " + + "session=\(sessionKey, privacy: .public)") + + guard let assistantText = await self.waitForAssistantText( + sessionKey: sessionKey, + since: startedAt, + timeoutSeconds: 45) + else { + self.logger.warning("talk assistant text missing after timeout") + await self.startListening() + await self.startRecognition() + return + } + guard self.isCurrent(gen) else { return } + + self.logger.info("talk assistant text len=\(assistantText.count, privacy: .public)") + await self.playAssistant(text: assistantText) + guard self.isCurrent(gen) else { return } + await self.resumeListeningIfNeeded() + return + } catch { + self.logger.error("talk chat.send failed: \(error.localizedDescription, privacy: .public)") + await self.resumeListeningIfNeeded() + return + } + } + + private func resumeListeningIfNeeded() async { + if self.isPaused { + self.lastTranscript = "" + self.lastHeard = nil + self.lastSpeechEnergyAt = nil + await MainActor.run { + TalkModeController.shared.updateLevel(0) + } + return + } + await self.startListening() + await self.startRecognition() + } + + private func buildPrompt(transcript: String) -> String { + let interrupted = self.lastInterruptedAtSeconds + self.lastInterruptedAtSeconds = nil + return TalkPromptBuilder.build(transcript: transcript, interruptedAtSeconds: interrupted) + } + + private func waitForAssistantText( + sessionKey: String, + since: Double, + timeoutSeconds: Int) async -> String? + { + let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) + while Date() < deadline { + if let text = await self.latestAssistantText(sessionKey: sessionKey, since: since) { + return text + } + try? await Task.sleep(nanoseconds: 300_000_000) + } + return nil + } + + private func latestAssistantText(sessionKey: String, since: Double? = nil) async -> String? { + do { + let history = try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey) + let messages = history.messages ?? [] + let decoded: [OpenClawChatMessage] = messages.compactMap { item in + guard let data = try? JSONEncoder().encode(item) else { return nil } + return try? JSONDecoder().decode(OpenClawChatMessage.self, from: data) + } + let assistant = decoded.last { message in + guard message.role == "assistant" else { return false } + guard let since else { return true } + guard let timestamp = message.timestamp else { return false } + return TalkHistoryTimestamp.isAfter(timestamp, sinceSeconds: since) + } + guard let assistant else { return nil } + let text = assistant.content.compactMap(\.text).joined(separator: "\n") + let trimmed = text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } catch { + self.logger.error("talk history fetch failed: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + private func playAssistant(text: String) async { + guard let input = await self.preparePlaybackInput(text: text) else { return } + do { + if let apiKey = input.apiKey, !apiKey.isEmpty, let voiceId = input.voiceId { + try await self.playElevenLabs(input: input, apiKey: apiKey, voiceId: voiceId) + } else { + try await self.playSystemVoice(input: input) + } + } catch { + self.ttsLogger + .error( + "talk TTS failed: \(error.localizedDescription, privacy: .public); " + + "falling back to system voice") + do { + try await self.playSystemVoice(input: input) + } catch { + self.ttsLogger.error("talk system voice failed: \(error.localizedDescription, privacy: .public)") + } + } + + if self.phase == .speaking { + self.phase = .thinking + await MainActor.run { TalkModeController.shared.updatePhase(.thinking) } + } + } + + private struct TalkPlaybackInput { + let generation: Int + let cleanedText: String + let directive: TalkDirective? + let apiKey: String? + let voiceId: String? + let language: String? + let synthTimeoutSeconds: Double + } + + private func preparePlaybackInput(text: String) async -> TalkPlaybackInput? { + let gen = self.lifecycleGeneration + let parse = TalkDirectiveParser.parse(text) + let directive = parse.directive + let cleaned = parse.stripped.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return nil } + guard self.isCurrent(gen) else { return nil } + + if !parse.unknownKeys.isEmpty { + self.logger + .warning( + "talk directive ignored keys: " + + "\(parse.unknownKeys.joined(separator: ","), privacy: .public)") + } + + let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedVoice = self.resolveVoiceAlias(requestedVoice) + if let requestedVoice, !requestedVoice.isEmpty, resolvedVoice == nil { + self.logger.warning("talk unknown voice alias \(requestedVoice, privacy: .public)") + } + if let voice = resolvedVoice { + if directive?.once == true { + self.logger.info("talk voice override (once) voiceId=\(voice, privacy: .public)") + } else { + self.currentVoiceId = voice + self.voiceOverrideActive = true + self.logger.info("talk voice override voiceId=\(voice, privacy: .public)") + } + } + + if let model = directive?.modelId { + if directive?.once == true { + self.logger.info("talk model override (once) modelId=\(model, privacy: .public)") + } else { + self.currentModelId = model + self.modelOverrideActive = true + } + } + + let apiKey = self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) + let preferredVoice = + resolvedVoice ?? + self.currentVoiceId ?? + self.defaultVoiceId + + let language = ElevenLabsTTSClient.validatedLanguage(directive?.language) + + let voiceId: String? = if let apiKey, !apiKey.isEmpty { + await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey) + } else { + nil + } + + if apiKey?.isEmpty != false { + self.ttsLogger.warning("talk missing ELEVENLABS_API_KEY; falling back to system voice") + } else if voiceId == nil { + self.ttsLogger.warning("talk missing voiceId; falling back to system voice") + } else if let voiceId { + self.ttsLogger + .info( + "talk TTS request voiceId=\(voiceId, privacy: .public) " + + "chars=\(cleaned.count, privacy: .public)") + } + self.lastSpokenText = cleaned + + let synthTimeoutSeconds = max(20.0, min(90.0, Double(cleaned.count) * 0.12)) + + guard self.isCurrent(gen) else { return nil } + + return TalkPlaybackInput( + generation: gen, + cleanedText: cleaned, + directive: directive, + apiKey: apiKey, + voiceId: voiceId, + language: language, + synthTimeoutSeconds: synthTimeoutSeconds) + } + + private func playElevenLabs(input: TalkPlaybackInput, apiKey: String, voiceId: String) async throws { + let desiredOutputFormat = input.directive?.outputFormat ?? self.defaultOutputFormat ?? "pcm_44100" + let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(desiredOutputFormat) + if outputFormat == nil, !desiredOutputFormat.isEmpty { + self.logger + .warning( + "talk output_format unsupported for local playback: " + + "\(desiredOutputFormat, privacy: .public)") + } + + let modelId = input.directive?.modelId ?? self.currentModelId ?? self.defaultModelId + func makeRequest(outputFormat: String?) -> ElevenLabsTTSRequest { + ElevenLabsTTSRequest( + text: input.cleanedText, + modelId: modelId, + outputFormat: outputFormat, + speed: TalkTTSValidation.resolveSpeed( + speed: input.directive?.speed, + rateWPM: input.directive?.rateWPM), + stability: TalkTTSValidation.validatedStability( + input.directive?.stability, + modelId: modelId), + similarity: TalkTTSValidation.validatedUnit(input.directive?.similarity), + style: TalkTTSValidation.validatedUnit(input.directive?.style), + speakerBoost: input.directive?.speakerBoost, + seed: TalkTTSValidation.validatedSeed(input.directive?.seed), + normalize: ElevenLabsTTSClient.validatedNormalize(input.directive?.normalize), + language: input.language, + latencyTier: TalkTTSValidation.validatedLatencyTier(input.directive?.latencyTier)) + } + + let request = makeRequest(outputFormat: outputFormat) + self.ttsLogger.info("talk TTS synth timeout=\(input.synthTimeoutSeconds, privacy: .public)s") + let client = ElevenLabsTTSClient(apiKey: apiKey) + let stream = client.streamSynthesize(voiceId: voiceId, request: request) + guard self.isCurrent(input.generation) else { return } + + if self.interruptOnSpeech { + guard await self.prepareForPlayback(generation: input.generation) else { return } + } + + await MainActor.run { TalkModeController.shared.updatePhase(.speaking) } + self.phase = .speaking + + let result = await self.playRemoteStream( + client: client, + voiceId: voiceId, + outputFormat: outputFormat, + makeRequest: makeRequest, + stream: stream) + self.ttsLogger + .info( + "talk audio result finished=\(result.finished, privacy: .public) " + + "interruptedAt=\(String(describing: result.interruptedAt), privacy: .public)") + if !result.finished, result.interruptedAt == nil { + throw NSError(domain: "StreamingAudioPlayer", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "audio playback failed", + ]) + } + if !result.finished, let interruptedAt = result.interruptedAt, self.phase == .speaking { + if self.interruptOnSpeech { + self.lastInterruptedAtSeconds = interruptedAt + } + } + } + + private func playRemoteStream( + client: ElevenLabsTTSClient, + voiceId: String, + outputFormat: String?, + makeRequest: (String?) -> ElevenLabsTTSRequest, + stream: AsyncThrowingStream) async -> StreamingPlaybackResult + { + let sampleRate = TalkTTSValidation.pcmSampleRate(from: outputFormat) + if let sampleRate { + self.lastPlaybackWasPCM = true + let result = await self.playPCM(stream: stream, sampleRate: sampleRate) + if result.finished || result.interruptedAt != nil { + return result + } + let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") + self.ttsLogger.warning("talk pcm playback failed; retrying mp3") + self.lastPlaybackWasPCM = false + let mp3Stream = client.streamSynthesize( + voiceId: voiceId, + request: makeRequest(mp3Format)) + return await self.playMP3(stream: mp3Stream) + } + self.lastPlaybackWasPCM = false + return await self.playMP3(stream: stream) + } + + private func playSystemVoice(input: TalkPlaybackInput) async throws { + self.ttsLogger.info("talk system voice start chars=\(input.cleanedText.count, privacy: .public)") + if self.interruptOnSpeech { + guard await self.prepareForPlayback(generation: input.generation) else { return } + } + await MainActor.run { TalkModeController.shared.updatePhase(.speaking) } + self.phase = .speaking + await TalkSystemSpeechSynthesizer.shared.stop() + try await TalkSystemSpeechSynthesizer.shared.speak( + text: input.cleanedText, + language: input.language) + self.ttsLogger.info("talk system voice done") + } + + private func prepareForPlayback(generation: Int) async -> Bool { + await self.startRecognition() + return self.isCurrent(generation) + } + + private func resolveVoiceId(preferred: String?, apiKey: String) async -> String? { + let trimmed = preferred?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { + if let resolved = self.resolveVoiceAlias(trimmed) { return resolved } + self.ttsLogger.warning("talk unknown voice alias \(trimmed, privacy: .public)") + } + if let fallbackVoiceId { return fallbackVoiceId } + + do { + let voices = try await ElevenLabsTTSClient(apiKey: apiKey).listVoices() + guard let first = voices.first else { + self.ttsLogger.error("elevenlabs voices list empty") + return nil + } + self.fallbackVoiceId = first.voiceId + if self.defaultVoiceId == nil { + self.defaultVoiceId = first.voiceId + } + if !self.voiceOverrideActive { + self.currentVoiceId = first.voiceId + } + let name = first.name ?? "unknown" + self.ttsLogger + .info("talk default voice selected \(name, privacy: .public) (\(first.voiceId, privacy: .public))") + return first.voiceId + } catch { + self.ttsLogger.error("elevenlabs list voices failed: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + private func resolveVoiceAlias(_ value: String?) -> String? { + let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let normalized = trimmed.lowercased() + if let mapped = self.voiceAliases[normalized] { return mapped } + if self.voiceAliases.values.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { + return trimmed + } + return Self.isLikelyVoiceId(trimmed) ? trimmed : nil + } + + private static func isLikelyVoiceId(_ value: String) -> Bool { + guard value.count >= 10 else { return false } + return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } + } + + func stopSpeaking(reason: TalkStopReason) async { + let usePCM = self.lastPlaybackWasPCM + let interruptedAt = usePCM ? await self.stopPCM() : await self.stopMP3() + _ = usePCM ? await self.stopMP3() : await self.stopPCM() + await TalkSystemSpeechSynthesizer.shared.stop() + guard self.phase == .speaking else { return } + if reason == .speech, let interruptedAt { + self.lastInterruptedAtSeconds = interruptedAt + } + if reason == .manual { + return + } + if reason == .speech || reason == .userTap { + await self.startListening() + return + } + self.phase = .thinking + await MainActor.run { TalkModeController.shared.updatePhase(.thinking) } + } +} + +extension TalkModeRuntime { + // MARK: - Audio playback (MainActor helpers) + + @MainActor + private func playPCM( + stream: AsyncThrowingStream, + sampleRate: Double) async -> StreamingPlaybackResult + { + await PCMStreamingAudioPlayer.shared.play(stream: stream, sampleRate: sampleRate) + } + + @MainActor + private func playMP3(stream: AsyncThrowingStream) async -> StreamingPlaybackResult { + await StreamingAudioPlayer.shared.play(stream: stream) + } + + @MainActor + private func stopPCM() -> Double? { + PCMStreamingAudioPlayer.shared.stop() + } + + @MainActor + private func stopMP3() -> Double? { + StreamingAudioPlayer.shared.stop() + } + + // MARK: - Config + + private func reloadConfig() async { + let cfg = await self.fetchTalkConfig() + self.defaultVoiceId = cfg.voiceId + self.voiceAliases = cfg.voiceAliases + if !self.voiceOverrideActive { + self.currentVoiceId = cfg.voiceId + } + self.defaultModelId = cfg.modelId + if !self.modelOverrideActive { + self.currentModelId = cfg.modelId + } + self.defaultOutputFormat = cfg.outputFormat + self.interruptOnSpeech = cfg.interruptOnSpeech + self.apiKey = cfg.apiKey + let hasApiKey = (cfg.apiKey?.isEmpty == false) + let voiceLabel = (cfg.voiceId?.isEmpty == false) ? cfg.voiceId! : "none" + let modelLabel = (cfg.modelId?.isEmpty == false) ? cfg.modelId! : "none" + self.logger + .info( + "talk config voiceId=\(voiceLabel, privacy: .public) " + + "modelId=\(modelLabel, privacy: .public) " + + "apiKey=\(hasApiKey, privacy: .public) " + + "interrupt=\(cfg.interruptOnSpeech, privacy: .public)") + } + + private struct TalkRuntimeConfig { + let voiceId: String? + let voiceAliases: [String: String] + let modelId: String? + let outputFormat: String? + let interruptOnSpeech: Bool + let apiKey: String? + } + + struct TalkProviderConfigSelection { + let provider: String + let config: [String: AnyCodable] + let normalizedPayload: Bool + } + + private static func normalizedTalkProviderID(_ raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func normalizedTalkProviderConfig(_ value: AnyCodable) -> [String: AnyCodable]? { + if let typed = value.value as? [String: AnyCodable] { + return typed + } + if let foundation = value.value as? [String: Any] { + return foundation.mapValues(AnyCodable.init) + } + if let nsDict = value.value as? NSDictionary { + var converted: [String: AnyCodable] = [:] + for case let (key as String, raw) in nsDict { + converted[key] = AnyCodable(raw) + } + return converted + } + return nil + } + + private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] { + guard let raw else { return [:] } + var providerMap: [String: AnyCodable] = [:] + if let typed = raw.value as? [String: AnyCodable] { + providerMap = typed + } else if let foundation = raw.value as? [String: Any] { + providerMap = foundation.mapValues(AnyCodable.init) + } else if let nsDict = raw.value as? NSDictionary { + for case let (key as String, value) in nsDict { + providerMap[key] = AnyCodable(value) + } + } else { + return [:] + } + + return providerMap.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in + guard + let providerID = Self.normalizedTalkProviderID(entry.key), + let providerConfig = Self.normalizedTalkProviderConfig(entry.value) + else { return } + acc[providerID] = providerConfig + } + } + + static func selectTalkProviderConfig( + _ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection? + { + guard let talk else { return nil } + let rawProvider = talk["provider"]?.stringValue + let rawProviders = talk["providers"] + let hasNormalizedPayload = rawProvider != nil || rawProviders != nil + if hasNormalizedPayload { + let normalizedProviders = Self.normalizedTalkProviders(rawProviders) + let providerID = + Self.normalizedTalkProviderID(rawProvider) ?? + normalizedProviders.keys.min() ?? + Self.defaultTalkProvider + return TalkProviderConfigSelection( + provider: providerID, + config: normalizedProviders[providerID] ?? [:], + normalizedPayload: true) + } + return TalkProviderConfigSelection( + provider: Self.defaultTalkProvider, + config: talk, + normalizedPayload: false) + } + + private func fetchTalkConfig() async -> TalkRuntimeConfig { + let env = ProcessInfo.processInfo.environment + let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let sagVoice = env["SAG_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let envApiKey = env["ELEVENLABS_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines) + + do { + let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded( + method: .talkConfig, + params: ["includeSecrets": AnyCodable(true)], + timeoutMs: 8000) + let talk = snap.config?["talk"]?.dictionaryValue + let selection = Self.selectTalkProviderConfig(talk) + let activeProvider = selection?.provider ?? Self.defaultTalkProvider + let activeConfig = selection?.config + let ui = snap.config?["ui"]?.dictionaryValue + let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + await MainActor.run { + AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam + } + let voice = activeConfig?["voiceId"]?.stringValue + let rawAliases = activeConfig?["voiceAliases"]?.dictionaryValue + let resolvedAliases: [String: String] = + rawAliases?.reduce(into: [:]) { acc, entry in + let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !key.isEmpty, !value.isEmpty else { return } + acc[key] = value + } ?? [:] + let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedModel = (model?.isEmpty == false) ? model! : Self.defaultModelIdFallback + let outputFormat = activeConfig?["outputFormat"]?.stringValue + let interrupt = talk?["interruptOnSpeech"]?.boolValue + let apiKey = activeConfig?["apiKey"]?.stringValue + let resolvedVoice: String? = if activeProvider == Self.defaultTalkProvider { + (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ?? + (envVoice?.isEmpty == false ? envVoice : nil) ?? + (sagVoice?.isEmpty == false ? sagVoice : nil) + } else { + (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) + } + let resolvedApiKey: String? = if activeProvider == Self.defaultTalkProvider { + (envApiKey?.isEmpty == false ? envApiKey : nil) ?? + (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) + } else { + nil + } + if activeProvider != Self.defaultTalkProvider { + self.ttsLogger + .info("talk provider \(activeProvider, privacy: .public) unsupported; using system voice") + } else if selection?.normalizedPayload == true { + self.ttsLogger.info("talk config provider elevenlabs") + } + return TalkRuntimeConfig( + voiceId: resolvedVoice, + voiceAliases: resolvedAliases, + modelId: resolvedModel, + outputFormat: outputFormat, + interruptOnSpeech: interrupt ?? true, + apiKey: resolvedApiKey) + } catch { + let resolvedVoice = + (envVoice?.isEmpty == false ? envVoice : nil) ?? + (sagVoice?.isEmpty == false ? sagVoice : nil) + let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil + return TalkRuntimeConfig( + voiceId: resolvedVoice, + voiceAliases: [:], + modelId: Self.defaultModelIdFallback, + outputFormat: nil, + interruptOnSpeech: true, + apiKey: resolvedApiKey) + } + } + + // MARK: - Audio level handling + + private func noteAudioLevel(rms: Double) async { + if self.phase != .listening, self.phase != .speaking { return } + let alpha: Double = rms < self.noiseFloorRMS ? 0.08 : 0.01 + self.noiseFloorRMS = max(1e-7, self.noiseFloorRMS + (rms - self.noiseFloorRMS) * alpha) + + let threshold = max(self.minSpeechRMS, self.noiseFloorRMS * self.speechBoostFactor) + if rms >= threshold { + let now = Date() + self.lastHeard = now + self.lastSpeechEnergyAt = now + } + + if self.phase == .listening { + let clamped = min(1.0, max(0.0, rms / max(self.minSpeechRMS, threshold))) + await MainActor.run { TalkModeController.shared.updateLevel(clamped) } + } + } + + private static func rmsLevel(buffer: AVAudioPCMBuffer) -> Double? { + guard let channelData = buffer.floatChannelData?.pointee else { return nil } + let frameCount = Int(buffer.frameLength) + guard frameCount > 0 else { return nil } + var sum: Double = 0 + for i in 0.. Bool { + let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= 3 else { return false } + if self.isLikelyEcho(of: trimmed) { return false } + let now = Date() + if let lastSpeechEnergyAt, now.timeIntervalSince(lastSpeechEnergyAt) > 0.35 { + return false + } + return hasConfidence + } + + private func isLikelyEcho(of transcript: String) -> Bool { + guard let spoken = self.lastSpokenText?.lowercased(), !spoken.isEmpty else { return false } + let probe = transcript.lowercased() + if probe.count < 6 { + return spoken.contains(probe) + } + return spoken.contains(probe) + } + + private static func resolveSpeed(speed: Double?, rateWPM: Int?, logger: Logger) -> Double? { + if let rateWPM, rateWPM > 0 { + let resolved = Double(rateWPM) / 175.0 + if resolved <= 0.5 || resolved >= 2.0 { + logger.warning("talk rateWPM out of range: \(rateWPM, privacy: .public)") + return nil + } + return resolved + } + if let speed { + if speed <= 0.5 || speed >= 2.0 { + logger.warning("talk speed out of range: \(speed, privacy: .public)") + return nil + } + return speed + } + return nil + } + + private static func validatedUnit(_ value: Double?, name: String, logger: Logger) -> Double? { + guard let value else { return nil } + if value < 0 || value > 1 { + logger.warning("talk \(name, privacy: .public) out of range: \(value, privacy: .public)") + return nil + } + return value + } + + private static func validatedSeed(_ value: Int?, logger: Logger) -> UInt32? { + guard let value else { return nil } + if value < 0 || value > 4_294_967_295 { + logger.warning("talk seed out of range: \(value, privacy: .public)") + return nil + } + return UInt32(value) + } + + private static func validatedNormalize(_ value: String?, logger: Logger) -> String? { + guard let value else { return nil } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard ["auto", "on", "off"].contains(normalized) else { + logger.warning("talk normalize invalid: \(normalized, privacy: .public)") + return nil + } + return normalized + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkModeTypes.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkModeTypes.swift new file mode 100644 index 00000000..3ae97825 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkModeTypes.swift @@ -0,0 +1,8 @@ +import Foundation + +enum TalkModePhase: String { + case idle + case listening + case thinking + case speaking +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkOverlay.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkOverlay.swift new file mode 100644 index 00000000..27e5dedc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkOverlay.swift @@ -0,0 +1,146 @@ +import AppKit +import Observation +import OSLog +import SwiftUI + +@MainActor +@Observable +final class TalkOverlayController { + static let shared = TalkOverlayController() + static let overlaySize: CGFloat = 440 + static let orbSize: CGFloat = 96 + static let orbPadding: CGFloat = 12 + static let orbHitSlop: CGFloat = 10 + + private let logger = Logger(subsystem: "ai.openclaw", category: "talk.overlay") + + struct Model { + var isVisible: Bool = false + var phase: TalkModePhase = .idle + var isPaused: Bool = false + var level: Double = 0 + } + + var model = Model() + private var window: NSPanel? + private var hostingView: NSHostingView? + private let screenInset: CGFloat = 0 + + func present() { + self.ensureWindow() + self.hostingView?.rootView = TalkOverlayView(controller: self) + let target = self.targetFrame() + + guard let window else { return } + if !self.model.isVisible { + self.model.isVisible = true + let start = target.offsetBy(dx: 0, dy: -6) + window.setFrame(start, display: true) + window.alphaValue = 0 + window.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(target, display: true) + window.animator().alphaValue = 1 + } + } else { + window.setFrame(target, display: true) + window.orderFrontRegardless() + } + } + + func dismiss() { + guard let window else { + self.model.isVisible = false + return + } + + let target = window.frame.offsetBy(dx: 6, dy: 6) + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.16 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(target, display: true) + window.animator().alphaValue = 0 + } completionHandler: { + Task { @MainActor in + window.orderOut(nil) + self.model.isVisible = false + } + } + } + + func updatePhase(_ phase: TalkModePhase) { + guard self.model.phase != phase else { return } + self.logger.info("talk overlay phase=\(phase.rawValue, privacy: .public)") + self.model.phase = phase + } + + func updatePaused(_ paused: Bool) { + guard self.model.isPaused != paused else { return } + self.logger.info("talk overlay paused=\(paused)") + self.model.isPaused = paused + } + + func updateLevel(_ level: Double) { + guard self.model.isVisible else { return } + self.model.level = max(0, min(1, level)) + } + + func currentWindowOrigin() -> CGPoint? { + self.window?.frame.origin + } + + func setWindowOrigin(_ origin: CGPoint) { + guard let window else { return } + window.setFrameOrigin(origin) + } + + // MARK: - Private + + private func ensureWindow() { + if self.window != nil { return } + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: Self.overlaySize, height: Self.overlaySize), + styleMask: [.nonactivatingPanel, .borderless], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4) + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + panel.hidesOnDeactivate = false + panel.isMovable = false + panel.acceptsMouseMovedEvents = true + panel.isFloatingPanel = true + panel.becomesKeyOnlyIfNeeded = true + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + + let host = TalkOverlayHostingView(rootView: TalkOverlayView(controller: self)) + host.translatesAutoresizingMaskIntoConstraints = false + panel.contentView = host + self.hostingView = host + self.window = panel + } + + private func targetFrame() -> NSRect { + let screen = self.window?.screen + ?? NSScreen.main + ?? NSScreen.screens.first + guard let screen else { return .zero } + let size = NSSize(width: Self.overlaySize, height: Self.overlaySize) + let visible = screen.visibleFrame + let origin = CGPoint( + x: visible.maxX - size.width - self.screenInset, + y: visible.maxY - size.height - self.screenInset) + return NSRect(origin: origin, size: size) + } +} + +private final class TalkOverlayHostingView: NSHostingView { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkOverlayView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkOverlayView.swift new file mode 100644 index 00000000..80599d55 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TalkOverlayView.swift @@ -0,0 +1,225 @@ +import AppKit +import SwiftUI + +struct TalkOverlayView: View { + var controller: TalkOverlayController + @State private var appState = AppStateStore.shared + @State private var hoveringWindow = false + + var body: some View { + ZStack(alignment: .topTrailing) { + let isPaused = self.controller.model.isPaused + Color.clear + TalkOrbView( + phase: self.controller.model.phase, + level: self.controller.model.level, + accent: self.seamColor, + isPaused: isPaused) + .frame(width: TalkOverlayController.orbSize, height: TalkOverlayController.orbSize) + .padding(.top, TalkOverlayController.orbPadding) + .padding(.trailing, TalkOverlayController.orbPadding) + .contentShape(Circle()) + .opacity(isPaused ? 0.55 : 1) + .background( + TalkOrbInteractionView( + onSingleClick: { TalkModeController.shared.togglePaused() }, + onDoubleClick: { TalkModeController.shared.stopSpeaking(reason: .userTap) }, + onDragStart: { TalkModeController.shared.setPaused(true) })) + .overlay(alignment: .topLeading) { + Button { + TalkModeController.shared.exitTalkMode() + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Color.white.opacity(0.95)) + .frame(width: 18, height: 18) + .background(Color.black.opacity(0.4)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .contentShape(Circle()) + .offset(x: -2, y: -2) + .opacity(self.hoveringWindow ? 1 : 0) + .animation(.easeOut(duration: 0.12), value: self.hoveringWindow) + } + .onHover { self.hoveringWindow = $0 } + } + .frame( + width: TalkOverlayController.overlaySize, + height: TalkOverlayController.overlaySize, + alignment: .topTrailing) + } + + private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0) + + private var seamColor: Color { + Self.color(fromHex: self.appState.seamColorHex) ?? Self.defaultSeamColor + } + + private static func color(fromHex raw: String?) -> Color? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } + let r = Double((value >> 16) & 0xFF) / 255.0 + let g = Double((value >> 8) & 0xFF) / 255.0 + let b = Double(value & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } +} + +private struct TalkOrbInteractionView: NSViewRepresentable { + let onSingleClick: () -> Void + let onDoubleClick: () -> Void + let onDragStart: () -> Void + + func makeNSView(context: Context) -> NSView { + let view = OrbInteractionNSView() + view.onSingleClick = self.onSingleClick + view.onDoubleClick = self.onDoubleClick + view.onDragStart = self.onDragStart + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.clear.cgColor + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let view = nsView as? OrbInteractionNSView else { return } + view.onSingleClick = self.onSingleClick + view.onDoubleClick = self.onDoubleClick + view.onDragStart = self.onDragStart + } +} + +private final class OrbInteractionNSView: NSView { + var onSingleClick: (() -> Void)? + var onDoubleClick: (() -> Void)? + var onDragStart: (() -> Void)? + private var mouseDownEvent: NSEvent? + private var didDrag = false + private var suppressSingleClick = false + + override var acceptsFirstResponder: Bool { + true + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func mouseDown(with event: NSEvent) { + self.mouseDownEvent = event + self.didDrag = false + self.suppressSingleClick = event.clickCount > 1 + if event.clickCount == 2 { + self.onDoubleClick?() + } + } + + override func mouseDragged(with event: NSEvent) { + guard let startEvent = self.mouseDownEvent else { return } + if !self.didDrag { + let dx = event.locationInWindow.x - startEvent.locationInWindow.x + let dy = event.locationInWindow.y - startEvent.locationInWindow.y + if abs(dx) + abs(dy) < 2 { return } + self.didDrag = true + self.onDragStart?() + self.window?.performDrag(with: startEvent) + } + } + + override func mouseUp(with event: NSEvent) { + if !self.didDrag, !self.suppressSingleClick { + self.onSingleClick?() + } + self.mouseDownEvent = nil + self.didDrag = false + self.suppressSingleClick = false + } +} + +private struct TalkOrbView: View { + let phase: TalkModePhase + let level: Double + let accent: Color + let isPaused: Bool + + var body: some View { + if self.isPaused { + Circle() + .fill(self.orbGradient) + .overlay(Circle().stroke(Color.white.opacity(0.35), lineWidth: 1)) + .shadow(color: Color.black.opacity(0.18), radius: 10, x: 0, y: 5) + } else { + TimelineView(.animation) { context in + let t = context.date.timeIntervalSinceReferenceDate + let listenScale = self.phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1 + let pulse = self.phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1 + + ZStack { + Circle() + .fill(self.orbGradient) + .overlay(Circle().stroke(Color.white.opacity(0.45), lineWidth: 1)) + .shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5) + .scaleEffect(pulse * listenScale) + + TalkWaveRings(phase: self.phase, level: self.level, time: t, accent: self.accent) + + if self.phase == .thinking { + TalkOrbitArcs(time: t) + } + } + } + } + } + + private var orbGradient: RadialGradient { + RadialGradient( + colors: [Color.white, self.accent], + center: .topLeading, + startRadius: 4, + endRadius: 52) + } +} + +private struct TalkWaveRings: View { + let phase: TalkModePhase + let level: Double + let time: TimeInterval + let accent: Color + + var body: some View { + ZStack { + ForEach(0..<3, id: \.self) { idx in + let speed = self.phase == .speaking ? 1.4 : self.phase == .listening ? 0.9 : 0.6 + let progress = (time * speed + Double(idx) * 0.28).truncatingRemainder(dividingBy: 1) + let amplitude = self.phase == .speaking ? 0.95 : self.phase == .listening ? 0.5 + self + .level * 0.7 : 0.35 + let scale = 0.75 + progress * amplitude + (self.phase == .listening ? self.level * 0.15 : 0) + let alpha = self.phase == .speaking ? 0.72 : self.phase == .listening ? 0.58 + self.level * 0.28 : 0.4 + Circle() + .stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6) + .scaleEffect(scale) + .opacity(alpha - progress * 0.6) + } + } + } +} + +private struct TalkOrbitArcs: View { + let time: TimeInterval + + var body: some View { + ZStack { + Circle() + .trim(from: 0.08, to: 0.26) + .stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 1.6, lineCap: .round)) + .rotationEffect(.degrees(self.time * 42)) + Circle() + .trim(from: 0.62, to: 0.86) + .stroke(Color.white.opacity(0.7), style: StrokeStyle(lineWidth: 1.4, lineCap: .round)) + .rotationEffect(.degrees(-self.time * 35)) + } + .scaleEffect(1.08) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift new file mode 100644 index 00000000..add543c3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift @@ -0,0 +1,53 @@ +import AppKit +import Foundation +import OSLog + +@MainActor +final class TerminationSignalWatcher { + static let shared = TerminationSignalWatcher() + + private let logger = Logger(subsystem: "ai.openclaw", category: "lifecycle") + private var sources: [DispatchSourceSignal] = [] + private var terminationRequested = false + + func start() { + guard self.sources.isEmpty else { return } + self.install(SIGTERM) + self.install(SIGINT) + } + + func stop() { + for s in self.sources { + s.cancel() + } + self.sources.removeAll(keepingCapacity: false) + self.terminationRequested = false + } + + private func install(_ sig: Int32) { + // Make sure the default action doesn't kill the process before we can gracefully shut down. + signal(sig, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: sig, queue: .main) + source.setEventHandler { [weak self] in + self?.handle(sig) + } + source.resume() + self.sources.append(source) + } + + private func handle(_ sig: Int32) { + guard !self.terminationRequested else { return } + self.terminationRequested = true + + self.logger.info("received signal \(sig, privacy: .public); terminating") + // Ensure any pairing prompt can't accidentally approve during shutdown. + NodePairingApprovalPrompter.shared.stop() + DevicePairingApprovalPrompter.shared.stop() + NSApp.terminate(nil) + + // Safety net: don't hang forever if something blocks termination. + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + exit(0) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/UsageCostData.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/UsageCostData.swift new file mode 100644 index 00000000..ca1fb5cc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/UsageCostData.swift @@ -0,0 +1,60 @@ +import Foundation + +struct GatewayCostUsageTotals: Codable { + let input: Int + let output: Int + let cacheRead: Int + let cacheWrite: Int + let totalTokens: Int + let totalCost: Double + let missingCostEntries: Int +} + +struct GatewayCostUsageDay: Codable { + let date: String + let input: Int + let output: Int + let cacheRead: Int + let cacheWrite: Int + let totalTokens: Int + let totalCost: Double + let missingCostEntries: Int +} + +struct GatewayCostUsageSummary: Codable { + let updatedAt: Double + let days: Int + let daily: [GatewayCostUsageDay] + let totals: GatewayCostUsageTotals +} + +enum CostUsageFormatting { + static func formatUsd(_ value: Double?) -> String? { + guard let value, value.isFinite else { return nil } + if value >= 1 { return String(format: "$%.2f", value) } + if value >= 0.01 { return String(format: "$%.2f", value) } + return String(format: "$%.4f", value) + } + + static func formatTokenCount(_ value: Int?) -> String? { + guard let value else { return nil } + let safe = max(0, value) + if safe >= 1_000_000 { return String(format: "%.1fm", Double(safe) / 1_000_000.0) } + if safe >= 1000 { return safe >= 10000 + ? String(format: "%.0fk", Double(safe) / 1000.0) + : String(format: "%.1fk", Double(safe) / 1000.0) + } + return String(safe) + } +} + +@MainActor +enum CostUsageLoader { + static func loadSummary() async throws -> GatewayCostUsageSummary { + let data = try await ControlChannel.shared.request( + method: "usage.cost", + params: nil, + timeoutMs: 7000) + return try JSONDecoder().decode(GatewayCostUsageSummary.self, from: data) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/UsageData.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/UsageData.swift new file mode 100644 index 00000000..3886c966 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/UsageData.swift @@ -0,0 +1,103 @@ +import Foundation + +struct GatewayUsageWindow: Codable { + let label: String + let usedPercent: Double + let resetAt: Double? +} + +struct GatewayUsageProvider: Codable { + let provider: String + let displayName: String + let windows: [GatewayUsageWindow] + let plan: String? + let error: String? +} + +struct GatewayUsageSummary: Codable { + let updatedAt: Double + let providers: [GatewayUsageProvider] +} + +struct UsageRow: Identifiable { + let id: String + let providerId: String + let displayName: String + let plan: String? + let windowLabel: String? + let usedPercent: Double? + let resetAt: Date? + let error: String? + + var hasError: Bool { + if let error, !error.isEmpty { return true } + return false + } + + var titleText: String { + if let plan, !plan.isEmpty { return "\(self.displayName) (\(plan))" } + return self.displayName + } + + var remainingPercent: Int? { + guard let usedPercent, usedPercent.isFinite else { return nil } + return max(0, min(100, Int(round(100 - usedPercent)))) + } + + func detailText(now: Date = .init()) -> String { + guard let remaining = self.remainingPercent else { return "No data" } + var parts = ["\(remaining)% left"] + if let windowLabel, !windowLabel.isEmpty { parts.append(windowLabel) } + if let resetAt { + let reset = UsageRow.formatResetRemaining(target: resetAt, now: now) + if let reset { parts.append("⏱\(reset)") } + } + return parts.joined(separator: " · ") + } + + private static func formatResetRemaining(target: Date, now: Date) -> String? { + let diff = target.timeIntervalSince(now) + if diff <= 0 { return "now" } + let minutes = Int(floor(diff / 60)) + if minutes < 60 { return "\(minutes)m" } + let hours = minutes / 60 + let mins = minutes % 60 + if hours < 24 { return mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" } + let days = hours / 24 + if days < 7 { return "\(days)d \(hours % 24)h" } + let formatter = DateFormatter() + formatter.dateFormat = "MMM d" + return formatter.string(from: target) + } +} + +extension GatewayUsageSummary { + func primaryRows() -> [UsageRow] { + self.providers.compactMap { provider in + guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else { + return nil + } + + return UsageRow( + id: "\(provider.provider)-\(window.label)", + providerId: provider.provider, + displayName: provider.displayName, + plan: provider.plan, + windowLabel: window.label, + usedPercent: window.usedPercent, + resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) }, + error: nil) + } + } +} + +@MainActor +enum UsageLoader { + static func loadSummary() async throws -> GatewayUsageSummary { + let data = try await ControlChannel.shared.request( + method: "usage.status", + params: nil, + timeoutMs: 5000) + return try JSONDecoder().decode(GatewayUsageSummary.self, from: data) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift new file mode 100644 index 00000000..c7f95e47 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct UsageMenuLabelView: View { + let row: UsageRow + let width: CGFloat + var showsChevron: Bool = false + @Environment(\.menuItemHighlighted) private var isHighlighted + private let paddingLeading: CGFloat = 22 + private let paddingTrailing: CGFloat = 14 + private let barHeight: CGFloat = 6 + + private var primaryTextColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary + } + + private var secondaryTextColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let used = row.usedPercent { + ContextUsageBar( + usedTokens: Int(round(used)), + contextTokens: 100, + width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)), + height: self.barHeight) + } + + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(self.row.titleText) + .font(.caption.weight(.semibold)) + .foregroundStyle(self.primaryTextColor) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) + + Spacer(minLength: 4) + + Text(self.row.detailText()) + .font(.caption.monospacedDigit()) + .foregroundStyle(self.secondaryTextColor) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(2) + + if self.showsChevron { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(self.secondaryTextColor) + .padding(.leading, 2) + } + } + } + .padding(.vertical, 10) + .padding(.leading, self.paddingLeading) + .padding(.trailing, self.paddingTrailing) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/UserDefaultsMigration.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/UserDefaultsMigration.swift new file mode 100644 index 00000000..793e52ba --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/UserDefaultsMigration.swift @@ -0,0 +1,16 @@ +import Foundation + +private let legacyDefaultsPrefix = "openclaw." +private let defaultsPrefix = "openclaw." + +func migrateLegacyDefaults() { + let defaults = UserDefaults.standard + let snapshot = defaults.dictionaryRepresentation() + for (key, value) in snapshot where key.hasPrefix(legacyDefaultsPrefix) { + let suffix = key.dropFirst(legacyDefaultsPrefix.count) + let newKey = defaultsPrefix + suffix + if defaults.object(forKey: newKey) == nil { + defaults.set(value, forKey: newKey) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ViewMetrics.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ViewMetrics.swift new file mode 100644 index 00000000..dfd7180d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/ViewMetrics.swift @@ -0,0 +1,29 @@ +import SwiftUI + +private struct ViewWidthPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +extension View { + func onWidthChange(_ onChange: @escaping (CGFloat) -> Void) -> some View { + self.background( + GeometryReader { proxy in + Color.clear.preference(key: ViewWidthPreferenceKey.self, value: proxy.size.width) + }) + .onPreferenceChange(ViewWidthPreferenceKey.self, perform: onChange) + } +} + +#if DEBUG +enum ViewMetricsTesting { + static func reduceWidth(current: CGFloat, next: CGFloat) -> CGFloat { + var value = current + ViewWidthPreferenceKey.reduce(value: &value, nextValue: { next }) + return value + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VisualEffectView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VisualEffectView.swift new file mode 100644 index 00000000..b1897110 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VisualEffectView.swift @@ -0,0 +1,37 @@ +import AppKit +import SwiftUI + +struct VisualEffectView: NSViewRepresentable { + var material: NSVisualEffectView.Material + var blendingMode: NSVisualEffectView.BlendingMode + var state: NSVisualEffectView.State + var emphasized: Bool + + init( + material: NSVisualEffectView.Material, + blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, + state: NSVisualEffectView.State = .active, + emphasized: Bool = false) + { + self.material = material + self.blendingMode = blendingMode + self.state = state + self.emphasized = emphasized + } + + func makeNSView(context _: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = self.material + view.blendingMode = self.blendingMode + view.state = self.state + view.isEmphasized = self.emphasized + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context _: Context) { + nsView.material = self.material + nsView.blendingMode = self.blendingMode + nsView.state = self.state + nsView.isEmphasized = self.emphasized + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift new file mode 100644 index 00000000..6eaa45e0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift @@ -0,0 +1,429 @@ +import AppKit +import AVFoundation +import Dispatch +import OSLog +import Speech + +/// Observes right Option and starts a push-to-talk capture while it is held. +final class VoicePushToTalkHotkey: @unchecked Sendable { + static let shared = VoicePushToTalkHotkey() + + private var globalMonitor: Any? + private var localMonitor: Any? + private var optionDown = false // right option only + private var active = false + + private let beginAction: @Sendable () async -> Void + private let endAction: @Sendable () async -> Void + + init( + beginAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.begin() }, + endAction: @escaping @Sendable () async -> Void = { await VoicePushToTalk.shared.end() }) + { + self.beginAction = beginAction + self.endAction = endAction + } + + func setEnabled(_ enabled: Bool) { + if ProcessInfo.processInfo.isRunningTests { return } + self.withMainThread { [weak self] in + guard let self else { return } + if enabled { + self.startMonitoring() + } else { + self.stopMonitoring() + } + } + } + + private func startMonitoring() { + // assert(Thread.isMainThread) - Removed for Swift 6 + guard self.globalMonitor == nil, self.localMonitor == nil else { return } + // Listen-only global monitor; we rely on Input Monitoring permission to receive events. + self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + let keyCode = event.keyCode + let flags = event.modifierFlags + self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags) + } + // Also listen locally so we still catch events when the app is active/focused. + self.localMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + let keyCode = event.keyCode + let flags = event.modifierFlags + self?.handleFlagsChanged(keyCode: keyCode, modifierFlags: flags) + return event + } + } + + private func stopMonitoring() { + // assert(Thread.isMainThread) - Removed for Swift 6 + if let globalMonitor { + NSEvent.removeMonitor(globalMonitor) + self.globalMonitor = nil + } + if let localMonitor { + NSEvent.removeMonitor(localMonitor) + self.localMonitor = nil + } + self.optionDown = false + self.active = false + } + + private func handleFlagsChanged(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { + self.withMainThread { [weak self] in + self?.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags) + } + } + + private func withMainThread(_ block: @escaping @Sendable () -> Void) { + DispatchQueue.main.async(execute: block) + } + + private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { + // assert(Thread.isMainThread) - Removed for Swift 6 + // Right Option (keyCode 61) acts as a hold-to-talk modifier. + if keyCode == 61 { + self.optionDown = modifierFlags.contains(.option) + } + + let chordActive = self.optionDown + if chordActive, !self.active { + self.active = true + Task { + Logger(subsystem: "ai.openclaw", category: "voicewake.ptt") + .info("ptt hotkey down") + await self.beginAction() + } + } else if !chordActive, self.active { + self.active = false + Task { + Logger(subsystem: "ai.openclaw", category: "voicewake.ptt") + .info("ptt hotkey up") + await self.endAction() + } + } + } + + func _testUpdateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { + self.updateModifierState(keyCode: keyCode, modifierFlags: modifierFlags) + } +} + +/// Short-lived speech recognizer that records while the hotkey is held. +actor VoicePushToTalk { + static let shared = VoicePushToTalk() + + private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.ptt") + + private var recognizer: SFSpeechRecognizer? + // Lazily created on begin() to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth + // headphones into the low-quality headset profile even if push-to-talk is never used. + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var tapInstalled = false + + /// Session token used to drop stale callbacks when a new capture starts. + private var sessionID = UUID() + + private var committed: String = "" + private var volatile: String = "" + private var activeConfig: Config? + private var isCapturing = false + private var triggerChimePlayed = false + private var finalized = false + private var timeoutTask: Task? + private var overlayToken: UUID? + private var adoptedPrefix: String = "" + + private struct Config { + let micID: String? + let localeID: String? + let triggerChime: VoiceWakeChime + let sendChime: VoiceWakeChime + } + + func begin() async { + guard voiceWakeSupported else { return } + guard !self.isCapturing else { return } + + // Start a fresh session and invalidate any in-flight callbacks tied to an older one. + let sessionID = UUID() + self.sessionID = sessionID + + // Ensure permissions up front. + let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true) + guard granted else { return } + + let config = await MainActor.run { self.makeConfig() } + self.activeConfig = config + self.isCapturing = true + self.triggerChimePlayed = false + self.finalized = false + self.timeoutTask?.cancel(); self.timeoutTask = nil + let snapshot = await MainActor.run { VoiceSessionCoordinator.shared.snapshot() } + self.adoptedPrefix = snapshot.visible ? snapshot.text.trimmingCharacters(in: .whitespacesAndNewlines) : "" + self.logger.info("ptt begin adopted_prefix_len=\(self.adoptedPrefix.count, privacy: .public)") + if config.triggerChime != .none { + self.triggerChimePlayed = true + await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime, reason: "ptt.trigger") } + } + // Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap. + await VoiceWakeRuntime.shared.pauseForPushToTalk() + let adoptedPrefix = self.adoptedPrefix + let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : Self.makeAttributed( + committed: adoptedPrefix, + volatile: "", + isFinal: false) + self.overlayToken = await MainActor.run { + VoiceSessionCoordinator.shared.startSession( + source: .pushToTalk, + text: adoptedPrefix, + attributed: adoptedAttributed, + forwardEnabled: true) + } + + do { + try await self.startRecognition(localeID: config.localeID, sessionID: sessionID) + } catch { + await MainActor.run { + VoiceWakeOverlayController.shared.dismiss() + } + self.isCapturing = false + // If push-to-talk fails to start after pausing wake-word, ensure we resume listening. + await VoiceWakeRuntime.shared.applyPushToTalkCooldown() + await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) + } + } + + func end() async { + guard self.isCapturing else { return } + self.isCapturing = false + let sessionID = self.sessionID + + // Stop feeding Speech buffers first, then end the request. Stopping the engine here can race with + // Speech draining its converter chain (and we already stop/cancel in finalize). + if self.tapInstalled { + self.audioEngine?.inputNode.removeTap(onBus: 0) + self.tapInstalled = false + } + self.recognitionRequest?.endAudio() + + // If we captured nothing, dismiss immediately when the user lets go. + if self.committed.isEmpty, self.volatile.isEmpty, self.adoptedPrefix.isEmpty { + await self.finalize(transcriptOverride: "", reason: "emptyOnRelease", sessionID: sessionID) + return + } + + // Otherwise, give Speech a brief window to deliver the final result; then fall back. + self.timeoutTask?.cancel() + self.timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 1_500_000_000) // 1.5s grace period to await final result + await self?.finalize(transcriptOverride: nil, reason: "timeout", sessionID: sessionID) + } + } + + // MARK: - Private + + private func startRecognition(localeID: String?, sessionID: UUID) async throws { + let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier) + self.recognizer = SFSpeechRecognizer(locale: locale) + guard let recognizer, recognizer.isAvailable else { + throw NSError( + domain: "VoicePushToTalk", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Recognizer unavailable"]) + } + + self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + self.recognitionRequest?.shouldReportPartialResults = true + guard let request = self.recognitionRequest else { return } + + // Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP. + if self.audioEngine == nil { + self.audioEngine = AVAudioEngine() + } + guard let audioEngine = self.audioEngine else { return } + + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoicePushToTalk", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + + let input = audioEngine.inputNode + let format = input.outputFormat(forBus: 0) + if self.tapInstalled { + input.removeTap(onBus: 0) + self.tapInstalled = false + } + // Pipe raw mic buffers into the Speech request while the chord is held. + input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in + request?.append(buffer) + } + self.tapInstalled = true + + audioEngine.prepare() + try audioEngine.start() + + self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self else { return } + if let error { + self.logger.debug("push-to-talk error: \(error.localizedDescription, privacy: .public)") + } + let transcript = result?.bestTranscription.formattedString + let isFinal = result?.isFinal ?? false + // Hop to a Task so UI updates stay off the Speech callback thread. + Task.detached { [weak self, transcript, isFinal, sessionID] in + guard let self else { return } + await self.handle(transcript: transcript, isFinal: isFinal, sessionID: sessionID) + } + } + } + + private func handle(transcript: String?, isFinal: Bool, sessionID: UUID) async { + guard sessionID == self.sessionID else { + self.logger.debug("push-to-talk drop transcript for stale session") + return + } + guard let transcript else { return } + if isFinal { + self.committed = transcript + self.volatile = "" + } else { + self.volatile = Self.delta(after: self.committed, current: transcript) + } + + let committedWithPrefix = Self.join(self.adoptedPrefix, self.committed) + let snapshot = Self.join(committedWithPrefix, self.volatile) + let attributed = Self.makeAttributed(committed: committedWithPrefix, volatile: self.volatile, isFinal: isFinal) + if let token = self.overlayToken { + await MainActor.run { + VoiceSessionCoordinator.shared.updatePartial( + token: token, + text: snapshot, + attributed: attributed) + } + } + } + + private func finalize(transcriptOverride: String?, reason: String, sessionID: UUID?) async { + if self.finalized { return } + if let sessionID, sessionID != self.sessionID { + self.logger.debug("push-to-talk drop finalize for stale session") + return + } + self.finalized = true + self.isCapturing = false + self.timeoutTask?.cancel(); self.timeoutTask = nil + + let finalRecognized: String = { + if let override = transcriptOverride?.trimmingCharacters(in: .whitespacesAndNewlines) { + return override + } + return (self.committed + self.volatile).trimmingCharacters(in: .whitespacesAndNewlines) + }() + let finalText = Self.join(self.adoptedPrefix, finalRecognized) + let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none) + + let token = self.overlayToken + let logger = self.logger + await MainActor.run { + logger.info("ptt finalize reason=\(reason, privacy: .public) len=\(finalText.count, privacy: .public)") + if let token { + VoiceSessionCoordinator.shared.finalize( + token: token, + text: finalText, + sendChime: chime, + autoSendAfter: nil) + VoiceSessionCoordinator.shared.sendNow(token: token, reason: reason) + } else if !finalText.isEmpty { + if chime != .none { + VoiceWakeChimePlayer.play(chime, reason: "ptt.fallback_send") + } + Task.detached { + await VoiceWakeForwarder.forward(transcript: finalText) + } + } + } + + self.recognitionTask?.cancel() + self.recognitionRequest = nil + self.recognitionTask = nil + if self.tapInstalled { + self.audioEngine?.inputNode.removeTap(onBus: 0) + self.tapInstalled = false + } + if self.audioEngine?.isRunning == true { + self.audioEngine?.stop() + self.audioEngine?.reset() + } + // Release the engine so we also release any audio session/resources when push-to-talk ends. + self.audioEngine = nil + + self.committed = "" + self.volatile = "" + self.activeConfig = nil + self.triggerChimePlayed = false + self.overlayToken = nil + self.adoptedPrefix = "" + + // Resume the wake-word runtime after push-to-talk finishes. + await VoiceWakeRuntime.shared.applyPushToTalkCooldown() + _ = await MainActor.run { Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) } } + } + + @MainActor + private func makeConfig() -> Config { + let state = AppStateStore.shared + return Config( + micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, + localeID: state.voiceWakeLocaleID, + triggerChime: state.voiceWakeTriggerChime, + sendChime: state.voiceWakeSendChime) + } + + // MARK: - Test helpers + + static func _testDelta(committed: String, current: String) -> String { + self.delta(after: committed, current: current) + } + + static func _testAttributedColors(isFinal: Bool) -> (NSColor, NSColor) { + let sample = self.makeAttributed(committed: "a", volatile: "b", isFinal: isFinal) + let committedColor = sample.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear + let volatileColor = sample.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? NSColor ?? .clear + return (committedColor, volatileColor) + } + + private static func join(_ prefix: String, _ suffix: String) -> String { + if prefix.isEmpty { return suffix } + if suffix.isEmpty { return prefix } + return "\(prefix) \(suffix)" + } + + private static func delta(after committed: String, current: String) -> String { + if current.hasPrefix(committed) { + let start = current.index(current.startIndex, offsetBy: committed.count) + return String(current[start...]) + } + return current + } + + private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString { + let full = NSMutableAttributedString() + let committedAttr: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.labelColor, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + ] + full.append(NSAttributedString(string: committed, attributes: committedAttr)) + let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor + let volatileAttr: [NSAttributedString.Key: Any] = [ + .foregroundColor: volatileColor, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + ] + full.append(NSAttributedString(string: volatile, attributes: volatileAttr)) + return full + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceSessionCoordinator.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceSessionCoordinator.swift new file mode 100644 index 00000000..87c32d26 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceSessionCoordinator.swift @@ -0,0 +1,134 @@ +import AppKit +import Foundation +import Observation + +@MainActor +@Observable +final class VoiceSessionCoordinator { + static let shared = VoiceSessionCoordinator() + + enum Source: String { case wakeWord, pushToTalk } + + struct Session { + let token: UUID + let source: Source + var text: String + var attributed: NSAttributedString? + var isFinal: Bool + var sendChime: VoiceWakeChime + var autoSendDelay: TimeInterval? + } + + private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.coordinator") + private var session: Session? + + // MARK: - API + + func startSession( + source: Source, + text: String, + attributed: NSAttributedString? = nil, + forwardEnabled: Bool = false) -> UUID + { + let token = UUID() + self.logger.info("coordinator start token=\(token.uuidString) source=\(source.rawValue) len=\(text.count)") + let attributedText = attributed ?? VoiceWakeOverlayController.shared.makeAttributed(from: text) + let session = Session( + token: token, + source: source, + text: text, + attributed: attributedText, + isFinal: false, + sendChime: .none, + autoSendDelay: nil) + self.session = session + VoiceWakeOverlayController.shared.startSession( + token: token, + source: VoiceWakeOverlayController.Source(rawValue: source.rawValue) ?? .wakeWord, + transcript: text, + attributed: attributedText, + forwardEnabled: forwardEnabled, + isFinal: false) + return token + } + + func updatePartial(token: UUID, text: String, attributed: NSAttributedString? = nil) { + guard let session, session.token == token else { return } + self.session?.text = text + self.session?.attributed = attributed + VoiceWakeOverlayController.shared.updatePartial(token: token, transcript: text, attributed: attributed) + } + + func finalize( + token: UUID, + text: String, + sendChime: VoiceWakeChime, + autoSendAfter: TimeInterval?) + { + guard let session, session.token == token else { return } + self.logger + .info( + "coordinator finalize token=\(token.uuidString) len=\(text.count) autoSendAfter=\(autoSendAfter ?? -1)") + self.session?.text = text + self.session?.isFinal = true + self.session?.sendChime = sendChime + self.session?.autoSendDelay = autoSendAfter + + let attributed = VoiceWakeOverlayController.shared.makeAttributed(from: text) + VoiceWakeOverlayController.shared.presentFinal( + token: token, + transcript: text, + autoSendAfter: autoSendAfter, + sendChime: sendChime, + attributed: attributed) + } + + func sendNow(token: UUID, reason: String = "explicit") { + guard let session, session.token == token else { return } + let text = session.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + self.logger.info("coordinator sendNow \(reason) empty -> dismiss") + VoiceWakeOverlayController.shared.dismiss(token: token, reason: .empty, outcome: .empty) + self.clearSession() + return + } + VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime) + Task.detached { + _ = await VoiceWakeForwarder.forward(transcript: text) + } + } + + func dismiss( + token: UUID, + reason: VoiceWakeOverlayController.DismissReason, + outcome: VoiceWakeOverlayController.SendOutcome) + { + guard let session, session.token == token else { return } + VoiceWakeOverlayController.shared.dismiss(token: token, reason: reason, outcome: outcome) + self.clearSession() + } + + func updateLevel(token: UUID, _ level: Double) { + guard let session, session.token == token else { return } + VoiceWakeOverlayController.shared.updateLevel(token: token, level) + } + + func snapshot() -> (token: UUID?, text: String, visible: Bool) { + (self.session?.token, self.session?.text ?? "", VoiceWakeOverlayController.shared.isVisible) + } + + // MARK: - Private + + private func clearSession() { + self.session = nil + } + + /// Overlay dismiss completion callback (manual X, empty, auto-dismiss after send). + /// Ensures the wake-word recognizer is resumed if Voice Wake is enabled. + func overlayDidDismiss(token: UUID?) { + if let token, self.session?.token == token { + self.clearSession() + } + Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift new file mode 100644 index 00000000..8a258389 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift @@ -0,0 +1,76 @@ +import AppKit +import Foundation +import OSLog + +enum VoiceWakeChime: Codable, Equatable, Sendable { + case none + case system(name: String) + case custom(displayName: String, bookmark: Data) + + var systemName: String? { + if case let .system(name) = self { + return name + } + return nil + } + + var displayLabel: String { + switch self { + case .none: + "No Sound" + case let .system(name): + VoiceWakeChimeCatalog.displayName(for: name) + case let .custom(displayName, _): + displayName + } + } +} + +enum VoiceWakeChimeCatalog { + /// Options shown in the picker. + static var systemOptions: [String] { + SoundEffectCatalog.systemOptions + } + + static func displayName(for raw: String) -> String { + SoundEffectCatalog.displayName(for: raw) + } + + static func url(for name: String) -> URL? { + SoundEffectCatalog.url(for: name) + } +} + +@MainActor +enum VoiceWakeChimePlayer { + private static let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.chime") + private static var lastSound: NSSound? + + static func play(_ chime: VoiceWakeChime, reason: String? = nil) { + guard let sound = self.sound(for: chime) else { return } + if let reason { + self.logger.log(level: .info, "chime play reason=\(reason, privacy: .public)") + } else { + self.logger.log(level: .info, "chime play") + } + DiagnosticsFileLog.shared.log(category: "voicewake.chime", event: "play", fields: [ + "reason": reason ?? "", + "chime": chime.displayLabel, + "systemName": chime.systemName ?? "", + ]) + SoundEffectPlayer.play(sound) + } + + private static func sound(for chime: VoiceWakeChime) -> NSSound? { + switch chime { + case .none: + nil + + case let .system(name): + SoundEffectPlayer.sound(named: name) + + case let .custom(_, bookmark): + SoundEffectPlayer.sound(from: bookmark) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift new file mode 100644 index 00000000..0c6ea54c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift @@ -0,0 +1,73 @@ +import Foundation +import OSLog + +enum VoiceWakeForwarder { + private static let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.forward") + + static func prefixedTranscript(_ transcript: String, machineName: String? = nil) -> String { + let resolvedMachine = machineName + .flatMap { name -> String? in + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + ?? Host.current().localizedName + ?? ProcessInfo.processInfo.hostName + + let safeMachine = resolvedMachine.isEmpty ? "this Mac" : resolvedMachine + return """ + User talked via voice recognition on \(safeMachine) - repeat prompt first \ + + remember some words might be incorrectly transcribed. + + \(transcript) + """ + } + + enum VoiceWakeForwardError: LocalizedError, Equatable { + case rpcFailed(String) + + var errorDescription: String? { + switch self { + case let .rpcFailed(message): message + } + } + } + + struct ForwardOptions: Sendable { + var sessionKey: String = "main" + var thinking: String = "low" + var deliver: Bool = true + var to: String? + var channel: GatewayAgentChannel = .webchat + } + + @discardableResult + static func forward( + transcript: String, + options: ForwardOptions = ForwardOptions()) async -> Result + { + let payload = Self.prefixedTranscript(transcript) + let deliver = options.channel.shouldDeliver(options.deliver) + let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( + message: payload, + sessionKey: options.sessionKey, + thinking: options.thinking, + deliver: deliver, + to: options.to, + channel: options.channel)) + + if result.ok { + self.logger.info("voice wake forward ok") + return .success(()) + } + + let message = result.error ?? "agent rpc unavailable" + self.logger.error("voice wake forward failed: \(message, privacy: .public)") + return .failure(.rpcFailed(message)) + } + + static func checkConnection() async -> Result { + let status = await GatewayConnection.shared.status() + if status.ok { return .success(()) } + return .failure(.rpcFailed(status.error ?? "agent rpc unreachable")) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift new file mode 100644 index 00000000..af4fae35 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift @@ -0,0 +1,66 @@ +import Foundation +import OpenClawKit +import OSLog + +@MainActor +final class VoiceWakeGlobalSettingsSync { + static let shared = VoiceWakeGlobalSettingsSync() + + private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.sync") + private var task: Task? + + private struct VoiceWakePayload: Codable, Equatable { + let triggers: [String] + } + + func start() { + guard self.task == nil else { return } + self.task = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + do { + try await GatewayConnection.shared.refresh() + } catch { + // Not configured / not reachable yet. + } + + await self.refreshFromGateway() + + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) + for await push in stream { + if Task.isCancelled { return } + await self.handle(push: push) + } + + // If the stream finishes (gateway shutdown / reconnect), loop and resubscribe. + try? await Task.sleep(nanoseconds: 600_000_000) + } + } + } + + func stop() { + self.task?.cancel() + self.task = nil + } + + private func refreshFromGateway() async { + do { + let triggers = try await GatewayConnection.shared.voiceWakeGetTriggers() + AppStateStore.shared.applyGlobalVoiceWakeTriggers(triggers) + } catch { + // Best-effort only. + } + } + + func handle(push: GatewayPush) async { + guard case let .event(evt) = push else { return } + guard evt.event == "voicewake.changed" else { return } + guard let payload = evt.payload else { return } + do { + let decoded = try GatewayPayloadDecoding.decode(payload, as: VoiceWakePayload.self) + AppStateStore.shared.applyGlobalVoiceWakeTriggers(decoded.triggers) + } catch { + self.logger.error("failed to decode voicewake.changed: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeHelpers.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeHelpers.swift new file mode 100644 index 00000000..98cdc0cb --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeHelpers.swift @@ -0,0 +1,24 @@ +import Foundation + +func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] { + let cleaned = words + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .prefix(voiceWakeMaxWords) + .map { String($0.prefix(voiceWakeMaxWordLength)) } + return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned +} + +func normalizeLocaleIdentifier(_ raw: String) -> String { + var trimmed = raw + if let at = trimmed.firstIndex(of: "@") { + trimmed = String(trimmed[..? + var autoSendTask: Task? + var autoSendToken: UUID? + var activeToken: UUID? + var activeSource: Source? + var lastLevelUpdate: TimeInterval = 0 + + let width: CGFloat = 360 + let padding: CGFloat = 10 + let buttonWidth: CGFloat = 36 + let spacing: CGFloat = 8 + let verticalPadding: CGFloat = 8 + let maxHeight: CGFloat = 400 + let minHeight: CGFloat = 48 + let closeOverflow: CGFloat = 10 + let levelUpdateInterval: TimeInterval = 1.0 / 12.0 + + enum DismissReason { case explicit, empty } + enum SendOutcome { case sent, empty } + enum GuardOutcome { case accept, dropMismatch, dropNoActive } + + init(enableUI: Bool = true) { + self.enableUI = enableUI + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Session.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Session.swift new file mode 100644 index 00000000..f021eac9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Session.swift @@ -0,0 +1,281 @@ +import AppKit +import QuartzCore + +extension VoiceWakeOverlayController { + @discardableResult + func startSession( + token: UUID = UUID(), + source: Source, + transcript: String, + attributed: NSAttributedString? = nil, + forwardEnabled: Bool = false, + isFinal: Bool = false) -> UUID + { + let message = """ + overlay session_start source=\(source.rawValue) \ + len=\(transcript.count) + """ + self.logger.log(level: .info, "\(message)") + self.activeToken = token + self.activeSource = source + self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil + self.model.text = transcript + self.model.isFinal = isFinal + self.model.forwardEnabled = forwardEnabled + self.model.isSending = false + self.model.isEditing = false + self.model.attributed = attributed ?? self.makeAttributed(from: transcript) + self.model.level = 0 + self.lastLevelUpdate = 0 + self.present() + self.updateWindowFrame(animate: true) + return token + } + + func snapshot() -> (token: UUID?, source: Source?, text: String, isVisible: Bool) { + (self.activeToken, self.activeSource, self.model.text, self.model.isVisible) + } + + func updatePartial(token: UUID, transcript: String, attributed: NSAttributedString? = nil) { + guard self.guardToken(token, context: "partial") else { return } + guard !self.model.isFinal else { return } + let message = """ + overlay partial token=\(token.uuidString) \ + len=\(transcript.count) + """ + self.logger.log(level: .info, "\(message)") + self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil + self.model.text = transcript + self.model.isFinal = false + self.model.forwardEnabled = false + self.model.isSending = false + self.model.isEditing = false + self.model.attributed = attributed ?? self.makeAttributed(from: transcript) + self.model.level = 0 + self.present() + self.updateWindowFrame(animate: true) + } + + func presentFinal( + token: UUID, + transcript: String, + autoSendAfter delay: TimeInterval?, + sendChime: VoiceWakeChime = .none, + attributed: NSAttributedString? = nil) + { + guard self.guardToken(token, context: "final") else { return } + let message = """ + overlay presentFinal token=\(token.uuidString) \ + len=\(transcript.count) \ + autoSendAfter=\(delay ?? -1) \ + forwardEnabled=\(!transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + """ + self.logger.log(level: .info, "\(message)") + self.autoSendTask?.cancel() + self.autoSendToken = token + self.model.text = transcript + self.model.isFinal = true + self.model.forwardEnabled = !transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + self.model.isSending = false + self.model.isEditing = false + self.model.attributed = attributed ?? self.makeAttributed(from: transcript) + self.model.level = 0 + self.present() + if let delay { + if delay <= 0 { + self.logger.log(level: .info, "overlay autoSend immediate token=\(token.uuidString)") + VoiceSessionCoordinator.shared.sendNow(token: token, reason: "autoSendImmediate") + } else { + self.scheduleAutoSend(token: token, after: delay) + } + } + } + + func userBeganEditing() { + self.autoSendTask?.cancel() + self.model.isSending = false + self.model.isEditing = true + } + + func cancelEditingAndDismiss() { + self.autoSendTask?.cancel() + self.model.isSending = false + self.model.isEditing = false + self.dismiss(reason: .explicit) + } + + func endEditing() { + self.model.isEditing = false + } + + func updateText(_ text: String) { + self.model.text = text + self.model.isSending = false + self.model.attributed = self.makeAttributed(from: text) + self.updateWindowFrame(animate: true) + } + + /// UI-only path: show sending state and dismiss; actual forwarding is handled by the coordinator. + func beginSendUI(token: UUID, sendChime: VoiceWakeChime = .none) { + guard self.guardToken(token, context: "beginSendUI") else { return } + self.autoSendTask?.cancel(); self.autoSendToken = nil + let message = """ + overlay beginSendUI token=\(token.uuidString) \ + isSending=\(self.model.isSending) \ + forwardEnabled=\(self.model.forwardEnabled) \ + textLen=\(self.model.text.count) + """ + self.logger.log(level: .info, "\(message)") + if self.model.isSending { return } + self.model.isEditing = false + + if sendChime != .none { + let message = "overlay beginSendUI playing sendChime=\(String(describing: sendChime))" + self.logger.log(level: .info, "\(message)") + VoiceWakeChimePlayer.play(sendChime, reason: "overlay.send") + } + + self.model.isSending = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) { + self.logger.log( + level: .info, + "overlay beginSendUI dismiss ticking token=\(self.activeToken?.uuidString ?? "nil")") + self.dismiss(token: token, reason: .explicit, outcome: .sent) + } + } + + func requestSend(token: UUID? = nil, reason: String = "overlay_request") { + guard self.guardToken(token, context: "requestSend") else { return } + guard let active = token ?? self.activeToken else { return } + VoiceSessionCoordinator.shared.sendNow(token: active, reason: reason) + } + + func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) { + guard self.guardToken(token, context: "dismiss") else { return } + let message = """ + overlay dismiss token=\(self.activeToken?.uuidString ?? "nil") \ + reason=\(String(describing: reason)) \ + outcome=\(String(describing: outcome)) \ + visible=\(self.model.isVisible) \ + sending=\(self.model.isSending) + """ + self.logger.log(level: .info, "\(message)") + self.autoSendTask?.cancel(); self.autoSendToken = nil + self.model.isSending = false + self.model.isEditing = false + + if !self.enableUI { + self.model.isVisible = false + self.model.level = 0 + self.lastLevelUpdate = 0 + self.activeToken = nil + self.activeSource = nil + return + } + guard let window else { + if ProcessInfo.processInfo.isRunningTests { + self.model.isVisible = false + self.model.level = 0 + self.activeToken = nil + self.activeSource = nil + } + return + } + let target = self.dismissTargetFrame(for: window.frame, reason: reason, outcome: outcome) + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + if let target { + window.animator().setFrame(target, display: true) + } + window.animator().alphaValue = 0 + } completionHandler: { + Task { @MainActor in + let dismissedToken = self.activeToken + window.orderOut(nil) + self.model.isVisible = false + self.model.level = 0 + self.lastLevelUpdate = 0 + self.activeToken = nil + self.activeSource = nil + if outcome == .empty { + AppStateStore.shared.blinkOnce() + } else if outcome == .sent { + AppStateStore.shared.celebrateSend() + } + AppStateStore.shared.stopVoiceEars() + VoiceSessionCoordinator.shared.overlayDidDismiss(token: dismissedToken) + } + } + } + + func updateLevel(token: UUID, _ level: Double) { + guard self.guardToken(token, context: "level") else { return } + guard self.model.isVisible else { return } + let now = ProcessInfo.processInfo.systemUptime + if level != 0, now - self.lastLevelUpdate < self.levelUpdateInterval { + return + } + self.lastLevelUpdate = now + self.model.level = max(0, min(1, level)) + } + + private func guardToken(_ token: UUID?, context: String) -> Bool { + switch Self.evaluateToken(active: self.activeToken, incoming: token) { + case .accept: + return true + case .dropMismatch: + self.logger.log( + level: .info, + """ + overlay drop \(context, privacy: .public) token_mismatch \ + active=\(self.activeToken?.uuidString ?? "nil", privacy: .public) \ + got=\(token?.uuidString ?? "nil", privacy: .public) + """) + return false + case .dropNoActive: + self.logger.log(level: .info, "overlay drop \(context, privacy: .public) no_active") + return false + } + } + + nonisolated static func evaluateToken(active: UUID?, incoming: UUID?) -> GuardOutcome { + guard let active else { return .dropNoActive } + if let incoming, incoming != active { return .dropMismatch } + return .accept + } + + func scheduleAutoSend(token: UUID, after delay: TimeInterval) { + self.logger.log( + level: .info, + """ + overlay scheduleAutoSend token=\(token.uuidString) \ + after=\(delay) + """) + self.autoSendTask?.cancel() + self.autoSendToken = token + self.autoSendTask = Task { [weak self, token] in + let nanos = UInt64(max(0, delay) * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanos) + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self else { return } + guard self.guardToken(token, context: "autoSend") else { return } + self.logger.log( + level: .info, + "overlay autoSend firing token=\(token.uuidString, privacy: .public)") + VoiceSessionCoordinator.shared.sendNow(token: token, reason: "autoSendDelay") + self.autoSendTask = nil + } + } + } + + func makeAttributed(from text: String) -> NSAttributedString { + NSAttributedString( + string: text, + attributes: [ + .foregroundColor: NSColor.labelColor, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + ]) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Testing.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Testing.swift new file mode 100644 index 00000000..af1111df --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Testing.swift @@ -0,0 +1,49 @@ +import AppKit + +#if DEBUG +@MainActor +extension VoiceWakeOverlayController { + static func exerciseForTesting() async { + let controller = VoiceWakeOverlayController(enableUI: false) + let token = controller.startSession( + source: .wakeWord, + transcript: "Hello", + attributed: nil, + forwardEnabled: true, + isFinal: false) + + controller.updatePartial(token: token, transcript: "Hello world") + controller.presentFinal(token: token, transcript: "Final", autoSendAfter: nil) + controller.userBeganEditing() + controller.endEditing() + controller.updateText("Edited text") + + _ = controller.makeAttributed(from: "Attributed") + _ = controller.targetFrame() + _ = controller.measuredHeight() + _ = controller.dismissTargetFrame( + for: NSRect(x: 0, y: 0, width: 120, height: 60), + reason: .empty, + outcome: .empty) + _ = controller.dismissTargetFrame( + for: NSRect(x: 0, y: 0, width: 120, height: 60), + reason: .explicit, + outcome: .sent) + _ = controller.dismissTargetFrame( + for: NSRect(x: 0, y: 0, width: 120, height: 60), + reason: .explicit, + outcome: .empty) + + controller.beginSendUI(token: token, sendChime: .none) + try? await Task.sleep(nanoseconds: 350_000_000) + + controller.scheduleAutoSend(token: token, after: 10) + controller.autoSendTask?.cancel() + controller.autoSendTask = nil + controller.autoSendToken = nil + + controller.dismiss(token: token, reason: .explicit, outcome: .sent) + controller.bringToFrontIfVisible() + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift new file mode 100644 index 00000000..fb5526a8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift @@ -0,0 +1,141 @@ +import AppKit +import QuartzCore +import SwiftUI + +extension VoiceWakeOverlayController { + func present() { + if !self.enableUI || ProcessInfo.processInfo.isRunningTests { + if !self.model.isVisible { + self.model.isVisible = true + } + return + } + self.ensureWindow() + self.hostingView?.rootView = VoiceWakeOverlayView(controller: self) + let target = self.targetFrame() + + guard let window else { return } + if !self.model.isVisible { + self.model.isVisible = true + self.logger.log( + level: .info, + "overlay present windowShown textLen=\(self.model.text.count, privacy: .public)") + // Keep the status item in “listening” mode until we explicitly dismiss the overlay. + AppStateStore.shared.triggerVoiceEars(ttl: nil) + let start = target.offsetBy(dx: 0, dy: -6) + window.setFrame(start, display: true) + window.alphaValue = 0 + window.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(target, display: true) + window.animator().alphaValue = 1 + } + } else { + self.updateWindowFrame(animate: true) + window.orderFrontRegardless() + } + } + + private func ensureWindow() { + if self.window != nil { return } + let borderPad = self.closeOverflow + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: self.width + borderPad * 2, height: 60 + borderPad * 2), + styleMask: [.nonactivatingPanel, .borderless], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.level = Self.preferredWindowLevel + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + panel.hidesOnDeactivate = false + panel.isMovable = false + panel.isFloatingPanel = true + panel.becomesKeyOnlyIfNeeded = true + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + + let host = NSHostingView(rootView: VoiceWakeOverlayView(controller: self)) + host.translatesAutoresizingMaskIntoConstraints = false + panel.contentView = host + self.hostingView = host + self.window = panel + } + + /// Reassert window ordering when other panels are shown. + func bringToFrontIfVisible() { + guard self.model.isVisible, let window = self.window else { return } + window.level = Self.preferredWindowLevel + window.orderFrontRegardless() + } + + func targetFrame() -> NSRect { + guard let screen = NSScreen.main else { return .zero } + let height = self.measuredHeight() + let size = NSSize(width: self.width + self.closeOverflow * 2, height: height + self.closeOverflow * 2) + let visible = screen.visibleFrame + let origin = CGPoint( + x: visible.maxX - size.width, + y: visible.maxY - size.height) + return NSRect(origin: origin, size: size) + } + + func updateWindowFrame(animate: Bool = false) { + guard let window else { return } + let frame = self.targetFrame() + if animate { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(frame, display: true) + } + } else { + window.setFrame(frame, display: true) + } + } + + func measuredHeight() -> CGFloat { + let attributed = self.model.attributed.length > 0 ? self.model.attributed : self + .makeAttributed(from: self.model.text) + let maxWidth = self.width - (self.padding * 2) - self.spacing - self.buttonWidth + + let textInset = NSSize(width: 2, height: 6) + let lineFragmentPadding: CGFloat = 0 + let containerWidth = max(1, maxWidth - (textInset.width * 2) - (lineFragmentPadding * 2)) + + let storage = NSTextStorage(attributedString: attributed) + let container = NSTextContainer(containerSize: CGSize(width: containerWidth, height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = lineFragmentPadding + container.lineBreakMode = .byWordWrapping + + let layout = NSLayoutManager() + layout.addTextContainer(container) + storage.addLayoutManager(layout) + + _ = layout.glyphRange(for: container) + let used = layout.usedRect(for: container) + + let contentHeight = ceil(used.height + (textInset.height * 2)) + let total = contentHeight + self.verticalPadding * 2 + self.model.isOverflowing = total > self.maxHeight + return max(self.minHeight, min(total, self.maxHeight)) + } + + func dismissTargetFrame(for frame: NSRect, reason: DismissReason, outcome: SendOutcome) -> NSRect? { + switch (reason, outcome) { + case (.empty, _): + let scale: CGFloat = 0.95 + let newSize = NSSize(width: frame.size.width * scale, height: frame.size.height * scale) + let dx = (frame.size.width - newSize.width) / 2 + let dy = (frame.size.height - newSize.height) / 2 + return NSRect(x: frame.origin.x + dx, y: frame.origin.y + dy, width: newSize.width, height: newSize.height) + case (.explicit, .sent): + return frame.offsetBy(dx: 8, dy: 6) + default: + return frame + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift new file mode 100644 index 00000000..bbbed729 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayTextViews.swift @@ -0,0 +1,207 @@ +import AppKit +import SwiftUI + +struct TranscriptTextView: NSViewRepresentable { + @Binding var text: String + var attributed: NSAttributedString + var isFinal: Bool + var isOverflowing: Bool + var onBeginEditing: () -> Void + var onEscape: () -> Void + var onEndEditing: () -> Void + var onSend: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let textView = TranscriptNSTextView() + textView.delegate = context.coordinator + textView.drawsBackground = false + textView.isRichText = true + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.font = .systemFont(ofSize: 13, weight: .regular) + textView.textContainer?.lineBreakMode = .byWordWrapping + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainerInset = NSSize(width: 2, height: 6) + + textView.minSize = .zero + textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + + textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true + + textView.textStorage?.setAttributedString(self.attributed) + textView.typingAttributes = [ + .foregroundColor: NSColor.labelColor, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + ] + textView.focusRingType = .none + textView.onSend = { [weak textView] in + textView?.window?.makeFirstResponder(nil) + self.onSend() + } + textView.onBeginEditing = self.onBeginEditing + textView.onEscape = self.onEscape + textView.onEndEditing = self.onEndEditing + + let scroll = NSScrollView() + scroll.drawsBackground = false + scroll.borderType = .noBorder + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.scrollerStyle = .overlay + scroll.hasHorizontalScroller = false + scroll.documentView = textView + return scroll + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? TranscriptNSTextView else { return } + let isEditing = scrollView.window?.firstResponder == textView + if isEditing { + return + } + + if !textView.attributedString().isEqual(to: self.attributed) { + context.coordinator.isProgrammaticUpdate = true + defer { context.coordinator.isProgrammaticUpdate = false } + textView.textStorage?.setAttributedString(self.attributed) + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: TranscriptTextView + var isProgrammaticUpdate = false + + init(_ parent: TranscriptTextView) { + self.parent = parent + } + + func textDidBeginEditing(_ notification: Notification) { + self.parent.onBeginEditing() + } + + func textDidEndEditing(_ notification: Notification) { + self.parent.onEndEditing() + } + + func textDidChange(_ notification: Notification) { + guard !self.isProgrammaticUpdate else { return } + guard let view = notification.object as? NSTextView else { return } + guard view.window?.firstResponder === view else { return } + self.parent.text = view.string + } + } +} + +// MARK: - Vibrant display label + +struct VibrantLabelView: NSViewRepresentable { + var attributed: NSAttributedString + var onTap: () -> Void + + func makeNSView(context: Context) -> NSView { + let label = NSTextField(labelWithAttributedString: self.attributed) + label.isEditable = false + label.isBordered = false + label.drawsBackground = false + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 0 + label.usesSingleLineMode = false + label.cell?.wraps = true + label.cell?.isScrollable = false + label.setContentHuggingPriority(.defaultLow, for: .horizontal) + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + label.setContentHuggingPriority(.required, for: .vertical) + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.textColor = .labelColor + + let container = ClickCatcher(onTap: onTap) + container.addSubview(label) + + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: container.leadingAnchor), + label.trailingAnchor.constraint(equalTo: container.trailingAnchor), + label.topAnchor.constraint(equalTo: container.topAnchor), + label.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + return container + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let container = nsView as? ClickCatcher, + let label = container.subviews.first as? NSTextField else { return } + label.attributedStringValue = self.attributed.strippingForegroundColor() + label.textColor = .labelColor + } +} + +private final class ClickCatcher: NSView { + let onTap: () -> Void + init(onTap: @escaping () -> Void) { + self.onTap = onTap + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func mouseDown(with event: NSEvent) { + super.mouseDown(with: event) + self.onTap() + } +} + +private final class TranscriptNSTextView: NSTextView { + var onSend: (() -> Void)? + var onBeginEditing: (() -> Void)? + var onEndEditing: (() -> Void)? + var onEscape: (() -> Void)? + + override func becomeFirstResponder() -> Bool { + self.onBeginEditing?() + return super.becomeFirstResponder() + } + + override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + self.onEndEditing?() + return result + } + + override func keyDown(with event: NSEvent) { + let isReturn = event.keyCode == 36 + let isEscape = event.keyCode == 53 + if isEscape { + self.onEscape?() + return + } + // Keep IME candidate confirmation behavior: Return should commit marked text first. + if isReturn, self.hasMarkedText() { + super.keyDown(with: event) + return + } + if isReturn, event.modifierFlags.contains(.command) { + self.onSend?() + return + } + if isReturn { + if event.modifierFlags.contains(.shift) { + super.insertNewline(nil) + return + } + self.onSend?() + return + } + super.keyDown(with: event) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayView.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayView.swift new file mode 100644 index 00000000..516da776 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeOverlayView.swift @@ -0,0 +1,188 @@ +import SwiftUI + +struct VoiceWakeOverlayView: View { + var controller: VoiceWakeOverlayController + @FocusState private var textFocused: Bool + @State private var isHovering: Bool = false + @State private var closeHovering: Bool = false + + var body: some View { + ZStack(alignment: .topLeading) { + HStack(alignment: .top, spacing: 8) { + if self.controller.model.isEditing { + TranscriptTextView( + text: Binding( + get: { self.controller.model.text }, + set: { self.controller.updateText($0) }), + attributed: self.controller.model.attributed, + isFinal: self.controller.model.isFinal, + isOverflowing: self.controller.model.isOverflowing, + onBeginEditing: { + self.controller.userBeganEditing() + }, + onEscape: { + self.controller.cancelEditingAndDismiss() + }, + onEndEditing: { + self.controller.endEditing() + }, + onSend: { + self.controller.requestSend() + }) + .focused(self.$textFocused) + .frame(maxWidth: .infinity, minHeight: 32, maxHeight: .infinity, alignment: .topLeading) + .id("editing") + } else { + VibrantLabelView( + attributed: self.controller.model.attributed, + onTap: { + self.controller.userBeganEditing() + self.textFocused = true + }) + .frame(maxWidth: .infinity, minHeight: 32, maxHeight: .infinity, alignment: .topLeading) + .focusable(false) + .id("display") + } + + Button { + self.controller.requestSend() + } label: { + let sending = self.controller.model.isSending + let level = self.controller.model.level + ZStack { + GeometryReader { geo in + let width = geo.size.width + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.accentColor.opacity(0.12)) + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.accentColor.opacity(0.25)) + .frame(width: width * max(0, min(1, level)), alignment: .leading) + .animation(.easeOut(duration: 0.08), value: level) + } + .frame(height: 28) + + ZStack { + Image(systemName: "paperplane.fill") + .opacity(sending ? 0 : 1) + .scaleEffect(sending ? 0.5 : 1) + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .opacity(sending ? 1 : 0) + .scaleEffect(sending ? 1.05 : 0.8) + } + .imageScale(.small) + } + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .frame(width: 32, height: 28) + .animation(.spring(response: 0.35, dampingFraction: 0.78), value: sending) + } + .buttonStyle(.plain) + .disabled(!self.controller.model.forwardEnabled || self.controller.model.isSending) + .keyboardShortcut(.return, modifiers: [.command]) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background { + OverlayBackground() + .equatable() + } + .shadow(color: Color.black.opacity(0.22), radius: 14, x: 0, y: -2) + .onHover { self.isHovering = $0 } + + // Close button rendered above and outside the clipped bubble + CloseButtonOverlay( + isVisible: self.controller.model.isEditing || self.isHovering || self.closeHovering, + onHover: { self.closeHovering = $0 }, + onClose: { self.controller.cancelEditingAndDismiss() }) + } + .padding(.top, self.controller.closeOverflow) + .padding(.leading, self.controller.closeOverflow) + .padding(.trailing, self.controller.closeOverflow) + .padding(.bottom, self.controller.closeOverflow) + .onAppear { + self.updateFocusState(visible: self.controller.model.isVisible, editing: self.controller.model.isEditing) + } + .onChange(of: self.controller.model.isVisible) { _, visible in + self.updateFocusState(visible: visible, editing: self.controller.model.isEditing) + } + .onChange(of: self.controller.model.isEditing) { _, editing in + self.updateFocusState(visible: self.controller.model.isVisible, editing: editing) + } + .onChange(of: self.controller.model.attributed) { _, _ in + self.controller.updateWindowFrame(animate: true) + } + } + + private func updateFocusState(visible: Bool, editing: Bool) { + let shouldFocus = visible && editing + guard self.textFocused != shouldFocus else { return } + self.textFocused = shouldFocus + } +} + +private struct OverlayBackground: View { + var body: some View { + let shape = RoundedRectangle(cornerRadius: 12, style: .continuous) + VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) + .clipShape(shape) + .overlay(shape.strokeBorder(Color.white.opacity(0.16), lineWidth: 1)) + } +} + +extension OverlayBackground: @MainActor Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + true + } +} + +struct CloseHoverButton: View { + var onClose: () -> Void + + var body: some View { + Button(action: self.onClose) { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(Color.white.opacity(0.85)) + .frame(width: 22, height: 22) + .background(Color.black.opacity(0.35)) + .clipShape(Circle()) + .shadow(color: Color.black.opacity(0.35), radius: 6, y: 2) + } + .buttonStyle(.plain) + .focusable(false) + .contentShape(Circle()) + .padding(6) + } +} + +struct CloseButtonOverlay: View { + var isVisible: Bool + var onHover: (Bool) -> Void + var onClose: () -> Void + + var body: some View { + Group { + if self.isVisible { + Button(action: self.onClose) { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(Color.white.opacity(0.9)) + .frame(width: 22, height: 22) + .background(Color.black.opacity(0.4)) + .clipShape(Circle()) + .shadow(color: Color.black.opacity(0.45), radius: 10, x: 0, y: 3) + .shadow(color: Color.black.opacity(0.2), radius: 2, x: 0, y: 0) + } + .buttonStyle(.plain) + .focusable(false) + .contentShape(Circle()) + .padding(6) + .onHover { self.onHover($0) } + .offset(x: -9, y: -9) + .transition(.opacity) + } + } + .allowsHitTesting(self.isVisible) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift new file mode 100644 index 00000000..b7e2d329 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift @@ -0,0 +1,813 @@ +import AVFoundation +import Foundation +import OSLog +import Speech +import SwabbleKit +#if canImport(AppKit) +import AppKit +#endif + +/// Background listener that keeps the voice-wake pipeline alive outside the settings test view. +actor VoiceWakeRuntime { + static let shared = VoiceWakeRuntime() + + enum ListeningState { case idle, voiceWake, pushToTalk } + + private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.runtime") + + private var recognizer: SFSpeechRecognizer? + // Lazily created on start to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth + // headphones into the low-quality headset profile even if Voice Wake is disabled. + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var recognitionGeneration: Int = 0 // drop stale callbacks after restarts + private var lastHeard: Date? + private var noiseFloorRMS: Double = 1e-4 + private var captureStartedAt: Date? + private var captureTask: Task? + private var capturedTranscript: String = "" + private var isCapturing: Bool = false + private var heardBeyondTrigger: Bool = false + private var triggerChimePlayed: Bool = false + private var committedTranscript: String = "" + private var volatileTranscript: String = "" + private var cooldownUntil: Date? + private var currentConfig: RuntimeConfig? + private var listeningState: ListeningState = .idle + private var overlayToken: UUID? + private var activeTriggerEndTime: TimeInterval? + private var scheduledRestartTask: Task? + private var lastLoggedText: String? + private var lastLoggedAt: Date? + private var lastTapLogAt: Date? + private var lastCallbackLogAt: Date? + private var lastTranscript: String? + private var lastTranscriptAt: Date? + private var preDetectTask: Task? + private var isStarting: Bool = false + private var triggerOnlyTask: Task? + + /// Tunables + /// Silence threshold once we've captured user speech (post-trigger). + private let silenceWindow: TimeInterval = 2.0 + /// Silence threshold when we only heard the trigger but no post-trigger speech yet. + private let triggerOnlySilenceWindow: TimeInterval = 5.0 + // Maximum capture duration from trigger until we force-send, to avoid runaway sessions. + private let captureHardStop: TimeInterval = 120.0 + private let debounceAfterSend: TimeInterval = 0.35 + // Voice activity detection parameters (RMS-based). + private let minSpeechRMS: Double = 1e-3 + private let speechBoostFactor: Double = 6.0 // how far above noise floor we require to mark speech + private let preDetectSilenceWindow: TimeInterval = 1.0 + private let triggerPauseWindow: TimeInterval = 0.55 + + /// Stops the active Speech pipeline without clearing the stored config, so we can restart cleanly. + private func haltRecognitionPipeline() { + // Bump generation first so any in-flight callbacks from the cancelled task get dropped. + self.recognitionGeneration &+= 1 + self.recognitionTask?.cancel() + self.recognitionTask = nil + self.recognitionRequest?.endAudio() + self.recognitionRequest = nil + self.audioEngine?.inputNode.removeTap(onBus: 0) + self.audioEngine?.stop() + // Release the engine so we also release any audio session/resources when Voice Wake is idle. + self.audioEngine = nil + } + + struct RuntimeConfig: Equatable { + let triggers: [String] + let micID: String? + let localeID: String? + let triggerChime: VoiceWakeChime + let sendChime: VoiceWakeChime + } + + private struct RecognitionUpdate { + let transcript: String? + let segments: [WakeWordSegment] + let isFinal: Bool + let error: Error? + let generation: Int + } + + func refresh(state: AppState) async { + let snapshot = await MainActor.run { () -> (Bool, RuntimeConfig) in + let enabled = state.swabbleEnabled + let config = RuntimeConfig( + triggers: sanitizeVoiceWakeTriggers(state.swabbleTriggerWords), + micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, + localeID: state.voiceWakeLocaleID.isEmpty ? nil : state.voiceWakeLocaleID, + triggerChime: state.voiceWakeTriggerChime, + sendChime: state.voiceWakeSendChime) + return (enabled, config) + } + + guard voiceWakeSupported, snapshot.0 else { + self.stop() + return + } + + guard PermissionManager.voiceWakePermissionsGranted() else { + self.logger.debug("voicewake runtime not starting: permissions missing") + self.stop() + return + } + + let config = snapshot.1 + + if self.isStarting { + return + } + + if self.scheduledRestartTask != nil, config == self.currentConfig, self.recognitionTask == nil { + return + } + + if self.scheduledRestartTask != nil { + self.scheduledRestartTask?.cancel() + self.scheduledRestartTask = nil + } + + if config == self.currentConfig, self.recognitionTask != nil { + return + } + + self.stop() + await self.start(with: config) + } + + private func start(with config: RuntimeConfig) async { + if self.isStarting { + return + } + self.isStarting = true + defer { self.isStarting = false } + do { + self.recognitionGeneration &+= 1 + let generation = self.recognitionGeneration + + self.configureSession(localeID: config.localeID) + + guard let recognizer, recognizer.isAvailable else { + self.logger.error("voicewake runtime: speech recognizer unavailable") + return + } + + self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + self.recognitionRequest?.shouldReportPartialResults = true + self.recognitionRequest?.taskHint = .dictation + guard let request = self.recognitionRequest else { return } + + // Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP. + if self.audioEngine == nil { + self.audioEngine = AVAudioEngine() + } + guard let audioEngine = self.audioEngine else { return } + + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoiceWakeRuntime", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + + let input = audioEngine.inputNode + let format = input.outputFormat(forBus: 0) + guard format.channelCount > 0, format.sampleRate > 0 else { + throw NSError( + domain: "VoiceWakeRuntime", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) + } + input.removeTap(onBus: 0) + input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak self, weak request] buffer, _ in + request?.append(buffer) + guard let rms = Self.rmsLevel(buffer: buffer) else { return } + Task.detached { [weak self] in + await self?.noteAudioLevel(rms: rms) + await self?.noteAudioTap(rms: rms) + } + } + + audioEngine.prepare() + try audioEngine.start() + + self.currentConfig = config + self.lastHeard = Date() + // Preserve any existing cooldownUntil so the debounce after send isn't wiped by a restart. + + self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in + guard let self else { return } + let transcript = result?.bestTranscription.formattedString + let segments = result.flatMap { result in + transcript + .map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) } + } ?? [] + let isFinal = result?.isFinal ?? false + Task { await self.noteRecognitionCallback(transcript: transcript, isFinal: isFinal, error: error) } + let update = RecognitionUpdate( + transcript: transcript, + segments: segments, + isFinal: isFinal, + error: error, + generation: generation) + Task { await self.handleRecognition(update, config: config) } + } + + let preferred = config.micID?.isEmpty == false ? config.micID! : "system-default" + self.logger.info( + "voicewake runtime input preferred=\(preferred, privacy: .public) " + + "\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)") + self.logger.info("voicewake runtime started") + DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "started", fields: [ + "locale": config.localeID ?? "", + "micID": config.micID ?? "", + ]) + } catch { + self.logger.error("voicewake runtime failed to start: \(error.localizedDescription, privacy: .public)") + self.stop() + } + } + + private func stop(dismissOverlay: Bool = true, cancelScheduledRestart: Bool = true) { + if cancelScheduledRestart { + self.scheduledRestartTask?.cancel() + self.scheduledRestartTask = nil + } + self.captureTask?.cancel() + self.captureTask = nil + self.isCapturing = false + self.capturedTranscript = "" + self.captureStartedAt = nil + self.triggerChimePlayed = false + self.lastTranscript = nil + self.lastTranscriptAt = nil + self.preDetectTask?.cancel() + self.preDetectTask = nil + self.triggerOnlyTask?.cancel() + self.triggerOnlyTask = nil + self.haltRecognitionPipeline() + self.recognizer = nil + self.currentConfig = nil + self.listeningState = .idle + self.activeTriggerEndTime = nil + self.logger.debug("voicewake runtime stopped") + DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped") + + let token = self.overlayToken + self.overlayToken = nil + guard dismissOverlay else { return } + Task { @MainActor in + if let token { + VoiceSessionCoordinator.shared.dismiss(token: token, reason: .explicit, outcome: .empty) + } else { + VoiceWakeOverlayController.shared.dismiss() + } + } + } + + private func configureSession(localeID: String?) { + let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier) + self.recognizer = SFSpeechRecognizer(locale: locale) + self.recognizer?.defaultTaskHint = .dictation + } + + private func handleRecognition(_ update: RecognitionUpdate, config: RuntimeConfig) async { + if update.generation != self.recognitionGeneration { + return // stale callback from a superseded recognizer session + } + if let error = update.error { + self.logger.debug("voicewake recognition error: \(error.localizedDescription, privacy: .public)") + } + + guard let transcript = update.transcript else { return } + + let now = Date() + if !transcript.isEmpty { + self.lastHeard = now + if !self.isCapturing { + self.lastTranscript = transcript + self.lastTranscriptAt = now + } + if self.isCapturing { + self.maybeLogRecognition( + transcript: transcript, + segments: update.segments, + triggers: config.triggers, + isFinal: update.isFinal, + match: nil, + usedFallback: false, + capturing: true) + let trimmed = Self.commandAfterTrigger( + transcript: transcript, + segments: update.segments, + triggerEndTime: self.activeTriggerEndTime, + triggers: config.triggers) + self.capturedTranscript = trimmed + self.updateHeardBeyondTrigger(withTrimmed: trimmed) + if update.isFinal { + self.committedTranscript = trimmed + self.volatileTranscript = "" + } else { + self.volatileTranscript = Self.delta(after: self.committedTranscript, current: trimmed) + } + + let attributed = Self.makeAttributed( + committed: self.committedTranscript, + volatile: self.volatileTranscript, + isFinal: update.isFinal) + let snapshot = self.committedTranscript + self.volatileTranscript + if let token = self.overlayToken { + await MainActor.run { + VoiceSessionCoordinator.shared.updatePartial( + token: token, + text: snapshot, + attributed: attributed) + } + } + } + } + + if self.isCapturing { return } + + let gateConfig = WakeWordGateConfig(triggers: config.triggers) + var usedFallback = false + var match = WakeWordGate.match(transcript: transcript, segments: update.segments, config: gateConfig) + if match == nil, update.isFinal { + match = self.textOnlyFallbackMatch( + transcript: transcript, + triggers: config.triggers, + config: gateConfig) + usedFallback = match != nil + } + self.maybeLogRecognition( + transcript: transcript, + segments: update.segments, + triggers: config.triggers, + isFinal: update.isFinal, + match: match, + usedFallback: usedFallback, + capturing: false) + + if let match { + if let cooldown = cooldownUntil, now < cooldown { + return + } + if usedFallback { + self.logger.info("voicewake runtime detected (text-only fallback) len=\(match.command.count)") + } else { + self.logger.info("voicewake runtime detected len=\(match.command.count)") + } + await self.beginCapture(command: match.command, triggerEndTime: match.triggerEndTime, config: config) + } else if !transcript.isEmpty, update.error == nil { + if self.isTriggerOnly(transcript: transcript, triggers: config.triggers) { + self.preDetectTask?.cancel() + self.preDetectTask = nil + self.scheduleTriggerOnlyPauseCheck(triggers: config.triggers, config: config) + } else { + self.triggerOnlyTask?.cancel() + self.triggerOnlyTask = nil + self.schedulePreDetectSilenceCheck( + triggers: config.triggers, + gateConfig: gateConfig, + config: config) + } + } + } + + private func maybeLogRecognition( + transcript: String, + segments: [WakeWordSegment], + triggers: [String], + isFinal: Bool, + match: WakeWordGateMatch?, + usedFallback: Bool, + capturing: Bool) + { + guard !transcript.isEmpty else { return } + let level = self.logger.logLevel + guard level == .debug || level == .trace else { return } + if transcript == self.lastLoggedText, !isFinal { + if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 { + return + } + } + self.lastLoggedText = transcript + self.lastLoggedAt = Date() + + let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) + let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 }) + let matchSummary = match.map { + "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)" + } ?? "match=false" + let segmentSummary = segments.map { seg in + let start = String(format: "%.2f", seg.start) + let end = String(format: "%.2f", seg.end) + return "\(seg.text)@\(start)-\(end)" + }.joined(separator: ", ") + + self.logger.debug( + "voicewake runtime transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " + + "isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " + + "capturing=\(capturing) fallback=\(usedFallback) " + + "\(matchSummary) segments=[\(segmentSummary, privacy: .private)]") + } + + private func noteAudioTap(rms: Double) { + let now = Date() + if let last = self.lastTapLogAt, now.timeIntervalSince(last) < 1.0 { + return + } + self.lastTapLogAt = now + let db = 20 * log10(max(rms, 1e-7)) + self.logger.debug( + "voicewake runtime audio tap rms=\(String(format: "%.6f", rms)) " + + "db=\(String(format: "%.1f", db)) capturing=\(self.isCapturing)") + } + + private func noteRecognitionCallback(transcript: String?, isFinal: Bool, error: Error?) { + guard transcript?.isEmpty ?? true else { return } + let now = Date() + if let last = self.lastCallbackLogAt, now.timeIntervalSince(last) < 1.0 { + return + } + self.lastCallbackLogAt = now + let errorSummary = error?.localizedDescription ?? "none" + self.logger.debug( + "voicewake runtime callback empty transcript isFinal=\(isFinal) error=\(errorSummary, privacy: .public)") + } + + private func scheduleTriggerOnlyPauseCheck(triggers: [String], config: RuntimeConfig) { + self.triggerOnlyTask?.cancel() + let lastSeenAt = self.lastTranscriptAt + let lastText = self.lastTranscript + let windowNanos = UInt64(self.triggerPauseWindow * 1_000_000_000) + self.triggerOnlyTask = Task { [weak self, lastSeenAt, lastText] in + try? await Task.sleep(nanoseconds: windowNanos) + guard let self else { return } + await self.triggerOnlyPauseCheck( + lastSeenAt: lastSeenAt, + lastText: lastText, + triggers: triggers, + config: config) + } + } + + private func schedulePreDetectSilenceCheck( + triggers: [String], + gateConfig: WakeWordGateConfig, + config: RuntimeConfig) + { + self.preDetectTask?.cancel() + let lastSeenAt = self.lastTranscriptAt + let lastText = self.lastTranscript + let windowNanos = UInt64(self.preDetectSilenceWindow * 1_000_000_000) + self.preDetectTask = Task { [weak self, lastSeenAt, lastText] in + try? await Task.sleep(nanoseconds: windowNanos) + guard let self else { return } + await self.preDetectSilenceCheck( + lastSeenAt: lastSeenAt, + lastText: lastText, + triggers: triggers, + gateConfig: gateConfig, + config: config) + } + } + + private func triggerOnlyPauseCheck( + lastSeenAt: Date?, + lastText: String?, + triggers: [String], + config: RuntimeConfig) async + { + guard !Task.isCancelled else { return } + guard !self.isCapturing else { return } + guard let lastSeenAt, let lastText else { return } + guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } + guard self.isTriggerOnly(transcript: lastText, triggers: triggers) else { return } + if let cooldown = self.cooldownUntil, Date() < cooldown { + return + } + self.logger.info("voicewake runtime detected (trigger-only pause)") + await self.beginCapture(command: "", triggerEndTime: nil, config: config) + } + + private func textOnlyFallbackMatch( + transcript: String, + triggers: [String], + config: WakeWordGateConfig) -> WakeWordGateMatch? + { + guard let command = VoiceWakeTextUtils.textOnlyCommand( + transcript: transcript, + triggers: triggers, + minCommandLength: config.minCommandLength, + trimWake: Self.trimmedAfterTrigger) + else { return nil } + return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) + } + + private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool { + guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false } + guard VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers) else { return false } + return Self.trimmedAfterTrigger(transcript, triggers: triggers).isEmpty + } + + private func preDetectSilenceCheck( + lastSeenAt: Date?, + lastText: String?, + triggers: [String], + gateConfig: WakeWordGateConfig, + config: RuntimeConfig) async + { + guard !Task.isCancelled else { return } + guard !self.isCapturing else { return } + guard let lastSeenAt, let lastText else { return } + guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } + guard let match = self.textOnlyFallbackMatch( + transcript: lastText, + triggers: triggers, + config: gateConfig) + else { return } + if let cooldown = self.cooldownUntil, Date() < cooldown { + return + } + self.logger.info("voicewake runtime detected (silence fallback) len=\(match.command.count)") + await self.beginCapture( + command: match.command, + triggerEndTime: match.triggerEndTime, + config: config) + } + + private func beginCapture(command: String, triggerEndTime: TimeInterval?, config: RuntimeConfig) async { + self.listeningState = .voiceWake + self.isCapturing = true + DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture") + self.capturedTranscript = command + self.committedTranscript = "" + self.volatileTranscript = command + self.captureStartedAt = Date() + self.cooldownUntil = nil + self.heardBeyondTrigger = !command.isEmpty + self.triggerChimePlayed = false + self.activeTriggerEndTime = triggerEndTime + self.preDetectTask?.cancel() + self.preDetectTask = nil + self.triggerOnlyTask?.cancel() + self.triggerOnlyTask = nil + + if config.triggerChime != .none, !self.triggerChimePlayed { + self.triggerChimePlayed = true + await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime, reason: "voicewake.trigger") } + } + + let snapshot = self.committedTranscript + self.volatileTranscript + let attributed = Self.makeAttributed( + committed: self.committedTranscript, + volatile: self.volatileTranscript, + isFinal: false) + self.overlayToken = await MainActor.run { + VoiceSessionCoordinator.shared.startSession( + source: .wakeWord, + text: snapshot, + attributed: attributed, + forwardEnabled: true) + } + + // Keep the "ears" boosted for the capture window so the status icon animates while recording. + await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } + + self.captureTask?.cancel() + self.captureTask = Task { [weak self] in + guard let self else { return } + await self.monitorCapture(config: config) + } + } + + private func monitorCapture(config: RuntimeConfig) async { + let start = self.captureStartedAt ?? Date() + let hardStop = start.addingTimeInterval(self.captureHardStop) + + while self.isCapturing { + let now = Date() + if now >= hardStop { + // Hard-stop after a maximum duration so we never leave the recognizer pinned open. + await self.finalizeCapture(config: config) + return + } + + let silenceThreshold = self.heardBeyondTrigger ? self.silenceWindow : self.triggerOnlySilenceWindow + if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceThreshold { + await self.finalizeCapture(config: config) + return + } + + try? await Task.sleep(nanoseconds: 200_000_000) + } + } + + private func finalizeCapture(config: RuntimeConfig) async { + guard self.isCapturing else { return } + self.isCapturing = false + // Disarm trigger matching immediately (before halting recognition) to avoid double-trigger + // races from late callbacks that arrive after isCapturing is cleared. + self.cooldownUntil = Date().addingTimeInterval(self.debounceAfterSend) + self.captureTask?.cancel() + self.captureTask = nil + + let finalTranscript = self.capturedTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "finalizeCapture", fields: [ + "finalLen": "\(finalTranscript.count)", + ]) + // Stop further recognition events so we don't retrigger immediately with buffered audio. + self.haltRecognitionPipeline() + self.capturedTranscript = "" + self.captureStartedAt = nil + self.lastHeard = nil + self.heardBeyondTrigger = false + self.triggerChimePlayed = false + self.activeTriggerEndTime = nil + self.lastTranscript = nil + self.lastTranscriptAt = nil + self.preDetectTask?.cancel() + self.preDetectTask = nil + self.triggerOnlyTask?.cancel() + self.triggerOnlyTask = nil + + await MainActor.run { AppStateStore.shared.stopVoiceEars() } + if let token = self.overlayToken { + await MainActor.run { VoiceSessionCoordinator.shared.updateLevel(token: token, 0) } + } + + let delay: TimeInterval = 0.0 + let sendChime = finalTranscript.isEmpty ? .none : config.sendChime + if let token = self.overlayToken { + await MainActor.run { + VoiceSessionCoordinator.shared.finalize( + token: token, + text: finalTranscript, + sendChime: sendChime, + autoSendAfter: delay) + } + } else if !finalTranscript.isEmpty { + if sendChime != .none { + await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") } + } + Task.detached { + await VoiceWakeForwarder.forward(transcript: finalTranscript) + } + } + self.overlayToken = nil + self.scheduleRestartRecognizer() + } + + // MARK: - Audio level handling + + private func noteAudioLevel(rms: Double) { + guard self.isCapturing else { return } + + // Update adaptive noise floor: faster when lower energy (quiet), slower when loud. + let alpha: Double = rms < self.noiseFloorRMS ? 0.08 : 0.01 + self.noiseFloorRMS = max(1e-7, self.noiseFloorRMS + (rms - self.noiseFloorRMS) * alpha) + + let threshold = max(self.minSpeechRMS, self.noiseFloorRMS * self.speechBoostFactor) + if rms >= threshold { + self.lastHeard = Date() + } + + // Normalize against the adaptive threshold so the UI meter stays roughly 0...1 across devices. + let clamped = min(1.0, max(0.0, rms / max(self.minSpeechRMS, threshold))) + if let token = self.overlayToken { + Task { @MainActor in + VoiceSessionCoordinator.shared.updateLevel(token: token, clamped) + } + } + } + + private static func rmsLevel(buffer: AVAudioPCMBuffer) -> Double? { + guard let channelData = buffer.floatChannelData?.pointee else { return nil } + let frameCount = Int(buffer.frameLength) + guard frameCount > 0 else { return nil } + var sum: Double = 0 + for i in 0.. String { + for trigger in triggers { + let token = trigger.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { continue } + guard let range = text.range( + of: token, + options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive]) else { continue } + let trimmed = text[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) + return String(trimmed) + } + return text + } + + private static func commandAfterTrigger( + transcript: String, + segments: [WakeWordSegment], + triggerEndTime: TimeInterval?, + triggers: [String]) -> String + { + guard let triggerEndTime else { + return self.trimmedAfterTrigger(transcript, triggers: triggers) + } + let trimmed = WakeWordGate.commandText( + transcript: transcript, + segments: segments, + triggerEndTime: triggerEndTime) + return trimmed.isEmpty ? self.trimmedAfterTrigger(transcript, triggers: triggers) : trimmed + } + + #if DEBUG + static func _testTrimmedAfterTrigger(_ text: String, triggers: [String]) -> String { + self.trimmedAfterTrigger(text, triggers: triggers) + } + + static func _testHasContentAfterTrigger(_ text: String, triggers: [String]) -> Bool { + !self.trimmedAfterTrigger(text, triggers: triggers).isEmpty + } + + static func _testAttributedColor(isFinal: Bool) -> NSColor { + self.makeAttributed(committed: "sample", volatile: "", isFinal: isFinal) + .attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear + } + + #endif + + private static func delta(after committed: String, current: String) -> String { + if current.hasPrefix(committed) { + let start = current.index(current.startIndex, offsetBy: committed.count) + return String(current[start...]) + } + return current + } + + private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString { + let full = NSMutableAttributedString() + let committedAttr: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.labelColor, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + ] + full.append(NSAttributedString(string: committed, attributes: committedAttr)) + let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor + let volatileAttr: [NSAttributedString.Key: Any] = [ + .foregroundColor: volatileColor, + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + ] + full.append(NSAttributedString(string: volatile, attributes: volatileAttr)) + return full + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift new file mode 100644 index 00000000..d4413618 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift @@ -0,0 +1,675 @@ +import AppKit +import AVFoundation +import Observation +import Speech +import SwabbleKit +import SwiftUI +import UniformTypeIdentifiers + +struct VoiceWakeSettings: View { + @Bindable var state: AppState + let isActive: Bool + @State private var testState: VoiceWakeTestState = .idle + @State private var tester = VoiceWakeTester() + @State private var isTesting = false + @State private var testTimeoutTask: Task? + @State private var availableMics: [AudioInputDevice] = [] + @State private var loadingMics = false + @State private var meterLevel: Double = 0 + @State private var meterError: String? + private let meter = MicLevelMonitor() + @State private var micObserver = AudioInputDeviceObserver() + @State private var micRefreshTask: Task? + @State private var availableLocales: [Locale] = [] + @State private var triggerEntries: [TriggerEntry] = [] + private let fieldLabelWidth: CGFloat = 140 + private let controlWidth: CGFloat = 240 + private let isPreview = ProcessInfo.processInfo.isPreview + + private struct AudioInputDevice: Identifiable, Equatable { + let uid: String + let name: String + var id: String { + self.uid + } + } + + private struct TriggerEntry: Identifiable { + let id: UUID + var value: String + } + + private var voiceWakeBinding: Binding { + Binding( + get: { self.state.swabbleEnabled }, + set: { newValue in + Task { await self.state.setVoiceWakeEnabled(newValue) } + }) + } + + var body: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 14) { + SettingsToggleRow( + title: "Enable Voice Wake", + subtitle: "Listen for a wake phrase (e.g. \"Claude\") before running voice commands. " + + "Voice recognition runs fully on-device.", + binding: self.voiceWakeBinding) + .disabled(!voiceWakeSupported) + + SettingsToggleRow( + title: "Hold Right Option to talk", + subtitle: """ + Push-to-talk mode that starts listening while you hold the key + and shows the preview overlay. + """, + binding: self.$state.voicePushToTalkEnabled) + .disabled(!voiceWakeSupported) + + if !voiceWakeSupported { + Label("Voice Wake requires macOS 26 or newer.", systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.yellow) + .padding(8) + .background(Color.secondary.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + self.localePicker + self.micPicker + self.levelMeter + + VoiceWakeTestCard( + testState: self.$testState, + isTesting: self.$isTesting, + onToggle: self.toggleTest) + + self.chimeSection + + self.triggerTable + + Spacer(minLength: 8) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + } + .task { + guard !self.isPreview else { return } + await self.loadMicsIfNeeded() + } + .task { + guard !self.isPreview else { return } + await self.loadLocalesIfNeeded() + } + .task { + guard !self.isPreview else { return } + await self.restartMeter() + } + .onAppear { + guard !self.isPreview else { return } + self.startMicObserver() + self.loadTriggerEntries() + } + .onChange(of: self.state.voiceWakeMicID) { _, _ in + guard !self.isPreview else { return } + self.updateSelectedMicName() + Task { await self.restartMeter() } + } + .onChange(of: self.isActive) { _, active in + guard !self.isPreview else { return } + if !active { + self.tester.stop() + self.isTesting = false + self.testState = .idle + self.testTimeoutTask?.cancel() + self.micRefreshTask?.cancel() + self.micRefreshTask = nil + Task { await self.meter.stop() } + self.micObserver.stop() + self.syncTriggerEntriesToState() + } else { + self.startMicObserver() + self.loadTriggerEntries() + } + } + .onDisappear { + guard !self.isPreview else { return } + self.tester.stop() + self.isTesting = false + self.testState = .idle + self.testTimeoutTask?.cancel() + self.micRefreshTask?.cancel() + self.micRefreshTask = nil + self.micObserver.stop() + Task { await self.meter.stop() } + self.syncTriggerEntriesToState() + } + } + + private func loadTriggerEntries() { + self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) } + } + + private func syncTriggerEntriesToState() { + self.state.swabbleTriggerWords = self.triggerEntries.map(\.value) + } + + private var triggerTable: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Trigger words") + .font(.callout.weight(.semibold)) + Spacer() + Button { + self.addWord() + } label: { + Label("Add word", systemImage: "plus") + } + .disabled(self.triggerEntries + .contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) + + Button("Reset defaults") { + self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) } + self.syncTriggerEntriesToState() + } + } + + VStack(spacing: 0) { + ForEach(self.$triggerEntries) { $entry in + HStack(spacing: 8) { + TextField("Wake word", text: $entry.value) + .textFieldStyle(.roundedBorder) + .onSubmit { + self.syncTriggerEntriesToState() + } + + Button { + self.removeWord(id: entry.id) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Remove trigger word") + .frame(width: 24) + } + .padding(8) + + if entry.id != self.triggerEntries.last?.id { + Divider() + } + } + } + .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.25), lineWidth: 1)) + + Text( + "OpenClaw reacts when any trigger appears in a transcription. " + + "Keep them short to avoid false positives.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var chimeSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("Sounds") + .font(.callout.weight(.semibold)) + Spacer() + } + + self.chimeRow( + title: "Trigger sound", + selection: self.$state.voiceWakeTriggerChime) + + self.chimeRow( + title: "Send sound", + selection: self.$state.voiceWakeSendChime) + } + .padding(.top, 4) + } + + private func addWord() { + self.triggerEntries.append(TriggerEntry(id: UUID(), value: "")) + } + + private func removeWord(id: UUID) { + self.triggerEntries.removeAll { $0.id == id } + self.syncTriggerEntriesToState() + } + + private func toggleTest() { + guard voiceWakeSupported else { + self.testState = .failed("Voice Wake requires macOS 26 or newer.") + return + } + if self.isTesting { + self.tester.finalize() + self.isTesting = false + self.testState = .finalizing + Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_000_000_000) + if self.testState == .finalizing { + self.tester.stop() + self.testState = .failed("Stopped") + } + } + self.testTimeoutTask?.cancel() + return + } + + let triggers = self.sanitizedTriggers() + self.tester.stop() + self.testTimeoutTask?.cancel() + self.isTesting = true + self.testState = .requesting + Task { @MainActor in + do { + try await self.tester.start( + triggers: triggers, + micID: self.state.voiceWakeMicID.isEmpty ? nil : self.state.voiceWakeMicID, + localeID: self.state.voiceWakeLocaleID, + onUpdate: { newState in + DispatchQueue.main.async { [self] in + self.testState = newState + if case .detected = newState { self.isTesting = false } + if case .failed = newState { self.isTesting = false } + if case .detected = newState { self.testTimeoutTask?.cancel() } + if case .failed = newState { self.testTimeoutTask?.cancel() } + } + }) + self.testTimeoutTask?.cancel() + self.testTimeoutTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 10 * 1_000_000_000) + guard !Task.isCancelled else { return } + if self.isTesting { + self.tester.stop() + if case let .hearing(text) = self.testState, + let command = Self.textOnlyCommand(from: text, triggers: triggers) + { + self.testState = .detected(command) + } else { + self.testState = .failed("Timeout: no trigger heard") + } + self.isTesting = false + } + } + } catch { + self.tester.stop() + self.testState = .failed(error.localizedDescription) + self.isTesting = false + self.testTimeoutTask?.cancel() + } + } + } + + private func chimeRow(title: String, selection: Binding) -> some View { + HStack(alignment: .center, spacing: 10) { + Text(title) + .font(.callout.weight(.semibold)) + .frame(width: self.fieldLabelWidth, alignment: .leading) + + Menu { + Button("No Sound") { self.selectChime(.none, binding: selection) } + Divider() + ForEach(VoiceWakeChimeCatalog.systemOptions, id: \.self) { option in + Button(VoiceWakeChimeCatalog.displayName(for: option)) { + self.selectChime(.system(name: option), binding: selection) + } + } + Divider() + Button("Choose file…") { self.chooseCustomChime(for: selection) } + } label: { + HStack(spacing: 6) { + Text(selection.wrappedValue.displayLabel) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Image(systemName: "chevron.down") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(6) + .frame(minWidth: self.controlWidth, maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .windowBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.25), lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + Button("Play") { + VoiceWakeChimePlayer.play(selection.wrappedValue) + } + .keyboardShortcut(.space, modifiers: [.command]) + } + } + + private func chooseCustomChime(for selection: Binding) { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.audio] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.resolvesAliases = true + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + do { + let bookmark = try url.bookmarkData( + options: [.withSecurityScope], + includingResourceValuesForKeys: nil, + relativeTo: nil) + let chosen = VoiceWakeChime.custom(displayName: url.lastPathComponent, bookmark: bookmark) + selection.wrappedValue = chosen + VoiceWakeChimePlayer.play(chosen) + } catch { + // Ignore failures; user can retry. + } + } + } + + private func selectChime(_ chime: VoiceWakeChime, binding: Binding) { + binding.wrappedValue = chime + VoiceWakeChimePlayer.play(chime) + } + + private func sanitizedTriggers() -> [String] { + sanitizeVoiceWakeTriggers(self.state.swabbleTriggerWords) + } + + private static func textOnlyCommand(from transcript: String, triggers: [String]) -> String? { + VoiceWakeTextUtils.textOnlyCommand( + transcript: transcript, + triggers: triggers, + minCommandLength: 1, + trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) }) + } + + private var micPicker: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("Microphone") + .font(.callout.weight(.semibold)) + .frame(width: self.fieldLabelWidth, alignment: .leading) + Picker("Microphone", selection: self.$state.voiceWakeMicID) { + Text("System default").tag("") + if self.isSelectedMicUnavailable { + Text(self.state.voiceWakeMicName.isEmpty ? "Unavailable" : self.state.voiceWakeMicName) + .tag(self.state.voiceWakeMicID) + } + ForEach(self.availableMics) { mic in + Text(mic.name).tag(mic.uid) + } + } + .labelsHidden() + .frame(width: self.controlWidth) + } + if self.isSelectedMicUnavailable { + HStack(spacing: 10) { + Color.clear.frame(width: self.fieldLabelWidth, height: 1) + Text("Disconnected (using System default)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + if self.loadingMics { + ProgressView().controlSize(.small) + } + } + } + + private var localePicker: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("Recognition language") + .font(.callout.weight(.semibold)) + .frame(width: self.fieldLabelWidth, alignment: .leading) + Picker("Language", selection: self.$state.voiceWakeLocaleID) { + let current = Locale(identifier: Locale.current.identifier) + Text("\(self.friendlyName(for: current)) (System)").tag(Locale.current.identifier) + ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in + if id != Locale.current.identifier { + Text(self.friendlyName(for: Locale(identifier: id))).tag(id) + } + } + } + .labelsHidden() + .frame(width: self.controlWidth) + } + + if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Additional languages") + .font(.footnote.weight(.semibold)) + ForEach( + Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()), + id: \.offset) + { idx, localeID in + HStack(spacing: 8) { + Picker("Extra \(idx + 1)", selection: Binding( + get: { localeID }, + set: { newValue in + guard self.state + .voiceWakeAdditionalLocaleIDs.indices + .contains(idx) else { return } + self.state + .voiceWakeAdditionalLocaleIDs[idx] = + newValue + })) { + ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in + Text(self.friendlyName(for: Locale(identifier: id))).tag(id) + } + } + .labelsHidden() + .frame(width: 220) + + Button { + guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return } + self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Remove language") + } + } + + Button { + if let first = availableLocales.first { + self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier) + } + } label: { + Label("Add language", systemImage: "plus") + } + .disabled(self.availableLocales.isEmpty) + } + .padding(.top, 4) + } else { + Button { + if let first = availableLocales.first { + self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier) + } + } label: { + Label("Add additional language", systemImage: "plus") + } + .buttonStyle(.link) + .disabled(self.availableLocales.isEmpty) + .padding(.top, 4) + } + + Text("Languages are tried in order. Models may need a first-use download on macOS 26.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @MainActor + private func loadMicsIfNeeded(force: Bool = false) async { + guard force || self.availableMics.isEmpty, !self.loadingMics else { return } + self.loadingMics = true + let discovery = AVCaptureDevice.DiscoverySession( + deviceTypes: [.external, .microphone], + mediaType: .audio, + position: .unspecified) + let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs() + let connectedDevices = discovery.devices.filter(\.isConnected) + let devices = aliveUIDs.isEmpty + ? connectedDevices + : connectedDevices.filter { aliveUIDs.contains($0.uniqueID) } + self.availableMics = devices.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) } + self.updateSelectedMicName() + self.loadingMics = false + } + + private var isSelectedMicUnavailable: Bool { + let selected = self.state.voiceWakeMicID + guard !selected.isEmpty else { return false } + return !self.availableMics.contains(where: { $0.uid == selected }) + } + + @MainActor + private func updateSelectedMicName() { + let selected = self.state.voiceWakeMicID + if selected.isEmpty { + self.state.voiceWakeMicName = "" + return + } + if let match = self.availableMics.first(where: { $0.uid == selected }) { + self.state.voiceWakeMicName = match.name + } + } + + private func startMicObserver() { + self.micObserver.start { + Task { @MainActor in + self.scheduleMicRefresh() + } + } + } + + @MainActor + private func scheduleMicRefresh() { + self.micRefreshTask?.cancel() + self.micRefreshTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await self.loadMicsIfNeeded(force: true) + await self.restartMeter() + } + } + + @MainActor + private func loadLocalesIfNeeded() async { + guard self.availableLocales.isEmpty else { return } + self.availableLocales = Array(SFSpeechRecognizer.supportedLocales()).sorted { lhs, rhs in + self.friendlyName(for: lhs) + .localizedCaseInsensitiveCompare(self.friendlyName(for: rhs)) == .orderedAscending + } + } + + private func friendlyName(for locale: Locale) -> String { + let cleanedID = normalizeLocaleIdentifier(locale.identifier) + let cleanLocale = Locale(identifier: cleanedID) + + if let langCode = cleanLocale.language.languageCode?.identifier, + let lang = cleanLocale.localizedString(forLanguageCode: langCode), + let regionCode = cleanLocale.region?.identifier, + let region = cleanLocale.localizedString(forRegionCode: regionCode) + { + return "\(lang) (\(region))" + } + if let langCode = cleanLocale.language.languageCode?.identifier, + let lang = cleanLocale.localizedString(forLanguageCode: langCode) + { + return lang + } + return cleanLocale.localizedString(forIdentifier: cleanedID) ?? cleanedID + } + + private var levelMeter: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 10) { + Text("Live level") + .font(.callout.weight(.semibold)) + .frame(width: self.fieldLabelWidth, alignment: .leading) + MicLevelBar(level: self.meterLevel) + .frame(width: self.controlWidth, alignment: .leading) + Text(self.levelLabel) + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 60, alignment: .trailing) + } + if let meterError { + Text(meterError) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + private var levelLabel: String { + let db = (meterLevel * 50) - 50 + return String(format: "%.0f dB", db) + } + + @MainActor + private func restartMeter() async { + self.meterError = nil + await self.meter.stop() + do { + try await self.meter.start { [weak state] level in + Task { @MainActor in + guard state != nil else { return } + self.meterLevel = level + } + } + } catch { + self.meterError = error.localizedDescription + } + } +} + +#if DEBUG +struct VoiceWakeSettings_Previews: PreviewProvider { + static var previews: some View { + VoiceWakeSettings(state: .preview, isActive: true) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} + +@MainActor +extension VoiceWakeSettings { + static func exerciseForTesting() { + let state = AppState(preview: true) + state.swabbleEnabled = true + state.voicePushToTalkEnabled = true + state.swabbleTriggerWords = ["Claude", "Hey"] + + let view = VoiceWakeSettings(state: state, isActive: true) + view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")] + view.availableLocales = [Locale(identifier: "en_US")] + view.meterLevel = 0.42 + view.meterError = "No input" + view.testState = .detected("ok") + view.isTesting = true + view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")] + + _ = view.body + _ = view.localePicker + _ = view.micPicker + _ = view.levelMeter + _ = view.triggerTable + _ = view.chimeSection + + view.addWord() + if let entryId = view.triggerEntries.first?.id { + view.removeWord(id: entryId) + } + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeTestCard.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeTestCard.swift new file mode 100644 index 00000000..7de20885 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeTestCard.swift @@ -0,0 +1,95 @@ +import SwiftUI + +struct VoiceWakeTestCard: View { + @Binding var testState: VoiceWakeTestState + @Binding var isTesting: Bool + let onToggle: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Test Voice Wake") + .font(.callout.weight(.semibold)) + Spacer() + Button(action: self.onToggle) { + Label( + self.isTesting ? "Stop" : "Start test", + systemImage: self.isTesting ? "stop.circle.fill" : "play.circle") + } + .buttonStyle(.borderedProminent) + .tint(self.isTesting ? .red : .accentColor) + } + + HStack(spacing: 8) { + self.statusIcon + VStack(alignment: .leading, spacing: 4) { + Text(self.statusText) + .font(.subheadline) + .frame(maxHeight: 22, alignment: .center) + if case let .detected(text) = testState { + Text("Heard: \(text)") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + Spacer() + } + .padding(10) + .background(.quaternary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(minHeight: 54) + } + .padding(.vertical, 2) + } + + private var statusIcon: some View { + switch self.testState { + case .idle: + AnyView(Image(systemName: "waveform").foregroundStyle(.secondary)) + + case .requesting: + AnyView(ProgressView().controlSize(.small)) + + case .listening, .hearing: + AnyView( + Image(systemName: "ear.and.waveform") + .symbolEffect(.pulse) + .foregroundStyle(Color.accentColor)) + + case .finalizing: + AnyView(ProgressView().controlSize(.small)) + + case .detected: + AnyView(Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)) + + case .failed: + AnyView(Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)) + } + } + + private var statusText: String { + switch self.testState { + case .idle: + "Press start, say a trigger word, and wait for detection." + + case .requesting: + "Requesting mic & speech permission…" + + case .listening: + "Listening… say your trigger word." + + case let .hearing(text): + "Heard: \(text)" + + case .finalizing: + "Finalizing…" + + case .detected: + "Voice wake detected!" + + case let .failed(reason): + reason + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift new file mode 100644 index 00000000..063fea82 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift @@ -0,0 +1,481 @@ +import AVFoundation +import Foundation +import Speech +import SwabbleKit + +enum VoiceWakeTestState: Equatable { + case idle + case requesting + case listening + case hearing(String) + case finalizing + case detected(String) + case failed(String) +} + +final class VoiceWakeTester { + private let recognizer: SFSpeechRecognizer? + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var isStopping = false + private var isFinalizing = false + private var detectionStart: Date? + private var lastHeard: Date? + private var lastLoggedText: String? + private var lastLoggedAt: Date? + private var lastTranscript: String? + private var lastTranscriptAt: Date? + private var silenceTask: Task? + private var currentTriggers: [String] = [] + private var holdingAfterDetect = false + private var detectedText: String? + private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake") + private let silenceWindow: TimeInterval = 1.0 + + init(locale: Locale = .current) { + self.recognizer = SFSpeechRecognizer(locale: locale) + } + + func start( + triggers: [String], + micID: String?, + localeID: String?, + onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async throws + { + guard self.recognitionTask == nil else { return } + self.isStopping = false + self.isFinalizing = false + self.holdingAfterDetect = false + self.detectedText = nil + self.lastHeard = nil + self.lastLoggedText = nil + self.lastLoggedAt = nil + self.lastTranscript = nil + self.lastTranscriptAt = nil + self.silenceTask?.cancel() + self.silenceTask = nil + self.currentTriggers = triggers + let chosenLocale = localeID.flatMap { Locale(identifier: $0) } ?? Locale.current + let recognizer = SFSpeechRecognizer(locale: chosenLocale) + guard let recognizer, recognizer.isAvailable else { + throw NSError( + domain: "VoiceWakeTester", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Speech recognition unavailable"]) + } + recognizer.defaultTaskHint = .dictation + + guard Self.hasPrivacyStrings else { + throw NSError( + domain: "VoiceWakeTester", + code: 3, + userInfo: [ + NSLocalizedDescriptionKey: """ + Missing mic/speech privacy strings. Rebuild the mac app (scripts/restart-mac.sh) \ + to include usage descriptions. + """, + ]) + } + + let granted = try await Self.ensurePermissions() + guard granted else { + throw NSError( + domain: "VoiceWakeTester", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Microphone or speech permission denied"]) + } + + self.logInputSelection(preferredMicID: micID) + self.configureSession(preferredMicID: micID) + + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoiceWakeTester", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + + let engine = AVAudioEngine() + self.audioEngine = engine + + self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + self.recognitionRequest?.shouldReportPartialResults = true + self.recognitionRequest?.taskHint = .dictation + let request = self.recognitionRequest + + let inputNode = engine.inputNode + let format = inputNode.outputFormat(forBus: 0) + guard format.channelCount > 0, format.sampleRate > 0 else { + self.audioEngine = nil + throw NSError( + domain: "VoiceWakeTester", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) + } + inputNode.removeTap(onBus: 0) + inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in + request?.append(buffer) + } + + engine.prepare() + try engine.start() + DispatchQueue.main.async { + onUpdate(.listening) + } + + self.detectionStart = Date() + self.lastHeard = self.detectionStart + + guard let request = recognitionRequest else { return } + + self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self, !self.isStopping else { return } + let text = result?.bestTranscription.formattedString ?? "" + let segments = result.map { WakeWordSpeechSegments.from( + transcription: $0.bestTranscription, + transcript: text) } ?? [] + let isFinal = result?.isFinal ?? false + let gateConfig = WakeWordGateConfig(triggers: triggers) + var match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig) + if match == nil, isFinal { + match = self.textOnlyFallbackMatch( + transcript: text, + triggers: triggers, + config: gateConfig) + } + self.maybeLogDebug( + transcript: text, + segments: segments, + triggers: triggers, + match: match, + isFinal: isFinal) + let errorMessage = error?.localizedDescription + + Task { [weak self] in + guard let self, !self.isStopping else { return } + await self.handleResult( + match: match, + text: text, + isFinal: isFinal, + errorMessage: errorMessage, + onUpdate: onUpdate) + } + } + } + + func stop() { + self.stop(force: true) + } + + func finalize(timeout: TimeInterval = 1.5) { + guard self.recognitionTask != nil else { + self.stop(force: true) + return + } + self.isFinalizing = true + self.recognitionRequest?.endAudio() + if let engine = self.audioEngine { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + } + Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + if !self.isStopping { + self.stop(force: true) + } + } + } + + private func stop(force: Bool) { + if force { self.isStopping = true } + self.isFinalizing = false + self.recognitionRequest?.endAudio() + self.recognitionTask?.cancel() + self.recognitionTask = nil + self.recognitionRequest = nil + if let engine = self.audioEngine { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + } + self.audioEngine = nil + self.holdingAfterDetect = false + self.detectedText = nil + self.lastHeard = nil + self.detectionStart = nil + self.lastLoggedText = nil + self.lastLoggedAt = nil + self.lastTranscript = nil + self.lastTranscriptAt = nil + self.silenceTask?.cancel() + self.silenceTask = nil + self.currentTriggers = [] + } + + private func handleResult( + match: WakeWordGateMatch?, + text: String, + isFinal: Bool, + errorMessage: String?, + onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async + { + if !text.isEmpty { + self.lastHeard = Date() + self.lastTranscript = text + self.lastTranscriptAt = Date() + } + if self.holdingAfterDetect { + return + } + if let match, !match.command.isEmpty { + self.holdingAfterDetect = true + self.detectedText = match.command + self.logger.info("voice wake detected (test) (len=\(match.command.count))") + await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } + self.stop() + await MainActor.run { + AppStateStore.shared.stopVoiceEars() + onUpdate(.detected(match.command)) + } + return + } + if !isFinal, !text.isEmpty { + self.scheduleSilenceCheck( + triggers: self.currentTriggers, + onUpdate: onUpdate) + } + if self.isFinalizing { + Task { @MainActor in onUpdate(.finalizing) } + } + if let errorMessage { + self.stop(force: true) + Task { @MainActor in onUpdate(.failed(errorMessage)) } + return + } + if isFinal { + self.stop(force: true) + let state: VoiceWakeTestState = text.isEmpty + ? .failed("No speech detected") + : .failed("No trigger heard: “\(text)”") + Task { @MainActor in onUpdate(state) } + } else { + let state: VoiceWakeTestState = text.isEmpty ? .listening : .hearing(text) + Task { @MainActor in onUpdate(state) } + } + } + + private func maybeLogDebug( + transcript: String, + segments: [WakeWordSegment], + triggers: [String], + match: WakeWordGateMatch?, + isFinal: Bool) + { + guard !transcript.isEmpty else { return } + let level = self.logger.logLevel + guard level == .debug || level == .trace else { return } + if transcript == self.lastLoggedText, !isFinal { + if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 { + return + } + } + self.lastLoggedText = transcript + self.lastLoggedAt = Date() + + let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) + let gaps = Self.debugCandidateGaps(triggers: triggers, segments: segments) + let segmentSummary = Self.debugSegments(segments) + let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 }) + let matchSummary = match.map { + "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)" + } ?? "match=false" + + self.logger.debug( + "voicewake test transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " + + "isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " + + "\(matchSummary) gaps=[\(gaps, privacy: .private)] segments=[\(segmentSummary, privacy: .private)]") + } + + private static func debugSegments(_ segments: [WakeWordSegment]) -> String { + segments.map { seg in + let start = String(format: "%.2f", seg.start) + let end = String(format: "%.2f", seg.end) + return "\(seg.text)@\(start)-\(end)" + }.joined(separator: ", ") + } + + private static func debugCandidateGaps(triggers: [String], segments: [WakeWordSegment]) -> String { + let tokens = self.normalizeSegments(segments) + guard !tokens.isEmpty else { return "" } + let triggerTokens = self.normalizeTriggers(triggers) + var gaps: [String] = [] + + for trigger in triggerTokens { + let count = trigger.tokens.count + guard count > 0, tokens.count > count else { continue } + for i in 0...(tokens.count - count - 1) { + let matched = (0.. [DebugTriggerTokens] { + var output: [DebugTriggerTokens] = [] + for trigger in triggers { + let tokens = trigger + .split(whereSeparator: { $0.isWhitespace }) + .map { VoiceWakeTextUtils.normalizeToken(String($0)) } + .filter { !$0.isEmpty } + if tokens.isEmpty { continue } + output.append(DebugTriggerTokens(tokens: tokens)) + } + return output + } + + private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [DebugToken] { + segments.compactMap { segment in + let normalized = VoiceWakeTextUtils.normalizeToken(segment.text) + guard !normalized.isEmpty else { return nil } + return DebugToken( + normalized: normalized, + start: segment.start, + end: segment.end) + } + } + + private func textOnlyFallbackMatch( + transcript: String, + triggers: [String], + config: WakeWordGateConfig) -> WakeWordGateMatch? + { + guard let command = VoiceWakeTextUtils.textOnlyCommand( + transcript: transcript, + triggers: triggers, + minCommandLength: config.minCommandLength, + trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) }) + else { return nil } + return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command) + } + + private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) { + Task { [weak self] in + guard let self else { return } + let detectedAt = Date() + let hardStop = detectedAt.addingTimeInterval(6) // cap overall listen after trigger + + while !self.isStopping { + let now = Date() + if now >= hardStop { break } + if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceWindow { + break + } + try? await Task.sleep(nanoseconds: 200_000_000) + } + if !self.isStopping { + self.stop() + await MainActor.run { AppStateStore.shared.stopVoiceEars() } + if let detectedText { + self.logger.info("voice wake hold finished; len=\(detectedText.count)") + Task { @MainActor in onUpdate(.detected(detectedText)) } + } + } + } + } + + private func scheduleSilenceCheck( + triggers: [String], + onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) + { + self.silenceTask?.cancel() + let lastSeenAt = self.lastTranscriptAt + let lastText = self.lastTranscript + self.silenceTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(self.silenceWindow * 1_000_000_000)) + guard !Task.isCancelled else { return } + guard !self.isStopping, !self.holdingAfterDetect else { return } + guard let lastSeenAt, let lastText else { return } + guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return } + guard let match = self.textOnlyFallbackMatch( + transcript: lastText, + triggers: triggers, + config: WakeWordGateConfig(triggers: triggers)) else { return } + self.holdingAfterDetect = true + self.detectedText = match.command + self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))") + await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } + self.stop() + await MainActor.run { + AppStateStore.shared.stopVoiceEars() + onUpdate(.detected(match.command)) + } + } + } + + private func configureSession(preferredMicID: String?) { + _ = preferredMicID + } + + private func logInputSelection(preferredMicID: String?) { + let preferred = (preferredMicID?.isEmpty == false) ? preferredMicID! : "system-default" + self.logger.info( + "voicewake test input preferred=\(preferred, privacy: .public) " + + "\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)") + } + + private nonisolated static func ensurePermissions() async throws -> Bool { + let speechStatus = SFSpeechRecognizer.authorizationStatus() + if speechStatus == .notDetermined { + let granted = await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status == .authorized) + } + } + guard granted else { return false } + } else if speechStatus != .authorized { + return false + } + + let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) + switch micStatus { + case .authorized: return true + + case .notDetermined: + return await withCheckedContinuation { continuation in + AVCaptureDevice.requestAccess(for: .audio) { granted in + continuation.resume(returning: granted) + } + } + + default: + return false + } + } + + private static var hasPrivacyStrings: Bool { + let speech = Bundle.main.object(forInfoDictionaryKey: "NSSpeechRecognitionUsageDescription") as? String + let mic = Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") as? String + return speech?.isEmpty == false && mic?.isEmpty == false + } +} + +extension VoiceWakeTester: @unchecked Sendable {} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeTextUtils.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeTextUtils.swift new file mode 100644 index 00000000..9311765a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/VoiceWakeTextUtils.swift @@ -0,0 +1,48 @@ +import Foundation +import SwabbleKit + +enum VoiceWakeTextUtils { + private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines + .union(.punctuationCharacters) + typealias TrimWake = (String, [String]) -> String + + static func normalizeToken(_ token: String) -> String { + token + .trimmingCharacters(in: self.whitespaceAndPunctuation) + .lowercased() + } + + static func startsWithTrigger(transcript: String, triggers: [String]) -> Bool { + let tokens = transcript + .split(whereSeparator: { $0.isWhitespace }) + .map { self.normalizeToken(String($0)) } + .filter { !$0.isEmpty } + guard !tokens.isEmpty else { return false } + for trigger in triggers { + let triggerTokens = trigger + .split(whereSeparator: { $0.isWhitespace }) + .map { self.normalizeToken(String($0)) } + .filter { !$0.isEmpty } + guard !triggerTokens.isEmpty, tokens.count >= triggerTokens.count else { continue } + if zip(triggerTokens, tokens.prefix(triggerTokens.count)).allSatisfy({ $0 == $1 }) { + return true + } + } + return false + } + + static func textOnlyCommand( + transcript: String, + triggers: [String], + minCommandLength: Int, + trimWake: TrimWake) -> String? + { + guard !transcript.isEmpty else { return nil } + guard !self.normalizeToken(transcript).isEmpty else { return nil } + guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return nil } + guard self.startsWithTrigger(transcript: transcript, triggers: triggers) else { return nil } + let trimmed = trimWake(transcript, triggers) + guard trimmed.count >= minCommandLength else { return nil } + return trimmed + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/WebChatManager.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/WebChatManager.swift new file mode 100644 index 00000000..61d1b4d3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/WebChatManager.swift @@ -0,0 +1,127 @@ +import AppKit +import Foundation + +/// A borderless panel that can still accept key focus (needed for typing). +final class WebChatPanel: NSPanel { + override var canBecomeKey: Bool { + true + } + + override var canBecomeMain: Bool { + true + } +} + +enum WebChatPresentation { + case window + case panel(anchorProvider: () -> NSRect?) + + var isPanel: Bool { + if case .panel = self { return true } + return false + } +} + +@MainActor +final class WebChatManager { + static let shared = WebChatManager() + + private var windowController: WebChatSwiftUIWindowController? + private var windowSessionKey: String? + private var panelController: WebChatSwiftUIWindowController? + private var panelSessionKey: String? + private var cachedPreferredSessionKey: String? + + var onPanelVisibilityChanged: ((Bool) -> Void)? + + var activeSessionKey: String? { + self.panelSessionKey ?? self.windowSessionKey + } + + func show(sessionKey: String) { + self.closePanel() + if let controller = self.windowController { + if self.windowSessionKey == sessionKey { + controller.show() + return + } + + controller.close() + self.windowController = nil + self.windowSessionKey = nil + } + let controller = WebChatSwiftUIWindowController(sessionKey: sessionKey, presentation: .window) + controller.onVisibilityChanged = { [weak self] visible in + self?.onPanelVisibilityChanged?(visible) + } + self.windowController = controller + self.windowSessionKey = sessionKey + controller.show() + } + + func togglePanel(sessionKey: String, anchorProvider: @escaping () -> NSRect?) { + if let controller = self.panelController { + if self.panelSessionKey != sessionKey { + controller.close() + self.panelController = nil + self.panelSessionKey = nil + } else { + if controller.isVisible { + controller.close() + } else { + controller.presentAnchored(anchorProvider: anchorProvider) + } + return + } + } + + let controller = WebChatSwiftUIWindowController( + sessionKey: sessionKey, + presentation: .panel(anchorProvider: anchorProvider)) + controller.onClosed = { [weak self] in + self?.panelHidden() + } + controller.onVisibilityChanged = { [weak self] visible in + self?.onPanelVisibilityChanged?(visible) + } + self.panelController = controller + self.panelSessionKey = sessionKey + controller.presentAnchored(anchorProvider: anchorProvider) + } + + func closePanel() { + self.panelController?.close() + } + + func preferredSessionKey() async -> String { + if let cachedPreferredSessionKey { return cachedPreferredSessionKey } + let key = await GatewayConnection.shared.mainSessionKey() + self.cachedPreferredSessionKey = key + return key + } + + func resetTunnels() { + self.windowController?.close() + self.windowController = nil + self.windowSessionKey = nil + self.panelController?.close() + self.panelController = nil + self.panelSessionKey = nil + self.cachedPreferredSessionKey = nil + } + + func close() { + self.windowController?.close() + self.windowController = nil + self.windowSessionKey = nil + self.panelController?.close() + self.panelController = nil + self.panelSessionKey = nil + self.cachedPreferredSessionKey = nil + } + + private func panelHidden() { + self.onPanelVisibilityChanged?(false) + // Keep panel controller cached so reopening doesn't re-bootstrap. + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift new file mode 100644 index 00000000..46e5d80a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -0,0 +1,383 @@ +import AppKit +import Foundation +import OpenClawChatUI +import OpenClawKit +import OpenClawProtocol +import OSLog +import QuartzCore +import SwiftUI + +private let webChatSwiftLogger = Logger(subsystem: "ai.openclaw", category: "WebChatSwiftUI") + +private enum WebChatSwiftUILayout { + static let windowSize = NSSize(width: 500, height: 840) + static let panelSize = NSSize(width: 480, height: 640) + static let windowMinSize = NSSize(width: 480, height: 360) + static let anchorPadding: CGFloat = 8 +} + +struct MacGatewayChatTransport: OpenClawChatTransport, Sendable { + func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { + try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey) + } + + func abortRun(sessionKey: String, runId: String) async throws { + _ = try await GatewayConnection.shared.request( + method: "chat.abort", + params: [ + "sessionKey": AnyCodable(sessionKey), + "runId": AnyCodable(runId), + ], + timeoutMs: 10000) + } + + func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse { + var params: [String: AnyCodable] = [ + "includeGlobal": AnyCodable(true), + "includeUnknown": AnyCodable(false), + ] + if let limit { + params["limit"] = AnyCodable(limit) + } + let data = try await GatewayConnection.shared.request( + method: "sessions.list", + params: params, + timeoutMs: 15000) + return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data) + } + + func sendMessage( + sessionKey: String, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + { + try await GatewayConnection.shared.chatSend( + sessionKey: sessionKey, + message: message, + thinking: thinking, + idempotencyKey: idempotencyKey, + attachments: attachments) + } + + func requestHealth(timeoutMs: Int) async throws -> Bool { + try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) + } + + func events() -> AsyncStream { + AsyncStream { continuation in + let task = Task { + do { + try await GatewayConnection.shared.refresh() + } catch { + webChatSwiftLogger.error("gateway refresh failed \(error.localizedDescription, privacy: .public)") + } + + let stream = await GatewayConnection.shared.subscribe() + for await push in stream { + if Task.isCancelled { return } + if let evt = Self.mapPushToTransportEvent(push) { + continuation.yield(evt) + } + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + static func mapPushToTransportEvent(_ push: GatewayPush) -> OpenClawChatTransportEvent? { + switch push { + case let .snapshot(hello): + let ok = (try? JSONDecoder().decode( + OpenClawGatewayHealthOK.self, + from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true + return .health(ok: ok) + + case let .event(evt): + switch evt.event { + case "health": + guard let payload = evt.payload else { return nil } + let ok = (try? JSONDecoder().decode( + OpenClawGatewayHealthOK.self, + from: JSONEncoder().encode(payload)))?.ok ?? true + return .health(ok: ok) + case "tick": + return .tick + case "chat": + guard let payload = evt.payload else { return nil } + guard let chat = try? JSONDecoder().decode( + OpenClawChatEventPayload.self, + from: JSONEncoder().encode(payload)) + else { + return nil + } + return .chat(chat) + case "agent": + guard let payload = evt.payload else { return nil } + guard let agent = try? JSONDecoder().decode( + OpenClawAgentEventPayload.self, + from: JSONEncoder().encode(payload)) + else { + return nil + } + return .agent(agent) + default: + return nil + } + + case .seqGap: + return .seqGap + } + } +} + +// MARK: - Window controller + +@MainActor +final class WebChatSwiftUIWindowController { + private let presentation: WebChatPresentation + private let sessionKey: String + private let hosting: NSHostingController + private let contentController: NSViewController + private var window: NSWindow? + private var dismissMonitor: Any? + var onClosed: (() -> Void)? + var onVisibilityChanged: ((Bool) -> Void)? + + convenience init(sessionKey: String, presentation: WebChatPresentation) { + self.init(sessionKey: sessionKey, presentation: presentation, transport: MacGatewayChatTransport()) + } + + init(sessionKey: String, presentation: WebChatPresentation, transport: any OpenClawChatTransport) { + self.sessionKey = sessionKey + self.presentation = presentation + let vm = OpenClawChatViewModel(sessionKey: sessionKey, transport: transport) + let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex) + self.hosting = NSHostingController(rootView: OpenClawChatView( + viewModel: vm, + showsSessionSwitcher: true, + userAccent: accent)) + self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting) + self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController) + } + + deinit {} + + var isVisible: Bool { + self.window?.isVisible ?? false + } + + func show() { + guard let window else { return } + self.ensureWindowSize() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + self.onVisibilityChanged?(true) + } + + func presentAnchored(anchorProvider: () -> NSRect?) { + guard case .panel = self.presentation, let window else { return } + self.installDismissMonitor() + let target = self.reposition(using: anchorProvider) + + if !self.isVisible { + let start = target.offsetBy(dx: 0, dy: 8) + window.setFrame(start, display: true) + window.alphaValue = 0 + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(target, display: true) + window.animator().alphaValue = 1 + } + } else { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + self.onVisibilityChanged?(true) + } + + func close() { + self.window?.orderOut(nil) + self.onVisibilityChanged?(false) + self.onClosed?() + self.removeDismissMonitor() + } + + @discardableResult + private func reposition(using anchorProvider: () -> NSRect?) -> NSRect { + guard let window else { return .zero } + guard let anchor = anchorProvider() else { + let frame = WindowPlacement.topRightFrame( + size: WebChatSwiftUILayout.panelSize, + padding: WebChatSwiftUILayout.anchorPadding) + window.setFrame(frame, display: false) + return frame + } + let screen = NSScreen.screens.first { screen in + screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) + } ?? NSScreen.main + let bounds = (screen?.visibleFrame ?? .zero).insetBy( + dx: WebChatSwiftUILayout.anchorPadding, + dy: WebChatSwiftUILayout.anchorPadding) + let frame = WindowPlacement.anchoredBelowFrame( + size: WebChatSwiftUILayout.panelSize, + anchor: anchor, + padding: WebChatSwiftUILayout.anchorPadding, + in: bounds) + window.setFrame(frame, display: false) + return frame + } + + private func installDismissMonitor() { + if ProcessInfo.processInfo.isRunningTests { return } + guard self.dismissMonitor == nil, self.window != nil else { return } + self.dismissMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) + { [weak self] _ in + guard let self, let win = self.window else { return } + let pt = NSEvent.mouseLocation + if !win.frame.contains(pt) { + self.close() + } + } + } + + private func removeDismissMonitor() { + if let monitor = self.dismissMonitor { + NSEvent.removeMonitor(monitor) + self.dismissMonitor = nil + } + } + + private static func makeWindow( + for presentation: WebChatPresentation, + contentViewController: NSViewController) -> NSWindow + { + switch presentation { + case .window: + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false) + window.title = "OpenClaw Chat" + window.contentViewController = contentViewController + window.isReleasedWhenClosed = false + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.backgroundColor = .clear + window.isOpaque = false + window.center() + WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize) + window.minSize = WebChatSwiftUILayout.windowMinSize + window.contentView?.wantsLayer = true + window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor + return window + case .panel: + let panel = WebChatPanel( + contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.panelSize), + styleMask: [.borderless], + backing: .buffered, + defer: false) + panel.level = .statusBar + panel.hidesOnDeactivate = true + panel.hasShadow = true + panel.isMovable = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.backgroundColor = .clear + panel.isOpaque = false + panel.contentViewController = contentViewController + panel.becomesKeyOnlyIfNeeded = true + panel.contentView?.wantsLayer = true + panel.contentView?.layer?.backgroundColor = NSColor.clear.cgColor + panel.setFrame( + WindowPlacement.topRightFrame( + size: WebChatSwiftUILayout.panelSize, + padding: WebChatSwiftUILayout.anchorPadding), + display: false) + return panel + } + } + + private static func makeContentController( + for presentation: WebChatPresentation, + hosting: NSHostingController) -> NSViewController + { + let controller = NSViewController() + let effectView = NSVisualEffectView() + effectView.material = .sidebar + effectView.blendingMode = switch presentation { + case .panel: + .withinWindow + case .window: + .behindWindow + } + effectView.state = .active + effectView.wantsLayer = true + effectView.layer?.cornerCurve = .continuous + let cornerRadius: CGFloat = switch presentation { + case .panel: + 16 + case .window: + 0 + } + effectView.layer?.cornerRadius = cornerRadius + effectView.layer?.masksToBounds = true + effectView.layer?.backgroundColor = NSColor.clear.cgColor + + effectView.translatesAutoresizingMaskIntoConstraints = true + effectView.autoresizingMask = [.width, .height] + let rootView = effectView + + hosting.view.translatesAutoresizingMaskIntoConstraints = false + hosting.view.wantsLayer = true + hosting.view.layer?.cornerCurve = .continuous + hosting.view.layer?.cornerRadius = cornerRadius + hosting.view.layer?.masksToBounds = true + hosting.view.layer?.backgroundColor = NSColor.clear.cgColor + + controller.addChild(hosting) + effectView.addSubview(hosting.view) + controller.view = rootView + + NSLayoutConstraint.activate([ + hosting.view.leadingAnchor.constraint(equalTo: effectView.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: effectView.trailingAnchor), + hosting.view.topAnchor.constraint(equalTo: effectView.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: effectView.bottomAnchor), + ]) + + return controller + } + + private func ensureWindowSize() { + guard case .window = self.presentation, let window else { return } + let current = window.frame.size + let min = WebChatSwiftUILayout.windowMinSize + if current.width < min.width || current.height < min.height { + let frame = WindowPlacement.centeredFrame(size: WebChatSwiftUILayout.windowSize) + window.setFrame(frame, display: false) + } + } + + private static func color(fromHex raw: String?) -> Color? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } + let r = Double((value >> 16) & 0xFF) / 255.0 + let g = Double((value >> 8) & 0xFF) / 255.0 + let b = Double(value & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/WindowPlacement.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/WindowPlacement.swift new file mode 100644 index 00000000..a088dd74 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/WindowPlacement.swift @@ -0,0 +1,84 @@ +import AppKit + +@MainActor +enum WindowPlacement { + static func centeredFrame(size: NSSize, on screen: NSScreen? = NSScreen.main) -> NSRect { + let bounds = (screen?.visibleFrame ?? NSScreen.screens.first?.visibleFrame ?? .zero) + return self.centeredFrame(size: size, in: bounds) + } + + static func topRightFrame( + size: NSSize, + padding: CGFloat, + on screen: NSScreen? = NSScreen.main) -> NSRect + { + let bounds = (screen?.visibleFrame ?? NSScreen.screens.first?.visibleFrame ?? .zero) + return self.topRightFrame(size: size, padding: padding, in: bounds) + } + + static func centeredFrame(size: NSSize, in bounds: NSRect) -> NSRect { + if bounds == .zero { + return NSRect(origin: .zero, size: size) + } + + let clampedWidth = min(size.width, bounds.width) + let clampedHeight = min(size.height, bounds.height) + + let x = round(bounds.minX + (bounds.width - clampedWidth) / 2) + let y = round(bounds.minY + (bounds.height - clampedHeight) / 2) + return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) + } + + static func topRightFrame(size: NSSize, padding: CGFloat, in bounds: NSRect) -> NSRect { + if bounds == .zero { + return NSRect(origin: .zero, size: size) + } + + let clampedWidth = min(size.width, bounds.width) + let clampedHeight = min(size.height, bounds.height) + + let x = round(bounds.maxX - clampedWidth - padding) + let y = round(bounds.maxY - clampedHeight - padding) + return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) + } + + static func anchoredBelowFrame(size: NSSize, anchor: NSRect, padding: CGFloat, in bounds: NSRect) -> NSRect { + if bounds == .zero { + let x = round(anchor.midX - size.width / 2) + let y = round(anchor.minY - size.height - padding) + return NSRect(x: x, y: y, width: size.width, height: size.height) + } + + let clampedWidth = min(size.width, bounds.width) + let clampedHeight = min(size.height, bounds.height) + + let desiredX = round(anchor.midX - clampedWidth / 2) + let desiredY = round(anchor.minY - clampedHeight - padding) + + let maxX = bounds.maxX - clampedWidth + let maxY = bounds.maxY - clampedHeight + + let x = maxX >= bounds.minX ? min(max(desiredX, bounds.minX), maxX) : bounds.minX + let y = maxY >= bounds.minY ? min(max(desiredY, bounds.minY), maxY) : bounds.minY + + return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) + } + + static func ensureOnScreen( + window: NSWindow, + defaultSize: NSSize, + fallback: ((NSScreen?) -> NSRect)? = nil) + { + let frame = window.frame + let targetScreens = NSScreen.screens.isEmpty ? [NSScreen.main].compactMap(\.self) : NSScreen.screens + let isVisibleSomewhere = targetScreens.contains { screen in + frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12)) + } + + if isVisibleSomewhere { return } + + let screen = NSScreen.main ?? targetScreens.first + let next = fallback?(screen) ?? self.centeredFrame(size: defaultSize, on: screen) + window.setFrame(next, display: false) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/WorkActivityStore.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/WorkActivityStore.swift new file mode 100644 index 00000000..77d62963 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClaw/WorkActivityStore.swift @@ -0,0 +1,262 @@ +import Foundation +import Observation +import OpenClawKit +import OpenClawProtocol +import SwiftUI + +@MainActor +@Observable +final class WorkActivityStore { + static let shared = WorkActivityStore() + + struct Activity: Equatable { + let sessionKey: String + let role: SessionRole + let kind: ActivityKind + let label: String + let startedAt: Date + var lastUpdate: Date + } + + private(set) var current: Activity? + private(set) var iconState: IconState = .idle + private(set) var lastToolLabel: String? + private(set) var lastToolUpdatedAt: Date? + + private var jobs: [String: Activity] = [:] + private var tools: [String: Activity] = [:] + private var currentSessionKey: String? + private var toolSeqBySession: [String: Int] = [:] + + private var mainSessionKeyStorage = "main" + private let toolResultGrace: TimeInterval = 2.0 + + var mainSessionKey: String { + self.mainSessionKeyStorage + } + + func handleJob(sessionKey: String, state: String) { + let isStart = state.lowercased() == "started" || state.lowercased() == "streaming" + if isStart { + let activity = Activity( + sessionKey: sessionKey, + role: self.role(for: sessionKey), + kind: .job, + label: "job", + startedAt: Date(), + lastUpdate: Date()) + self.setJobActive(activity) + } else { + // Job ended (done/error/aborted/etc). Clear everything for this session. + self.clearTool(sessionKey: sessionKey) + self.clearJob(sessionKey: sessionKey) + } + } + + func handleTool( + sessionKey: String, + phase: String, + name: String?, + meta: String?, + args: [String: OpenClawProtocol.AnyCodable]?) + { + let toolKind = Self.mapToolKind(name) + let label = Self.buildLabel(name: name, meta: meta, args: args) + if phase.lowercased() == "start" { + self.lastToolLabel = label + self.lastToolUpdatedAt = Date() + self.toolSeqBySession[sessionKey, default: 0] += 1 + let activity = Activity( + sessionKey: sessionKey, + role: self.role(for: sessionKey), + kind: .tool(toolKind), + label: label, + startedAt: Date(), + lastUpdate: Date()) + self.setToolActive(activity) + } else { + // Delay removal slightly to avoid flicker on rapid result/start bursts. + let key = sessionKey + let seq = self.toolSeqBySession[key, default: 0] + Task { [weak self] in + let nsDelay = UInt64((self?.toolResultGrace ?? 0) * 1_000_000_000) + try? await Task.sleep(nanoseconds: nsDelay) + await MainActor.run { + guard let self else { return } + guard self.toolSeqBySession[key, default: 0] == seq else { return } + self.lastToolUpdatedAt = Date() + self.clearTool(sessionKey: key) + } + } + } + } + + func resolveIconState(override selection: IconOverrideSelection) { + switch selection { + case .system: + self.iconState = self.deriveIconState() + case .idle: + self.iconState = .idle + default: + let base = selection.toIconState() + switch base { + case let .workingMain(kind), + let .workingOther(kind): + self.iconState = .overridden(kind) + case let .overridden(kind): + self.iconState = .overridden(kind) + case .idle: + self.iconState = .idle + } + } + } + + private func setJobActive(_ activity: Activity) { + self.jobs[activity.sessionKey] = activity + // Main session preempts immediately. + if activity.role == .main { + self.currentSessionKey = activity.sessionKey + } else if self.currentSessionKey == nil || !self.isActive(sessionKey: self.currentSessionKey!) { + self.currentSessionKey = activity.sessionKey + } + self.refreshDerivedState() + } + + private func setToolActive(_ activity: Activity) { + self.tools[activity.sessionKey] = activity + // Main session preempts immediately. + if activity.role == .main { + self.currentSessionKey = activity.sessionKey + } else if self.currentSessionKey == nil || !self.isActive(sessionKey: self.currentSessionKey!) { + self.currentSessionKey = activity.sessionKey + } + self.refreshDerivedState() + } + + func setMainSessionKey(_ sessionKey: String) { + let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard trimmed != self.mainSessionKeyStorage else { return } + self.mainSessionKeyStorage = trimmed + if let current = self.currentSessionKey, !self.isActive(sessionKey: current) { + self.pickNextSession() + } + self.refreshDerivedState() + } + + private func clearJob(sessionKey: String) { + guard self.jobs[sessionKey] != nil else { return } + self.jobs.removeValue(forKey: sessionKey) + + if self.currentSessionKey == sessionKey, !self.isActive(sessionKey: sessionKey) { + self.pickNextSession() + } + self.refreshDerivedState() + } + + private func clearTool(sessionKey: String) { + guard self.tools[sessionKey] != nil else { return } + self.tools.removeValue(forKey: sessionKey) + + if self.currentSessionKey == sessionKey, !self.isActive(sessionKey: sessionKey) { + self.pickNextSession() + } + self.refreshDerivedState() + } + + private func pickNextSession() { + // Prefer main if present. + if self.isActive(sessionKey: self.mainSessionKeyStorage) { + self.currentSessionKey = self.mainSessionKeyStorage + return + } + + // Otherwise, pick most recent by lastUpdate across job/tool. + let keys = Set(self.jobs.keys).union(self.tools.keys) + let next = keys.max(by: { self.lastUpdate(for: $0) < self.lastUpdate(for: $1) }) + self.currentSessionKey = next + } + + private func role(for sessionKey: String) -> SessionRole { + sessionKey == self.mainSessionKeyStorage ? .main : .other + } + + private func isActive(sessionKey: String) -> Bool { + self.jobs[sessionKey] != nil || self.tools[sessionKey] != nil + } + + private func lastUpdate(for sessionKey: String) -> Date { + max(self.jobs[sessionKey]?.lastUpdate ?? .distantPast, self.tools[sessionKey]?.lastUpdate ?? .distantPast) + } + + private func currentActivity(for sessionKey: String) -> Activity? { + // Prefer tool overlay if present, otherwise job. + self.tools[sessionKey] ?? self.jobs[sessionKey] + } + + private func refreshDerivedState() { + if let key = self.currentSessionKey, !self.isActive(sessionKey: key) { + self.currentSessionKey = nil + } + self.current = self.currentSessionKey.flatMap { self.currentActivity(for: $0) } + self.iconState = self.deriveIconState() + } + + private func deriveIconState() -> IconState { + guard let sessionKey = self.currentSessionKey, + let activity = self.currentActivity(for: sessionKey) + else { return .idle } + + switch activity.role { + case .main: return .workingMain(activity.kind) + case .other: return .workingOther(activity.kind) + } + } + + private static func mapToolKind(_ name: String?) -> ToolKind { + switch name?.lowercased() { + case "bash", "shell": .bash + case "read": .read + case "write": .write + case "edit": .edit + case "attach": .attach + default: .other + } + } + + private static func buildLabel( + name: String?, + meta: String?, + args: [String: OpenClawProtocol.AnyCodable]?) -> String + { + let wrappedArgs = self.wrapToolArgs(args) + let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta) + if let detail = display.detailLine, !detail.isEmpty { + return "\(display.label): \(detail)" + } + + return display.label + } + + private static func wrapToolArgs(_ args: [String: OpenClawProtocol.AnyCodable]?) -> OpenClawKit.AnyCodable? { + guard let args else { return nil } + let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) } + return OpenClawKit.AnyCodable(converted) + } + + private static func unwrapJSONValue(_ value: Any) -> Any { + if let dict = value as? [String: OpenClawProtocol.AnyCodable] { + return dict.mapValues { self.unwrapJSONValue($0.value) } + } + if let array = value as? [OpenClawProtocol.AnyCodable] { + return array.map { self.unwrapJSONValue($0.value) } + } + if let dict = value as? [String: Any] { + return dict.mapValues { self.unwrapJSONValue($0) } + } + if let array = value as? [Any] { + return array.map { self.unwrapJSONValue($0) } + } + return value + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift new file mode 100644 index 00000000..abd18efa --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift @@ -0,0 +1,682 @@ +import Foundation +import Network +import Observation +import OpenClawKit +import OSLog + +@MainActor +@Observable +public final class GatewayDiscoveryModel { + public struct LocalIdentity: Equatable, Sendable { + public var hostTokens: Set + public var displayTokens: Set + + public init(hostTokens: Set, displayTokens: Set) { + self.hostTokens = hostTokens + self.displayTokens = displayTokens + } + } + + public struct DiscoveredGateway: Identifiable, Equatable, Sendable { + public var id: String { + self.stableID + } + + public var displayName: String + // Resolved service endpoint (SRV + A/AAAA). Used for routing; do not trust TXT for routing. + public var serviceHost: String? + public var servicePort: Int? + public var lanHost: String? + public var tailnetDns: String? + public var sshPort: Int + public var gatewayPort: Int? + public var cliPath: String? + public var stableID: String + public var debugID: String + public var isLocal: Bool + + public init( + displayName: String, + serviceHost: String? = nil, + servicePort: Int? = nil, + lanHost: String? = nil, + tailnetDns: String? = nil, + sshPort: Int, + gatewayPort: Int? = nil, + cliPath: String? = nil, + stableID: String, + debugID: String, + isLocal: Bool) + { + self.displayName = displayName + self.serviceHost = serviceHost + self.servicePort = servicePort + self.lanHost = lanHost + self.tailnetDns = tailnetDns + self.sshPort = sshPort + self.gatewayPort = gatewayPort + self.cliPath = cliPath + self.stableID = stableID + self.debugID = debugID + self.isLocal = isLocal + } + } + + public var gateways: [DiscoveredGateway] = [] + public var statusText: String = "Idle" + + private var browsers: [String: NWBrowser] = [:] + private var resultsByDomain: [String: Set] = [:] + private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:] + private var statesByDomain: [String: NWBrowser.State] = [:] + private var localIdentity: LocalIdentity + private let localDisplayName: String? + private let filterLocalGateways: Bool + private var resolvedServiceByID: [String: ResolvedGatewayService] = [:] + private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:] + private var wideAreaFallbackTask: Task? + private var wideAreaFallbackGateways: [DiscoveredGateway] = [] + private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-discovery") + + public init( + localDisplayName: String? = nil, + filterLocalGateways: Bool = true) + { + self.localDisplayName = localDisplayName + self.filterLocalGateways = filterLocalGateways + self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName) + self.refreshLocalIdentity() + } + + public func start() { + if !self.browsers.isEmpty { return } + + for domain in OpenClawBonjour.gatewayServiceDomains { + let params = NWParameters.tcp + params.includePeerToPeer = true + let browser = NWBrowser( + for: .bonjour(type: OpenClawBonjour.gatewayServiceType, domain: domain), + using: params) + + browser.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + guard let self else { return } + self.statesByDomain[domain] = state + self.updateStatusText() + } + } + + browser.browseResultsChangedHandler = { [weak self] results, _ in + Task { @MainActor in + guard let self else { return } + self.resultsByDomain[domain] = results + self.updateGateways(for: domain) + self.recomputeGateways() + } + } + + self.browsers[domain] = browser + browser.start(queue: DispatchQueue(label: "ai.openclaw.macos.gateway-discovery.\(domain)")) + } + + self.scheduleWideAreaFallback() + } + + public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) { + guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return } + Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds) + await MainActor.run { [weak self] in + guard let self else { return } + self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain) + self.recomputeGateways() + } + } + } + + public func stop() { + for browser in self.browsers.values { + browser.cancel() + } + self.browsers = [:] + self.resultsByDomain = [:] + self.gatewaysByDomain = [:] + self.statesByDomain = [:] + self.resolvedServiceByID = [:] + self.pendingServiceResolvers.values.forEach { $0.cancel() } + self.pendingServiceResolvers = [:] + self.wideAreaFallbackTask?.cancel() + self.wideAreaFallbackTask = nil + self.wideAreaFallbackGateways = [] + self.gateways = [] + self.statusText = "Stopped" + } + + private func mapWideAreaBeacons(_ beacons: [WideAreaGatewayBeacon], domain: String) -> [DiscoveredGateway] { + beacons.map { beacon in + let stableID = "wide-area|\(domain)|\(beacon.instanceName)" + let isLocal = Self.isLocalGateway( + lanHost: beacon.lanHost, + tailnetDns: beacon.tailnetDns, + displayName: beacon.displayName, + serviceName: beacon.instanceName, + local: self.localIdentity) + return DiscoveredGateway( + displayName: beacon.displayName, + serviceHost: beacon.host, + servicePort: beacon.port, + lanHost: beacon.lanHost, + tailnetDns: beacon.tailnetDns, + sshPort: beacon.sshPort ?? 22, + gatewayPort: beacon.gatewayPort, + cliPath: beacon.cliPath, + stableID: stableID, + debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)", + isLocal: isLocal) + } + } + + private func recomputeGateways() { + let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self)) + let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary + if !primaryFiltered.isEmpty { + self.gateways = primaryFiltered + return + } + + // Bonjour can return only "local" results for the wide-area domain (or no results at all), + // which makes onboarding look empty even though Tailscale DNS-SD can already see gateways. + guard !self.wideAreaFallbackGateways.isEmpty else { + self.gateways = primaryFiltered + return + } + + let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways) + self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined + } + + private func updateGateways(for domain: String) { + guard let results = self.resultsByDomain[domain] else { + self.gatewaysByDomain[domain] = [] + return + } + + self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in + guard case let .service(name, type, resultDomain, _) = result.endpoint else { return nil } + + let decodedName = BonjourEscapes.decode(name) + let stableID = GatewayEndpointID.stableID(result.endpoint) + let resolved = self.resolvedServiceByID[stableID] + let resolvedTXT = resolved?.txt ?? [:] + let txt = Self.txtDictionary(from: result).merging( + resolvedTXT, + uniquingKeysWith: { _, new in new }) + + let advertisedName = txt["displayName"] + .map(Self.prettifyInstanceName) + .flatMap { $0.isEmpty ? nil : $0 } + let prettyName = + advertisedName ?? Self.prettifyServiceName(decodedName) + + let parsedTXT = Self.parseGatewayTXT(txt) + + // Always attempt NetService resolution for the endpoint (host/port and TXT). + // TXT is unauthenticated; do not use it for routing. + if resolved == nil { + self.ensureServiceResolution( + stableID: stableID, + serviceName: name, + type: type, + domain: resultDomain) + } + + let isLocal = Self.isLocalGateway( + lanHost: parsedTXT.lanHost, + tailnetDns: parsedTXT.tailnetDns, + displayName: prettyName, + serviceName: decodedName, + local: self.localIdentity) + return DiscoveredGateway( + displayName: prettyName, + serviceHost: resolved?.host, + servicePort: resolved?.port, + lanHost: parsedTXT.lanHost, + tailnetDns: parsedTXT.tailnetDns, + sshPort: parsedTXT.sshPort, + gatewayPort: parsedTXT.gatewayPort, + cliPath: parsedTXT.cliPath, + stableID: stableID, + debugID: GatewayEndpointID.prettyDescription(result.endpoint), + isLocal: isLocal) + } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + + if let wideAreaDomain = OpenClawBonjour.wideAreaGatewayServiceDomain, + domain == wideAreaDomain, + self.hasUsableWideAreaResults + { + self.wideAreaFallbackGateways = [] + } + } + + private func scheduleWideAreaFallback() { + guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return } + if Self.isRunningTests { return } + guard self.wideAreaFallbackTask == nil else { return } + self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + var attempt = 0 + let startedAt = Date() + while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 { + let hasResults = await MainActor.run { + self.hasUsableWideAreaResults + } + if hasResults { return } + + // Wide-area discovery can be racy (Tailscale not yet up, DNS zone not + // published yet). Retry with a short backoff while onboarding is open. + let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 2.0) + if !beacons.isEmpty { + await MainActor.run { [weak self] in + guard let self else { return } + self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain) + self.recomputeGateways() + } + return + } + + attempt += 1 + let backoff = min(8.0, 0.6 + (Double(attempt) * 0.7)) + try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) + } + } + } + + private var hasUsableWideAreaResults: Bool { + guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false } + guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false } + if !self.filterLocalGateways { return true } + return gateways.contains(where: { !$0.isLocal }) + } + + private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] { + var seen = Set() + let deduped = gateways.filter { gateway in + if seen.contains(gateway.stableID) { return false } + seen.insert(gateway.stableID) + return true + } + return deduped.sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } + + private nonisolated static var isRunningTests: Bool { + // Keep discovery background work from running forever during SwiftPM test runs. + if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true } + + let env = ProcessInfo.processInfo.environment + return env["XCTestConfigurationFilePath"] != nil + || env["XCTestBundlePath"] != nil + || env["XCTestSessionIdentifier"] != nil + } + + private func updateGatewaysForAllDomains() { + for domain in self.resultsByDomain.keys { + self.updateGateways(for: domain) + } + } + + private func updateStatusText() { + self.statusText = GatewayDiscoveryStatusText.make( + states: Array(self.statesByDomain.values), + hasBrowsers: !self.browsers.isEmpty) + } + + private static func txtDictionary(from result: NWBrowser.Result) -> [String: String] { + var merged: [String: String] = [:] + + if case let .bonjour(txt) = result.metadata { + merged.merge(txt.dictionary, uniquingKeysWith: { _, new in new }) + } + + if let endpointTxt = result.endpoint.txtRecord?.dictionary { + merged.merge(endpointTxt, uniquingKeysWith: { _, new in new }) + } + + return merged + } + + public struct GatewayTXT: Equatable { + public var lanHost: String? + public var tailnetDns: String? + public var sshPort: Int + public var gatewayPort: Int? + public var cliPath: String? + } + + public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT { + var lanHost: String? + var tailnetDns: String? + var sshPort = 22 + var gatewayPort: Int? + var cliPath: String? + + if let value = txt["lanHost"] { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + lanHost = trimmed.isEmpty ? nil : trimmed + } + if let value = txt["tailnetDns"] { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + tailnetDns = trimmed.isEmpty ? nil : trimmed + } + if let value = txt["sshPort"], + let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)), + parsed > 0 + { + sshPort = parsed + } + if let value = txt["gatewayPort"], + let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)), + parsed > 0 + { + gatewayPort = parsed + } + if let value = txt["cliPath"] { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + cliPath = trimmed.isEmpty ? nil : trimmed + } + + return GatewayTXT( + lanHost: lanHost, + tailnetDns: tailnetDns, + sshPort: sshPort, + gatewayPort: gatewayPort, + cliPath: cliPath) + } + + public static func buildSSHTarget(user: String, host: String, port: Int) -> String { + var target = "\(user)@\(host)" + if port != 22 { + target += ":\(port)" + } + return target + } + + private func ensureServiceResolution( + stableID: String, + serviceName: String, + type: String, + domain: String) + { + guard self.resolvedServiceByID[stableID] == nil else { return } + guard self.pendingServiceResolvers[stableID] == nil else { return } + + let resolver = GatewayServiceResolver( + name: serviceName, + type: type, + domain: domain, + logger: self.logger) + { [weak self] result in + Task { @MainActor in + guard let self else { return } + self.pendingServiceResolvers[stableID] = nil + switch result { + case let .success(resolved): + self.resolvedServiceByID[stableID] = resolved + self.updateGatewaysForAllDomains() + self.recomputeGateways() + case .failure: + break + } + } + } + + self.pendingServiceResolvers[stableID] = resolver + resolver.start() + } + + private nonisolated static func prettifyInstanceName(_ decodedName: String) -> String { + let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ") + let stripped = normalized.replacingOccurrences(of: " (OpenClaw)", with: "") + .replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression) + return stripped.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private nonisolated static func prettifyServiceName(_ decodedName: String) -> String { + let normalized = Self.prettifyInstanceName(decodedName) + var cleaned = normalized.replacingOccurrences(of: #"\s*-?gateway$"#, with: "", options: .regularExpression) + cleaned = cleaned + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + if cleaned.isEmpty { + cleaned = normalized + } + let words = cleaned.split(separator: " ") + let titled = words.map { word -> String in + let lower = word.lowercased() + guard let first = lower.first else { return "" } + return String(first).uppercased() + lower.dropFirst() + }.joined(separator: " ") + return titled.isEmpty ? normalized : titled + } + + public nonisolated static func isLocalGateway( + lanHost: String?, + tailnetDns: String?, + displayName: String?, + serviceName: String?, + local: LocalIdentity) -> Bool + { + if let host = normalizeHostToken(lanHost), + local.hostTokens.contains(host) + { + return true + } + if let host = normalizeHostToken(tailnetDns), + local.hostTokens.contains(host) + { + return true + } + if let name = normalizeDisplayToken(displayName), + local.displayTokens.contains(name) + { + return true + } + if let serviceHost = normalizeServiceHostToken(serviceName), + local.hostTokens.contains(serviceHost) + { + return true + } + return false + } + + private func refreshLocalIdentity() { + let fastIdentity = self.localIdentity + let displayName = self.localDisplayName + Task.detached(priority: .utility) { + let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName) + let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity) + await MainActor.run { [weak self] in + guard let self else { return } + guard self.localIdentity != merged else { return } + self.localIdentity = merged + self.recomputeGateways() + } + } + } + + private nonisolated static func mergeLocalIdentity( + fast: LocalIdentity, + slow: LocalIdentity) -> LocalIdentity + { + LocalIdentity( + hostTokens: fast.hostTokens.union(slow.hostTokens), + displayTokens: fast.displayTokens.union(slow.displayTokens)) + } + + private nonisolated static func buildLocalIdentityFast(displayName: String?) -> LocalIdentity { + var hostTokens: Set = [] + var displayTokens: Set = [] + + let hostName = ProcessInfo.processInfo.hostName + if let token = normalizeHostToken(hostName) { + hostTokens.insert(token) + } + + if let token = normalizeDisplayToken(displayName) { + displayTokens.insert(token) + } + + return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens) + } + + private nonisolated static func buildLocalIdentitySlow(displayName: String?) -> LocalIdentity { + var hostTokens: Set = [] + var displayTokens: Set = [] + + if let host = Host.current().name, + let token = normalizeHostToken(host) + { + hostTokens.insert(token) + } + + if let token = normalizeDisplayToken(displayName) { + displayTokens.insert(token) + } + + if let token = normalizeDisplayToken(Host.current().localizedName) { + displayTokens.insert(token) + } + + return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens) + } + + private nonisolated static func normalizeHostToken(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + let lower = trimmed.lowercased() + let strippedTrailingDot = lower.hasSuffix(".") + ? String(lower.dropLast()) + : lower + let withoutLocal = strippedTrailingDot.hasSuffix(".local") + ? String(strippedTrailingDot.dropLast(6)) + : strippedTrailingDot + let firstLabel = withoutLocal.split(separator: ".").first.map(String.init) + let token = (firstLabel ?? withoutLocal).trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty ? nil : token + } + + private nonisolated static func normalizeDisplayToken(_ raw: String?) -> String? { + guard let raw else { return nil } + let prettified = Self.prettifyInstanceName(raw) + let trimmed = prettified.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + return trimmed.lowercased() + } + + private nonisolated static func normalizeServiceHostToken(_ raw: String?) -> String? { + guard let raw else { return nil } + let prettified = Self.prettifyInstanceName(raw) + let strippedGateway = prettified.replacingOccurrences( + of: #"\s*-?\s*gateway$"#, + with: "", + options: .regularExpression) + return self.normalizeHostToken(strippedGateway) + } +} + +struct ResolvedGatewayService: Equatable, Sendable { + var txt: [String: String] + var host: String? + var port: Int? +} + +final class GatewayServiceResolver: NSObject, NetServiceDelegate { + private let service: NetService + private let completion: (Result) -> Void + private let logger: Logger + private var didFinish = false + + init( + name: String, + type: String, + domain: String, + logger: Logger, + completion: @escaping (Result) -> Void) + { + self.service = NetService(domain: domain, type: type, name: name) + self.completion = completion + self.logger = logger + super.init() + self.service.delegate = self + } + + func start(timeout: TimeInterval = 2.0) { + self.service.schedule(in: .main, forMode: .common) + self.service.resolve(withTimeout: timeout) + } + + func cancel() { + self.finish(result: .failure(GatewayServiceResolverError.cancelled)) + } + + func netServiceDidResolveAddress(_ sender: NetService) { + let txt = Self.decodeTXT(sender.txtRecordData()) + let host = Self.normalizeHost(sender.hostName) + let port = sender.port > 0 ? sender.port : nil + if !txt.isEmpty { + let payload = self.formatTXT(txt) + self.logger.debug( + "discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, privacy: .public)") + } + let resolved = ResolvedGatewayService(txt: txt, host: host, port: port) + self.finish(result: .success(resolved)) + } + + func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { + self.finish(result: .failure(GatewayServiceResolverError.resolveFailed(errorDict))) + } + + private func finish(result: Result) { + guard !self.didFinish else { return } + self.didFinish = true + self.service.stop() + self.service.remove(from: .main, forMode: .common) + self.completion(result) + } + + private static func decodeTXT(_ data: Data?) -> [String: String] { + guard let data else { return [:] } + let dict = NetService.dictionary(fromTXTRecord: data) + var out: [String: String] = [:] + out.reserveCapacity(dict.count) + for (key, value) in dict { + if let str = String(data: value, encoding: .utf8) { + out[key] = str + } + } + return out + } + + private static func normalizeHost(_ raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { return nil } + return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed + } + + private func formatTXT(_ txt: [String: String]) -> String { + txt.sorted(by: { $0.key < $1.key }) + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + } +} + +enum GatewayServiceResolverError: Error { + case cancelled + case resolveFailed([String: NSNumber]) +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift new file mode 100644 index 00000000..ef78e6f4 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift @@ -0,0 +1,46 @@ +import Darwin +import Foundation + +public enum TailscaleNetwork { + public static func isTailnetIPv4(_ address: String) -> Bool { + let parts = address.split(separator: ".") + guard parts.count == 4 else { return false } + let octets = parts.compactMap { Int($0) } + guard octets.count == 4 else { return false } + let a = octets[0] + let b = octets[1] + return a == 100 && b >= 64 && b <= 127 + } + + public static func detectTailnetIPv4() -> String? { + var addrList: UnsafeMutablePointer? + guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } + defer { freeifaddrs(addrList) } + + for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { + let flags = Int32(ptr.pointee.ifa_flags) + let isUp = (flags & IFF_UP) != 0 + let isLoopback = (flags & IFF_LOOPBACK) != 0 + let family = ptr.pointee.ifa_addr.pointee.sa_family + if !isUp || isLoopback || family != UInt8(AF_INET) { continue } + + var addr = ptr.pointee.ifa_addr.pointee + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + &addr, + socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard result == 0 else { continue } + let len = buffer.prefix { $0 != 0 } + let bytes = len.map { UInt8(bitPattern: $0) } + guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } + if self.isTailnetIPv4(ip) { return ip } + } + + return nil + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift new file mode 100644 index 00000000..fea0aca9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift @@ -0,0 +1,375 @@ +import Foundation +import OpenClawKit + +struct WideAreaGatewayBeacon: Sendable, Equatable { + var instanceName: String + var displayName: String + var host: String + var port: Int + var lanHost: String? + var tailnetDns: String? + var gatewayPort: Int? + var sshPort: Int? + var cliPath: String? +} + +enum WideAreaGatewayDiscovery { + private static let maxCandidates = 40 + private static let digPath = "/usr/bin/dig" + private static let defaultTimeoutSeconds: TimeInterval = 0.2 + private static let nameserverProbeConcurrency = 6 + + struct DiscoveryContext: Sendable { + var tailscaleStatus: @Sendable () -> String? + var dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String? + + static let live = DiscoveryContext( + tailscaleStatus: { readTailscaleStatus() }, + dig: { args, timeout in + runDig(args: args, timeout: timeout) + }) + } + + static func discover( + timeoutSeconds: TimeInterval = 2.0, + context: DiscoveryContext = .live) -> [WideAreaGatewayBeacon] + { + let startedAt = Date() + let remaining = { + timeoutSeconds - Date().timeIntervalSince(startedAt) + } + + guard let ips = collectTailnetIPv4s( + statusJson: context.tailscaleStatus()).nonEmpty else { return [] } + var candidates = Array(ips.prefix(self.maxCandidates)) + guard let nameserver = findNameserver( + candidates: &candidates, + remaining: remaining, + dig: context.dig) + else { + return [] + } + + guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return [] } + let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let probeName = "_openclaw-gw._tcp.\(domainTrimmed)" + guard let ptrLines = context.dig( + ["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"], + min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline), + !ptrLines.isEmpty + else { + return [] + } + + var beacons: [WideAreaGatewayBeacon] = [] + for raw in ptrLines { + let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if ptr.isEmpty { continue } + let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr + let suffix = "._openclaw-gw._tcp.\(domainTrimmed)" + let rawInstanceName = ptrName.hasSuffix(suffix) + ? String(ptrName.dropLast(suffix.count)) + : ptrName + let instanceName = self.decodeDnsSdEscapes(rawInstanceName) + + guard let srv = context.dig( + ["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"], + min(defaultTimeoutSeconds, remaining())) + else { continue } + guard let (host, port) = parseSrv(srv) else { continue } + + let txtRaw = context.dig( + ["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "TXT"], + min(self.defaultTimeoutSeconds, remaining())) + let txtTokens = txtRaw.map(self.parseTxtTokens) ?? [] + let txt = self.mapTxt(tokens: txtTokens) + + let displayName = txt["displayName"] ?? instanceName + let beacon = WideAreaGatewayBeacon( + instanceName: instanceName, + displayName: displayName, + host: host, + port: port, + lanHost: txt["lanHost"], + tailnetDns: txt["tailnetDns"], + gatewayPort: parseInt(txt["gatewayPort"]), + sshPort: parseInt(txt["sshPort"]), + cliPath: txt["cliPath"]) + beacons.append(beacon) + } + + return beacons + } + + private static func collectTailnetIPv4s(statusJson: String?) -> [String] { + guard let statusJson else { return [] } + let decoder = JSONDecoder() + guard let data = statusJson.data(using: .utf8), + let status = try? decoder.decode(TailscaleStatus.self, from: data) + else { return [] } + + var ips: [String] = [] + ips.append(contentsOf: status.selfNode?.resolvedIPs ?? []) + if let peers = status.peer { + for peer in peers.values { + ips.append(contentsOf: peer.resolvedIPs) + } + } + + var seen = Set() + return ips.filter { value in + guard self.isTailnetIPv4(value) else { return false } + if seen.contains(value) { return false } + seen.insert(value) + return true + } + } + + private static func readTailscaleStatus() -> String? { + let candidates = [ + "/usr/local/bin/tailscale", + "/opt/homebrew/bin/tailscale", + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + "tailscale", + ] + + var output: String? + for candidate in candidates { + if let result = run( + path: candidate, + args: ["status", "--json"], + timeout: 0.7) + { + output = result + break + } + } + + return output + } + + private static func findNameserver( + candidates: inout [String], + remaining: () -> TimeInterval, + dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String? + { + guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return nil } + let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let probeName = "_openclaw-gw._tcp.\(domainTrimmed)" + + let ips = candidates + candidates.removeAll(keepingCapacity: true) + if ips.isEmpty { return nil } + + final class ProbeState: @unchecked Sendable { + let lock = NSLock() + var nextIndex = 0 + var found: String? + } + + let state = ProbeState() + let deadline = Date().addingTimeInterval(max(0, remaining())) + let workerCount = min(self.nameserverProbeConcurrency, ips.count) + let group = DispatchGroup() + + for _ in 0..= ips.count { return } + let ip = ips[i] + let budget = deadline.timeIntervalSinceNow + if budget <= 0 { return } + + if let stdout = dig( + ["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"], + min(defaultTimeoutSeconds, budget)), + stdout.split(whereSeparator: \.isNewline).isEmpty == false + { + state.lock.lock() + if state.found == nil { + state.found = ip + } + state.lock.unlock() + return + } + } + } + } + + _ = group.wait(timeout: .now() + max(0.0, remaining())) + return state.found + } + + private static func runDig(args: [String], timeout: TimeInterval) -> String? { + self.run(path: self.digPath, args: args, timeout: timeout) + } + + private static func run(path: String, args: [String], timeout: TimeInterval) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = args + let outPipe = Pipe() + process.standardOutput = outPipe + // Avoid stderr pipe backpressure; we don't consume it. + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return nil + } + + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning, Date() < deadline { + Thread.sleep(forTimeInterval: 0.02) + } + if process.isRunning { + process.terminate() + } + process.waitUntilExit() + + let data = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data() + let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + return output?.isEmpty == false ? output : nil + } + + private static func parseSrv(_ stdout: String) -> (String, Int)? { + let line = stdout + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first(where: { !$0.isEmpty }) + guard let line else { return nil } + let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) + guard parts.count >= 4 else { return nil } + guard let port = Int(parts[2]), port > 0 else { return nil } + let host = parts[3].hasSuffix(".") ? String(parts[3].dropLast()) : parts[3] + return (host, port) + } + + private static func parseTxtTokens(_ stdout: String) -> [String] { + let lines = stdout.split(whereSeparator: \.isNewline) + var tokens: [String] = [] + for raw in lines { + let line = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if line.isEmpty { continue } + let matches = line.matches(of: /"([^"]*)"/) + for match in matches { + tokens.append(self.unescapeTxt(String(match.1))) + } + } + return tokens + } + + private static func unescapeTxt(_ value: String) -> String { + value + .replacingOccurrences(of: "\\\\", with: "\\") + .replacingOccurrences(of: "\\\"", with: "\"") + .replacingOccurrences(of: "\\n", with: "\n") + } + + private static func mapTxt(tokens: [String]) -> [String: String] { + var out: [String: String] = [:] + for token in tokens { + guard let idx = token.firstIndex(of: "=") else { continue } + let key = String(token[.. Int? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Int(trimmed) + } + + private static func isTailnetIPv4(_ value: String) -> Bool { + let parts = value.split(separator: ".") + if parts.count != 4 { return false } + let octets = parts.compactMap { Int($0) } + if octets.count != 4 { return false } + let a = octets[0] + let b = octets[1] + return a == 100 && b >= 64 && b <= 127 + } + + private static func decodeDnsSdEscapes(_ value: String) -> String { + var bytes: [UInt8] = [] + var pending = "" + + func flushPending() { + guard !pending.isEmpty else { return } + bytes.append(contentsOf: pending.utf8) + pending = "" + } + + let chars = Array(value) + var i = 0 + while i < chars.count { + let ch = chars[i] + if ch == "\\", i + 3 < chars.count { + let digits = String(chars[(i + 1)...(i + 3)]) + if digits.allSatisfy(\.isNumber), + let byte = UInt8(digits) + { + flushPending() + bytes.append(byte) + i += 4 + continue + } + } + pending.append(ch) + i += 1 + } + flushPending() + + if bytes.isEmpty { return value } + if let decoded = String(bytes: bytes, encoding: .utf8) { + return decoded + } + return value + } +} + +private struct TailscaleStatus: Decodable { + struct Node: Decodable { + let tailscaleIPs: [String]? + + var resolvedIPs: [String] { + self.tailscaleIPs ?? [] + } + + private enum CodingKeys: String, CodingKey { + case tailscaleIPs = "TailscaleIPs" + } + } + + let selfNode: Node? + let peer: [String: Node]? + + private enum CodingKeys: String, CodingKey { + case selfNode = "Self" + case peer = "Peer" + } +} + +extension Collection { + fileprivate var nonEmpty: Self? { + isEmpty ? nil : self + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawIPC/IPC.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawIPC/IPC.swift new file mode 100644 index 00000000..13fbe875 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawIPC/IPC.swift @@ -0,0 +1,416 @@ +import CoreGraphics +import Foundation + +// MARK: - Capabilities + +public enum Capability: String, Codable, CaseIterable, Sendable { + /// AppleScript / Automation access to control other apps (TCC Automation). + case appleScript + case notifications + case accessibility + case screenRecording + case microphone + case speechRecognition + case camera + case location +} + +public enum CameraFacing: String, Codable, Sendable { + case front + case back +} + +// MARK: - Requests + +/// Notification interruption level (maps to UNNotificationInterruptionLevel) +public enum NotificationPriority: String, Codable, Sendable { + case passive // silent, no wake + case active // default + case timeSensitive // breaks through Focus modes +} + +/// Notification delivery mechanism. +public enum NotificationDelivery: String, Codable, Sendable { + /// Use macOS notification center (UNUserNotificationCenter). + case system + /// Use an in-app overlay/toast (no Notification Center history). + case overlay + /// Prefer system; fall back to overlay when system isn't available. + case auto +} + +// MARK: - Canvas geometry + +/// Optional placement hints for the Canvas panel. +/// Values are in screen coordinates (same as `NSWindow` frame). +public struct CanvasPlacement: Codable, Sendable { + public var x: Double? + public var y: Double? + public var width: Double? + public var height: Double? + + public init(x: Double? = nil, y: Double? = nil, width: Double? = nil, height: Double? = nil) { + self.x = x + self.y = y + self.width = width + self.height = height + } +} + +// MARK: - Canvas show result + +public enum CanvasShowStatus: String, Codable, Sendable { + /// Panel was shown, but no navigation occurred (no target passed and session already existed). + case shown + /// Target was a direct URL (http(s) or file). + case web + /// Local canvas target resolved to an existing file. + case ok + /// Local canvas target did not resolve to a file (404 page). + case notFound + /// Local scaffold fallback (e.g., no index.html present). + case welcome +} + +public struct CanvasShowResult: Codable, Sendable { + /// Session directory on disk (e.g. `~/Library/Application Support/OpenClaw/canvas//`). + public var directory: String + /// Target as provided by the caller (may be nil/empty). + public var target: String? + /// Target actually navigated to (nil when no navigation occurred; defaults to "/" for a newly created session). + public var effectiveTarget: String? + public var status: CanvasShowStatus + /// URL that was loaded (nil when no navigation occurred). + public var url: String? + + public init( + directory: String, + target: String?, + effectiveTarget: String?, + status: CanvasShowStatus, + url: String?) + { + self.directory = directory + self.target = target + self.effectiveTarget = effectiveTarget + self.status = status + self.url = url + } +} + +// MARK: - Canvas A2UI + +public enum CanvasA2UICommand: String, Codable, Sendable { + case pushJSONL + case reset +} + +public enum Request: Sendable { + case notify( + title: String, + body: String, + sound: String?, + priority: NotificationPriority?, + delivery: NotificationDelivery?) + case ensurePermissions([Capability], interactive: Bool) + case runShell( + command: [String], + cwd: String?, + env: [String: String]?, + timeoutSec: Double?, + needsScreenRecording: Bool) + case status + case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?) + case rpcStatus + case canvasPresent(session: String, path: String?, placement: CanvasPlacement?) + case canvasHide(session: String) + case canvasEval(session: String, javaScript: String) + case canvasSnapshot(session: String, outPath: String?) + case canvasA2UI(session: String, command: CanvasA2UICommand, jsonl: String?) + case nodeList + case nodeDescribe(nodeId: String) + case nodeInvoke(nodeId: String, command: String, paramsJSON: String?) + case cameraSnap(facing: CameraFacing?, maxWidth: Int?, quality: Double?, outPath: String?) + case cameraClip(facing: CameraFacing?, durationMs: Int?, includeAudio: Bool, outPath: String?) + case screenRecord(screenIndex: Int?, durationMs: Int?, fps: Double?, includeAudio: Bool, outPath: String?) +} + +// MARK: - Responses + +public struct Response: Codable, Sendable { + public var ok: Bool + public var message: String? + /// Optional payload (PNG bytes, stdout text, etc.). + public var payload: Data? + + public init(ok: Bool, message: String? = nil, payload: Data? = nil) { + self.ok = ok + self.message = message + self.payload = payload + } +} + +// MARK: - Codable conformance for Request + +extension Request: Codable { + private enum CodingKeys: String, CodingKey { + case type + case title, body, sound, priority, delivery + case caps, interactive + case command, cwd, env, timeoutSec, needsScreenRecording + case message, thinking, session, deliver, to + case rpcStatus + case path + case javaScript + case outPath + case screenIndex + case fps + case canvasA2UICommand + case jsonl + case facing + case maxWidth + case quality + case durationMs + case includeAudio + case placement + case nodeId + case nodeCommand + case paramsJSON + } + + private enum Kind: String, Codable { + case notify + case ensurePermissions + case runShell + case status + case agent + case rpcStatus + case canvasPresent + case canvasHide + case canvasEval + case canvasSnapshot + case canvasA2UI + case nodeList + case nodeDescribe + case nodeInvoke + case cameraSnap + case cameraClip + case screenRecord + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .notify(title, body, sound, priority, delivery): + try container.encode(Kind.notify, forKey: .type) + try container.encode(title, forKey: .title) + try container.encode(body, forKey: .body) + try container.encodeIfPresent(sound, forKey: .sound) + try container.encodeIfPresent(priority, forKey: .priority) + try container.encodeIfPresent(delivery, forKey: .delivery) + + case let .ensurePermissions(caps, interactive): + try container.encode(Kind.ensurePermissions, forKey: .type) + try container.encode(caps, forKey: .caps) + try container.encode(interactive, forKey: .interactive) + + case let .runShell(command, cwd, env, timeoutSec, needsSR): + try container.encode(Kind.runShell, forKey: .type) + try container.encode(command, forKey: .command) + try container.encodeIfPresent(cwd, forKey: .cwd) + try container.encodeIfPresent(env, forKey: .env) + try container.encodeIfPresent(timeoutSec, forKey: .timeoutSec) + try container.encode(needsSR, forKey: .needsScreenRecording) + + case .status: + try container.encode(Kind.status, forKey: .type) + + case let .agent(message, thinking, session, deliver, to): + try container.encode(Kind.agent, forKey: .type) + try container.encode(message, forKey: .message) + try container.encodeIfPresent(thinking, forKey: .thinking) + try container.encodeIfPresent(session, forKey: .session) + try container.encode(deliver, forKey: .deliver) + try container.encodeIfPresent(to, forKey: .to) + + case .rpcStatus: + try container.encode(Kind.rpcStatus, forKey: .type) + + case let .canvasPresent(session, path, placement): + try container.encode(Kind.canvasPresent, forKey: .type) + try container.encode(session, forKey: .session) + try container.encodeIfPresent(path, forKey: .path) + try container.encodeIfPresent(placement, forKey: .placement) + + case let .canvasHide(session): + try container.encode(Kind.canvasHide, forKey: .type) + try container.encode(session, forKey: .session) + + case let .canvasEval(session, javaScript): + try container.encode(Kind.canvasEval, forKey: .type) + try container.encode(session, forKey: .session) + try container.encode(javaScript, forKey: .javaScript) + + case let .canvasSnapshot(session, outPath): + try container.encode(Kind.canvasSnapshot, forKey: .type) + try container.encode(session, forKey: .session) + try container.encodeIfPresent(outPath, forKey: .outPath) + + case let .canvasA2UI(session, command, jsonl): + try container.encode(Kind.canvasA2UI, forKey: .type) + try container.encode(session, forKey: .session) + try container.encode(command, forKey: .canvasA2UICommand) + try container.encodeIfPresent(jsonl, forKey: .jsonl) + + case .nodeList: + try container.encode(Kind.nodeList, forKey: .type) + + case let .nodeDescribe(nodeId): + try container.encode(Kind.nodeDescribe, forKey: .type) + try container.encode(nodeId, forKey: .nodeId) + + case let .nodeInvoke(nodeId, command, paramsJSON): + try container.encode(Kind.nodeInvoke, forKey: .type) + try container.encode(nodeId, forKey: .nodeId) + try container.encode(command, forKey: .nodeCommand) + try container.encodeIfPresent(paramsJSON, forKey: .paramsJSON) + + case let .cameraSnap(facing, maxWidth, quality, outPath): + try container.encode(Kind.cameraSnap, forKey: .type) + try container.encodeIfPresent(facing, forKey: .facing) + try container.encodeIfPresent(maxWidth, forKey: .maxWidth) + try container.encodeIfPresent(quality, forKey: .quality) + try container.encodeIfPresent(outPath, forKey: .outPath) + + case let .cameraClip(facing, durationMs, includeAudio, outPath): + try container.encode(Kind.cameraClip, forKey: .type) + try container.encodeIfPresent(facing, forKey: .facing) + try container.encodeIfPresent(durationMs, forKey: .durationMs) + try container.encode(includeAudio, forKey: .includeAudio) + try container.encodeIfPresent(outPath, forKey: .outPath) + + case let .screenRecord(screenIndex, durationMs, fps, includeAudio, outPath): + try container.encode(Kind.screenRecord, forKey: .type) + try container.encodeIfPresent(screenIndex, forKey: .screenIndex) + try container.encodeIfPresent(durationMs, forKey: .durationMs) + try container.encodeIfPresent(fps, forKey: .fps) + try container.encode(includeAudio, forKey: .includeAudio) + try container.encodeIfPresent(outPath, forKey: .outPath) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(Kind.self, forKey: .type) + switch kind { + case .notify: + let title = try container.decode(String.self, forKey: .title) + let body = try container.decode(String.self, forKey: .body) + let sound = try container.decodeIfPresent(String.self, forKey: .sound) + let priority = try container.decodeIfPresent(NotificationPriority.self, forKey: .priority) + let delivery = try container.decodeIfPresent(NotificationDelivery.self, forKey: .delivery) + self = .notify(title: title, body: body, sound: sound, priority: priority, delivery: delivery) + + case .ensurePermissions: + let caps = try container.decode([Capability].self, forKey: .caps) + let interactive = try container.decode(Bool.self, forKey: .interactive) + self = .ensurePermissions(caps, interactive: interactive) + + case .runShell: + let command = try container.decode([String].self, forKey: .command) + let cwd = try container.decodeIfPresent(String.self, forKey: .cwd) + let env = try container.decodeIfPresent([String: String].self, forKey: .env) + let timeout = try container.decodeIfPresent(Double.self, forKey: .timeoutSec) + let needsSR = try container.decode(Bool.self, forKey: .needsScreenRecording) + self = .runShell(command: command, cwd: cwd, env: env, timeoutSec: timeout, needsScreenRecording: needsSR) + + case .status: + self = .status + + case .agent: + let message = try container.decode(String.self, forKey: .message) + let thinking = try container.decodeIfPresent(String.self, forKey: .thinking) + let session = try container.decodeIfPresent(String.self, forKey: .session) + let deliver = try container.decode(Bool.self, forKey: .deliver) + let to = try container.decodeIfPresent(String.self, forKey: .to) + self = .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to) + + case .rpcStatus: + self = .rpcStatus + + case .canvasPresent: + let session = try container.decode(String.self, forKey: .session) + let path = try container.decodeIfPresent(String.self, forKey: .path) + let placement = try container.decodeIfPresent(CanvasPlacement.self, forKey: .placement) + self = .canvasPresent(session: session, path: path, placement: placement) + + case .canvasHide: + let session = try container.decode(String.self, forKey: .session) + self = .canvasHide(session: session) + + case .canvasEval: + let session = try container.decode(String.self, forKey: .session) + let javaScript = try container.decode(String.self, forKey: .javaScript) + self = .canvasEval(session: session, javaScript: javaScript) + + case .canvasSnapshot: + let session = try container.decode(String.self, forKey: .session) + let outPath = try container.decodeIfPresent(String.self, forKey: .outPath) + self = .canvasSnapshot(session: session, outPath: outPath) + + case .canvasA2UI: + let session = try container.decode(String.self, forKey: .session) + let command = try container.decode(CanvasA2UICommand.self, forKey: .canvasA2UICommand) + let jsonl = try container.decodeIfPresent(String.self, forKey: .jsonl) + self = .canvasA2UI(session: session, command: command, jsonl: jsonl) + + case .nodeList: + self = .nodeList + + case .nodeDescribe: + let nodeId = try container.decode(String.self, forKey: .nodeId) + self = .nodeDescribe(nodeId: nodeId) + + case .nodeInvoke: + let nodeId = try container.decode(String.self, forKey: .nodeId) + let command = try container.decode(String.self, forKey: .nodeCommand) + let paramsJSON = try container.decodeIfPresent(String.self, forKey: .paramsJSON) + self = .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON) + + case .cameraSnap: + let facing = try container.decodeIfPresent(CameraFacing.self, forKey: .facing) + let maxWidth = try container.decodeIfPresent(Int.self, forKey: .maxWidth) + let quality = try container.decodeIfPresent(Double.self, forKey: .quality) + let outPath = try container.decodeIfPresent(String.self, forKey: .outPath) + self = .cameraSnap(facing: facing, maxWidth: maxWidth, quality: quality, outPath: outPath) + + case .cameraClip: + let facing = try container.decodeIfPresent(CameraFacing.self, forKey: .facing) + let durationMs = try container.decodeIfPresent(Int.self, forKey: .durationMs) + let includeAudio = (try? container.decode(Bool.self, forKey: .includeAudio)) ?? true + let outPath = try container.decodeIfPresent(String.self, forKey: .outPath) + self = .cameraClip(facing: facing, durationMs: durationMs, includeAudio: includeAudio, outPath: outPath) + + case .screenRecord: + let screenIndex = try container.decodeIfPresent(Int.self, forKey: .screenIndex) + let durationMs = try container.decodeIfPresent(Int.self, forKey: .durationMs) + let fps = try container.decodeIfPresent(Double.self, forKey: .fps) + let includeAudio = (try? container.decode(Bool.self, forKey: .includeAudio)) ?? true + let outPath = try container.decodeIfPresent(String.self, forKey: .outPath) + self = .screenRecord( + screenIndex: screenIndex, + durationMs: durationMs, + fps: fps, + includeAudio: includeAudio, + outPath: outPath) + } + } +} + +/// Shared transport settings +public let controlSocketPath: String = { + let home = FileManager().homeDirectoryForCurrentUser + return home + .appendingPathComponent("Library/Application Support/OpenClaw/control.sock") + .path +}() diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift new file mode 100644 index 00000000..151b7fdd --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift @@ -0,0 +1,309 @@ +import Foundation +import OpenClawDiscovery +import OpenClawKit +import OpenClawProtocol + +struct ConnectOptions { + var url: String? + var token: String? + var password: String? + var mode: String? + var timeoutMs: Int = 15000 + var json: Bool = false + var probe: Bool = false + var clientId: String = "openclaw-macos" + var clientMode: String = "ui" + var displayName: String? + var role: String = "operator" + var scopes: [String] = defaultOperatorConnectScopes + var help: Bool = false + + static func parse(_ args: [String]) -> ConnectOptions { + var opts = ConnectOptions() + let flagHandlers: [String: (inout ConnectOptions) -> Void] = [ + "-h": { $0.help = true }, + "--help": { $0.help = true }, + "--json": { $0.json = true }, + "--probe": { $0.probe = true }, + ] + let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [ + "--url": { $0.url = $1 }, + "--token": { $0.token = $1 }, + "--password": { $0.password = $1 }, + "--mode": { $0.mode = $1 }, + "--timeout": { opts, raw in + if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) { + opts.timeoutMs = max(250, parsed) + } + }, + "--client-id": { $0.clientId = $1 }, + "--client-mode": { $0.clientMode = $1 }, + "--display-name": { $0.displayName = $1 }, + "--role": { $0.role = $1 }, + "--scopes": { opts, raw in + opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + }, + ] + var i = 0 + while i < args.count { + let arg = args[i] + if let handler = flagHandlers[arg] { + handler(&opts) + i += 1 + continue + } + if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) { + handler(&opts, value) + i += 1 + continue + } + i += 1 + } + return opts + } + + private static func nextValue(_ args: [String], index: inout Int) -> String? { + guard index + 1 < args.count else { return nil } + index += 1 + return args[index].trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +struct ConnectOutput: Encodable { + var status: String + var url: String + var mode: String + var role: String + var clientId: String + var clientMode: String + var scopes: [String] + var snapshot: HelloOk? + var health: ProtoAnyCodable? + var error: String? +} + +actor SnapshotStore { + private var value: HelloOk? + + func set(_ snapshot: HelloOk) { + self.value = snapshot + } + + func get() -> HelloOk? { + self.value + } +} + +func runConnect(_ args: [String]) async { + let opts = ConnectOptions.parse(args) + if opts.help { + print(""" + openclaw-mac connect + + Usage: + openclaw-mac connect [--url ] [--token ] [--password ] + [--mode ] [--timeout ] [--probe] [--json] + [--client-id ] [--client-mode ] [--display-name ] + [--role ] [--scopes ] + + Options: + --url Gateway WebSocket URL (overrides config) + --token Gateway token (if required) + --password Gateway password (if required) + --mode Resolve from config: local|remote (default: config or local) + --timeout Request timeout (default: 15000) + --probe Force a fresh health probe + --json Emit JSON + --client-id Override client id (default: openclaw-macos) + --client-mode Override client mode (default: ui) + --display-name Override display name + --role Override role (default: operator) + --scopes Override scopes list + -h, --help Show help + """) + return + } + + let config = loadGatewayConfig() + do { + let endpoint = try resolveGatewayEndpoint(opts: opts, config: config) + let displayName = opts.displayName ?? Host.current().localizedName ?? "OpenClaw macOS Debug CLI" + let connectOptions = GatewayConnectOptions( + role: opts.role, + scopes: opts.scopes, + caps: [], + commands: [], + permissions: [:], + clientId: opts.clientId, + clientMode: opts.clientMode, + clientDisplayName: displayName) + + let snapshotStore = SnapshotStore() + let channel = GatewayChannelActor( + url: endpoint.url, + token: endpoint.token, + password: endpoint.password, + pushHandler: { push in + if case let .snapshot(ok) = push { + await snapshotStore.set(ok) + } + }, + connectOptions: connectOptions) + + let params: [String: KitAnyCodable]? = opts.probe ? ["probe": KitAnyCodable(true)] : nil + let data = try await channel.request( + method: "health", + params: params, + timeoutMs: Double(opts.timeoutMs)) + let health = try? JSONDecoder().decode(ProtoAnyCodable.self, from: data) + let snapshot = await snapshotStore.get() + await channel.shutdown() + + let output = ConnectOutput( + status: "ok", + url: endpoint.url.absoluteString, + mode: endpoint.mode, + role: opts.role, + clientId: opts.clientId, + clientMode: opts.clientMode, + scopes: opts.scopes, + snapshot: snapshot, + health: health, + error: nil) + printConnectOutput(output, json: opts.json) + } catch { + let endpoint = bestEffortEndpoint(opts: opts, config: config) + let fallbackMode = (opts.mode ?? config.mode ?? "local").lowercased() + let output = ConnectOutput( + status: "error", + url: endpoint?.url.absoluteString ?? "unknown", + mode: endpoint?.mode ?? fallbackMode, + role: opts.role, + clientId: opts.clientId, + clientMode: opts.clientMode, + scopes: opts.scopes, + snapshot: nil, + health: nil, + error: error.localizedDescription) + printConnectOutput(output, json: opts.json) + exit(1) + } +} + +private func printConnectOutput(_ output: ConnectOutput, json: Bool) { + if json { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(output), + let text = String(data: data, encoding: .utf8) + { + print(text) + } else { + print("{\"error\":\"failed to encode JSON\"}") + } + return + } + + print("OpenClaw macOS Gateway Connect") + print("Status: \(output.status)") + print("URL: \(output.url)") + print("Mode: \(output.mode)") + print("Client: \(output.clientId) (\(output.clientMode))") + print("Role: \(output.role)") + print("Scopes: \(output.scopes.joined(separator: ", "))") + if let snapshot = output.snapshot { + print("Protocol: \(snapshot._protocol)") + if let version = snapshot.server["version"]?.value as? String { + print("Server: \(version)") + } + } + if let health = output.health, + let ok = (health.value as? [String: ProtoAnyCodable])?["ok"]?.value as? Bool + { + print("Health: \(ok ? "ok" : "error")") + } else if output.health != nil { + print("Health: received") + } + if let error = output.error { + print("Error: \(error)") + } +} + +private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint { + let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased() + if let raw = opts.url, !raw.isEmpty { + guard let url = URL(string: raw) else { + throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) + } + return GatewayEndpoint( + url: url, + token: resolvedToken(opts: opts, mode: resolvedMode, config: config), + password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), + mode: resolvedMode) + } + + if resolvedMode == "remote" { + guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"]) + } + guard let url = URL(string: raw) else { + throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) + } + return GatewayEndpoint( + url: url, + token: resolvedToken(opts: opts, mode: resolvedMode, config: config), + password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), + mode: resolvedMode) + } + + let port = config.port ?? 18789 + let host = resolveLocalHost(bind: config.bind) + guard let url = URL(string: "ws://\(host):\(port)") else { + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"]) + } + return GatewayEndpoint( + url: url, + token: resolvedToken(opts: opts, mode: resolvedMode, config: config), + password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), + mode: resolvedMode) +} + +private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? { + try? resolveGatewayEndpoint(opts: opts, config: config) +} + +private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? { + if let token = opts.token, !token.isEmpty { return token } + if mode == "remote" { + return config.remoteToken + } + return config.token +} + +private func resolvedPassword(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? { + if let password = opts.password, !password.isEmpty { return password } + if mode == "remote" { + return config.remotePassword + } + return config.password +} + +private func resolveLocalHost(bind: String?) -> String { + let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let tailnetIP = TailscaleNetwork.detectTailnetIPv4() + switch normalized { + case "tailnet": + return tailnetIP ?? "127.0.0.1" + default: + return "127.0.0.1" + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/DiscoverCommand.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/DiscoverCommand.swift new file mode 100644 index 00000000..b039ecdf --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/DiscoverCommand.swift @@ -0,0 +1,149 @@ +import Foundation +import OpenClawDiscovery + +struct DiscoveryOptions { + var timeoutMs: Int = 2000 + var json: Bool = false + var includeLocal: Bool = false + var help: Bool = false + + static func parse(_ args: [String]) -> DiscoveryOptions { + var opts = DiscoveryOptions() + var i = 0 + while i < args.count { + let arg = args[i] + switch arg { + case "-h", "--help": + opts.help = true + case "--json": + opts.json = true + case "--include-local": + opts.includeLocal = true + case "--timeout": + let next = (i + 1 < args.count) ? args[i + 1] : nil + if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) { + opts.timeoutMs = max(100, parsed) + i += 1 + } + default: + break + } + i += 1 + } + return opts + } +} + +struct DiscoveryOutput: Encodable { + struct Gateway: Encodable { + var displayName: String + var lanHost: String? + var tailnetDns: String? + var sshPort: Int + var gatewayPort: Int? + var cliPath: String? + var stableID: String + var debugID: String + var isLocal: Bool + } + + var status: String + var timeoutMs: Int + var includeLocal: Bool + var count: Int + var gateways: [Gateway] +} + +func runDiscover(_ args: [String]) async { + let opts = DiscoveryOptions.parse(args) + if opts.help { + print(""" + openclaw-mac discover + + Usage: + openclaw-mac discover [--timeout ] [--json] [--include-local] + + Options: + --timeout Discovery window in milliseconds (default: 2000) + --json Emit JSON + --include-local Include gateways considered local + -h, --help Show help + """) + return + } + + let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName + let model = await MainActor.run { + GatewayDiscoveryModel( + localDisplayName: displayName, + filterLocalGateways: !opts.includeLocal) + } + + await MainActor.run { + model.start() + } + + let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000 + try? await Task.sleep(nanoseconds: nanos) + + let gateways = await MainActor.run { model.gateways } + let status = await MainActor.run { model.statusText } + + await MainActor.run { + model.stop() + } + + if opts.json { + let payload = DiscoveryOutput( + status: status, + timeoutMs: opts.timeoutMs, + includeLocal: opts.includeLocal, + count: gateways.count, + gateways: gateways.map { + DiscoveryOutput.Gateway( + displayName: $0.displayName, + lanHost: $0.lanHost, + tailnetDns: $0.tailnetDns, + sshPort: $0.sshPort, + gatewayPort: $0.gatewayPort, + cliPath: $0.cliPath, + stableID: $0.stableID, + debugID: $0.debugID, + isLocal: $0.isLocal) + }) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(payload), + let json = String(data: data, encoding: .utf8) + { + print(json) + } else { + print("{\"error\":\"failed to encode JSON\"}") + } + return + } + + print("Gateway Discovery (macOS NWBrowser)") + print("Status: \(status)") + print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")") + if gateways.isEmpty { return } + + for gateway in gateways { + let hosts = [gateway.tailnetDns, gateway.lanHost] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: ", ") + print("- \(gateway.displayName)") + print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)") + print(" ssh: \(gateway.sshPort)") + if let port = gateway.gatewayPort { + print(" gatewayPort: \(port)") + } + if let cliPath = gateway.cliPath { + print(" cliPath: \(cliPath)") + } + print(" isLocal: \(gateway.isLocal)") + print(" stableID: \(gateway.stableID)") + print(" debugID: \(gateway.debugID)") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/EntryPoint.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/EntryPoint.swift new file mode 100644 index 00000000..6cb4880c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/EntryPoint.swift @@ -0,0 +1,56 @@ +import Foundation + +private struct RootCommand { + var name: String + var args: [String] +} + +@main +struct OpenClawMacCLI { + static func main() async { + let args = Array(CommandLine.arguments.dropFirst()) + let command = parseRootCommand(args) + switch command?.name { + case nil: + printUsage() + case "-h", "--help", "help": + printUsage() + case "connect": + await runConnect(command?.args ?? []) + case "discover": + await runDiscover(command?.args ?? []) + case "wizard": + await runWizardCommand(command?.args ?? []) + default: + fputs("openclaw-mac: unknown command\n", stderr) + printUsage() + exit(1) + } + } +} + +private func parseRootCommand(_ args: [String]) -> RootCommand? { + guard let first = args.first else { return nil } + return RootCommand(name: first, args: Array(args.dropFirst())) +} + +private func printUsage() { + print(""" + openclaw-mac + + Usage: + openclaw-mac connect [--url ] [--token ] [--password ] + [--mode ] [--timeout ] [--probe] [--json] + [--client-id ] [--client-mode ] [--display-name ] + [--role ] [--scopes ] + openclaw-mac discover [--timeout ] [--json] [--include-local] + openclaw-mac wizard [--url ] [--token ] [--password ] + [--mode ] [--workspace ] [--json] + + Examples: + openclaw-mac connect + openclaw-mac connect --url ws://127.0.0.1:18789 --json + openclaw-mac discover --timeout 3000 --json + openclaw-mac wizard --mode local + """) +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift new file mode 100644 index 00000000..c3c963b2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift @@ -0,0 +1,62 @@ +import Foundation + +struct GatewayConfig { + var mode: String? + var bind: String? + var port: Int? + var remoteUrl: String? + var token: String? + var password: String? + var remoteToken: String? + var remotePassword: String? +} + +struct GatewayEndpoint { + let url: URL + let token: String? + let password: String? + let mode: String +} + +func loadGatewayConfig() -> GatewayConfig { + let home = FileManager().homeDirectoryForCurrentUser + let candidates = [ + home.appendingPathComponent(".openclaw/openclaw.json"), + ] + let url = candidates.first { FileManager().isReadableFile(atPath: $0.path) } ?? candidates[0] + guard let data = try? Data(contentsOf: url) else { return GatewayConfig() } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return GatewayConfig() + } + + var cfg = GatewayConfig() + if let gateway = json["gateway"] as? [String: Any] { + cfg.mode = gateway["mode"] as? String + cfg.bind = gateway["bind"] as? String + cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"]) + + if let auth = gateway["auth"] as? [String: Any] { + cfg.token = auth["token"] as? String + cfg.password = auth["password"] as? String + } + if let remote = gateway["remote"] as? [String: Any] { + cfg.remoteUrl = remote["url"] as? String + cfg.remoteToken = remote["token"] as? String + cfg.remotePassword = remote["password"] as? String + } + } + return cfg +} + +func parseInt(_ value: Any?) -> Int? { + switch value { + case let number as Int: + number + case let number as Double: + Int(number) + case let raw as String: + Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + nil + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift new file mode 100644 index 00000000..479c176d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift @@ -0,0 +1,7 @@ +let defaultOperatorConnectScopes: [String] = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", +] diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/TypeAliases.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/TypeAliases.swift new file mode 100644 index 00000000..28b3a7eb --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/TypeAliases.swift @@ -0,0 +1,5 @@ +import OpenClawKit +import OpenClawProtocol + +typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable +typealias KitAnyCodable = OpenClawKit.AnyCodable diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift new file mode 100644 index 00000000..ebe3e8ae --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -0,0 +1,539 @@ +import Darwin +import Foundation +import OpenClawKit +import OpenClawProtocol + +struct WizardCliOptions { + var url: String? + var token: String? + var password: String? + var mode: String = "local" + var workspace: String? + var json: Bool = false + var help: Bool = false + + static func parse(_ args: [String]) -> WizardCliOptions { + var opts = WizardCliOptions() + var i = 0 + while i < args.count { + let arg = args[i] + switch arg { + case "-h", "--help": + opts.help = true + case "--json": + opts.json = true + case "--url": + opts.url = self.nextValue(args, index: &i) + case "--token": + opts.token = self.nextValue(args, index: &i) + case "--password": + opts.password = self.nextValue(args, index: &i) + case "--mode": + if let value = nextValue(args, index: &i) { + opts.mode = value + } + case "--workspace": + opts.workspace = self.nextValue(args, index: &i) + default: + break + } + i += 1 + } + return opts + } + + private static func nextValue(_ args: [String], index: inout Int) -> String? { + guard index + 1 < args.count else { return nil } + index += 1 + return args[index].trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +enum WizardCliError: Error, CustomStringConvertible { + case invalidUrl(String) + case missingRemoteUrl + case gatewayError(String) + case decodeError(String) + case cancelled + + var description: String { + switch self { + case let .invalidUrl(raw): "Invalid URL: \(raw)" + case .missingRemoteUrl: "gateway.remote.url is missing" + case let .gatewayError(msg): msg + case let .decodeError(msg): msg + case .cancelled: "Wizard cancelled" + } + } +} + +func runWizardCommand(_ args: [String]) async { + let opts = WizardCliOptions.parse(args) + if opts.help { + print(""" + openclaw-mac wizard + + Usage: + openclaw-mac wizard [--url ] [--token ] [--password ] + [--mode ] [--workspace ] [--json] + + Options: + --url Gateway WebSocket URL (overrides config) + --token Gateway token (if required) + --password Gateway password (if required) + --mode Wizard mode (local|remote). Default: local + --workspace Wizard workspace override + --json Print raw wizard responses + -h, --help Show help + """) + return + } + + let config = loadGatewayConfig() + do { + guard isatty(STDIN_FILENO) != 0 else { + throw WizardCliError.gatewayError("Wizard requires an interactive TTY.") + } + let endpoint = try resolveWizardGatewayEndpoint(opts: opts, config: config) + let client = GatewayWizardClient( + url: endpoint.url, + token: endpoint.token, + password: endpoint.password, + json: opts.json) + try await client.connect() + defer { Task { await client.close() } } + try await runWizard(client: client, opts: opts) + } catch { + fputs("wizard: \(error)\n", stderr) + exit(1) + } +} + +private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint { + if let raw = opts.url, !raw.isEmpty { + guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) } + return GatewayEndpoint( + url: url, + token: resolvedToken(opts: opts, config: config), + password: resolvedPassword(opts: opts, config: config), + mode: (config.mode ?? "local").lowercased()) + } + + let mode = (config.mode ?? "local").lowercased() + if mode == "remote" { + guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + throw WizardCliError.missingRemoteUrl + } + guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) } + return GatewayEndpoint( + url: url, + token: resolvedToken(opts: opts, config: config), + password: resolvedPassword(opts: opts, config: config), + mode: mode) + } + + let port = config.port ?? 18789 + let host = "127.0.0.1" + guard let url = URL(string: "ws://\(host):\(port)") else { + throw WizardCliError.invalidUrl("ws://\(host):\(port)") + } + return GatewayEndpoint( + url: url, + token: resolvedToken(opts: opts, config: config), + password: resolvedPassword(opts: opts, config: config), + mode: mode) +} + +private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? { + if let token = opts.token, !token.isEmpty { return token } + if (config.mode ?? "local").lowercased() == "remote" { + return config.remoteToken + } + return config.token +} + +private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) -> String? { + if let password = opts.password, !password.isEmpty { return password } + if (config.mode ?? "local").lowercased() == "remote" { + return config.remotePassword + } + return config.password +} + +actor GatewayWizardClient { + private enum ConnectChallengeError: Error { + case timeout + } + + private let url: URL + private let token: String? + private let password: String? + private let json: Bool + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private let session = URLSession(configuration: .default) + private let connectChallengeTimeoutSeconds: Double = 0.75 + private var task: URLSessionWebSocketTask? + + init(url: URL, token: String?, password: String?, json: Bool) { + self.url = url + self.token = token + self.password = password + self.json = json + } + + func connect() async throws { + let socket = self.session.webSocketTask(with: self.url) + socket.maximumMessageSize = 16 * 1024 * 1024 + socket.resume() + self.task = socket + try await self.sendConnect() + } + + func close() { + self.task?.cancel(with: .goingAway, reason: nil) + self.task = nil + } + + func request(method: String, params: [String: ProtoAnyCodable]?) async throws -> ResponseFrame { + guard let task = self.task else { + throw WizardCliError.gatewayError("gateway not connected") + } + let id = UUID().uuidString + let frame = RequestFrame( + type: "req", + id: id, + method: method, + params: params.map { ProtoAnyCodable($0) }) + let data = try self.encoder.encode(frame) + try await task.send(.data(data)) + + while true { + let message = try await task.receive() + let frame = try decodeFrame(message) + if case let .res(res) = frame, res.id == id { + if res.ok == false { + let msg = (res.error?["message"]?.value as? String) ?? "gateway error" + throw WizardCliError.gatewayError(msg) + } + return res + } + } + } + + func decodePayload(_ response: ResponseFrame, as _: T.Type) throws -> T { + guard let payload = response.payload else { + throw WizardCliError.decodeError("missing payload") + } + let data = try self.encoder.encode(payload) + return try self.decoder.decode(T.self, from: data) + } + + private func decodeFrame(_ message: URLSessionWebSocketTask.Message) throws -> GatewayFrame { + let data: Data? = switch message { + case let .data(data): data + case let .string(text): text.data(using: .utf8) + @unknown default: nil + } + guard let data else { + throw WizardCliError.decodeError("empty gateway response") + } + return try self.decoder.decode(GatewayFrame.self, from: data) + } + + private func sendConnect() async throws { + guard let task = self.task else { + throw WizardCliError.gatewayError("gateway not connected") + } + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" + let clientId = "openclaw-macos" + let clientMode = "ui" + let role = "operator" + // Explicit scopes; gateway no longer defaults empty scopes to admin. + let scopes = defaultOperatorConnectScopes + let client: [String: ProtoAnyCodable] = [ + "id": ProtoAnyCodable(clientId), + "displayName": ProtoAnyCodable(Host.current().localizedName ?? "OpenClaw macOS Wizard CLI"), + "version": ProtoAnyCodable("dev"), + "platform": ProtoAnyCodable(platform), + "deviceFamily": ProtoAnyCodable("Mac"), + "mode": ProtoAnyCodable(clientMode), + "instanceId": ProtoAnyCodable(UUID().uuidString), + ] + + var params: [String: ProtoAnyCodable] = [ + "minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), + "maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), + "client": ProtoAnyCodable(client), + "caps": ProtoAnyCodable([String]()), + "locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier), + "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), + "role": ProtoAnyCodable(role), + "scopes": ProtoAnyCodable(scopes), + ] + if let token = self.token { + params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)]) + } else if let password = self.password { + params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) + } + let connectNonce = try await self.waitForConnectChallenge() + let identity = DeviceIdentityStore.loadOrCreate() + let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) + let scopesValue = scopes.joined(separator: ",") + let payloadParts = [ + "v2", + identity.deviceId, + clientId, + clientMode, + role, + scopesValue, + String(signedAtMs), + self.token ?? "", + connectNonce, + ] + let payload = payloadParts.joined(separator: "|") + if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), + let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) + { + let device: [String: ProtoAnyCodable] = [ + "id": ProtoAnyCodable(identity.deviceId), + "publicKey": ProtoAnyCodable(publicKey), + "signature": ProtoAnyCodable(signature), + "signedAt": ProtoAnyCodable(signedAtMs), + "nonce": ProtoAnyCodable(connectNonce), + ] + params["device"] = ProtoAnyCodable(device) + } + + let reqId = UUID().uuidString + let frame = RequestFrame( + type: "req", + id: reqId, + method: "connect", + params: ProtoAnyCodable(params)) + let data = try self.encoder.encode(frame) + try await task.send(.data(data)) + + while true { + let message = try await task.receive() + let frameResponse = try decodeFrame(message) + if case let .res(res) = frameResponse, res.id == reqId { + if res.ok == false { + let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed" + throw WizardCliError.gatewayError(msg) + } + _ = try self.decodePayload(res, as: HelloOk.self) + return + } + } + } + + private func waitForConnectChallenge() async throws -> String { + guard let task = self.task else { throw ConnectChallengeError.timeout } + return try await AsyncTimeout.withTimeout( + seconds: self.connectChallengeTimeoutSeconds, + onTimeout: { ConnectChallengeError.timeout }, + operation: { + while true { + let message = try await task.receive() + let frame = try await self.decodeFrame(message) + if case let .event(evt) = frame, evt.event == "connect.challenge", + let payload = evt.payload?.value as? [String: ProtoAnyCodable], + let nonce = payload["nonce"]?.value as? String, + nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + { + return nonce + } + } + }) + } +} + +private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws { + var params: [String: ProtoAnyCodable] = [:] + let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if mode == "local" || mode == "remote" { + params["mode"] = ProtoAnyCodable(mode) + } + if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty { + params["workspace"] = ProtoAnyCodable(workspace) + } + + let startResponse = try await client.request(method: "wizard.start", params: params) + let startResult = try await client.decodePayload(startResponse, as: WizardStartResult.self) + if opts.json { + dumpResult(startResponse) + } + + let sessionId = startResult.sessionid + var nextResult = WizardNextResult( + done: startResult.done, + step: startResult.step, + status: startResult.status, + error: startResult.error) + + do { + while true { + let status = wizardStatusString(nextResult.status) ?? (nextResult.done ? "done" : "running") + if status == "cancelled" { + print("Wizard cancelled.") + return + } + if status == "error" || (nextResult.done && nextResult.error != nil) { + throw WizardCliError.gatewayError(nextResult.error ?? "wizard error") + } + if status == "done" || nextResult.done { + print("Wizard complete.") + return + } + + if let step = decodeWizardStep(nextResult.step) { + let answer = try promptAnswer(for: step) + var answerPayload: [String: ProtoAnyCodable] = [ + "stepId": ProtoAnyCodable(step.id), + ] + if !(answer is NSNull) { + answerPayload["value"] = ProtoAnyCodable(answer) + } + let response = try await client.request( + method: "wizard.next", + params: [ + "sessionId": ProtoAnyCodable(sessionId), + "answer": ProtoAnyCodable(answerPayload), + ]) + nextResult = try await client.decodePayload(response, as: WizardNextResult.self) + if opts.json { + dumpResult(response) + } + } else { + let response = try await client.request( + method: "wizard.next", + params: ["sessionId": ProtoAnyCodable(sessionId)]) + nextResult = try await client.decodePayload(response, as: WizardNextResult.self) + if opts.json { + dumpResult(response) + } + } + } + } catch WizardCliError.cancelled { + _ = try? await client.request( + method: "wizard.cancel", + params: ["sessionId": ProtoAnyCodable(sessionId)]) + throw WizardCliError.cancelled + } +} + +private func dumpResult(_ response: ResponseFrame) { + guard let payload = response.payload else { + print("{\"error\":\"missing payload\"}") + return + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(payload), let text = String(data: data, encoding: .utf8) { + print(text) + } +} + +private func promptAnswer(for step: WizardStep) throws -> Any { + let type = wizardStepType(step) + if let title = step.title, !title.isEmpty { + print("\n\(title)") + } + if let message = step.message, !message.isEmpty { + print(message) + } + + switch type { + case "note": + _ = try readLineWithPrompt("Continue? (enter)") + return NSNull() + case "progress": + _ = try readLineWithPrompt("Continue? (enter)") + return NSNull() + case "action": + _ = try readLineWithPrompt("Run? (enter)") + return true + case "text": + let initial = anyCodableString(step.initialvalue) + let prompt = step.placeholder ?? "Value" + let value = try readLineWithPrompt("\(prompt)\(initial.isEmpty ? "" : " [\(initial)]")") + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? initial : trimmed + case "confirm": + let initial = anyCodableBool(step.initialvalue) + let value = try readLineWithPrompt("Confirm? (y/n) [\(initial ? "y" : "n")]") + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.isEmpty { return initial } + return trimmed == "y" || trimmed == "yes" || trimmed == "true" + case "select": + return try promptSelect(step) + case "multiselect": + return try promptMultiSelect(step) + default: + _ = try readLineWithPrompt("Continue? (enter)") + return NSNull() + } +} + +private func promptSelect(_ step: WizardStep) throws -> Any { + let options = parseWizardOptions(step.options) + guard !options.isEmpty else { return NSNull() } + for (idx, option) in options.enumerated() { + let hint = option.hint?.isEmpty == false ? " — \(option.hint!)" : "" + print(" [\(idx + 1)] \(option.label)\(hint)") + } + let initialIndex = options.firstIndex(where: { anyCodableEqual($0.value, step.initialvalue) }) + let defaultLabel = initialIndex.map { " [\($0 + 1)]" } ?? "" + while true { + let input = try readLineWithPrompt("Select one\(defaultLabel)") + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty, let initialIndex { + return options[initialIndex].value?.value ?? options[initialIndex].label + } + if trimmed.lowercased() == "q" { throw WizardCliError.cancelled } + if let number = Int(trimmed), (1...options.count).contains(number) { + let option = options[number - 1] + return option.value?.value ?? option.label + } + print("Invalid selection.") + } +} + +private func promptMultiSelect(_ step: WizardStep) throws -> [Any] { + let options = parseWizardOptions(step.options) + guard !options.isEmpty else { return [] } + for (idx, option) in options.enumerated() { + let hint = option.hint?.isEmpty == false ? " — \(option.hint!)" : "" + print(" [\(idx + 1)] \(option.label)\(hint)") + } + let initialValues = anyCodableArray(step.initialvalue) + let initialIndices = options.enumerated().compactMap { index, option in + initialValues.contains { anyCodableEqual($0, option.value) } ? index + 1 : nil + } + let defaultLabel = initialIndices.isEmpty ? "" : " [\(initialIndices.map(String.init).joined(separator: ","))]" + while true { + let input = try readLineWithPrompt("Select (comma-separated)\(defaultLabel)") + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return initialIndices.map { options[$0 - 1].value?.value ?? options[$0 - 1].label } + } + if trimmed.lowercased() == "q" { throw WizardCliError.cancelled } + let parts = trimmed.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + let indices = parts.compactMap { Int($0) }.filter { (1...options.count).contains($0) } + if indices.isEmpty { + print("Invalid selection.") + continue + } + return indices.map { options[$0 - 1].value?.value ?? options[$0 - 1].label } + } +} + +private func readLineWithPrompt(_ prompt: String) throws -> String { + print("\(prompt): ", terminator: "") + guard let line = readLine() else { + throw WizardCliError.cancelled + } + return line +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift new file mode 100644 index 00000000..4e766514 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -0,0 +1,3293 @@ +// Generated by scripts/protocol-gen-swift.ts — do not edit by hand +// swiftlint:disable file_length +import Foundation + +public let GATEWAY_PROTOCOL_VERSION = 3 + +public enum ErrorCode: String, Codable, Sendable { + case notLinked = "NOT_LINKED" + case notPaired = "NOT_PAIRED" + case agentTimeout = "AGENT_TIMEOUT" + case invalidRequest = "INVALID_REQUEST" + case unavailable = "UNAVAILABLE" +} + +public struct ConnectParams: Codable, Sendable { + public let minprotocol: Int + public let maxprotocol: Int + public let client: [String: AnyCodable] + public let caps: [String]? + public let commands: [String]? + public let permissions: [String: AnyCodable]? + public let pathenv: String? + public let role: String? + public let scopes: [String]? + public let device: [String: AnyCodable]? + public let auth: [String: AnyCodable]? + public let locale: String? + public let useragent: String? + + public init( + minprotocol: Int, + maxprotocol: Int, + client: [String: AnyCodable], + caps: [String]?, + commands: [String]?, + permissions: [String: AnyCodable]?, + pathenv: String?, + role: String?, + scopes: [String]?, + device: [String: AnyCodable]?, + auth: [String: AnyCodable]?, + locale: String?, + useragent: String?) + { + self.minprotocol = minprotocol + self.maxprotocol = maxprotocol + self.client = client + self.caps = caps + self.commands = commands + self.permissions = permissions + self.pathenv = pathenv + self.role = role + self.scopes = scopes + self.device = device + self.auth = auth + self.locale = locale + self.useragent = useragent + } + + private enum CodingKeys: String, CodingKey { + case minprotocol = "minProtocol" + case maxprotocol = "maxProtocol" + case client + case caps + case commands + case permissions + case pathenv = "pathEnv" + case role + case scopes + case device + case auth + case locale + case useragent = "userAgent" + } +} + +public struct HelloOk: Codable, Sendable { + public let type: String + public let _protocol: Int + public let server: [String: AnyCodable] + public let features: [String: AnyCodable] + public let snapshot: Snapshot + public let canvashosturl: String? + public let auth: [String: AnyCodable]? + public let policy: [String: AnyCodable] + + public init( + type: String, + _protocol: Int, + server: [String: AnyCodable], + features: [String: AnyCodable], + snapshot: Snapshot, + canvashosturl: String?, + auth: [String: AnyCodable]?, + policy: [String: AnyCodable]) + { + self.type = type + self._protocol = _protocol + self.server = server + self.features = features + self.snapshot = snapshot + self.canvashosturl = canvashosturl + self.auth = auth + self.policy = policy + } + + private enum CodingKeys: String, CodingKey { + case type + case _protocol = "protocol" + case server + case features + case snapshot + case canvashosturl = "canvasHostUrl" + case auth + case policy + } +} + +public struct RequestFrame: Codable, Sendable { + public let type: String + public let id: String + public let method: String + public let params: AnyCodable? + + public init( + type: String, + id: String, + method: String, + params: AnyCodable?) + { + self.type = type + self.id = id + self.method = method + self.params = params + } + + private enum CodingKeys: String, CodingKey { + case type + case id + case method + case params + } +} + +public struct ResponseFrame: Codable, Sendable { + public let type: String + public let id: String + public let ok: Bool + public let payload: AnyCodable? + public let error: [String: AnyCodable]? + + public init( + type: String, + id: String, + ok: Bool, + payload: AnyCodable?, + error: [String: AnyCodable]?) + { + self.type = type + self.id = id + self.ok = ok + self.payload = payload + self.error = error + } + + private enum CodingKeys: String, CodingKey { + case type + case id + case ok + case payload + case error + } +} + +public struct EventFrame: Codable, Sendable { + public let type: String + public let event: String + public let payload: AnyCodable? + public let seq: Int? + public let stateversion: [String: AnyCodable]? + + public init( + type: String, + event: String, + payload: AnyCodable?, + seq: Int?, + stateversion: [String: AnyCodable]?) + { + self.type = type + self.event = event + self.payload = payload + self.seq = seq + self.stateversion = stateversion + } + + private enum CodingKeys: String, CodingKey { + case type + case event + case payload + case seq + case stateversion = "stateVersion" + } +} + +public struct PresenceEntry: Codable, Sendable { + public let host: String? + public let ip: String? + public let version: String? + public let platform: String? + public let devicefamily: String? + public let modelidentifier: String? + public let mode: String? + public let lastinputseconds: Int? + public let reason: String? + public let tags: [String]? + public let text: String? + public let ts: Int + public let deviceid: String? + public let roles: [String]? + public let scopes: [String]? + public let instanceid: String? + + public init( + host: String?, + ip: String?, + version: String?, + platform: String?, + devicefamily: String?, + modelidentifier: String?, + mode: String?, + lastinputseconds: Int?, + reason: String?, + tags: [String]?, + text: String?, + ts: Int, + deviceid: String?, + roles: [String]?, + scopes: [String]?, + instanceid: String?) + { + self.host = host + self.ip = ip + self.version = version + self.platform = platform + self.devicefamily = devicefamily + self.modelidentifier = modelidentifier + self.mode = mode + self.lastinputseconds = lastinputseconds + self.reason = reason + self.tags = tags + self.text = text + self.ts = ts + self.deviceid = deviceid + self.roles = roles + self.scopes = scopes + self.instanceid = instanceid + } + + private enum CodingKeys: String, CodingKey { + case host + case ip + case version + case platform + case devicefamily = "deviceFamily" + case modelidentifier = "modelIdentifier" + case mode + case lastinputseconds = "lastInputSeconds" + case reason + case tags + case text + case ts + case deviceid = "deviceId" + case roles + case scopes + case instanceid = "instanceId" + } +} + +public struct StateVersion: Codable, Sendable { + public let presence: Int + public let health: Int + + public init( + presence: Int, + health: Int) + { + self.presence = presence + self.health = health + } + + private enum CodingKeys: String, CodingKey { + case presence + case health + } +} + +public struct Snapshot: Codable, Sendable { + public let presence: [PresenceEntry] + public let health: AnyCodable + public let stateversion: StateVersion + public let uptimems: Int + public let configpath: String? + public let statedir: String? + public let sessiondefaults: [String: AnyCodable]? + public let authmode: AnyCodable? + public let updateavailable: [String: AnyCodable]? + + public init( + presence: [PresenceEntry], + health: AnyCodable, + stateversion: StateVersion, + uptimems: Int, + configpath: String?, + statedir: String?, + sessiondefaults: [String: AnyCodable]?, + authmode: AnyCodable?, + updateavailable: [String: AnyCodable]?) + { + self.presence = presence + self.health = health + self.stateversion = stateversion + self.uptimems = uptimems + self.configpath = configpath + self.statedir = statedir + self.sessiondefaults = sessiondefaults + self.authmode = authmode + self.updateavailable = updateavailable + } + + private enum CodingKeys: String, CodingKey { + case presence + case health + case stateversion = "stateVersion" + case uptimems = "uptimeMs" + case configpath = "configPath" + case statedir = "stateDir" + case sessiondefaults = "sessionDefaults" + case authmode = "authMode" + case updateavailable = "updateAvailable" + } +} + +public struct ErrorShape: Codable, Sendable { + public let code: String + public let message: String + public let details: AnyCodable? + public let retryable: Bool? + public let retryafterms: Int? + + public init( + code: String, + message: String, + details: AnyCodable?, + retryable: Bool?, + retryafterms: Int?) + { + self.code = code + self.message = message + self.details = details + self.retryable = retryable + self.retryafterms = retryafterms + } + + private enum CodingKeys: String, CodingKey { + case code + case message + case details + case retryable + case retryafterms = "retryAfterMs" + } +} + +public struct AgentEvent: Codable, Sendable { + public let runid: String + public let seq: Int + public let stream: String + public let ts: Int + public let data: [String: AnyCodable] + + public init( + runid: String, + seq: Int, + stream: String, + ts: Int, + data: [String: AnyCodable]) + { + self.runid = runid + self.seq = seq + self.stream = stream + self.ts = ts + self.data = data + } + + private enum CodingKeys: String, CodingKey { + case runid = "runId" + case seq + case stream + case ts + case data + } +} + +public struct SendParams: Codable, Sendable { + public let to: String + public let message: String? + public let mediaurl: String? + public let mediaurls: [String]? + public let gifplayback: Bool? + public let channel: String? + public let accountid: String? + public let threadid: String? + public let sessionkey: String? + public let idempotencykey: String + + public init( + to: String, + message: String?, + mediaurl: String?, + mediaurls: [String]?, + gifplayback: Bool?, + channel: String?, + accountid: String?, + threadid: String?, + sessionkey: String?, + idempotencykey: String) + { + self.to = to + self.message = message + self.mediaurl = mediaurl + self.mediaurls = mediaurls + self.gifplayback = gifplayback + self.channel = channel + self.accountid = accountid + self.threadid = threadid + self.sessionkey = sessionkey + self.idempotencykey = idempotencykey + } + + private enum CodingKeys: String, CodingKey { + case to + case message + case mediaurl = "mediaUrl" + case mediaurls = "mediaUrls" + case gifplayback = "gifPlayback" + case channel + case accountid = "accountId" + case threadid = "threadId" + case sessionkey = "sessionKey" + case idempotencykey = "idempotencyKey" + } +} + +public struct PollParams: Codable, Sendable { + public let to: String + public let question: String + public let options: [String] + public let maxselections: Int? + public let durationseconds: Int? + public let durationhours: Int? + public let silent: Bool? + public let isanonymous: Bool? + public let threadid: String? + public let channel: String? + public let accountid: String? + public let idempotencykey: String + + public init( + to: String, + question: String, + options: [String], + maxselections: Int?, + durationseconds: Int?, + durationhours: Int?, + silent: Bool?, + isanonymous: Bool?, + threadid: String?, + channel: String?, + accountid: String?, + idempotencykey: String) + { + self.to = to + self.question = question + self.options = options + self.maxselections = maxselections + self.durationseconds = durationseconds + self.durationhours = durationhours + self.silent = silent + self.isanonymous = isanonymous + self.threadid = threadid + self.channel = channel + self.accountid = accountid + self.idempotencykey = idempotencykey + } + + private enum CodingKeys: String, CodingKey { + case to + case question + case options + case maxselections = "maxSelections" + case durationseconds = "durationSeconds" + case durationhours = "durationHours" + case silent + case isanonymous = "isAnonymous" + case threadid = "threadId" + case channel + case accountid = "accountId" + case idempotencykey = "idempotencyKey" + } +} + +public struct AgentParams: Codable, Sendable { + public let message: String + public let agentid: String? + public let to: String? + public let replyto: String? + public let sessionid: String? + public let sessionkey: String? + public let thinking: String? + public let deliver: Bool? + public let attachments: [AnyCodable]? + public let channel: String? + public let replychannel: String? + public let accountid: String? + public let replyaccountid: String? + public let threadid: String? + public let groupid: String? + public let groupchannel: String? + public let groupspace: String? + public let timeout: Int? + public let besteffortdeliver: Bool? + public let lane: String? + public let extrasystemprompt: String? + public let inputprovenance: [String: AnyCodable]? + public let idempotencykey: String + public let label: String? + public let spawnedby: String? + + public init( + message: String, + agentid: String?, + to: String?, + replyto: String?, + sessionid: String?, + sessionkey: String?, + thinking: String?, + deliver: Bool?, + attachments: [AnyCodable]?, + channel: String?, + replychannel: String?, + accountid: String?, + replyaccountid: String?, + threadid: String?, + groupid: String?, + groupchannel: String?, + groupspace: String?, + timeout: Int?, + besteffortdeliver: Bool?, + lane: String?, + extrasystemprompt: String?, + inputprovenance: [String: AnyCodable]?, + idempotencykey: String, + label: String?, + spawnedby: String?) + { + self.message = message + self.agentid = agentid + self.to = to + self.replyto = replyto + self.sessionid = sessionid + self.sessionkey = sessionkey + self.thinking = thinking + self.deliver = deliver + self.attachments = attachments + self.channel = channel + self.replychannel = replychannel + self.accountid = accountid + self.replyaccountid = replyaccountid + self.threadid = threadid + self.groupid = groupid + self.groupchannel = groupchannel + self.groupspace = groupspace + self.timeout = timeout + self.besteffortdeliver = besteffortdeliver + self.lane = lane + self.extrasystemprompt = extrasystemprompt + self.inputprovenance = inputprovenance + self.idempotencykey = idempotencykey + self.label = label + self.spawnedby = spawnedby + } + + private enum CodingKeys: String, CodingKey { + case message + case agentid = "agentId" + case to + case replyto = "replyTo" + case sessionid = "sessionId" + case sessionkey = "sessionKey" + case thinking + case deliver + case attachments + case channel + case replychannel = "replyChannel" + case accountid = "accountId" + case replyaccountid = "replyAccountId" + case threadid = "threadId" + case groupid = "groupId" + case groupchannel = "groupChannel" + case groupspace = "groupSpace" + case timeout + case besteffortdeliver = "bestEffortDeliver" + case lane + case extrasystemprompt = "extraSystemPrompt" + case inputprovenance = "inputProvenance" + case idempotencykey = "idempotencyKey" + case label + case spawnedby = "spawnedBy" + } +} + +public struct AgentIdentityParams: Codable, Sendable { + public let agentid: String? + public let sessionkey: String? + + public init( + agentid: String?, + sessionkey: String?) + { + self.agentid = agentid + self.sessionkey = sessionkey + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case sessionkey = "sessionKey" + } +} + +public struct AgentIdentityResult: Codable, Sendable { + public let agentid: String + public let name: String? + public let avatar: String? + public let emoji: String? + + public init( + agentid: String, + name: String?, + avatar: String?, + emoji: String?) + { + self.agentid = agentid + self.name = name + self.avatar = avatar + self.emoji = emoji + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case name + case avatar + case emoji + } +} + +public struct AgentWaitParams: Codable, Sendable { + public let runid: String + public let timeoutms: Int? + + public init( + runid: String, + timeoutms: Int?) + { + self.runid = runid + self.timeoutms = timeoutms + } + + private enum CodingKeys: String, CodingKey { + case runid = "runId" + case timeoutms = "timeoutMs" + } +} + +public struct WakeParams: Codable, Sendable { + public let mode: AnyCodable + public let text: String + + public init( + mode: AnyCodable, + text: String) + { + self.mode = mode + self.text = text + } + + private enum CodingKeys: String, CodingKey { + case mode + case text + } +} + +public struct NodePairRequestParams: Codable, Sendable { + public let nodeid: String + public let displayname: String? + public let platform: String? + public let version: String? + public let coreversion: String? + public let uiversion: String? + public let devicefamily: String? + public let modelidentifier: String? + public let caps: [String]? + public let commands: [String]? + public let remoteip: String? + public let silent: Bool? + + public init( + nodeid: String, + displayname: String?, + platform: String?, + version: String?, + coreversion: String?, + uiversion: String?, + devicefamily: String?, + modelidentifier: String?, + caps: [String]?, + commands: [String]?, + remoteip: String?, + silent: Bool?) + { + self.nodeid = nodeid + self.displayname = displayname + self.platform = platform + self.version = version + self.coreversion = coreversion + self.uiversion = uiversion + self.devicefamily = devicefamily + self.modelidentifier = modelidentifier + self.caps = caps + self.commands = commands + self.remoteip = remoteip + self.silent = silent + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case displayname = "displayName" + case platform + case version + case coreversion = "coreVersion" + case uiversion = "uiVersion" + case devicefamily = "deviceFamily" + case modelidentifier = "modelIdentifier" + case caps + case commands + case remoteip = "remoteIp" + case silent + } +} + +public struct NodePairListParams: Codable, Sendable {} + +public struct NodePairApproveParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String) + { + self.requestid = requestid + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct NodePairRejectParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String) + { + self.requestid = requestid + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct NodePairVerifyParams: Codable, Sendable { + public let nodeid: String + public let token: String + + public init( + nodeid: String, + token: String) + { + self.nodeid = nodeid + self.token = token + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case token + } +} + +public struct NodeRenameParams: Codable, Sendable { + public let nodeid: String + public let displayname: String + + public init( + nodeid: String, + displayname: String) + { + self.nodeid = nodeid + self.displayname = displayname + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case displayname = "displayName" + } +} + +public struct NodeListParams: Codable, Sendable {} + +public struct NodeDescribeParams: Codable, Sendable { + public let nodeid: String + + public init( + nodeid: String) + { + self.nodeid = nodeid + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + } +} + +public struct NodeInvokeParams: Codable, Sendable { + public let nodeid: String + public let command: String + public let params: AnyCodable? + public let timeoutms: Int? + public let idempotencykey: String + + public init( + nodeid: String, + command: String, + params: AnyCodable?, + timeoutms: Int?, + idempotencykey: String) + { + self.nodeid = nodeid + self.command = command + self.params = params + self.timeoutms = timeoutms + self.idempotencykey = idempotencykey + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case command + case params + case timeoutms = "timeoutMs" + case idempotencykey = "idempotencyKey" + } +} + +public struct NodeInvokeResultParams: Codable, Sendable { + public let id: String + public let nodeid: String + public let ok: Bool + public let payload: AnyCodable? + public let payloadjson: String? + public let error: [String: AnyCodable]? + + public init( + id: String, + nodeid: String, + ok: Bool, + payload: AnyCodable?, + payloadjson: String?, + error: [String: AnyCodable]?) + { + self.id = id + self.nodeid = nodeid + self.ok = ok + self.payload = payload + self.payloadjson = payloadjson + self.error = error + } + + private enum CodingKeys: String, CodingKey { + case id + case nodeid = "nodeId" + case ok + case payload + case payloadjson = "payloadJSON" + case error + } +} + +public struct NodeEventParams: Codable, Sendable { + public let event: String + public let payload: AnyCodable? + public let payloadjson: String? + + public init( + event: String, + payload: AnyCodable?, + payloadjson: String?) + { + self.event = event + self.payload = payload + self.payloadjson = payloadjson + } + + private enum CodingKeys: String, CodingKey { + case event + case payload + case payloadjson = "payloadJSON" + } +} + +public struct NodeInvokeRequestEvent: Codable, Sendable { + public let id: String + public let nodeid: String + public let command: String + public let paramsjson: String? + public let timeoutms: Int? + public let idempotencykey: String? + + public init( + id: String, + nodeid: String, + command: String, + paramsjson: String?, + timeoutms: Int?, + idempotencykey: String?) + { + self.id = id + self.nodeid = nodeid + self.command = command + self.paramsjson = paramsjson + self.timeoutms = timeoutms + self.idempotencykey = idempotencykey + } + + private enum CodingKeys: String, CodingKey { + case id + case nodeid = "nodeId" + case command + case paramsjson = "paramsJSON" + case timeoutms = "timeoutMs" + case idempotencykey = "idempotencyKey" + } +} + +public struct PushTestParams: Codable, Sendable { + public let nodeid: String + public let title: String? + public let body: String? + public let environment: String? + + public init( + nodeid: String, + title: String?, + body: String?, + environment: String?) + { + self.nodeid = nodeid + self.title = title + self.body = body + self.environment = environment + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case title + case body + case environment + } +} + +public struct PushTestResult: Codable, Sendable { + public let ok: Bool + public let status: Int + public let apnsid: String? + public let reason: String? + public let tokensuffix: String + public let topic: String + public let environment: String + + public init( + ok: Bool, + status: Int, + apnsid: String?, + reason: String?, + tokensuffix: String, + topic: String, + environment: String) + { + self.ok = ok + self.status = status + self.apnsid = apnsid + self.reason = reason + self.tokensuffix = tokensuffix + self.topic = topic + self.environment = environment + } + + private enum CodingKeys: String, CodingKey { + case ok + case status + case apnsid = "apnsId" + case reason + case tokensuffix = "tokenSuffix" + case topic + case environment + } +} + +public struct SessionsListParams: Codable, Sendable { + public let limit: Int? + public let activeminutes: Int? + public let includeglobal: Bool? + public let includeunknown: Bool? + public let includederivedtitles: Bool? + public let includelastmessage: Bool? + public let label: String? + public let spawnedby: String? + public let agentid: String? + public let search: String? + + public init( + limit: Int?, + activeminutes: Int?, + includeglobal: Bool?, + includeunknown: Bool?, + includederivedtitles: Bool?, + includelastmessage: Bool?, + label: String?, + spawnedby: String?, + agentid: String?, + search: String?) + { + self.limit = limit + self.activeminutes = activeminutes + self.includeglobal = includeglobal + self.includeunknown = includeunknown + self.includederivedtitles = includederivedtitles + self.includelastmessage = includelastmessage + self.label = label + self.spawnedby = spawnedby + self.agentid = agentid + self.search = search + } + + private enum CodingKeys: String, CodingKey { + case limit + case activeminutes = "activeMinutes" + case includeglobal = "includeGlobal" + case includeunknown = "includeUnknown" + case includederivedtitles = "includeDerivedTitles" + case includelastmessage = "includeLastMessage" + case label + case spawnedby = "spawnedBy" + case agentid = "agentId" + case search + } +} + +public struct SessionsPreviewParams: Codable, Sendable { + public let keys: [String] + public let limit: Int? + public let maxchars: Int? + + public init( + keys: [String], + limit: Int?, + maxchars: Int?) + { + self.keys = keys + self.limit = limit + self.maxchars = maxchars + } + + private enum CodingKeys: String, CodingKey { + case keys + case limit + case maxchars = "maxChars" + } +} + +public struct SessionsResolveParams: Codable, Sendable { + public let key: String? + public let sessionid: String? + public let label: String? + public let agentid: String? + public let spawnedby: String? + public let includeglobal: Bool? + public let includeunknown: Bool? + + public init( + key: String?, + sessionid: String?, + label: String?, + agentid: String?, + spawnedby: String?, + includeglobal: Bool?, + includeunknown: Bool?) + { + self.key = key + self.sessionid = sessionid + self.label = label + self.agentid = agentid + self.spawnedby = spawnedby + self.includeglobal = includeglobal + self.includeunknown = includeunknown + } + + private enum CodingKeys: String, CodingKey { + case key + case sessionid = "sessionId" + case label + case agentid = "agentId" + case spawnedby = "spawnedBy" + case includeglobal = "includeGlobal" + case includeunknown = "includeUnknown" + } +} + +public struct SessionsPatchParams: Codable, Sendable { + public let key: String + public let label: AnyCodable? + public let thinkinglevel: AnyCodable? + public let verboselevel: AnyCodable? + public let reasoninglevel: AnyCodable? + public let responseusage: AnyCodable? + public let elevatedlevel: AnyCodable? + public let exechost: AnyCodable? + public let execsecurity: AnyCodable? + public let execask: AnyCodable? + public let execnode: AnyCodable? + public let model: AnyCodable? + public let spawnedby: AnyCodable? + public let spawndepth: AnyCodable? + public let sendpolicy: AnyCodable? + public let groupactivation: AnyCodable? + + public init( + key: String, + label: AnyCodable?, + thinkinglevel: AnyCodable?, + verboselevel: AnyCodable?, + reasoninglevel: AnyCodable?, + responseusage: AnyCodable?, + elevatedlevel: AnyCodable?, + exechost: AnyCodable?, + execsecurity: AnyCodable?, + execask: AnyCodable?, + execnode: AnyCodable?, + model: AnyCodable?, + spawnedby: AnyCodable?, + spawndepth: AnyCodable?, + sendpolicy: AnyCodable?, + groupactivation: AnyCodable?) + { + self.key = key + self.label = label + self.thinkinglevel = thinkinglevel + self.verboselevel = verboselevel + self.reasoninglevel = reasoninglevel + self.responseusage = responseusage + self.elevatedlevel = elevatedlevel + self.exechost = exechost + self.execsecurity = execsecurity + self.execask = execask + self.execnode = execnode + self.model = model + self.spawnedby = spawnedby + self.spawndepth = spawndepth + self.sendpolicy = sendpolicy + self.groupactivation = groupactivation + } + + private enum CodingKeys: String, CodingKey { + case key + case label + case thinkinglevel = "thinkingLevel" + case verboselevel = "verboseLevel" + case reasoninglevel = "reasoningLevel" + case responseusage = "responseUsage" + case elevatedlevel = "elevatedLevel" + case exechost = "execHost" + case execsecurity = "execSecurity" + case execask = "execAsk" + case execnode = "execNode" + case model + case spawnedby = "spawnedBy" + case spawndepth = "spawnDepth" + case sendpolicy = "sendPolicy" + case groupactivation = "groupActivation" + } +} + +public struct SessionsResetParams: Codable, Sendable { + public let key: String + public let reason: AnyCodable? + + public init( + key: String, + reason: AnyCodable?) + { + self.key = key + self.reason = reason + } + + private enum CodingKeys: String, CodingKey { + case key + case reason + } +} + +public struct SessionsDeleteParams: Codable, Sendable { + public let key: String + public let deletetranscript: Bool? + public let emitlifecyclehooks: Bool? + + public init( + key: String, + deletetranscript: Bool?, + emitlifecyclehooks: Bool?) + { + self.key = key + self.deletetranscript = deletetranscript + self.emitlifecyclehooks = emitlifecyclehooks + } + + private enum CodingKeys: String, CodingKey { + case key + case deletetranscript = "deleteTranscript" + case emitlifecyclehooks = "emitLifecycleHooks" + } +} + +public struct SessionsCompactParams: Codable, Sendable { + public let key: String + public let maxlines: Int? + + public init( + key: String, + maxlines: Int?) + { + self.key = key + self.maxlines = maxlines + } + + private enum CodingKeys: String, CodingKey { + case key + case maxlines = "maxLines" + } +} + +public struct SessionsUsageParams: Codable, Sendable { + public let key: String? + public let startdate: String? + public let enddate: String? + public let mode: AnyCodable? + public let utcoffset: String? + public let limit: Int? + public let includecontextweight: Bool? + + public init( + key: String?, + startdate: String?, + enddate: String?, + mode: AnyCodable?, + utcoffset: String?, + limit: Int?, + includecontextweight: Bool?) + { + self.key = key + self.startdate = startdate + self.enddate = enddate + self.mode = mode + self.utcoffset = utcoffset + self.limit = limit + self.includecontextweight = includecontextweight + } + + private enum CodingKeys: String, CodingKey { + case key + case startdate = "startDate" + case enddate = "endDate" + case mode + case utcoffset = "utcOffset" + case limit + case includecontextweight = "includeContextWeight" + } +} + +public struct ConfigGetParams: Codable, Sendable {} + +public struct ConfigSetParams: Codable, Sendable { + public let raw: String + public let basehash: String? + + public init( + raw: String, + basehash: String?) + { + self.raw = raw + self.basehash = basehash + } + + private enum CodingKeys: String, CodingKey { + case raw + case basehash = "baseHash" + } +} + +public struct ConfigApplyParams: Codable, Sendable { + public let raw: String + public let basehash: String? + public let sessionkey: String? + public let note: String? + public let restartdelayms: Int? + + public init( + raw: String, + basehash: String?, + sessionkey: String?, + note: String?, + restartdelayms: Int?) + { + self.raw = raw + self.basehash = basehash + self.sessionkey = sessionkey + self.note = note + self.restartdelayms = restartdelayms + } + + private enum CodingKeys: String, CodingKey { + case raw + case basehash = "baseHash" + case sessionkey = "sessionKey" + case note + case restartdelayms = "restartDelayMs" + } +} + +public struct ConfigPatchParams: Codable, Sendable { + public let raw: String + public let basehash: String? + public let sessionkey: String? + public let note: String? + public let restartdelayms: Int? + + public init( + raw: String, + basehash: String?, + sessionkey: String?, + note: String?, + restartdelayms: Int?) + { + self.raw = raw + self.basehash = basehash + self.sessionkey = sessionkey + self.note = note + self.restartdelayms = restartdelayms + } + + private enum CodingKeys: String, CodingKey { + case raw + case basehash = "baseHash" + case sessionkey = "sessionKey" + case note + case restartdelayms = "restartDelayMs" + } +} + +public struct ConfigSchemaParams: Codable, Sendable {} + +public struct ConfigSchemaResponse: Codable, Sendable { + public let schema: AnyCodable + public let uihints: [String: AnyCodable] + public let version: String + public let generatedat: String + + public init( + schema: AnyCodable, + uihints: [String: AnyCodable], + version: String, + generatedat: String) + { + self.schema = schema + self.uihints = uihints + self.version = version + self.generatedat = generatedat + } + + private enum CodingKeys: String, CodingKey { + case schema + case uihints = "uiHints" + case version + case generatedat = "generatedAt" + } +} + +public struct WizardStartParams: Codable, Sendable { + public let mode: AnyCodable? + public let workspace: String? + + public init( + mode: AnyCodable?, + workspace: String?) + { + self.mode = mode + self.workspace = workspace + } + + private enum CodingKeys: String, CodingKey { + case mode + case workspace + } +} + +public struct WizardNextParams: Codable, Sendable { + public let sessionid: String + public let answer: [String: AnyCodable]? + + public init( + sessionid: String, + answer: [String: AnyCodable]?) + { + self.sessionid = sessionid + self.answer = answer + } + + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + case answer + } +} + +public struct WizardCancelParams: Codable, Sendable { + public let sessionid: String + + public init( + sessionid: String) + { + self.sessionid = sessionid + } + + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + } +} + +public struct WizardStatusParams: Codable, Sendable { + public let sessionid: String + + public init( + sessionid: String) + { + self.sessionid = sessionid + } + + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + } +} + +public struct WizardStep: Codable, Sendable { + public let id: String + public let type: AnyCodable + public let title: String? + public let message: String? + public let options: [[String: AnyCodable]]? + public let initialvalue: AnyCodable? + public let placeholder: String? + public let sensitive: Bool? + public let executor: AnyCodable? + + public init( + id: String, + type: AnyCodable, + title: String?, + message: String?, + options: [[String: AnyCodable]]?, + initialvalue: AnyCodable?, + placeholder: String?, + sensitive: Bool?, + executor: AnyCodable?) + { + self.id = id + self.type = type + self.title = title + self.message = message + self.options = options + self.initialvalue = initialvalue + self.placeholder = placeholder + self.sensitive = sensitive + self.executor = executor + } + + private enum CodingKeys: String, CodingKey { + case id + case type + case title + case message + case options + case initialvalue = "initialValue" + case placeholder + case sensitive + case executor + } +} + +public struct WizardNextResult: Codable, Sendable { + public let done: Bool + public let step: [String: AnyCodable]? + public let status: AnyCodable? + public let error: String? + + public init( + done: Bool, + step: [String: AnyCodable]?, + status: AnyCodable?, + error: String?) + { + self.done = done + self.step = step + self.status = status + self.error = error + } + + private enum CodingKeys: String, CodingKey { + case done + case step + case status + case error + } +} + +public struct WizardStartResult: Codable, Sendable { + public let sessionid: String + public let done: Bool + public let step: [String: AnyCodable]? + public let status: AnyCodable? + public let error: String? + + public init( + sessionid: String, + done: Bool, + step: [String: AnyCodable]?, + status: AnyCodable?, + error: String?) + { + self.sessionid = sessionid + self.done = done + self.step = step + self.status = status + self.error = error + } + + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + case done + case step + case status + case error + } +} + +public struct WizardStatusResult: Codable, Sendable { + public let status: AnyCodable + public let error: String? + + public init( + status: AnyCodable, + error: String?) + { + self.status = status + self.error = error + } + + private enum CodingKeys: String, CodingKey { + case status + case error + } +} + +public struct TalkModeParams: Codable, Sendable { + public let enabled: Bool + public let phase: String? + + public init( + enabled: Bool, + phase: String?) + { + self.enabled = enabled + self.phase = phase + } + + private enum CodingKeys: String, CodingKey { + case enabled + case phase + } +} + +public struct TalkConfigParams: Codable, Sendable { + public let includesecrets: Bool? + + public init( + includesecrets: Bool?) + { + self.includesecrets = includesecrets + } + + private enum CodingKeys: String, CodingKey { + case includesecrets = "includeSecrets" + } +} + +public struct TalkConfigResult: Codable, Sendable { + public let config: [String: AnyCodable] + + public init( + config: [String: AnyCodable]) + { + self.config = config + } + + private enum CodingKeys: String, CodingKey { + case config + } +} + +public struct ChannelsStatusParams: Codable, Sendable { + public let probe: Bool? + public let timeoutms: Int? + + public init( + probe: Bool?, + timeoutms: Int?) + { + self.probe = probe + self.timeoutms = timeoutms + } + + private enum CodingKeys: String, CodingKey { + case probe + case timeoutms = "timeoutMs" + } +} + +public struct ChannelsStatusResult: Codable, Sendable { + public let ts: Int + public let channelorder: [String] + public let channellabels: [String: AnyCodable] + public let channeldetaillabels: [String: AnyCodable]? + public let channelsystemimages: [String: AnyCodable]? + public let channelmeta: [[String: AnyCodable]]? + public let channels: [String: AnyCodable] + public let channelaccounts: [String: AnyCodable] + public let channeldefaultaccountid: [String: AnyCodable] + + public init( + ts: Int, + channelorder: [String], + channellabels: [String: AnyCodable], + channeldetaillabels: [String: AnyCodable]?, + channelsystemimages: [String: AnyCodable]?, + channelmeta: [[String: AnyCodable]]?, + channels: [String: AnyCodable], + channelaccounts: [String: AnyCodable], + channeldefaultaccountid: [String: AnyCodable]) + { + self.ts = ts + self.channelorder = channelorder + self.channellabels = channellabels + self.channeldetaillabels = channeldetaillabels + self.channelsystemimages = channelsystemimages + self.channelmeta = channelmeta + self.channels = channels + self.channelaccounts = channelaccounts + self.channeldefaultaccountid = channeldefaultaccountid + } + + private enum CodingKeys: String, CodingKey { + case ts + case channelorder = "channelOrder" + case channellabels = "channelLabels" + case channeldetaillabels = "channelDetailLabels" + case channelsystemimages = "channelSystemImages" + case channelmeta = "channelMeta" + case channels + case channelaccounts = "channelAccounts" + case channeldefaultaccountid = "channelDefaultAccountId" + } +} + +public struct ChannelsLogoutParams: Codable, Sendable { + public let channel: String + public let accountid: String? + + public init( + channel: String, + accountid: String?) + { + self.channel = channel + self.accountid = accountid + } + + private enum CodingKeys: String, CodingKey { + case channel + case accountid = "accountId" + } +} + +public struct WebLoginStartParams: Codable, Sendable { + public let force: Bool? + public let timeoutms: Int? + public let verbose: Bool? + public let accountid: String? + + public init( + force: Bool?, + timeoutms: Int?, + verbose: Bool?, + accountid: String?) + { + self.force = force + self.timeoutms = timeoutms + self.verbose = verbose + self.accountid = accountid + } + + private enum CodingKeys: String, CodingKey { + case force + case timeoutms = "timeoutMs" + case verbose + case accountid = "accountId" + } +} + +public struct WebLoginWaitParams: Codable, Sendable { + public let timeoutms: Int? + public let accountid: String? + + public init( + timeoutms: Int?, + accountid: String?) + { + self.timeoutms = timeoutms + self.accountid = accountid + } + + private enum CodingKeys: String, CodingKey { + case timeoutms = "timeoutMs" + case accountid = "accountId" + } +} + +public struct AgentSummary: Codable, Sendable { + public let id: String + public let name: String? + public let identity: [String: AnyCodable]? + + public init( + id: String, + name: String?, + identity: [String: AnyCodable]?) + { + self.id = id + self.name = name + self.identity = identity + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case identity + } +} + +public struct AgentsCreateParams: Codable, Sendable { + public let name: String + public let workspace: String + public let emoji: String? + public let avatar: String? + + public init( + name: String, + workspace: String, + emoji: String?, + avatar: String?) + { + self.name = name + self.workspace = workspace + self.emoji = emoji + self.avatar = avatar + } + + private enum CodingKeys: String, CodingKey { + case name + case workspace + case emoji + case avatar + } +} + +public struct AgentsCreateResult: Codable, Sendable { + public let ok: Bool + public let agentid: String + public let name: String + public let workspace: String + + public init( + ok: Bool, + agentid: String, + name: String, + workspace: String) + { + self.ok = ok + self.agentid = agentid + self.name = name + self.workspace = workspace + } + + private enum CodingKeys: String, CodingKey { + case ok + case agentid = "agentId" + case name + case workspace + } +} + +public struct AgentsUpdateParams: Codable, Sendable { + public let agentid: String + public let name: String? + public let workspace: String? + public let model: String? + public let avatar: String? + + public init( + agentid: String, + name: String?, + workspace: String?, + model: String?, + avatar: String?) + { + self.agentid = agentid + self.name = name + self.workspace = workspace + self.model = model + self.avatar = avatar + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case name + case workspace + case model + case avatar + } +} + +public struct AgentsUpdateResult: Codable, Sendable { + public let ok: Bool + public let agentid: String + + public init( + ok: Bool, + agentid: String) + { + self.ok = ok + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case ok + case agentid = "agentId" + } +} + +public struct AgentsDeleteParams: Codable, Sendable { + public let agentid: String + public let deletefiles: Bool? + + public init( + agentid: String, + deletefiles: Bool?) + { + self.agentid = agentid + self.deletefiles = deletefiles + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case deletefiles = "deleteFiles" + } +} + +public struct AgentsDeleteResult: Codable, Sendable { + public let ok: Bool + public let agentid: String + public let removedbindings: Int + + public init( + ok: Bool, + agentid: String, + removedbindings: Int) + { + self.ok = ok + self.agentid = agentid + self.removedbindings = removedbindings + } + + private enum CodingKeys: String, CodingKey { + case ok + case agentid = "agentId" + case removedbindings = "removedBindings" + } +} + +public struct AgentsFileEntry: Codable, Sendable { + public let name: String + public let path: String + public let missing: Bool + public let size: Int? + public let updatedatms: Int? + public let content: String? + + public init( + name: String, + path: String, + missing: Bool, + size: Int?, + updatedatms: Int?, + content: String?) + { + self.name = name + self.path = path + self.missing = missing + self.size = size + self.updatedatms = updatedatms + self.content = content + } + + private enum CodingKeys: String, CodingKey { + case name + case path + case missing + case size + case updatedatms = "updatedAtMs" + case content + } +} + +public struct AgentsFilesListParams: Codable, Sendable { + public let agentid: String + + public init( + agentid: String) + { + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + } +} + +public struct AgentsFilesListResult: Codable, Sendable { + public let agentid: String + public let workspace: String + public let files: [AgentsFileEntry] + + public init( + agentid: String, + workspace: String, + files: [AgentsFileEntry]) + { + self.agentid = agentid + self.workspace = workspace + self.files = files + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case workspace + case files + } +} + +public struct AgentsFilesGetParams: Codable, Sendable { + public let agentid: String + public let name: String + + public init( + agentid: String, + name: String) + { + self.agentid = agentid + self.name = name + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case name + } +} + +public struct AgentsFilesGetResult: Codable, Sendable { + public let agentid: String + public let workspace: String + public let file: AgentsFileEntry + + public init( + agentid: String, + workspace: String, + file: AgentsFileEntry) + { + self.agentid = agentid + self.workspace = workspace + self.file = file + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case workspace + case file + } +} + +public struct AgentsFilesSetParams: Codable, Sendable { + public let agentid: String + public let name: String + public let content: String + + public init( + agentid: String, + name: String, + content: String) + { + self.agentid = agentid + self.name = name + self.content = content + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case name + case content + } +} + +public struct AgentsFilesSetResult: Codable, Sendable { + public let ok: Bool + public let agentid: String + public let workspace: String + public let file: AgentsFileEntry + + public init( + ok: Bool, + agentid: String, + workspace: String, + file: AgentsFileEntry) + { + self.ok = ok + self.agentid = agentid + self.workspace = workspace + self.file = file + } + + private enum CodingKeys: String, CodingKey { + case ok + case agentid = "agentId" + case workspace + case file + } +} + +public struct AgentsListParams: Codable, Sendable {} + +public struct AgentsListResult: Codable, Sendable { + public let defaultid: String + public let mainkey: String + public let scope: AnyCodable + public let agents: [AgentSummary] + + public init( + defaultid: String, + mainkey: String, + scope: AnyCodable, + agents: [AgentSummary]) + { + self.defaultid = defaultid + self.mainkey = mainkey + self.scope = scope + self.agents = agents + } + + private enum CodingKeys: String, CodingKey { + case defaultid = "defaultId" + case mainkey = "mainKey" + case scope + case agents + } +} + +public struct ModelChoice: Codable, Sendable { + public let id: String + public let name: String + public let provider: String + public let contextwindow: Int? + public let reasoning: Bool? + + public init( + id: String, + name: String, + provider: String, + contextwindow: Int?, + reasoning: Bool?) + { + self.id = id + self.name = name + self.provider = provider + self.contextwindow = contextwindow + self.reasoning = reasoning + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case provider + case contextwindow = "contextWindow" + case reasoning + } +} + +public struct ModelsListParams: Codable, Sendable {} + +public struct ModelsListResult: Codable, Sendable { + public let models: [ModelChoice] + + public init( + models: [ModelChoice]) + { + self.models = models + } + + private enum CodingKeys: String, CodingKey { + case models + } +} + +public struct SkillsStatusParams: Codable, Sendable { + public let agentid: String? + + public init( + agentid: String?) + { + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + } +} + +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } +} + +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + +public struct SkillsBinsParams: Codable, Sendable {} + +public struct SkillsBinsResult: Codable, Sendable { + public let bins: [String] + + public init( + bins: [String]) + { + self.bins = bins + } + + private enum CodingKeys: String, CodingKey { + case bins + } +} + +public struct SkillsInstallParams: Codable, Sendable { + public let name: String + public let installid: String + public let timeoutms: Int? + + public init( + name: String, + installid: String, + timeoutms: Int?) + { + self.name = name + self.installid = installid + self.timeoutms = timeoutms + } + + private enum CodingKeys: String, CodingKey { + case name + case installid = "installId" + case timeoutms = "timeoutMs" + } +} + +public struct SkillsUpdateParams: Codable, Sendable { + public let skillkey: String + public let enabled: Bool? + public let apikey: String? + public let env: [String: AnyCodable]? + + public init( + skillkey: String, + enabled: Bool?, + apikey: String?, + env: [String: AnyCodable]?) + { + self.skillkey = skillkey + self.enabled = enabled + self.apikey = apikey + self.env = env + } + + private enum CodingKeys: String, CodingKey { + case skillkey = "skillKey" + case enabled + case apikey = "apiKey" + case env + } +} + +public struct CronJob: Codable, Sendable { + public let id: String + public let agentid: String? + public let sessionkey: String? + public let name: String + public let description: String? + public let enabled: Bool + public let deleteafterrun: Bool? + public let createdatms: Int + public let updatedatms: Int + public let schedule: AnyCodable + public let sessiontarget: AnyCodable + public let wakemode: AnyCodable + public let payload: AnyCodable + public let delivery: AnyCodable? + public let state: [String: AnyCodable] + + public init( + id: String, + agentid: String?, + sessionkey: String?, + name: String, + description: String?, + enabled: Bool, + deleteafterrun: Bool?, + createdatms: Int, + updatedatms: Int, + schedule: AnyCodable, + sessiontarget: AnyCodable, + wakemode: AnyCodable, + payload: AnyCodable, + delivery: AnyCodable?, + state: [String: AnyCodable]) + { + self.id = id + self.agentid = agentid + self.sessionkey = sessionkey + self.name = name + self.description = description + self.enabled = enabled + self.deleteafterrun = deleteafterrun + self.createdatms = createdatms + self.updatedatms = updatedatms + self.schedule = schedule + self.sessiontarget = sessiontarget + self.wakemode = wakemode + self.payload = payload + self.delivery = delivery + self.state = state + } + + private enum CodingKeys: String, CodingKey { + case id + case agentid = "agentId" + case sessionkey = "sessionKey" + case name + case description + case enabled + case deleteafterrun = "deleteAfterRun" + case createdatms = "createdAtMs" + case updatedatms = "updatedAtMs" + case schedule + case sessiontarget = "sessionTarget" + case wakemode = "wakeMode" + case payload + case delivery + case state + } +} + +public struct CronListParams: Codable, Sendable { + public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? + + public init( + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) + { + self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" + } +} + +public struct CronStatusParams: Codable, Sendable {} + +public struct CronAddParams: Codable, Sendable { + public let name: String + public let agentid: AnyCodable? + public let sessionkey: AnyCodable? + public let description: String? + public let enabled: Bool? + public let deleteafterrun: Bool? + public let schedule: AnyCodable + public let sessiontarget: AnyCodable + public let wakemode: AnyCodable + public let payload: AnyCodable + public let delivery: AnyCodable? + + public init( + name: String, + agentid: AnyCodable?, + sessionkey: AnyCodable?, + description: String?, + enabled: Bool?, + deleteafterrun: Bool?, + schedule: AnyCodable, + sessiontarget: AnyCodable, + wakemode: AnyCodable, + payload: AnyCodable, + delivery: AnyCodable?) + { + self.name = name + self.agentid = agentid + self.sessionkey = sessionkey + self.description = description + self.enabled = enabled + self.deleteafterrun = deleteafterrun + self.schedule = schedule + self.sessiontarget = sessiontarget + self.wakemode = wakemode + self.payload = payload + self.delivery = delivery + } + + private enum CodingKeys: String, CodingKey { + case name + case agentid = "agentId" + case sessionkey = "sessionKey" + case description + case enabled + case deleteafterrun = "deleteAfterRun" + case schedule + case sessiontarget = "sessionTarget" + case wakemode = "wakeMode" + case payload + case delivery + } +} + +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + +public struct CronRunLogEntry: Codable, Sendable { + public let ts: Int + public let jobid: String + public let action: String + public let status: AnyCodable? + public let error: String? + public let summary: String? + public let delivered: Bool? + public let deliverystatus: AnyCodable? + public let deliveryerror: String? + public let sessionid: String? + public let sessionkey: String? + public let runatms: Int? + public let durationms: Int? + public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? + + public init( + ts: Int, + jobid: String, + action: String, + status: AnyCodable?, + error: String?, + summary: String?, + delivered: Bool?, + deliverystatus: AnyCodable?, + deliveryerror: String?, + sessionid: String?, + sessionkey: String?, + runatms: Int?, + durationms: Int?, + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) + { + self.ts = ts + self.jobid = jobid + self.action = action + self.status = status + self.error = error + self.summary = summary + self.delivered = delivered + self.deliverystatus = deliverystatus + self.deliveryerror = deliveryerror + self.sessionid = sessionid + self.sessionkey = sessionkey + self.runatms = runatms + self.durationms = durationms + self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname + } + + private enum CodingKeys: String, CodingKey { + case ts + case jobid = "jobId" + case action + case status + case error + case summary + case delivered + case deliverystatus = "deliveryStatus" + case deliveryerror = "deliveryError" + case sessionid = "sessionId" + case sessionkey = "sessionKey" + case runatms = "runAtMs" + case durationms = "durationMs" + case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" + } +} + +public struct LogsTailParams: Codable, Sendable { + public let cursor: Int? + public let limit: Int? + public let maxbytes: Int? + + public init( + cursor: Int?, + limit: Int?, + maxbytes: Int?) + { + self.cursor = cursor + self.limit = limit + self.maxbytes = maxbytes + } + + private enum CodingKeys: String, CodingKey { + case cursor + case limit + case maxbytes = "maxBytes" + } +} + +public struct LogsTailResult: Codable, Sendable { + public let file: String + public let cursor: Int + public let size: Int + public let lines: [String] + public let truncated: Bool? + public let reset: Bool? + + public init( + file: String, + cursor: Int, + size: Int, + lines: [String], + truncated: Bool?, + reset: Bool?) + { + self.file = file + self.cursor = cursor + self.size = size + self.lines = lines + self.truncated = truncated + self.reset = reset + } + + private enum CodingKeys: String, CodingKey { + case file + case cursor + case size + case lines + case truncated + case reset + } +} + +public struct ExecApprovalsGetParams: Codable, Sendable {} + +public struct ExecApprovalsSetParams: Codable, Sendable { + public let file: [String: AnyCodable] + public let basehash: String? + + public init( + file: [String: AnyCodable], + basehash: String?) + { + self.file = file + self.basehash = basehash + } + + private enum CodingKeys: String, CodingKey { + case file + case basehash = "baseHash" + } +} + +public struct ExecApprovalsNodeGetParams: Codable, Sendable { + public let nodeid: String + + public init( + nodeid: String) + { + self.nodeid = nodeid + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + } +} + +public struct ExecApprovalsNodeSetParams: Codable, Sendable { + public let nodeid: String + public let file: [String: AnyCodable] + public let basehash: String? + + public init( + nodeid: String, + file: [String: AnyCodable], + basehash: String?) + { + self.nodeid = nodeid + self.file = file + self.basehash = basehash + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case file + case basehash = "baseHash" + } +} + +public struct ExecApprovalsSnapshot: Codable, Sendable { + public let path: String + public let exists: Bool + public let hash: String + public let file: [String: AnyCodable] + + public init( + path: String, + exists: Bool, + hash: String, + file: [String: AnyCodable]) + { + self.path = path + self.exists = exists + self.hash = hash + self.file = file + } + + private enum CodingKeys: String, CodingKey { + case path + case exists + case hash + case file + } +} + +public struct ExecApprovalRequestParams: Codable, Sendable { + public let id: String? + public let command: String + public let cwd: AnyCodable? + public let nodeid: AnyCodable? + public let host: AnyCodable? + public let security: AnyCodable? + public let ask: AnyCodable? + public let agentid: AnyCodable? + public let resolvedpath: AnyCodable? + public let sessionkey: AnyCodable? + public let timeoutms: Int? + public let twophase: Bool? + + public init( + id: String?, + command: String, + cwd: AnyCodable?, + nodeid: AnyCodable?, + host: AnyCodable?, + security: AnyCodable?, + ask: AnyCodable?, + agentid: AnyCodable?, + resolvedpath: AnyCodable?, + sessionkey: AnyCodable?, + timeoutms: Int?, + twophase: Bool?) + { + self.id = id + self.command = command + self.cwd = cwd + self.nodeid = nodeid + self.host = host + self.security = security + self.ask = ask + self.agentid = agentid + self.resolvedpath = resolvedpath + self.sessionkey = sessionkey + self.timeoutms = timeoutms + self.twophase = twophase + } + + private enum CodingKeys: String, CodingKey { + case id + case command + case cwd + case nodeid = "nodeId" + case host + case security + case ask + case agentid = "agentId" + case resolvedpath = "resolvedPath" + case sessionkey = "sessionKey" + case timeoutms = "timeoutMs" + case twophase = "twoPhase" + } +} + +public struct ExecApprovalResolveParams: Codable, Sendable { + public let id: String + public let decision: String + + public init( + id: String, + decision: String) + { + self.id = id + self.decision = decision + } + + private enum CodingKeys: String, CodingKey { + case id + case decision + } +} + +public struct DevicePairListParams: Codable, Sendable {} + +public struct DevicePairApproveParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String) + { + self.requestid = requestid + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct DevicePairRejectParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String) + { + self.requestid = requestid + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct DevicePairRemoveParams: Codable, Sendable { + public let deviceid: String + + public init( + deviceid: String) + { + self.deviceid = deviceid + } + + private enum CodingKeys: String, CodingKey { + case deviceid = "deviceId" + } +} + +public struct DeviceTokenRotateParams: Codable, Sendable { + public let deviceid: String + public let role: String + public let scopes: [String]? + + public init( + deviceid: String, + role: String, + scopes: [String]?) + { + self.deviceid = deviceid + self.role = role + self.scopes = scopes + } + + private enum CodingKeys: String, CodingKey { + case deviceid = "deviceId" + case role + case scopes + } +} + +public struct DeviceTokenRevokeParams: Codable, Sendable { + public let deviceid: String + public let role: String + + public init( + deviceid: String, + role: String) + { + self.deviceid = deviceid + self.role = role + } + + private enum CodingKeys: String, CodingKey { + case deviceid = "deviceId" + case role + } +} + +public struct DevicePairRequestedEvent: Codable, Sendable { + public let requestid: String + public let deviceid: String + public let publickey: String + public let displayname: String? + public let platform: String? + public let clientid: String? + public let clientmode: String? + public let role: String? + public let roles: [String]? + public let scopes: [String]? + public let remoteip: String? + public let silent: Bool? + public let isrepair: Bool? + public let ts: Int + + public init( + requestid: String, + deviceid: String, + publickey: String, + displayname: String?, + platform: String?, + clientid: String?, + clientmode: String?, + role: String?, + roles: [String]?, + scopes: [String]?, + remoteip: String?, + silent: Bool?, + isrepair: Bool?, + ts: Int) + { + self.requestid = requestid + self.deviceid = deviceid + self.publickey = publickey + self.displayname = displayname + self.platform = platform + self.clientid = clientid + self.clientmode = clientmode + self.role = role + self.roles = roles + self.scopes = scopes + self.remoteip = remoteip + self.silent = silent + self.isrepair = isrepair + self.ts = ts + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + case deviceid = "deviceId" + case publickey = "publicKey" + case displayname = "displayName" + case platform + case clientid = "clientId" + case clientmode = "clientMode" + case role + case roles + case scopes + case remoteip = "remoteIp" + case silent + case isrepair = "isRepair" + case ts + } +} + +public struct DevicePairResolvedEvent: Codable, Sendable { + public let requestid: String + public let deviceid: String + public let decision: String + public let ts: Int + + public init( + requestid: String, + deviceid: String, + decision: String, + ts: Int) + { + self.requestid = requestid + self.deviceid = deviceid + self.decision = decision + self.ts = ts + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + case deviceid = "deviceId" + case decision + case ts + } +} + +public struct ChatHistoryParams: Codable, Sendable { + public let sessionkey: String + public let limit: Int? + + public init( + sessionkey: String, + limit: Int?) + { + self.sessionkey = sessionkey + self.limit = limit + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case limit + } +} + +public struct ChatSendParams: Codable, Sendable { + public let sessionkey: String + public let message: String + public let thinking: String? + public let deliver: Bool? + public let attachments: [AnyCodable]? + public let timeoutms: Int? + public let idempotencykey: String + + public init( + sessionkey: String, + message: String, + thinking: String?, + deliver: Bool?, + attachments: [AnyCodable]?, + timeoutms: Int?, + idempotencykey: String) + { + self.sessionkey = sessionkey + self.message = message + self.thinking = thinking + self.deliver = deliver + self.attachments = attachments + self.timeoutms = timeoutms + self.idempotencykey = idempotencykey + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case message + case thinking + case deliver + case attachments + case timeoutms = "timeoutMs" + case idempotencykey = "idempotencyKey" + } +} + +public struct ChatAbortParams: Codable, Sendable { + public let sessionkey: String + public let runid: String? + + public init( + sessionkey: String, + runid: String?) + { + self.sessionkey = sessionkey + self.runid = runid + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case runid = "runId" + } +} + +public struct ChatInjectParams: Codable, Sendable { + public let sessionkey: String + public let message: String + public let label: String? + + public init( + sessionkey: String, + message: String, + label: String?) + { + self.sessionkey = sessionkey + self.message = message + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case message + case label + } +} + +public struct ChatEvent: Codable, Sendable { + public let runid: String + public let sessionkey: String + public let seq: Int + public let state: AnyCodable + public let message: AnyCodable? + public let errormessage: String? + public let usage: AnyCodable? + public let stopreason: String? + + public init( + runid: String, + sessionkey: String, + seq: Int, + state: AnyCodable, + message: AnyCodable?, + errormessage: String?, + usage: AnyCodable?, + stopreason: String?) + { + self.runid = runid + self.sessionkey = sessionkey + self.seq = seq + self.state = state + self.message = message + self.errormessage = errormessage + self.usage = usage + self.stopreason = stopreason + } + + private enum CodingKeys: String, CodingKey { + case runid = "runId" + case sessionkey = "sessionKey" + case seq + case state + case message + case errormessage = "errorMessage" + case usage + case stopreason = "stopReason" + } +} + +public struct UpdateRunParams: Codable, Sendable { + public let sessionkey: String? + public let note: String? + public let restartdelayms: Int? + public let timeoutms: Int? + + public init( + sessionkey: String?, + note: String?, + restartdelayms: Int?, + timeoutms: Int?) + { + self.sessionkey = sessionkey + self.note = note + self.restartdelayms = restartdelayms + self.timeoutms = timeoutms + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case note + case restartdelayms = "restartDelayMs" + case timeoutms = "timeoutMs" + } +} + +public struct TickEvent: Codable, Sendable { + public let ts: Int + + public init( + ts: Int) + { + self.ts = ts + } + + private enum CodingKeys: String, CodingKey { + case ts + } +} + +public struct ShutdownEvent: Codable, Sendable { + public let reason: String + public let restartexpectedms: Int? + + public init( + reason: String, + restartexpectedms: Int?) + { + self.reason = reason + self.restartexpectedms = restartexpectedms + } + + private enum CodingKeys: String, CodingKey { + case reason + case restartexpectedms = "restartExpectedMs" + } +} + +public enum GatewayFrame: Codable, Sendable { + case req(RequestFrame) + case res(ResponseFrame) + case event(EventFrame) + case unknown(type: String, raw: [String: AnyCodable]) + + private enum CodingKeys: String, CodingKey { + case type + } + + public init(from decoder: Decoder) throws { + let typeContainer = try decoder.container(keyedBy: CodingKeys.self) + let type = try typeContainer.decode(String.self, forKey: .type) + switch type { + case "req": + self = try .req(RequestFrame(from: decoder)) + case "res": + self = try .res(ResponseFrame(from: decoder)) + case "event": + self = try .event(EventFrame(from: decoder)) + default: + let container = try decoder.singleValueContainer() + let raw = try container.decode([String: AnyCodable].self) + self = .unknown(type: type, raw: raw) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case let .req(v): + try v.encode(to: encoder) + case let .res(v): + try v.encode(to: encoder) + case let .event(v): + try v.encode(to: encoder) + case let .unknown(_, raw): + var container = encoder.singleValueContainer() + try container.encode(raw) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AgentEventStoreTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AgentEventStoreTests.swift new file mode 100644 index 00000000..89754f86 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AgentEventStoreTests.swift @@ -0,0 +1,44 @@ +import OpenClawProtocol +import Foundation +import Testing +@testable import OpenClaw + +@Suite +@MainActor +struct AgentEventStoreTests { + @Test + func appendAndClear() { + let store = AgentEventStore() + #expect(store.events.isEmpty) + + store.append(ControlAgentEvent( + runId: "run", + seq: 1, + stream: "test", + ts: 0, + data: [:] as [String: OpenClawProtocol.AnyCodable], + summary: nil)) + #expect(store.events.count == 1) + + store.clear() + #expect(store.events.isEmpty) + } + + @Test + func trimsToMaxEvents() { + let store = AgentEventStore() + for i in 1...401 { + store.append(ControlAgentEvent( + runId: "run", + seq: i, + stream: "test", + ts: Double(i), + data: [:] as [String: OpenClawProtocol.AnyCodable], + summary: nil)) + } + + #expect(store.events.count == 400) + #expect(store.events.first?.seq == 2) + #expect(store.events.last?.seq == 401) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift new file mode 100644 index 00000000..8794a3f2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite +struct AgentWorkspaceTests { + @Test + func displayPathUsesTildeForHome() { + let home = FileManager().homeDirectoryForCurrentUser + #expect(AgentWorkspace.displayPath(for: home) == "~") + + let inside = home.appendingPathComponent("Projects", isDirectory: true) + #expect(AgentWorkspace.displayPath(for: inside).hasPrefix("~/")) + } + + @Test + func resolveWorkspaceURLExpandsTilde() { + let url = AgentWorkspace.resolveWorkspaceURL(from: "~/tmp") + #expect(url.path.hasSuffix("/tmp")) + } + + @Test + func agentsURLAppendsFilename() { + let root = URL(fileURLWithPath: "/tmp/ws", isDirectory: true) + let url = AgentWorkspace.agentsURL(workspaceURL: root) + #expect(url.lastPathComponent == AgentWorkspace.agentsFilename) + } + + @Test + func bootstrapCreatesAgentsFileWhenMissing() throws { + let tmp = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager().removeItem(at: tmp) } + + let agentsURL = try AgentWorkspace.bootstrap(workspaceURL: tmp) + #expect(FileManager().fileExists(atPath: agentsURL.path)) + + let contents = try String(contentsOf: agentsURL, encoding: .utf8) + #expect(contents.contains("# AGENTS.md")) + + let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename) + let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename) + let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) + #expect(FileManager().fileExists(atPath: identityURL.path)) + #expect(FileManager().fileExists(atPath: userURL.path)) + #expect(FileManager().fileExists(atPath: bootstrapURL.path)) + + let second = try AgentWorkspace.bootstrap(workspaceURL: tmp) + #expect(second == agentsURL) + } + + @Test + func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws { + let tmp = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager().removeItem(at: tmp) } + try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) + let marker = tmp.appendingPathComponent("notes.txt") + try "hello".write(to: marker, atomically: true, encoding: .utf8) + + let result = AgentWorkspace.bootstrapSafety(for: tmp) + #expect(result.unsafeReason != nil) + } + + @Test + func bootstrapSafetyAllowsExistingAgentsFile() throws { + let tmp = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager().removeItem(at: tmp) } + try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) + let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename) + try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8) + + let result = AgentWorkspace.bootstrapSafety(for: tmp) + #expect(result.unsafeReason == nil) + } + + @Test + func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws { + let tmp = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager().removeItem(at: tmp) } + try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) + let marker = tmp.appendingPathComponent("notes.txt") + try "hello".write(to: marker, atomically: true, encoding: .utf8) + + _ = try AgentWorkspace.bootstrap(workspaceURL: tmp) + + let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) + #expect(!FileManager().fileExists(atPath: bootstrapURL.path)) + } + + @Test + func needsBootstrapFalseWhenIdentityAlreadySet() throws { + let tmp = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager().removeItem(at: tmp) } + try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) + let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename) + try """ + # IDENTITY.md - Agent Identity + + - Name: Clawd + - Creature: Space Lobster + - Vibe: Helpful + - Emoji: lobster + """.write(to: identityURL, atomically: true, encoding: .utf8) + let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) + try "bootstrap".write(to: bootstrapURL, atomically: true, encoding: .utf8) + + #expect(!AgentWorkspace.needsBootstrap(workspaceURL: tmp)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift new file mode 100644 index 00000000..84c61833 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthControlsSmokeTests.swift @@ -0,0 +1,29 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct AnthropicAuthControlsSmokeTests { + @Test func anthropicAuthControlsBuildsBodyLocal() { + let pkce = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge") + let view = AnthropicAuthControls( + connectionMode: .local, + oauthStatus: .connected(expiresAtMs: 1_700_000_000_000), + pkce: pkce, + code: "code#state", + statusText: "Detected code", + autoDetectClipboard: false, + autoConnectClipboard: false) + _ = view.body + } + + @Test func anthropicAuthControlsBuildsBodyRemote() { + let view = AnthropicAuthControls( + connectionMode: .remote, + oauthStatus: .missingFile, + pkce: nil, + code: "", + statusText: nil) + _ = view.body + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift new file mode 100644 index 00000000..c41b7f64 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite +struct AnthropicAuthResolverTests { + @Test + func prefersOAuthFileOverEnv() throws { + let dir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true) + try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) + let oauthFile = dir.appendingPathComponent("oauth.json") + let payload = [ + "anthropic": [ + "type": "oauth", + "refresh": "r1", + "access": "a1", + "expires": 1_234_567_890, + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: oauthFile, options: [.atomic]) + + let status = OpenClawOAuthStore.anthropicOAuthStatus(at: oauthFile) + let mode = AnthropicAuthResolver.resolve(environment: [ + "ANTHROPIC_API_KEY": "sk-ant-ignored", + ], oauthStatus: status) + #expect(mode == .oauthFile) + } + + @Test + func reportsOAuthEnvWhenPresent() { + let mode = AnthropicAuthResolver.resolve(environment: [ + "ANTHROPIC_OAUTH_TOKEN": "token", + ], oauthStatus: .missingFile) + #expect(mode == .oauthEnv) + } + + @Test + func reportsAPIKeyEnvWhenPresent() { + let mode = AnthropicAuthResolver.resolve(environment: [ + "ANTHROPIC_API_KEY": "sk-ant-key", + ], oauthStatus: .missingFile) + #expect(mode == .apiKeyEnv) + } + + @Test + func reportsMissingWhenNothingConfigured() { + let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile) + #expect(mode == .missing) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift new file mode 100644 index 00000000..3d337c2b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AnthropicOAuthCodeStateTests.swift @@ -0,0 +1,31 @@ +import Testing +@testable import OpenClaw + +@Suite +struct AnthropicOAuthCodeStateTests { + @Test + func parsesRawToken() { + let parsed = AnthropicOAuthCodeState.parse(from: "abcDEF1234#stateXYZ9876") + #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) + } + + @Test + func parsesBacktickedToken() { + let parsed = AnthropicOAuthCodeState.parse(from: "`abcDEF1234#stateXYZ9876`") + #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) + } + + @Test + func parsesCallbackURL() { + let raw = "https://console.anthropic.com/oauth/code/callback?code=abcDEF1234&state=stateXYZ9876" + let parsed = AnthropicOAuthCodeState.parse(from: raw) + #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) + } + + @Test + func extractsFromSurroundingText() { + let raw = "Paste the code#state value: abcDEF1234#stateXYZ9876 then return." + let parsed = AnthropicOAuthCodeState.parse(from: raw) + #expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876")) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AnyCodableEncodingTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AnyCodableEncodingTests.swift new file mode 100644 index 00000000..98ff08af --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AnyCodableEncodingTests.swift @@ -0,0 +1,38 @@ +import OpenClawProtocol +import Foundation +import Testing + +@testable import OpenClaw + +@Suite struct AnyCodableEncodingTests { + @Test func encodesSwiftArrayAndDictionaryValues() throws { + let payload: [String: Any] = [ + "tags": ["node", "ios"], + "meta": ["count": 2], + "null": NSNull(), + ] + + let data = try JSONEncoder().encode(OpenClawProtocol.AnyCodable(payload)) + let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(obj["tags"] as? [String] == ["node", "ios"]) + #expect((obj["meta"] as? [String: Any])?["count"] as? Int == 2) + #expect(obj["null"] is NSNull) + } + + @Test func protocolAnyCodableEncodesPrimitiveArrays() throws { + let payload: [String: Any] = [ + "items": [1, "two", NSNull(), ["ok": true]], + ] + + let data = try JSONEncoder().encode(OpenClawProtocol.AnyCodable(payload)) + let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + let items = try #require(obj["items"] as? [Any]) + #expect(items.count == 4) + #expect(items[0] as? Int == 1) + #expect(items[1] as? String == "two") + #expect(items[2] is NSNull) + #expect((items[3] as? [String: Any])?["ok"] as? Bool == true) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift new file mode 100644 index 00000000..a175e5e1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct AudioInputDeviceObserverTests { + @Test func hasUsableDefaultInputDeviceReturnsBool() { + // Smoke test: verifies the composition logic runs without crashing. + // Actual result depends on whether the host has an audio input device. + let result = AudioInputDeviceObserver.hasUsableDefaultInputDevice() + _ = result // suppress unused-variable warning; the assertion is "no crash" + } + + @Test func hasUsableDefaultInputDeviceConsistentWithComponents() { + // When no default UID exists, the method must return false. + // When a default UID exists, the result must match alive-set membership. + let uid = AudioInputDeviceObserver.defaultInputDeviceUID() + let alive = AudioInputDeviceObserver.aliveInputDeviceUIDs() + let expected = uid.map { alive.contains($0) } ?? false + #expect(AudioInputDeviceObserver.hasUsableDefaultInputDevice() == expected) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift new file mode 100644 index 00000000..651dfeb4 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct CLIInstallerTests { + @Test func installedLocationFindsExecutable() throws { + let fm = FileManager() + let root = fm.temporaryDirectory.appendingPathComponent( + "openclaw-cli-installer-\(UUID().uuidString)") + defer { try? fm.removeItem(at: root) } + + let binDir = root.appendingPathComponent("bin") + try fm.createDirectory(at: binDir, withIntermediateDirectories: true) + let cli = binDir.appendingPathComponent("openclaw") + fm.createFile(atPath: cli.path, contents: Data()) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cli.path) + + let found = CLIInstaller.installedLocation( + searchPaths: [binDir.path], + fileManager: fm) + #expect(found == cli.path) + + try fm.removeItem(at: cli) + fm.createFile(atPath: cli.path, contents: Data()) + try fm.setAttributes([.posixPermissions: 0o644], ofItemAtPath: cli.path) + + let missing = CLIInstaller.installedLocation( + searchPaths: [binDir.path], + fileManager: fm) + #expect(missing == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CameraCaptureServiceTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CameraCaptureServiceTests.swift new file mode 100644 index 00000000..14b5e605 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CameraCaptureServiceTests.swift @@ -0,0 +1,21 @@ +import Testing + +@testable import OpenClaw + +@Suite struct CameraCaptureServiceTests { + @Test func normalizeSnapDefaults() { + let res = CameraCaptureService.normalizeSnap(maxWidth: nil, quality: nil) + #expect(res.maxWidth == 1600) + #expect(res.quality == 0.9) + } + + @Test func normalizeSnapClampsValues() { + let low = CameraCaptureService.normalizeSnap(maxWidth: -1, quality: -10) + #expect(low.maxWidth == 1600) + #expect(low.quality == 0.05) + + let high = CameraCaptureService.normalizeSnap(maxWidth: 9999, quality: 10) + #expect(high.maxWidth == 9999) + #expect(high.quality == 1.0) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CameraIPCTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CameraIPCTests.swift new file mode 100644 index 00000000..a233154a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CameraIPCTests.swift @@ -0,0 +1,61 @@ +import OpenClawIPC +import Foundation +import Testing + +@Suite struct CameraIPCTests { + @Test func cameraSnapCodableRoundtrip() throws { + let req: Request = .cameraSnap( + facing: .front, + maxWidth: 640, + quality: 0.85, + outPath: "/tmp/test.jpg") + + let data = try JSONEncoder().encode(req) + let decoded = try JSONDecoder().decode(Request.self, from: data) + + switch decoded { + case let .cameraSnap(facing, maxWidth, quality, outPath): + #expect(facing == .front) + #expect(maxWidth == 640) + #expect(quality == 0.85) + #expect(outPath == "/tmp/test.jpg") + default: + Issue.record("expected cameraSnap, got \(decoded)") + } + } + + @Test func cameraClipCodableRoundtrip() throws { + let req: Request = .cameraClip( + facing: .back, + durationMs: 3000, + includeAudio: false, + outPath: "/tmp/test.mp4") + + let data = try JSONEncoder().encode(req) + let decoded = try JSONDecoder().decode(Request.self, from: data) + + switch decoded { + case let .cameraClip(facing, durationMs, includeAudio, outPath): + #expect(facing == .back) + #expect(durationMs == 3000) + #expect(includeAudio == false) + #expect(outPath == "/tmp/test.mp4") + default: + Issue.record("expected cameraClip, got \(decoded)") + } + } + + @Test func cameraClipDefaultsIncludeAudioToTrueWhenMissing() throws { + let json = """ + {"type":"cameraClip","durationMs":1234} + """ + let decoded = try JSONDecoder().decode(Request.self, from: Data(json.utf8)) + switch decoded { + case let .cameraClip(_, durationMs, includeAudio, _): + #expect(durationMs == 1234) + #expect(includeAudio == true) + default: + Issue.record("expected cameraClip, got \(decoded)") + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CanvasFileWatcherTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CanvasFileWatcherTests.swift new file mode 100644 index 00000000..3c957161 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CanvasFileWatcherTests.swift @@ -0,0 +1,78 @@ +import Foundation +import os +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct CanvasFileWatcherTests { + private func makeTempDir() throws -> URL { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let dir = base.appendingPathComponent("openclaw-canvaswatch-\(UUID().uuidString)", isDirectory: true) + try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + @Test func detectsInPlaceFileWrites() async throws { + let dir = try self.makeTempDir() + defer { try? FileManager().removeItem(at: dir) } + + let file = dir.appendingPathComponent("index.html") + try "hello".write(to: file, atomically: false, encoding: .utf8) + + let fired = OSAllocatedUnfairLock(initialState: false) + let waitState = OSAllocatedUnfairLock<(fired: Bool, cont: CheckedContinuation?)>( + initialState: (false, nil)) + + func waitForFire(timeoutNs: UInt64) async -> Bool { + await withTaskGroup(of: Bool.self) { group in + group.addTask { + await withCheckedContinuation { cont in + let resumeImmediately = waitState.withLock { state in + if state.fired { return true } + state.cont = cont + return false + } + if resumeImmediately { + cont.resume() + } + } + return true + } + + group.addTask { + try? await Task.sleep(nanoseconds: timeoutNs) + return false + } + + let result = await group.next() ?? false + group.cancelAll() + return result + } + } + + let watcher = CanvasFileWatcher(url: dir) { + fired.withLock { $0 = true } + let cont = waitState.withLock { state in + state.fired = true + let cont = state.cont + state.cont = nil + return cont + } + cont?.resume() + } + watcher.start() + defer { watcher.stop() } + + // Give the stream a moment to start. + try await Task.sleep(nanoseconds: 150 * 1_000_000) + + // Modify the file in-place (no rename). This used to be missed when only watching the directory vnode. + let handle = try FileHandle(forUpdating: file) + try handle.seekToEnd() + try handle.write(contentsOf: Data(" world".utf8)) + try handle.close() + + let ok = await waitForFire(timeoutNs: 2_000_000_000) + #expect(ok == true) + #expect(fired.withLock { $0 } == true) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CanvasIPCTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CanvasIPCTests.swift new file mode 100644 index 00000000..b509efd8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CanvasIPCTests.swift @@ -0,0 +1,41 @@ +import OpenClawIPC +import Foundation +import Testing + +@Suite struct CanvasIPCTests { + @Test func canvasPresentCodableRoundtrip() throws { + let placement = CanvasPlacement(x: 10, y: 20, width: 640, height: 480) + let req: Request = .canvasPresent(session: "main", path: "/index.html", placement: placement) + + let data = try JSONEncoder().encode(req) + let decoded = try JSONDecoder().decode(Request.self, from: data) + + switch decoded { + case let .canvasPresent(session, path, placement): + #expect(session == "main") + #expect(path == "/index.html") + #expect(placement?.x == 10) + #expect(placement?.y == 20) + #expect(placement?.width == 640) + #expect(placement?.height == 480) + default: + Issue.record("expected canvasPresent, got \(decoded)") + } + } + + @Test func canvasPresentDecodesNilPlacementWhenMissing() throws { + let json = """ + {"type":"canvasPresent","session":"s","path":"/"} + """ + let decoded = try JSONDecoder().decode(Request.self, from: Data(json.utf8)) + + switch decoded { + case let .canvasPresent(session, path, placement): + #expect(session == "s") + #expect(path == "/") + #expect(placement == nil) + default: + Issue.record("expected canvasPresent, got \(decoded)") + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift new file mode 100644 index 00000000..4299ca74 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift @@ -0,0 +1,49 @@ +import AppKit +import OpenClawIPC +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct CanvasWindowSmokeTests { + @Test func panelControllerShowsAndHides() async throws { + let root = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-canvas-test-\(UUID().uuidString)") + try FileManager().createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager().removeItem(at: root) } + + let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) } + let controller = try CanvasWindowController( + sessionKey: " main/invalid⚡️ ", + root: root, + presentation: .panel(anchorProvider: anchor)) + + #expect(controller.directoryPath.contains("main_invalid__") == true) + + controller.applyPreferredPlacement(CanvasPlacement(x: 120, y: 200, width: 520, height: 680)) + controller.showCanvas(path: "/") + _ = try await controller.eval(javaScript: "1 + 1") + controller.windowDidMove(Notification(name: NSWindow.didMoveNotification)) + controller.windowDidEndLiveResize(Notification(name: NSWindow.didEndLiveResizeNotification)) + controller.hideCanvas() + controller.close() + } + + @Test func windowControllerShowsAndCloses() async throws { + let root = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-canvas-test-\(UUID().uuidString)") + try FileManager().createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager().removeItem(at: root) } + + let controller = try CanvasWindowController( + sessionKey: "main", + root: root, + presentation: .window) + + controller.showCanvas(path: "/") + controller.windowWillClose(Notification(name: NSWindow.willCloseNotification)) + controller.hideCanvas() + controller.close() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift new file mode 100644 index 00000000..8810d123 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift @@ -0,0 +1,164 @@ +import OpenClawProtocol +import SwiftUI +import Testing +@testable import OpenClaw + +private typealias SnapshotAnyCodable = OpenClaw.AnyCodable + +@Suite(.serialized) +@MainActor +struct ChannelsSettingsSmokeTests { + @Test func channelsSettingsBuildsBodyWithSnapshot() { + let store = ChannelsStore(isPreview: true) + store.snapshot = ChannelsStatusSnapshot( + ts: 1_700_000_000_000, + channelOrder: ["whatsapp", "telegram", "signal", "imessage"], + channelLabels: [ + "whatsapp": "WhatsApp", + "telegram": "Telegram", + "signal": "Signal", + "imessage": "iMessage", + ], + channelDetailLabels: nil, + channelSystemImages: nil, + channelMeta: nil, + channels: [ + "whatsapp": SnapshotAnyCodable([ + "configured": true, + "linked": true, + "authAgeMs": 86_400_000, + "self": ["e164": "+15551234567"], + "running": true, + "connected": false, + "lastConnectedAt": 1_700_000_000_000, + "lastDisconnect": [ + "at": 1_700_000_050_000, + "status": 401, + "error": "logged out", + "loggedOut": true, + ], + "reconnectAttempts": 2, + "lastMessageAt": 1_700_000_060_000, + "lastEventAt": 1_700_000_060_000, + "lastError": "needs login", + ]), + "telegram": SnapshotAnyCodable([ + "configured": true, + "tokenSource": "env", + "running": true, + "mode": "polling", + "lastStartAt": 1_700_000_000_000, + "probe": [ + "ok": true, + "status": 200, + "elapsedMs": 120, + "bot": ["id": 123, "username": "openclawbot"], + "webhook": ["url": "https://example.com/hook", "hasCustomCert": false], + ], + "lastProbeAt": 1_700_000_050_000, + ]), + "signal": SnapshotAnyCodable([ + "configured": true, + "baseUrl": "http://127.0.0.1:8080", + "running": true, + "lastStartAt": 1_700_000_000_000, + "probe": [ + "ok": true, + "status": 200, + "elapsedMs": 140, + "version": "0.12.4", + ], + "lastProbeAt": 1_700_000_050_000, + ]), + "imessage": SnapshotAnyCodable([ + "configured": false, + "running": false, + "lastError": "not configured", + "probe": ["ok": false, "error": "imsg not found (imsg)"], + "lastProbeAt": 1_700_000_050_000, + ]), + ], + channelAccounts: [:], + channelDefaultAccountId: [ + "whatsapp": "default", + "telegram": "default", + "signal": "default", + "imessage": "default", + ]) + + store.whatsappLoginMessage = "Scan QR" + store.whatsappLoginQrDataUrl = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ay7pS8AAAAASUVORK5CYII=" + + let view = ChannelsSettings(store: store) + _ = view.body + } + + @Test func channelsSettingsBuildsBodyWithoutSnapshot() { + let store = ChannelsStore(isPreview: true) + store.snapshot = ChannelsStatusSnapshot( + ts: 1_700_000_000_000, + channelOrder: ["whatsapp", "telegram", "signal", "imessage"], + channelLabels: [ + "whatsapp": "WhatsApp", + "telegram": "Telegram", + "signal": "Signal", + "imessage": "iMessage", + ], + channelDetailLabels: nil, + channelSystemImages: nil, + channelMeta: nil, + channels: [ + "whatsapp": SnapshotAnyCodable([ + "configured": false, + "linked": false, + "running": false, + "connected": false, + "reconnectAttempts": 0, + ]), + "telegram": SnapshotAnyCodable([ + "configured": false, + "running": false, + "lastError": "bot missing", + "probe": [ + "ok": false, + "status": 403, + "error": "unauthorized", + "elapsedMs": 120, + ], + "lastProbeAt": 1_700_000_100_000, + ]), + "signal": SnapshotAnyCodable([ + "configured": false, + "baseUrl": "http://127.0.0.1:8080", + "running": false, + "lastError": "not configured", + "probe": [ + "ok": false, + "status": 404, + "error": "unreachable", + "elapsedMs": 200, + ], + "lastProbeAt": 1_700_000_200_000, + ]), + "imessage": SnapshotAnyCodable([ + "configured": false, + "running": false, + "lastError": "not configured", + "cliPath": "imsg", + "probe": ["ok": false, "error": "imsg not found (imsg)"], + "lastProbeAt": 1_700_000_200_000, + ]), + ], + channelAccounts: [:], + channelDefaultAccountId: [ + "whatsapp": "default", + "telegram": "default", + "signal": "default", + "imessage": "default", + ]) + + let view = ChannelsSettings(store: store) + _ = view.body + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift new file mode 100644 index 00000000..d8470679 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift @@ -0,0 +1,218 @@ +import Darwin +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct CommandResolverTests { + private func makeDefaults() -> UserDefaults { + // Use a unique suite to avoid cross-suite concurrency on UserDefaults.standard. + UserDefaults(suiteName: "CommandResolverTests.\(UUID().uuidString)")! + } + + private func makeTempDir() throws -> URL { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func makeExec(at path: URL) throws { + try FileManager().createDirectory( + at: path.deletingLastPathComponent(), + withIntermediateDirectories: true) + FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8)) + try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) + } + + @Test func prefersOpenClawBinary() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw") + try self.makeExec(at: openclawPath) + + let cmd = CommandResolver.openclawCommand(subcommand: "gateway", defaults: defaults, configRoot: [:]) + #expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"])) + } + + @Test func fallsBackToNodeAndScript() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let nodePath = tmp.appendingPathComponent("node_modules/.bin/node") + let scriptPath = tmp.appendingPathComponent("bin/openclaw.js") + try self.makeExec(at: nodePath) + try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8) + try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path) + try self.makeExec(at: scriptPath) + + let cmd = CommandResolver.openclawCommand( + subcommand: "rpc", + defaults: defaults, + configRoot: [:], + searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path]) + + #expect(cmd.count >= 3) + if cmd.count >= 3 { + #expect(cmd[0] == nodePath.path) + #expect(cmd[1] == scriptPath.path) + #expect(cmd[2] == "rpc") + } + } + + @Test func prefersOpenClawBinaryOverPnpm() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let binDir = tmp.appendingPathComponent("bin") + let openclawPath = binDir.appendingPathComponent("openclaw") + let pnpmPath = binDir.appendingPathComponent("pnpm") + try self.makeExec(at: openclawPath) + try self.makeExec(at: pnpmPath) + + let cmd = CommandResolver.openclawCommand( + subcommand: "rpc", + defaults: defaults, + configRoot: [:], + searchPaths: [binDir.path]) + + #expect(cmd.prefix(2).elementsEqual([openclawPath.path, "rpc"])) + } + + @Test func usesOpenClawBinaryWithoutNodeRuntime() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let binDir = tmp.appendingPathComponent("bin") + let openclawPath = binDir.appendingPathComponent("openclaw") + try self.makeExec(at: openclawPath) + + let cmd = CommandResolver.openclawCommand( + subcommand: "gateway", + defaults: defaults, + configRoot: [:], + searchPaths: [binDir.path]) + + #expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"])) + } + + @Test func fallsBackToPnpm() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") + try self.makeExec(at: pnpmPath) + + let cmd = CommandResolver.openclawCommand( + subcommand: "rpc", + defaults: defaults, + configRoot: [:], + searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path]) + + #expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "openclaw", "rpc"])) + } + + @Test func pnpmKeepsExtraArgsAfterSubcommand() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") + try self.makeExec(at: pnpmPath) + + let cmd = CommandResolver.openclawCommand( + subcommand: "health", + extraArgs: ["--json", "--timeout", "5"], + defaults: defaults, + configRoot: [:], + searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path]) + + #expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "openclaw", "health", "--json"])) + #expect(cmd.suffix(2).elementsEqual(["--timeout", "5"])) + } + + @Test func preferredPathsStartWithProjectNodeBins() async throws { + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let first = CommandResolver.preferredPaths().first + #expect(first == tmp.appendingPathComponent("node_modules/.bin").path) + } + + @Test func buildsSSHCommandForRemoteMode() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) + defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey) + defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey) + defaults.set("/srv/openclaw", forKey: remoteProjectRootKey) + + let cmd = CommandResolver.openclawCommand( + subcommand: "status", + extraArgs: ["--json"], + defaults: defaults, + configRoot: [:]) + + #expect(cmd.first == "/usr/bin/ssh") + if let marker = cmd.firstIndex(of: "--") { + #expect(cmd[marker + 1] == "openclaw@example.com") + } else { + #expect(Bool(false)) + } + #expect(cmd.contains("-i")) + #expect(cmd.contains("/tmp/id_ed25519")) + if let script = cmd.last { + #expect(script.contains("PRJ='/srv/openclaw'")) + #expect(script.contains("cd \"$PRJ\"")) + #expect(script.contains("openclaw")) + #expect(script.contains("status")) + #expect(script.contains("--json")) + #expect(script.contains("CLI=")) + } + } + + @Test func rejectsUnsafeSSHTargets() async throws { + #expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil) + #expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil) + #expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222) + } + + @Test func configRootLocalOverridesRemoteDefaults() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) + defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw") + try self.makeExec(at: openclawPath) + + let cmd = CommandResolver.openclawCommand( + subcommand: "daemon", + defaults: defaults, + configRoot: ["gateway": ["mode": "local"]]) + + #expect(cmd.first == openclawPath.path) + #expect(cmd.count >= 2) + if cmd.count >= 2 { + #expect(cmd[1] == "daemon") + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift new file mode 100644 index 00000000..50f72241 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift @@ -0,0 +1,68 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct ConfigStoreTests { + @Test func loadUsesRemoteInRemoteMode() async { + var localHit = false + var remoteHit = false + await ConfigStore._testSetOverrides(.init( + isRemoteMode: { true }, + loadLocal: { localHit = true; return ["local": true] }, + loadRemote: { remoteHit = true; return ["remote": true] })) + + let result = await ConfigStore.load() + + await ConfigStore._testClearOverrides() + #expect(remoteHit) + #expect(!localHit) + #expect(result["remote"] as? Bool == true) + } + + @Test func loadUsesLocalInLocalMode() async { + var localHit = false + var remoteHit = false + await ConfigStore._testSetOverrides(.init( + isRemoteMode: { false }, + loadLocal: { localHit = true; return ["local": true] }, + loadRemote: { remoteHit = true; return ["remote": true] })) + + let result = await ConfigStore.load() + + await ConfigStore._testClearOverrides() + #expect(localHit) + #expect(!remoteHit) + #expect(result["local"] as? Bool == true) + } + + @Test func saveRoutesToRemoteInRemoteMode() async throws { + var localHit = false + var remoteHit = false + await ConfigStore._testSetOverrides(.init( + isRemoteMode: { true }, + saveLocal: { _ in localHit = true }, + saveRemote: { _ in remoteHit = true })) + + try await ConfigStore.save(["remote": true]) + + await ConfigStore._testClearOverrides() + #expect(remoteHit) + #expect(!localHit) + } + + @Test func saveRoutesToLocalInLocalMode() async throws { + var localHit = false + var remoteHit = false + await ConfigStore._testSetOverrides(.init( + isRemoteMode: { false }, + saveLocal: { _ in localHit = true }, + saveRemote: { _ in remoteHit = true })) + + try await ConfigStore.save(["local": true]) + + await ConfigStore._testClearOverrides() + #expect(localHit) + #expect(!remoteHit) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CoverageDumpTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CoverageDumpTests.swift new file mode 100644 index 00000000..27847744 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CoverageDumpTests.swift @@ -0,0 +1,24 @@ +import Darwin +import Foundation +import Testing + +@Suite(.serialized) +struct CoverageDumpTests { + @Test func periodicallyFlushCoverage() async { + guard ProcessInfo.processInfo.environment["LLVM_PROFILE_FILE"] != nil else { return } + guard let writeProfile = resolveProfileWriteFile() else { return } + let deadline = Date().addingTimeInterval(4) + while Date() < deadline { + _ = writeProfile() + try? await Task.sleep(nanoseconds: 250_000_000) + } + } +} + +private typealias ProfileWriteFn = @convention(c) () -> Int32 + +private func resolveProfileWriteFile() -> ProfileWriteFn? { + let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "__llvm_profile_write_file") + guard let symbol else { return nil } + return unsafeBitCast(symbol, to: ProfileWriteFn.self) +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift new file mode 100644 index 00000000..41baee63 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift @@ -0,0 +1,37 @@ +import AppKit +import Testing +@testable import OpenClaw + +@Suite +@MainActor +struct CritterIconRendererTests { + @Test func makeIconRendersExpectedSize() { + let image = CritterIconRenderer.makeIcon( + blink: 0.25, + legWiggle: 0.5, + earWiggle: 0.2, + earScale: 1, + earHoles: true, + badge: nil) + + #expect(image.size.width == 18) + #expect(image.size.height == 18) + #expect(image.tiffRepresentation != nil) + } + + @Test func makeIconRendersWithBadge() { + let image = CritterIconRenderer.makeIcon( + blink: 0, + legWiggle: 0, + earWiggle: 0, + earScale: 1, + earHoles: false, + badge: .init(symbolName: "terminal.fill", prominence: .primary)) + + #expect(image.tiffRepresentation != nil) + } + + @Test func critterStatusLabelExercisesHelpers() async { + await CritterStatusLabel.exerciseForTesting() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift new file mode 100644 index 00000000..ed8315b7 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift @@ -0,0 +1,93 @@ +import SwiftUI +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct CronJobEditorSmokeTests { + @Test func statusPillBuildsBody() { + _ = StatusPill(text: "ok", tint: .green).body + _ = StatusPill(text: "disabled", tint: .secondary).body + } + + @Test func cronJobEditorBuildsBodyForNewJob() { + let channelsStore = ChannelsStore(isPreview: true) + let view = CronJobEditor( + job: nil, + isSaving: .constant(false), + error: .constant(nil), + channelsStore: channelsStore, + onCancel: {}, + onSave: { _ in }) + _ = view.body + } + + @Test func cronJobEditorBuildsBodyForExistingJob() { + let channelsStore = ChannelsStore(isPreview: true) + let job = CronJob( + id: "job-1", + agentId: "ops", + name: "Daily summary", + description: nil, + enabled: true, + deleteAfterRun: nil, + createdAtMs: 1_700_000_000_000, + updatedAtMs: 1_700_000_000_000, + schedule: .every(everyMs: 3_600_000, anchorMs: 1_700_000_000_000), + sessionTarget: .isolated, + wakeMode: .nextHeartbeat, + payload: .agentTurn( + message: "Summarize the last day", + thinking: "low", + timeoutSeconds: 120, + deliver: nil, + channel: nil, + to: nil, + bestEffortDeliver: nil), + delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true), + state: CronJobState( + nextRunAtMs: 1_700_000_100_000, + runningAtMs: nil, + lastRunAtMs: 1_700_000_050_000, + lastStatus: "ok", + lastError: nil, + lastDurationMs: 1000)) + + let view = CronJobEditor( + job: job, + isSaving: .constant(false), + error: .constant(nil), + channelsStore: channelsStore, + onCancel: {}, + onSave: { _ in }) + _ = view.body + } + + @Test func cronJobEditorExercisesBuilders() { + let channelsStore = ChannelsStore(isPreview: true) + var view = CronJobEditor( + job: nil, + isSaving: .constant(false), + error: .constant(nil), + channelsStore: channelsStore, + onCancel: {}, + onSave: { _ in }) + view.exerciseForTesting() + } + + @Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws { + let channelsStore = ChannelsStore(isPreview: true) + let view = CronJobEditor( + job: nil, + isSaving: .constant(false), + error: .constant(nil), + channelsStore: channelsStore, + onCancel: {}, + onSave: { _ in }) + + var root: [String: Any] = [:] + view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true) + let raw = root["deleteAfterRun"] as? Bool + #expect(raw == true) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift new file mode 100644 index 00000000..f90ac25a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite +struct CronModelsTests { + @Test func scheduleAtEncodesAndDecodes() throws { + let schedule = CronSchedule.at(at: "2026-02-03T18:00:00Z") + let data = try JSONEncoder().encode(schedule) + let decoded = try JSONDecoder().decode(CronSchedule.self, from: data) + #expect(decoded == schedule) + } + + @Test func scheduleAtDecodesLegacyAtMs() throws { + let json = """ + {"kind":"at","atMs":1700000000000} + """ + let decoded = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8)) + if case let .at(at) = decoded { + #expect(at.hasPrefix("2023-")) + } else { + #expect(Bool(false)) + } + } + + @Test func scheduleEveryEncodesAndDecodesWithAnchor() throws { + let schedule = CronSchedule.every(everyMs: 5000, anchorMs: 10000) + let data = try JSONEncoder().encode(schedule) + let decoded = try JSONDecoder().decode(CronSchedule.self, from: data) + #expect(decoded == schedule) + } + + @Test func scheduleCronEncodesAndDecodesWithTimezone() throws { + let schedule = CronSchedule.cron(expr: "*/5 * * * *", tz: "Europe/Vienna") + let data = try JSONEncoder().encode(schedule) + let decoded = try JSONDecoder().decode(CronSchedule.self, from: data) + #expect(decoded == schedule) + } + + @Test func payloadAgentTurnEncodesAndDecodes() throws { + let payload = CronPayload.agentTurn( + message: "hello", + thinking: "low", + timeoutSeconds: 15, + deliver: true, + channel: "whatsapp", + to: "+15551234567", + bestEffortDeliver: false) + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(CronPayload.self, from: data) + #expect(decoded == payload) + } + + @Test func jobEncodesAndDecodesDeleteAfterRun() throws { + let job = CronJob( + id: "job-1", + agentId: nil, + name: "One-shot", + description: nil, + enabled: true, + deleteAfterRun: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: .at(at: "2026-02-03T18:00:00Z"), + sessionTarget: .main, + wakeMode: .now, + payload: .systemEvent(text: "ping"), + delivery: nil, + state: CronJobState()) + let data = try JSONEncoder().encode(job) + let decoded = try JSONDecoder().decode(CronJob.self, from: data) + #expect(decoded.deleteAfterRun == true) + } + + @Test func scheduleDecodeRejectsUnknownKind() { + let json = """ + {"kind":"wat","at":"2026-02-03T18:00:00Z"} + """ + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8)) + } + } + + @Test func payloadDecodeRejectsUnknownKind() { + let json = """ + {"kind":"wat","text":"hello"} + """ + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(CronPayload.self, from: Data(json.utf8)) + } + } + + @Test func displayNameTrimsWhitespaceAndFallsBack() { + let base = CronJob( + id: "x", + agentId: nil, + name: " hello ", + description: nil, + enabled: true, + deleteAfterRun: nil, + createdAtMs: 0, + updatedAtMs: 0, + schedule: .at(at: "2026-02-03T18:00:00Z"), + sessionTarget: .main, + wakeMode: .now, + payload: .systemEvent(text: "hi"), + delivery: nil, + state: CronJobState()) + #expect(base.displayName == "hello") + + var unnamed = base + unnamed.name = " " + #expect(unnamed.displayName == "Untitled job") + } + + @Test func nextRunDateAndLastRunDateDeriveFromState() { + let job = CronJob( + id: "x", + agentId: nil, + name: "t", + description: nil, + enabled: true, + deleteAfterRun: nil, + createdAtMs: 0, + updatedAtMs: 0, + schedule: .at(at: "2026-02-03T18:00:00Z"), + sessionTarget: .main, + wakeMode: .now, + payload: .systemEvent(text: "hi"), + delivery: nil, + state: CronJobState( + nextRunAtMs: 1_700_000_000_000, + runningAtMs: nil, + lastRunAtMs: 1_700_000_050_000, + lastStatus: nil, + lastError: nil, + lastDurationMs: nil)) + #expect(job.nextRunDate == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(job.lastRunDate == Date(timeIntervalSince1970: 1_700_000_050)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift new file mode 100644 index 00000000..ee537f1b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift @@ -0,0 +1,77 @@ +import OpenClawKit +import Testing +@testable import OpenClaw + +@Suite struct DeepLinkAgentPolicyTests { + @Test func validateMessageForHandleRejectsTooLongWhenUnkeyed() { + let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1) + let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: false) + switch res { + case let .failure(error): + #expect( + error == .messageTooLongForConfirmation( + max: DeepLinkAgentPolicy.maxUnkeyedConfirmChars, + actual: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1)) + case .success: + Issue.record("expected failure, got success") + } + } + + @Test func validateMessageForHandleAllowsTooLongWhenKeyed() { + let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1) + let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: true) + switch res { + case .success: + break + case let .failure(error): + Issue.record("expected success, got failure: \(error)") + } + } + + @Test func effectiveDeliveryIgnoresDeliveryFieldsWhenUnkeyed() { + let link = AgentDeepLink( + message: "Hello", + sessionKey: "s", + thinking: "low", + deliver: true, + to: "+15551234567", + channel: "whatsapp", + timeoutSeconds: 10, + key: nil) + let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: false) + #expect(res.deliver == false) + #expect(res.to == nil) + #expect(res.channel == .last) + } + + @Test func effectiveDeliveryHonorsDeliverForDeliverableChannelsWhenKeyed() { + let link = AgentDeepLink( + message: "Hello", + sessionKey: "s", + thinking: "low", + deliver: true, + to: " +15551234567 ", + channel: "whatsapp", + timeoutSeconds: 10, + key: "secret") + let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: true) + #expect(res.deliver == true) + #expect(res.to == "+15551234567") + #expect(res.channel == .whatsapp) + } + + @Test func effectiveDeliveryStillBlocksWebChatDeliveryWhenKeyed() { + let link = AgentDeepLink( + message: "Hello", + sessionKey: "s", + thinking: "low", + deliver: true, + to: "+15551234567", + channel: "webchat", + timeoutSeconds: 10, + key: "secret") + let res = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: true) + #expect(res.deliver == false) + #expect(res.channel == .webchat) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/DeviceModelCatalogTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/DeviceModelCatalogTests.swift new file mode 100644 index 00000000..7d5f1ef6 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/DeviceModelCatalogTests.swift @@ -0,0 +1,41 @@ +import Testing +@testable import OpenClaw + +@Suite +struct DeviceModelCatalogTests { + @Test + func symbolPrefersModelIdentifierPrefixes() { + #expect(DeviceModelCatalog + .symbol(deviceFamily: "iPad", modelIdentifier: "iPad16,6", friendlyName: nil) == "ipad") + #expect(DeviceModelCatalog + .symbol(deviceFamily: "iPhone", modelIdentifier: "iPhone17,3", friendlyName: nil) == "iphone") + } + + @Test + func symbolUsesFriendlyNameForMacVariants() { + #expect(DeviceModelCatalog.symbol( + deviceFamily: "Mac", + modelIdentifier: "Mac99,1", + friendlyName: "Mac Studio (2025)") == "macstudio") + #expect(DeviceModelCatalog.symbol( + deviceFamily: "Mac", + modelIdentifier: "Mac99,2", + friendlyName: "Mac mini (2024)") == "macmini") + #expect(DeviceModelCatalog.symbol( + deviceFamily: "Mac", + modelIdentifier: "Mac99,3", + friendlyName: "MacBook Pro (14-inch, 2024)") == "laptopcomputer") + } + + @Test + func symbolFallsBackToDeviceFamily() { + #expect(DeviceModelCatalog.symbol(deviceFamily: "Android", modelIdentifier: "", friendlyName: nil) == "android") + #expect(DeviceModelCatalog.symbol(deviceFamily: "Linux", modelIdentifier: "", friendlyName: nil) == "cpu") + } + + @Test + func presentationUsesBundledModelMappings() { + let presentation = DeviceModelCatalog.presentation(deviceFamily: "iPhone", modelIdentifier: "iPhone1,1") + #expect(presentation?.title == "iPhone") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift new file mode 100644 index 00000000..3b27740d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -0,0 +1,249 @@ +import Foundation +import Testing +@testable import OpenClaw + +/// These cases cover optional `security=allowlist` behavior. +/// Default install posture remains deny-by-default for exec on macOS node-host. +struct ExecAllowlistTests { + private struct ShellParserParityFixture: Decodable { + struct Case: Decodable { + let id: String + let command: String + let ok: Bool + let executables: [String] + } + + let cases: [Case] + } + + private struct WrapperResolutionParityFixture: Decodable { + struct Case: Decodable { + let id: String + let argv: [String] + let expectedRawExecutable: String? + } + + let cases: [Case] + } + + private static func loadShellParserParityCases() throws -> [ShellParserParityFixture.Case] { + let fixtureURL = self.fixtureURL(filename: "exec-allowlist-shell-parser-parity.json") + let data = try Data(contentsOf: fixtureURL) + let fixture = try JSONDecoder().decode(ShellParserParityFixture.self, from: data) + return fixture.cases + } + + private static func loadWrapperResolutionParityCases() throws -> [WrapperResolutionParityFixture.Case] { + let fixtureURL = self.fixtureURL(filename: "exec-wrapper-resolution-parity.json") + let data = try Data(contentsOf: fixtureURL) + let fixture = try JSONDecoder().decode(WrapperResolutionParityFixture.self, from: data) + return fixture.cases + } + + private static func fixtureURL(filename: String) -> URL { + var repoRoot = URL(fileURLWithPath: #filePath) + for _ in 0..<5 { + repoRoot.deleteLastPathComponent() + } + return repoRoot + .appendingPathComponent("test") + .appendingPathComponent("fixtures") + .appendingPathComponent(filename) + } + + @Test func matchUsesResolvedPath() { + let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg") + let resolution = ExecCommandResolution( + rawExecutable: "rg", + resolvedPath: "/opt/homebrew/bin/rg", + executableName: "rg", + cwd: nil) + let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) + #expect(match?.pattern == entry.pattern) + } + + @Test func matchIgnoresBasenamePattern() { + let entry = ExecAllowlistEntry(pattern: "rg") + let resolution = ExecCommandResolution( + rawExecutable: "rg", + resolvedPath: "/opt/homebrew/bin/rg", + executableName: "rg", + cwd: nil) + let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) + #expect(match == nil) + } + + @Test func matchIgnoresBasenameForRelativeExecutable() { + let entry = ExecAllowlistEntry(pattern: "echo") + let resolution = ExecCommandResolution( + rawExecutable: "./echo", + resolvedPath: "/tmp/oc-basename/echo", + executableName: "echo", + cwd: "/tmp/oc-basename") + let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) + #expect(match == nil) + } + + @Test func matchIsCaseInsensitive() { + let entry = ExecAllowlistEntry(pattern: "/OPT/HOMEBREW/BIN/RG") + let resolution = ExecCommandResolution( + rawExecutable: "rg", + resolvedPath: "/opt/homebrew/bin/rg", + executableName: "rg", + cwd: nil) + let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) + #expect(match?.pattern == entry.pattern) + } + + @Test func matchSupportsGlobStar() { + let entry = ExecAllowlistEntry(pattern: "/opt/**/rg") + let resolution = ExecCommandResolution( + rawExecutable: "rg", + resolvedPath: "/opt/homebrew/bin/rg", + executableName: "rg", + cwd: nil) + let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) + #expect(match?.pattern == entry.pattern) + } + + @Test func resolveForAllowlistSplitsShellChains() { + let command = ["/bin/sh", "-lc", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 2) + #expect(resolutions[0].executableName == "echo") + #expect(resolutions[1].executableName == "touch") + } + + @Test func resolveForAllowlistKeepsQuotedOperatorsInSingleSegment() { + let command = ["/bin/sh", "-lc", "echo \"a && b\""] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo \"a && b\"", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 1) + #expect(resolutions[0].executableName == "echo") + } + + @Test func resolveForAllowlistFailsClosedOnCommandSubstitution() { + let command = ["/bin/sh", "-lc", "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.isEmpty) + } + + @Test func resolveForAllowlistFailsClosedOnQuotedCommandSubstitution() { + let command = ["/bin/sh", "-lc", "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\""] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\"", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.isEmpty) + } + + @Test func resolveForAllowlistFailsClosedOnQuotedBackticks() { + let command = ["/bin/sh", "-lc", "echo \"ok `/usr/bin/id`\""] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo \"ok `/usr/bin/id`\"", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.isEmpty) + } + + @Test func resolveForAllowlistMatchesSharedShellParserFixture() throws { + let fixtures = try Self.loadShellParserParityCases() + for fixture in fixtures { + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: ["/bin/sh", "-lc", fixture.command], + rawCommand: fixture.command, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + + #expect(!resolutions.isEmpty == fixture.ok) + if fixture.ok { + let executables = resolutions.map { $0.executableName.lowercased() } + let expected = fixture.executables.map { $0.lowercased() } + #expect(executables == expected) + } + } + } + + @Test func resolveMatchesSharedWrapperResolutionFixture() throws { + let fixtures = try Self.loadWrapperResolutionParityCases() + for fixture in fixtures { + let resolution = ExecCommandResolution.resolve( + command: fixture.argv, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolution?.rawExecutable == fixture.expectedRawExecutable) + } + } + + @Test func resolveForAllowlistTreatsPlainShInvocationAsDirectExec() { + let command = ["/bin/sh", "./script.sh"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: "/tmp", + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 1) + #expect(resolutions[0].executableName == "sh") + } + + @Test func resolveForAllowlistUnwrapsEnvShellWrapperChains() { + let command = ["/usr/bin/env", "/bin/sh", "-lc", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 2) + #expect(resolutions[0].executableName == "echo") + #expect(resolutions[1].executableName == "touch") + } + + @Test func resolveForAllowlistUnwrapsEnvToEffectiveDirectExecutable() { + let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 1) + #expect(resolutions[0].resolvedPath == "/usr/bin/printf") + #expect(resolutions[0].executableName == "printf") + } + + @Test func matchAllRequiresEverySegmentToMatch() { + let first = ExecCommandResolution( + rawExecutable: "echo", + resolvedPath: "/usr/bin/echo", + executableName: "echo", + cwd: nil) + let second = ExecCommandResolution( + rawExecutable: "/usr/bin/touch", + resolvedPath: "/usr/bin/touch", + executableName: "touch", + cwd: nil) + let resolutions = [first, second] + + let partial = ExecAllowlistMatcher.matchAll( + entries: [ExecAllowlistEntry(pattern: "/usr/bin/echo")], + resolutions: resolutions) + #expect(partial.isEmpty) + + let full = ExecAllowlistMatcher.matchAll( + entries: [ExecAllowlistEntry(pattern: "/USR/BIN/ECHO"), ExecAllowlistEntry(pattern: "/usr/bin/touch")], + resolutions: resolutions) + #expect(full.count == 2) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift new file mode 100644 index 00000000..455b4296 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct ExecApprovalHelpersTests { + @Test func parseDecisionTrimsAndRejectsInvalid() { + #expect(ExecApprovalHelpers.parseDecision("allow-once") == .allowOnce) + #expect(ExecApprovalHelpers.parseDecision(" allow-always ") == .allowAlways) + #expect(ExecApprovalHelpers.parseDecision("deny") == .deny) + #expect(ExecApprovalHelpers.parseDecision("") == nil) + #expect(ExecApprovalHelpers.parseDecision("nope") == nil) + } + + @Test func allowlistPatternPrefersResolution() { + let resolved = ExecCommandResolution( + rawExecutable: "rg", + resolvedPath: "/opt/homebrew/bin/rg", + executableName: "rg", + cwd: nil) + #expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: resolved) == resolved.resolvedPath) + + let rawOnly = ExecCommandResolution( + rawExecutable: "rg", + resolvedPath: nil, + executableName: "rg", + cwd: nil) + #expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: rawOnly) == "rg") + #expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: nil) == "rg") + #expect(ExecApprovalHelpers.allowlistPattern(command: [], resolution: nil) == nil) + } + + @Test func validateAllowlistPatternReturnsReasons() { + #expect(ExecApprovalHelpers.isPathPattern("/usr/bin/rg")) + #expect(ExecApprovalHelpers.isPathPattern(" ~/bin/rg ")) + #expect(!ExecApprovalHelpers.isPathPattern("rg")) + + if case .invalid(let reason) = ExecApprovalHelpers.validateAllowlistPattern(" ") { + #expect(reason == .empty) + } else { + Issue.record("Expected empty pattern rejection") + } + + if case .invalid(let reason) = ExecApprovalHelpers.validateAllowlistPattern("echo") { + #expect(reason == .missingPathComponent) + } else { + Issue.record("Expected basename pattern rejection") + } + } + + @Test func requiresAskMatchesPolicy() { + let entry = ExecAllowlistEntry(pattern: "/bin/ls", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: nil) + #expect(ExecApprovalHelpers.requiresAsk( + ask: .always, + security: .deny, + allowlistMatch: nil, + skillAllow: false)) + #expect(ExecApprovalHelpers.requiresAsk( + ask: .onMiss, + security: .allowlist, + allowlistMatch: nil, + skillAllow: false)) + #expect(!ExecApprovalHelpers.requiresAsk( + ask: .onMiss, + security: .allowlist, + allowlistMatch: entry, + skillAllow: false)) + #expect(!ExecApprovalHelpers.requiresAsk( + ask: .onMiss, + security: .allowlist, + allowlistMatch: nil, + skillAllow: true)) + #expect(!ExecApprovalHelpers.requiresAsk( + ask: .off, + security: .allowlist, + allowlistMatch: nil, + skillAllow: false)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift new file mode 100644 index 00000000..4bc75405 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift @@ -0,0 +1,56 @@ +import Testing +@testable import OpenClaw + +@Suite +@MainActor +struct ExecApprovalsGatewayPrompterTests { + @Test func sessionMatchPrefersActiveSession() { + let matches = ExecApprovalsGatewayPrompter._testShouldPresent( + mode: .remote, + activeSession: " main ", + requestSession: "main", + lastInputSeconds: nil) + #expect(matches) + + let mismatched = ExecApprovalsGatewayPrompter._testShouldPresent( + mode: .remote, + activeSession: "other", + requestSession: "main", + lastInputSeconds: 0) + #expect(!mismatched) + } + + @Test func sessionFallbackUsesRecentActivity() { + let recent = ExecApprovalsGatewayPrompter._testShouldPresent( + mode: .remote, + activeSession: nil, + requestSession: "main", + lastInputSeconds: 10, + thresholdSeconds: 120) + #expect(recent) + + let stale = ExecApprovalsGatewayPrompter._testShouldPresent( + mode: .remote, + activeSession: nil, + requestSession: "main", + lastInputSeconds: 200, + thresholdSeconds: 120) + #expect(!stale) + } + + @Test func defaultBehaviorMatchesMode() { + let local = ExecApprovalsGatewayPrompter._testShouldPresent( + mode: .local, + activeSession: nil, + requestSession: nil, + lastInputSeconds: 400) + #expect(local) + + let remote = ExecApprovalsGatewayPrompter._testShouldPresent( + mode: .remote, + activeSession: nil, + requestSession: nil, + lastInputSeconds: 400) + #expect(!remote) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift new file mode 100644 index 00000000..fa9eef87 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift @@ -0,0 +1,75 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) +struct ExecApprovalsStoreRefactorTests { + @Test + func ensureFileSkipsRewriteWhenUnchanged() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager().removeItem(at: stateDir) } + + try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) { + _ = ExecApprovalsStore.ensureFile() + let url = ExecApprovalsStore.fileURL() + let firstWriteDate = try Self.modificationDate(at: url) + + try await Task.sleep(nanoseconds: 1_100_000_000) + _ = ExecApprovalsStore.ensureFile() + let secondWriteDate = try Self.modificationDate(at: url) + + #expect(firstWriteDate == secondWriteDate) + } + } + + @Test + func updateAllowlistReportsRejectedBasenamePattern() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager().removeItem(at: stateDir) } + + await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) { + let rejected = ExecApprovalsStore.updateAllowlist( + agentId: "main", + allowlist: [ + ExecAllowlistEntry(pattern: "echo"), + ExecAllowlistEntry(pattern: "/bin/echo"), + ]) + #expect(rejected.count == 1) + #expect(rejected.first?.reason == .missingPathComponent) + #expect(rejected.first?.pattern == "echo") + + let resolved = ExecApprovalsStore.resolve(agentId: "main") + #expect(resolved.allowlist.map(\.pattern) == ["/bin/echo"]) + } + } + + @Test + func updateAllowlistMigratesLegacyPatternFromResolvedPath() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager().removeItem(at: stateDir) } + + await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) { + let rejected = ExecApprovalsStore.updateAllowlist( + agentId: "main", + allowlist: [ + ExecAllowlistEntry(pattern: "echo", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: " /usr/bin/echo "), + ]) + #expect(rejected.isEmpty) + + let resolved = ExecApprovalsStore.resolve(agentId: "main") + #expect(resolved.allowlist.map(\.pattern) == ["/usr/bin/echo"]) + } + } + + private static func modificationDate(at url: URL) throws -> Date { + let attributes = try FileManager().attributesOfItem(atPath: url.path) + guard let date = attributes[.modificationDate] as? Date else { + struct MissingDateError: Error {} + throw MissingDateError() + } + return date + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift new file mode 100644 index 00000000..64ef6a21 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import OpenClaw + +struct ExecHostRequestEvaluatorTests { + @Test func validateRequestRejectsEmptyCommand() { + let request = ExecHostRequest(command: [], rawCommand: nil, cwd: nil, env: nil, timeoutMs: nil, needsScreenRecording: nil, agentId: nil, sessionKey: nil, approvalDecision: nil) + switch ExecHostRequestEvaluator.validateRequest(request) { + case .success: + Issue.record("expected invalid request") + case .failure(let error): + #expect(error.code == "INVALID_REQUEST") + #expect(error.message == "command required") + } + } + + @Test func evaluateRequiresPromptOnAllowlistMissWithoutDecision() { + let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false) + let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: nil) + switch decision { + case .requiresPrompt: + break + case .allow: + Issue.record("expected prompt requirement") + case .deny(let error): + Issue.record("unexpected deny: \(error.message)") + } + } + + @Test func evaluateAllowsAllowOnceDecisionOnAllowlistMiss() { + let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false) + let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .allowOnce) + switch decision { + case .allow(let approvedByAsk): + #expect(approvedByAsk) + case .requiresPrompt: + Issue.record("expected allow decision") + case .deny(let error): + Issue.record("unexpected deny: \(error.message)") + } + } + + @Test func evaluateDeniesOnExplicitDenyDecision() { + let context = Self.makeContext(security: .full, ask: .off, allowlistSatisfied: true, skillAllow: false) + let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .deny) + switch decision { + case .deny(let error): + #expect(error.reason == "user-denied") + case .requiresPrompt: + Issue.record("expected deny decision") + case .allow: + Issue.record("expected deny decision") + } + } + + private static func makeContext( + security: ExecSecurity, + ask: ExecAsk, + allowlistSatisfied: Bool, + skillAllow: Bool) -> ExecApprovalEvaluation + { + ExecApprovalEvaluation( + command: ["/usr/bin/echo", "hi"], + displayCommand: "/usr/bin/echo hi", + agentId: nil, + security: security, + ask: ask, + env: [:], + resolution: nil, + allowlistResolutions: [], + allowlistMatches: [], + allowlistSatisfied: allowlistSatisfied, + allowlistMatch: nil, + skillAllow: skillAllow) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift new file mode 100644 index 00000000..ed3773a4 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import OpenClaw + +private struct SystemRunCommandContractFixture: Decodable { + let cases: [SystemRunCommandContractCase] +} + +private struct SystemRunCommandContractCase: Decodable { + let name: String + let command: [String] + let rawCommand: String? + let expected: SystemRunCommandContractExpected +} + +private struct SystemRunCommandContractExpected: Decodable { + let valid: Bool + let displayCommand: String? + let errorContains: String? +} + +struct ExecSystemRunCommandValidatorTests { + @Test func matchesSharedSystemRunCommandContractFixture() throws { + for entry in try Self.loadContractCases() { + let result = ExecSystemRunCommandValidator.resolve(command: entry.command, rawCommand: entry.rawCommand) + + if !entry.expected.valid { + switch result { + case .ok(let resolved): + Issue.record("\(entry.name): expected invalid result, got displayCommand=\(resolved.displayCommand)") + case .invalid(let message): + if let expected = entry.expected.errorContains { + #expect( + message.contains(expected), + "\(entry.name): expected error containing \(expected), got \(message)") + } + } + continue + } + + switch result { + case .ok(let resolved): + #expect( + resolved.displayCommand == entry.expected.displayCommand, + "\(entry.name): unexpected display command") + case .invalid(let message): + Issue.record("\(entry.name): unexpected invalid result: \(message)") + } + } + } + + private static func loadContractCases() throws -> [SystemRunCommandContractCase] { + let fixtureURL = try self.findContractFixtureURL() + let data = try Data(contentsOf: fixtureURL) + let decoded = try JSONDecoder().decode(SystemRunCommandContractFixture.self, from: data) + return decoded.cases + } + + private static func findContractFixtureURL() throws -> URL { + var cursor = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0..<8 { + let candidate = cursor + .appendingPathComponent("test") + .appendingPathComponent("fixtures") + .appendingPathComponent("system-run-command-contract.json") + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + cursor.deleteLastPathComponent() + } + throw NSError( + domain: "ExecSystemRunCommandValidatorTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "missing shared system-run command contract fixture"]) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/FileHandleLegacyAPIGuardTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/FileHandleLegacyAPIGuardTests.swift new file mode 100644 index 00000000..a6836aaa --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/FileHandleLegacyAPIGuardTests.swift @@ -0,0 +1,155 @@ +import Foundation +import Testing + +@Suite struct FileHandleLegacyAPIGuardTests { + @Test func sourcesAvoidLegacyNonThrowingFileHandleReadAPIs() throws { + let testFile = URL(fileURLWithPath: #filePath) + let packageRoot = testFile + .deletingLastPathComponent() // OpenClawIPCTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // apps/macos + + let sourcesRoot = packageRoot.appendingPathComponent("Sources") + let swiftFiles = try Self.swiftFiles(under: sourcesRoot) + + var offenders: [String] = [] + for file in swiftFiles { + let raw = try String(contentsOf: file, encoding: .utf8) + let stripped = Self.stripCommentsAndStrings(from: raw) + + if stripped.contains("readDataToEndOfFile(") || stripped.contains(".availableData") { + offenders.append(file.path) + } + } + + if !offenders.isEmpty { + let message = "Found legacy FileHandle reads in:\n" + offenders.joined(separator: "\n") + throw NSError( + domain: "FileHandleLegacyAPIGuardTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: message]) + } + } + + private static func swiftFiles(under root: URL) throws -> [URL] { + let fm = FileManager() + guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else { + return [] + } + + var files: [URL] = [] + for case let url as URL in enumerator { + guard url.pathExtension == "swift" else { continue } + files.append(url) + } + return files + } + + private static func stripCommentsAndStrings(from source: String) -> String { + enum Mode { + case code + case lineComment + case blockComment(depth: Int) + case string(quoteCount: Int) // 1 = ", 3 = """ + } + + var mode: Mode = .code + var out = "" + out.reserveCapacity(source.count) + + var index = source.startIndex + func peek(_ offset: Int) -> Character? { + guard + let i = source.index(index, offsetBy: offset, limitedBy: source.endIndex), + i < source.endIndex + else { return nil } + return source[i] + } + + while index < source.endIndex { + let ch = source[index] + + switch mode { + case .code: + if ch == "/", peek(1) == "/" { + out.append(" ") + index = source.index(index, offsetBy: 2) + mode = .lineComment + continue + } + if ch == "/", peek(1) == "*" { + out.append(" ") + index = source.index(index, offsetBy: 2) + mode = .blockComment(depth: 1) + continue + } + if ch == "\"" { + let triple = (peek(1) == "\"") && (peek(2) == "\"") + out.append(triple ? " " : " ") + index = source.index(index, offsetBy: triple ? 3 : 1) + mode = .string(quoteCount: triple ? 3 : 1) + continue + } + out.append(ch) + index = source.index(after: index) + + case .lineComment: + if ch == "\n" { + out.append(ch) + index = source.index(after: index) + mode = .code + } else { + out.append(" ") + index = source.index(after: index) + } + + case let .blockComment(depth): + if ch == "/", peek(1) == "*" { + out.append(" ") + index = source.index(index, offsetBy: 2) + mode = .blockComment(depth: depth + 1) + continue + } + if ch == "*", peek(1) == "/" { + out.append(" ") + index = source.index(index, offsetBy: 2) + let newDepth = depth - 1 + mode = newDepth > 0 ? .blockComment(depth: newDepth) : .code + continue + } + out.append(ch == "\n" ? "\n" : " ") + index = source.index(after: index) + + case let .string(quoteCount): + if ch == "\\", quoteCount == 1 { + // Skip escaped character in normal strings. + out.append(" ") + index = source.index(after: index) + if index < source.endIndex { + out.append(" ") + index = source.index(after: index) + } + continue + } + if ch == "\"" { + if quoteCount == 3, peek(1) == "\"", peek(2) == "\"" { + out.append(" ") + index = source.index(index, offsetBy: 3) + mode = .code + continue + } + if quoteCount == 1 { + out.append(" ") + index = source.index(after: index) + mode = .code + continue + } + } + out.append(ch == "\n" ? "\n" : " ") + index = source.index(after: index) + } + } + + return out + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/FileHandleSafeReadTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/FileHandleSafeReadTests.swift new file mode 100644 index 00000000..3b679a7d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/FileHandleSafeReadTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct FileHandleSafeReadTests { + @Test func readToEndSafelyReturnsEmptyForClosedHandle() { + let pipe = Pipe() + let handle = pipe.fileHandleForReading + try? handle.close() + + let data = handle.readToEndSafely() + #expect(data.isEmpty) + } + + @Test func readSafelyUpToCountReturnsEmptyForClosedHandle() { + let pipe = Pipe() + let handle = pipe.fileHandleForReading + try? handle.close() + + let data = handle.readSafely(upToCount: 16) + #expect(data.isEmpty) + } + + @Test func readToEndSafelyReadsPipeContents() { + let pipe = Pipe() + let writeHandle = pipe.fileHandleForWriting + writeHandle.write(Data("hello".utf8)) + try? writeHandle.close() + + let data = pipe.fileHandleForReading.readToEndSafely() + #expect(String(data: data, encoding: .utf8) == "hello") + } + + @Test func readSafelyUpToCountReadsIncrementally() { + let pipe = Pipe() + let writeHandle = pipe.fileHandleForWriting + writeHandle.write(Data("hello world".utf8)) + try? writeHandle.close() + + let readHandle = pipe.fileHandleForReading + let first = readHandle.readSafely(upToCount: 5) + let second = readHandle.readSafely(upToCount: 32) + + #expect(String(data: first, encoding: .utf8) == "hello") + #expect(String(data: second, encoding: .utf8) == " world") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayAgentChannelTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayAgentChannelTests.swift new file mode 100644 index 00000000..18972a23 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayAgentChannelTests.swift @@ -0,0 +1,27 @@ +import Testing +@testable import OpenClaw + +@Suite struct GatewayAgentChannelTests { + @Test func shouldDeliverBlocksWebChat() { + #expect(GatewayAgentChannel.webchat.shouldDeliver(true) == false) + #expect(GatewayAgentChannel.webchat.shouldDeliver(false) == false) + } + + @Test func shouldDeliverAllowsLastAndProviderChannels() { + #expect(GatewayAgentChannel.last.shouldDeliver(true) == true) + #expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true) + #expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true) + #expect(GatewayAgentChannel.googlechat.shouldDeliver(true) == true) + #expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true) + #expect(GatewayAgentChannel.last.shouldDeliver(false) == false) + } + + @Test func initRawNormalizesAndFallsBackToLast() { + #expect(GatewayAgentChannel(raw: nil) == .last) + #expect(GatewayAgentChannel(raw: " ") == .last) + #expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat) + #expect(GatewayAgentChannel(raw: "googlechat") == .googlechat) + #expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles) + #expect(GatewayAgentChannel(raw: "unknown") == .last) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayAutostartPolicyTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayAutostartPolicyTests.swift new file mode 100644 index 00000000..f2fea5fc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayAutostartPolicyTests.swift @@ -0,0 +1,24 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) +struct GatewayAutostartPolicyTests { + @Test func startsGatewayOnlyWhenLocalAndNotPaused() { + #expect(GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: false)) + #expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: true)) + #expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .remote, paused: false)) + #expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .unconfigured, paused: false)) + } + + @Test func ensuresLaunchAgentWhenLocalAndNotAttachOnly() { + #expect(GatewayAutostartPolicy.shouldEnsureLaunchAgent( + mode: .local, + paused: false)) + #expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent( + mode: .local, + paused: true)) + #expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent( + mode: .remote, + paused: false)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift new file mode 100644 index 00000000..ec2caf60 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift @@ -0,0 +1,246 @@ +import OpenClawKit +import Foundation +import os +import Testing +@testable import OpenClaw + +@Suite struct GatewayConnectionTests { + private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { + private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) + private let pendingReceiveHandler = + OSAllocatedUnfairLock<(@Sendable (Result) + -> Void)?>(initialState: nil) + private let cancelCount = OSAllocatedUnfairLock(initialState: 0) + private let sendCount = OSAllocatedUnfairLock(initialState: 0) + private let helloDelayMs: Int + + var state: URLSessionTask.State = .suspended + + init(helloDelayMs: Int = 0) { + self.helloDelayMs = helloDelayMs + } + + func snapshotCancelCount() -> Int { self.cancelCount.withLock { $0 } } + + func resume() { + self.state = .running + } + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + _ = (closeCode, reason) + self.state = .canceling + self.cancelCount.withLock { $0 += 1 } + let handler = self.pendingReceiveHandler.withLock { handler in + defer { handler = nil } + return handler + } + handler?(Result.failure(URLError(.cancelled))) + } + + func send(_ message: URLSessionWebSocketTask.Message) async throws { + let currentSendCount = self.sendCount.withLock { count in + defer { count += 1 } + return count + } + + // First send is the connect handshake request. Subsequent sends are request frames. + if currentSendCount == 0 { + if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { + self.connectRequestID.withLock { $0 = id } + } + return + } + + guard case let .data(data) = message else { return } + guard + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + (obj["type"] as? String) == "req", + let id = obj["id"] as? String + else { + return + } + + let response = GatewayWebSocketTestSupport.okResponseData(id: id) + let handler = self.pendingReceiveHandler.withLock { $0 } + handler?(Result.success(.data(response))) + } + + func receive() async throws -> URLSessionWebSocketTask.Message { + if self.helloDelayMs > 0 { + try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000) + } + let id = self.connectRequestID.withLock { $0 } ?? "connect" + return .data(GatewayWebSocketTestSupport.connectOkData(id: id)) + } + + func receive( + completionHandler: @escaping @Sendable (Result) -> Void) + { + self.pendingReceiveHandler.withLock { $0 = completionHandler } + } + + func emitIncoming(_ data: Data) { + let handler = self.pendingReceiveHandler.withLock { $0 } + handler?(Result.success(.data(data))) + } + + } + + private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { + private let makeCount = OSAllocatedUnfairLock(initialState: 0) + private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]()) + private let helloDelayMs: Int + + init(helloDelayMs: Int = 0) { + self.helloDelayMs = helloDelayMs + } + + func snapshotMakeCount() -> Int { self.makeCount.withLock { $0 } } + func snapshotCancelCount() -> Int { + self.tasks.withLock { tasks in + tasks.reduce(0) { $0 + $1.snapshotCancelCount() } + } + } + + func latestTask() -> FakeWebSocketTask? { + self.tasks.withLock { $0.last } + } + + func makeWebSocketTask(url: URL) -> WebSocketTaskBox { + _ = url + self.makeCount.withLock { $0 += 1 } + let task = FakeWebSocketTask(helloDelayMs: self.helloDelayMs) + self.tasks.withLock { $0.append(task) } + return WebSocketTaskBox(task: task) + } + } + + private final class ConfigSource: @unchecked Sendable { + private let token = OSAllocatedUnfairLock(initialState: nil) + + init(token: String?) { + self.token.withLock { $0 = token } + } + + func snapshotToken() -> String? { self.token.withLock { $0 } } + func setToken(_ value: String?) { self.token.withLock { $0 = value } } + } + + @Test func requestReusesSingleWebSocketForSameConfig() async throws { + let session = FakeWebSocketSession() + let url = URL(string: "ws://example.invalid")! + let cfg = ConfigSource(token: nil) + let conn = GatewayConnection( + configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + + _ = try await conn.request(method: "status", params: nil) + #expect(session.snapshotMakeCount() == 1) + + _ = try await conn.request(method: "status", params: nil) + #expect(session.snapshotMakeCount() == 1) + #expect(session.snapshotCancelCount() == 0) + } + + @Test func requestReconfiguresAndCancelsOnTokenChange() async throws { + let session = FakeWebSocketSession() + let url = URL(string: "ws://example.invalid")! + let cfg = ConfigSource(token: "a") + let conn = GatewayConnection( + configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + + _ = try await conn.request(method: "status", params: nil) + #expect(session.snapshotMakeCount() == 1) + + cfg.setToken("b") + _ = try await conn.request(method: "status", params: nil) + #expect(session.snapshotMakeCount() == 2) + #expect(session.snapshotCancelCount() == 1) + } + + @Test func concurrentRequestsStillUseSingleWebSocket() async throws { + let session = FakeWebSocketSession(helloDelayMs: 150) + let url = URL(string: "ws://example.invalid")! + let cfg = ConfigSource(token: nil) + let conn = GatewayConnection( + configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + + async let r1: Data = conn.request(method: "status", params: nil) + async let r2: Data = conn.request(method: "status", params: nil) + _ = try await (r1, r2) + + #expect(session.snapshotMakeCount() == 1) + } + + @Test func subscribeReplaysLatestSnapshot() async throws { + let session = FakeWebSocketSession() + let url = URL(string: "ws://example.invalid")! + let cfg = ConfigSource(token: nil) + let conn = GatewayConnection( + configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + + _ = try await conn.request(method: "status", params: nil) + + let stream = await conn.subscribe(bufferingNewest: 10) + var iterator = stream.makeAsyncIterator() + let first = await iterator.next() + + guard case let .snapshot(snap) = first else { + Issue.record("expected snapshot, got \(String(describing: first))") + return + } + #expect(snap.type == "hello-ok") + } + + @Test func subscribeEmitsSeqGapBeforeEvent() async throws { + let session = FakeWebSocketSession() + let url = URL(string: "ws://example.invalid")! + let cfg = ConfigSource(token: nil) + let conn = GatewayConnection( + configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + + let stream = await conn.subscribe(bufferingNewest: 10) + var iterator = stream.makeAsyncIterator() + + _ = try await conn.request(method: "status", params: nil) + _ = await iterator.next() // snapshot + + let evt1 = Data( + """ + {"type":"event","event":"presence","payload":{"presence":[]},"seq":1} + """.utf8) + session.latestTask()?.emitIncoming(evt1) + + let firstEvent = await iterator.next() + guard case let .event(firstFrame) = firstEvent else { + Issue.record("expected event, got \(String(describing: firstEvent))") + return + } + #expect(firstFrame.seq == 1) + + let evt3 = Data( + """ + {"type":"event","event":"presence","payload":{"presence":[]},"seq":3} + """.utf8) + session.latestTask()?.emitIncoming(evt3) + + let gap = await iterator.next() + guard case let .seqGap(expected, received) = gap else { + Issue.record("expected seqGap, got \(String(describing: gap))") + return + } + #expect(expected == 2) + #expect(received == 3) + + let secondEvent = await iterator.next() + guard case let .event(secondFrame) = secondEvent else { + Issue.record("expected event, got \(String(describing: secondEvent))") + return + } + #expect(secondFrame.seq == 3) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift new file mode 100644 index 00000000..afe9dea9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift @@ -0,0 +1,127 @@ +import OpenClawKit +import Foundation +import os +import Testing +@testable import OpenClaw + +@Suite struct GatewayChannelConnectTests { + private enum FakeResponse { + case helloOk(delayMs: Int) + case invalid(delayMs: Int) + } + + private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { + private let response: FakeResponse + private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) + private let pendingReceiveHandler = + OSAllocatedUnfairLock<(@Sendable (Result) -> Void)?>( + initialState: nil) + + var state: URLSessionTask.State = .suspended + + init(response: FakeResponse) { + self.response = response + } + + func resume() { + self.state = .running + } + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + _ = (closeCode, reason) + self.state = .canceling + let handler = self.pendingReceiveHandler.withLock { handler in + defer { handler = nil } + return handler + } + handler?(Result.failure(URLError(.cancelled))) + } + + func send(_ message: URLSessionWebSocketTask.Message) async throws { + if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { + self.connectRequestID.withLock { $0 = id } + } + } + + func receive() async throws -> URLSessionWebSocketTask.Message { + let delayMs: Int + let msg: URLSessionWebSocketTask.Message + switch self.response { + case let .helloOk(ms): + delayMs = ms + let id = self.connectRequestID.withLock { $0 } ?? "connect" + msg = .data(GatewayWebSocketTestSupport.connectOkData(id: id)) + case let .invalid(ms): + delayMs = ms + msg = .string("not json") + } + try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) + return msg + } + + func receive( + completionHandler: @escaping @Sendable (Result) -> Void) + { + // The production channel sets up a continuous receive loop after hello. + // Tests only need the handshake receive; keep the loop idle. + self.pendingReceiveHandler.withLock { $0 = completionHandler } + } + + } + + private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { + private let response: FakeResponse + private let makeCount = OSAllocatedUnfairLock(initialState: 0) + + init(response: FakeResponse) { + self.response = response + } + + func snapshotMakeCount() -> Int { self.makeCount.withLock { $0 } } + + func makeWebSocketTask(url: URL) -> WebSocketTaskBox { + _ = url + self.makeCount.withLock { $0 += 1 } + let task = FakeWebSocketTask(response: self.response) + return WebSocketTaskBox(task: task) + } + } + + @Test func concurrentConnectIsSingleFlightOnSuccess() async throws { + let session = FakeWebSocketSession(response: .helloOk(delayMs: 200)) + let channel = GatewayChannelActor( + url: URL(string: "ws://example.invalid")!, + token: nil, + session: WebSocketSessionBox(session: session)) + + let t1 = Task { try await channel.connect() } + let t2 = Task { try await channel.connect() } + + _ = try await t1.value + _ = try await t2.value + + #expect(session.snapshotMakeCount() == 1) + } + + @Test func concurrentConnectSharesFailure() async { + let session = FakeWebSocketSession(response: .invalid(delayMs: 200)) + let channel = GatewayChannelActor( + url: URL(string: "ws://example.invalid")!, + token: nil, + session: WebSocketSessionBox(session: session)) + + let t1 = Task { try await channel.connect() } + let t2 = Task { try await channel.connect() } + + let r1 = await t1.result + let r2 = await t2.result + + #expect({ + if case .failure = r1 { true } else { false } + }()) + #expect({ + if case .failure = r2 { true } else { false } + }()) + #expect(session.snapshotMakeCount() == 1) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift new file mode 100644 index 00000000..4c788a95 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift @@ -0,0 +1,101 @@ +import OpenClawKit +import Foundation +import os +import Testing +@testable import OpenClaw + +@Suite struct GatewayChannelRequestTests { + private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { + private let requestSendDelayMs: Int + private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) + private let pendingReceiveHandler = + OSAllocatedUnfairLock<(@Sendable (Result) + -> Void)?>(initialState: nil) + private let sendCount = OSAllocatedUnfairLock(initialState: 0) + + var state: URLSessionTask.State = .suspended + + init(requestSendDelayMs: Int) { + self.requestSendDelayMs = requestSendDelayMs + } + + func resume() { + self.state = .running + } + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + _ = (closeCode, reason) + self.state = .canceling + let handler = self.pendingReceiveHandler.withLock { handler in + defer { handler = nil } + return handler + } + handler?(Result.failure(URLError(.cancelled))) + } + + func send(_ message: URLSessionWebSocketTask.Message) async throws { + _ = message + let currentSendCount = self.sendCount.withLock { count in + defer { count += 1 } + return count + } + + // First send is the connect handshake. Second send is the request frame. + if currentSendCount == 0 { + if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { + self.connectRequestID.withLock { $0 = id } + } + } + if currentSendCount == 1 { + try await Task.sleep(nanoseconds: UInt64(self.requestSendDelayMs) * 1_000_000) + throw URLError(.cannotConnectToHost) + } + } + + func receive() async throws -> URLSessionWebSocketTask.Message { + let id = self.connectRequestID.withLock { $0 } ?? "connect" + return .data(GatewayWebSocketTestSupport.connectOkData(id: id)) + } + + func receive( + completionHandler: @escaping @Sendable (Result) -> Void) + { + self.pendingReceiveHandler.withLock { $0 = completionHandler } + } + + } + + private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { + private let requestSendDelayMs: Int + + init(requestSendDelayMs: Int) { + self.requestSendDelayMs = requestSendDelayMs + } + + func makeWebSocketTask(url: URL) -> WebSocketTaskBox { + _ = url + let task = FakeWebSocketTask(requestSendDelayMs: self.requestSendDelayMs) + return WebSocketTaskBox(task: task) + } + } + + @Test func requestTimeoutThenSendFailureDoesNotDoubleResume() async { + let session = FakeWebSocketSession(requestSendDelayMs: 100) + let channel = GatewayChannelActor( + url: URL(string: "ws://example.invalid")!, + token: nil, + session: WebSocketSessionBox(session: session)) + + do { + _ = try await channel.request(method: "test", params: nil, timeoutMs: 10) + Issue.record("Expected request to time out") + } catch { + let ns = error as NSError + #expect(ns.domain == "Gateway") + #expect(ns.code == 5) + } + + // Give the delayed send failure task time to run; this used to crash due to a double-resume. + try? await Task.sleep(nanoseconds: 250 * 1_000_000) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift new file mode 100644 index 00000000..5f995cd3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift @@ -0,0 +1,96 @@ +import OpenClawKit +import Foundation +import os +import Testing +@testable import OpenClaw + +@Suite struct GatewayChannelShutdownTests { + private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { + private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) + private let pendingReceiveHandler = + OSAllocatedUnfairLock<(@Sendable (Result) + -> Void)?>(initialState: nil) + private let cancelCount = OSAllocatedUnfairLock(initialState: 0) + + var state: URLSessionTask.State = .suspended + + func snapshotCancelCount() -> Int { self.cancelCount.withLock { $0 } } + + func resume() { + self.state = .running + } + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + _ = (closeCode, reason) + self.state = .canceling + self.cancelCount.withLock { $0 += 1 } + let handler = self.pendingReceiveHandler.withLock { handler in + defer { handler = nil } + return handler + } + handler?(Result.failure(URLError(.cancelled))) + } + + func send(_ message: URLSessionWebSocketTask.Message) async throws { + if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { + self.connectRequestID.withLock { $0 = id } + } + } + + func receive() async throws -> URLSessionWebSocketTask.Message { + let id = self.connectRequestID.withLock { $0 } ?? "connect" + return .data(GatewayWebSocketTestSupport.connectOkData(id: id)) + } + + func receive( + completionHandler: @escaping @Sendable (Result) -> Void) + { + self.pendingReceiveHandler.withLock { $0 = completionHandler } + } + + func triggerReceiveFailure() { + let handler = self.pendingReceiveHandler.withLock { $0 } + handler?(Result.failure(URLError(.networkConnectionLost))) + } + + } + + private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { + private let makeCount = OSAllocatedUnfairLock(initialState: 0) + private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]()) + + func snapshotMakeCount() -> Int { self.makeCount.withLock { $0 } } + func latestTask() -> FakeWebSocketTask? { self.tasks.withLock { $0.last } } + + func makeWebSocketTask(url: URL) -> WebSocketTaskBox { + _ = url + self.makeCount.withLock { $0 += 1 } + let task = FakeWebSocketTask() + self.tasks.withLock { $0.append(task) } + return WebSocketTaskBox(task: task) + } + } + + @Test func shutdownPreventsReconnectLoopFromReceiveFailure() async throws { + let session = FakeWebSocketSession() + let channel = GatewayChannelActor( + url: URL(string: "ws://example.invalid")!, + token: nil, + session: WebSocketSessionBox(session: session)) + + // Establish a connection so `listen()` is active. + try await channel.connect() + #expect(session.snapshotMakeCount() == 1) + + // Simulate a socket receive failure, which would normally schedule a reconnect. + session.latestTask()?.triggerReceiveFailure() + + // Shut down quickly, before backoff reconnect triggers. + await channel.shutdown() + + // Wait longer than the default reconnect backoff (500ms) to ensure no reconnect happens. + try? await Task.sleep(nanoseconds: 750 * 1_000_000) + + #expect(session.snapshotMakeCount() == 1) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift new file mode 100644 index 00000000..e95cf7a2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift @@ -0,0 +1,59 @@ +import OpenClawKit +import Foundation +import Testing +@testable import OpenClaw +@testable import OpenClawIPC + +private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { + var state: URLSessionTask.State = .running + + func resume() {} + + func cancel(with _: URLSessionWebSocketTask.CloseCode, reason _: Data?) { + self.state = .canceling + } + + func send(_: URLSessionWebSocketTask.Message) async throws {} + + func receive() async throws -> URLSessionWebSocketTask.Message { + throw URLError(.cannotConnectToHost) + } + + func receive(completionHandler: @escaping @Sendable (Result) -> Void) { + completionHandler(.failure(URLError(.cannotConnectToHost))) + } +} + +private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { + func makeWebSocketTask(url _: URL) -> WebSocketTaskBox { + WebSocketTaskBox(task: FakeWebSocketTask()) + } +} + +private func makeTestGatewayConnection() -> GatewayConnection { + GatewayConnection( + configProvider: { + (url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil) + }, + sessionBox: WebSocketSessionBox(session: FakeWebSocketSession())) +} + +@Suite(.serialized) struct GatewayConnectionControlTests { + @Test func statusFailsWhenProcessMissing() async { + let connection = makeTestGatewayConnection() + let result = await connection.status() + #expect(result.ok == false) + #expect(result.error != nil) + } + + @Test func rejectEmptyMessage() async { + let connection = makeTestGatewayConnection() + let result = await connection.sendAgent( + message: "", + thinking: nil, + sessionKey: "main", + deliver: false, + to: nil) + #expect(result.ok == false) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift new file mode 100644 index 00000000..17ffec07 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift @@ -0,0 +1,98 @@ +import Foundation +import OpenClawDiscovery +import Testing +@testable import OpenClaw + +@Suite +struct GatewayDiscoveryHelpersTests { + private func makeGateway( + serviceHost: String?, + servicePort: Int?, + lanHost: String? = "txt-host.local", + tailnetDns: String? = "txt-host.ts.net", + sshPort: Int = 22, + gatewayPort: Int? = 18789) -> GatewayDiscoveryModel.DiscoveredGateway + { + GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Gateway", + serviceHost: serviceHost, + servicePort: servicePort, + lanHost: lanHost, + tailnetDns: tailnetDns, + sshPort: sshPort, + gatewayPort: gatewayPort, + cliPath: "/tmp/openclaw", + stableID: UUID().uuidString, + debugID: UUID().uuidString, + isLocal: false) + } + + @Test func sshTargetUsesResolvedServiceHostOnly() { + let gateway = self.makeGateway( + serviceHost: "resolved.example.ts.net", + servicePort: 18789, + sshPort: 2201) + + guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else { + Issue.record("expected ssh target") + return + } + let parsed = CommandResolver.parseSSHTarget(target) + #expect(parsed?.host == "resolved.example.ts.net") + #expect(parsed?.port == 2201) + } + + @Test func sshTargetAllowsMissingResolvedServicePort() { + let gateway = self.makeGateway( + serviceHost: "resolved.example.ts.net", + servicePort: nil, + sshPort: 2201) + + guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else { + Issue.record("expected ssh target") + return + } + let parsed = CommandResolver.parseSSHTarget(target) + #expect(parsed?.host == "resolved.example.ts.net") + #expect(parsed?.port == 2201) + } + + @Test func sshTargetRejectsTxtOnlyGateways() { + let gateway = self.makeGateway( + serviceHost: nil, + servicePort: nil, + lanHost: "txt-only.local", + tailnetDns: "txt-only.ts.net", + sshPort: 2222) + + #expect(GatewayDiscoveryHelpers.sshTarget(for: gateway) == nil) + } + + @Test func directUrlUsesResolvedServiceEndpointOnly() { + let tlsGateway = self.makeGateway( + serviceHost: "resolved.example.ts.net", + servicePort: 443) + #expect(GatewayDiscoveryHelpers.directUrl(for: tlsGateway) == "wss://resolved.example.ts.net") + + let wsGateway = self.makeGateway( + serviceHost: "resolved.example.ts.net", + servicePort: 18789) + #expect(GatewayDiscoveryHelpers.directUrl(for: wsGateway) == "wss://resolved.example.ts.net:18789") + + let localGateway = self.makeGateway( + serviceHost: "127.0.0.1", + servicePort: 18789) + #expect(GatewayDiscoveryHelpers.directUrl(for: localGateway) == "ws://127.0.0.1:18789") + } + + @Test func directUrlRejectsTxtOnlyFallback() { + let gateway = self.makeGateway( + serviceHost: nil, + servicePort: nil, + lanHost: "txt-only.local", + tailnetDns: "txt-only.ts.net", + gatewayPort: 22222) + + #expect(GatewayDiscoveryHelpers.directUrl(for: gateway) == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift new file mode 100644 index 00000000..02888c73 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift @@ -0,0 +1,124 @@ +import OpenClawDiscovery +import Testing + +@Suite +@MainActor +struct GatewayDiscoveryModelTests { + @Test func localGatewayMatchesLanHost() { + let local = GatewayDiscoveryModel.LocalIdentity( + hostTokens: ["studio"], + displayTokens: []) + #expect(GatewayDiscoveryModel.isLocalGateway( + lanHost: "studio.local", + tailnetDns: nil, + displayName: nil, + serviceName: nil, + local: local)) + } + + @Test func localGatewayMatchesTailnetDns() { + let local = GatewayDiscoveryModel.LocalIdentity( + hostTokens: ["studio"], + displayTokens: []) + #expect(GatewayDiscoveryModel.isLocalGateway( + lanHost: nil, + tailnetDns: "studio.tailnet.example", + displayName: nil, + serviceName: nil, + local: local)) + } + + @Test func localGatewayMatchesDisplayName() { + let local = GatewayDiscoveryModel.LocalIdentity( + hostTokens: [], + displayTokens: ["peter's mac studio"]) + #expect(GatewayDiscoveryModel.isLocalGateway( + lanHost: nil, + tailnetDns: nil, + displayName: "Peter's Mac Studio (OpenClaw)", + serviceName: nil, + local: local)) + } + + @Test func remoteGatewayDoesNotMatch() { + let local = GatewayDiscoveryModel.LocalIdentity( + hostTokens: ["studio"], + displayTokens: ["peter's mac studio"]) + #expect(!GatewayDiscoveryModel.isLocalGateway( + lanHost: "other.local", + tailnetDns: "other.tailnet.example", + displayName: "Other Mac", + serviceName: "other-gateway", + local: local)) + } + + @Test func localGatewayMatchesServiceName() { + let local = GatewayDiscoveryModel.LocalIdentity( + hostTokens: ["studio"], + displayTokens: []) + #expect(GatewayDiscoveryModel.isLocalGateway( + lanHost: nil, + tailnetDns: nil, + displayName: nil, + serviceName: "studio-gateway", + local: local)) + } + + @Test func serviceNameDoesNotFalsePositiveOnSubstringHostToken() { + let local = GatewayDiscoveryModel.LocalIdentity( + hostTokens: ["steipete"], + displayTokens: []) + #expect(!GatewayDiscoveryModel.isLocalGateway( + lanHost: nil, + tailnetDns: nil, + displayName: nil, + serviceName: "steipetacstudio (OpenClaw)", + local: local)) + #expect(GatewayDiscoveryModel.isLocalGateway( + lanHost: nil, + tailnetDns: nil, + displayName: nil, + serviceName: "steipete (OpenClaw)", + local: local)) + } + + @Test func parsesGatewayTXTFields() { + let parsed = GatewayDiscoveryModel.parseGatewayTXT([ + "lanHost": " studio.local ", + "tailnetDns": " peters-mac-studio-1.ts.net ", + "sshPort": " 2222 ", + "gatewayPort": " 18799 ", + "cliPath": " /opt/openclaw ", + ]) + #expect(parsed.lanHost == "studio.local") + #expect(parsed.tailnetDns == "peters-mac-studio-1.ts.net") + #expect(parsed.sshPort == 2222) + #expect(parsed.gatewayPort == 18799) + #expect(parsed.cliPath == "/opt/openclaw") + } + + @Test func parsesGatewayTXTDefaults() { + let parsed = GatewayDiscoveryModel.parseGatewayTXT([ + "lanHost": " ", + "tailnetDns": "\n", + "gatewayPort": "nope", + "sshPort": "nope", + ]) + #expect(parsed.lanHost == nil) + #expect(parsed.tailnetDns == nil) + #expect(parsed.sshPort == 22) + #expect(parsed.gatewayPort == nil) + #expect(parsed.cliPath == nil) + } + + @Test func buildsSSHTarget() { + #expect(GatewayDiscoveryModel.buildSSHTarget( + user: "peter", + host: "studio.local", + port: 22) == "peter@studio.local") + #expect(GatewayDiscoveryModel.buildSSHTarget( + user: "peter", + host: "studio.local", + port: 2201) == "peter@studio.local:2201") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift new file mode 100644 index 00000000..bb969aea --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -0,0 +1,236 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct GatewayEndpointStoreTests { + private func makeDefaults() -> UserDefaults { + let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + @Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() { + let snapshot = LaunchAgentPlistSnapshot( + programArguments: [], + environment: ["OPENCLAW_GATEWAY_TOKEN": "launchd-token"], + stdoutPath: nil, + stderrPath: nil, + port: nil, + bind: nil, + token: "launchd-token", + password: nil) + + let envToken = GatewayEndpointStore._testResolveGatewayToken( + isRemote: false, + root: [:], + env: ["OPENCLAW_GATEWAY_TOKEN": "env-token"], + launchdSnapshot: snapshot) + #expect(envToken == "env-token") + + let fallbackToken = GatewayEndpointStore._testResolveGatewayToken( + isRemote: false, + root: [:], + env: [:], + launchdSnapshot: snapshot) + #expect(fallbackToken == "launchd-token") + } + + @Test func resolveGatewayTokenIgnoresLaunchdInRemoteMode() { + let snapshot = LaunchAgentPlistSnapshot( + programArguments: [], + environment: ["OPENCLAW_GATEWAY_TOKEN": "launchd-token"], + stdoutPath: nil, + stderrPath: nil, + port: nil, + bind: nil, + token: "launchd-token", + password: nil) + + let token = GatewayEndpointStore._testResolveGatewayToken( + isRemote: true, + root: [:], + env: [:], + launchdSnapshot: snapshot) + #expect(token == nil) + } + + @Test func resolveGatewayPasswordFallsBackToLaunchd() { + let snapshot = LaunchAgentPlistSnapshot( + programArguments: [], + environment: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"], + stdoutPath: nil, + stderrPath: nil, + port: nil, + bind: nil, + token: nil, + password: "launchd-pass") + + let password = GatewayEndpointStore._testResolveGatewayPassword( + isRemote: false, + root: [:], + env: [:], + launchdSnapshot: snapshot) + #expect(password == "launchd-pass") + } + + @Test func connectionModeResolverPrefersConfigModeOverDefaults() { + let defaults = self.makeDefaults() + defaults.set("remote", forKey: connectionModeKey) + + let root: [String: Any] = [ + "gateway": [ + "mode": " local ", + ], + ] + + let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) + #expect(resolved.mode == .local) + } + + @Test func connectionModeResolverTrimsConfigMode() { + let defaults = self.makeDefaults() + defaults.set("local", forKey: connectionModeKey) + + let root: [String: Any] = [ + "gateway": [ + "mode": " remote ", + ], + ] + + let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) + #expect(resolved.mode == .remote) + } + + @Test func connectionModeResolverFallsBackToDefaultsWhenMissingConfig() { + let defaults = self.makeDefaults() + defaults.set("remote", forKey: connectionModeKey) + + let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults) + #expect(resolved.mode == .remote) + } + + @Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() { + let defaults = self.makeDefaults() + defaults.set("local", forKey: connectionModeKey) + + let root: [String: Any] = [ + "gateway": [ + "mode": "staging", + ], + ] + + let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) + #expect(resolved.mode == .local) + } + + @Test func connectionModeResolverPrefersRemoteURLWhenModeMissing() { + let defaults = self.makeDefaults() + defaults.set("local", forKey: connectionModeKey) + + let root: [String: Any] = [ + "gateway": [ + "remote": [ + "url": " ws://umbrel:18789 ", + ], + ], + ] + + let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) + #expect(resolved.mode == .remote) + } + + @Test func resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() { + let host = GatewayEndpointStore._testResolveLocalGatewayHost( + bindMode: "auto", + tailscaleIP: "100.64.1.2") + #expect(host == "127.0.0.1") + } + + @Test func resolveLocalGatewayHostUsesLoopbackForAutoWithoutTailnet() { + let host = GatewayEndpointStore._testResolveLocalGatewayHost( + bindMode: "auto", + tailscaleIP: nil) + #expect(host == "127.0.0.1") + } + + @Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() { + let host = GatewayEndpointStore._testResolveLocalGatewayHost( + bindMode: "tailnet", + tailscaleIP: "100.64.1.5") + #expect(host == "100.64.1.5") + } + + @Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() { + let host = GatewayEndpointStore._testResolveLocalGatewayHost( + bindMode: "tailnet", + tailscaleIP: nil) + #expect(host == "127.0.0.1") + } + + @Test func resolveLocalGatewayHostUsesCustomBindHost() { + let host = GatewayEndpointStore._testResolveLocalGatewayHost( + bindMode: "custom", + tailscaleIP: "100.64.1.9", + customBindHost: "192.168.1.10") + #expect(host == "192.168.1.10") + } + + @Test func dashboardURLUsesLocalBasePathInLocalMode() throws { + let config: GatewayConnection.Config = ( + url: try #require(URL(string: "ws://127.0.0.1:18789")), + token: nil, + password: nil + ) + + let url = try GatewayEndpointStore.dashboardURL( + for: config, + mode: .local, + localBasePath: " control ") + #expect(url.absoluteString == "http://127.0.0.1:18789/control/") + } + + @Test func dashboardURLSkipsLocalBasePathInRemoteMode() throws { + let config: GatewayConnection.Config = ( + url: try #require(URL(string: "ws://gateway.example:18789")), + token: nil, + password: nil + ) + + let url = try GatewayEndpointStore.dashboardURL( + for: config, + mode: .remote, + localBasePath: "/local-ui") + #expect(url.absoluteString == "http://gateway.example:18789/") + } + + @Test func dashboardURLPrefersPathFromConfigURL() throws { + let config: GatewayConnection.Config = ( + url: try #require(URL(string: "wss://gateway.example:443/remote-ui")), + token: nil, + password: nil + ) + + let url = try GatewayEndpointStore.dashboardURL( + for: config, + mode: .remote, + localBasePath: "/local-ui") + #expect(url.absoluteString == "https://gateway.example:443/remote-ui/") + } + + @Test func normalizeGatewayUrlAddsDefaultPortForLoopbackWs() { + let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1") + #expect(url?.port == 18789) + #expect(url?.absoluteString == "ws://127.0.0.1:18789") + } + + @Test func normalizeGatewayUrlRejectsNonLoopbackWs() { + let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway.example:18789") + #expect(url == nil) + } + + @Test func normalizeGatewayUrlRejectsPrefixBypassLoopbackHost() { + let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.attacker.example") + #expect(url == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift new file mode 100644 index 00000000..32dcbb73 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct GatewayEnvironmentTests { + @Test func semverParsesCommonForms() { + #expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3)) + #expect(Semver.parse(" v1.2.3 \n") == Semver(major: 1, minor: 2, patch: 3)) + #expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0)) + #expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped + #expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped + #expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped + #expect(Semver.parse("v1.2.3+build.9") == Semver(major: 1, minor: 2, patch: 3)) + #expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3)) + #expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3)) + #expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3)) + #expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0)) + #expect(Semver.parse(nil) == nil) + #expect(Semver.parse("invalid") == nil) + #expect(Semver.parse("1.2") == nil) + #expect(Semver.parse("1.2.x") == nil) + } + + @Test func semverCompatibilityRequiresSameMajorAndNotOlder() { + let required = Semver(major: 2, minor: 1, patch: 0) + #expect(Semver(major: 2, minor: 1, patch: 0).compatible(with: required)) + #expect(Semver(major: 2, minor: 2, patch: 0).compatible(with: required)) + #expect(Semver(major: 2, minor: 1, patch: 1).compatible(with: required)) + #expect(Semver(major: 2, minor: 0, patch: 9).compatible(with: required) == false) + #expect(Semver(major: 3, minor: 0, patch: 0).compatible(with: required) == false) + #expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false) + } + + @Test func gatewayPortDefaultsAndRespectsOverride() async { + let configPath = TestIsolation.tempConfigPath() + await TestIsolation.withIsolatedState( + env: ["OPENCLAW_CONFIG_PATH": configPath], + defaults: ["gatewayPort": nil]) + { + let defaultPort = GatewayEnvironment.gatewayPort() + #expect(defaultPort == 18789) + + UserDefaults.standard.set(19999, forKey: "gatewayPort") + defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") } + #expect(GatewayEnvironment.gatewayPort() == 19999) + } + } + + @Test func expectedGatewayVersionFromStringUsesParser() { + #expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2)) + #expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver( + major: 2026, + minor: 1, + patch: 11)) + #expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayFrameDecodeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayFrameDecodeTests.swift new file mode 100644 index 00000000..bda8ff0e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayFrameDecodeTests.swift @@ -0,0 +1,98 @@ +import OpenClawProtocol +import Foundation +import Testing + +@Suite struct GatewayFrameDecodeTests { + @Test func decodesEventFrameWithAnyCodablePayload() throws { + let json = """ + { + "type": "event", + "event": "presence", + "payload": { "foo": "bar", "count": 1 }, + "seq": 7 + } + """ + + let frame = try JSONDecoder().decode(GatewayFrame.self, from: Data(json.utf8)) + + #expect({ + if case .event = frame { true } else { false } + }(), "expected .event frame") + + guard case let .event(evt) = frame else { + return + } + + let payload = evt.payload?.value as? [String: AnyCodable] + #expect(payload?["foo"]?.value as? String == "bar") + #expect(payload?["count"]?.value as? Int == 1) + #expect(evt.seq == 7) + } + + @Test func decodesRequestFrameWithNestedParams() throws { + let json = """ + { + "type": "req", + "id": "1", + "method": "agent.send", + "params": { + "text": "hi", + "items": [1, null, {"ok": true}], + "meta": { "count": 2 } + } + } + """ + + let frame = try JSONDecoder().decode(GatewayFrame.self, from: Data(json.utf8)) + + #expect({ + if case .req = frame { true } else { false } + }(), "expected .req frame") + + guard case let .req(req) = frame else { + return + } + + let params = req.params?.value as? [String: AnyCodable] + #expect(params?["text"]?.value as? String == "hi") + + let items = params?["items"]?.value as? [AnyCodable] + #expect(items?.count == 3) + #expect(items?[0].value as? Int == 1) + #expect(items?[1].value is NSNull) + + let item2 = items?[2].value as? [String: AnyCodable] + #expect(item2?["ok"]?.value as? Bool == true) + + let meta = params?["meta"]?.value as? [String: AnyCodable] + #expect(meta?["count"]?.value as? Int == 2) + } + + @Test func decodesUnknownFrameAndPreservesRaw() throws { + let json = """ + { + "type": "made-up", + "foo": "bar", + "count": 1, + "nested": { "ok": true } + } + """ + + let frame = try JSONDecoder().decode(GatewayFrame.self, from: Data(json.utf8)) + + #expect({ + if case .unknown = frame { true } else { false } + }(), "expected .unknown frame") + + guard case let .unknown(type, raw) = frame else { + return + } + + #expect(type == "made-up") + #expect(raw["type"]?.value as? String == "made-up") + #expect(raw["foo"]?.value as? String == "bar") + #expect(raw["count"]?.value as? Int == 1) + let nested = raw["nested"]?.value as? [String: AnyCodable] + #expect(nested?["ok"]?.value as? Bool == true) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift new file mode 100644 index 00000000..685db818 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct GatewayLaunchAgentManagerTests { + @Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws { + let url = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist") + let plist: [String: Any] = [ + "ProgramArguments": ["openclaw", "gateway-daemon", "--port", "18789", "--bind", "loopback"], + "EnvironmentVariables": [ + "OPENCLAW_GATEWAY_TOKEN": " secret ", + "OPENCLAW_GATEWAY_PASSWORD": "pw", + ], + ] + let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try data.write(to: url, options: [.atomic]) + defer { try? FileManager().removeItem(at: url) } + + let snapshot = try #require(LaunchAgentPlist.snapshot(url: url)) + #expect(snapshot.port == 18789) + #expect(snapshot.bind == "loopback") + #expect(snapshot.token == "secret") + #expect(snapshot.password == "pw") + } + + @Test func launchAgentPlistSnapshotAllowsMissingBind() throws { + let url = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist") + let plist: [String: Any] = [ + "ProgramArguments": ["openclaw", "gateway-daemon", "--port", "18789"], + ] + let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try data.write(to: url, options: [.atomic]) + defer { try? FileManager().removeItem(at: url) } + + let snapshot = try #require(LaunchAgentPlist.snapshot(url: url)) + #expect(snapshot.port == 18789) + #expect(snapshot.bind == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift new file mode 100644 index 00000000..dabb15f8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift @@ -0,0 +1,107 @@ +import OpenClawKit +import Foundation +import os +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct GatewayProcessManagerTests { + private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { + private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) + private let pendingReceiveHandler = + OSAllocatedUnfairLock<(@Sendable (Result) + -> Void)?>(initialState: nil) + private let cancelCount = OSAllocatedUnfairLock(initialState: 0) + private let sendCount = OSAllocatedUnfairLock(initialState: 0) + + var state: URLSessionTask.State = .suspended + + func resume() { + self.state = .running + } + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + _ = (closeCode, reason) + self.state = .canceling + self.cancelCount.withLock { $0 += 1 } + let handler = self.pendingReceiveHandler.withLock { handler in + defer { handler = nil } + return handler + } + handler?(Result.failure(URLError(.cancelled))) + } + + func send(_ message: URLSessionWebSocketTask.Message) async throws { + let currentSendCount = self.sendCount.withLock { count in + defer { count += 1 } + return count + } + + if currentSendCount == 0 { + if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { + self.connectRequestID.withLock { $0 = id } + } + return + } + + guard case let .data(data) = message else { return } + guard + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + (obj["type"] as? String) == "req", + let id = obj["id"] as? String + else { + return + } + + let response = GatewayWebSocketTestSupport.okResponseData(id: id) + let handler = self.pendingReceiveHandler.withLock { $0 } + handler?(Result.success(.data(response))) + } + + func receive() async throws -> URLSessionWebSocketTask.Message { + let id = self.connectRequestID.withLock { $0 } ?? "connect" + return .data(GatewayWebSocketTestSupport.connectOkData(id: id)) + } + + func receive( + completionHandler: @escaping @Sendable (Result) -> Void) + { + self.pendingReceiveHandler.withLock { $0 = completionHandler } + } + + } + + private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { + private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]()) + + func makeWebSocketTask(url: URL) -> WebSocketTaskBox { + _ = url + let task = FakeWebSocketTask() + self.tasks.withLock { $0.append(task) } + return WebSocketTaskBox(task: task) + } + } + + @Test func clearsLastFailureWhenHealthSucceeds() async { + let session = FakeWebSocketSession() + let url = URL(string: "ws://example.invalid")! + let connection = GatewayConnection( + configProvider: { (url: url, token: nil, password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + + let manager = GatewayProcessManager.shared + manager.setTestingConnection(connection) + manager.setTestingDesiredActive(true) + manager.setTestingLastFailureReason("health failed") + defer { + manager.setTestingConnection(nil) + manager.setTestingDesiredActive(false) + manager.setTestingLastFailureReason(nil) + } + + let ready = await manager.waitForGatewayReady(timeout: 0.5) + #expect(ready) + #expect(manager.lastFailureReason == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift new file mode 100644 index 00000000..0ba41f28 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift @@ -0,0 +1,63 @@ +import OpenClawKit +import Foundation + +extension WebSocketTasking { + // Keep unit-test doubles resilient to protocol additions. + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { + pongReceiveHandler(nil) + } +} + +enum GatewayWebSocketTestSupport { + static func connectRequestID(from message: URLSessionWebSocketTask.Message) -> String? { + let data: Data? = switch message { + case let .data(d): d + case let .string(s): s.data(using: .utf8) + @unknown default: nil + } + guard let data else { return nil } + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + guard (obj["type"] as? String) == "req", (obj["method"] as? String) == "connect" else { + return nil + } + return obj["id"] as? String + } + + static func connectOkData(id: String) -> Data { + let json = """ + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": { + "type": "hello-ok", + "protocol": 2, + "server": { "version": "test", "connId": "test" }, + "features": { "methods": [], "events": [] }, + "snapshot": { + "presence": [ { "ts": 1 } ], + "health": {}, + "stateVersion": { "presence": 0, "health": 0 }, + "uptimeMs": 0 + }, + "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 } + } + } + """ + return Data(json.utf8) + } + + static func okResponseData(id: String) -> Data { + let json = """ + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": { "ok": true } + } + """ + return Data(json.utf8) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/HealthDecodeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/HealthDecodeTests.swift new file mode 100644 index 00000000..f6b65b15 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/HealthDecodeTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct HealthDecodeTests { + private let sampleJSON: String = // minimal but complete payload + """ + {"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}} + """ + + @Test func decodesCleanJSON() async throws { + let data = Data(sampleJSON.utf8) + let snap = decodeHealthSnapshot(from: data) + + #expect(snap?.channels["whatsapp"]?.linked == true) + #expect(snap?.sessions.count == 1) + } + + @Test func decodesWithLeadingNoise() async throws { + let noisy = "debug: something logged\n" + self.sampleJSON + "\ntrailer" + let snap = decodeHealthSnapshot(from: Data(noisy.utf8)) + + #expect(snap?.channels["telegram"]?.probe?.elapsedMs == 800) + } + + @Test func failsWithoutBraces() async throws { + let data = Data("no json here".utf8) + let snap = decodeHealthSnapshot(from: data) + + #expect(snap == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/HealthStoreStateTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/HealthStoreStateTests.swift new file mode 100644 index 00000000..ca2601cf --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/HealthStoreStateTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct HealthStoreStateTests { + @Test @MainActor func linkedChannelProbeFailureDegradesState() async throws { + let snap = HealthSnapshot( + ok: true, + ts: 0, + durationMs: 1, + channels: [ + "whatsapp": .init( + configured: true, + linked: true, + authAgeMs: 1, + probe: .init( + ok: false, + status: 503, + error: "gateway connect failed", + elapsedMs: 12, + bot: nil, + webhook: nil), + lastProbeAt: 0), + ], + channelOrder: ["whatsapp"], + channelLabels: ["whatsapp": "WhatsApp"], + heartbeatSeconds: 60, + sessions: .init(path: "/tmp/sessions.json", count: 0, recent: [])) + + let store = HealthStore.shared + store.__setSnapshotForTest(snap, lastError: nil) + + switch store.state { + case let .degraded(message): + #expect(!message.isEmpty) + default: + Issue.record("Expected degraded state when probe fails for linked channel") + } + + #expect(store.summaryLine.contains("probe degraded")) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift new file mode 100644 index 00000000..7ee15107 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift @@ -0,0 +1,36 @@ +import Testing +@testable import OpenClaw + +struct HostEnvSanitizerTests { + @Test func sanitizeBlocksShellTraceVariables() { + let env = HostEnvSanitizer.sanitize(overrides: [ + "SHELLOPTS": "xtrace", + "PS4": "$(touch /tmp/pwned)", + "OPENCLAW_TEST": "1", + ]) + #expect(env["SHELLOPTS"] == nil) + #expect(env["PS4"] == nil) + #expect(env["OPENCLAW_TEST"] == "1") + } + + @Test func sanitizeShellWrapperAllowsOnlyExplicitOverrideKeys() { + let env = HostEnvSanitizer.sanitize( + overrides: [ + "LANG": "C", + "LC_ALL": "C", + "OPENCLAW_TOKEN": "secret", + "PS4": "$(touch /tmp/pwned)", + ], + shellWrapper: true) + + #expect(env["LANG"] == "C") + #expect(env["LC_ALL"] == "C") + #expect(env["OPENCLAW_TOKEN"] == nil) + #expect(env["PS4"] == nil) + } + + @Test func sanitizeNonShellWrapperKeepsRegularOverrides() { + let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"]) + #expect(env["OPENCLAW_TOKEN"] == "secret") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift new file mode 100644 index 00000000..eff3ee6d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift @@ -0,0 +1,26 @@ +import AppKit +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct HoverHUDControllerTests { + @Test func hoverHUDControllerPresentsAndDismisses() async { + let controller = HoverHUDController() + controller.setSuppressed(false) + + controller.statusItemHoverChanged( + inside: true, + anchorProvider: { NSRect(x: 10, y: 10, width: 24, height: 24) }) + try? await Task.sleep(nanoseconds: 260_000_000) + + controller.panelHoverChanged(inside: true) + controller.panelHoverChanged(inside: false) + controller.statusItemHoverChanged( + inside: false, + anchorProvider: { NSRect(x: 10, y: 10, width: 24, height: 24) }) + + controller.dismiss(reason: "test") + controller.setSuppressed(true) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/InstancesSettingsSmokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/InstancesSettingsSmokeTests.swift new file mode 100644 index 00000000..c43982ee --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/InstancesSettingsSmokeTests.swift @@ -0,0 +1,59 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct InstancesSettingsSmokeTests { + @Test func instancesSettingsBuildsBodyWithMultipleInstances() { + let store = InstancesStore(isPreview: true) + store.statusMessage = "Loaded" + store.instances = [ + InstanceInfo( + id: "macbook", + host: "macbook-pro", + ip: "10.0.0.2", + version: "1.2.3", + platform: "macOS 15.1", + deviceFamily: "Mac", + modelIdentifier: "MacBookPro18,1", + lastInputSeconds: 15, + mode: "local", + reason: "heartbeat", + text: "MacBook Pro local", + ts: 1_700_000_000_000), + InstanceInfo( + id: "android", + host: "pixel", + ip: "10.0.0.3", + version: "2.0.0", + platform: "Android 14", + deviceFamily: "Android", + modelIdentifier: nil, + lastInputSeconds: 120, + mode: "node", + reason: "presence", + text: "Android node", + ts: 1_700_000_100_000), + InstanceInfo( + id: "gateway", + host: "gateway", + ip: "10.0.0.4", + version: "3.0.0", + platform: "iOS 18", + deviceFamily: nil, + modelIdentifier: nil, + lastInputSeconds: nil, + mode: "gateway", + reason: "gateway", + text: "Gateway", + ts: 1_700_000_200_000), + ] + + let view = InstancesSettings(store: store) + _ = view.body + } + + @Test func instancesSettingsExercisesHelpers() { + InstancesSettings.exerciseForTesting() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/InstancesStoreTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/InstancesStoreTests.swift new file mode 100644 index 00000000..f148c35f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/InstancesStoreTests.swift @@ -0,0 +1,36 @@ +import OpenClawProtocol +import Testing +@testable import OpenClaw + +@Suite struct InstancesStoreTests { + @Test + @MainActor + func presenceEventPayloadDecodesViaJSONEncoder() { + // Build a payload that mirrors the gateway's presence event shape: + // { "presence": [ PresenceEntry ] } + let entry: [String: OpenClawProtocol.AnyCodable] = [ + "host": .init("gw"), + "ip": .init("10.0.0.1"), + "version": .init("2.0.0"), + "mode": .init("gateway"), + "lastInputSeconds": .init(5), + "reason": .init("test"), + "text": .init("Gateway node"), + "ts": .init(1_730_000_000), + ] + let payloadMap: [String: OpenClawProtocol.AnyCodable] = [ + "presence": .init([OpenClawProtocol.AnyCodable(entry)]), + ] + let payload = OpenClawProtocol.AnyCodable(payloadMap) + + let store = InstancesStore(isPreview: true) + store.handlePresenceEventPayload(payload) + + #expect(store.instances.count == 1) + let instance = store.instances.first + #expect(instance?.host == "gw") + #expect(instance?.ip == "10.0.0.1") + #expect(instance?.mode == "gateway") + #expect(instance?.reason == "test") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/LogLocatorTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/LogLocatorTests.swift new file mode 100644 index 00000000..6f7fc5dc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/LogLocatorTests.swift @@ -0,0 +1,24 @@ +import Darwin +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct LogLocatorTests { + @Test func launchdGatewayLogPathEnsuresTmpDirExists() throws { + let fm = FileManager() + let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let logDir = baseDir.appendingPathComponent("openclaw-tests-\(UUID().uuidString)") + + setenv("OPENCLAW_LOG_DIR", logDir.path, 1) + defer { + unsetenv("OPENCLAW_LOG_DIR") + try? fm.removeItem(at: logDir) + } + + _ = LogLocator.launchdGatewayLogPath + + var isDir: ObjCBool = false + #expect(fm.fileExists(atPath: logDir.path, isDirectory: &isDir)) + #expect(isDir.boolValue == true) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift new file mode 100644 index 00000000..174dc1d1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift @@ -0,0 +1,215 @@ +import AppKit +import OpenClawProtocol +import Foundation +import Testing + +@testable import OpenClaw + +@Suite(.serialized) +struct LowCoverageHelperTests { + private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable + + @Test func anyCodableHelperAccessors() throws { + let payload: [String: ProtoAnyCodable] = [ + "title": ProtoAnyCodable("Hello"), + "flag": ProtoAnyCodable(true), + "count": ProtoAnyCodable(3), + "ratio": ProtoAnyCodable(1.25), + "list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]), + ] + let any = ProtoAnyCodable(payload) + let dict = try #require(any.dictionaryValue) + #expect(dict["title"]?.stringValue == "Hello") + #expect(dict["flag"]?.boolValue == true) + #expect(dict["count"]?.intValue == 3) + #expect(dict["ratio"]?.doubleValue == 1.25) + #expect(dict["list"]?.arrayValue?.count == 2) + + let foundation = any.foundationValue as? [String: Any] + #expect((foundation?["title"] as? String) == "Hello") + } + + @Test func attributedStringStripsForegroundColor() { + let text = NSMutableAttributedString(string: "Test") + text.addAttribute(.foregroundColor, value: NSColor.red, range: NSRange(location: 0, length: 4)) + let stripped = text.strippingForegroundColor() + let color = stripped.attribute(.foregroundColor, at: 0, effectiveRange: nil) + #expect(color == nil) + } + + @Test func viewMetricsReduceWidth() { + let value = ViewMetricsTesting.reduceWidth(current: 120, next: 180) + #expect(value == 180) + } + + @Test func shellExecutorHandlesEmptyCommand() async { + let result = await ShellExecutor.runDetailed(command: [], cwd: nil, env: nil, timeout: nil) + #expect(result.success == false) + #expect(result.errorMessage != nil) + } + + @Test func shellExecutorRunsCommand() async { + let result = await ShellExecutor.runDetailed(command: ["/bin/echo", "ok"], cwd: nil, env: nil, timeout: 2) + #expect(result.success == true) + #expect(result.stdout.contains("ok") || result.stderr.contains("ok")) + } + + @Test func shellExecutorTimesOut() async { + let result = await ShellExecutor.runDetailed(command: ["/bin/sleep", "1"], cwd: nil, env: nil, timeout: 0.05) + #expect(result.timedOut == true) + } + + @Test func shellExecutorDrainsStdoutAndStderr() async { + let script = """ + i=0 + while [ $i -lt 2000 ]; do + echo "stdout-$i" + echo "stderr-$i" 1>&2 + i=$((i+1)) + done + """ + let result = await ShellExecutor.runDetailed( + command: ["/bin/sh", "-c", script], + cwd: nil, + env: nil, + timeout: 2) + #expect(result.success == true) + #expect(result.stdout.contains("stdout-1999")) + #expect(result.stderr.contains("stderr-1999")) + } + + @Test func nodeInfoCodableRoundTrip() throws { + let info = NodeInfo( + nodeId: "node-1", + displayName: "Node One", + platform: "macOS", + version: "1.0", + coreVersion: "1.0-core", + uiVersion: "1.0-ui", + deviceFamily: "Mac", + modelIdentifier: "MacBookPro", + remoteIp: "192.168.1.2", + caps: ["chat"], + commands: ["send"], + permissions: ["send": true], + paired: true, + connected: false) + let data = try JSONEncoder().encode(info) + let decoded = try JSONDecoder().decode(NodeInfo.self, from: data) + #expect(decoded.nodeId == "node-1") + #expect(decoded.isPaired == true) + #expect(decoded.isConnected == false) + } + + @Test @MainActor func presenceReporterHelpers() { + let summary = PresenceReporter._testComposePresenceSummary(mode: "local", reason: "test") + #expect(summary.contains("mode local")) + #expect(!PresenceReporter._testAppVersionString().isEmpty) + #expect(!PresenceReporter._testPlatformString().isEmpty) + _ = PresenceReporter._testLastInputSeconds() + _ = PresenceReporter._testPrimaryIPv4Address() + } + + @Test func portGuardianParsesListenersAndBuildsReports() { + let output = """ + p123 + cnode + uuser + p456 + cssh + uroot + """ + let listeners = PortGuardian._testParseListeners(output) + #expect(listeners.count == 2) + #expect(listeners[0].command == "node") + #expect(listeners[1].command == "ssh") + + let okReport = PortGuardian._testBuildReport( + port: 18789, + mode: .local, + listeners: [(pid: 1, command: "node", fullCommand: "node", user: "me")]) + #expect(okReport.offenders.isEmpty) + + let badReport = PortGuardian._testBuildReport( + port: 18789, + mode: .local, + listeners: [(pid: 2, command: "python", fullCommand: "python", user: "me")]) + #expect(!badReport.offenders.isEmpty) + + let emptyReport = PortGuardian._testBuildReport(port: 18789, mode: .local, listeners: []) + #expect(emptyReport.summary.contains("Nothing is listening")) + } + + @Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws { + let root = FileManager().temporaryDirectory + .appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager().removeItem(at: root) } + try FileManager().createDirectory(at: root, withIntermediateDirectories: true) + let session = root.appendingPathComponent("main", isDirectory: true) + try FileManager().createDirectory(at: session, withIntermediateDirectories: true) + + let index = session.appendingPathComponent("index.html") + try "

Hello

".write(to: index, atomically: true, encoding: .utf8) + + let handler = CanvasSchemeHandler(root: root) + let url = try #require(CanvasScheme.makeURL(session: "main", path: "index.html")) + let response = handler._testResponse(for: url) + #expect(response.mime == "text/html") + #expect(String(data: response.data, encoding: .utf8)?.contains("Hello") == true) + + let invalid = URL(string: "https://example.com")! + let invalidResponse = handler._testResponse(for: invalid) + #expect(invalidResponse.mime == "text/html") + + let missing = try #require(CanvasScheme.makeURL(session: "missing", path: "/")) + let missingResponse = handler._testResponse(for: missing) + #expect(missingResponse.mime == "text/html") + + #expect(handler._testTextEncodingName(for: "text/html") == "utf-8") + #expect(handler._testTextEncodingName(for: "application/octet-stream") == nil) + } + + @Test @MainActor func menuContextCardInjectorInsertsAndFindsIndex() { + let injector = MenuContextCardInjector() + let menu = NSMenu() + menu.minimumWidth = 280 + menu.addItem(NSMenuItem(title: "Active", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem(title: "Quit", action: nil, keyEquivalent: "q")) + + let idx = injector._testFindInsertIndex(in: menu) + #expect(idx == 1) + #expect(injector._testInitialCardWidth(for: menu) >= 300) + + injector._testSetCache(rows: [SessionRow.previewRows[0]], errorText: nil, updatedAt: Date()) + injector.menuWillOpen(menu) + injector.menuDidClose(menu) + + let fallbackMenu = NSMenu() + fallbackMenu.addItem(NSMenuItem(title: "First", action: nil, keyEquivalent: "")) + #expect(injector._testFindInsertIndex(in: fallbackMenu) == 1) + } + + @Test @MainActor func canvasWindowHelperFunctions() { + #expect(CanvasWindowController._testSanitizeSessionKey(" main ") == "main") + #expect(CanvasWindowController._testSanitizeSessionKey("bad/..") == "bad___") + #expect(CanvasWindowController._testJSOptionalStringLiteral(nil) == "null") + + let rect = NSRect(x: 10, y: 12, width: 400, height: 420) + let key = CanvasWindowController._testStoredFrameKey(sessionKey: "test") + let loaded = CanvasWindowController._testStoreAndLoadFrame(sessionKey: "test", frame: rect) + UserDefaults.standard.removeObject(forKey: key) + #expect(loaded?.size.width == rect.size.width) + + let parsed = CanvasWindowController._testParseIPv4("192.168.1.2") + #expect(parsed != nil) + if let parsed { + #expect(CanvasWindowController._testIsLocalNetworkIPv4(parsed)) + } + + let url = URL(string: "http://192.168.1.2")! + #expect(CanvasWindowController._testIsLocalNetworkCanvasURL(url)) + #expect(CanvasWindowController._testParseIPv4("not-an-ip") == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift new file mode 100644 index 00000000..aea7f616 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift @@ -0,0 +1,99 @@ +import AppKit +import OpenClawProtocol +import SwiftUI +import Testing + +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct LowCoverageViewSmokeTests { + @Test func contextMenuCardBuildsBody() { + let loading = ContextMenuCardView(rows: [], statusText: "Loading…", isLoading: true) + _ = loading.body + + let empty = ContextMenuCardView(rows: [], statusText: nil, isLoading: false) + _ = empty.body + + let withRows = ContextMenuCardView(rows: SessionRow.previewRows, statusText: nil, isLoading: false) + _ = withRows.body + } + + @Test func settingsToggleRowBuildsBody() { + var flag = false + let binding = Binding(get: { flag }, set: { flag = $0 }) + let view = SettingsToggleRow(title: "Enable", subtitle: "Detail", binding: binding) + _ = view.body + } + + @Test func voiceWakeTestCardBuildsBodyAcrossStates() { + var state = VoiceWakeTestState.idle + var isTesting = false + let stateBinding = Binding(get: { state }, set: { state = $0 }) + let testingBinding = Binding(get: { isTesting }, set: { isTesting = $0 }) + + _ = VoiceWakeTestCard(testState: stateBinding, isTesting: testingBinding, onToggle: {}).body + + state = .hearing("hello") + _ = VoiceWakeTestCard(testState: stateBinding, isTesting: testingBinding, onToggle: {}).body + + state = .detected("command") + isTesting = true + _ = VoiceWakeTestCard(testState: stateBinding, isTesting: testingBinding, onToggle: {}).body + + state = .failed("No mic") + _ = VoiceWakeTestCard(testState: stateBinding, isTesting: testingBinding, onToggle: {}).body + } + + @Test func agentEventsWindowBuildsBodyWithEvent() { + AgentEventStore.shared.clear() + let sample = ControlAgentEvent( + runId: "run-1", + seq: 1, + stream: "tool", + ts: Date().timeIntervalSince1970 * 1000, + data: ["phase": AnyCodable("start"), "name": AnyCodable("test")], + summary: nil) + AgentEventStore.shared.append(sample) + _ = AgentEventsWindow().body + AgentEventStore.shared.clear() + } + + @Test func notifyOverlayPresentsAndDismisses() async { + let controller = NotifyOverlayController() + controller.present(title: "Hello", body: "World", autoDismissAfter: 0) + controller.present(title: "Updated", body: "Again", autoDismissAfter: 0) + controller.dismiss() + try? await Task.sleep(nanoseconds: 250_000_000) + } + + @Test func visualEffectViewHostsInNSHostingView() { + let hosting = NSHostingView(rootView: VisualEffectView(material: .sidebar)) + _ = hosting.fittingSize + hosting.rootView = VisualEffectView(material: .popover, emphasized: true) + _ = hosting.fittingSize + } + + @Test func menuHostedItemHostsContent() { + let view = MenuHostedItem(width: 240, rootView: AnyView(Text("Menu"))) + let hosting = NSHostingView(rootView: view) + _ = hosting.fittingSize + hosting.rootView = MenuHostedItem(width: 320, rootView: AnyView(Text("Updated"))) + _ = hosting.fittingSize + } + + @Test func dockIconManagerUpdatesVisibility() { + _ = NSApplication.shared + UserDefaults.standard.set(false, forKey: showDockIconKey) + DockIconManager.shared.updateDockVisibility() + DockIconManager.shared.temporarilyShowDock() + } + + @Test func voiceWakeSettingsExercisesHelpers() { + VoiceWakeSettings.exerciseForTesting() + } + + @Test func debugSettingsExercisesHelpers() async { + await DebugSettings.exerciseForTesting() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift new file mode 100644 index 00000000..2d26b7c0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift @@ -0,0 +1,101 @@ +import OpenClawChatUI +import OpenClawProtocol +import Testing +@testable import OpenClaw + +@Suite struct MacGatewayChatTransportMappingTests { + @Test func snapshotMapsToHealth() { + let snapshot = Snapshot( + presence: [], + health: OpenClawProtocol.AnyCodable(["ok": OpenClawProtocol.AnyCodable(false)]), + stateversion: StateVersion(presence: 1, health: 1), + uptimems: 123, + configpath: nil, + statedir: nil, + sessiondefaults: nil, + authmode: nil, + updateavailable: nil) + + let hello = HelloOk( + type: "hello", + _protocol: 2, + server: [:], + features: [:], + snapshot: snapshot, + canvashosturl: nil, + auth: nil, + policy: [:]) + + let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello)) + switch mapped { + case let .health(ok): + #expect(ok == false) + default: + Issue.record("expected .health from snapshot, got \(String(describing: mapped))") + } + } + + @Test func healthEventMapsToHealth() { + let frame = EventFrame( + type: "event", + event: "health", + payload: OpenClawProtocol.AnyCodable(["ok": OpenClawProtocol.AnyCodable(true)]), + seq: 1, + stateversion: nil) + + let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame)) + switch mapped { + case let .health(ok): + #expect(ok == true) + default: + Issue.record("expected .health from health event, got \(String(describing: mapped))") + } + } + + @Test func tickEventMapsToTick() { + let frame = EventFrame(type: "event", event: "tick", payload: nil, seq: 1, stateversion: nil) + let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame)) + #expect({ + if case .tick = mapped { return true } + return false + }()) + } + + @Test func chatEventMapsToChat() { + let payload = OpenClawProtocol.AnyCodable([ + "runId": OpenClawProtocol.AnyCodable("run-1"), + "sessionKey": OpenClawProtocol.AnyCodable("main"), + "state": OpenClawProtocol.AnyCodable("final"), + ]) + let frame = EventFrame(type: "event", event: "chat", payload: payload, seq: 1, stateversion: nil) + let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame)) + + switch mapped { + case let .chat(chat): + #expect(chat.runId == "run-1") + #expect(chat.sessionKey == "main") + #expect(chat.state == "final") + default: + Issue.record("expected .chat from chat event, got \(String(describing: mapped))") + } + } + + @Test func unknownEventMapsToNil() { + let frame = EventFrame( + type: "event", + event: "unknown", + payload: OpenClawProtocol.AnyCodable(["a": OpenClawProtocol.AnyCodable(1)]), + seq: 1, + stateversion: nil) + let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame)) + #expect(mapped == nil) + } + + @Test func seqGapMapsToSeqGap() { + let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.seqGap(expected: 1, received: 9)) + #expect({ + if case .seqGap = mapped { return true } + return false + }()) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift new file mode 100644 index 00000000..86625624 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift @@ -0,0 +1,97 @@ +import OpenClawKit +import CoreLocation +import Foundation +import Testing +@testable import OpenClaw + +struct MacNodeRuntimeTests { + @Test func handleInvokeRejectsUnknownCommand() async { + let runtime = MacNodeRuntime() + let response = await runtime.handleInvoke( + BridgeInvokeRequest(id: "req-1", command: "unknown.command")) + #expect(response.ok == false) + } + + @Test func handleInvokeRejectsEmptySystemRun() async throws { + let runtime = MacNodeRuntime() + let params = OpenClawSystemRunParams(command: []) + let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) + let response = await runtime.handleInvoke( + BridgeInvokeRequest(id: "req-2", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json)) + #expect(response.ok == false) + } + + @Test func handleInvokeRejectsEmptySystemWhich() async throws { + let runtime = MacNodeRuntime() + let params = OpenClawSystemWhichParams(bins: []) + let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) + let response = await runtime.handleInvoke( + BridgeInvokeRequest(id: "req-2b", command: OpenClawSystemCommand.which.rawValue, paramsJSON: json)) + #expect(response.ok == false) + } + + @Test func handleInvokeRejectsEmptyNotification() async throws { + let runtime = MacNodeRuntime() + let params = OpenClawSystemNotifyParams(title: "", body: "") + let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) + let response = await runtime.handleInvoke( + BridgeInvokeRequest(id: "req-3", command: OpenClawSystemCommand.notify.rawValue, paramsJSON: json)) + #expect(response.ok == false) + } + + @Test func handleInvokeCameraListRequiresEnabledCamera() async { + await TestIsolation.withUserDefaultsValues([cameraEnabledKey: false]) { + let runtime = MacNodeRuntime() + let response = await runtime.handleInvoke( + BridgeInvokeRequest(id: "req-4", command: OpenClawCameraCommand.list.rawValue)) + #expect(response.ok == false) + #expect(response.error?.message.contains("CAMERA_DISABLED") == true) + } + } + + @Test func handleInvokeScreenRecordUsesInjectedServices() async throws { + @MainActor + final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable { + func recordScreen( + screenIndex: Int?, + durationMs: Int?, + fps: Double?, + includeAudio: Bool?, + outPath: String?) async throws -> (path: String, hasAudio: Bool) + { + let url = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-test-screen-record-\(UUID().uuidString).mp4") + try Data("ok".utf8).write(to: url) + return (path: url.path, hasAudio: false) + } + + func locationAuthorizationStatus() -> CLAuthorizationStatus { .authorizedAlways } + func locationAccuracyAuthorization() -> CLAccuracyAuthorization { .fullAccuracy } + func currentLocation( + desiredAccuracy: OpenClawLocationAccuracy, + maxAgeMs: Int?, + timeoutMs: Int?) async throws -> CLLocation + { + CLLocation(latitude: 0, longitude: 0) + } + } + + let services = await MainActor.run { FakeMainActorServices() } + let runtime = MacNodeRuntime(makeMainActorServices: { services }) + + let params = MacNodeScreenRecordParams(durationMs: 250) + let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) + let response = await runtime.handleInvoke( + BridgeInvokeRequest(id: "req-5", command: MacNodeScreenCommand.record.rawValue, paramsJSON: json)) + #expect(response.ok == true) + let payloadJSON = try #require(response.payloadJSON) + + struct Payload: Decodable { + var format: String + var base64: String + } + let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8)) + #expect(payload.format == "mp4") + #expect(!payload.base64.isEmpty) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MasterDiscoveryMenuSmokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MasterDiscoveryMenuSmokeTests.swift new file mode 100644 index 00000000..c6d58cc3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MasterDiscoveryMenuSmokeTests.swift @@ -0,0 +1,78 @@ +import OpenClawDiscovery +import SwiftUI +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct MasterDiscoveryMenuSmokeTests { + @Test func inlineListBuildsBodyWhenEmpty() { + let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) + discovery.statusText = "Searching…" + discovery.gateways = [] + + let view = GatewayDiscoveryInlineList( + discovery: discovery, + currentTarget: nil, + currentUrl: nil, + transport: .ssh, + onSelect: { _ in }) + _ = view.body + } + + @Test func inlineListBuildsBodyWithMasterAndSelection() { + let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) + discovery.statusText = "Found 1" + discovery.gateways = [ + GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Office Mac", + lanHost: "office.local", + tailnetDns: "office.tailnet-123.ts.net", + sshPort: 2222, + gatewayPort: nil, + cliPath: nil, + stableID: "office", + debugID: "office", + isLocal: false), + ] + + let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222" + let view = GatewayDiscoveryInlineList( + discovery: discovery, + currentTarget: currentTarget, + currentUrl: nil, + transport: .ssh, + onSelect: { _ in }) + _ = view.body + } + + @Test func menuBuildsBodyWithMasters() { + let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) + discovery.statusText = "Found 2" + discovery.gateways = [ + GatewayDiscoveryModel.DiscoveredGateway( + displayName: "A", + lanHost: "a.local", + tailnetDns: nil, + sshPort: 22, + gatewayPort: nil, + cliPath: nil, + stableID: "a", + debugID: "a", + isLocal: false), + GatewayDiscoveryModel.DiscoveredGateway( + displayName: "B", + lanHost: nil, + tailnetDns: "b.ts.net", + sshPort: 22, + gatewayPort: nil, + cliPath: nil, + stableID: "b", + debugID: "b", + isLocal: false), + ] + + let view = GatewayDiscoveryMenu(discovery: discovery, onSelect: { _ in }) + _ = view.body + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift new file mode 100644 index 00000000..a5778214 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift @@ -0,0 +1,41 @@ +import SwiftUI +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct MenuContentSmokeTests { + @Test func menuContentBuildsBodyLocalMode() { + let state = AppState(preview: true) + state.connectionMode = .local + let view = MenuContent(state: state, updater: nil) + _ = view.body + } + + @Test func menuContentBuildsBodyRemoteMode() { + let state = AppState(preview: true) + state.connectionMode = .remote + let view = MenuContent(state: state, updater: nil) + _ = view.body + } + + @Test func menuContentBuildsBodyUnconfiguredMode() { + let state = AppState(preview: true) + state.connectionMode = .unconfigured + let view = MenuContent(state: state, updater: nil) + _ = view.body + } + + @Test func menuContentBuildsBodyWithDebugAndCanvas() { + let state = AppState(preview: true) + state.connectionMode = .local + state.debugPaneEnabled = true + state.canvasEnabled = true + state.canvasPanelVisible = true + state.swabbleEnabled = true + state.voicePushToTalkEnabled = true + state.heartbeatsEnabled = true + let view = MenuContent(state: state, updater: nil) + _ = view.body + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift new file mode 100644 index 00000000..ff63673b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift @@ -0,0 +1,137 @@ +import AppKit +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct MenuSessionsInjectorTests { + @Test func injectsDisconnectedMessage() { + let injector = MenuSessionsInjector() + injector.setTestingControlChannelConnected(false) + injector.setTestingSnapshot(nil, errorText: nil) + + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + + injector.injectForTesting(into: menu) + #expect(menu.items.contains { $0.tag == 9_415_557 }) + } + + @Test func injectsSessionRows() { + let injector = MenuSessionsInjector() + injector.setTestingControlChannelConnected(true) + + let defaults = SessionDefaults(model: "anthropic/claude-opus-4-6", contextTokens: 200_000) + let rows = [ + SessionRow( + id: "main", + key: "main", + kind: .direct, + displayName: nil, + provider: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: Date(), + sessionId: "s1", + thinkingLevel: "low", + verboseLevel: nil, + systemSent: false, + abortedLastRun: false, + tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000), + model: "claude-opus-4-6"), + SessionRow( + id: "discord:group:alpha", + key: "discord:group:alpha", + kind: .group, + displayName: nil, + provider: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: Date(timeIntervalSinceNow: -60), + sessionId: "s2", + thinkingLevel: "high", + verboseLevel: "debug", + systemSent: true, + abortedLastRun: true, + tokens: SessionTokenStats(input: 50, output: 50, total: 100, contextTokens: 200_000), + model: "claude-opus-4-6"), + ] + let snapshot = SessionStoreSnapshot( + storePath: "/tmp/sessions.json", + defaults: defaults, + rows: rows) + injector.setTestingSnapshot(snapshot, errorText: nil) + + let usage = GatewayUsageSummary( + updatedAt: Date().timeIntervalSince1970 * 1000, + providers: [ + GatewayUsageProvider( + provider: "anthropic", + displayName: "Claude", + windows: [GatewayUsageWindow(label: "5h", usedPercent: 12, resetAt: nil)], + plan: "Pro", + error: nil), + GatewayUsageProvider( + provider: "openai-codex", + displayName: "Codex", + windows: [GatewayUsageWindow(label: "day", usedPercent: 3, resetAt: nil)], + plan: nil, + error: nil), + ]) + injector.setTestingUsageSummary(usage, errorText: nil) + + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + + injector.injectForTesting(into: menu) + #expect(menu.items.contains { $0.tag == 9_415_557 }) + #expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem }) + } + + @Test func costUsageSubmenuDoesNotUseInjectorDelegate() { + let injector = MenuSessionsInjector() + injector.setTestingControlChannelConnected(true) + + let summary = GatewayCostUsageSummary( + updatedAt: Date().timeIntervalSince1970 * 1000, + days: 1, + daily: [ + GatewayCostUsageDay( + date: "2026-02-24", + input: 10, + output: 20, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 30, + totalCost: 0.12, + missingCostEntries: 0), + ], + totals: GatewayCostUsageTotals( + input: 10, + output: 20, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 30, + totalCost: 0.12, + missingCostEntries: 0)) + injector.setTestingCostUsageSummary(summary, errorText: nil) + + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: "")) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: "")) + + injector.injectForTesting(into: menu) + + let usageCostItem = menu.items.first { $0.title == "Usage cost (30 days)" } + #expect(usageCostItem != nil) + #expect(usageCostItem?.submenu != nil) + #expect(usageCostItem?.submenu?.delegate == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift new file mode 100644 index 00000000..05ed6f85 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite +struct ModelCatalogLoaderTests { + @Test + func loadParsesModelsFromTypeScriptAndSorts() async throws { + let src = """ + export const MODELS = { + openai: { + "gpt-4o-mini": { name: "GPT-4o mini", contextWindow: 128000 } satisfies any, + "gpt-4o": { name: "GPT-4o", contextWindow: 128000 } as any, + "gpt-3.5": { contextWindow: 16000 }, + }, + anthropic: { + "claude-3": { name: "Claude 3", contextWindow: 200000 }, + }, + }; + """ + + let tmp = FileManager().temporaryDirectory + .appendingPathComponent("models-\(UUID().uuidString).ts") + defer { try? FileManager().removeItem(at: tmp) } + try src.write(to: tmp, atomically: true, encoding: .utf8) + + let choices = try await ModelCatalogLoader.load(from: tmp.path) + #expect(choices.count == 4) + #expect(choices.first?.provider == "anthropic") + #expect(choices.first?.id == "claude-3") + + let ids = Set(choices.map(\.id)) + #expect(ids == Set(["claude-3", "gpt-4o", "gpt-4o-mini", "gpt-3.5"])) + + let openai = choices.filter { $0.provider == "openai" } + let openaiNames = openai.map(\.name) + #expect(openaiNames == openaiNames.sorted { a, b in + a.localizedCaseInsensitiveCompare(b) == .orderedAscending + }) + } + + @Test + func loadWithNoExportReturnsEmptyChoices() async throws { + let src = "const NOPE = 1;" + let tmp = FileManager().temporaryDirectory + .appendingPathComponent("models-\(UUID().uuidString).ts") + defer { try? FileManager().removeItem(at: tmp) } + try src.write(to: tmp, atomically: true, encoding: .utf8) + + let choices = try await ModelCatalogLoader.load(from: tmp.path) + #expect(choices.isEmpty) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift new file mode 100644 index 00000000..98f7b4c8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) +struct NixModeStableSuiteTests { + @Test func resolvesFromStableSuiteForAppBundles() { + let suite = UserDefaults(suiteName: launchdLabel)! + let key = "openclaw.nixMode" + let prev = suite.object(forKey: key) + defer { + if let prev { suite.set(prev, forKey: key) } else { suite.removeObject(forKey: key) } + } + + suite.set(true, forKey: key) + + let standard = UserDefaults(suiteName: "NixModeStableSuiteTests.\(UUID().uuidString)")! + #expect(!standard.bool(forKey: key)) + + let resolved = ProcessInfo.resolveNixMode( + environment: [:], + standard: standard, + stableSuite: suite, + isAppBundle: true) + #expect(resolved) + } + + @Test func ignoresStableSuiteOutsideAppBundles() { + let suite = UserDefaults(suiteName: launchdLabel)! + let key = "openclaw.nixMode" + let prev = suite.object(forKey: key) + defer { + if let prev { suite.set(prev, forKey: key) } else { suite.removeObject(forKey: key) } + } + + suite.set(true, forKey: key) + let standard = UserDefaults(suiteName: "NixModeStableSuiteTests.\(UUID().uuidString)")! + + let resolved = ProcessInfo.resolveNixMode( + environment: [:], + standard: standard, + stableSuite: suite, + isAppBundle: false) + #expect(!resolved) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift new file mode 100644 index 00000000..9ee41b4f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct NodeManagerPathsTests { + private func makeTempDir() throws -> URL { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func makeExec(at path: URL) throws { + try FileManager().createDirectory( + at: path.deletingLastPathComponent(), + withIntermediateDirectories: true) + FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8)) + try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) + } + + @Test func fnmNodeBinsPreferNewestInstalledVersion() throws { + let home = try self.makeTempDir() + + let v20Bin = home + .appendingPathComponent(".local/share/fnm/node-versions/v20.19.5/installation/bin/node") + let v25Bin = home + .appendingPathComponent(".local/share/fnm/node-versions/v25.1.0/installation/bin/node") + try self.makeExec(at: v20Bin) + try self.makeExec(at: v25Bin) + + let bins = CommandResolver._testNodeManagerBinPaths(home: home) + #expect(bins.first == v25Bin.deletingLastPathComponent().path) + #expect(bins.contains(v20Bin.deletingLastPathComponent().path)) + } + + @Test func ignoresEntriesWithoutNodeExecutable() throws { + let home = try self.makeTempDir() + let missingNodeBin = home + .appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin") + try FileManager().createDirectory(at: missingNodeBin, withIntermediateDirectories: true) + + let bins = CommandResolver._testNodeManagerBinPaths(home: home) + #expect(!bins.contains(missingNodeBin.path)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift new file mode 100644 index 00000000..7c2a90e4 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift @@ -0,0 +1,10 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct NodePairingApprovalPrompterTests { + @Test func nodePairingApprovalPrompterExercises() async { + await NodePairingApprovalPrompter.exerciseForTesting() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift new file mode 100644 index 00000000..cc1113f7 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift @@ -0,0 +1,14 @@ +import Testing +@testable import OpenClaw + +@Suite struct NodePairingReconcilePolicyTests { + @Test func policyPollsOnlyWhenActive() { + #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: false) == false) + #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 1, isPresenting: false)) + #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: true)) + } + + @Test func policyUsesSlowSafetyInterval() { + #expect(NodePairingReconcilePolicy.activeIntervalMs >= 10000) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OnboardingCoverageTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OnboardingCoverageTests.swift new file mode 100644 index 00000000..e79d0026 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OnboardingCoverageTests.swift @@ -0,0 +1,10 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct OnboardingCoverageTests { + @Test func exerciseOnboardingPages() { + OnboardingView.exerciseForTesting() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift new file mode 100644 index 00000000..b824b2b0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift @@ -0,0 +1,61 @@ +import Foundation +import OpenClawDiscovery +import SwiftUI +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct OnboardingViewSmokeTests { + @Test func onboardingViewBuildsBody() { + let state = AppState(preview: true) + let view = OnboardingView( + state: state, + permissionMonitor: PermissionMonitor.shared, + discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)) + _ = view.body + } + + @Test func pageOrderOmitsWorkspaceAndIdentitySteps() { + let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false) + #expect(!order.contains(7)) + #expect(order.contains(3)) + } + + @Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() { + let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false) + #expect(!order.contains(8)) + } + + @Test func selectRemoteGatewayClearsStaleSshTargetWhenEndpointUnresolved() async { + let override = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-config-\(UUID().uuidString)") + .appendingPathComponent("openclaw.json") + .path + + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { + let state = AppState(preview: true) + state.remoteTransport = .ssh + state.remoteTarget = "user@old-host:2222" + let view = OnboardingView( + state: state, + permissionMonitor: PermissionMonitor.shared, + discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)) + let gateway = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Unresolved", + serviceHost: nil, + servicePort: nil, + lanHost: "txt-host.local", + tailnetDns: "txt-host.ts.net", + sshPort: 22, + gatewayPort: 18789, + cliPath: "/tmp/openclaw", + stableID: UUID().uuidString, + debugID: UUID().uuidString, + isLocal: false) + + view.selectRemoteGateway(gateway) + #expect(state.remoteTarget.isEmpty) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift new file mode 100644 index 00000000..7211482f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift @@ -0,0 +1,44 @@ +import OpenClawProtocol +import SwiftUI +import Testing +@testable import OpenClaw + +private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable + +@Suite(.serialized) +@MainActor +struct OnboardingWizardStepViewTests { + @Test func noteStepBuilds() { + let step = WizardStep( + id: "step-1", + type: ProtoAnyCodable("note"), + title: "Welcome", + message: "Hello", + options: nil, + initialvalue: nil, + placeholder: nil, + sensitive: nil, + executor: nil) + let view = OnboardingWizardStepView(step: step, isSubmitting: false, onSubmit: { _ in }) + _ = view.body + } + + @Test func selectStepBuilds() { + let options: [[String: ProtoAnyCodable]] = [ + ["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")], + ["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")], + ] + let step = WizardStep( + id: "step-2", + type: ProtoAnyCodable("select"), + title: "Mode", + message: "Choose a mode", + options: options, + initialvalue: ProtoAnyCodable("local"), + placeholder: nil, + sensitive: nil, + executor: nil) + let view = OnboardingWizardStepView(step: step, isSubmitting: false, onSubmit: { _ in }) + _ = view.body + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift new file mode 100644 index 00000000..2cd9d643 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) +struct OpenClawConfigFileTests { + @Test + func configPathRespectsEnvOverride() async { + let override = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-config-\(UUID().uuidString)") + .appendingPathComponent("openclaw.json") + .path + + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { + #expect(OpenClawConfigFile.url().path == override) + } + } + + @MainActor + @Test + func remoteGatewayPortParsesAndMatchesHost() async { + let override = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-config-\(UUID().uuidString)") + .appendingPathComponent("openclaw.json") + .path + + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "remote": [ + "url": "ws://gateway.ts.net:19999", + ], + ], + ]) + #expect(OpenClawConfigFile.remoteGatewayPort() == 19999) + #expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway.ts.net") == 19999) + #expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway") == 19999) + #expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil) + } + } + + @MainActor + @Test + func setRemoteGatewayUrlPreservesScheme() async { + let override = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-config-\(UUID().uuidString)") + .appendingPathComponent("openclaw.json") + .path + + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "remote": [ + "url": "wss://old-host:111", + ], + ], + ]) + OpenClawConfigFile.setRemoteGatewayUrl(host: "new-host", port: 2222) + let root = OpenClawConfigFile.loadDict() + let url = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any])?["url"] as? String + #expect(url == "wss://new-host:2222") + } + } + + @MainActor + @Test + func clearRemoteGatewayUrlRemovesOnlyUrlField() async { + let override = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-config-\(UUID().uuidString)") + .appendingPathComponent("openclaw.json") + .path + + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "remote": [ + "url": "wss://old-host:111", + "token": "tok", + ], + ], + ]) + OpenClawConfigFile.clearRemoteGatewayUrl() + let root = OpenClawConfigFile.loadDict() + let remote = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:] + #expect((remote["url"] as? String) == nil) + #expect((remote["token"] as? String) == "tok") + } + } + + @Test + func stateDirOverrideSetsConfigPath() async { + let dir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + .path + + await TestIsolation.withEnvValues([ + "OPENCLAW_CONFIG_PATH": nil, + "OPENCLAW_STATE_DIR": dir, + ]) { + #expect(OpenClawConfigFile.stateDirURL().path == dir) + #expect(OpenClawConfigFile.url().path == "\(dir)/openclaw.json") + } + } + + @MainActor + @Test + func saveDictAppendsConfigAuditLog() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + let configPath = stateDir.appendingPathComponent("openclaw.json") + let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl") + + defer { try? FileManager().removeItem(at: stateDir) } + + try await TestIsolation.withEnvValues([ + "OPENCLAW_STATE_DIR": stateDir.path, + "OPENCLAW_CONFIG_PATH": configPath.path, + ]) { + OpenClawConfigFile.saveDict([ + "gateway": ["mode": "local"], + ]) + + let configData = try Data(contentsOf: configPath) + let configRoot = try JSONSerialization.jsonObject(with: configData) as? [String: Any] + #expect((configRoot?["meta"] as? [String: Any]) != nil) + + let rawAudit = try String(contentsOf: auditPath, encoding: .utf8) + let lines = rawAudit + .split(whereSeparator: \.isNewline) + .map(String.init) + #expect(!lines.isEmpty) + guard let last = lines.last else { + Issue.record("Missing config audit line") + return + } + let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any] + #expect(auditRoot?["source"] as? String == "macos-openclaw-config-file") + #expect(auditRoot?["event"] as? String == "config.write") + #expect(auditRoot?["result"] as? String == "success") + #expect(auditRoot?["configPath"] as? String == configPath.path) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift new file mode 100644 index 00000000..b34e9c30 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/OpenClawOAuthStoreTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite +struct OpenClawOAuthStoreTests { + @Test + func returnsMissingWhenFileAbsent() { + let url = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)") + .appendingPathComponent("oauth.json") + #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingFile) + } + + @Test + func usesEnvOverrideForOpenClawOAuthDir() throws { + let key = "OPENCLAW_OAUTH_DIR" + let previous = ProcessInfo.processInfo.environment[key] + defer { + if let previous { + setenv(key, previous, 1) + } else { + unsetenv(key) + } + } + + let dir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true) + setenv(key, dir.path, 1) + + #expect(OpenClawOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL) + } + + @Test + func acceptsPiFormatTokens() throws { + let url = try self.writeOAuthFile([ + "anthropic": [ + "type": "oauth", + "refresh": "r1", + "access": "a1", + "expires": 1_234_567_890, + ], + ]) + + #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected) + } + + @Test + func acceptsTokenKeyVariants() throws { + let url = try self.writeOAuthFile([ + "anthropic": [ + "type": "oauth", + "refresh_token": "r1", + "access_token": "a1", + ], + ]) + + #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected) + } + + @Test + func reportsMissingProviderEntry() throws { + let url = try self.writeOAuthFile([ + "other": [ + "type": "oauth", + "refresh": "r1", + "access": "a1", + ], + ]) + + #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry) + } + + @Test + func reportsMissingTokens() throws { + let url = try self.writeOAuthFile([ + "anthropic": [ + "type": "oauth", + "refresh": "", + "access": "a1", + ], + ]) + + #expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens) + } + + private func writeOAuthFile(_ json: [String: Any]) throws -> URL { + let dir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true) + try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) + + let url = dir.appendingPathComponent("oauth.json") + let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url, options: [.atomic]) + return url + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift new file mode 100644 index 00000000..871998cb --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift @@ -0,0 +1,20 @@ +import CoreLocation +import Testing + +@testable import OpenClaw + +@Suite("PermissionManager Location") +struct PermissionManagerLocationTests { + @Test("authorizedAlways counts for both modes") + func authorizedAlwaysCountsForBothModes() { + #expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: false)) + #expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: true)) + } + + @Test("other statuses not authorized") + func otherStatusesNotAuthorized() { + #expect(!PermissionManager.isLocationAuthorized(status: .notDetermined, requireAlways: false)) + #expect(!PermissionManager.isLocationAuthorized(status: .denied, requireAlways: false)) + #expect(!PermissionManager.isLocationAuthorized(status: .restricted, requireAlways: false)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift new file mode 100644 index 00000000..5e41339f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift @@ -0,0 +1,38 @@ +import OpenClawIPC +import CoreLocation +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct PermissionManagerTests { + @Test func voiceWakePermissionHelpersMatchStatus() async { + let direct = PermissionManager.voiceWakePermissionsGranted() + let ensured = await PermissionManager.ensureVoiceWakePermissions(interactive: false) + #expect(ensured == direct) + } + + @Test func statusCanQueryNonInteractiveCaps() async { + let caps: [Capability] = [.microphone, .speechRecognition, .screenRecording] + let status = await PermissionManager.status(caps) + #expect(status.keys.count == caps.count) + } + + @Test func ensureNonInteractiveDoesNotThrow() async { + let caps: [Capability] = [.microphone, .speechRecognition, .screenRecording] + let ensured = await PermissionManager.ensure(caps, interactive: false) + #expect(ensured.keys.count == caps.count) + } + + @Test func locationStatusMatchesAuthorizationAlways() async { + let status = CLLocationManager().authorizationStatus + let results = await PermissionManager.status([.location]) + #expect(results[.location] == (status == .authorizedAlways)) + } + + @Test func ensureLocationNonInteractiveMatchesAuthorizationAlways() async { + let status = CLLocationManager().authorizationStatus + let ensured = await PermissionManager.ensure([.location], interactive: false) + #expect(ensured[.location] == (status == .authorizedAlways)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/Placeholder.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/Placeholder.swift new file mode 100644 index 00000000..14e5c056 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/Placeholder.swift @@ -0,0 +1,7 @@ +import Testing + +@Suite struct PlaceholderTests { + @Test func placeholder() { + #expect(true) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift new file mode 100644 index 00000000..856af896 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift @@ -0,0 +1,74 @@ +import Testing +@testable import OpenClaw + +#if canImport(Darwin) +import Darwin +import Foundation + +@Suite struct RemotePortTunnelTests { + @Test func drainStderrDoesNotCrashWhenHandleClosed() { + let pipe = Pipe() + let handle = pipe.fileHandleForReading + try? handle.close() + + let drained = RemotePortTunnel._testDrainStderr(handle) + #expect(drained.isEmpty) + } + + @Test func portIsFreeDetectsIPv4Listener() { + var fd = socket(AF_INET, SOCK_STREAM, 0) + #expect(fd >= 0) + guard fd >= 0 else { return } + defer { + if fd >= 0 { _ = Darwin.close(fd) } + } + + var one: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size(ofValue: one))) + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = 0 + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bound = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + Darwin.bind(fd, sa, socklen_t(MemoryLayout.size)) + } + } + #expect(bound == 0) + guard bound == 0 else { return } + #expect(Darwin.listen(fd, 1) == 0) + + var name = sockaddr_in() + var nameLen = socklen_t(MemoryLayout.size) + let got = withUnsafeMutablePointer(to: &name) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + getsockname(fd, sa, &nameLen) + } + } + #expect(got == 0) + guard got == 0 else { return } + + let port = UInt16(bigEndian: name.sin_port) + #expect(RemotePortTunnel._testPortIsFree(port) == false) + + _ = Darwin.close(fd) + fd = -1 + + // In parallel test runs, another test may briefly grab the same ephemeral port. + // Poll for a short window to avoid flakiness. + let deadline = Date().addingTimeInterval(0.5) + var free = false + while Date() < deadline { + if RemotePortTunnel._testPortIsFree(port) { + free = true + break + } + usleep(10000) // 10ms + } + #expect(free == true) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift new file mode 100644 index 00000000..6662132c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct RuntimeLocatorTests { + private func makeTempExecutable(contents: String) throws -> URL { + let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) + let path = dir.appendingPathComponent("node") + try contents.write(to: path, atomically: true, encoding: .utf8) + try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) + return path + } + + @Test func resolveSucceedsWithValidNode() throws { + let script = """ + #!/bin/sh + echo v22.5.0 + """ + let node = try self.makeTempExecutable(contents: script) + let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) + guard case let .success(res) = result else { + Issue.record("Expected success, got \(result)") + return + } + #expect(res.path == node.path) + #expect(res.version == RuntimeVersion(major: 22, minor: 5, patch: 0)) + } + + @Test func resolveFailsWhenTooOld() throws { + let script = """ + #!/bin/sh + echo v18.2.0 + """ + let node = try self.makeTempExecutable(contents: script) + let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) + guard case let .failure(.unsupported(_, found, _, path, _)) = result else { + Issue.record("Expected unsupported error, got \(result)") + return + } + #expect(found == RuntimeVersion(major: 18, minor: 2, patch: 0)) + #expect(path == node.path) + } + + @Test func resolveFailsWhenVersionUnparsable() throws { + let script = """ + #!/bin/sh + echo node-version:unknown + """ + let node = try self.makeTempExecutable(contents: script) + let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) + guard case let .failure(.versionParse(_, raw, path, _)) = result else { + Issue.record("Expected versionParse error, got \(result)") + return + } + #expect(raw.contains("unknown")) + #expect(path == node.path) + } + + @Test func describeFailureIncludesPaths() { + let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"])) + #expect(msg.contains("PATH searched: /tmp/a:/tmp/b")) + } + + @Test func runtimeVersionParsesWithLeadingVAndMetadata() { + #expect(RuntimeVersion.from(string: "v22.1.3") == RuntimeVersion(major: 22, minor: 1, patch: 3)) + #expect(RuntimeVersion.from(string: "node 22.3.0-alpha.1") == RuntimeVersion(major: 22, minor: 3, patch: 0)) + #expect(RuntimeVersion.from(string: "bogus") == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ScreenshotSizeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ScreenshotSizeTests.swift new file mode 100644 index 00000000..84fe1775 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/ScreenshotSizeTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite +struct ScreenshotSizeTests { + @Test + func readPNGSizeReturnsDimensions() throws { + let pngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+WZxkAAAAASUVORK5CYII=" + let data = try #require(Data(base64Encoded: pngBase64)) + let size = ScreenshotSize.readPNGSize(data: data) + #expect(size?.width == 1) + #expect(size?.height == 1) + } + + @Test + func readPNGSizeRejectsNonPNGData() { + #expect(ScreenshotSize.readPNGSize(data: Data("nope".utf8)) == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift new file mode 100644 index 00000000..83d8e847 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift @@ -0,0 +1,21 @@ +import Testing +@testable import OpenClaw + +@Suite struct SemverTests { + @Test func comparisonOrdersByMajorMinorPatch() { + let a = Semver(major: 1, minor: 0, patch: 0) + let b = Semver(major: 1, minor: 1, patch: 0) + let c = Semver(major: 1, minor: 1, patch: 1) + let d = Semver(major: 2, minor: 0, patch: 0) + + #expect(a < b) + #expect(b < c) + #expect(c < d) + #expect(d > a) + } + + @Test func descriptionMatchesParts() { + let v = Semver(major: 3, minor: 2, patch: 1) + #expect(v.description == "3.2.1") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift new file mode 100644 index 00000000..f1594ba7 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite +struct SessionDataTests { + @Test func sessionKindFromKeyDetectsCommonKinds() { + #expect(SessionKind.from(key: "global") == .global) + #expect(SessionKind.from(key: "discord:group:engineering") == .group) + #expect(SessionKind.from(key: "unknown") == .unknown) + #expect(SessionKind.from(key: "user@example.com") == .direct) + } + + @Test func sessionTokenStatsFormatKTokensRoundsAsExpected() { + #expect(SessionTokenStats.formatKTokens(999) == "999") + #expect(SessionTokenStats.formatKTokens(1000) == "1.0k") + #expect(SessionTokenStats.formatKTokens(12340) == "12k") + } + + @Test func sessionTokenStatsPercentUsedClampsTo100() { + let stats = SessionTokenStats(input: 0, output: 0, total: 250_000, contextTokens: 200_000) + #expect(stats.percentUsed == 100) + } + + @Test func sessionRowFlagLabelsIncludeNonDefaultFlags() { + let row = SessionRow( + id: "x", + key: "user@example.com", + kind: .direct, + displayName: nil, + provider: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: Date(), + sessionId: nil, + thinkingLevel: "high", + verboseLevel: "debug", + systemSent: true, + abortedLastRun: true, + tokens: SessionTokenStats(input: 1, output: 2, total: 3, contextTokens: 10), + model: nil) + #expect(row.flagLabels.contains("think high")) + #expect(row.flagLabels.contains("verbose debug")) + #expect(row.flagLabels.contains("system sent")) + #expect(row.flagLabels.contains("aborted")) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SessionMenuPreviewTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SessionMenuPreviewTests.swift new file mode 100644 index 00000000..44bb3c39 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SessionMenuPreviewTests.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) +struct SessionMenuPreviewTests { + @Test func loaderReturnsCachedItems() async { + await SessionPreviewCache.shared._testReset() + let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")] + let snapshot = SessionMenuPreviewSnapshot(items: items, status: .ready) + await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main") + + let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10) + #expect(loaded.status == .ready) + #expect(loaded.items.count == 1) + #expect(loaded.items.first?.text == "Hi") + } + + @Test func loaderReturnsEmptyWhenCachedEmpty() async { + await SessionPreviewCache.shared._testReset() + let snapshot = SessionMenuPreviewSnapshot(items: [], status: .empty) + await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main") + + let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10) + #expect(loaded.status == .empty) + #expect(loaded.items.isEmpty) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift new file mode 100644 index 00000000..f9de602e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift @@ -0,0 +1,165 @@ +import SwiftUI +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct SettingsViewSmokeTests { + @Test func cronSettingsBuildsBody() { + let store = CronJobsStore(isPreview: true) + store.schedulerEnabled = false + store.schedulerStorePath = "/tmp/openclaw-cron-store.json" + + let job1 = CronJob( + id: "job-1", + agentId: "ops", + name: " Morning Check-in ", + description: nil, + enabled: true, + deleteAfterRun: nil, + createdAtMs: 1_700_000_000_000, + updatedAtMs: 1_700_000_100_000, + schedule: .cron(expr: "0 8 * * *", tz: "UTC"), + sessionTarget: .main, + wakeMode: .now, + payload: .systemEvent(text: "ping"), + delivery: nil, + state: CronJobState( + nextRunAtMs: 1_700_000_200_000, + runningAtMs: nil, + lastRunAtMs: 1_700_000_050_000, + lastStatus: "ok", + lastError: nil, + lastDurationMs: 123)) + + let job2 = CronJob( + id: "job-2", + agentId: nil, + name: "", + description: nil, + enabled: false, + deleteAfterRun: nil, + createdAtMs: 1_700_000_000_000, + updatedAtMs: 1_700_000_100_000, + schedule: .every(everyMs: 30000, anchorMs: nil), + sessionTarget: .isolated, + wakeMode: .nextHeartbeat, + payload: .agentTurn( + message: "hello", + thinking: "low", + timeoutSeconds: 30, + deliver: nil, + channel: nil, + to: nil, + bestEffortDeliver: nil), + delivery: CronDelivery(mode: .announce, channel: "sms", to: "+15551234567", bestEffort: true), + state: CronJobState( + nextRunAtMs: nil, + runningAtMs: nil, + lastRunAtMs: nil, + lastStatus: nil, + lastError: nil, + lastDurationMs: nil)) + + store.jobs = [job1, job2] + store.selectedJobId = job1.id + store.runEntries = [ + CronRunLogEntry( + ts: 1_700_000_050_000, + jobId: job1.id, + action: "finished", + status: "ok", + error: nil, + summary: "ok", + runAtMs: 1_700_000_050_000, + durationMs: 123, + nextRunAtMs: 1_700_000_200_000), + ] + + let view = CronSettings(store: store) + _ = view.body + } + + @Test func cronSettingsExercisesPrivateViews() { + CronSettings.exerciseForTesting() + } + + @Test func configSettingsBuildsBody() { + let view = ConfigSettings() + _ = view.body + } + + @Test func debugSettingsBuildsBody() { + let view = DebugSettings() + _ = view.body + } + + @Test func generalSettingsBuildsBody() { + let state = AppState(preview: true) + let view = GeneralSettings(state: state) + _ = view.body + } + + @Test func generalSettingsExercisesBranches() { + GeneralSettings.exerciseForTesting() + } + + @Test func sessionsSettingsBuildsBody() { + let view = SessionsSettings(rows: SessionRow.previewRows, isPreview: true) + _ = view.body + } + + @Test func instancesSettingsBuildsBody() { + let store = InstancesStore(isPreview: true) + store.instances = [ + InstanceInfo( + id: "local", + host: "this-mac", + ip: "127.0.0.1", + version: "1.0", + platform: "macos 15.0", + deviceFamily: "Mac", + modelIdentifier: "MacPreview", + lastInputSeconds: 12, + mode: "local", + reason: "test", + text: "test instance", + ts: Date().timeIntervalSince1970 * 1000), + ] + let view = InstancesSettings(store: store) + _ = view.body + } + + @Test func permissionsSettingsBuildsBody() { + let view = PermissionsSettings( + status: [ + .notifications: true, + .screenRecording: false, + ], + refresh: {}, + showOnboarding: {}) + _ = view.body + } + + @Test func settingsRootViewBuildsBody() { + let state = AppState(preview: true) + let view = SettingsRootView(state: state, updater: nil, initialTab: .general) + _ = view.body + } + + @Test func aboutSettingsBuildsBody() { + let view = AboutSettings(updater: nil) + _ = view.body + } + + @Test func voiceWakeSettingsBuildsBody() { + let state = AppState(preview: true) + let view = VoiceWakeSettings(state: state, isActive: false) + _ = view.body + } + + @Test func skillsSettingsBuildsBody() { + let view = SkillsSettings(state: .preview) + _ = view.body + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift new file mode 100644 index 00000000..560f3d2f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift @@ -0,0 +1,119 @@ +import OpenClawProtocol +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct SkillsSettingsSmokeTests { + @Test func skillsSettingsBuildsBodyWithSkillsRemote() { + let model = SkillsSettingsModel() + model.statusMessage = "Loaded" + model.skills = [ + SkillStatus( + name: "Needs Setup", + description: "Missing bins and env", + source: "openclaw-managed", + filePath: "/tmp/skills/needs-setup", + baseDir: "/tmp/skills", + skillKey: "needs-setup", + primaryEnv: "API_KEY", + emoji: "🧰", + homepage: "https://example.com/needs-setup", + always: false, + disabled: false, + eligible: false, + requirements: SkillRequirements( + bins: ["python3"], + env: ["API_KEY"], + config: ["skills.needs-setup"]), + missing: SkillMissing( + bins: ["python3"], + env: ["API_KEY"], + config: ["skills.needs-setup"]), + configChecks: [ + SkillStatusConfigCheck(path: "skills.needs-setup", value: AnyCodable(false), satisfied: false), + ], + install: [ + SkillInstallOption(id: "brew", kind: "brew", label: "brew install python", bins: ["python3"]), + ]), + SkillStatus( + name: "Ready Skill", + description: "All set", + source: "openclaw-bundled", + filePath: "/tmp/skills/ready", + baseDir: "/tmp/skills", + skillKey: "ready", + primaryEnv: nil, + emoji: "✅", + homepage: "https://example.com/ready", + always: false, + disabled: false, + eligible: true, + requirements: SkillRequirements(bins: [], env: [], config: []), + missing: SkillMissing(bins: [], env: [], config: []), + configChecks: [ + SkillStatusConfigCheck(path: "skills.ready", value: AnyCodable(true), satisfied: true), + SkillStatusConfigCheck(path: "skills.limit", value: AnyCodable(5), satisfied: true), + ], + install: []), + SkillStatus( + name: "Disabled Skill", + description: "Disabled in config", + source: "openclaw-extra", + filePath: "/tmp/skills/disabled", + baseDir: "/tmp/skills", + skillKey: "disabled", + primaryEnv: nil, + emoji: "🚫", + homepage: nil, + always: false, + disabled: true, + eligible: false, + requirements: SkillRequirements(bins: [], env: [], config: []), + missing: SkillMissing(bins: [], env: [], config: []), + configChecks: [], + install: []), + ] + + let state = AppState(preview: true) + state.connectionMode = .remote + var view = SkillsSettings(state: state, model: model) + view.setFilterForTesting("all") + _ = view.body + view.setFilterForTesting("needsSetup") + _ = view.body + } + + @Test func skillsSettingsBuildsBodyWithLocalMode() { + let model = SkillsSettingsModel() + model.skills = [ + SkillStatus( + name: "Local Skill", + description: "Local ready", + source: "openclaw-workspace", + filePath: "/tmp/skills/local", + baseDir: "/tmp/skills", + skillKey: "local", + primaryEnv: nil, + emoji: "🏠", + homepage: nil, + always: false, + disabled: false, + eligible: true, + requirements: SkillRequirements(bins: [], env: [], config: []), + missing: SkillMissing(bins: [], env: [], config: []), + configChecks: [], + install: []), + ] + + let state = AppState(preview: true) + state.connectionMode = .local + var view = SkillsSettings(state: state, model: model) + view.setFilterForTesting("ready") + _ = view.body + } + + @Test func skillsSettingsExercisesPrivateViews() { + SkillsSettings.exerciseForTesting() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift new file mode 100644 index 00000000..fdfa96cb --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift @@ -0,0 +1,48 @@ +import SwiftUI +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct TailscaleIntegrationSectionTests { + @Test func tailscaleSectionBuildsBodyWhenNotInstalled() { + let service = TailscaleService(isInstalled: false, isRunning: false, statusError: "not installed") + var view = TailscaleIntegrationSection(connectionMode: .local, isPaused: false) + view.setTestingService(service) + view.setTestingState(mode: "off", requireCredentials: false, statusMessage: "Idle") + _ = view.body + } + + @Test func tailscaleSectionBuildsBodyForServeMode() { + let service = TailscaleService( + isInstalled: true, + isRunning: true, + tailscaleHostname: "openclaw.tailnet.ts.net", + tailscaleIP: "100.64.0.1") + var view = TailscaleIntegrationSection(connectionMode: .local, isPaused: false) + view.setTestingService(service) + view.setTestingState( + mode: "serve", + requireCredentials: true, + password: "secret", + statusMessage: "Running") + _ = view.body + } + + @Test func tailscaleSectionBuildsBodyForFunnelMode() { + let service = TailscaleService( + isInstalled: true, + isRunning: false, + tailscaleHostname: nil, + tailscaleIP: nil, + statusError: "not running") + var view = TailscaleIntegrationSection(connectionMode: .remote, isPaused: false) + view.setTestingService(service) + view.setTestingState( + mode: "funnel", + requireCredentials: false, + statusMessage: "Needs start", + validationMessage: "Invalid token") + _ = view.body + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift new file mode 100644 index 00000000..bba233fa --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct TalkAudioPlayerTests { + @MainActor + @Test func playDoesNotHangWhenPlaybackEndsOrFails() async throws { + let wav = makeWav16Mono(sampleRate: 8000, samples: 80) + defer { _ = TalkAudioPlayer.shared.stop() } + + _ = try await withTimeout(seconds: 4.0) { + await TalkAudioPlayer.shared.play(data: wav) + } + + #expect(true) + } + + @MainActor + @Test func playDoesNotHangWhenPlayIsCalledTwice() async throws { + let wav = makeWav16Mono(sampleRate: 8000, samples: 800) + defer { _ = TalkAudioPlayer.shared.stop() } + + let first = Task { @MainActor in + await TalkAudioPlayer.shared.play(data: wav) + } + + await Task.yield() + _ = await TalkAudioPlayer.shared.play(data: wav) + + _ = try await withTimeout(seconds: 4.0) { + await first.value + } + #expect(true) + } +} + +private struct TimeoutError: Error {} + +private func withTimeout( + seconds: Double, + _ work: @escaping @Sendable () async throws -> T) async throws -> T +{ + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await work() + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TimeoutError() + } + let result = try await group.next() + group.cancelAll() + guard let result else { throw TimeoutError() } + return result + } +} + +private func makeWav16Mono(sampleRate: UInt32, samples: Int) -> Data { + let channels: UInt16 = 1 + let bitsPerSample: UInt16 = 16 + let blockAlign = channels * (bitsPerSample / 8) + let byteRate = sampleRate * UInt32(blockAlign) + let dataSize = UInt32(samples) * UInt32(blockAlign) + + var data = Data() + data.append(contentsOf: [0x52, 0x49, 0x46, 0x46]) // RIFF + data.appendLEUInt32(36 + dataSize) + data.append(contentsOf: [0x57, 0x41, 0x56, 0x45]) // WAVE + + data.append(contentsOf: [0x66, 0x6D, 0x74, 0x20]) // fmt + data.appendLEUInt32(16) // PCM + data.appendLEUInt16(1) // audioFormat + data.appendLEUInt16(channels) + data.appendLEUInt32(sampleRate) + data.appendLEUInt32(byteRate) + data.appendLEUInt16(blockAlign) + data.appendLEUInt16(bitsPerSample) + + data.append(contentsOf: [0x64, 0x61, 0x74, 0x61]) // data + data.appendLEUInt32(dataSize) + + // Silence samples. + data.append(Data(repeating: 0, count: Int(dataSize))) + return data +} + +extension Data { + fileprivate mutating func appendLEUInt16(_ value: UInt16) { + var v = value.littleEndian + Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } + } + + fileprivate mutating func appendLEUInt32(_ value: UInt32) { + var v = value.littleEndian + Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift new file mode 100644 index 00000000..5ee30af2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift @@ -0,0 +1,36 @@ +import OpenClawProtocol +import Testing + +@testable import OpenClaw + +@Suite struct TalkModeConfigParsingTests { + @Test func prefersNormalizedTalkProviderPayload() { + let talk: [String: AnyCodable] = [ + "provider": AnyCodable("elevenlabs"), + "providers": AnyCodable([ + "elevenlabs": [ + "voiceId": "voice-normalized", + ], + ]), + "voiceId": AnyCodable("voice-legacy"), + ] + + let selection = TalkModeRuntime.selectTalkProviderConfig(talk) + #expect(selection?.provider == "elevenlabs") + #expect(selection?.normalizedPayload == true) + #expect(selection?.config["voiceId"]?.stringValue == "voice-normalized") + } + + @Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() { + let talk: [String: AnyCodable] = [ + "voiceId": AnyCodable("voice-legacy"), + "apiKey": AnyCodable("legacy-key"), + ] + + let selection = TalkModeRuntime.selectTalkProviderConfig(talk) + #expect(selection?.provider == "elevenlabs") + #expect(selection?.normalizedPayload == false) + #expect(selection?.config["voiceId"]?.stringValue == "voice-legacy") + #expect(selection?.config["apiKey"]?.stringValue == "legacy-key") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift new file mode 100644 index 00000000..1002b7ed --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift @@ -0,0 +1,116 @@ +import Foundation + +actor TestIsolationLock { + static let shared = TestIsolationLock() + + private var locked = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if !self.locked { + self.locked = true + return + } + await withCheckedContinuation { cont in + self.waiters.append(cont) + } + // `unlock()` resumed us; lock is now held for this caller. + } + + func release() { + if self.waiters.isEmpty { + self.locked = false + return + } + let next = self.waiters.removeFirst() + next.resume() + } +} + +@MainActor +enum TestIsolation { + static func withIsolatedState( + env: [String: String?] = [:], + defaults: [String: Any?] = [:], + _ body: () async throws -> T) async rethrows -> T + { + await TestIsolationLock.shared.acquire() + var previousEnv: [String: String?] = [:] + for (key, value) in env { + previousEnv[key] = getenv(key).map { String(cString: $0) } + if let value { + setenv(key, value, 1) + } else { + unsetenv(key) + } + } + + let userDefaults = UserDefaults.standard + var previousDefaults: [String: Any?] = [:] + for (key, value) in defaults { + previousDefaults[key] = userDefaults.object(forKey: key) + if let value { + userDefaults.set(value, forKey: key) + } else { + userDefaults.removeObject(forKey: key) + } + } + + do { + let result = try await body() + for (key, value) in previousDefaults { + if let value { + userDefaults.set(value, forKey: key) + } else { + userDefaults.removeObject(forKey: key) + } + } + for (key, value) in previousEnv { + if let value { + setenv(key, value, 1) + } else { + unsetenv(key) + } + } + await TestIsolationLock.shared.release() + return result + } catch { + for (key, value) in previousDefaults { + if let value { + userDefaults.set(value, forKey: key) + } else { + userDefaults.removeObject(forKey: key) + } + } + for (key, value) in previousEnv { + if let value { + setenv(key, value, 1) + } else { + unsetenv(key) + } + } + await TestIsolationLock.shared.release() + throw error + } + } + + static func withEnvValues( + _ values: [String: String?], + _ body: () async throws -> T) async rethrows -> T + { + try await self.withIsolatedState(env: values, defaults: [:], body) + } + + static func withUserDefaultsValues( + _ values: [String: Any?], + _ body: () async throws -> T) async rethrows -> T + { + try await self.withIsolatedState(env: [:], defaults: values, body) + } + + nonisolated static func tempConfigPath() -> String { + FileManager().temporaryDirectory + .appendingPathComponent("openclaw-test-config-\(UUID().uuidString).json") + .path + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift new file mode 100644 index 00000000..ddeef38d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct UtilitiesTests { + @Test func ageStringsCoverCommonWindows() { + let now = Date(timeIntervalSince1970: 1_000_000) + #expect(age(from: now, now: now) == "just now") + #expect(age(from: now.addingTimeInterval(-45), now: now) == "just now") + #expect(age(from: now.addingTimeInterval(-75), now: now) == "1 minute ago") + #expect(age(from: now.addingTimeInterval(-10 * 60), now: now) == "10m ago") + #expect(age(from: now.addingTimeInterval(-3600), now: now) == "1 hour ago") + #expect(age(from: now.addingTimeInterval(-5 * 3600), now: now) == "5h ago") + #expect(age(from: now.addingTimeInterval(-26 * 3600), now: now) == "yesterday") + #expect(age(from: now.addingTimeInterval(-3 * 86400), now: now) == "3d ago") + } + + @Test func parseSSHTargetSupportsUserPortAndDefaults() { + let parsed1 = CommandResolver.parseSSHTarget("alice@example.com:2222") + #expect(parsed1?.user == "alice") + #expect(parsed1?.host == "example.com") + #expect(parsed1?.port == 2222) + + let parsed2 = CommandResolver.parseSSHTarget("example.com") + #expect(parsed2?.user == nil) + #expect(parsed2?.host == "example.com") + #expect(parsed2?.port == 22) + + let parsed3 = CommandResolver.parseSSHTarget("bob@host") + #expect(parsed3?.user == "bob") + #expect(parsed3?.host == "host") + #expect(parsed3?.port == 22) + } + + @Test func sanitizedTargetStripsLeadingSSHPrefix() { + let defaults = UserDefaults(suiteName: "UtilitiesTests.\(UUID().uuidString)")! + defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) + defaults.set("ssh alice@example.com", forKey: remoteTargetKey) + + let settings = CommandResolver.connectionSettings(defaults: defaults, configRoot: [:]) + #expect(settings.mode == .remote) + #expect(settings.target == "alice@example.com") + } + + @Test func gatewayEntrypointPrefersDistOverBin() throws { + let tmp = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let dist = tmp.appendingPathComponent("dist/index.js") + let bin = tmp.appendingPathComponent("bin/openclaw.js") + try FileManager().createDirectory(at: dist.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager().createDirectory(at: bin.deletingLastPathComponent(), withIntermediateDirectories: true) + FileManager().createFile(atPath: dist.path, contents: Data()) + FileManager().createFile(atPath: bin.path, contents: Data()) + + let entry = CommandResolver.gatewayEntrypoint(in: tmp) + #expect(entry == dist.path) + } + + @Test func logLocatorPicksNewestLogFile() throws { + let fm = FileManager() + let dir = URL(fileURLWithPath: "/tmp/openclaw", isDirectory: true) + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + + let older = dir.appendingPathComponent("openclaw-old-\(UUID().uuidString).log") + let newer = dir.appendingPathComponent("openclaw-new-\(UUID().uuidString).log") + fm.createFile(atPath: older.path, contents: Data("old".utf8)) + fm.createFile(atPath: newer.path, contents: Data("new".utf8)) + try fm.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -100)], ofItemAtPath: older.path) + try fm.setAttributes([.modificationDate: Date()], ofItemAtPath: newer.path) + + let best = LogLocator.bestLogFile() + #expect(best?.lastPathComponent == newer.lastPathComponent) + + try? fm.removeItem(at: older) + try? fm.removeItem(at: newer) + } + + @Test func gatewayEntrypointNilWhenMissing() { + let tmp = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + #expect(CommandResolver.gatewayEntrypoint(in: tmp) == nil) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkHotkeyTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkHotkeyTests.swift new file mode 100644 index 00000000..85cd7293 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkHotkeyTests.swift @@ -0,0 +1,37 @@ +import AppKit +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct VoicePushToTalkHotkeyTests { + actor Counter { + private(set) var began = 0 + private(set) var ended = 0 + + func incBegin() { self.began += 1 } + func incEnd() { self.ended += 1 } + func snapshot() -> (began: Int, ended: Int) { (self.began, self.ended) } + } + + @Test func beginEndFiresOncePerHold() async { + let counter = Counter() + let hotkey = VoicePushToTalkHotkey( + beginAction: { await counter.incBegin() }, + endAction: { await counter.incEnd() }) + + await MainActor.run { + hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: [.option]) + hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: [.option]) + hotkey._testUpdateModifierState(keyCode: 61, modifierFlags: []) + } + + for _ in 0..<50 { + let snap = await counter.snapshot() + if snap.began == 1, snap.ended == 1 { break } + try? await Task.sleep(nanoseconds: 10_000_000) + } + + let snap = await counter.snapshot() + #expect(snap.began == 1) + #expect(snap.ended == 1) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkTests.swift new file mode 100644 index 00000000..4a69bfea --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkTests.swift @@ -0,0 +1,24 @@ +import Testing +@testable import OpenClaw + +@Suite struct VoicePushToTalkTests { + @Test func deltaTrimsCommittedPrefix() { + let delta = VoicePushToTalk._testDelta(committed: "hello ", current: "hello world again") + #expect(delta == "world again") + } + + @Test func deltaFallsBackWhenPrefixDiffers() { + let delta = VoicePushToTalk._testDelta(committed: "goodbye", current: "hello world") + #expect(delta == "hello world") + } + + @Test func attributedColorsDifferWhenNotFinal() { + let colors = VoicePushToTalk._testAttributedColors(isFinal: false) + #expect(colors.0 != colors.1) + } + + @Test func attributedColorsMatchWhenFinal() { + let colors = VoicePushToTalk._testAttributedColors(isFinal: true) + #expect(colors.0 == colors.1) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift new file mode 100644 index 00000000..6640d526 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift @@ -0,0 +1,23 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct VoiceWakeForwarderTests { + @Test func prefixedTranscriptUsesMachineName() { + let transcript = "hello world" + let prefixed = VoiceWakeForwarder.prefixedTranscript(transcript, machineName: "My-Mac") + + #expect(prefixed.starts(with: "User talked via voice recognition on")) + #expect(prefixed.contains("My-Mac")) + #expect(prefixed.hasSuffix("\n\nhello world")) + } + + @Test func forwardOptionsDefaults() { + let opts = VoiceWakeForwarder.ForwardOptions() + #expect(opts.sessionKey == "main") + #expect(opts.thinking == "low") + #expect(opts.deliver == true) + #expect(opts.to == nil) + #expect(opts.channel == .webchat) + #expect(opts.channel.shouldDeliver(opts.deliver) == false) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift new file mode 100644 index 00000000..9065f6b6 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift @@ -0,0 +1,56 @@ +import OpenClawProtocol +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct VoiceWakeGlobalSettingsSyncTests { + @Test func appliesVoiceWakeChangedEventToAppState() async { + let previous = await MainActor.run { AppStateStore.shared.swabbleTriggerWords } + + await MainActor.run { + AppStateStore.shared.applyGlobalVoiceWakeTriggers(["before"]) + } + + let payload = OpenClawProtocol.AnyCodable(["triggers": ["openclaw", "computer"]]) + let evt = EventFrame( + type: "event", + event: "voicewake.changed", + payload: payload, + seq: nil, + stateversion: nil) + + await VoiceWakeGlobalSettingsSync.shared.handle(push: .event(evt)) + + let updated = await MainActor.run { AppStateStore.shared.swabbleTriggerWords } + #expect(updated == ["openclaw", "computer"]) + + await MainActor.run { + AppStateStore.shared.applyGlobalVoiceWakeTriggers(previous) + } + } + + @Test func ignoresVoiceWakeChangedEventWithInvalidPayload() async { + let previous = await MainActor.run { AppStateStore.shared.swabbleTriggerWords } + + await MainActor.run { + AppStateStore.shared.applyGlobalVoiceWakeTriggers(["before"]) + } + + let payload = OpenClawProtocol.AnyCodable(["unexpected": 123]) + let evt = EventFrame( + type: "event", + event: "voicewake.changed", + payload: payload, + seq: nil, + stateversion: nil) + + await VoiceWakeGlobalSettingsSync.shared.handle(push: .event(evt)) + + let updated = await MainActor.run { AppStateStore.shared.swabbleTriggerWords } + #expect(updated == ["before"]) + + await MainActor.run { + AppStateStore.shared.applyGlobalVoiceWakeTriggers(previous) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeHelpersTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeHelpersTests.swift new file mode 100644 index 00000000..20ba7d7c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeHelpersTests.swift @@ -0,0 +1,35 @@ +import Testing +@testable import OpenClaw + +struct VoiceWakeHelpersTests { + @Test func sanitizeTriggersTrimsAndDropsEmpty() { + let cleaned = sanitizeVoiceWakeTriggers([" hi ", " ", "\n", "there"]) + #expect(cleaned == ["hi", "there"]) + } + + @Test func sanitizeTriggersFallsBackToDefaults() { + let cleaned = sanitizeVoiceWakeTriggers([" ", ""]) + #expect(cleaned == defaultVoiceWakeTriggers) + } + + @Test func sanitizeTriggersLimitsWordLength() { + let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5) + let cleaned = sanitizeVoiceWakeTriggers(["ok", long]) + #expect(cleaned[1].count == voiceWakeMaxWordLength) + } + + @Test func sanitizeTriggersLimitsWordCount() { + let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" } + let cleaned = sanitizeVoiceWakeTriggers(words) + #expect(cleaned.count == voiceWakeMaxWords) + } + + @Test func normalizeLocaleStripsCollation() { + #expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US") + } + + @Test func normalizeLocaleStripsUnicodeExtensions() { + #expect(normalizeLocaleIdentifier("de-DE-u-co-phonebk") == "de-DE") + #expect(normalizeLocaleIdentifier("ja-JP-t-ja") == "ja-JP") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayControllerTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayControllerTests.swift new file mode 100644 index 00000000..5e5636ae --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayControllerTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct VoiceWakeOverlayControllerTests { + @Test func overlayControllerLifecycleWithoutUI() async { + let controller = VoiceWakeOverlayController(enableUI: false) + let token = controller.startSession( + source: .wakeWord, + transcript: "hello", + attributed: nil, + forwardEnabled: true, + isFinal: false) + + #expect(controller.snapshot().token == token) + #expect(controller.snapshot().isVisible == true) + + controller.updatePartial(token: token, transcript: "hello world") + #expect(controller.snapshot().text == "hello world") + + controller.updateLevel(token: token, -0.5) + #expect(controller.model.level == 0) + try? await Task.sleep(nanoseconds: 120_000_000) + controller.updateLevel(token: token, 2.0) + #expect(controller.model.level == 1) + + controller.dismiss(token: token, reason: .explicit, outcome: .empty) + #expect(controller.snapshot().isVisible == false) + #expect(controller.snapshot().token == nil) + } + + @Test func evaluateTokenDropsMismatchAndNoActive() { + let active = UUID() + #expect(VoiceWakeOverlayController.evaluateToken(active: nil, incoming: active) == .dropNoActive) + #expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: UUID()) == .dropMismatch) + #expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: active) == .accept) + #expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: nil) == .accept) + } + + @Test func updateLevelThrottlesRapidChanges() async { + let controller = VoiceWakeOverlayController(enableUI: false) + let token = controller.startSession( + source: .wakeWord, + transcript: "level test", + attributed: nil, + forwardEnabled: false, + isFinal: false) + + controller.updateLevel(token: token, 0.25) + let first = controller.model.level + + controller.updateLevel(token: token, 0.9) + #expect(controller.model.level == first) + + controller.updateLevel(token: token, 0) + #expect(controller.model.level == 0) + + try? await Task.sleep(nanoseconds: 120_000_000) + controller.updateLevel(token: token, 0.9) + #expect(controller.model.level == 0.9) + } + + @Test func overlayControllerExercisesHelpers() async { + await VoiceWakeOverlayController.exerciseForTesting() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayTests.swift new file mode 100644 index 00000000..7e8b0a17 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct VoiceWakeOverlayTests { + @Test func guardTokenDropsWhenNoActive() { + let outcome = VoiceWakeOverlayController.evaluateToken(active: nil, incoming: UUID()) + #expect(outcome == .dropNoActive) + } + + @Test func guardTokenAcceptsMatching() { + let token = UUID() + let outcome = VoiceWakeOverlayController.evaluateToken(active: token, incoming: token) + #expect(outcome == .accept) + } + + @Test func guardTokenDropsMismatchWithoutDismissing() { + let outcome = VoiceWakeOverlayController.evaluateToken(active: UUID(), incoming: UUID()) + #expect(outcome == .dropMismatch) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayViewSmokeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayViewSmokeTests.swift new file mode 100644 index 00000000..eaec98ab --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayViewSmokeTests.swift @@ -0,0 +1,28 @@ +import SwiftUI +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct VoiceWakeOverlayViewSmokeTests { + @Test func overlayViewBuildsBodyInDisplayMode() { + let controller = VoiceWakeOverlayController(enableUI: false) + _ = controller.startSession(source: .wakeWord, transcript: "hello", forwardEnabled: true) + let view = VoiceWakeOverlayView(controller: controller) + _ = view.body + } + + @Test func overlayViewBuildsBodyInEditingMode() { + let controller = VoiceWakeOverlayController(enableUI: false) + let token = controller.startSession(source: .pushToTalk, transcript: "edit me", forwardEnabled: true) + controller.userBeganEditing() + controller.updateLevel(token: token, 0.6) + let view = VoiceWakeOverlayView(controller: controller) + _ = view.body + } + + @Test func closeButtonOverlayBuildsBody() { + let view = CloseButtonOverlay(isVisible: true, onHover: { _ in }, onClose: {}) + _ = view.body + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift new file mode 100644 index 00000000..89345914 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift @@ -0,0 +1,91 @@ +import Foundation +import SwabbleKit +import Testing +@testable import OpenClaw + +@Suite struct VoiceWakeRuntimeTests { + @Test func trimsAfterTriggerKeepsPostSpeech() { + let triggers = ["claude", "openclaw"] + let text = "hey Claude how are you" + #expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "how are you") + } + + @Test func trimsAfterTriggerReturnsOriginalWhenNoTrigger() { + let triggers = ["claude"] + let text = "good morning friend" + #expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == text) + } + + @Test func trimsAfterFirstMatchingTrigger() { + let triggers = ["buddy", "claude"] + let text = "hello buddy this is after trigger claude also here" + #expect(VoiceWakeRuntime + ._testTrimmedAfterTrigger(text, triggers: triggers) == "this is after trigger claude also here") + } + + @Test func hasContentAfterTriggerFalseWhenOnlyTrigger() { + let triggers = ["openclaw"] + let text = "hey openclaw" + #expect(!VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers)) + } + + @Test func hasContentAfterTriggerTrueWhenSpeechContinues() { + let triggers = ["claude"] + let text = "claude write a note" + #expect(VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers)) + } + + @Test func trimsAfterChineseTriggerKeepsPostSpeech() { + let triggers = ["小爪", "openclaw"] + let text = "嘿 小爪 帮我打开设置" + #expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "帮我打开设置") + } + + @Test func trimsAfterTriggerHandlesWidthInsensitiveForms() { + let triggers = ["openclaw"] + let text = "OpenClaw 请帮我" + #expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "请帮我") + } + + @Test func gateRequiresGapBetweenTriggerAndCommand() { + let transcript = "hey openclaw do thing" + let segments = makeSegments( + transcript: transcript, + words: [ + ("hey", 0.0, 0.1), + ("openclaw", 0.2, 0.1), + ("do", 0.35, 0.1), + ("thing", 0.5, 0.1), + ]) + let config = WakeWordGateConfig(triggers: ["openclaw"], minPostTriggerGap: 0.3) + #expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil) + } + + @Test func gateAcceptsGapAndExtractsCommand() { + let transcript = "hey openclaw do thing" + let segments = makeSegments( + transcript: transcript, + words: [ + ("hey", 0.0, 0.1), + ("openclaw", 0.2, 0.1), + ("do", 0.9, 0.1), + ("thing", 1.1, 0.1), + ]) + let config = WakeWordGateConfig(triggers: ["openclaw"], minPostTriggerGap: 0.3) + #expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing") + } +} + +private func makeSegments( + transcript: String, + words: [(String, TimeInterval, TimeInterval)]) +-> [WakeWordSegment] { + var searchStart = transcript.startIndex + var output: [WakeWordSegment] = [] + for (word, start, duration) in words { + let range = transcript.range(of: word, range: searchStart.. [WakeWordSegment] { + var searchStart = transcript.startIndex + var output: [WakeWordSegment] = [] + for (word, start, duration) in words { + let range = transcript.range(of: word, range: searchStart.. OpenClawChatHistoryPayload { + let json = """ + {"sessionKey":"\(sessionKey)","sessionId":null,"messages":[],"thinkingLevel":"off"} + """ + return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: Data(json.utf8)) + } + + func sendMessage( + sessionKey _: String, + message _: String, + thinking _: String, + idempotencyKey _: String, + attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + { + let json = """ + {"runId":"\(UUID().uuidString)","status":"ok"} + """ + return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: Data(json.utf8)) + } + + func requestHealth(timeoutMs _: Int) async throws -> Bool { true } + + func events() -> AsyncStream { + AsyncStream { continuation in + continuation.finish() + } + } + + func setActiveSessionKey(_: String) async throws {} + } + + @Test func windowControllerShowAndClose() { + let controller = WebChatSwiftUIWindowController( + sessionKey: "main", + presentation: .window, + transport: TestTransport()) + controller.show() + controller.close() + } + + @Test func panelControllerPresentAndClose() { + let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) } + let controller = WebChatSwiftUIWindowController( + sessionKey: "main", + presentation: .panel(anchorProvider: anchor), + transport: TestTransport()) + controller.presentAnchored(anchorProvider: anchor) + controller.close() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift new file mode 100644 index 00000000..24644a2f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift @@ -0,0 +1,51 @@ +import Darwin +import Testing +@testable import OpenClawDiscovery + +@Suite +struct WideAreaGatewayDiscoveryTests { + @Test func discoversBeaconFromTailnetDnsSdFallback() { + setenv("OPENCLAW_WIDE_AREA_DOMAIN", "openclaw.internal", 1) + let statusJson = """ + { + "Self": { "TailscaleIPs": ["100.69.232.64"] }, + "Peer": { + "peer-1": { "TailscaleIPs": ["100.123.224.76"] } + } + } + """ + + let context = WideAreaGatewayDiscovery.DiscoveryContext( + tailscaleStatus: { statusJson }, + dig: { args, _ in + let recordType = args.last ?? "" + let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? "" + if recordType == "PTR" { + if nameserver == "@100.123.224.76" { + return "steipetacstudio-gateway._openclaw-gw._tcp.openclaw.internal.\n" + } + return "" + } + if recordType == "SRV" { + return "0 0 18789 steipetacstudio.openclaw.internal." + } + if recordType == "TXT" { + return "\"displayName=Peter\\226\\128\\153s Mac Studio (OpenClaw)\" \"gatewayPort=18789\" \"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net\" \"cliPath=/Users/steipete/openclaw/src/entry.ts\"" + } + return "" + }) + + let beacons = WideAreaGatewayDiscovery.discover( + timeoutSeconds: 2.0, + context: context) + + #expect(beacons.count == 1) + let beacon = beacons[0] + let expectedDisplay = "Peter\u{2019}s Mac Studio (OpenClaw)" + #expect(beacon.displayName == expectedDisplay) + #expect(beacon.port == 18789) + #expect(beacon.gatewayPort == 18789) + #expect(beacon.tailnetDns == "peters-mac-studio-1.sheep-coho.ts.net") + #expect(beacon.cliPath == "/Users/steipete/openclaw/src/entry.ts") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/WindowPlacementTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/WindowPlacementTests.swift new file mode 100644 index 00000000..0afd3eb5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/WindowPlacementTests.swift @@ -0,0 +1,85 @@ +import AppKit +import Testing +@testable import OpenClaw + +@Suite +@MainActor +struct WindowPlacementTests { + @Test + func centeredFrameZeroBoundsFallsBackToOrigin() { + let frame = WindowPlacement.centeredFrame(size: NSSize(width: 120, height: 80), in: NSRect.zero) + #expect(frame.origin == .zero) + #expect(frame.size == NSSize(width: 120, height: 80)) + } + + @Test + func centeredFrameClampsToBoundsAndCenters() { + let bounds = NSRect(x: 10, y: 20, width: 300, height: 200) + let frame = WindowPlacement.centeredFrame(size: NSSize(width: 600, height: 120), in: bounds) + #expect(frame.size.width == bounds.width) + #expect(frame.size.height == 120) + #expect(frame.minX == bounds.minX) + #expect(frame.midY == bounds.midY) + } + + @Test + func topRightFrameZeroBoundsFallsBackToOrigin() { + let frame = WindowPlacement.topRightFrame( + size: NSSize(width: 120, height: 80), + padding: 12, + in: NSRect.zero) + #expect(frame.origin == .zero) + #expect(frame.size == NSSize(width: 120, height: 80)) + } + + @Test + func topRightFrameClampsToBoundsAndAppliesPadding() { + let bounds = NSRect(x: 10, y: 20, width: 300, height: 200) + let frame = WindowPlacement.topRightFrame( + size: NSSize(width: 400, height: 50), + padding: 8, + in: bounds) + #expect(frame.size.width == bounds.width) + #expect(frame.size.height == 50) + #expect(frame.maxX == bounds.maxX - 8) + #expect(frame.maxY == bounds.maxY - 8) + } + + @Test + func ensureOnScreenUsesFallbackWhenWindowOffscreen() { + let window = NSWindow( + contentRect: NSRect(x: 100_000, y: 100_000, width: 200, height: 120), + styleMask: [.borderless], + backing: .buffered, + defer: false) + + WindowPlacement.ensureOnScreen( + window: window, + defaultSize: NSSize(width: 200, height: 120), + fallback: { _ in NSRect(x: 11, y: 22, width: 33, height: 44) }) + + #expect(window.frame == NSRect(x: 11, y: 22, width: 33, height: 44)) + } + + @Test + func ensureOnScreenDoesNotMoveVisibleWindow() { + let screen = NSScreen.main ?? NSScreen.screens.first + #expect(screen != nil) + guard let screen else { return } + + let visible = screen.visibleFrame.insetBy(dx: 40, dy: 40) + let window = NSWindow( + contentRect: NSRect(x: visible.minX, y: visible.minY, width: 200, height: 120), + styleMask: [.titled], + backing: .buffered, + defer: false) + let original = window.frame + + WindowPlacement.ensureOnScreen( + window: window, + defaultSize: NSSize(width: 200, height: 120), + fallback: { _ in NSRect(x: 11, y: 22, width: 33, height: 44) }) + + #expect(window.frame == original) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/WorkActivityStoreTests.swift b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/WorkActivityStoreTests.swift new file mode 100644 index 00000000..78827064 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/macos/Tests/OpenClawIPCTests/WorkActivityStoreTests.swift @@ -0,0 +1,99 @@ +import OpenClawProtocol +import Foundation +import Testing +@testable import OpenClaw + +@Suite +@MainActor +struct WorkActivityStoreTests { + @Test func mainSessionJobPreemptsOther() { + let store = WorkActivityStore() + + store.handleJob(sessionKey: "discord:group:1", state: "started") + #expect(store.iconState == .workingOther(.job)) + #expect(store.current?.sessionKey == "discord:group:1") + + store.handleJob(sessionKey: "main", state: "started") + #expect(store.iconState == .workingMain(.job)) + #expect(store.current?.sessionKey == "main") + + store.handleJob(sessionKey: "main", state: "finished") + #expect(store.iconState == .workingOther(.job)) + #expect(store.current?.sessionKey == "discord:group:1") + + store.handleJob(sessionKey: "discord:group:1", state: "finished") + #expect(store.iconState == .idle) + #expect(store.current == nil) + } + + @Test func jobStaysWorkingAfterToolResultGrace() async { + let store = WorkActivityStore() + + store.handleJob(sessionKey: "main", state: "started") + #expect(store.iconState == .workingMain(.job)) + + store.handleTool( + sessionKey: "main", + phase: "start", + name: "read", + meta: nil, + args: ["path": AnyCodable("/tmp/file.txt")]) + #expect(store.iconState == .workingMain(.tool(.read))) + + store.handleTool( + sessionKey: "main", + phase: "result", + name: "read", + meta: nil, + args: ["path": AnyCodable("/tmp/file.txt")]) + + for _ in 0..<50 { + if store.iconState == .workingMain(.job) { break } + try? await Task.sleep(nanoseconds: 100_000_000) + } + #expect(store.iconState == .workingMain(.job)) + + store.handleJob(sessionKey: "main", state: "done") + #expect(store.iconState == .idle) + } + + @Test func toolLabelExtractsFirstLineAndShortensHome() { + let store = WorkActivityStore() + let home = NSHomeDirectory() + + store.handleTool( + sessionKey: "main", + phase: "start", + name: "bash", + meta: nil, + args: [ + "command": AnyCodable("echo hi\necho bye"), + "path": AnyCodable("\(home)/Projects/openclaw"), + ]) + + #expect(store.current?.label == "bash: echo hi") + #expect(store.iconState == .workingMain(.tool(.bash))) + + store.handleTool( + sessionKey: "main", + phase: "start", + name: "read", + meta: nil, + args: ["path": AnyCodable("\(home)/secret.txt")]) + + #expect(store.current?.label == "read: ~/secret.txt") + #expect(store.iconState == .workingMain(.tool(.read))) + } + + @Test func resolveIconStateHonorsOverrideSelection() { + let store = WorkActivityStore() + store.handleJob(sessionKey: "main", state: "started") + #expect(store.iconState == .workingMain(.job)) + + store.resolveIconState(override: .idle) + #expect(store.iconState == .idle) + + store.resolveIconState(override: .otherEdit) + #expect(store.iconState == .overridden(.tool(.edit))) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Package.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Package.swift new file mode 100644 index 00000000..5c8132d2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Package.swift @@ -0,0 +1,61 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "OpenClawKit", + platforms: [ + .iOS(.v18), + .macOS(.v15), + ], + products: [ + .library(name: "OpenClawProtocol", targets: ["OpenClawProtocol"]), + .library(name: "OpenClawKit", targets: ["OpenClawKit"]), + .library(name: "OpenClawChatUI", targets: ["OpenClawChatUI"]), + ], + dependencies: [ + .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"), + .package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"), + ], + targets: [ + .target( + name: "OpenClawProtocol", + path: "Sources/OpenClawProtocol", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .target( + name: "OpenClawKit", + dependencies: [ + "OpenClawProtocol", + .product(name: "ElevenLabsKit", package: "ElevenLabsKit"), + ], + path: "Sources/OpenClawKit", + resources: [ + .process("Resources"), + ], + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .target( + name: "OpenClawChatUI", + dependencies: [ + "OpenClawKit", + .product( + name: "Textual", + package: "textual", + condition: .when(platforms: [.macOS, .iOS])), + ], + path: "Sources/OpenClawChatUI", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), + .testTarget( + name: "OpenClawKitTests", + dependencies: ["OpenClawKit", "OpenClawChatUI"], + path: "Tests/OpenClawKitTests", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("SwiftTesting"), + ]), + ]) diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/AssistantTextParser.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/AssistantTextParser.swift new file mode 100644 index 00000000..c4395adf --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/AssistantTextParser.swift @@ -0,0 +1,139 @@ +import Foundation + +struct AssistantTextSegment: Identifiable { + enum Kind { + case thinking + case response + } + + let id = UUID() + let kind: Kind + let text: String +} + +enum AssistantTextParser { + static func segments(from raw: String) -> [AssistantTextSegment] { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + guard raw.contains("<") else { + return [AssistantTextSegment(kind: .response, text: trimmed)] + } + + var segments: [AssistantTextSegment] = [] + var cursor = raw.startIndex + var currentKind: AssistantTextSegment.Kind = .response + var matchedTag = false + + while let match = self.nextTag(in: raw, from: cursor) { + matchedTag = true + if match.range.lowerBound > cursor { + self.appendSegment(kind: currentKind, text: raw[cursor..", range: match.range.upperBound.. Bool { + !self.segments(from: raw).isEmpty + } + + private enum TagKind { + case think + case final + } + + private struct TagMatch { + let kind: TagKind + let closing: Bool + let range: Range + } + + private static func nextTag(in text: String, from start: String.Index) -> TagMatch? { + let candidates: [TagMatch] = [ + self.findTagStart(tag: "think", closing: false, in: text, from: start).map { + TagMatch(kind: .think, closing: false, range: $0) + }, + self.findTagStart(tag: "think", closing: true, in: text, from: start).map { + TagMatch(kind: .think, closing: true, range: $0) + }, + self.findTagStart(tag: "final", closing: false, in: text, from: start).map { + TagMatch(kind: .final, closing: false, range: $0) + }, + self.findTagStart(tag: "final", closing: true, in: text, from: start).map { + TagMatch(kind: .final, closing: true, range: $0) + }, + ].compactMap(\.self) + + return candidates.min { $0.range.lowerBound < $1.range.lowerBound } + } + + private static func findTagStart( + tag: String, + closing: Bool, + in text: String, + from start: String.Index) -> Range? + { + let token = closing ? "" || boundary.isWhitespace || (!closing && boundary == "/") + if isBoundary { + return range + } + searchRange = boundaryIndex..) -> Bool { + var cursor = tagEnd.lowerBound + while cursor > text.startIndex { + cursor = text.index(before: cursor) + let char = text[cursor] + if char.isWhitespace { continue } + return char == "/" + } + return false + } + + private static func appendSegment( + kind: AssistantTextSegment.Kind, + text: Substring, + to segments: inout [AssistantTextSegment]) + { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + segments.append(AssistantTextSegment(kind: kind, text: trimmed)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift new file mode 100644 index 00000000..62714838 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift @@ -0,0 +1,503 @@ +import Foundation +import Observation +import SwiftUI + +#if !os(macOS) +import PhotosUI +import UniformTypeIdentifiers +#endif + +@MainActor +struct OpenClawChatComposer: View { + @Bindable var viewModel: OpenClawChatViewModel + let style: OpenClawChatView.Style + let showsSessionSwitcher: Bool + + #if !os(macOS) + @State private var pickerItems: [PhotosPickerItem] = [] + @FocusState private var isFocused: Bool + #else + @State private var shouldFocusTextView = false + #endif + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if self.showsToolbar { + HStack(spacing: 6) { + if self.showsSessionSwitcher { + self.sessionPicker + } + self.thinkingPicker + Spacer() + self.refreshButton + self.attachmentPicker + } + } + + if self.showsAttachments, !self.viewModel.attachments.isEmpty { + self.attachmentsStrip + } + + self.editor + } + .padding(self.composerPadding) + .background { + let cornerRadius: CGFloat = 18 + + #if os(macOS) + if self.style == .standard { + let shape = UnevenRoundedRectangle( + cornerRadii: RectangleCornerRadii( + topLeading: 0, + bottomLeading: cornerRadius, + bottomTrailing: cornerRadius, + topTrailing: 0), + style: .continuous) + shape + .fill(OpenClawChatTheme.composerBackground) + .overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1)) + .shadow(color: .black.opacity(0.12), radius: 12, y: 6) + } else { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + shape + .fill(OpenClawChatTheme.composerBackground) + .overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1)) + .shadow(color: .black.opacity(0.12), radius: 12, y: 6) + } + #else + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + shape + .fill(OpenClawChatTheme.composerBackground) + .overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1)) + .shadow(color: .black.opacity(0.12), radius: 12, y: 6) + #endif + } + #if os(macOS) + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + self.handleDrop(providers) + } + .onAppear { + self.shouldFocusTextView = true + } + #endif + } + + private var thinkingPicker: some View { + Picker("Thinking", selection: self.$viewModel.thinkingLevel) { + Text("Off").tag("off") + Text("Low").tag("low") + Text("Medium").tag("medium") + Text("High").tag("high") + } + .labelsHidden() + .pickerStyle(.menu) + .controlSize(.small) + .frame(maxWidth: 140, alignment: .leading) + } + + private var sessionPicker: some View { + Picker( + "Session", + selection: Binding( + get: { self.viewModel.sessionKey }, + set: { next in self.viewModel.switchSession(to: next) })) + { + ForEach(self.viewModel.sessionChoices, id: \.key) { session in + Text(session.displayName ?? session.key) + .font(.system(.caption, design: .monospaced)) + .tag(session.key) + } + } + .labelsHidden() + .pickerStyle(.menu) + .controlSize(.small) + .frame(maxWidth: 160, alignment: .leading) + .help("Session") + } + + @ViewBuilder + private var attachmentPicker: some View { + #if os(macOS) + Button { + self.pickFilesMac() + } label: { + Image(systemName: "paperclip") + } + .help("Add Image") + .buttonStyle(.bordered) + .controlSize(.small) + #else + PhotosPicker(selection: self.$pickerItems, maxSelectionCount: 8, matching: .images) { + Image(systemName: "paperclip") + } + .help("Add Image") + .buttonStyle(.bordered) + .controlSize(.small) + .onChange(of: self.pickerItems) { _, newItems in + Task { await self.loadPhotosPickerItems(newItems) } + } + #endif + } + + private var attachmentsStrip: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach( + self.viewModel.attachments, + id: \OpenClawPendingAttachment.id) + { (att: OpenClawPendingAttachment) in + HStack(spacing: 6) { + if let img = att.preview { + OpenClawPlatformImageFactory.image(img) + .resizable() + .scaledToFill() + .frame(width: 22, height: 22) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } else { + Image(systemName: "photo") + } + + Text(att.fileName) + .lineLimit(1) + + Button { + self.viewModel.removeAttachment(att.id) + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.accentColor.opacity(0.08)) + .clipShape(Capsule()) + } + } + } + } + + private var editor: some View { + VStack(alignment: .leading, spacing: 8) { + self.editorOverlay + + if !self.isComposerCompacted { + Rectangle() + .fill(OpenClawChatTheme.divider) + .frame(height: 1) + .padding(.horizontal, 2) + } + + HStack(alignment: .center, spacing: 8) { + if self.showsConnectionPill { + self.connectionPill + } + Spacer(minLength: 0) + self.sendButton + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(OpenClawChatTheme.composerField) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(OpenClawChatTheme.composerBorder))) + .padding(self.editorPadding) + } + + private var connectionPill: some View { + HStack(spacing: 6) { + Circle() + .fill(self.viewModel.healthOK ? .green : .orange) + .frame(width: 7, height: 7) + Text(self.activeSessionLabel) + .font(.caption2.weight(.semibold)) + Text(self.viewModel.healthOK ? "Connected" : "Connecting…") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(OpenClawChatTheme.subtleCard) + .clipShape(Capsule()) + } + + private var activeSessionLabel: String { + let match = self.viewModel.sessions.first { $0.key == self.viewModel.sessionKey } + let trimmed = match?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? self.viewModel.sessionKey : trimmed + } + + private var editorOverlay: some View { + ZStack(alignment: .topLeading) { + if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("Message OpenClaw…") + .foregroundStyle(.tertiary) + .padding(.horizontal, 4) + .padding(.vertical, 4) + } + + #if os(macOS) + ChatComposerTextView(text: self.$viewModel.input, shouldFocus: self.$shouldFocusTextView) { + self.viewModel.send() + } + .frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight) + .padding(.horizontal, 4) + .padding(.vertical, 3) + #else + TextEditor(text: self.$viewModel.input) + .font(.system(size: 15)) + .scrollContentBackground(.hidden) + .frame( + minHeight: self.textMinHeight, + idealHeight: self.textMinHeight, + maxHeight: self.textMaxHeight) + .padding(.horizontal, 4) + .padding(.vertical, 4) + .focused(self.$isFocused) + #endif + } + } + + private var sendButton: some View { + Group { + if self.viewModel.pendingRunCount > 0 { + Button { + self.viewModel.abort() + } label: { + if self.viewModel.isAborting { + ProgressView().controlSize(.mini) + } else { + Image(systemName: "stop.fill") + .font(.system(size: 13, weight: .semibold)) + } + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .padding(6) + .background(Circle().fill(Color.red)) + .disabled(self.viewModel.isAborting) + } else { + Button { + self.viewModel.send() + } label: { + if self.viewModel.isSending { + ProgressView().controlSize(.mini) + } else { + Image(systemName: "arrow.up") + .font(.system(size: 13, weight: .semibold)) + } + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .padding(6) + .background(Circle().fill(Color.accentColor)) + .disabled(!self.viewModel.canSend) + } + } + } + + private var refreshButton: some View { + Button { + self.viewModel.refresh() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Refresh") + } + + private var showsToolbar: Bool { + self.style == .standard && !self.isComposerCompacted + } + + private var showsAttachments: Bool { + self.style == .standard + } + + private var showsConnectionPill: Bool { + self.style == .standard && !self.isComposerCompacted + } + + private var composerPadding: CGFloat { + self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6) + } + + private var editorPadding: CGFloat { + self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6) + } + + private var textMinHeight: CGFloat { + self.style == .onboarding ? 24 : 28 + } + + private var textMaxHeight: CGFloat { + self.style == .onboarding ? 52 : 64 + } + + private var isComposerCompacted: Bool { + #if os(macOS) + false + #else + self.style == .standard && self.isFocused + #endif + } + + #if os(macOS) + private func pickFilesMac() { + let panel = NSOpenPanel() + panel.title = "Select image attachments" + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.allowedContentTypes = [.image] + panel.begin { resp in + guard resp == .OK else { return } + self.viewModel.addAttachments(urls: panel.urls) + } + } + + private func handleDrop(_ providers: [NSItemProvider]) -> Bool { + let fileProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) } + guard !fileProviders.isEmpty else { return false } + for item in fileProviders { + item.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in + guard let data = item as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil) + else { return } + Task { @MainActor in + self.viewModel.addAttachments(urls: [url]) + } + } + } + return true + } + #else + private func loadPhotosPickerItems(_ items: [PhotosPickerItem]) async { + for item in items { + do { + guard let data = try await item.loadTransferable(type: Data.self) else { continue } + let type = item.supportedContentTypes.first ?? .image + let ext = type.preferredFilenameExtension ?? "jpg" + let mime = type.preferredMIMEType ?? "image/jpeg" + let name = "photo-\(UUID().uuidString.prefix(8)).\(ext)" + self.viewModel.addImageAttachment(data: data, fileName: name, mimeType: mime) + } catch { + self.viewModel.errorText = error.localizedDescription + } + } + self.pickerItems = [] + } + #endif +} + +#if os(macOS) +import AppKit +import UniformTypeIdentifiers + +private struct ChatComposerTextView: NSViewRepresentable { + @Binding var text: String + @Binding var shouldFocus: Bool + var onSend: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSScrollView { + let textView = ChatComposerNSTextView() + textView.delegate = context.coordinator + textView.drawsBackground = false + textView.isRichText = false + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.font = .systemFont(ofSize: 14, weight: .regular) + textView.textContainer?.lineBreakMode = .byWordWrapping + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainerInset = NSSize(width: 2, height: 4) + textView.focusRingType = .none + + textView.minSize = .zero + textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true + + textView.string = self.text + textView.onSend = { [weak textView] in + textView?.window?.makeFirstResponder(nil) + self.onSend() + } + + let scroll = NSScrollView() + scroll.drawsBackground = false + scroll.borderType = .noBorder + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.scrollerStyle = .overlay + scroll.hasHorizontalScroller = false + scroll.documentView = textView + return scroll + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? ChatComposerNSTextView else { return } + + if self.shouldFocus, let window = scrollView.window { + window.makeFirstResponder(textView) + self.shouldFocus = false + } + + let isEditing = scrollView.window?.firstResponder == textView + + // Always allow clearing the text (e.g. after send), even while editing. + // Only skip other updates while editing to avoid cursor jumps. + let shouldClear = self.text.isEmpty && !textView.string.isEmpty + if isEditing, !shouldClear { return } + + if textView.string != self.text { + context.coordinator.isProgrammaticUpdate = true + defer { context.coordinator.isProgrammaticUpdate = false } + textView.string = self.text + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: ChatComposerTextView + var isProgrammaticUpdate = false + + init(_ parent: ChatComposerTextView) { self.parent = parent } + + func textDidChange(_ notification: Notification) { + guard !self.isProgrammaticUpdate else { return } + guard let view = notification.object as? NSTextView else { return } + guard view.window?.firstResponder === view else { return } + self.parent.text = view.string + } + } +} + +private final class ChatComposerNSTextView: NSTextView { + var onSend: (() -> Void)? + + override func keyDown(with event: NSEvent) { + let isReturn = event.keyCode == 36 + if isReturn { + if self.hasMarkedText() { + super.keyDown(with: event) + return + } + if event.modifierFlags.contains(.shift) { + super.insertNewline(nil) + return + } + self.onSend?() + return + } + super.keyDown(with: event) + } +} +#endif diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift new file mode 100644 index 00000000..a96e288d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift @@ -0,0 +1,123 @@ +import Foundation + +enum ChatMarkdownPreprocessor { + // Keep in sync with `src/auto-reply/reply/strip-inbound-meta.ts` + // (`INBOUND_META_SENTINELS`), and extend parser expectations in + // `ChatMarkdownPreprocessorTests` when sentinels change. + private static let inboundContextHeaders = [ + "Conversation info (untrusted metadata):", + "Sender (untrusted metadata):", + "Thread starter (untrusted, for context):", + "Replied message (untrusted, for context):", + "Forwarded message context (untrusted metadata):", + "Chat history since last reply (untrusted, for context):", + ] + + struct InlineImage: Identifiable { + let id = UUID() + let label: String + let image: OpenClawPlatformImage? + } + + struct Result { + let cleaned: String + let images: [InlineImage] + } + + static func preprocess(markdown raw: String) -> Result { + let withoutContextBlocks = self.stripInboundContextBlocks(raw) + let withoutTimestamps = self.stripPrefixedTimestamps(withoutContextBlocks) + let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"# + guard let re = try? NSRegularExpression(pattern: pattern) else { + return Result(cleaned: self.normalize(withoutTimestamps), images: []) + } + + let ns = withoutTimestamps as NSString + let matches = re.matches( + in: withoutTimestamps, + range: NSRange(location: 0, length: ns.length)) + if matches.isEmpty { return Result(cleaned: self.normalize(withoutTimestamps), images: []) } + + var images: [InlineImage] = [] + var cleaned = withoutTimestamps + + for match in matches.reversed() { + guard match.numberOfRanges >= 3 else { continue } + let label = ns.substring(with: match.range(at: 1)) + let dataURL = ns.substring(with: match.range(at: 2)) + + let image: OpenClawPlatformImage? = { + guard let comma = dataURL.firstIndex(of: ",") else { return nil } + let b64 = String(dataURL[dataURL.index(after: comma)...]) + guard let data = Data(base64Encoded: b64) else { return nil } + return OpenClawPlatformImage(data: data) + }() + images.append(InlineImage(label: label, image: image)) + + let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location) + let end = cleaned.index(start, offsetBy: match.range.length) + cleaned.replaceSubrange(start.. String { + guard self.inboundContextHeaders.contains(where: raw.contains) else { + return raw + } + + let normalized = raw.replacingOccurrences(of: "\r\n", with: "\n") + var outputLines: [String] = [] + var inMetaBlock = false + var inFencedJson = false + + for line in normalized.split(separator: "\n", omittingEmptySubsequences: false) { + let currentLine = String(line) + + if !inMetaBlock && self.inboundContextHeaders.contains(where: currentLine.hasPrefix) { + inMetaBlock = true + inFencedJson = false + continue + } + + if inMetaBlock { + if !inFencedJson && currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```json" { + inFencedJson = true + continue + } + + if inFencedJson { + if currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```" { + inMetaBlock = false + inFencedJson = false + } + continue + } + + if currentLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + continue + } + + inMetaBlock = false + } + + outputLines.append(currentLine) + } + + return outputLines.joined(separator: "\n").replacingOccurrences(of: #"^\n+"#, with: "", options: .regularExpression) + } + + private static func stripPrefixedTimestamps(_ raw: String) -> String { + let pattern = #"(?m)^\[[A-Za-z]{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+(?:GMT|UTC)[+-]?\d{0,2}\]\s*"# + return raw.replacingOccurrences(of: pattern, with: "", options: .regularExpression) + } + + private static func normalize(_ raw: String) -> String { + var output = raw + output = output.replacingOccurrences(of: "\r\n", with: "\n") + output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n") + output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n") + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift new file mode 100644 index 00000000..e68c8591 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift @@ -0,0 +1,90 @@ +import SwiftUI +import Textual + +public enum ChatMarkdownVariant: String, CaseIterable, Sendable { + case standard + case compact +} + +@MainActor +struct ChatMarkdownRenderer: View { + enum Context { + case user + case assistant + } + + let text: String + let context: Context + let variant: ChatMarkdownVariant + let font: Font + let textColor: Color + + var body: some View { + let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text) + VStack(alignment: .leading, spacing: 10) { + StructuredText(markdown: processed.cleaned) + .modifier(ChatMarkdownStyle( + variant: self.variant, + context: self.context, + font: self.font, + textColor: self.textColor)) + + if !processed.images.isEmpty { + InlineImageList(images: processed.images) + } + } + } +} + +private struct ChatMarkdownStyle: ViewModifier { + let variant: ChatMarkdownVariant + let context: ChatMarkdownRenderer.Context + let font: Font + let textColor: Color + + func body(content: Content) -> some View { + Group { + if self.variant == .compact { + content.textual.structuredTextStyle(.default) + } else { + content.textual.structuredTextStyle(.gitHub) + } + } + .font(self.font) + .foregroundStyle(self.textColor) + .textual.inlineStyle(self.inlineStyle) + .textual.textSelection(.enabled) + } + + private var inlineStyle: InlineStyle { + let linkColor: Color = self.context == .user ? self.textColor : .accentColor + let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9 + return InlineStyle() + .code(.monospaced, .fontScale(codeScale)) + .link(.foregroundColor(linkColor)) + } +} + +@MainActor +private struct InlineImageList: View { + let images: [ChatMarkdownPreprocessor.InlineImage] + + var body: some View { + ForEach(images, id: \.id) { item in + if let img = item.image { + OpenClawPlatformImageFactory.image(img) + .resizable() + .scaledToFit() + .frame(maxHeight: 260) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)) + } else { + Text(item.label.isEmpty ? "Image" : item.label) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift new file mode 100644 index 00000000..22f28517 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -0,0 +1,620 @@ +import OpenClawKit +import Foundation +import SwiftUI + +private enum ChatUIConstants { + static let bubbleMaxWidth: CGFloat = 560 + static let bubbleCorner: CGFloat = 18 +} + +private struct ChatBubbleShape: InsettableShape { + enum Tail { + case left + case right + case none + } + + let cornerRadius: CGFloat + let tail: Tail + var insetAmount: CGFloat = 0 + + private let tailWidth: CGFloat = 7 + private let tailBaseHeight: CGFloat = 9 + + func inset(by amount: CGFloat) -> ChatBubbleShape { + var copy = self + copy.insetAmount += amount + return copy + } + + func path(in rect: CGRect) -> Path { + let rect = rect.insetBy(dx: self.insetAmount, dy: self.insetAmount) + switch self.tail { + case .left: + return self.leftTailPath(in: rect, radius: self.cornerRadius) + case .right: + return self.rightTailPath(in: rect, radius: self.cornerRadius) + case .none: + return Path(roundedRect: rect, cornerRadius: self.cornerRadius) + } + } + + private func rightTailPath(in rect: CGRect, radius r: CGFloat) -> Path { + var path = Path() + let bubbleMinX = rect.minX + let bubbleMaxX = rect.maxX - self.tailWidth + let bubbleMinY = rect.minY + let bubbleMaxY = rect.maxY + + let available = max(4, bubbleMaxY - bubbleMinY - 2 * r) + let baseH = min(tailBaseHeight, available) + let baseBottomY = bubbleMaxY - max(r * 0.45, 6) + let baseTopY = baseBottomY - baseH + let midY = (baseTopY + baseBottomY) / 2 + + let baseTop = CGPoint(x: bubbleMaxX, y: baseTopY) + let baseBottom = CGPoint(x: bubbleMaxX, y: baseBottomY) + let tip = CGPoint(x: bubbleMaxX + self.tailWidth, y: midY) + + path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY)) + path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY)) + path.addQuadCurve( + to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r), + control: CGPoint(x: bubbleMaxX, y: bubbleMinY)) + path.addLine(to: baseTop) + path.addCurve( + to: tip, + control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseTopY + baseH * 0.05), + control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY - baseH * 0.15)) + path.addCurve( + to: baseBottom, + control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15), + control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05)) + path.addQuadCurve( + to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY), + control: CGPoint(x: bubbleMaxX, y: bubbleMaxY)) + path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY)) + path.addQuadCurve( + to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r), + control: CGPoint(x: bubbleMinX, y: bubbleMaxY)) + path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r)) + path.addQuadCurve( + to: CGPoint(x: bubbleMinX + r, y: bubbleMinY), + control: CGPoint(x: bubbleMinX, y: bubbleMinY)) + + return path + } + + private func leftTailPath(in rect: CGRect, radius r: CGFloat) -> Path { + var path = Path() + let bubbleMinX = rect.minX + self.tailWidth + let bubbleMaxX = rect.maxX + let bubbleMinY = rect.minY + let bubbleMaxY = rect.maxY + + let available = max(4, bubbleMaxY - bubbleMinY - 2 * r) + let baseH = min(tailBaseHeight, available) + let baseBottomY = bubbleMaxY - max(r * 0.45, 6) + let baseTopY = baseBottomY - baseH + let midY = (baseTopY + baseBottomY) / 2 + + let baseTop = CGPoint(x: bubbleMinX, y: baseTopY) + let baseBottom = CGPoint(x: bubbleMinX, y: baseBottomY) + let tip = CGPoint(x: bubbleMinX - self.tailWidth, y: midY) + + path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY)) + path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY)) + path.addQuadCurve( + to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r), + control: CGPoint(x: bubbleMaxX, y: bubbleMinY)) + path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r)) + path.addQuadCurve( + to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY), + control: CGPoint(x: bubbleMaxX, y: bubbleMaxY)) + path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY)) + path.addQuadCurve( + to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r), + control: CGPoint(x: bubbleMinX, y: bubbleMaxY)) + path.addLine(to: baseBottom) + path.addCurve( + to: tip, + control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05), + control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY + baseH * 0.15)) + path.addCurve( + to: baseTop, + control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY - baseH * 0.15), + control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseTopY + baseH * 0.05)) + path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r)) + path.addQuadCurve( + to: CGPoint(x: bubbleMinX + r, y: bubbleMinY), + control: CGPoint(x: bubbleMinX, y: bubbleMinY)) + + return path + } +} + +@MainActor +struct ChatMessageBubble: View { + let message: OpenClawChatMessage + let style: OpenClawChatView.Style + let markdownVariant: ChatMarkdownVariant + let userAccent: Color? + + var body: some View { + ChatMessageBody( + message: self.message, + isUser: self.isUser, + style: self.style, + markdownVariant: self.markdownVariant, + userAccent: self.userAccent) + .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading) + .frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading) + .padding(.horizontal, 2) + } + + private var isUser: Bool { self.message.role.lowercased() == "user" } +} + +@MainActor +private struct ChatMessageBody: View { + let message: OpenClawChatMessage + let isUser: Bool + let style: OpenClawChatView.Style + let markdownVariant: ChatMarkdownVariant + let userAccent: Color? + + var body: some View { + let text = self.primaryText + let textColor = self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText + + VStack(alignment: .leading, spacing: 10) { + if self.isToolResultMessage { + if !text.isEmpty { + ToolResultCard( + title: self.toolResultTitle, + text: text, + isUser: self.isUser, + toolName: self.message.toolName) + } + } else if self.isUser { + ChatMarkdownRenderer( + text: text, + context: .user, + variant: self.markdownVariant, + font: .system(size: 14), + textColor: textColor) + } else { + ChatAssistantTextBody(text: text, markdownVariant: self.markdownVariant) + } + + if !self.inlineAttachments.isEmpty { + ForEach(self.inlineAttachments.indices, id: \.self) { idx in + AttachmentRow(att: self.inlineAttachments[idx], isUser: self.isUser) + } + } + + if !self.toolCalls.isEmpty { + ForEach(self.toolCalls.indices, id: \.self) { idx in + ToolCallCard( + content: self.toolCalls[idx], + isUser: self.isUser) + } + } + + if !self.inlineToolResults.isEmpty { + ForEach(self.inlineToolResults.indices, id: \.self) { idx in + let toolResult = self.inlineToolResults[idx] + let display = ToolDisplayRegistry.resolve(name: toolResult.name ?? "tool", args: nil) + ToolResultCard( + title: "\(display.emoji) \(display.title)", + text: toolResult.text ?? "", + isUser: self.isUser, + toolName: toolResult.name) + } + } + } + .textSelection(.enabled) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .foregroundStyle(textColor) + .background(self.bubbleBackground) + .clipShape(self.bubbleShape) + .overlay(self.bubbleBorder) + .shadow(color: self.bubbleShadowColor, radius: self.bubbleShadowRadius, y: self.bubbleShadowYOffset) + .padding(.leading, self.tailPaddingLeading) + .padding(.trailing, self.tailPaddingTrailing) + } + + private var primaryText: String { + let parts = self.message.content.compactMap { content -> String? in + let kind = (content.type ?? "text").lowercased() + guard kind == "text" || kind.isEmpty else { return nil } + return content.text + } + return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var inlineAttachments: [OpenClawChatMessageContent] { + self.message.content.filter { content in + switch content.type ?? "text" { + case "file", "attachment": + true + default: + false + } + } + } + + private var toolCalls: [OpenClawChatMessageContent] { + self.message.content.filter { content in + let kind = (content.type ?? "").lowercased() + if ["toolcall", "tool_call", "tooluse", "tool_use"].contains(kind) { + return true + } + return content.name != nil && content.arguments != nil + } + } + + private var inlineToolResults: [OpenClawChatMessageContent] { + self.message.content.filter { content in + let kind = (content.type ?? "").lowercased() + return kind == "toolresult" || kind == "tool_result" + } + } + + private var isToolResultMessage: Bool { + let role = self.message.role.lowercased() + return role == "toolresult" || role == "tool_result" + } + + private var toolResultTitle: String { + if let name = self.message.toolName, !name.isEmpty { + let display = ToolDisplayRegistry.resolve(name: name, args: nil) + return "\(display.emoji) \(display.title)" + } + let display = ToolDisplayRegistry.resolve(name: "tool", args: nil) + return "\(display.emoji) \(display.title)" + } + + private var bubbleFillColor: Color { + if self.isUser { + return self.userAccent ?? OpenClawChatTheme.userBubble + } + if self.style == .onboarding { + return OpenClawChatTheme.onboardingAssistantBubble + } + return OpenClawChatTheme.assistantBubble + } + + private var bubbleBackground: AnyShapeStyle { + AnyShapeStyle(self.bubbleFillColor) + } + + private var bubbleBorderColor: Color { + if self.isUser { + return Color.white.opacity(0.12) + } + if self.style == .onboarding { + return OpenClawChatTheme.onboardingAssistantBorder + } + return Color.white.opacity(0.08) + } + + private var bubbleBorderWidth: CGFloat { + if self.isUser { return 0.5 } + if self.style == .onboarding { return 0.8 } + return 1 + } + + private var bubbleBorder: some View { + self.bubbleShape.strokeBorder(self.bubbleBorderColor, lineWidth: self.bubbleBorderWidth) + } + + private var bubbleShape: ChatBubbleShape { + ChatBubbleShape(cornerRadius: ChatUIConstants.bubbleCorner, tail: self.bubbleTail) + } + + private var bubbleTail: ChatBubbleShape.Tail { + guard self.style == .onboarding else { return .none } + return self.isUser ? .right : .left + } + + private var tailPaddingLeading: CGFloat { + self.style == .onboarding && !self.isUser ? 8 : 0 + } + + private var tailPaddingTrailing: CGFloat { + self.style == .onboarding && self.isUser ? 8 : 0 + } + + private var bubbleShadowColor: Color { + self.style == .onboarding && !self.isUser ? Color.black.opacity(0.28) : .clear + } + + private var bubbleShadowRadius: CGFloat { + self.style == .onboarding && !self.isUser ? 6 : 0 + } + + private var bubbleShadowYOffset: CGFloat { + self.style == .onboarding && !self.isUser ? 2 : 0 + } +} + +private struct AttachmentRow: View { + let att: OpenClawChatMessageContent + let isUser: Bool + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "paperclip") + Text(self.att.fileName ?? "Attachment") + .font(.footnote) + .lineLimit(1) + .foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText) + Spacer() + } + .padding(10) + .background(self.isUser ? Color.white.opacity(0.2) : Color.black.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} + +private struct ToolCallCard: View { + let content: OpenClawChatMessageContent + let isUser: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(self.toolName) + .font(.footnote.weight(.semibold)) + Spacer(minLength: 0) + } + + if let summary = self.summary, !summary.isEmpty { + Text(summary) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(OpenClawChatTheme.subtleCard) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))) + } + + private var toolName: String { + "\(self.display.emoji) \(self.display.title)" + } + + private var summary: String? { + self.display.detailLine + } + + private var display: ToolDisplaySummary { + ToolDisplayRegistry.resolve(name: self.content.name ?? "tool", args: self.content.arguments) + } +} + +private struct ToolResultCard: View { + let title: String + let text: String + let isUser: Bool + let toolName: String? + @State private var expanded = false + + var body: some View { + if !self.displayContent.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text(self.title) + .font(.footnote.weight(.semibold)) + Spacer(minLength: 0) + } + + Text(self.displayText) + .font(.footnote.monospaced()) + .foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText) + .lineLimit(self.expanded ? nil : Self.previewLineLimit) + + if self.shouldShowToggle { + Button(self.expanded ? "Show less" : "Show full output") { + self.expanded.toggle() + } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(OpenClawChatTheme.subtleCard) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))) + } + } + + private static let previewLineLimit = 8 + + private var displayContent: String { + ToolResultTextFormatter.format(text: self.text, toolName: self.toolName) + } + + private var lines: [Substring] { + self.displayContent.components(separatedBy: .newlines).map { Substring($0) } + } + + private var displayText: String { + guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.displayContent } + return self.lines.prefix(Self.previewLineLimit).joined(separator: "\n") + "\n…" + } + + private var shouldShowToggle: Bool { + self.lines.count > Self.previewLineLimit + } +} + +@MainActor +struct ChatTypingIndicatorBubble: View { + let style: OpenClawChatView.Style + + var body: some View { + HStack(spacing: 10) { + TypingDots() + Spacer(minLength: 0) + } + .padding(.vertical, self.style == .standard ? 12 : 10) + .padding(.horizontal, self.style == .standard ? 12 : 14) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(OpenClawChatTheme.assistantBubble)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1)) + .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading) + .focusable(false) + } +} + +extension ChatTypingIndicatorBubble: @MainActor Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.style == rhs.style + } +} + +@MainActor +struct ChatStreamingAssistantBubble: View { + let text: String + let markdownVariant: ChatMarkdownVariant + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ChatAssistantTextBody(text: self.text, markdownVariant: self.markdownVariant) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(OpenClawChatTheme.assistantBubble)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1)) + .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading) + .focusable(false) + } +} + +@MainActor +struct ChatPendingToolsBubble: View { + let toolCalls: [OpenClawChatPendingToolCall] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label("Running tools…", systemImage: "hammer") + .font(.caption) + .foregroundStyle(.secondary) + + ForEach(self.toolCalls) { call in + let display = ToolDisplayRegistry.resolve(name: call.name, args: call.args) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(display.emoji) \(display.label)") + .font(.footnote.monospaced()) + .lineLimit(1) + Spacer(minLength: 0) + ProgressView().controlSize(.mini) + } + if let detail = display.detailLine, !detail.isEmpty { + Text(detail) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(10) + .background(Color.white.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(OpenClawChatTheme.assistantBubble)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1)) + .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading) + .focusable(false) + } +} + +extension ChatPendingToolsBubble: @MainActor Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.toolCalls == rhs.toolCalls + } +} + +@MainActor +private struct TypingDots: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.scenePhase) private var scenePhase + @State private var animate = false + + var body: some View { + HStack(spacing: 5) { + ForEach(0..<3, id: \.self) { idx in + Circle() + .fill(Color.secondary.opacity(0.55)) + .frame(width: 7, height: 7) + .scaleEffect(self.reduceMotion ? 0.85 : (self.animate ? 1.05 : 0.70)) + .opacity(self.reduceMotion ? 0.55 : (self.animate ? 0.95 : 0.30)) + .animation( + self.reduceMotion ? nil : .easeInOut(duration: 0.55) + .repeatForever(autoreverses: true) + .delay(Double(idx) * 0.16), + value: self.animate) + } + } + .onAppear { self.updateAnimationState() } + .onDisappear { self.animate = false } + .onChange(of: self.scenePhase) { _, _ in + self.updateAnimationState() + } + .onChange(of: self.reduceMotion) { _, _ in + self.updateAnimationState() + } + } + + private func updateAnimationState() { + guard !self.reduceMotion, self.scenePhase == .active else { + self.animate = false + return + } + self.animate = true + } +} + +private struct ChatAssistantTextBody: View { + let text: String + let markdownVariant: ChatMarkdownVariant + + var body: some View { + let segments = AssistantTextParser.segments(from: self.text) + VStack(alignment: .leading, spacing: 10) { + ForEach(segments) { segment in + let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14) + ChatMarkdownRenderer( + text: segment.text, + context: .assistant, + variant: self.markdownVariant, + font: font, + textColor: OpenClawChatTheme.assistantText) + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift new file mode 100644 index 00000000..c58f2d70 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift @@ -0,0 +1,332 @@ +import OpenClawKit +import Foundation + +// NOTE: keep this file lightweight; decode must be resilient to varying transcript formats. + +#if canImport(AppKit) +import AppKit + +public typealias OpenClawPlatformImage = NSImage +#elseif canImport(UIKit) +import UIKit + +public typealias OpenClawPlatformImage = UIImage +#endif + +public struct OpenClawChatUsageCost: Codable, Hashable, Sendable { + public let input: Double? + public let output: Double? + public let cacheRead: Double? + public let cacheWrite: Double? + public let total: Double? +} + +public struct OpenClawChatUsage: Codable, Hashable, Sendable { + public let input: Int? + public let output: Int? + public let cacheRead: Int? + public let cacheWrite: Int? + public let cost: OpenClawChatUsageCost? + public let total: Int? + + enum CodingKeys: String, CodingKey { + case input + case output + case cacheRead + case cacheWrite + case cost + case total + case totalTokens + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.input = try container.decodeIfPresent(Int.self, forKey: .input) + self.output = try container.decodeIfPresent(Int.self, forKey: .output) + self.cacheRead = try container.decodeIfPresent(Int.self, forKey: .cacheRead) + self.cacheWrite = try container.decodeIfPresent(Int.self, forKey: .cacheWrite) + self.cost = try container.decodeIfPresent(OpenClawChatUsageCost.self, forKey: .cost) + self.total = + try container.decodeIfPresent(Int.self, forKey: .total) ?? + container.decodeIfPresent(Int.self, forKey: .totalTokens) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.input, forKey: .input) + try container.encodeIfPresent(self.output, forKey: .output) + try container.encodeIfPresent(self.cacheRead, forKey: .cacheRead) + try container.encodeIfPresent(self.cacheWrite, forKey: .cacheWrite) + try container.encodeIfPresent(self.cost, forKey: .cost) + try container.encodeIfPresent(self.total, forKey: .total) + } +} + +public struct OpenClawChatMessageContent: Codable, Hashable, Sendable { + public let type: String? + public let text: String? + public let thinking: String? + public let thinkingSignature: String? + public let mimeType: String? + public let fileName: String? + public let content: AnyCodable? + + // Tool-call fields (when `type == "toolCall"` or similar) + public let id: String? + public let name: String? + public let arguments: AnyCodable? + + public init( + type: String?, + text: String?, + thinking: String? = nil, + thinkingSignature: String? = nil, + mimeType: String?, + fileName: String?, + content: AnyCodable?, + id: String? = nil, + name: String? = nil, + arguments: AnyCodable? = nil) + { + self.type = type + self.text = text + self.thinking = thinking + self.thinkingSignature = thinkingSignature + self.mimeType = mimeType + self.fileName = fileName + self.content = content + self.id = id + self.name = name + self.arguments = arguments + } + + enum CodingKeys: String, CodingKey { + case type + case text + case thinking + case thinkingSignature + case mimeType + case fileName + case content + case id + case name + case arguments + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decodeIfPresent(String.self, forKey: .type) + self.text = try container.decodeIfPresent(String.self, forKey: .text) + self.thinking = try container.decodeIfPresent(String.self, forKey: .thinking) + self.thinkingSignature = try container.decodeIfPresent(String.self, forKey: .thinkingSignature) + self.mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) + self.fileName = try container.decodeIfPresent(String.self, forKey: .fileName) + self.id = try container.decodeIfPresent(String.self, forKey: .id) + self.name = try container.decodeIfPresent(String.self, forKey: .name) + self.arguments = try container.decodeIfPresent(AnyCodable.self, forKey: .arguments) + + if let any = try container.decodeIfPresent(AnyCodable.self, forKey: .content) { + self.content = any + } else if let str = try container.decodeIfPresent(String.self, forKey: .content) { + self.content = AnyCodable(str) + } else { + self.content = nil + } + } +} + +public struct OpenClawChatMessage: Codable, Identifiable, Sendable { + public var id: UUID = .init() + public let role: String + public let content: [OpenClawChatMessageContent] + public let timestamp: Double? + public let toolCallId: String? + public let toolName: String? + public let usage: OpenClawChatUsage? + public let stopReason: String? + + enum CodingKeys: String, CodingKey { + case role + case content + case timestamp + case toolCallId + case tool_call_id + case toolName + case tool_name + case usage + case stopReason + } + + public init( + id: UUID = .init(), + role: String, + content: [OpenClawChatMessageContent], + timestamp: Double?, + toolCallId: String? = nil, + toolName: String? = nil, + usage: OpenClawChatUsage? = nil, + stopReason: String? = nil) + { + self.id = id + self.role = role + self.content = content + self.timestamp = timestamp + self.toolCallId = toolCallId + self.toolName = toolName + self.usage = usage + self.stopReason = stopReason + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.role = try container.decode(String.self, forKey: .role) + self.timestamp = try container.decodeIfPresent(Double.self, forKey: .timestamp) + self.toolCallId = + try container.decodeIfPresent(String.self, forKey: .toolCallId) ?? + container.decodeIfPresent(String.self, forKey: .tool_call_id) + self.toolName = + try container.decodeIfPresent(String.self, forKey: .toolName) ?? + container.decodeIfPresent(String.self, forKey: .tool_name) + self.usage = try container.decodeIfPresent(OpenClawChatUsage.self, forKey: .usage) + self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason) + + if let decoded = try? container.decode([OpenClawChatMessageContent].self, forKey: .content) { + self.content = decoded + return + } + + // Some session log formats store `content` as a plain string. + if let text = try? container.decode(String.self, forKey: .content) { + self.content = [ + OpenClawChatMessageContent( + type: "text", + text: text, + thinking: nil, + thinkingSignature: nil, + mimeType: nil, + fileName: nil, + content: nil, + id: nil, + name: nil, + arguments: nil), + ] + return + } + + self.content = [] + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.role, forKey: .role) + try container.encodeIfPresent(self.timestamp, forKey: .timestamp) + try container.encodeIfPresent(self.toolCallId, forKey: .toolCallId) + try container.encodeIfPresent(self.toolName, forKey: .toolName) + try container.encodeIfPresent(self.usage, forKey: .usage) + try container.encodeIfPresent(self.stopReason, forKey: .stopReason) + try container.encode(self.content, forKey: .content) + } +} + +public struct OpenClawChatHistoryPayload: Codable, Sendable { + public let sessionKey: String + public let sessionId: String? + public let messages: [AnyCodable]? + public let thinkingLevel: String? +} + +public struct OpenClawSessionPreviewItem: Codable, Hashable, Sendable { + public let role: String + public let text: String +} + +public struct OpenClawSessionPreviewEntry: Codable, Sendable { + public let key: String + public let status: String + public let items: [OpenClawSessionPreviewItem] +} + +public struct OpenClawSessionsPreviewPayload: Codable, Sendable { + public let ts: Int + public let previews: [OpenClawSessionPreviewEntry] + + public init(ts: Int, previews: [OpenClawSessionPreviewEntry]) { + self.ts = ts + self.previews = previews + } +} + +public struct OpenClawChatSendResponse: Codable, Sendable { + public let runId: String + public let status: String +} + +public struct OpenClawChatEventPayload: Codable, Sendable { + public let runId: String? + public let sessionKey: String? + public let state: String? + public let message: AnyCodable? + public let errorMessage: String? +} + +public struct OpenClawAgentEventPayload: Codable, Sendable, Identifiable { + public var id: String { "\(self.runId)-\(self.seq ?? -1)" } + public let runId: String + public let seq: Int? + public let stream: String + public let ts: Int? + public let data: [String: AnyCodable] +} + +public struct OpenClawChatPendingToolCall: Identifiable, Hashable, Sendable { + public var id: String { self.toolCallId } + public let toolCallId: String + public let name: String + public let args: AnyCodable? + public let startedAt: Double? + public let isError: Bool? +} + +public struct OpenClawGatewayHealthOK: Codable, Sendable { + public let ok: Bool? +} + +public struct OpenClawPendingAttachment: Identifiable { + public let id = UUID() + public let url: URL? + public let data: Data + public let fileName: String + public let mimeType: String + public let type: String + public let preview: OpenClawPlatformImage? + + public init( + url: URL?, + data: Data, + fileName: String, + mimeType: String, + type: String = "file", + preview: OpenClawPlatformImage?) + { + self.url = url + self.data = data + self.fileName = fileName + self.mimeType = mimeType + self.type = type + self.preview = preview + } +} + +public struct OpenClawChatAttachmentPayload: Codable, Sendable, Hashable { + public let type: String + public let mimeType: String + public let fileName: String + public let content: String + + public init(type: String, mimeType: String, fileName: String, content: String) { + self.type = type + self.mimeType = mimeType + self.fileName = fileName + self.content = content + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatPayloadDecoding.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatPayloadDecoding.swift new file mode 100644 index 00000000..02636696 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatPayloadDecoding.swift @@ -0,0 +1,9 @@ +import OpenClawKit +import Foundation + +enum ChatPayloadDecoding { + static func decode(_ payload: AnyCodable, as _: T.Type = T.self) throws -> T { + let data = try JSONEncoder().encode(payload) + return try JSONDecoder().decode(T.self, from: data) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift new file mode 100644 index 00000000..febe69a3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct OpenClawChatSessionsDefaults: Codable, Sendable { + public let model: String? + public let contextTokens: Int? +} + +public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable { + public var id: String { self.key } + + public let key: String + public let kind: String? + public let displayName: String? + public let surface: String? + public let subject: String? + public let room: String? + public let space: String? + public let updatedAt: Double? + public let sessionId: String? + + public let systemSent: Bool? + public let abortedLastRun: Bool? + public let thinkingLevel: String? + public let verboseLevel: String? + + public let inputTokens: Int? + public let outputTokens: Int? + public let totalTokens: Int? + + public let model: String? + public let contextTokens: Int? +} + +public struct OpenClawChatSessionsListResponse: Codable, Sendable { + public let ts: Double? + public let path: String? + public let count: Int? + public let defaults: OpenClawChatSessionsDefaults? + public let sessions: [OpenClawChatSessionEntry] +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift new file mode 100644 index 00000000..678000d2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift @@ -0,0 +1,69 @@ +import Observation +import SwiftUI + +@MainActor +struct ChatSessionsSheet: View { + @Bindable var viewModel: OpenClawChatViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List(self.viewModel.sessions) { session in + Button { + self.viewModel.switchSession(to: session.key) + self.dismiss() + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(session.displayName ?? session.key) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + if let updatedAt = session.updatedAt, updatedAt > 0 { + Text(Date(timeIntervalSince1970: updatedAt / 1000).formatted( + date: .abbreviated, + time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .navigationTitle("Sessions") + .toolbar { + #if os(macOS) + ToolbarItem(placement: .automatic) { + Button { + self.viewModel.refreshSessions(limit: 200) + } label: { + Image(systemName: "arrow.clockwise") + } + } + ToolbarItem(placement: .primaryAction) { + Button { + self.dismiss() + } label: { + Image(systemName: "xmark") + } + } + #else + ToolbarItem(placement: .topBarLeading) { + Button { + self.viewModel.refreshSessions(limit: 200) + } label: { + Image(systemName: "arrow.clockwise") + } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + self.dismiss() + } label: { + Image(systemName: "xmark") + } + } + #endif + } + .onAppear { + self.viewModel.refreshSessions(limit: 200) + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift new file mode 100644 index 00000000..c06ed4f4 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift @@ -0,0 +1,174 @@ +import SwiftUI + +#if os(macOS) +import AppKit +#else +import UIKit +#endif + +#if os(macOS) +extension NSAppearance { + fileprivate var isDarkAqua: Bool { + self.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + } +} +#endif + +enum OpenClawChatTheme { + #if os(macOS) + static func resolvedAssistantBubbleColor(for appearance: NSAppearance) -> NSColor { + // NSColor semantic colors don't reliably resolve for arbitrary NSAppearance in SwiftPM. + // Use explicit light/dark values so the bubble updates when the system appearance flips. + appearance.isDarkAqua + ? NSColor(calibratedWhite: 0.18, alpha: 0.88) + : NSColor(calibratedWhite: 0.94, alpha: 0.92) + } + + static func resolvedOnboardingAssistantBubbleColor(for appearance: NSAppearance) -> NSColor { + appearance.isDarkAqua + ? NSColor(calibratedWhite: 0.20, alpha: 0.94) + : NSColor(calibratedWhite: 0.97, alpha: 0.98) + } + + static let assistantBubbleDynamicNSColor = NSColor( + name: NSColor.Name("OpenClawChatTheme.assistantBubble"), + dynamicProvider: resolvedAssistantBubbleColor(for:)) + + static let onboardingAssistantBubbleDynamicNSColor = NSColor( + name: NSColor.Name("OpenClawChatTheme.onboardingAssistantBubble"), + dynamicProvider: resolvedOnboardingAssistantBubbleColor(for:)) + #endif + + static var surface: Color { + #if os(macOS) + Color(nsColor: .windowBackgroundColor) + #else + Color(uiColor: .systemBackground) + #endif + } + + @ViewBuilder + static var background: some View { + #if os(macOS) + ZStack { + Rectangle() + .fill(.ultraThinMaterial) + LinearGradient( + colors: [ + Color.white.opacity(0.12), + Color(nsColor: .windowBackgroundColor).opacity(0.35), + Color.black.opacity(0.35), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing) + RadialGradient( + colors: [ + Color(nsColor: .systemOrange).opacity(0.14), + .clear, + ], + center: .topLeading, + startRadius: 40, + endRadius: 320) + RadialGradient( + colors: [ + Color(nsColor: .systemTeal).opacity(0.12), + .clear, + ], + center: .topTrailing, + startRadius: 40, + endRadius: 280) + Color.black.opacity(0.08) + } + #else + Color(uiColor: .systemBackground) + #endif + } + + static var card: Color { + #if os(macOS) + Color(nsColor: .textBackgroundColor) + #else + Color(uiColor: .secondarySystemBackground) + #endif + } + + static var subtleCard: AnyShapeStyle { + #if os(macOS) + AnyShapeStyle(.ultraThinMaterial) + #else + AnyShapeStyle(Color(uiColor: .secondarySystemBackground).opacity(0.9)) + #endif + } + + static var userBubble: Color { + Color(red: 127 / 255.0, green: 184 / 255.0, blue: 212 / 255.0) + } + + static var assistantBubble: Color { + #if os(macOS) + Color(nsColor: self.assistantBubbleDynamicNSColor) + #else + Color(uiColor: .secondarySystemBackground) + #endif + } + + static var onboardingAssistantBubble: Color { + #if os(macOS) + Color(nsColor: self.onboardingAssistantBubbleDynamicNSColor) + #else + Color(uiColor: .secondarySystemBackground) + #endif + } + + static var onboardingAssistantBorder: Color { + #if os(macOS) + Color.white.opacity(0.12) + #else + Color.white.opacity(0.12) + #endif + } + + static var userText: Color { .white } + + static var assistantText: Color { + #if os(macOS) + Color(nsColor: .labelColor) + #else + Color(uiColor: .label) + #endif + } + + static var composerBackground: AnyShapeStyle { + #if os(macOS) + AnyShapeStyle(.ultraThinMaterial) + #else + AnyShapeStyle(Color(uiColor: .systemBackground)) + #endif + } + + static var composerField: AnyShapeStyle { + #if os(macOS) + AnyShapeStyle(.thinMaterial) + #else + AnyShapeStyle(Color(uiColor: .secondarySystemBackground)) + #endif + } + + static var composerBorder: Color { + Color.white.opacity(0.12) + } + + static var divider: Color { + Color.secondary.opacity(0.2) + } +} + +enum OpenClawPlatformImageFactory { + static func image(_ image: OpenClawPlatformImage) -> Image { + #if os(macOS) + Image(nsImage: image) + #else + Image(uiImage: image) + #endif + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift new file mode 100644 index 00000000..037c1352 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift @@ -0,0 +1,45 @@ +import Foundation + +public enum OpenClawChatTransportEvent: Sendable { + case health(ok: Bool) + case tick + case chat(OpenClawChatEventPayload) + case agent(OpenClawAgentEventPayload) + case seqGap +} + +public protocol OpenClawChatTransport: Sendable { + func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload + func sendMessage( + sessionKey: String, + message: String, + thinking: String, + idempotencyKey: String, + attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + + func abortRun(sessionKey: String, runId: String) async throws + func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse + + func requestHealth(timeoutMs: Int) async throws -> Bool + func events() -> AsyncStream + + func setActiveSessionKey(_ sessionKey: String) async throws +} + +extension OpenClawChatTransport { + public func setActiveSessionKey(_: String) async throws {} + + public func abortRun(sessionKey _: String, runId _: String) async throws { + throw NSError( + domain: "OpenClawChatTransport", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "chat.abort not supported by this transport"]) + } + + public func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse { + throw NSError( + domain: "OpenClawChatTransport", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "sessions.list not supported by this transport"]) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift new file mode 100644 index 00000000..0675ffc2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -0,0 +1,527 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +@MainActor +public struct OpenClawChatView: View { + public enum Style { + case standard + case onboarding + } + + @State private var viewModel: OpenClawChatViewModel + @State private var scrollerBottomID = UUID() + @State private var scrollPosition: UUID? + @State private var showSessions = false + @State private var hasPerformedInitialScroll = false + @State private var isPinnedToBottom = true + @State private var lastUserMessageID: UUID? + private let showsSessionSwitcher: Bool + private let style: Style + private let markdownVariant: ChatMarkdownVariant + private let userAccent: Color? + + private enum Layout { + #if os(macOS) + static let outerPaddingHorizontal: CGFloat = 6 + static let outerPaddingVertical: CGFloat = 0 + static let composerPaddingHorizontal: CGFloat = 0 + static let stackSpacing: CGFloat = 0 + static let messageSpacing: CGFloat = 6 + static let messageListPaddingTop: CGFloat = 12 + static let messageListPaddingBottom: CGFloat = 16 + static let messageListPaddingHorizontal: CGFloat = 6 + #else + static let outerPaddingHorizontal: CGFloat = 6 + static let outerPaddingVertical: CGFloat = 6 + static let composerPaddingHorizontal: CGFloat = 6 + static let stackSpacing: CGFloat = 6 + static let messageSpacing: CGFloat = 12 + static let messageListPaddingTop: CGFloat = 10 + static let messageListPaddingBottom: CGFloat = 6 + static let messageListPaddingHorizontal: CGFloat = 8 + #endif + } + + public init( + viewModel: OpenClawChatViewModel, + showsSessionSwitcher: Bool = false, + style: Style = .standard, + markdownVariant: ChatMarkdownVariant = .standard, + userAccent: Color? = nil) + { + self._viewModel = State(initialValue: viewModel) + self.showsSessionSwitcher = showsSessionSwitcher + self.style = style + self.markdownVariant = markdownVariant + self.userAccent = userAccent + } + + public var body: some View { + ZStack { + if self.style == .standard { + OpenClawChatTheme.background + .ignoresSafeArea() + } + + VStack(spacing: Layout.stackSpacing) { + self.messageList + .padding(.horizontal, Layout.outerPaddingHorizontal) + OpenClawChatComposer( + viewModel: self.viewModel, + style: self.style, + showsSessionSwitcher: self.showsSessionSwitcher) + .padding(.horizontal, Layout.composerPaddingHorizontal) + } + .padding(.vertical, Layout.outerPaddingVertical) + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity, alignment: .top) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .onAppear { self.viewModel.load() } + .sheet(isPresented: self.$showSessions) { + if self.showsSessionSwitcher { + ChatSessionsSheet(viewModel: self.viewModel) + } else { + EmptyView() + } + } + } + + private var messageList: some View { + ZStack { + ScrollView { + LazyVStack(spacing: Layout.messageSpacing) { + self.messageListRows + + Color.clear + #if os(macOS) + .frame(height: Layout.messageListPaddingBottom) + #else + .frame(height: Layout.messageListPaddingBottom + 1) + #endif + .id(self.scrollerBottomID) + } + // Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches. + .scrollTargetLayout() + .padding(.top, Layout.messageListPaddingTop) + .padding(.horizontal, Layout.messageListPaddingHorizontal) + } + #if !os(macOS) + .scrollDismissesKeyboard(.interactively) + #endif + // Keep the scroll pinned to the bottom for new messages. + .scrollPosition(id: self.$scrollPosition, anchor: .bottom) + .onChange(of: self.scrollPosition) { _, position in + guard let position else { return } + self.isPinnedToBottom = position == self.scrollerBottomID + } + + if self.viewModel.isLoading { + ProgressView() + .controlSize(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + self.messageListOverlay + } + // Ensure the message list claims vertical space on the first layout pass. + .frame(maxHeight: .infinity, alignment: .top) + .layoutPriority(1) + .simultaneousGesture( + TapGesture().onEnded { + self.dismissKeyboardIfNeeded() + }) + .onChange(of: self.viewModel.isLoading) { _, isLoading in + guard !isLoading, !self.hasPerformedInitialScroll else { return } + self.scrollPosition = self.scrollerBottomID + self.hasPerformedInitialScroll = true + self.isPinnedToBottom = true + } + .onChange(of: self.viewModel.sessionKey) { _, _ in + self.hasPerformedInitialScroll = false + self.isPinnedToBottom = true + } + .onChange(of: self.viewModel.isSending) { _, isSending in + // Scroll to bottom when user sends a message, even if scrolled up. + guard isSending, self.hasPerformedInitialScroll else { return } + self.isPinnedToBottom = true + withAnimation(.snappy(duration: 0.22)) { + self.scrollPosition = self.scrollerBottomID + } + } + .onChange(of: self.viewModel.messages.count) { _, _ in + guard self.hasPerformedInitialScroll else { return } + if let lastMessage = self.viewModel.messages.last, + lastMessage.role.lowercased() == "user", + lastMessage.id != self.lastUserMessageID { + self.lastUserMessageID = lastMessage.id + self.isPinnedToBottom = true + withAnimation(.snappy(duration: 0.22)) { + self.scrollPosition = self.scrollerBottomID + } + return + } + + guard self.isPinnedToBottom else { return } + withAnimation(.snappy(duration: 0.22)) { + self.scrollPosition = self.scrollerBottomID + } + } + .onChange(of: self.viewModel.pendingRunCount) { _, _ in + guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return } + withAnimation(.snappy(duration: 0.22)) { + self.scrollPosition = self.scrollerBottomID + } + } + .onChange(of: self.viewModel.streamingAssistantText) { _, _ in + guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return } + withAnimation(.snappy(duration: 0.22)) { + self.scrollPosition = self.scrollerBottomID + } + } + } + + @ViewBuilder + private var messageListRows: some View { + ForEach(self.visibleMessages) { msg in + ChatMessageBubble( + message: msg, + style: self.style, + markdownVariant: self.markdownVariant, + userAccent: self.userAccent) + .frame( + maxWidth: .infinity, + alignment: msg.role.lowercased() == "user" ? .trailing : .leading) + } + + if self.viewModel.pendingRunCount > 0 { + HStack { + ChatTypingIndicatorBubble(style: self.style) + .equatable() + Spacer(minLength: 0) + } + } + + if !self.viewModel.pendingToolCalls.isEmpty { + ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls) + .equatable() + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) { + ChatStreamingAssistantBubble(text: text, markdownVariant: self.markdownVariant) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var visibleMessages: [OpenClawChatMessage] { + let base: [OpenClawChatMessage] + if self.style == .onboarding { + guard let first = self.viewModel.messages.first else { return [] } + base = first.role.lowercased() == "user" ? Array(self.viewModel.messages.dropFirst()) : self.viewModel + .messages + } else { + base = self.viewModel.messages + } + return self.mergeToolResults(in: base) + } + + @ViewBuilder + private var messageListOverlay: some View { + if self.viewModel.isLoading { + EmptyView() + } else if let error = self.activeErrorText { + let presentation = self.errorPresentation(for: error) + if self.hasVisibleMessageListContent { + VStack(spacing: 0) { + ChatNoticeBanner( + systemImage: presentation.systemImage, + title: presentation.title, + message: error, + tint: presentation.tint, + dismiss: { self.viewModel.errorText = nil }, + refresh: { self.viewModel.refresh() }) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.top, 8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } else { + ChatNoticeCard( + systemImage: presentation.systemImage, + title: presentation.title, + message: error, + tint: presentation.tint, + actionTitle: "Refresh", + action: { self.viewModel.refresh() }) + .padding(.horizontal, 24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else if self.showsEmptyState { + ChatNoticeCard( + systemImage: "bubble.left.and.bubble.right.fill", + title: self.emptyStateTitle, + message: self.emptyStateMessage, + tint: .accentColor, + actionTitle: nil, + action: nil) + .padding(.horizontal, 24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var activeErrorText: String? { + guard let text = self.viewModel.errorText? + .trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty + else { + return nil + } + return text + } + + private var hasVisibleMessageListContent: Bool { + if !self.visibleMessages.isEmpty { + return true + } + if let text = self.viewModel.streamingAssistantText, + AssistantTextParser.hasVisibleContent(in: text) + { + return true + } + if self.viewModel.pendingRunCount > 0 { + return true + } + if !self.viewModel.pendingToolCalls.isEmpty { + return true + } + return false + } + + private var showsEmptyState: Bool { + self.viewModel.messages.isEmpty && + !(self.viewModel.streamingAssistantText.map { AssistantTextParser.hasVisibleContent(in: $0) } ?? false) && + self.viewModel.pendingRunCount == 0 && + self.viewModel.pendingToolCalls.isEmpty + } + + private var emptyStateTitle: String { + #if os(macOS) + "Web Chat" + #else + "Chat" + #endif + } + + private var emptyStateMessage: String { + #if os(macOS) + "Type a message below to start.\nReturn sends • Shift-Return adds a line break." + #else + "Type a message below to start." + #endif + } + + private func errorPresentation(for error: String) -> (title: String, systemImage: String, tint: Color) { + let lower = error.lowercased() + if lower.contains("not connected") || lower.contains("socket") { + return ("Disconnected", "wifi.slash", .orange) + } + if lower.contains("timed out") { + return ("Timed out", "clock.badge.exclamationmark", .orange) + } + return ("Error", "exclamationmark.triangle.fill", .orange) + } + + private func mergeToolResults(in messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] { + var result: [OpenClawChatMessage] = [] + result.reserveCapacity(messages.count) + + for message in messages { + guard self.isToolResultMessage(message) else { + result.append(message) + continue + } + + guard let toolCallId = message.toolCallId, + let last = result.last, + self.toolCallIds(in: last).contains(toolCallId) + else { + result.append(message) + continue + } + + let toolText = self.toolResultText(from: message) + if toolText.isEmpty { + continue + } + + var content = last.content + content.append( + OpenClawChatMessageContent( + type: "tool_result", + text: toolText, + thinking: nil, + thinkingSignature: nil, + mimeType: nil, + fileName: nil, + content: nil, + id: toolCallId, + name: message.toolName, + arguments: nil)) + + let merged = OpenClawChatMessage( + id: last.id, + role: last.role, + content: content, + timestamp: last.timestamp, + toolCallId: last.toolCallId, + toolName: last.toolName, + usage: last.usage, + stopReason: last.stopReason) + result[result.count - 1] = merged + } + + return result + } + + private func isToolResultMessage(_ message: OpenClawChatMessage) -> Bool { + let role = message.role.lowercased() + return role == "toolresult" || role == "tool_result" + } + + private func toolCallIds(in message: OpenClawChatMessage) -> Set { + var ids = Set() + for content in message.content { + let kind = (content.type ?? "").lowercased() + let isTool = + ["toolcall", "tool_call", "tooluse", "tool_use"].contains(kind) || + (content.name != nil && content.arguments != nil) + if isTool, let id = content.id { + ids.insert(id) + } + } + if let toolCallId = message.toolCallId { + ids.insert(toolCallId) + } + return ids + } + + private func toolResultText(from message: OpenClawChatMessage) -> String { + let parts = message.content.compactMap { content -> String? in + let kind = (content.type ?? "text").lowercased() + guard kind == "text" || kind.isEmpty else { return nil } + return content.text + } + return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func dismissKeyboardIfNeeded() { + #if canImport(UIKit) + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil) + #endif + } +} + +private struct ChatNoticeCard: View { + let systemImage: String + let title: String + let message: String + let tint: Color + let actionTitle: String? + let action: (() -> Void)? + + var body: some View { + VStack(spacing: 12) { + ZStack { + Circle() + .fill(self.tint.opacity(0.16)) + Image(systemName: self.systemImage) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(self.tint) + } + .frame(width: 52, height: 52) + + Text(self.title) + .font(.headline) + + Text(self.message) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(4) + .frame(maxWidth: 360) + + if let actionTitle, let action { + Button(actionTitle, action: action) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(18) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(OpenClawChatTheme.subtleCard) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 1))) + .shadow(color: .black.opacity(0.14), radius: 18, y: 8) + } +} + +private struct ChatNoticeBanner: View { + let systemImage: String + let title: String + let message: String + let tint: Color + let dismiss: () -> Void + let refresh: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: self.systemImage) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(self.tint) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 3) { + Text(self.title) + .font(.caption.weight(.semibold)) + + Text(self.message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer(minLength: 0) + + Button(action: self.refresh) { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Refresh") + + Button(action: self.dismiss) { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Dismiss") + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(OpenClawChatTheme.subtleCard) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 1))) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift new file mode 100644 index 00000000..62cb97a0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -0,0 +1,685 @@ +import OpenClawKit +import Foundation +import Observation +import OSLog +import UniformTypeIdentifiers + +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif + +private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawChatUI") + +@MainActor +@Observable +public final class OpenClawChatViewModel { + public private(set) var messages: [OpenClawChatMessage] = [] + public var input: String = "" + public var thinkingLevel: String = "off" + public private(set) var isLoading = false + public private(set) var isSending = false + public private(set) var isAborting = false + public var errorText: String? + public var attachments: [OpenClawPendingAttachment] = [] + public private(set) var healthOK: Bool = false + public private(set) var pendingRunCount: Int = 0 + + public private(set) var sessionKey: String + public private(set) var sessionId: String? + public private(set) var streamingAssistantText: String? + public private(set) var pendingToolCalls: [OpenClawChatPendingToolCall] = [] + public private(set) var sessions: [OpenClawChatSessionEntry] = [] + private let transport: any OpenClawChatTransport + + @ObservationIgnored + private nonisolated(unsafe) var eventTask: Task? + private var pendingRuns = Set() { + didSet { self.pendingRunCount = self.pendingRuns.count } + } + + @ObservationIgnored + private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task] = [:] + private let pendingRunTimeoutMs: UInt64 = 120_000 + + private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] { + didSet { + self.pendingToolCalls = self.pendingToolCallsById.values + .sorted { ($0.startedAt ?? 0) < ($1.startedAt ?? 0) } + } + } + + private var lastHealthPollAt: Date? + + public init(sessionKey: String, transport: any OpenClawChatTransport) { + self.sessionKey = sessionKey + self.transport = transport + + self.eventTask = Task { [weak self] in + guard let self else { return } + let stream = self.transport.events() + for await evt in stream { + if Task.isCancelled { return } + await MainActor.run { [weak self] in + self?.handleTransportEvent(evt) + } + } + } + } + + deinit { + self.eventTask?.cancel() + for (_, task) in self.pendingRunTimeoutTasks { + task.cancel() + } + } + + public func load() { + Task { await self.bootstrap() } + } + + public func refresh() { + Task { await self.bootstrap() } + } + + public func send() { + Task { await self.performSend() } + } + + public func abort() { + Task { await self.performAbort() } + } + + public func refreshSessions(limit: Int? = nil) { + Task { await self.fetchSessions(limit: limit) } + } + + public func switchSession(to sessionKey: String) { + Task { await self.performSwitchSession(to: sessionKey) } + } + + public var sessionChoices: [OpenClawChatSessionEntry] { + let now = Date().timeIntervalSince1970 * 1000 + let cutoff = now - (24 * 60 * 60 * 1000) + let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } + + var result: [OpenClawChatSessionEntry] = [] + var included = Set() + + // Always show the main session first, even if it hasn't been updated recently. + if let main = sorted.first(where: { $0.key == "main" }) { + result.append(main) + included.insert(main.key) + } else { + result.append(self.placeholderSession(key: "main")) + included.insert("main") + } + + for entry in sorted { + guard !included.contains(entry.key) else { continue } + guard (entry.updatedAt ?? 0) >= cutoff else { continue } + result.append(entry) + included.insert(entry.key) + } + + if !included.contains(self.sessionKey) { + if let current = sorted.first(where: { $0.key == self.sessionKey }) { + result.append(current) + } else { + result.append(self.placeholderSession(key: self.sessionKey)) + } + } + + return result + } + + public func addAttachments(urls: [URL]) { + Task { await self.loadAttachments(urls: urls) } + } + + public func addImageAttachment(data: Data, fileName: String, mimeType: String) { + Task { await self.addImageAttachment(url: nil, data: data, fileName: fileName, mimeType: mimeType) } + } + + public func removeAttachment(_ id: OpenClawPendingAttachment.ID) { + self.attachments.removeAll { $0.id == id } + } + + public var canSend: Bool { + let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) + return !self.isSending && self.pendingRunCount == 0 && (!trimmed.isEmpty || !self.attachments.isEmpty) + } + + // MARK: - Internals + + private func bootstrap() async { + self.isLoading = true + self.errorText = nil + self.healthOK = false + self.clearPendingRuns(reason: nil) + self.pendingToolCallsById = [:] + self.streamingAssistantText = nil + self.sessionId = nil + defer { self.isLoading = false } + do { + do { + try await self.transport.setActiveSessionKey(self.sessionKey) + } catch { + // Best-effort only; history/send/health still work without push events. + } + + let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey) + self.messages = Self.reconcileMessageIDs( + previous: self.messages, + incoming: Self.decodeMessages(payload.messages ?? [])) + self.sessionId = payload.sessionId + if let level = payload.thinkingLevel, !level.isEmpty { + self.thinkingLevel = level + } + await self.pollHealthIfNeeded(force: true) + await self.fetchSessions(limit: 50) + self.errorText = nil + } catch { + self.errorText = error.localizedDescription + chatUILogger.error("bootstrap failed \(error.localizedDescription, privacy: .public)") + } + } + + private static func decodeMessages(_ raw: [AnyCodable]) -> [OpenClawChatMessage] { + let decoded = raw.compactMap { item in + (try? ChatPayloadDecoding.decode(item, as: OpenClawChatMessage.self)) + .map { Self.stripInboundMetadata(from: $0) } + } + return Self.dedupeMessages(decoded) + } + + private static func stripInboundMetadata(from message: OpenClawChatMessage) -> OpenClawChatMessage { + guard message.role.lowercased() == "user" else { + return message + } + + let sanitizedContent = message.content.map { content -> OpenClawChatMessageContent in + guard let text = content.text else { return content } + let cleaned = ChatMarkdownPreprocessor.preprocess(markdown: text).cleaned + return OpenClawChatMessageContent( + type: content.type, + text: cleaned, + thinking: content.thinking, + thinkingSignature: content.thinkingSignature, + mimeType: content.mimeType, + fileName: content.fileName, + content: content.content, + id: content.id, + name: content.name, + arguments: content.arguments) + } + + return OpenClawChatMessage( + id: message.id, + role: message.role, + content: sanitizedContent, + timestamp: message.timestamp, + toolCallId: message.toolCallId, + toolName: message.toolName, + usage: message.usage, + stopReason: message.stopReason) + } + + private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? { + let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !role.isEmpty else { return nil } + + let timestamp: String = { + guard let value = message.timestamp, value.isFinite else { return "" } + return String(format: "%.3f", value) + }() + + let contentFingerprint = message.content.map { item in + let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return [type, text, id, name, fileName].joined(separator: "\\u{001F}") + }.joined(separator: "\\u{001E}") + + let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty { + return nil + } + return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|") + } + + private static func reconcileMessageIDs( + previous: [OpenClawChatMessage], + incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage] + { + guard !previous.isEmpty, !incoming.isEmpty else { return incoming } + + var idsByKey: [String: [UUID]] = [:] + for message in previous { + guard let key = Self.messageIdentityKey(for: message) else { continue } + idsByKey[key, default: []].append(message.id) + } + + return incoming.map { message in + guard let key = Self.messageIdentityKey(for: message), + var ids = idsByKey[key], + let reusedId = ids.first + else { + return message + } + ids.removeFirst() + if ids.isEmpty { + idsByKey.removeValue(forKey: key) + } else { + idsByKey[key] = ids + } + guard reusedId != message.id else { return message } + return OpenClawChatMessage( + id: reusedId, + role: message.role, + content: message.content, + timestamp: message.timestamp, + toolCallId: message.toolCallId, + toolName: message.toolName, + usage: message.usage, + stopReason: message.stopReason) + } + } + + private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] { + var result: [OpenClawChatMessage] = [] + result.reserveCapacity(messages.count) + var seen = Set() + + for message in messages { + guard let key = Self.dedupeKey(for: message) else { + result.append(message) + continue + } + if seen.contains(key) { continue } + seen.insert(key) + result.append(message) + } + + return result + } + + private static func dedupeKey(for message: OpenClawChatMessage) -> String? { + guard let timestamp = message.timestamp else { return nil } + let text = message.content.compactMap(\.text).joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + return "\(message.role)|\(timestamp)|\(text)" + } + + private func performSend() async { + guard !self.isSending else { return } + let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty || !self.attachments.isEmpty else { return } + + guard self.healthOK else { + self.errorText = "Gateway health not OK; cannot send" + return + } + + self.isSending = true + self.errorText = nil + let runId = UUID().uuidString + let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed + self.pendingRuns.insert(runId) + self.armPendingRunTimeout(runId: runId) + self.pendingToolCallsById = [:] + self.streamingAssistantText = nil + + // Optimistically append user message to UI. + var userContent: [OpenClawChatMessageContent] = [ + OpenClawChatMessageContent( + type: "text", + text: messageText, + thinking: nil, + thinkingSignature: nil, + mimeType: nil, + fileName: nil, + content: nil, + id: nil, + name: nil, + arguments: nil), + ] + let encodedAttachments = self.attachments.map { att -> OpenClawChatAttachmentPayload in + OpenClawChatAttachmentPayload( + type: att.type, + mimeType: att.mimeType, + fileName: att.fileName, + content: att.data.base64EncodedString()) + } + for att in encodedAttachments { + userContent.append( + OpenClawChatMessageContent( + type: att.type, + text: nil, + thinking: nil, + thinkingSignature: nil, + mimeType: att.mimeType, + fileName: att.fileName, + content: AnyCodable(att.content), + id: nil, + name: nil, + arguments: nil)) + } + self.messages.append( + OpenClawChatMessage( + id: UUID(), + role: "user", + content: userContent, + timestamp: Date().timeIntervalSince1970 * 1000)) + + // Clear input immediately for responsive UX (before network await) + self.input = "" + self.attachments = [] + + do { + let response = try await self.transport.sendMessage( + sessionKey: self.sessionKey, + message: messageText, + thinking: self.thinkingLevel, + idempotencyKey: runId, + attachments: encodedAttachments) + if response.runId != runId { + self.clearPendingRun(runId) + self.pendingRuns.insert(response.runId) + self.armPendingRunTimeout(runId: response.runId) + } + } catch { + self.clearPendingRun(runId) + self.errorText = error.localizedDescription + chatUILogger.error("chat.send failed \(error.localizedDescription, privacy: .public)") + } + + self.isSending = false + } + + private func performAbort() async { + guard !self.pendingRuns.isEmpty else { return } + guard !self.isAborting else { return } + self.isAborting = true + defer { self.isAborting = false } + + let runIds = Array(self.pendingRuns) + for runId in runIds { + do { + try await self.transport.abortRun(sessionKey: self.sessionKey, runId: runId) + } catch { + // Best-effort. + } + } + } + + private func fetchSessions(limit: Int?) async { + do { + let res = try await self.transport.listSessions(limit: limit) + self.sessions = res.sessions + } catch { + // Best-effort. + } + } + + private func performSwitchSession(to sessionKey: String) async { + let next = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !next.isEmpty else { return } + guard next != self.sessionKey else { return } + self.sessionKey = next + await self.bootstrap() + } + + private func placeholderSession(key: String) -> OpenClawChatSessionEntry { + OpenClawChatSessionEntry( + key: key, + kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: nil, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + model: nil, + contextTokens: nil) + } + + private func handleTransportEvent(_ evt: OpenClawChatTransportEvent) { + switch evt { + case let .health(ok): + self.healthOK = ok + case .tick: + Task { await self.pollHealthIfNeeded(force: false) } + case let .chat(chat): + self.handleChatEvent(chat) + case let .agent(agent): + self.handleAgentEvent(agent) + case .seqGap: + self.errorText = nil + self.clearPendingRuns(reason: nil) + Task { + await self.refreshHistoryAfterRun() + await self.pollHealthIfNeeded(force: true) + } + } + } + + private func handleChatEvent(_ chat: OpenClawChatEventPayload) { + let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false + + // Gateway may publish canonical session keys (for example "agent:main:main") + // even when this view currently uses an alias key (for example "main"). + // Never drop events for our own pending run on key mismatch, or the UI can stay + // stuck at "thinking" until the user reopens and forces a history reload. + if let sessionKey = chat.sessionKey, + !Self.matchesCurrentSessionKey(incoming: sessionKey, current: self.sessionKey), + !isOurRun + { + return + } + if !isOurRun { + // Keep multiple clients in sync: if another client finishes a run for our session, refresh history. + switch chat.state { + case "final", "aborted", "error": + self.streamingAssistantText = nil + self.pendingToolCallsById = [:] + Task { await self.refreshHistoryAfterRun() } + default: + break + } + return + } + + switch chat.state { + case "final", "aborted", "error": + if chat.state == "error" { + self.errorText = chat.errorMessage ?? "Chat failed" + } + if let runId = chat.runId { + self.clearPendingRun(runId) + } else if self.pendingRuns.count <= 1 { + self.clearPendingRuns(reason: nil) + } + self.pendingToolCallsById = [:] + self.streamingAssistantText = nil + Task { await self.refreshHistoryAfterRun() } + default: + break + } + } + + private static func matchesCurrentSessionKey(incoming: String, current: String) -> Bool { + let incomingNormalized = incoming.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let currentNormalized = current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if incomingNormalized == currentNormalized { + return true + } + // Common alias pair in operator clients: UI uses "main" while gateway emits canonical. + if (incomingNormalized == "agent:main:main" && currentNormalized == "main") || + (incomingNormalized == "main" && currentNormalized == "agent:main:main") + { + return true + } + return false + } + + private func handleAgentEvent(_ evt: OpenClawAgentEventPayload) { + if let sessionId, evt.runId != sessionId { + return + } + + switch evt.stream { + case "assistant": + if let text = evt.data["text"]?.value as? String { + self.streamingAssistantText = text + } + case "tool": + guard let phase = evt.data["phase"]?.value as? String else { return } + guard let name = evt.data["name"]?.value as? String else { return } + guard let toolCallId = evt.data["toolCallId"]?.value as? String else { return } + if phase == "start" { + let args = evt.data["args"] + self.pendingToolCallsById[toolCallId] = OpenClawChatPendingToolCall( + toolCallId: toolCallId, + name: name, + args: args, + startedAt: evt.ts.map(Double.init) ?? Date().timeIntervalSince1970 * 1000, + isError: nil) + } else if phase == "result" { + self.pendingToolCallsById[toolCallId] = nil + } + default: + break + } + } + + private func refreshHistoryAfterRun() async { + do { + let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey) + self.messages = Self.reconcileMessageIDs( + previous: self.messages, + incoming: Self.decodeMessages(payload.messages ?? [])) + self.sessionId = payload.sessionId + if let level = payload.thinkingLevel, !level.isEmpty { + self.thinkingLevel = level + } + } catch { + chatUILogger.error("refresh history failed \(error.localizedDescription, privacy: .public)") + } + } + + private func armPendingRunTimeout(runId: String) { + self.pendingRunTimeoutTasks[runId]?.cancel() + self.pendingRunTimeoutTasks[runId] = Task { [weak self] in + let timeoutMs = await MainActor.run { self?.pendingRunTimeoutMs ?? 0 } + try? await Task.sleep(nanoseconds: timeoutMs * 1_000_000) + await MainActor.run { [weak self] in + guard let self else { return } + guard self.pendingRuns.contains(runId) else { return } + self.clearPendingRun(runId) + self.errorText = "Timed out waiting for a reply; try again or refresh." + } + } + } + + private func clearPendingRun(_ runId: String) { + self.pendingRuns.remove(runId) + self.pendingRunTimeoutTasks[runId]?.cancel() + self.pendingRunTimeoutTasks[runId] = nil + } + + private func clearPendingRuns(reason: String?) { + for runId in self.pendingRuns { + self.pendingRunTimeoutTasks[runId]?.cancel() + } + self.pendingRunTimeoutTasks.removeAll() + self.pendingRuns.removeAll() + if let reason, !reason.isEmpty { + self.errorText = reason + } + } + + private func pollHealthIfNeeded(force: Bool) async { + if !force, let last = self.lastHealthPollAt, Date().timeIntervalSince(last) < 10 { + return + } + self.lastHealthPollAt = Date() + do { + let ok = try await self.transport.requestHealth(timeoutMs: 5000) + self.healthOK = ok + } catch { + self.healthOK = false + } + } + + private func loadAttachments(urls: [URL]) async { + for url in urls { + do { + let data = try await Task.detached { try Data(contentsOf: url) }.value + await self.addImageAttachment( + url: url, + data: data, + fileName: url.lastPathComponent, + mimeType: Self.mimeType(for: url) ?? "application/octet-stream") + } catch { + await MainActor.run { self.errorText = error.localizedDescription } + } + } + } + + private static func mimeType(for url: URL) -> String? { + let ext = url.pathExtension + guard !ext.isEmpty else { return nil } + return (UTType(filenameExtension: ext) ?? .data).preferredMIMEType + } + + private func addImageAttachment(url: URL?, data: Data, fileName: String, mimeType: String) async { + if data.count > 5_000_000 { + self.errorText = "Attachment \(fileName) exceeds 5 MB limit" + return + } + + let uti: UTType = { + if let url { + return UTType(filenameExtension: url.pathExtension) ?? .data + } + return UTType(mimeType: mimeType) ?? .data + }() + guard uti.conforms(to: .image) else { + self.errorText = "Only image attachments are supported right now" + return + } + + let preview = Self.previewImage(data: data) + self.attachments.append( + OpenClawPendingAttachment( + url: url, + data: data, + fileName: fileName, + mimeType: mimeType, + preview: preview)) + } + + private static func previewImage(data: Data) -> OpenClawPlatformImage? { + #if canImport(AppKit) + NSImage(data: data) + #elseif canImport(UIKit) + UIImage(data: data) + #else + nil + #endif + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ToolResultTextFormatter.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ToolResultTextFormatter.swift new file mode 100644 index 00000000..719e82cd --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ToolResultTextFormatter.swift @@ -0,0 +1,157 @@ +import Foundation + +enum ToolResultTextFormatter { + static func format(text: String, toolName: String?) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + guard self.looksLikeJSON(trimmed), + let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) + else { + return trimmed + } + + let normalizedTool = toolName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.renderJSON(json, toolName: normalizedTool) + } + + private static func looksLikeJSON(_ value: String) -> Bool { + guard let first = value.first else { return false } + return first == "{" || first == "[" + } + + private static func renderJSON(_ json: Any, toolName: String?) -> String { + if let dict = json as? [String: Any] { + return self.renderDictionary(dict, toolName: toolName) + } + if let array = json as? [Any] { + if array.isEmpty { return "No items." } + return "\(array.count) item\(array.count == 1 ? "" : "s")." + } + return "" + } + + private static func renderDictionary(_ dict: [String: Any], toolName: String?) -> String { + let status = (dict["status"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let errorText = self.firstString(in: dict, keys: ["error", "reason"]) + let messageText = self.firstString(in: dict, keys: ["message", "result", "detail"]) + + if status?.lowercased() == "error" || errorText != nil { + if let errorText { + return "Error: \(self.sanitizeError(errorText))" + } + if let messageText { + return "Error: \(self.sanitizeError(messageText))" + } + return "Error" + } + + if toolName == "nodes", let summary = self.renderNodesSummary(dict) { + return summary + } + + if let message = messageText { + return message + } + + if let status, !status.isEmpty { + return "Status: \(status)" + } + + return "" + } + + private static func renderNodesSummary(_ dict: [String: Any]) -> String? { + if let nodes = dict["nodes"] as? [[String: Any]] { + if nodes.isEmpty { return "No nodes found." } + var lines: [String] = [] + lines.append("\(nodes.count) node\(nodes.count == 1 ? "" : "s") found.") + + for node in nodes.prefix(3) { + let label = self.firstString(in: node, keys: ["displayName", "name", "nodeId"]) ?? "Node" + var details: [String] = [] + + if let connected = node["connected"] as? Bool { + details.append(connected ? "connected" : "offline") + } + if let platform = self.firstString(in: node, keys: ["platform"]) { + details.append(platform) + } + if let version = self.firstString(in: node, keys: ["osVersion", "appVersion", "version"]) { + details.append(version) + } + if let pairing = self.pairingDetail(node) { + details.append(pairing) + } + + if details.isEmpty { + lines.append("• \(label)") + } else { + lines.append("• \(label) - \(details.joined(separator: ", "))") + } + } + + let extra = nodes.count - 3 + if extra > 0 { + lines.append("... +\(extra) more") + } + return lines.joined(separator: "\n") + } + + if let pending = dict["pending"] as? [Any], let paired = dict["paired"] as? [Any] { + return "Pairing requests: \(pending.count) pending, \(paired.count) paired." + } + + if let pending = dict["pending"] as? [Any] { + if pending.isEmpty { return "No pending pairing requests." } + return "\(pending.count) pending pairing request\(pending.count == 1 ? "" : "s")." + } + + return nil + } + + private static func pairingDetail(_ node: [String: Any]) -> String? { + if let paired = node["paired"] as? Bool, !paired { + return "pairing required" + } + + for key in ["status", "state", "deviceStatus"] { + if let raw = node[key] as? String, raw.lowercased().contains("pairing required") { + return "pairing required" + } + } + return nil + } + + private static func firstString(in dict: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = dict[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return nil + } + + private static func sanitizeError(_ raw: String) -> String { + var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if cleaned.contains("agent="), + cleaned.contains("action="), + let marker = cleaned.range(of: ": ") + { + cleaned = String(cleaned[marker.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + } + + if let firstLine = cleaned.split(separator: "\n").first { + cleaned = String(firstLine).trimmingCharacters(in: .whitespacesAndNewlines) + } + + if cleaned.count > 220 { + cleaned = String(cleaned.prefix(217)) + "..." + } + return cleaned + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift new file mode 100644 index 00000000..02b53e3c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift @@ -0,0 +1,4 @@ +import OpenClawProtocol + +public typealias AnyCodable = OpenClawProtocol.AnyCodable + diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/AsyncTimeout.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/AsyncTimeout.swift new file mode 100644 index 00000000..eed2d758 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/AsyncTimeout.swift @@ -0,0 +1,36 @@ +import Foundation + +public enum AsyncTimeout { + public static func withTimeout( + seconds: Double, + onTimeout: @escaping @Sendable () -> Error, + operation: @escaping @Sendable () async throws -> T) async throws -> T + { + let clamped = max(0, seconds) + if clamped == 0 { + return try await operation() + } + + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000)) + throw onTimeout() + } + let result = try await group.next() + group.cancelAll() + if let result { return result } + throw onTimeout() + } + } + + public static func withTimeoutMs( + timeoutMs: Int, + onTimeout: @escaping @Sendable () -> Error, + operation: @escaping @Sendable () async throws -> T) async throws -> T + { + let clamped = max(0, timeoutMs) + let seconds = Double(clamped) / 1000.0 + return try await self.withTimeout(seconds: seconds, onTimeout: onTimeout, operation: operation) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/AudioStreamingProtocols.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/AudioStreamingProtocols.swift new file mode 100644 index 00000000..a211a4b3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/AudioStreamingProtocols.swift @@ -0,0 +1,16 @@ +import Foundation + +@MainActor +public protocol StreamingAudioPlaying { + func play(stream: AsyncThrowingStream) async -> StreamingPlaybackResult + func stop() -> Double? +} + +@MainActor +public protocol PCMStreamingAudioPlaying { + func play(stream: AsyncThrowingStream, sampleRate: Double) async -> StreamingPlaybackResult + func stop() -> Double? +} + +extension StreamingAudioPlayer: StreamingAudioPlaying {} +extension PCMStreamingAudioPlayer: PCMStreamingAudioPlaying {} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourEscapes.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourEscapes.swift new file mode 100644 index 00000000..0760314f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourEscapes.swift @@ -0,0 +1,33 @@ +import Foundation + +public enum BonjourEscapes { + /// mDNS / DNS-SD commonly escapes bytes in instance names as `\DDD` (decimal-encoded), + /// e.g. spaces are `\032`. + public static func decode(_ input: String) -> String { + var out = "" + var i = input.startIndex + while i < input.endIndex { + if input[i] == "\\", + let d0 = input.index(i, offsetBy: 1, limitedBy: input.index(before: input.endIndex)), + let d1 = input.index(i, offsetBy: 2, limitedBy: input.index(before: input.endIndex)), + let d2 = input.index(i, offsetBy: 3, limitedBy: input.index(before: input.endIndex)), + input[d0].isNumber, + input[d1].isNumber, + input[d2].isNumber + { + let digits = String(input[d0...d2]) + if let value = Int(digits), + let scalar = UnicodeScalar(value) + { + out.append(Character(scalar)) + i = input.index(i, offsetBy: 4) + continue + } + } + + out.append(input[i]) + i = input.index(after: i) + } + return out + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourTypes.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourTypes.swift new file mode 100644 index 00000000..5c3c50ca --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourTypes.swift @@ -0,0 +1,40 @@ +import Foundation + +public enum OpenClawBonjour { + // v0: internal-only, subject to rename. + public static let gatewayServiceType = "_openclaw-gw._tcp" + public static let gatewayServiceDomain = "local." + public static var wideAreaGatewayServiceDomain: String? { + let env = ProcessInfo.processInfo.environment + return resolveWideAreaDomain(env["OPENCLAW_WIDE_AREA_DOMAIN"]) + } + + public static var gatewayServiceDomains: [String] { + var domains = [gatewayServiceDomain] + if let wideArea = wideAreaGatewayServiceDomain { + domains.append(wideArea) + } + return domains + } + + private static func resolveWideAreaDomain(_ raw: String?) -> String? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + let normalized = normalizeServiceDomain(trimmed) + return normalized == gatewayServiceDomain ? nil : normalized + } + + public static func normalizeServiceDomain(_ raw: String?) -> String { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return self.gatewayServiceDomain + } + + let lower = trimmed.lowercased() + if lower == "local" || lower == "local." { + return self.gatewayServiceDomain + } + + return lower.hasSuffix(".") ? lower : (lower + ".") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift new file mode 100644 index 00000000..648b257b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift @@ -0,0 +1,261 @@ +import Foundation + +public struct BridgeBaseFrame: Codable, Sendable { + public let type: String + + public init(type: String) { + self.type = type + } +} + +public struct BridgeInvokeRequest: Codable, Sendable { + public let type: String + public let id: String + public let command: String + public let paramsJSON: String? + + public init(type: String = "invoke", id: String, command: String, paramsJSON: String? = nil) { + self.type = type + self.id = id + self.command = command + self.paramsJSON = paramsJSON + } +} + +public struct BridgeInvokeResponse: Codable, Sendable { + public let type: String + public let id: String + public let ok: Bool + public let payloadJSON: String? + public let error: OpenClawNodeError? + + public init( + type: String = "invoke-res", + id: String, + ok: Bool, + payloadJSON: String? = nil, + error: OpenClawNodeError? = nil) + { + self.type = type + self.id = id + self.ok = ok + self.payloadJSON = payloadJSON + self.error = error + } +} + +public struct BridgeEventFrame: Codable, Sendable { + public let type: String + public let event: String + public let payloadJSON: String? + + public init(type: String = "event", event: String, payloadJSON: String? = nil) { + self.type = type + self.event = event + self.payloadJSON = payloadJSON + } +} + +public struct BridgeHello: Codable, Sendable { + public let type: String + public let nodeId: String + public let displayName: String? + public let token: String? + public let platform: String? + public let version: String? + public let coreVersion: String? + public let uiVersion: String? + public let deviceFamily: String? + public let modelIdentifier: String? + public let caps: [String]? + public let commands: [String]? + public let permissions: [String: Bool]? + + public init( + type: String = "hello", + nodeId: String, + displayName: String?, + token: String?, + platform: String?, + version: String?, + coreVersion: String? = nil, + uiVersion: String? = nil, + deviceFamily: String? = nil, + modelIdentifier: String? = nil, + caps: [String]? = nil, + commands: [String]? = nil, + permissions: [String: Bool]? = nil) + { + self.type = type + self.nodeId = nodeId + self.displayName = displayName + self.token = token + self.platform = platform + self.version = version + self.coreVersion = coreVersion + self.uiVersion = uiVersion + self.deviceFamily = deviceFamily + self.modelIdentifier = modelIdentifier + self.caps = caps + self.commands = commands + self.permissions = permissions + } +} + +public struct BridgeHelloOk: Codable, Sendable { + public let type: String + public let serverName: String + public let canvasHostUrl: String? + public let mainSessionKey: String? + + public init( + type: String = "hello-ok", + serverName: String, + canvasHostUrl: String? = nil, + mainSessionKey: String? = nil) + { + self.type = type + self.serverName = serverName + self.canvasHostUrl = canvasHostUrl + self.mainSessionKey = mainSessionKey + } +} + +public struct BridgePairRequest: Codable, Sendable { + public let type: String + public let nodeId: String + public let displayName: String? + public let platform: String? + public let version: String? + public let coreVersion: String? + public let uiVersion: String? + public let deviceFamily: String? + public let modelIdentifier: String? + public let caps: [String]? + public let commands: [String]? + public let permissions: [String: Bool]? + public let remoteAddress: String? + public let silent: Bool? + + public init( + type: String = "pair-request", + nodeId: String, + displayName: String?, + platform: String?, + version: String?, + coreVersion: String? = nil, + uiVersion: String? = nil, + deviceFamily: String? = nil, + modelIdentifier: String? = nil, + caps: [String]? = nil, + commands: [String]? = nil, + permissions: [String: Bool]? = nil, + remoteAddress: String? = nil, + silent: Bool? = nil) + { + self.type = type + self.nodeId = nodeId + self.displayName = displayName + self.platform = platform + self.version = version + self.coreVersion = coreVersion + self.uiVersion = uiVersion + self.deviceFamily = deviceFamily + self.modelIdentifier = modelIdentifier + self.caps = caps + self.commands = commands + self.permissions = permissions + self.remoteAddress = remoteAddress + self.silent = silent + } +} + +public struct BridgePairOk: Codable, Sendable { + public let type: String + public let token: String + + public init(type: String = "pair-ok", token: String) { + self.type = type + self.token = token + } +} + +public struct BridgePing: Codable, Sendable { + public let type: String + public let id: String + + public init(type: String = "ping", id: String) { + self.type = type + self.id = id + } +} + +public struct BridgePong: Codable, Sendable { + public let type: String + public let id: String + + public init(type: String = "pong", id: String) { + self.type = type + self.id = id + } +} + +public struct BridgeErrorFrame: Codable, Sendable { + public let type: String + public let code: String + public let message: String + + public init(type: String = "error", code: String, message: String) { + self.type = type + self.code = code + self.message = message + } +} + +// MARK: - Optional RPC (node -> bridge) + +public struct BridgeRPCRequest: Codable, Sendable { + public let type: String + public let id: String + public let method: String + public let paramsJSON: String? + + public init(type: String = "req", id: String, method: String, paramsJSON: String? = nil) { + self.type = type + self.id = id + self.method = method + self.paramsJSON = paramsJSON + } +} + +public struct BridgeRPCError: Codable, Sendable, Equatable { + public let code: String + public let message: String + + public init(code: String, message: String) { + self.code = code + self.message = message + } +} + +public struct BridgeRPCResponse: Codable, Sendable { + public let type: String + public let id: String + public let ok: Bool + public let payloadJSON: String? + public let error: BridgeRPCError? + + public init( + type: String = "res", + id: String, + ok: Bool, + payloadJSON: String? = nil, + error: BridgeRPCError? = nil) + { + self.type = type + self.id = id + self.ok = ok + self.payloadJSON = payloadJSON + self.error = error + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift new file mode 100644 index 00000000..9935b81b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift @@ -0,0 +1,93 @@ +import Foundation + +public enum OpenClawCalendarCommand: String, Codable, Sendable { + case events = "calendar.events" + case add = "calendar.add" +} + +public struct OpenClawCalendarEventsParams: Codable, Sendable, Equatable { + public var startISO: String? + public var endISO: String? + public var limit: Int? + + public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) { + self.startISO = startISO + self.endISO = endISO + self.limit = limit + } +} + +public struct OpenClawCalendarAddParams: Codable, Sendable, Equatable { + public var title: String + public var startISO: String + public var endISO: String + public var isAllDay: Bool? + public var location: String? + public var notes: String? + public var calendarId: String? + public var calendarTitle: String? + + public init( + title: String, + startISO: String, + endISO: String, + isAllDay: Bool? = nil, + location: String? = nil, + notes: String? = nil, + calendarId: String? = nil, + calendarTitle: String? = nil) + { + self.title = title + self.startISO = startISO + self.endISO = endISO + self.isAllDay = isAllDay + self.location = location + self.notes = notes + self.calendarId = calendarId + self.calendarTitle = calendarTitle + } +} + +public struct OpenClawCalendarEventPayload: Codable, Sendable, Equatable { + public var identifier: String + public var title: String + public var startISO: String + public var endISO: String + public var isAllDay: Bool + public var location: String? + public var calendarTitle: String? + + public init( + identifier: String, + title: String, + startISO: String, + endISO: String, + isAllDay: Bool, + location: String? = nil, + calendarTitle: String? = nil) + { + self.identifier = identifier + self.title = title + self.startISO = startISO + self.endISO = endISO + self.isAllDay = isAllDay + self.location = location + self.calendarTitle = calendarTitle + } +} + +public struct OpenClawCalendarEventsPayload: Codable, Sendable, Equatable { + public var events: [OpenClawCalendarEventPayload] + + public init(events: [OpenClawCalendarEventPayload]) { + self.events = events + } +} + +public struct OpenClawCalendarAddPayload: Codable, Sendable, Equatable { + public var event: OpenClawCalendarEventPayload + + public init(event: OpenClawCalendarEventPayload) { + self.event = event + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCommands.swift new file mode 100644 index 00000000..c76ff8e9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCommands.swift @@ -0,0 +1,68 @@ +import Foundation + +public enum OpenClawCameraCommand: String, Codable, Sendable { + case list = "camera.list" + case snap = "camera.snap" + case clip = "camera.clip" +} + +public enum OpenClawCameraFacing: String, Codable, Sendable { + case back + case front +} + +public enum OpenClawCameraImageFormat: String, Codable, Sendable { + case jpg + case jpeg +} + +public enum OpenClawCameraVideoFormat: String, Codable, Sendable { + case mp4 +} + +public struct OpenClawCameraSnapParams: Codable, Sendable, Equatable { + public var facing: OpenClawCameraFacing? + public var maxWidth: Int? + public var quality: Double? + public var format: OpenClawCameraImageFormat? + public var deviceId: String? + public var delayMs: Int? + + public init( + facing: OpenClawCameraFacing? = nil, + maxWidth: Int? = nil, + quality: Double? = nil, + format: OpenClawCameraImageFormat? = nil, + deviceId: String? = nil, + delayMs: Int? = nil) + { + self.facing = facing + self.maxWidth = maxWidth + self.quality = quality + self.format = format + self.deviceId = deviceId + self.delayMs = delayMs + } +} + +public struct OpenClawCameraClipParams: Codable, Sendable, Equatable { + public var facing: OpenClawCameraFacing? + public var durationMs: Int? + public var includeAudio: Bool? + public var format: OpenClawCameraVideoFormat? + public var deviceId: String? + + public init( + facing: OpenClawCameraFacing? = nil, + durationMs: Int? = nil, + includeAudio: Bool? = nil, + format: OpenClawCameraVideoFormat? = nil, + deviceId: String? = nil) + { + self.facing = facing + self.durationMs = durationMs + self.includeAudio = includeAudio + self.format = format + self.deviceId = deviceId + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIAction.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIAction.swift new file mode 100644 index 00000000..909f89a4 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIAction.swift @@ -0,0 +1,104 @@ +import Foundation + +public enum OpenClawCanvasA2UIAction: Sendable { + public struct AgentMessageContext: Sendable { + public struct Session: Sendable { + public var key: String + public var surfaceId: String + + public init(key: String, surfaceId: String) { + self.key = key + self.surfaceId = surfaceId + } + } + + public struct Component: Sendable { + public var id: String + public var host: String + public var instanceId: String + + public init(id: String, host: String, instanceId: String) { + self.id = id + self.host = host + self.instanceId = instanceId + } + } + + public var actionName: String + public var session: Session + public var component: Component + public var contextJSON: String? + + public init(actionName: String, session: Session, component: Component, contextJSON: String?) { + self.actionName = actionName + self.session = session + self.component = component + self.contextJSON = contextJSON + } + } + + public static func extractActionName(_ userAction: [String: Any]) -> String? { + let keys = ["name", "action"] + for key in keys { + if let raw = userAction[key] as? String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + } + return nil + } + + public static func sanitizeTagValue(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let nonEmpty = trimmed.isEmpty ? "-" : trimmed + let normalized = nonEmpty.replacingOccurrences(of: " ", with: "_") + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.:") + let scalars = normalized.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } + return String(scalars) + } + + public static func compactJSON(_ obj: Any?) -> String? { + guard let obj else { return nil } + guard JSONSerialization.isValidJSONObject(obj) else { return nil } + guard let data = try? JSONSerialization.data(withJSONObject: obj, options: []), + let str = String(data: data, encoding: .utf8) + else { return nil } + return str + } + + public static func formatAgentMessage(_ context: AgentMessageContext) -> String { + let ctxSuffix = context.contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? "" + return [ + "CANVAS_A2UI", + "action=\(self.sanitizeTagValue(context.actionName))", + "session=\(self.sanitizeTagValue(context.session.key))", + "surface=\(self.sanitizeTagValue(context.session.surfaceId))", + "component=\(self.sanitizeTagValue(context.component.id))", + "host=\(self.sanitizeTagValue(context.component.host))", + "instance=\(self.sanitizeTagValue(context.component.instanceId))\(ctxSuffix)", + "default=update_canvas", + ].joined(separator: " ") + } + + public static func jsDispatchA2UIActionStatus(actionId: String, ok: Bool, error: String?) -> String { + let payload: [String: Any] = [ + "id": actionId, + "ok": ok, + "error": error ?? "", + ] + let json: String = { + if let data = try? JSONSerialization.data(withJSONObject: payload, options: []), + let str = String(data: data, encoding: .utf8) + { + return str + } + return "{\"id\":\"\(actionId)\",\"ok\":\(ok ? "true" : "false"),\"error\":\"\"}" + }() + return """ + (() => { + const detail = \(json); + window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail })); + })(); + """ + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UICommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UICommands.swift new file mode 100644 index 00000000..ab3af0c3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UICommands.swift @@ -0,0 +1,26 @@ +import Foundation + +public enum OpenClawCanvasA2UICommand: String, Codable, Sendable { + /// Render A2UI content on the device canvas. + case push = "canvas.a2ui.push" + /// Legacy alias for `push` when sending JSONL. + case pushJSONL = "canvas.a2ui.pushJSONL" + /// Reset the A2UI renderer state. + case reset = "canvas.a2ui.reset" +} + +public struct OpenClawCanvasA2UIPushParams: Codable, Sendable, Equatable { + public var messages: [AnyCodable] + + public init(messages: [AnyCodable]) { + self.messages = messages + } +} + +public struct OpenClawCanvasA2UIPushJSONLParams: Codable, Sendable, Equatable { + public var jsonl: String + + public init(jsonl: String) { + self.jsonl = jsonl + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIJSONL.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIJSONL.swift new file mode 100644 index 00000000..d5026a8b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIJSONL.swift @@ -0,0 +1,81 @@ +import Foundation + +public enum OpenClawCanvasA2UIJSONL: Sendable { + public struct ParsedItem: Sendable { + public var lineNumber: Int + public var message: AnyCodable + + public init(lineNumber: Int, message: AnyCodable) { + self.lineNumber = lineNumber + self.message = message + } + } + + public static func parse(_ text: String) throws -> [ParsedItem] { + var out: [ParsedItem] = [] + var lineNumber = 0 + for rawLine in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) { + lineNumber += 1 + let line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines) + if line.isEmpty { continue } + let data = Data(line.utf8) + + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + out.append(ParsedItem(lineNumber: lineNumber, message: decoded)) + } + return out + } + + public static func validateV0_8(_ items: [ParsedItem]) throws { + let allowed = Set([ + "beginRendering", + "surfaceUpdate", + "dataModelUpdate", + "deleteSurface", + ]) + for item in items { + guard let dict = item.message.value as? [String: AnyCodable] else { + throw NSError(domain: "A2UI", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "A2UI JSONL line \(item.lineNumber): expected a JSON object", + ]) + } + + if dict.keys.contains("createSurface") { + throw NSError(domain: "A2UI", code: 2, userInfo: [ + NSLocalizedDescriptionKey: """ + A2UI JSONL line \(item.lineNumber): looks like A2UI v0.9 (`createSurface`). + Canvas currently supports A2UI v0.8 server→client messages + (`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`). + """, + ]) + } + + let matched = dict.keys.filter { allowed.contains($0) } + if matched.count != 1 { + let found = dict.keys.sorted().joined(separator: ", ") + throw NSError(domain: "A2UI", code: 3, userInfo: [ + NSLocalizedDescriptionKey: """ + A2UI JSONL line \(item.lineNumber): expected exactly one of \(allowed.sorted() + .joined(separator: ", ")); found: \(found) + """, + ]) + } + } + } + + public static func decodeMessagesFromJSONL(_ text: String) throws -> [AnyCodable] { + let items = try self.parse(text) + try self.validateV0_8(items) + return items.map(\.message) + } + + public static func encodeMessagesJSONArray(_ messages: [AnyCodable]) throws -> String { + let data = try JSONEncoder().encode(messages) + guard let json = String(data: data, encoding: .utf8) else { + throw NSError(domain: "A2UI", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode messages payload as UTF-8", + ]) + } + return json + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasCommandParams.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasCommandParams.swift new file mode 100644 index 00000000..2c109cf2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasCommandParams.swift @@ -0,0 +1,76 @@ +import Foundation + +public struct OpenClawCanvasNavigateParams: Codable, Sendable, Equatable { + public var url: String + + public init(url: String) { + self.url = url + } +} + +public struct OpenClawCanvasPlacement: Codable, Sendable, Equatable { + public var x: Double? + public var y: Double? + public var width: Double? + public var height: Double? + + public init(x: Double? = nil, y: Double? = nil, width: Double? = nil, height: Double? = nil) { + self.x = x + self.y = y + self.width = width + self.height = height + } +} + +public struct OpenClawCanvasPresentParams: Codable, Sendable, Equatable { + public var url: String? + public var placement: OpenClawCanvasPlacement? + + public init(url: String? = nil, placement: OpenClawCanvasPlacement? = nil) { + self.url = url + self.placement = placement + } +} + +public struct OpenClawCanvasEvalParams: Codable, Sendable, Equatable { + public var javaScript: String + + public init(javaScript: String) { + self.javaScript = javaScript + } +} + +public enum OpenClawCanvasSnapshotFormat: String, Codable, Sendable { + case png + case jpeg + + public init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + let raw = try c.decode(String.self).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch raw { + case "png": + self = .png + case "jpeg", "jpg": + self = .jpeg + default: + throw DecodingError.dataCorruptedError(in: c, debugDescription: "Invalid snapshot format: \(raw)") + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + try c.encode(self.rawValue) + } +} + +public struct OpenClawCanvasSnapshotParams: Codable, Sendable, Equatable { + public var maxWidth: Int? + public var quality: Double? + public var format: OpenClawCanvasSnapshotFormat? + + public init(maxWidth: Int? = nil, quality: Double? = nil, format: OpenClawCanvasSnapshotFormat? = nil) { + self.maxWidth = maxWidth + self.quality = quality + self.format = format + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasCommands.swift new file mode 100644 index 00000000..544353bc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasCommands.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum OpenClawCanvasCommand: String, Codable, Sendable { + case present = "canvas.present" + case hide = "canvas.hide" + case navigate = "canvas.navigate" + case evalJS = "canvas.eval" + case snapshot = "canvas.snapshot" +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift new file mode 100644 index 00000000..49f9efe9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift @@ -0,0 +1,16 @@ +import Foundation + +public enum OpenClawCapability: String, Codable, Sendable { + case canvas + case camera + case screen + case voiceWake + case location + case device + case watch + case photos + case contacts + case calendar + case reminders + case motion +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ChatCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ChatCommands.swift new file mode 100644 index 00000000..98bac620 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ChatCommands.swift @@ -0,0 +1,23 @@ +import Foundation + +public enum OpenClawChatCommand: String, Codable, Sendable { + case push = "chat.push" +} + +public struct OpenClawChatPushParams: Codable, Sendable, Equatable { + public var text: String + public var speak: Bool? + + public init(text: String, speak: Bool? = nil) { + self.text = text + self.speak = speak + } +} + +public struct OpenClawChatPushPayload: Codable, Sendable, Equatable { + public var messageId: String? + + public init(messageId: String? = nil) { + self.messageId = messageId + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift new file mode 100644 index 00000000..d99f6b9e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift @@ -0,0 +1,85 @@ +import Foundation + +public enum OpenClawContactsCommand: String, Codable, Sendable { + case search = "contacts.search" + case add = "contacts.add" +} + +public struct OpenClawContactsSearchParams: Codable, Sendable, Equatable { + public var query: String? + public var limit: Int? + + public init(query: String? = nil, limit: Int? = nil) { + self.query = query + self.limit = limit + } +} + +public struct OpenClawContactsAddParams: Codable, Sendable, Equatable { + public var givenName: String? + public var familyName: String? + public var organizationName: String? + public var displayName: String? + public var phoneNumbers: [String]? + public var emails: [String]? + + public init( + givenName: String? = nil, + familyName: String? = nil, + organizationName: String? = nil, + displayName: String? = nil, + phoneNumbers: [String]? = nil, + emails: [String]? = nil) + { + self.givenName = givenName + self.familyName = familyName + self.organizationName = organizationName + self.displayName = displayName + self.phoneNumbers = phoneNumbers + self.emails = emails + } +} + +public struct OpenClawContactPayload: Codable, Sendable, Equatable { + public var identifier: String + public var displayName: String + public var givenName: String + public var familyName: String + public var organizationName: String + public var phoneNumbers: [String] + public var emails: [String] + + public init( + identifier: String, + displayName: String, + givenName: String, + familyName: String, + organizationName: String, + phoneNumbers: [String], + emails: [String]) + { + self.identifier = identifier + self.displayName = displayName + self.givenName = givenName + self.familyName = familyName + self.organizationName = organizationName + self.phoneNumbers = phoneNumbers + self.emails = emails + } +} + +public struct OpenClawContactsSearchPayload: Codable, Sendable, Equatable { + public var contacts: [OpenClawContactPayload] + + public init(contacts: [OpenClawContactPayload]) { + self.contacts = contacts + } +} + +public struct OpenClawContactsAddPayload: Codable, Sendable, Equatable { + public var contact: OpenClawContactPayload + + public init(contact: OpenClawContactPayload) { + self.contact = contact + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift new file mode 100644 index 00000000..50714884 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -0,0 +1,185 @@ +import Foundation +import Network + +public enum DeepLinkRoute: Sendable, Equatable { + case agent(AgentDeepLink) + case gateway(GatewayConnectDeepLink) +} + +public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { + public let host: String + public let port: Int + public let tls: Bool + public let token: String? + public let password: String? + + public init(host: String, port: Int, tls: Bool, token: String?, password: String?) { + self.host = host + self.port = port + self.tls = tls + self.token = token + self.password = password + } + + fileprivate static func isLoopbackHost(_ raw: String) -> Bool { + var host = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + if host.hasSuffix(".") { + host.removeLast() + } + if let zoneIndex = host.firstIndex(of: "%") { + host = String(host[.. GatewayConnectDeepLink? { + guard let data = Self.decodeBase64Url(code) else { return nil } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + guard let urlString = json["url"] as? String, + let parsed = URLComponents(string: urlString), + let hostname = parsed.host, !hostname.isEmpty + else { return nil } + + let scheme = (parsed.scheme ?? "ws").lowercased() + guard scheme == "ws" || scheme == "wss" else { return nil } + let tls = scheme == "wss" + if !tls, !Self.isLoopbackHost(hostname) { + return nil + } + let port = parsed.port ?? (tls ? 443 : 18789) + let token = json["token"] as? String + let password = json["password"] as? String + return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password) + } + + private static func decodeBase64Url(_ input: String) -> Data? { + var base64 = input + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let remainder = base64.count % 4 + if remainder > 0 { + base64.append(contentsOf: String(repeating: "=", count: 4 - remainder)) + } + return Data(base64Encoded: base64) + } +} + +public struct AgentDeepLink: Codable, Sendable, Equatable { + public let message: String + public let sessionKey: String? + public let thinking: String? + public let deliver: Bool + public let to: String? + public let channel: String? + public let timeoutSeconds: Int? + public let key: String? + + public init( + message: String, + sessionKey: String?, + thinking: String?, + deliver: Bool, + to: String?, + channel: String?, + timeoutSeconds: Int?, + key: String?) + { + self.message = message + self.sessionKey = sessionKey + self.thinking = thinking + self.deliver = deliver + self.to = to + self.channel = channel + self.timeoutSeconds = timeoutSeconds + self.key = key + } +} + +public enum DeepLinkParser { + public static func parse(_ url: URL) -> DeepLinkRoute? { + guard let scheme = url.scheme?.lowercased(), + scheme == "openclaw" + else { + return nil + } + guard let host = url.host?.lowercased(), !host.isEmpty else { return nil } + guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } + + let query = (comps.queryItems ?? []).reduce(into: [String: String]()) { dict, item in + guard let value = item.value else { return } + dict[item.name] = value + } + + switch host { + case "agent": + guard let message = query["message"], + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + let deliver = (query["deliver"] as NSString?)?.boolValue ?? false + let timeoutSeconds = query["timeoutSeconds"].flatMap { Int($0) }.flatMap { $0 >= 0 ? $0 : nil } + return .agent( + .init( + message: message, + sessionKey: query["sessionKey"], + thinking: query["thinking"], + deliver: deliver, + to: query["to"], + channel: query["channel"], + timeoutSeconds: timeoutSeconds, + key: query["key"])) + + case "gateway": + guard let hostParam = query["host"], + !hostParam.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + let port = query["port"].flatMap { Int($0) } ?? 18789 + let tls = (query["tls"] as NSString?)?.boolValue ?? false + if !tls, !GatewayConnectDeepLink.isLoopbackHost(hostParam) { + return nil + } + return .gateway( + .init( + host: hostParam, + port: port, + tls: tls, + token: query["token"], + password: query["password"])) + + default: + return nil + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift new file mode 100644 index 00000000..80ff20c3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift @@ -0,0 +1,107 @@ +import Foundation + +public struct DeviceAuthEntry: Codable, Sendable { + public let token: String + public let role: String + public let scopes: [String] + public let updatedAtMs: Int + + public init(token: String, role: String, scopes: [String], updatedAtMs: Int) { + self.token = token + self.role = role + self.scopes = scopes + self.updatedAtMs = updatedAtMs + } +} + +private struct DeviceAuthStoreFile: Codable { + var version: Int + var deviceId: String + var tokens: [String: DeviceAuthEntry] +} + +public enum DeviceAuthStore { + private static let fileName = "device-auth.json" + + public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? { + guard let store = readStore(), store.deviceId == deviceId else { return nil } + let role = normalizeRole(role) + return store.tokens[role] + } + + public static func storeToken( + deviceId: String, + role: String, + token: String, + scopes: [String] = [] + ) -> DeviceAuthEntry { + let normalizedRole = normalizeRole(role) + var next = readStore() + if next?.deviceId != deviceId { + next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) + } + let entry = DeviceAuthEntry( + token: token, + role: normalizedRole, + scopes: normalizeScopes(scopes), + updatedAtMs: Int(Date().timeIntervalSince1970 * 1000) + ) + if next == nil { + next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) + } + next?.tokens[normalizedRole] = entry + if let store = next { + writeStore(store) + } + return entry + } + + public static func clearToken(deviceId: String, role: String) { + guard var store = readStore(), store.deviceId == deviceId else { return } + let normalizedRole = normalizeRole(role) + guard store.tokens[normalizedRole] != nil else { return } + store.tokens.removeValue(forKey: normalizedRole) + writeStore(store) + } + + private static func normalizeRole(_ role: String) -> String { + role.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func normalizeScopes(_ scopes: [String]) -> [String] { + let trimmed = scopes + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return Array(Set(trimmed)).sorted() + } + + private static func fileURL() -> URL { + DeviceIdentityPaths.stateDirURL() + .appendingPathComponent("identity", isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + } + + private static func readStore() -> DeviceAuthStoreFile? { + let url = fileURL() + guard let data = try? Data(contentsOf: url) else { return nil } + guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else { + return nil + } + guard decoded.version == 1 else { return nil } + return decoded + } + + private static func writeStore(_ store: DeviceAuthStoreFile) { + let url = fileURL() + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + let data = try JSONEncoder().encode(store) + try data.write(to: url, options: [.atomic]) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } catch { + // best-effort only + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift new file mode 100644 index 00000000..c58224b3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift @@ -0,0 +1,134 @@ +import Foundation + +public enum OpenClawDeviceCommand: String, Codable, Sendable { + case status = "device.status" + case info = "device.info" +} + +public enum OpenClawBatteryState: String, Codable, Sendable { + case unknown + case unplugged + case charging + case full +} + +public enum OpenClawThermalState: String, Codable, Sendable { + case nominal + case fair + case serious + case critical +} + +public enum OpenClawNetworkPathStatus: String, Codable, Sendable { + case satisfied + case unsatisfied + case requiresConnection +} + +public enum OpenClawNetworkInterfaceType: String, Codable, Sendable { + case wifi + case cellular + case wired + case other +} + +public struct OpenClawBatteryStatusPayload: Codable, Sendable, Equatable { + public var level: Double? + public var state: OpenClawBatteryState + public var lowPowerModeEnabled: Bool + + public init(level: Double?, state: OpenClawBatteryState, lowPowerModeEnabled: Bool) { + self.level = level + self.state = state + self.lowPowerModeEnabled = lowPowerModeEnabled + } +} + +public struct OpenClawThermalStatusPayload: Codable, Sendable, Equatable { + public var state: OpenClawThermalState + + public init(state: OpenClawThermalState) { + self.state = state + } +} + +public struct OpenClawStorageStatusPayload: Codable, Sendable, Equatable { + public var totalBytes: Int64 + public var freeBytes: Int64 + public var usedBytes: Int64 + + public init(totalBytes: Int64, freeBytes: Int64, usedBytes: Int64) { + self.totalBytes = totalBytes + self.freeBytes = freeBytes + self.usedBytes = usedBytes + } +} + +public struct OpenClawNetworkStatusPayload: Codable, Sendable, Equatable { + public var status: OpenClawNetworkPathStatus + public var isExpensive: Bool + public var isConstrained: Bool + public var interfaces: [OpenClawNetworkInterfaceType] + + public init( + status: OpenClawNetworkPathStatus, + isExpensive: Bool, + isConstrained: Bool, + interfaces: [OpenClawNetworkInterfaceType]) + { + self.status = status + self.isExpensive = isExpensive + self.isConstrained = isConstrained + self.interfaces = interfaces + } +} + +public struct OpenClawDeviceStatusPayload: Codable, Sendable, Equatable { + public var battery: OpenClawBatteryStatusPayload + public var thermal: OpenClawThermalStatusPayload + public var storage: OpenClawStorageStatusPayload + public var network: OpenClawNetworkStatusPayload + public var uptimeSeconds: Double + + public init( + battery: OpenClawBatteryStatusPayload, + thermal: OpenClawThermalStatusPayload, + storage: OpenClawStorageStatusPayload, + network: OpenClawNetworkStatusPayload, + uptimeSeconds: Double) + { + self.battery = battery + self.thermal = thermal + self.storage = storage + self.network = network + self.uptimeSeconds = uptimeSeconds + } +} + +public struct OpenClawDeviceInfoPayload: Codable, Sendable, Equatable { + public var deviceName: String + public var modelIdentifier: String + public var systemName: String + public var systemVersion: String + public var appVersion: String + public var appBuild: String + public var locale: String + + public init( + deviceName: String, + modelIdentifier: String, + systemName: String, + systemVersion: String, + appVersion: String, + appBuild: String, + locale: String) + { + self.deviceName = deviceName + self.modelIdentifier = modelIdentifier + self.systemName = systemName + self.systemVersion = systemVersion + self.appVersion = appVersion + self.appBuild = appBuild + self.locale = locale + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift new file mode 100644 index 00000000..a992bc58 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift @@ -0,0 +1,112 @@ +import CryptoKit +import Foundation + +public struct DeviceIdentity: Codable, Sendable { + public var deviceId: String + public var publicKey: String + public var privateKey: String + public var createdAtMs: Int + + public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int) { + self.deviceId = deviceId + self.publicKey = publicKey + self.privateKey = privateKey + self.createdAtMs = createdAtMs + } +} + +enum DeviceIdentityPaths { + private static let stateDirEnv = ["OPENCLAW_STATE_DIR"] + + static func stateDirURL() -> URL { + for key in self.stateDirEnv { + if let raw = getenv(key) { + let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + return URL(fileURLWithPath: value, isDirectory: true) + } + } + } + + if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + return appSupport.appendingPathComponent("OpenClaw", isDirectory: true) + } + + return FileManager.default.temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true) + } +} + +public enum DeviceIdentityStore { + private static let fileName = "device.json" + + public static func loadOrCreate() -> DeviceIdentity { + let url = self.fileURL() + if let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data), + !decoded.deviceId.isEmpty, + !decoded.publicKey.isEmpty, + !decoded.privateKey.isEmpty { + return decoded + } + let identity = self.generate() + self.save(identity) + return identity + } + + public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? { + guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil } + do { + let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData) + let signature = try privateKey.signature(for: Data(payload.utf8)) + return self.base64UrlEncode(signature) + } catch { + return nil + } + } + + private static func generate() -> DeviceIdentity { + let privateKey = Curve25519.Signing.PrivateKey() + let publicKey = privateKey.publicKey + let publicKeyData = publicKey.rawRepresentation + let privateKeyData = privateKey.rawRepresentation + let deviceId = SHA256.hash(data: publicKeyData).compactMap { String(format: "%02x", $0) }.joined() + return DeviceIdentity( + deviceId: deviceId, + publicKey: publicKeyData.base64EncodedString(), + privateKey: privateKeyData.base64EncodedString(), + createdAtMs: Int(Date().timeIntervalSince1970 * 1000)) + } + + private static func base64UrlEncode(_ data: Data) -> String { + let base64 = data.base64EncodedString() + return base64 + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + public static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? { + guard let data = Data(base64Encoded: identity.publicKey) else { return nil } + return self.base64UrlEncode(data) + } + + private static func save(_ identity: DeviceIdentity) { + let url = self.fileURL() + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + let data = try JSONEncoder().encode(identity) + try data.write(to: url, options: [.atomic]) + } catch { + // best-effort only + } + } + + private static func fileURL() -> URL { + let base = DeviceIdentityPaths.stateDirURL() + return base + .appendingPathComponent("identity", isDirectory: true) + .appendingPathComponent(fileName, isDirectory: false) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ElevenLabsKitShim.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ElevenLabsKitShim.swift new file mode 100644 index 00000000..07fe91ac --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ElevenLabsKitShim.swift @@ -0,0 +1,9 @@ +@_exported import ElevenLabsKit + +public typealias ElevenLabsVoice = ElevenLabsKit.ElevenLabsVoice +public typealias ElevenLabsTTSRequest = ElevenLabsKit.ElevenLabsTTSRequest +public typealias ElevenLabsTTSClient = ElevenLabsKit.ElevenLabsTTSClient +public typealias TalkTTSValidation = ElevenLabsKit.TalkTTSValidation +public typealias StreamingAudioPlayer = ElevenLabsKit.StreamingAudioPlayer +public typealias PCMStreamingAudioPlayer = ElevenLabsKit.PCMStreamingAudioPlayer +public typealias StreamingPlaybackResult = ElevenLabsKit.StreamingPlaybackResult diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift new file mode 100644 index 00000000..30935df7 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -0,0 +1,792 @@ +import OpenClawProtocol +import Foundation +import OSLog + +public protocol WebSocketTasking: AnyObject { + var state: URLSessionTask.State { get } + func resume() + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) + func send(_ message: URLSessionWebSocketTask.Message) async throws + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) + func receive() async throws -> URLSessionWebSocketTask.Message + func receive(completionHandler: @escaping @Sendable (Result) -> Void) +} + +extension URLSessionWebSocketTask: WebSocketTasking {} + +public struct WebSocketTaskBox: @unchecked Sendable { + public let task: any WebSocketTasking + public init(task: any WebSocketTasking) { + self.task = task + } + + public var state: URLSessionTask.State { self.task.state } + + public func resume() { self.task.resume() } + + public func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + self.task.cancel(with: closeCode, reason: reason) + } + + public func send(_ message: URLSessionWebSocketTask.Message) async throws { + try await self.task.send(message) + } + + public func receive() async throws -> URLSessionWebSocketTask.Message { + try await self.task.receive() + } + + public func receive( + completionHandler: @escaping @Sendable (Result) -> Void) + { + self.task.receive(completionHandler: completionHandler) + } + + public func sendPing() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.task.sendPing { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + } +} + +public protocol WebSocketSessioning: AnyObject { + func makeWebSocketTask(url: URL) -> WebSocketTaskBox +} + +extension URLSession: WebSocketSessioning { + public func makeWebSocketTask(url: URL) -> WebSocketTaskBox { + let task = self.webSocketTask(with: url) + // Avoid "Message too long" receive errors for large snapshots / history payloads. + task.maximumMessageSize = 16 * 1024 * 1024 // 16 MB + return WebSocketTaskBox(task: task) + } +} + +public struct WebSocketSessionBox: @unchecked Sendable { + public let session: any WebSocketSessioning + + public init(session: any WebSocketSessioning) { + self.session = session + } +} + +public struct GatewayConnectOptions: Sendable { + public var role: String + public var scopes: [String] + public var caps: [String] + public var commands: [String] + public var permissions: [String: Bool] + public var clientId: String + public var clientMode: String + public var clientDisplayName: String? + // When false, the connection omits the signed device identity payload and cannot use + // device-scoped auth (role/scope upgrades will require pairing). Keep this true for + // role/scoped sessions such as operator UI clients. + public var includeDeviceIdentity: Bool + + public init( + role: String, + scopes: [String], + caps: [String], + commands: [String], + permissions: [String: Bool], + clientId: String, + clientMode: String, + clientDisplayName: String?, + includeDeviceIdentity: Bool = true) + { + self.role = role + self.scopes = scopes + self.caps = caps + self.commands = commands + self.permissions = permissions + self.clientId = clientId + self.clientMode = clientMode + self.clientDisplayName = clientDisplayName + self.includeDeviceIdentity = includeDeviceIdentity + } +} + +public enum GatewayAuthSource: String, Sendable { + case deviceToken = "device-token" + case sharedToken = "shared-token" + case password = "password" + case none = "none" +} + +// Avoid ambiguity with the app's own AnyCodable type. +private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable + +private enum ConnectChallengeError: Error { + case timeout +} + +private let defaultOperatorConnectScopes: [String] = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", +] + +public actor GatewayChannelActor { + private let logger = Logger(subsystem: "ai.openclaw", category: "gateway") + private var task: WebSocketTaskBox? + private var pending: [String: CheckedContinuation] = [:] + private var connected = false + private var isConnecting = false + private var connectWaiters: [CheckedContinuation] = [] + private var url: URL + private var token: String? + private var password: String? + private let session: WebSocketSessioning + private var backoffMs: Double = 500 + private var shouldReconnect = true + private var lastSeq: Int? + private var lastTick: Date? + private var tickIntervalMs: Double = 30000 + private var lastAuthSource: GatewayAuthSource = .none + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + // Remote gateways (tailscale/wan) can take longer to deliver connect.challenge. + // Connect now requires this nonce before we send device-auth. + private let connectTimeoutSeconds: Double = 12 + private let connectChallengeTimeoutSeconds: Double = 6.0 + // Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client, + // but NATs/proxies often require outbound traffic to keep the connection alive. + private let keepaliveIntervalSeconds: Double = 15.0 + private var watchdogTask: Task? + private var tickTask: Task? + private var keepaliveTask: Task? + private let defaultRequestTimeoutMs: Double = 15000 + private let pushHandler: (@Sendable (GatewayPush) async -> Void)? + private let connectOptions: GatewayConnectOptions? + private let disconnectHandler: (@Sendable (String) async -> Void)? + + public init( + url: URL, + token: String?, + password: String? = nil, + session: WebSocketSessionBox? = nil, + pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil, + connectOptions: GatewayConnectOptions? = nil, + disconnectHandler: (@Sendable (String) async -> Void)? = nil) + { + self.url = url + self.token = token + self.password = password + self.session = session?.session ?? URLSession(configuration: .default) + self.pushHandler = pushHandler + self.connectOptions = connectOptions + self.disconnectHandler = disconnectHandler + Task { [weak self] in + await self?.startWatchdog() + } + } + + public func authSource() -> GatewayAuthSource { self.lastAuthSource } + + public func shutdown() async { + self.shouldReconnect = false + self.connected = false + + self.watchdogTask?.cancel() + self.watchdogTask = nil + + self.tickTask?.cancel() + self.tickTask = nil + + self.keepaliveTask?.cancel() + self.keepaliveTask = nil + + self.task?.cancel(with: .goingAway, reason: nil) + self.task = nil + + await self.failPending(NSError( + domain: "Gateway", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) + + let waiters = self.connectWaiters + self.connectWaiters.removeAll() + for waiter in waiters { + waiter.resume(throwing: NSError( + domain: "Gateway", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"])) + } + } + + private func startWatchdog() { + self.watchdogTask?.cancel() + self.watchdogTask = Task { [weak self] in + guard let self else { return } + await self.watchdogLoop() + } + } + + private func watchdogLoop() async { + // Keep nudging reconnect in case exponential backoff stalls. + while self.shouldReconnect { + guard await self.sleepUnlessCancelled(nanoseconds: 30 * 1_000_000_000) else { return } // 30s cadence + guard self.shouldReconnect else { return } + if self.connected { continue } + do { + try await self.connect() + } catch { + let wrapped = self.wrap(error, context: "gateway watchdog reconnect") + self.logger.error("gateway watchdog reconnect failed \(wrapped.localizedDescription, privacy: .public)") + } + } + } + + public func connect() async throws { + if self.connected, self.task?.state == .running { return } + if self.isConnecting { + try await withCheckedThrowingContinuation { cont in + self.connectWaiters.append(cont) + } + return + } + self.isConnecting = true + defer { self.isConnecting = false } + + self.task?.cancel(with: .goingAway, reason: nil) + self.task = self.session.makeWebSocketTask(url: self.url) + self.task?.resume() + do { + try await AsyncTimeout.withTimeout( + seconds: self.connectTimeoutSeconds, + onTimeout: { + NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "connect timed out"]) + }, + operation: { try await self.sendConnect() }) + } catch { + let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)") + self.connected = false + self.task?.cancel(with: .goingAway, reason: nil) + await self.disconnectHandler?("connect failed: \(wrapped.localizedDescription)") + let waiters = self.connectWaiters + self.connectWaiters.removeAll() + for waiter in waiters { + waiter.resume(throwing: wrapped) + } + self.logger.error("gateway ws connect failed \(wrapped.localizedDescription, privacy: .public)") + throw wrapped + } + self.listen() + self.connected = true + self.backoffMs = 500 + self.lastSeq = nil + self.startKeepalive() + + let waiters = self.connectWaiters + self.connectWaiters.removeAll() + for waiter in waiters { + waiter.resume(returning: ()) + } + } + + private func startKeepalive() { + self.keepaliveTask?.cancel() + self.keepaliveTask = Task { [weak self] in + guard let self else { return } + await self.keepaliveLoop() + } + } + + private func keepaliveLoop() async { + while self.shouldReconnect { + guard await self.sleepUnlessCancelled( + nanoseconds: UInt64(self.keepaliveIntervalSeconds * 1_000_000_000)) + else { return } + guard self.shouldReconnect else { return } + guard self.connected else { continue } + guard let task = self.task else { continue } + // Best-effort ping keeps NAT/proxy state alive without generating RPC load. + do { + try await task.sendPing() + } catch { + // Avoid spamming logs; the reconnect paths will surface meaningful errors. + } + } + } + + private func sendConnect() async throws { + let platform = InstanceIdentity.platformString + let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier + let options = self.connectOptions ?? GatewayConnectOptions( + role: "operator", + scopes: defaultOperatorConnectScopes, + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-macos", + clientMode: "ui", + clientDisplayName: InstanceIdentity.displayName) + let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName + let clientId = options.clientId + let clientMode = options.clientMode + let role = options.role + let scopes = options.scopes + + let reqId = UUID().uuidString + var client: [String: ProtoAnyCodable] = [ + "id": ProtoAnyCodable(clientId), + "displayName": ProtoAnyCodable(clientDisplayName), + "version": ProtoAnyCodable( + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), + "platform": ProtoAnyCodable(platform), + "mode": ProtoAnyCodable(clientMode), + "instanceId": ProtoAnyCodable(InstanceIdentity.instanceId), + ] + client["deviceFamily"] = ProtoAnyCodable(InstanceIdentity.deviceFamily) + if let model = InstanceIdentity.modelIdentifier { + client["modelIdentifier"] = ProtoAnyCodable(model) + } + var params: [String: ProtoAnyCodable] = [ + "minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), + "maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), + "client": ProtoAnyCodable(client), + "caps": ProtoAnyCodable(options.caps), + "locale": ProtoAnyCodable(primaryLocale), + "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), + "role": ProtoAnyCodable(role), + "scopes": ProtoAnyCodable(scopes), + ] + if !options.commands.isEmpty { + params["commands"] = ProtoAnyCodable(options.commands) + } + if !options.permissions.isEmpty { + params["permissions"] = ProtoAnyCodable(options.permissions) + } + let includeDeviceIdentity = options.includeDeviceIdentity + let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil + let storedToken = + (includeDeviceIdentity && identity != nil) + ? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token + : nil + // If we're not sending a device identity, a device token can't be validated server-side. + // In that mode we always use the shared gateway token/password. + let authToken = includeDeviceIdentity ? (storedToken ?? self.token) : self.token + let authSource: GatewayAuthSource + if storedToken != nil { + authSource = .deviceToken + } else if authToken != nil { + authSource = .sharedToken + } else if self.password != nil { + authSource = .password + } else { + authSource = .none + } + self.lastAuthSource = authSource + self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)") + let canFallbackToShared = includeDeviceIdentity && storedToken != nil && self.token != nil + if let authToken { + params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)]) + } else if let password = self.password { + params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) + } + let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) + let connectNonce = try await self.waitForConnectChallenge() + let scopesValue = scopes.joined(separator: ",") + let payloadParts = [ + "v2", + identity?.deviceId ?? "", + clientId, + clientMode, + role, + scopesValue, + String(signedAtMs), + authToken ?? "", + connectNonce, + ] + let payload = payloadParts.joined(separator: "|") + if includeDeviceIdentity, let identity { + if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), + let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) { + let device: [String: ProtoAnyCodable] = [ + "id": ProtoAnyCodable(identity.deviceId), + "publicKey": ProtoAnyCodable(publicKey), + "signature": ProtoAnyCodable(signature), + "signedAt": ProtoAnyCodable(signedAtMs), + "nonce": ProtoAnyCodable(connectNonce), + ] + params["device"] = ProtoAnyCodable(device) + } + } + + let frame = RequestFrame( + type: "req", + id: reqId, + method: "connect", + params: ProtoAnyCodable(params)) + let data = try self.encoder.encode(frame) + try await self.task?.send(.data(data)) + do { + let response = try await self.waitForConnectResponse(reqId: reqId) + try await self.handleConnectResponse(response, identity: identity, role: role) + } catch { + if canFallbackToShared { + if let identity { + DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role) + } + } + throw error + } + } + + private func handleConnectResponse( + _ res: ResponseFrame, + identity: DeviceIdentity?, + role: String + ) async throws { + if res.ok == false { + let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed" + throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg]) + } + guard let payload = res.payload else { + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "connect failed (missing payload)"]) + } + let payloadData = try self.encoder.encode(payload) + let ok = try decoder.decode(HelloOk.self, from: payloadData) + if let tick = ok.policy["tickIntervalMs"]?.value as? Double { + self.tickIntervalMs = tick + } else if let tick = ok.policy["tickIntervalMs"]?.value as? Int { + self.tickIntervalMs = Double(tick) + } + if let auth = ok.auth, + let deviceToken = auth["deviceToken"]?.value as? String { + let authRole = auth["role"]?.value as? String ?? role + let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])? + .compactMap { $0.value as? String } ?? [] + if let identity { + _ = DeviceAuthStore.storeToken( + deviceId: identity.deviceId, + role: authRole, + token: deviceToken, + scopes: scopes) + } + } + self.lastTick = Date() + self.tickTask?.cancel() + self.tickTask = Task { [weak self] in + guard let self else { return } + await self.watchTicks() + } + if let pushHandler = self.pushHandler { + Task { await pushHandler(.snapshot(ok)) } + } + } + + private func listen() { + self.task?.receive { [weak self] result in + guard let self else { return } + switch result { + case let .failure(err): + Task { await self.handleReceiveFailure(err) } + case let .success(msg): + Task { + await self.handle(msg) + await self.listen() + } + } + } + } + + private func handleReceiveFailure(_ err: Error) async { + let wrapped = self.wrap(err, context: "gateway receive") + self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)") + self.connected = false + self.keepaliveTask?.cancel() + self.keepaliveTask = nil + await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)") + await self.failPending(wrapped) + await self.scheduleReconnect() + } + + private func handle(_ msg: URLSessionWebSocketTask.Message) async { + let data: Data? = switch msg { + case let .data(d): d + case let .string(s): s.data(using: .utf8) + @unknown default: nil + } + guard let data else { return } + guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { + self.logger.error("gateway decode failed") + return + } + switch frame { + case let .res(res): + let id = res.id + if let waiter = pending.removeValue(forKey: id) { + waiter.resume(returning: .res(res)) + } + case let .event(evt): + if evt.event == "connect.challenge" { return } + if let seq = evt.seq { + if let last = lastSeq, seq > last + 1 { + await self.pushHandler?(.seqGap(expected: last + 1, received: seq)) + } + self.lastSeq = seq + } + if evt.event == "tick" { self.lastTick = Date() } + await self.pushHandler?(.event(evt)) + default: + break + } + } + + private func waitForConnectChallenge() async throws -> String { + guard let task = self.task else { throw ConnectChallengeError.timeout } + return try await AsyncTimeout.withTimeout( + seconds: self.connectChallengeTimeoutSeconds, + onTimeout: { ConnectChallengeError.timeout }, + operation: { [weak self] in + guard let self else { throw ConnectChallengeError.timeout } + while true { + let msg = try await task.receive() + guard let data = self.decodeMessageData(msg) else { continue } + guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue } + if case let .event(evt) = frame, evt.event == "connect.challenge", + let payload = evt.payload?.value as? [String: ProtoAnyCodable], + let nonce = payload["nonce"]?.value as? String, + nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + { + return nonce + } + } + }) + } + + private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame { + guard let task = self.task else { + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"]) + } + while true { + let msg = try await task.receive() + guard let data = self.decodeMessageData(msg) else { continue } + guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { + throw NSError( + domain: "Gateway", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"]) + } + if case let .res(res) = frame, res.id == reqId { + return res + } + } + } + + private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? { + let data: Data? = switch msg { + case let .data(data): data + case let .string(text): text.data(using: .utf8) + @unknown default: nil + } + return data + } + + private func watchTicks() async { + let tolerance = self.tickIntervalMs * 2 + while self.connected { + guard await self.sleepUnlessCancelled(nanoseconds: UInt64(tolerance * 1_000_000)) else { return } + guard self.connected else { return } + if let last = self.lastTick { + let delta = Date().timeIntervalSince(last) * 1000 + if delta > tolerance { + self.logger.error("gateway tick missed; reconnecting") + self.connected = false + await self.failPending( + NSError( + domain: "Gateway", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "gateway tick missed; reconnecting"])) + await self.scheduleReconnect() + return + } + } + } + } + + private func scheduleReconnect() async { + guard self.shouldReconnect else { return } + let delay = self.backoffMs / 1000 + self.backoffMs = min(self.backoffMs * 2, 30000) + guard await self.sleepUnlessCancelled(nanoseconds: UInt64(delay * 1_000_000_000)) else { return } + guard self.shouldReconnect else { return } + do { + try await self.connect() + } catch { + let wrapped = self.wrap(error, context: "gateway reconnect") + self.logger.error("gateway reconnect failed \(wrapped.localizedDescription, privacy: .public)") + await self.scheduleReconnect() + } + } + + private nonisolated func sleepUnlessCancelled(nanoseconds: UInt64) async -> Bool { + do { + try await Task.sleep(nanoseconds: nanoseconds) + } catch { + return false + } + return !Task.isCancelled + } + + public func request( + method: String, + params: [String: AnyCodable]?, + timeoutMs: Double? = nil) async throws -> Data + { + try await self.connectOrThrow(context: "gateway connect") + let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs + let payload = try self.encodeRequest(method: method, params: params, kind: "request") + let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + self.pending[payload.id] = cont + Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000)) + await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout) + } + Task { + do { + try await self.task?.send(.data(payload.data)) + } catch { + let wrapped = self.wrap(error, context: "gateway send \(method)") + let waiter = self.pending.removeValue(forKey: payload.id) + // Treat send failures as a broken socket: mark disconnected and trigger reconnect. + self.connected = false + self.task?.cancel(with: .goingAway, reason: nil) + Task { [weak self] in + guard let self else { return } + await self.scheduleReconnect() + } + if let waiter { waiter.resume(throwing: wrapped) } + } + } + } + guard case let .res(res) = response else { + throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"]) + } + if res.ok == false { + let code = res.error?["code"]?.value as? String + let msg = res.error?["message"]?.value as? String + let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in + acc[pair.key] = AnyCodable(pair.value.value) + } + throw GatewayResponseError(method: method, code: code, message: msg, details: details) + } + if let payload = res.payload { + // Encode back to JSON with Swift's encoder to preserve types and avoid ObjC bridging exceptions. + return try self.encoder.encode(payload) + } + return Data() // Should not happen, but tolerate empty payloads. + } + + public func send(method: String, params: [String: AnyCodable]?) async throws { + try await self.connectOrThrow(context: "gateway connect") + let payload = try self.encodeRequest(method: method, params: params, kind: "send") + guard let task = self.task else { + throw NSError( + domain: "Gateway", + code: 5, + userInfo: [NSLocalizedDescriptionKey: "gateway socket unavailable"]) + } + do { + try await task.send(.data(payload.data)) + } catch { + let wrapped = self.wrap(error, context: "gateway send \(method)") + self.connected = false + self.task?.cancel(with: .goingAway, reason: nil) + Task { [weak self] in + guard let self else { return } + await self.scheduleReconnect() + } + throw wrapped + } + } + + // Wrap low-level URLSession/WebSocket errors with context so UI can surface them. + private func wrap(_ error: Error, context: String) -> Error { + if let urlError = error as? URLError { + let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription + return NSError( + domain: URLError.errorDomain, + code: urlError.errorCode, + userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"]) + } + let ns = error as NSError + let desc = ns.localizedDescription.isEmpty ? "unknown" : ns.localizedDescription + return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"]) + } + + private func connectOrThrow(context: String) async throws { + do { + try await self.connect() + } catch { + throw self.wrap(error, context: context) + } + } + + private func encodeRequest( + method: String, + params: [String: AnyCodable]?, + kind: String) throws -> (id: String, data: Data) + { + let id = UUID().uuidString + // Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls. + let paramsObject: ProtoAnyCodable? = params.map { entries in + let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in + dict[entry.key] = ProtoAnyCodable(entry.value.value) + } + return ProtoAnyCodable(dict) + } + let frame = RequestFrame( + type: "req", + id: id, + method: method, + params: paramsObject) + do { + let data = try self.encoder.encode(frame) + return (id: id, data: data) + } catch { + self.logger.error( + "gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + throw error + } + } + + private func failPending(_ error: Error) async { + let waiters = self.pending + self.pending.removeAll() + for (_, waiter) in waiters { + waiter.resume(throwing: error) + } + } + + private func timeoutRequest(id: String, timeoutMs: Double) async { + guard let waiter = self.pending.removeValue(forKey: id) else { return } + let err = NSError( + domain: "Gateway", + code: 5, + userInfo: [NSLocalizedDescriptionKey: "gateway request timed out after \(Int(timeoutMs))ms"]) + waiter.resume(throwing: err) + } +} + +// Intentionally no `GatewayChannel` wrapper: the app should use the single shared `GatewayConnection`. diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift new file mode 100644 index 00000000..e15baf17 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift @@ -0,0 +1,39 @@ +import Foundation +import Network + +public enum GatewayDiscoveryStatusText { + public static func make(states: [NWBrowser.State], hasBrowsers: Bool) -> String { + if states.isEmpty { + return hasBrowsers ? "Setup" : "Idle" + } + + if let failed = states.first(where: { state in + if case .failed = state { return true } + return false + }) { + if case let .failed(err) = failed { + return "Failed: \(err)" + } + } + + if let waiting = states.first(where: { state in + if case .waiting = state { return true } + return false + }) { + if case let .waiting(err) = waiting { + return "Waiting: \(err)" + } + } + + if states.contains(where: { if case .ready = $0 { true } else { false } }) { + return "Searching…" + } + + if states.contains(where: { if case .setup = $0 { true } else { false } }) { + return "Setup" + } + + return "Searching…" + } +} + diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayEndpointID.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayEndpointID.swift new file mode 100644 index 00000000..eb2e94f5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayEndpointID.swift @@ -0,0 +1,25 @@ +import Foundation +import Network + +public enum GatewayEndpointID { + public static func stableID(_ endpoint: NWEndpoint) -> String { + switch endpoint { + case let .service(name, type, domain, _): + // Keep stable across encoded/decoded differences (e.g. \032 for spaces). + let normalizedName = Self.normalizeServiceNameForID(name) + return "\(type)|\(domain)|\(normalizedName)" + default: + return String(describing: endpoint) + } + } + + public static func prettyDescription(_ endpoint: NWEndpoint) -> String { + BonjourEscapes.decode(String(describing: endpoint)) + } + + private static func normalizeServiceNameForID(_ rawName: String) -> String { + let decoded = BonjourEscapes.decode(rawName) + let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ") + return normalized.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift new file mode 100644 index 00000000..6ca81dec --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift @@ -0,0 +1,38 @@ +import OpenClawProtocol +import Foundation + +/// Structured error surfaced when the gateway responds with `{ ok: false }`. +public struct GatewayResponseError: LocalizedError, @unchecked Sendable { + public let method: String + public let code: String + public let message: String + public let details: [String: AnyCodable] + + public init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) { + self.method = method + self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? code!.trimmingCharacters(in: .whitespacesAndNewlines) + : "GATEWAY_ERROR" + self.message = (message?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? message!.trimmingCharacters(in: .whitespacesAndNewlines) + : "gateway error" + self.details = details ?? [:] + } + + public var errorDescription: String? { + if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" } + return "\(self.method): [\(self.code)] \(self.message)" + } +} + +public struct GatewayDecodingError: LocalizedError, Sendable { + public let method: String + public let message: String + + public init(method: String, message: String) { + self.method = method + self.message = message + } + + public var errorDescription: String? { "\(self.method): \(self.message)" } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift new file mode 100644 index 00000000..7dd2fe1e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -0,0 +1,442 @@ +import OpenClawProtocol +import Foundation +import OSLog + +private struct NodeInvokeRequestPayload: Codable, Sendable { + var id: String + var nodeId: String + var command: String + var paramsJSON: String? + var timeoutMs: Int? + var idempotencyKey: String? +} + + +public actor GatewayNodeSession { + private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway") + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + private static let defaultInvokeTimeoutMs = 30_000 + private var channel: GatewayChannelActor? + private var activeURL: URL? + private var activeToken: String? + private var activePassword: String? + private var activeConnectOptionsKey: String? + private var connectOptions: GatewayConnectOptions? + private var onConnected: (@Sendable () async -> Void)? + private var onDisconnected: (@Sendable (String) async -> Void)? + private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)? + private var hasEverConnected = false + private var hasNotifiedConnected = false + private var snapshotReceived = false + private var snapshotWaiters: [CheckedContinuation] = [] + + static func invokeWithTimeout( + request: BridgeInvokeRequest, + timeoutMs: Int?, + onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse + ) async -> BridgeInvokeResponse { + let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway") + let timeout: Int = { + if let timeoutMs { return max(0, timeoutMs) } + return Self.defaultInvokeTimeoutMs + }() + guard timeout > 0 else { + return await onInvoke(request) + } + + // Use an explicit latch so timeouts win even if onInvoke blocks (e.g., permission prompts). + final class InvokeLatch: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var resumed = false + + func setContinuation(_ continuation: CheckedContinuation) { + self.lock.lock() + defer { self.lock.unlock() } + self.continuation = continuation + } + + func resume(_ response: BridgeInvokeResponse) { + let cont: CheckedContinuation? + self.lock.lock() + if self.resumed { + self.lock.unlock() + return + } + self.resumed = true + cont = self.continuation + self.continuation = nil + self.lock.unlock() + cont?.resume(returning: response) + } + } + + let latch = InvokeLatch() + var onInvokeTask: Task? + var timeoutTask: Task? + defer { + onInvokeTask?.cancel() + timeoutTask?.cancel() + } + let response = await withCheckedContinuation { (cont: CheckedContinuation) in + latch.setContinuation(cont) + onInvokeTask = Task.detached { + let result = await onInvoke(request) + latch.resume(result) + } + timeoutTask = Task.detached { + do { + try await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000) + } catch { + // Expected when invoke finishes first and cancels the timeout task. + return + } + guard !Task.isCancelled else { return } + timeoutLogger.info("node invoke timeout fired id=\(request.id, privacy: .public)") + latch.resume(BridgeInvokeResponse( + id: request.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "node invoke timed out") + )) + } + } + timeoutLogger.info("node invoke race resolved id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") + return response + } + private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] + private var canvasHostUrl: String? + + public init() {} + + private func connectOptionsKey(_ options: GatewayConnectOptions) -> String { + func sorted(_ values: [String]) -> String { + values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .sorted() + .joined(separator: ",") + } + let role = options.role.trimmingCharacters(in: .whitespacesAndNewlines) + let scopes = sorted(options.scopes) + let caps = sorted(options.caps) + let commands = sorted(options.commands) + let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines) + let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines) + let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0" + let permissions = options.permissions + .map { key, value in + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) + return "\(trimmed)=\(value ? "1" : "0")" + } + .sorted() + .joined(separator: ",") + + return [ + role, + scopes, + caps, + commands, + clientId, + clientMode, + clientDisplayName, + includeDeviceIdentity, + permissions, + ].joined(separator: "|") + } + + public func connect( + url: URL, + token: String?, + password: String?, + connectOptions: GatewayConnectOptions, + sessionBox: WebSocketSessionBox?, + onConnected: @escaping @Sendable () async -> Void, + onDisconnected: @escaping @Sendable (String) async -> Void, + onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse + ) async throws { + let nextOptionsKey = self.connectOptionsKey(connectOptions) + let shouldReconnect = self.activeURL != url || + self.activeToken != token || + self.activePassword != password || + self.activeConnectOptionsKey != nextOptionsKey || + self.channel == nil + + self.connectOptions = connectOptions + self.onConnected = onConnected + self.onDisconnected = onDisconnected + self.onInvoke = onInvoke + + if shouldReconnect { + self.resetConnectionState() + if let existing = self.channel { + await existing.shutdown() + } + let channel = GatewayChannelActor( + url: url, + token: token, + password: password, + session: sessionBox, + pushHandler: { [weak self] push in + await self?.handlePush(push) + }, + connectOptions: connectOptions, + disconnectHandler: { [weak self] reason in + await self?.handleChannelDisconnected(reason) + }) + self.channel = channel + self.activeURL = url + self.activeToken = token + self.activePassword = password + self.activeConnectOptionsKey = nextOptionsKey + } + + guard let channel = self.channel else { + throw NSError(domain: "Gateway", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "gateway channel unavailable", + ]) + } + + do { + try await channel.connect() + _ = await self.waitForSnapshot(timeoutMs: 500) + await self.notifyConnectedIfNeeded() + } catch { + throw error + } + } + + public func disconnect() async { + await self.channel?.shutdown() + self.channel = nil + self.activeURL = nil + self.activeToken = nil + self.activePassword = nil + self.activeConnectOptionsKey = nil + self.hasEverConnected = false + self.resetConnectionState() + } + + public func currentCanvasHostUrl() -> String? { + self.canvasHostUrl + } + + public func currentRemoteAddress() -> String? { + guard let url = self.activeURL else { return nil } + guard let host = url.host else { return url.absoluteString } + let port = url.port ?? (url.scheme == "wss" ? 443 : 80) + if host.contains(":") { + return "[\(host)]:\(port)" + } + return "\(host):\(port)" + } + + public func sendEvent(event: String, payloadJSON: String?) async { + guard let channel = self.channel else { return } + let params: [String: AnyCodable] = [ + "event": AnyCodable(event), + "payloadJSON": AnyCodable(payloadJSON ?? NSNull()), + ] + do { + try await channel.send(method: "node.event", params: params) + } catch { + self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)") + } + } + + public func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data { + guard let channel = self.channel else { + throw NSError(domain: "Gateway", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "not connected", + ]) + } + + let params = try self.decodeParamsJSON(paramsJSON) + return try await channel.request( + method: method, + params: params, + timeoutMs: Double(timeoutSeconds * 1000)) + } + + public func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream { + let id = UUID() + let session = self + return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in + self.serverEventSubscribers[id] = continuation + continuation.onTermination = { @Sendable _ in + Task { await session.removeServerEventSubscriber(id) } + } + } + } + + private func handlePush(_ push: GatewayPush) async { + switch push { + case let .snapshot(ok): + let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) + self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil + if self.hasEverConnected { + self.broadcastServerEvent( + EventFrame(type: "event", event: "seqGap", payload: nil, seq: nil, stateversion: nil)) + } + self.hasEverConnected = true + self.markSnapshotReceived() + await self.notifyConnectedIfNeeded() + case let .event(evt): + await self.handleEvent(evt) + default: + break + } + } + + private func resetConnectionState() { + self.hasNotifiedConnected = false + self.snapshotReceived = false + if !self.snapshotWaiters.isEmpty { + let waiters = self.snapshotWaiters + self.snapshotWaiters.removeAll() + for waiter in waiters { + waiter.resume(returning: false) + } + } + } + + private func handleChannelDisconnected(_ reason: String) async { + // The underlying channel can auto-reconnect; resetting state here ensures we surface a fresh + // onConnected callback once a new snapshot arrives after reconnect. + self.resetConnectionState() + await self.onDisconnected?(reason) + } + + private func markSnapshotReceived() { + self.snapshotReceived = true + if !self.snapshotWaiters.isEmpty { + let waiters = self.snapshotWaiters + self.snapshotWaiters.removeAll() + for waiter in waiters { + waiter.resume(returning: true) + } + } + } + + private func waitForSnapshot(timeoutMs: Int) async -> Bool { + if self.snapshotReceived { return true } + let clamped = max(0, timeoutMs) + return await withCheckedContinuation { cont in + self.snapshotWaiters.append(cont) + Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(clamped) * 1_000_000) + await self.timeoutSnapshotWaiters() + } + } + } + + private func timeoutSnapshotWaiters() { + guard !self.snapshotReceived else { return } + if !self.snapshotWaiters.isEmpty { + let waiters = self.snapshotWaiters + self.snapshotWaiters.removeAll() + for waiter in waiters { + waiter.resume(returning: false) + } + } + } + + private func notifyConnectedIfNeeded() async { + guard !self.hasNotifiedConnected else { return } + self.hasNotifiedConnected = true + await self.onConnected?() + } + + private func handleEvent(_ evt: EventFrame) async { + self.broadcastServerEvent(evt) + guard evt.event == "node.invoke.request" else { return } + self.logger.info("node invoke request received") + guard let payload = evt.payload else { return } + do { + let request = try self.decodeInvokeRequest(from: payload) + let timeoutLabel = request.timeoutMs.map(String.init) ?? "none" + self.logger.info("node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)") + guard let onInvoke else { return } + let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON) + self.logger.info("node invoke executing id=\(request.id, privacy: .public)") + let response = await Self.invokeWithTimeout( + request: req, + timeoutMs: request.timeoutMs, + onInvoke: onInvoke + ) + self.logger.info("node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") + await self.sendInvokeResult(request: request, response: response) + } catch { + self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func decodeInvokeRequest(from payload: OpenClawProtocol.AnyCodable) throws -> NodeInvokeRequestPayload { + do { + let data = try self.encoder.encode(payload) + return try self.decoder.decode(NodeInvokeRequestPayload.self, from: data) + } catch { + if let raw = payload.value as? String, let data = raw.data(using: .utf8) { + return try self.decoder.decode(NodeInvokeRequestPayload.self, from: data) + } + throw error + } + } + + private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async { + guard let channel = self.channel else { return } + self.logger.info("node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") + var params: [String: AnyCodable] = [ + "id": AnyCodable(request.id), + "nodeId": AnyCodable(request.nodeId), + "ok": AnyCodable(response.ok), + ] + if let payloadJSON = response.payloadJSON { + params["payloadJSON"] = AnyCodable(payloadJSON) + } + if let error = response.error { + params["error"] = AnyCodable([ + "code": error.code.rawValue, + "message": error.message, + ]) + } + do { + try await channel.send(method: "node.invoke.result", params: params) + } catch { + self.logger.error("node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + } + } + + private func decodeParamsJSON( + _ paramsJSON: String?) throws -> [String: AnyCodable]? + { + guard let paramsJSON, !paramsJSON.isEmpty else { return nil } + guard let data = paramsJSON.data(using: .utf8) else { + throw NSError(domain: "Gateway", code: 12, userInfo: [ + NSLocalizedDescriptionKey: "paramsJSON not UTF-8", + ]) + } + let raw = try JSONSerialization.jsonObject(with: data) + guard let dict = raw as? [String: Any] else { + return nil + } + return dict.reduce(into: [:]) { acc, entry in + acc[entry.key] = AnyCodable(entry.value) + } + } + + private func broadcastServerEvent(_ evt: EventFrame) { + for (id, continuation) in self.serverEventSubscribers { + if case .terminated = continuation.yield(evt) { + self.serverEventSubscribers.removeValue(forKey: id) + } + } + } + + private func removeServerEventSubscriber(_ id: UUID) { + self.serverEventSubscribers.removeValue(forKey: id) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift new file mode 100644 index 00000000..139aa7d2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPayloadDecoding.swift @@ -0,0 +1,20 @@ +import OpenClawProtocol +import Foundation + +public enum GatewayPayloadDecoding { + public static func decode( + _ payload: AnyCodable, + as _: T.Type = T.self) throws -> T + { + let data = try JSONEncoder().encode(payload) + return try JSONDecoder().decode(T.self, from: data) + } + + public static func decodeIfPresent( + _ payload: AnyCodable?, + as _: T.Type = T.self) throws -> T? + { + guard let payload else { return nil } + return try self.decode(payload, as: T.self) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift new file mode 100644 index 00000000..65e118ff --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift @@ -0,0 +1,13 @@ +import OpenClawProtocol + +/// Server-push messages from the gateway websocket. +/// +/// This is the in-process replacement for the legacy `NotificationCenter` fan-out. +public enum GatewayPush: Sendable { + /// A full snapshot that arrives on connect (or reconnect). + case snapshot(HelloOk) + /// A server push event frame. + case event(EventFrame) + /// A detected sequence gap (`expected...received`) for event frames. + case seqGap(expected: Int, received: Int) +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift new file mode 100644 index 00000000..a0cbcd37 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift @@ -0,0 +1,119 @@ +import CryptoKit +import Foundation +import Security + +public struct GatewayTLSParams: Sendable { + public let required: Bool + public let expectedFingerprint: String? + public let allowTOFU: Bool + public let storeKey: String? + + public init(required: Bool, expectedFingerprint: String?, allowTOFU: Bool, storeKey: String?) { + self.required = required + self.expectedFingerprint = expectedFingerprint + self.allowTOFU = allowTOFU + self.storeKey = storeKey + } +} + +public enum GatewayTLSStore { + private static let suiteName = "ai.openclaw.shared" + private static let keyPrefix = "gateway.tls." + + private static var defaults: UserDefaults { + UserDefaults(suiteName: suiteName) ?? .standard + } + + public static func loadFingerprint(stableID: String) -> String? { + let key = self.keyPrefix + stableID + let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) + if raw?.isEmpty == false { return raw } + return nil + } + + public static func saveFingerprint(_ value: String, stableID: String) { + let key = self.keyPrefix + stableID + self.defaults.set(value, forKey: key) + } +} + +public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable { + private let params: GatewayTLSParams + private lazy var session: URLSession = { + let config = URLSessionConfiguration.default + config.waitsForConnectivity = true + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + }() + + public init(params: GatewayTLSParams) { + self.params = params + super.init() + } + + public func makeWebSocketTask(url: URL) -> WebSocketTaskBox { + let task = self.session.webSocketTask(with: url) + task.maximumMessageSize = 16 * 1024 * 1024 + return WebSocketTaskBox(task: task) + } + + public func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust + else { + completionHandler(.performDefaultHandling, nil) + return + } + + let expected = params.expectedFingerprint.map(normalizeFingerprint) + if let fingerprint = certificateFingerprint(trust) { + if let expected { + if fingerprint == expected { + completionHandler(.useCredential, URLCredential(trust: trust)) + } else { + completionHandler(.cancelAuthenticationChallenge, nil) + } + return + } + if params.allowTOFU { + if let storeKey = params.storeKey { + GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey) + } + completionHandler(.useCredential, URLCredential(trust: trust)) + return + } + } + + let ok = SecTrustEvaluateWithError(trust, nil) + if ok || !params.required { + completionHandler(.useCredential, URLCredential(trust: trust)) + } else { + completionHandler(.cancelAuthenticationChallenge, nil) + } + } +} + +private func certificateFingerprint(_ trust: SecTrust) -> String? { + guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], + let cert = chain.first + else { + return nil + } + return sha256Hex(SecCertificateCopyData(cert) as Data) +} + +private func sha256Hex(_ data: Data) -> String { + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() +} + +private func normalizeFingerprint(_ raw: String) -> String { + let stripped = raw.replacingOccurrences( + of: #"(?i)^sha-?256\s*:?\s*"#, + with: "", + options: .regularExpression) + return stripped.lowercased().filter(\.isHexDigit) +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift new file mode 100644 index 00000000..d18fa4e9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/InstanceIdentity.swift @@ -0,0 +1,108 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +public enum InstanceIdentity { + private static let suiteName = "ai.openclaw.shared" + private static let instanceIdKey = "instanceId" + + private static var defaults: UserDefaults { + UserDefaults(suiteName: suiteName) ?? .standard + } + +#if canImport(UIKit) + private static func readMainActor(_ body: @MainActor () -> T) -> T { + if Thread.isMainThread { + return MainActor.assumeIsolated { body() } + } + return DispatchQueue.main.sync { + MainActor.assumeIsolated { body() } + } + } +#endif + + public static let instanceId: String = { + let defaults = Self.defaults + if let existing = defaults.string(forKey: instanceIdKey)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !existing.isEmpty + { + return existing + } + + let id = UUID().uuidString.lowercased() + defaults.set(id, forKey: instanceIdKey) + return id + }() + + public static let displayName: String = { +#if canImport(UIKit) + let name = Self.readMainActor { + UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) + } + return name.isEmpty ? "openclaw" : name +#else + if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty + { + return name + } + return "openclaw" +#endif + }() + + public static let modelIdentifier: String? = { +#if canImport(UIKit) + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in + String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) + } + let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed +#else + var size = 0 + guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil } + + var buffer = [CChar](repeating: 0, count: size) + guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return nil } + + let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + guard let raw = String(bytes: bytes, encoding: .utf8) else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed +#endif + }() + + public static let deviceFamily: String = { +#if canImport(UIKit) + return Self.readMainActor { + switch UIDevice.current.userInterfaceIdiom { + case .pad: return "iPad" + case .phone: return "iPhone" + default: return "iOS" + } + } +#else + return "Mac" +#endif + }() + + public static let platformString: String = { + let v = ProcessInfo.processInfo.operatingSystemVersion +#if canImport(UIKit) + let name = Self.readMainActor { + switch UIDevice.current.userInterfaceIdiom { + case .pad: return "iPadOS" + case .phone: return "iOS" + default: return "iOS" + } + } + return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" +#else + return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" +#endif + }() +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/JPEGTranscoder.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/JPEGTranscoder.swift new file mode 100644 index 00000000..f4b1cb95 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/JPEGTranscoder.swift @@ -0,0 +1,135 @@ +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +public enum JPEGTranscodeError: LocalizedError, Sendable { + case decodeFailed + case propertiesMissing + case encodeFailed + case sizeLimitExceeded(maxBytes: Int, actualBytes: Int) + + public var errorDescription: String? { + switch self { + case .decodeFailed: + "Failed to decode image data" + case .propertiesMissing: + "Failed to read image properties" + case .encodeFailed: + "Failed to encode JPEG" + case let .sizeLimitExceeded(maxBytes, actualBytes): + "JPEG exceeds size limit (\(actualBytes) bytes > \(maxBytes) bytes)" + } + } +} + +public struct JPEGTranscoder: Sendable { + public static func clampQuality(_ quality: Double) -> Double { + min(1.0, max(0.05, quality)) + } + + /// Re-encodes image data to JPEG, optionally downscaling so that the *oriented* pixel width is <= `maxWidthPx`. + /// + /// - Important: This normalizes EXIF orientation (the output pixels are rotated if needed; orientation tag is not + /// relied on). + public static func transcodeToJPEG( + imageData: Data, + maxWidthPx: Int?, + quality: Double, + maxBytes: Int? = nil) throws -> (data: Data, widthPx: Int, heightPx: Int) + { + guard let src = CGImageSourceCreateWithData(imageData as CFData, nil) else { + throw JPEGTranscodeError.decodeFailed + } + guard + let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any], + let rawWidth = props[kCGImagePropertyPixelWidth] as? NSNumber, + let rawHeight = props[kCGImagePropertyPixelHeight] as? NSNumber + else { + throw JPEGTranscodeError.propertiesMissing + } + + let pixelWidth = rawWidth.intValue + let pixelHeight = rawHeight.intValue + let orientation = (props[kCGImagePropertyOrientation] as? NSNumber)?.intValue ?? 1 + + guard pixelWidth > 0, pixelHeight > 0 else { + throw JPEGTranscodeError.propertiesMissing + } + + let rotates90 = orientation == 5 || orientation == 6 || orientation == 7 || orientation == 8 + let orientedWidth = rotates90 ? pixelHeight : pixelWidth + let orientedHeight = rotates90 ? pixelWidth : pixelHeight + + let maxDim = max(orientedWidth, orientedHeight) + var targetMaxPixelSize: Int = { + guard let maxWidthPx, maxWidthPx > 0 else { return maxDim } + guard orientedWidth > maxWidthPx else { return maxDim } // never upscale + + let scale = Double(maxWidthPx) / Double(orientedWidth) + return max(1, Int((Double(maxDim) * scale).rounded(.toNearestOrAwayFromZero))) + }() + + func encode(maxPixelSize: Int, quality: Double) throws -> (data: Data, widthPx: Int, heightPx: Int) { + let thumbOpts: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + kCGImageSourceShouldCacheImmediately: true, + ] + + guard let img = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOpts as CFDictionary) else { + throw JPEGTranscodeError.decodeFailed + } + + let out = NSMutableData() + guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else { + throw JPEGTranscodeError.encodeFailed + } + let q = self.clampQuality(quality) + let encodeProps = [kCGImageDestinationLossyCompressionQuality: q] as CFDictionary + CGImageDestinationAddImage(dest, img, encodeProps) + guard CGImageDestinationFinalize(dest) else { + throw JPEGTranscodeError.encodeFailed + } + + return (out as Data, img.width, img.height) + } + + guard let maxBytes, maxBytes > 0 else { + return try encode(maxPixelSize: targetMaxPixelSize, quality: quality) + } + + let minQuality = max(0.2, self.clampQuality(quality) * 0.35) + let minPixelSize = 256 + var best = try encode(maxPixelSize: targetMaxPixelSize, quality: quality) + if best.data.count <= maxBytes { + return best + } + + for _ in 0..<6 { + var q = self.clampQuality(quality) + for _ in 0..<6 { + let candidate = try encode(maxPixelSize: targetMaxPixelSize, quality: q) + best = candidate + if candidate.data.count <= maxBytes { + return candidate + } + if q <= minQuality { break } + q = max(minQuality, q * 0.75) + } + + let nextPixelSize = max(Int(Double(targetMaxPixelSize) * 0.85), minPixelSize) + if nextPixelSize == targetMaxPixelSize { + break + } + targetMaxPixelSize = nextPixelSize + } + + if best.data.count > maxBytes { + throw JPEGTranscodeError.sizeLimitExceeded(maxBytes: maxBytes, actualBytes: best.data.count) + } + + return best + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationCommands.swift new file mode 100644 index 00000000..c02bc842 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationCommands.swift @@ -0,0 +1,57 @@ +import Foundation + +public enum OpenClawLocationCommand: String, Codable, Sendable { + case get = "location.get" +} + +public enum OpenClawLocationAccuracy: String, Codable, Sendable { + case coarse + case balanced + case precise +} + +public struct OpenClawLocationGetParams: Codable, Sendable, Equatable { + public var timeoutMs: Int? + public var maxAgeMs: Int? + public var desiredAccuracy: OpenClawLocationAccuracy? + + public init(timeoutMs: Int? = nil, maxAgeMs: Int? = nil, desiredAccuracy: OpenClawLocationAccuracy? = nil) { + self.timeoutMs = timeoutMs + self.maxAgeMs = maxAgeMs + self.desiredAccuracy = desiredAccuracy + } +} + +public struct OpenClawLocationPayload: Codable, Sendable, Equatable { + public var lat: Double + public var lon: Double + public var accuracyMeters: Double + public var altitudeMeters: Double? + public var speedMps: Double? + public var headingDeg: Double? + public var timestamp: String + public var isPrecise: Bool + public var source: String? + + public init( + lat: Double, + lon: Double, + accuracyMeters: Double, + altitudeMeters: Double? = nil, + speedMps: Double? = nil, + headingDeg: Double? = nil, + timestamp: String, + isPrecise: Bool, + source: String? = nil) + { + self.lat = lat + self.lon = lon + self.accuracyMeters = accuracyMeters + self.altitudeMeters = altitudeMeters + self.speedMps = speedMps + self.headingDeg = headingDeg + self.timestamp = timestamp + self.isPrecise = isPrecise + self.source = source + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationSettings.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationSettings.swift new file mode 100644 index 00000000..961e2980 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationSettings.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum OpenClawLocationMode: String, Codable, Sendable, CaseIterable { + case off + case whileUsing + case always +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift new file mode 100644 index 00000000..ab487bfd --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift @@ -0,0 +1,95 @@ +import Foundation + +public enum OpenClawMotionCommand: String, Codable, Sendable { + case activity = "motion.activity" + case pedometer = "motion.pedometer" +} + +public struct OpenClawMotionActivityParams: Codable, Sendable, Equatable { + public var startISO: String? + public var endISO: String? + public var limit: Int? + + public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) { + self.startISO = startISO + self.endISO = endISO + self.limit = limit + } +} + +public struct OpenClawMotionActivityEntry: Codable, Sendable, Equatable { + public var startISO: String + public var endISO: String + public var confidence: String + public var isWalking: Bool + public var isRunning: Bool + public var isCycling: Bool + public var isAutomotive: Bool + public var isStationary: Bool + public var isUnknown: Bool + + public init( + startISO: String, + endISO: String, + confidence: String, + isWalking: Bool, + isRunning: Bool, + isCycling: Bool, + isAutomotive: Bool, + isStationary: Bool, + isUnknown: Bool) + { + self.startISO = startISO + self.endISO = endISO + self.confidence = confidence + self.isWalking = isWalking + self.isRunning = isRunning + self.isCycling = isCycling + self.isAutomotive = isAutomotive + self.isStationary = isStationary + self.isUnknown = isUnknown + } +} + +public struct OpenClawMotionActivityPayload: Codable, Sendable, Equatable { + public var activities: [OpenClawMotionActivityEntry] + + public init(activities: [OpenClawMotionActivityEntry]) { + self.activities = activities + } +} + +public struct OpenClawPedometerParams: Codable, Sendable, Equatable { + public var startISO: String? + public var endISO: String? + + public init(startISO: String? = nil, endISO: String? = nil) { + self.startISO = startISO + self.endISO = endISO + } +} + +public struct OpenClawPedometerPayload: Codable, Sendable, Equatable { + public var startISO: String + public var endISO: String + public var steps: Int? + public var distanceMeters: Double? + public var floorsAscended: Int? + public var floorsDescended: Int? + + public init( + startISO: String, + endISO: String, + steps: Int?, + distanceMeters: Double?, + floorsAscended: Int?, + floorsDescended: Int?) + { + self.startISO = startISO + self.endISO = endISO + self.steps = steps + self.distanceMeters = distanceMeters + self.floorsAscended = floorsAscended + self.floorsDescended = floorsDescended + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift new file mode 100644 index 00000000..3679ef54 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift @@ -0,0 +1,43 @@ +import Darwin +import Foundation + +public enum NetworkInterfaces { + public static func primaryIPv4Address() -> String? { + var addrList: UnsafeMutablePointer? + guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } + defer { freeifaddrs(addrList) } + + var fallback: String? + var en0: String? + + for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { + let flags = Int32(ptr.pointee.ifa_flags) + let isUp = (flags & IFF_UP) != 0 + let isLoopback = (flags & IFF_LOOPBACK) != 0 + let name = String(cString: ptr.pointee.ifa_name) + let family = ptr.pointee.ifa_addr.pointee.sa_family + if !isUp || isLoopback || family != UInt8(AF_INET) { continue } + + var addr = ptr.pointee.ifa_addr.pointee + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + &addr, + socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard result == 0 else { continue } + let len = buffer.prefix { $0 != 0 } + let bytes = len.map { UInt8(bitPattern: $0) } + guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } + + if name == "en0" { en0 = ip; break } + if fallback == nil { fallback = ip } + } + + return en0 ?? fallback + } +} + diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/NodeError.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/NodeError.swift new file mode 100644 index 00000000..4fe3fd04 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/NodeError.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum OpenClawNodeErrorCode: String, Codable, Sendable { + case notPaired = "NOT_PAIRED" + case unauthorized = "UNAUTHORIZED" + case backgroundUnavailable = "NODE_BACKGROUND_UNAVAILABLE" + case invalidRequest = "INVALID_REQUEST" + case unavailable = "UNAVAILABLE" +} + +public struct OpenClawNodeError: Error, Codable, Sendable, Equatable { + public var code: OpenClawNodeErrorCode + public var message: String + public var retryable: Bool? + public var retryAfterMs: Int? + + public init( + code: OpenClawNodeErrorCode, + message: String, + retryable: Bool? = nil, + retryAfterMs: Int? = nil) + { + self.code = code + self.message = message + self.retryable = retryable + self.retryAfterMs = retryAfterMs + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift new file mode 100644 index 00000000..5af33d1d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift @@ -0,0 +1,83 @@ +import Foundation + +public enum OpenClawKitResources { + /// Resource bundle for OpenClawKit. + /// + /// Locates the SwiftPM-generated resource bundle, checking multiple locations: + /// 1. Inside Bundle.main (packaged apps) + /// 2. Bundle.module (SwiftPM development/tests) + /// 3. Falls back to Bundle.main if not found (resource lookups will return nil) + /// + /// This avoids a fatal crash when Bundle.module can't locate its resources + /// in packaged .app bundles where the resource bundle path differs from + /// SwiftPM's expectations. + public static let bundle: Bundle = locateBundle() + + private static let bundleName = "OpenClawKit_OpenClawKit" + + private static func locateBundle() -> Bundle { + // 1. Check inside Bundle.main (packaged apps copy resources here) + if let mainResourceURL = Bundle.main.resourceURL { + let bundleURL = mainResourceURL.appendingPathComponent("\(bundleName).bundle") + if let bundle = Bundle(url: bundleURL) { + return bundle + } + } + + // 2. Check Bundle.main directly for embedded resources + if Bundle.main.url(forResource: "tool-display", withExtension: "json") != nil { + return Bundle.main + } + + // 3. Try Bundle.module (works in SwiftPM development/tests) + // Wrap in a function to defer the fatalError until actually called + if let moduleBundle = loadModuleBundleSafely() { + return moduleBundle + } + + // 4. Fallback: return Bundle.main (resource lookups will return nil gracefully) + return Bundle.main + } + + private static func loadModuleBundleSafely() -> Bundle? { + // Bundle.module is generated by SwiftPM and will fatalError if not found. + // We check likely locations manually to avoid the crash. + let candidates: [URL?] = [ + Bundle.main.resourceURL, + Bundle.main.bundleURL, + Bundle(for: BundleLocator.self).resourceURL, + Bundle(for: BundleLocator.self).bundleURL, + ] + + for candidate in candidates { + guard let baseURL = candidate else { continue } + + // SwiftPM often places the resource bundle next to (or near) the test runner bundle, + // not inside it. Walk up a few levels and check common container paths. + var roots: [URL] = [] + roots.append(baseURL) + roots.append(baseURL.appendingPathComponent("Resources")) + roots.append(baseURL.appendingPathComponent("Contents/Resources")) + + var current = baseURL + for _ in 0 ..< 5 { + current = current.deletingLastPathComponent() + roots.append(current) + roots.append(current.appendingPathComponent("Resources")) + roots.append(current.appendingPathComponent("Contents/Resources")) + } + + for root in roots { + let bundleURL = root.appendingPathComponent("\(bundleName).bundle") + if let bundle = Bundle(url: bundleURL) { + return bundle + } + } + } + + return nil + } +} + +// Helper class for bundle lookup via Bundle(for:) +private final class BundleLocator {} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotoCapture.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotoCapture.swift new file mode 100644 index 00000000..b5f00d34 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotoCapture.swift @@ -0,0 +1,19 @@ +import Foundation + +public enum PhotoCapture { + public static func transcodeJPEGForGateway( + rawData: Data, + maxWidthPx: Int, + quality: Double, + maxPayloadBytes: Int = 5 * 1024 * 1024 + ) throws -> (data: Data, widthPx: Int, heightPx: Int) { + // Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under maxPayloadBytes (API limit). + let maxEncodedBytes = (maxPayloadBytes / 4) * 3 + return try JPEGTranscoder.transcodeToJPEG( + imageData: rawData, + maxWidthPx: maxWidthPx, + quality: quality, + maxBytes: maxEncodedBytes) + } +} + diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotosCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotosCommands.swift new file mode 100644 index 00000000..8d22f5d2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotosCommands.swift @@ -0,0 +1,41 @@ +import Foundation + +public enum OpenClawPhotosCommand: String, Codable, Sendable { + case latest = "photos.latest" +} + +public struct OpenClawPhotosLatestParams: Codable, Sendable, Equatable { + public var limit: Int? + public var maxWidth: Int? + public var quality: Double? + + public init(limit: Int? = nil, maxWidth: Int? = nil, quality: Double? = nil) { + self.limit = limit + self.maxWidth = maxWidth + self.quality = quality + } +} + +public struct OpenClawPhotoPayload: Codable, Sendable, Equatable { + public var format: String + public var base64: String + public var width: Int + public var height: Int + public var createdAt: String? + + public init(format: String, base64: String, width: Int, height: Int, createdAt: String? = nil) { + self.format = format + self.base64 = base64 + self.width = width + self.height = height + self.createdAt = createdAt + } +} + +public struct OpenClawPhotosLatestPayload: Codable, Sendable, Equatable { + public var photos: [OpenClawPhotoPayload] + + public init(photos: [OpenClawPhotoPayload]) { + self.photos = photos + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift new file mode 100644 index 00000000..ac275d80 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift @@ -0,0 +1,82 @@ +import Foundation + +public enum OpenClawRemindersCommand: String, Codable, Sendable { + case list = "reminders.list" + case add = "reminders.add" +} + +public enum OpenClawReminderStatusFilter: String, Codable, Sendable { + case incomplete + case completed + case all +} + +public struct OpenClawRemindersListParams: Codable, Sendable, Equatable { + public var status: OpenClawReminderStatusFilter? + public var limit: Int? + + public init(status: OpenClawReminderStatusFilter? = nil, limit: Int? = nil) { + self.status = status + self.limit = limit + } +} + +public struct OpenClawRemindersAddParams: Codable, Sendable, Equatable { + public var title: String + public var dueISO: String? + public var notes: String? + public var listId: String? + public var listName: String? + + public init( + title: String, + dueISO: String? = nil, + notes: String? = nil, + listId: String? = nil, + listName: String? = nil) + { + self.title = title + self.dueISO = dueISO + self.notes = notes + self.listId = listId + self.listName = listName + } +} + +public struct OpenClawReminderPayload: Codable, Sendable, Equatable { + public var identifier: String + public var title: String + public var dueISO: String? + public var completed: Bool + public var listName: String? + + public init( + identifier: String, + title: String, + dueISO: String? = nil, + completed: Bool, + listName: String? = nil) + { + self.identifier = identifier + self.title = title + self.dueISO = dueISO + self.completed = completed + self.listName = listName + } +} + +public struct OpenClawRemindersListPayload: Codable, Sendable, Equatable { + public var reminders: [OpenClawReminderPayload] + + public init(reminders: [OpenClawReminderPayload]) { + self.reminders = reminders + } +} + +public struct OpenClawRemindersAddPayload: Codable, Sendable, Equatable { + public var reminder: OpenClawReminderPayload + + public init(reminder: OpenClawReminderPayload) { + self.reminder = reminder + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html new file mode 100644 index 00000000..ceb7a975 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html @@ -0,0 +1,225 @@ + + + + + + Canvas + + + + + +
+
+
Ready
+
Waiting for agent
+
+
+ + + diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json new file mode 100644 index 00000000..9c0e57fc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -0,0 +1,197 @@ +{ + "version": 1, + "fallback": { + "emoji": "🧩", + "detailKeys": [ + "command", + "path", + "url", + "targetUrl", + "targetId", + "ref", + "element", + "node", + "nodeId", + "id", + "requestId", + "to", + "channelId", + "guildId", + "userId", + "name", + "query", + "pattern", + "messageId" + ] + }, + "tools": { + "bash": { + "emoji": "🛠️", + "title": "Bash", + "detailKeys": ["command"] + }, + "process": { + "emoji": "🧰", + "title": "Process", + "detailKeys": ["sessionId"] + }, + "read": { + "emoji": "📖", + "title": "Read", + "detailKeys": ["path"] + }, + "write": { + "emoji": "✍️", + "title": "Write", + "detailKeys": ["path"] + }, + "edit": { + "emoji": "📝", + "title": "Edit", + "detailKeys": ["path"] + }, + "attach": { + "emoji": "📎", + "title": "Attach", + "detailKeys": ["path", "url", "fileName"] + }, + "browser": { + "emoji": "🌐", + "title": "Browser", + "actions": { + "status": { "label": "status" }, + "start": { "label": "start" }, + "stop": { "label": "stop" }, + "tabs": { "label": "tabs" }, + "open": { "label": "open", "detailKeys": ["targetUrl"] }, + "focus": { "label": "focus", "detailKeys": ["targetId"] }, + "close": { "label": "close", "detailKeys": ["targetId"] }, + "snapshot": { + "label": "snapshot", + "detailKeys": ["targetUrl", "targetId", "ref", "element", "format"] + }, + "screenshot": { + "label": "screenshot", + "detailKeys": ["targetUrl", "targetId", "ref", "element"] + }, + "navigate": { + "label": "navigate", + "detailKeys": ["targetUrl", "targetId"] + }, + "console": { "label": "console", "detailKeys": ["level", "targetId"] }, + "pdf": { "label": "pdf", "detailKeys": ["targetId"] }, + "upload": { + "label": "upload", + "detailKeys": ["paths", "ref", "inputRef", "element", "targetId"] + }, + "dialog": { + "label": "dialog", + "detailKeys": ["accept", "promptText", "targetId"] + }, + "act": { + "label": "act", + "detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"] + } + } + }, + "canvas": { + "emoji": "🖼️", + "title": "Canvas", + "actions": { + "present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] }, + "hide": { "label": "hide", "detailKeys": ["node", "nodeId"] }, + "navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] }, + "eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] }, + "snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] }, + "a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] }, + "a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] } + } + }, + "nodes": { + "emoji": "📱", + "title": "Nodes", + "actions": { + "status": { "label": "status" }, + "describe": { "label": "describe", "detailKeys": ["node", "nodeId"] }, + "pending": { "label": "pending" }, + "approve": { "label": "approve", "detailKeys": ["requestId"] }, + "reject": { "label": "reject", "detailKeys": ["requestId"] }, + "notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] }, + "camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] }, + "camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] }, + "camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] }, + "screen_record": { + "label": "screen record", + "detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"] + } + } + }, + "cron": { + "emoji": "⏰", + "title": "Cron", + "actions": { + "status": { "label": "status" }, + "list": { "label": "list" }, + "add": { + "label": "add", + "detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"] + }, + "update": { "label": "update", "detailKeys": ["id"] }, + "remove": { "label": "remove", "detailKeys": ["id"] }, + "run": { "label": "run", "detailKeys": ["id"] }, + "runs": { "label": "runs", "detailKeys": ["id"] }, + "wake": { "label": "wake", "detailKeys": ["text", "mode"] } + } + }, + "gateway": { + "emoji": "🔌", + "title": "Gateway", + "actions": { + "restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] } + } + }, + "whatsapp_login": { + "emoji": "🟢", + "title": "WhatsApp Login", + "actions": { + "start": { "label": "start" }, + "wait": { "label": "wait" } + } + }, + "discord": { + "emoji": "💬", + "title": "Discord", + "actions": { + "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] }, + "reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] }, + "sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] }, + "poll": { "label": "poll", "detailKeys": ["question", "to"] }, + "permissions": { "label": "permissions", "detailKeys": ["channelId"] }, + "readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] }, + "sendMessage": { "label": "send", "detailKeys": ["to", "content"] }, + "editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] }, + "deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] }, + "threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] }, + "threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] }, + "threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] }, + "pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] }, + "unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] }, + "listPins": { "label": "list pins", "detailKeys": ["channelId"] }, + "searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] }, + "memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] }, + "roleInfo": { "label": "roles", "detailKeys": ["guildId"] }, + "emojiList": { "label": "emoji list", "detailKeys": ["guildId"] }, + "roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] }, + "roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] }, + "channelInfo": { "label": "channel", "detailKeys": ["channelId"] }, + "channelList": { "label": "channels", "detailKeys": ["guildId"] }, + "voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] }, + "eventList": { "label": "events", "detailKeys": ["guildId"] }, + "eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] }, + "timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] }, + "kick": { "label": "kick", "detailKeys": ["guildId", "userId"] }, + "ban": { "label": "ban", "detailKeys": ["guildId", "userId"] } + } + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ScreenCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ScreenCommands.swift new file mode 100644 index 00000000..dfb57ce2 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ScreenCommands.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum OpenClawScreenCommand: String, Codable, Sendable { + case record = "screen.record" +} + +public struct OpenClawScreenRecordParams: Codable, Sendable, Equatable { + public var screenIndex: Int? + public var durationMs: Int? + public var fps: Double? + public var format: String? + public var includeAudio: Bool? + + public init( + screenIndex: Int? = nil, + durationMs: Int? = nil, + fps: Double? = nil, + format: String? = nil, + includeAudio: Bool? = nil) + { + self.screenIndex = screenIndex + self.durationMs = durationMs + self.fps = fps + self.format = format + self.includeAudio = includeAudio + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift new file mode 100644 index 00000000..7b4c3864 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift @@ -0,0 +1,62 @@ +import Foundation + +public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable { + public let gatewayURLString: String + public let token: String? + public let password: String? + public let sessionKey: String + public let deliveryChannel: String? + public let deliveryTo: String? + + public init( + gatewayURLString: String, + token: String?, + password: String?, + sessionKey: String, + deliveryChannel: String? = nil, + deliveryTo: String? = nil) + { + self.gatewayURLString = gatewayURLString + self.token = token + self.password = password + self.sessionKey = sessionKey + self.deliveryChannel = deliveryChannel + self.deliveryTo = deliveryTo + } +} + +public enum ShareGatewayRelaySettings { + private static let suiteName = "group.ai.openclaw.shared" + private static let relayConfigKey = "share.gatewayRelay.config.v1" + private static let lastEventKey = "share.gatewayRelay.event.v1" + + private static var defaults: UserDefaults { + UserDefaults(suiteName: self.suiteName) ?? .standard + } + + public static func loadConfig() -> ShareGatewayRelayConfig? { + guard let data = self.defaults.data(forKey: self.relayConfigKey) else { return nil } + return try? JSONDecoder().decode(ShareGatewayRelayConfig.self, from: data) + } + + public static func saveConfig(_ config: ShareGatewayRelayConfig) { + guard let data = try? JSONEncoder().encode(config) else { return } + self.defaults.set(data, forKey: self.relayConfigKey) + } + + public static func clearConfig() { + self.defaults.removeObject(forKey: self.relayConfigKey) + } + + public static func saveLastEvent(_ message: String) { + let timestamp = ISO8601DateFormatter().string(from: Date()) + let payload = "[\(timestamp)] \(message)" + self.defaults.set(payload, forKey: self.lastEventKey) + } + + public static func loadLastEvent() -> String? { + let value = self.defaults.string(forKey: self.lastEventKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return value.isEmpty ? nil : value + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift new file mode 100644 index 00000000..08f06234 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift @@ -0,0 +1,62 @@ +import Foundation + +public struct SharedContentPayload: Sendable, Equatable { + public let title: String? + public let url: URL? + public let text: String? + + public init(title: String?, url: URL?, text: String?) { + self.title = title + self.url = url + self.text = text + } +} + +public enum ShareToAgentDeepLink { + public static func buildURL(from payload: SharedContentPayload, instruction: String? = nil) -> URL? { + let message = self.buildMessage(from: payload, instruction: instruction) + guard !message.isEmpty else { return nil } + + var components = URLComponents() + components.scheme = "openclaw" + components.host = "agent" + components.queryItems = [ + URLQueryItem(name: "message", value: message), + URLQueryItem(name: "thinking", value: "low"), + ] + return components.url + } + + public static func buildMessage(from payload: SharedContentPayload, instruction: String? = nil) -> String { + let title = self.clean(payload.title) + let text = self.clean(payload.text) + let urlText = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedInstruction = self.clean(instruction) ?? ShareToAgentSettings.loadDefaultInstruction() + + var lines: [String] = ["Shared from iOS."] + if let title, !title.isEmpty { + lines.append("Title: \(title)") + } + if let urlText, !urlText.isEmpty { + lines.append("URL: \(urlText)") + } + if let text, !text.isEmpty { + lines.append("Text:\n\(text)") + } + lines.append(resolvedInstruction) + + let message = lines.joined(separator: "\n\n") + return self.limit(message, maxCharacters: 2400) + } + + private static func clean(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func limit(_ value: String, maxCharacters: Int) -> String { + guard value.count > maxCharacters else { return value } + return String(value.prefix(maxCharacters)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift new file mode 100644 index 00000000..9034dcfe --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift @@ -0,0 +1,29 @@ +import Foundation + +public enum ShareToAgentSettings { + private static let suiteName = "group.ai.openclaw.shared" + private static let defaultInstructionKey = "share.defaultInstruction" + private static let fallbackInstruction = "Please help me with this." + + private static var defaults: UserDefaults { + UserDefaults(suiteName: suiteName) ?? .standard + } + + public static func loadDefaultInstruction() -> String { + let raw = self.defaults.string(forKey: self.defaultInstructionKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let raw, !raw.isEmpty { + return raw + } + return self.fallbackInstruction + } + + public static func saveDefaultInstruction(_ value: String?) { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + self.defaults.removeObject(forKey: self.defaultInstructionKey) + return + } + self.defaults.set(trimmed, forKey: self.defaultInstructionKey) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/StoragePaths.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/StoragePaths.swift new file mode 100644 index 00000000..d7542295 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/StoragePaths.swift @@ -0,0 +1,37 @@ +import Foundation + +public enum OpenClawNodeStorage { + public static func appSupportDir() throws -> URL { + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first + guard let base else { + throw NSError(domain: "OpenClawNodeStorage", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Application Support directory unavailable", + ]) + } + return base.appendingPathComponent("OpenClaw", isDirectory: true) + } + + public static func canvasRoot(sessionKey: String) throws -> URL { + let root = try appSupportDir().appendingPathComponent("canvas", isDirectory: true) + let safe = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + let session = safe.isEmpty ? "main" : safe + return root.appendingPathComponent(session, isDirectory: true) + } + + public static func cachesDir() throws -> URL { + let base = FileManager().urls(for: .cachesDirectory, in: .userDomainMask).first + guard let base else { + throw NSError(domain: "OpenClawNodeStorage", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Caches directory unavailable", + ]) + } + return base.appendingPathComponent("OpenClaw", isDirectory: true) + } + + public static func canvasSnapshotsRoot(sessionKey: String) throws -> URL { + let root = try cachesDir().appendingPathComponent("canvas-snapshots", isDirectory: true) + let safe = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + let session = safe.isEmpty ? "main" : safe + return root.appendingPathComponent(session, isDirectory: true) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/SystemCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/SystemCommands.swift new file mode 100644 index 00000000..a2c83490 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/SystemCommands.swift @@ -0,0 +1,88 @@ +import Foundation + +public enum OpenClawSystemCommand: String, Codable, Sendable { + case run = "system.run" + case which = "system.which" + case notify = "system.notify" + case execApprovalsGet = "system.execApprovals.get" + case execApprovalsSet = "system.execApprovals.set" +} + +public enum OpenClawNotificationPriority: String, Codable, Sendable { + case passive + case active + case timeSensitive +} + +public enum OpenClawNotificationDelivery: String, Codable, Sendable { + case system + case overlay + case auto +} + +public struct OpenClawSystemRunParams: Codable, Sendable, Equatable { + public var command: [String] + public var rawCommand: String? + public var cwd: String? + public var env: [String: String]? + public var timeoutMs: Int? + public var needsScreenRecording: Bool? + public var agentId: String? + public var sessionKey: String? + public var approved: Bool? + public var approvalDecision: String? + + public init( + command: [String], + rawCommand: String? = nil, + cwd: String? = nil, + env: [String: String]? = nil, + timeoutMs: Int? = nil, + needsScreenRecording: Bool? = nil, + agentId: String? = nil, + sessionKey: String? = nil, + approved: Bool? = nil, + approvalDecision: String? = nil) + { + self.command = command + self.rawCommand = rawCommand + self.cwd = cwd + self.env = env + self.timeoutMs = timeoutMs + self.needsScreenRecording = needsScreenRecording + self.agentId = agentId + self.sessionKey = sessionKey + self.approved = approved + self.approvalDecision = approvalDecision + } +} + +public struct OpenClawSystemWhichParams: Codable, Sendable, Equatable { + public var bins: [String] + + public init(bins: [String]) { + self.bins = bins + } +} + +public struct OpenClawSystemNotifyParams: Codable, Sendable, Equatable { + public var title: String + public var body: String + public var sound: String? + public var priority: OpenClawNotificationPriority? + public var delivery: OpenClawNotificationDelivery? + + public init( + title: String, + body: String, + sound: String? = nil, + priority: OpenClawNotificationPriority? = nil, + delivery: OpenClawNotificationDelivery? = nil) + { + self.title = title + self.body = body + self.sound = sound + self.priority = priority + self.delivery = delivery + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkCommands.swift new file mode 100644 index 00000000..755fc97a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkCommands.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum OpenClawTalkCommand: String, Codable, Sendable { + case pttStart = "talk.ptt.start" + case pttStop = "talk.ptt.stop" + case pttCancel = "talk.ptt.cancel" + case pttOnce = "talk.ptt.once" +} + +public struct OpenClawTalkPTTStartPayload: Codable, Sendable, Equatable { + public var captureId: String + + public init(captureId: String) { + self.captureId = captureId + } +} + +public struct OpenClawTalkPTTStopPayload: Codable, Sendable, Equatable { + public var captureId: String + public var transcript: String? + public var status: String + + public init(captureId: String, transcript: String?, status: String) { + self.captureId = captureId + self.transcript = transcript + self.status = status + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift new file mode 100644 index 00000000..6c460dc0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift @@ -0,0 +1,201 @@ +import Foundation + +public struct TalkDirective: Equatable, Sendable { + public var voiceId: String? + public var modelId: String? + public var speed: Double? + public var rateWPM: Int? + public var stability: Double? + public var similarity: Double? + public var style: Double? + public var speakerBoost: Bool? + public var seed: Int? + public var normalize: String? + public var language: String? + public var outputFormat: String? + public var latencyTier: Int? + public var once: Bool? + + public init( + voiceId: String? = nil, + modelId: String? = nil, + speed: Double? = nil, + rateWPM: Int? = nil, + stability: Double? = nil, + similarity: Double? = nil, + style: Double? = nil, + speakerBoost: Bool? = nil, + seed: Int? = nil, + normalize: String? = nil, + language: String? = nil, + outputFormat: String? = nil, + latencyTier: Int? = nil, + once: Bool? = nil) + { + self.voiceId = voiceId + self.modelId = modelId + self.speed = speed + self.rateWPM = rateWPM + self.stability = stability + self.similarity = similarity + self.style = style + self.speakerBoost = speakerBoost + self.seed = seed + self.normalize = normalize + self.language = language + self.outputFormat = outputFormat + self.latencyTier = latencyTier + self.once = once + } +} + +public struct TalkDirectiveParseResult: Equatable, Sendable { + public let directive: TalkDirective? + public let stripped: String + public let unknownKeys: [String] + + public init(directive: TalkDirective?, stripped: String, unknownKeys: [String]) { + self.directive = directive + self.stripped = stripped + self.unknownKeys = unknownKeys + } +} + +public enum TalkDirectiveParser { + public static func parse(_ text: String) -> TalkDirectiveParseResult { + let normalized = text.replacingOccurrences(of: "\r\n", with: "\n") + var lines = normalized.split(separator: "\n", omittingEmptySubsequences: false) + guard !lines.isEmpty else { return TalkDirectiveParseResult(directive: nil, stripped: text, unknownKeys: []) } + + guard let firstNonEmptyIndex = + lines.firstIndex(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) + else { + return TalkDirectiveParseResult(directive: nil, stripped: text, unknownKeys: []) + } + + var firstNonEmpty = firstNonEmptyIndex + if firstNonEmpty > 0 { + lines.removeSubrange(0.. String? { + for key in keys { + if let value = dict[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + } + return nil + } + + private static func doubleValue(_ dict: [String: Any], keys: [String]) -> Double? { + for key in keys { + if let value = dict[key] as? Double { return value } + if let value = dict[key] as? Int { return Double(value) } + if let value = dict[key] as? String, let parsed = Double(value) { return parsed } + } + return nil + } + + private static func intValue(_ dict: [String: Any], keys: [String]) -> Int? { + for key in keys { + if let value = dict[key] as? Int { return value } + if let value = dict[key] as? Double { return Int(value) } + if let value = dict[key] as? String, let parsed = Int(value) { return parsed } + } + return nil + } + + private static func boolValue(_ dict: [String: Any], keys: [String]) -> Bool? { + for key in keys { + if let value = dict[key] as? Bool { return value } + if let value = dict[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if ["true", "yes", "1"].contains(trimmed) { return true } + if ["false", "no", "0"].contains(trimmed) { return false } + } + } + return nil + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkHistoryTimestamp.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkHistoryTimestamp.swift new file mode 100644 index 00000000..75f14ef8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkHistoryTimestamp.swift @@ -0,0 +1,12 @@ +public enum TalkHistoryTimestamp: Sendable { + /// Gateway history timestamps have historically been emitted as either seconds (Double, epoch seconds) + /// or milliseconds (Double, epoch ms). This helper accepts either. + public static func isAfter(_ timestamp: Double, sinceSeconds: Double) -> Bool { + let sinceMs = sinceSeconds * 1000 + // ~2286-11-20 in epoch seconds. Anything bigger is almost certainly epoch milliseconds. + if timestamp > 10_000_000_000 { + return timestamp >= sinceMs - 500 + } + return timestamp >= sinceSeconds - 0.5 + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift new file mode 100644 index 00000000..2a2e39d6 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift @@ -0,0 +1,26 @@ +public enum TalkPromptBuilder: Sendable { + public static func build( + transcript: String, + interruptedAtSeconds: Double?, + includeVoiceDirectiveHint: Bool = true + ) -> String { + var lines: [String] = [ + "Talk Mode active. Reply in a concise, spoken tone.", + ] + + if includeVoiceDirectiveHint { + lines.append( + "You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"\",\"once\":true}." + ) + } + + if let interruptedAtSeconds { + let formatted = String(format: "%.1f", interruptedAtSeconds) + lines.append("Assistant speech interrupted at \(formatted)s.") + } + + lines.append("") + lines.append(transcript) + return lines.joined(separator: "\n") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkSystemSpeechSynthesizer.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkSystemSpeechSynthesizer.swift new file mode 100644 index 00000000..4cfc536d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkSystemSpeechSynthesizer.swift @@ -0,0 +1,116 @@ +import AVFoundation +import Foundation + +@MainActor +public final class TalkSystemSpeechSynthesizer: NSObject { + public enum SpeakError: Error { + case canceled + } + + public static let shared = TalkSystemSpeechSynthesizer() + + private let synth = AVSpeechSynthesizer() + private var speakContinuation: CheckedContinuation? + private var currentUtterance: AVSpeechUtterance? + private var currentToken = UUID() + private var watchdog: Task? + + public var isSpeaking: Bool { self.synth.isSpeaking } + + override private init() { + super.init() + self.synth.delegate = self + } + + public func stop() { + self.currentToken = UUID() + self.watchdog?.cancel() + self.watchdog = nil + self.synth.stopSpeaking(at: .immediate) + self.finishCurrent(with: SpeakError.canceled) + } + + public func speak(text: String, language: String? = nil) async throws { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + self.stop() + let token = UUID() + self.currentToken = token + + let utterance = AVSpeechUtterance(string: trimmed) + if let language, let voice = AVSpeechSynthesisVoice(language: language) { + utterance.voice = voice + } + self.currentUtterance = utterance + + let estimatedSeconds = max(3.0, min(180.0, Double(trimmed.count) * 0.08)) + self.watchdog?.cancel() + self.watchdog = Task { @MainActor [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(estimatedSeconds * 1_000_000_000)) + if Task.isCancelled { return } + guard self.currentToken == token else { return } + if self.synth.isSpeaking { + self.synth.stopSpeaking(at: .immediate) + } + self.finishCurrent( + with: NSError(domain: "TalkSystemSpeechSynthesizer", code: 408, userInfo: [ + NSLocalizedDescriptionKey: "system TTS timed out after \(estimatedSeconds)s", + ])) + } + + try await withTaskCancellationHandler(operation: { + try await withCheckedThrowingContinuation { cont in + self.speakContinuation = cont + self.synth.speak(utterance) + } + }, onCancel: { + Task { @MainActor in + self.stop() + } + }) + + if self.currentToken != token { + throw SpeakError.canceled + } + } + + private func handleFinish(error: Error?) { + guard self.currentUtterance != nil else { return } + self.watchdog?.cancel() + self.watchdog = nil + self.finishCurrent(with: error) + } + + private func finishCurrent(with error: Error?) { + self.currentUtterance = nil + let cont = self.speakContinuation + self.speakContinuation = nil + if let error { + cont?.resume(throwing: error) + } else { + cont?.resume(returning: ()) + } + } +} + +extension TalkSystemSpeechSynthesizer: AVSpeechSynthesizerDelegate { + public nonisolated func speechSynthesizer( + _ synthesizer: AVSpeechSynthesizer, + didFinish utterance: AVSpeechUtterance) + { + Task { @MainActor in + self.handleFinish(error: nil) + } + } + + public nonisolated func speechSynthesizer( + _ synthesizer: AVSpeechSynthesizer, + didCancel utterance: AVSpeechUtterance) + { + Task { @MainActor in + self.handleFinish(error: SpeakError.canceled) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ToolDisplay.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ToolDisplay.swift new file mode 100644 index 00000000..d52e24ca --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/ToolDisplay.swift @@ -0,0 +1,265 @@ +import Foundation + +public struct ToolDisplaySummary: Sendable, Equatable { + public let name: String + public let emoji: String + public let title: String + public let label: String + public let verb: String? + public let detail: String? + + public var detailLine: String? { + var parts: [String] = [] + if let verb, !verb.isEmpty { parts.append(verb) } + if let detail, !detail.isEmpty { parts.append(detail) } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + public var summaryLine: String { + if let detailLine { + return "\(self.emoji) \(self.label): \(detailLine)" + } + return "\(self.emoji) \(self.label)" + } +} + +public enum ToolDisplayRegistry { + private struct ToolDisplayActionSpec: Decodable { + let label: String? + let detailKeys: [String]? + } + + private struct ToolDisplaySpec: Decodable { + let emoji: String? + let title: String? + let label: String? + let detailKeys: [String]? + let actions: [String: ToolDisplayActionSpec]? + } + + private struct ToolDisplayConfig: Decodable { + let version: Int? + let fallback: ToolDisplaySpec? + let tools: [String: ToolDisplaySpec]? + } + + private static let config: ToolDisplayConfig = loadConfig() + + public static func resolve(name: String?, args: AnyCodable?, meta: String? = nil) -> ToolDisplaySummary { + let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "tool" + let key = trimmedName.lowercased() + let spec = self.config.tools?[key] + let fallback = self.config.fallback + + let emoji = spec?.emoji ?? fallback?.emoji ?? "🧩" + let title = spec?.title ?? self.titleFromName(trimmedName) + let label = spec?.label ?? trimmedName + + let actionRaw = self.valueForKeyPath(args, path: "action") as? String + let action = actionRaw?.trimmingCharacters(in: .whitespacesAndNewlines) + let actionSpec = action.flatMap { spec?.actions?[$0] } + let verb = self.normalizeVerb(actionSpec?.label ?? action) + + var detail: String? + if key == "read" { + detail = self.readDetail(args) + } else if key == "write" || key == "edit" || key == "attach" { + detail = self.pathDetail(args) + } + + let detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? fallback?.detailKeys ?? [] + if detail == nil { + detail = self.firstValue(args, keys: detailKeys) + } + + if detail == nil { + detail = meta + } + + if let detailValue = detail { + detail = self.shortenHomeInString(detailValue) + } + + return ToolDisplaySummary( + name: trimmedName, + emoji: emoji, + title: title, + label: label, + verb: verb, + detail: detail) + } + + private static func loadConfig() -> ToolDisplayConfig { + guard let url = OpenClawKitResources.bundle.url(forResource: "tool-display", withExtension: "json") else { + return self.defaultConfig() + } + do { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(ToolDisplayConfig.self, from: data) + } catch { + return self.defaultConfig() + } + } + + private static func defaultConfig() -> ToolDisplayConfig { + ToolDisplayConfig( + version: 1, + fallback: ToolDisplaySpec( + emoji: "🧩", + title: nil, + label: nil, + detailKeys: [ + "command", + "path", + "url", + "targetUrl", + "targetId", + "ref", + "element", + "node", + "nodeId", + "id", + "requestId", + "to", + "channelId", + "guildId", + "userId", + "name", + "query", + "pattern", + "messageId", + ], + actions: nil), + tools: [ + "bash": ToolDisplaySpec( + emoji: "🛠️", + title: "Bash", + label: nil, + detailKeys: ["command"], + actions: nil), + "read": ToolDisplaySpec( + emoji: "📖", + title: "Read", + label: nil, + detailKeys: ["path"], + actions: nil), + "write": ToolDisplaySpec( + emoji: "✍️", + title: "Write", + label: nil, + detailKeys: ["path"], + actions: nil), + "edit": ToolDisplaySpec( + emoji: "📝", + title: "Edit", + label: nil, + detailKeys: ["path"], + actions: nil), + "attach": ToolDisplaySpec( + emoji: "📎", + title: "Attach", + label: nil, + detailKeys: ["path", "url", "fileName"], + actions: nil), + "process": ToolDisplaySpec( + emoji: "🧰", + title: "Process", + label: nil, + detailKeys: ["sessionId"], + actions: nil), + ]) + } + + private static func titleFromName(_ name: String) -> String { + let cleaned = name.replacingOccurrences(of: "_", with: " ").trimmingCharacters(in: .whitespaces) + guard !cleaned.isEmpty else { return "Tool" } + return cleaned + .split(separator: " ") + .map { part in + let upper = part.uppercased() + if part.count <= 2, part == upper { return String(part) } + return String(upper.prefix(1)) + String(part.lowercased().dropFirst()) + } + .joined(separator: " ") + } + + private static func normalizeVerb(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + return trimmed.replacingOccurrences(of: "_", with: " ") + } + + private static func readDetail(_ args: AnyCodable?) -> String? { + guard let path = valueForKeyPath(args, path: "path") as? String else { return nil } + let offsetAny = self.valueForKeyPath(args, path: "offset") + let limitAny = self.valueForKeyPath(args, path: "limit") + let offset = (offsetAny as? Double) ?? (offsetAny as? Int).map(Double.init) + let limit = (limitAny as? Double) ?? (limitAny as? Int).map(Double.init) + if let offset, let limit { + let end = offset + limit + return "\(path):\(Int(offset))-\(Int(end))" + } + return path + } + + private static func pathDetail(_ args: AnyCodable?) -> String? { + self.valueForKeyPath(args, path: "path") as? String + } + + private static func firstValue(_ args: AnyCodable?, keys: [String]) -> String? { + for key in keys { + if let value = valueForKeyPath(args, path: key), + let rendered = renderValue(value) + { + return rendered + } + } + return nil + } + + private static func renderValue(_ value: Any) -> String? { + if let str = value as? String { + let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let first = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed + if first.count > 160 { return String(first.prefix(157)) + "…" } + return first + } + if let num = value as? Int { return String(num) } + if let num = value as? Double { return String(num) } + if let bool = value as? Bool { return bool ? "true" : "false" } + if let array = value as? [Any] { + let items = array.compactMap { self.renderValue($0) } + guard !items.isEmpty else { return nil } + let preview = items.prefix(3).joined(separator: ", ") + return items.count > 3 ? "\(preview)…" : preview + } + if let dict = value as? [String: Any] { + if let label = dict["name"].flatMap({ renderValue($0) }) { return label } + if let label = dict["id"].flatMap({ renderValue($0) }) { return label } + } + return nil + } + + private static func valueForKeyPath(_ args: AnyCodable?, path: String) -> Any? { + guard let args else { return nil } + let parts = path.split(separator: ".").map(String.init) + var current: Any? = args.value + for part in parts { + if let dict = current as? [String: AnyCodable] { + current = dict[part]?.value + } else if let dict = current as? [String: Any] { + current = dict[part] + } else { + return nil + } + } + return current + } + + private static func shortenHomeInString(_ value: String) -> String { + let home = NSHomeDirectory() + guard !home.isEmpty else { return value } + return value.replacingOccurrences(of: home, with: "~") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift new file mode 100644 index 00000000..0bd69907 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift @@ -0,0 +1,95 @@ +import Foundation + +public enum OpenClawWatchCommand: String, Codable, Sendable { + case status = "watch.status" + case notify = "watch.notify" +} + +public enum OpenClawWatchRisk: String, Codable, Sendable, Equatable { + case low + case medium + case high +} + +public struct OpenClawWatchAction: Codable, Sendable, Equatable { + public var id: String + public var label: String + public var style: String? + + public init(id: String, label: String, style: String? = nil) { + self.id = id + self.label = label + self.style = style + } +} + +public struct OpenClawWatchStatusPayload: Codable, Sendable, Equatable { + public var supported: Bool + public var paired: Bool + public var appInstalled: Bool + public var reachable: Bool + public var activationState: String + + public init( + supported: Bool, + paired: Bool, + appInstalled: Bool, + reachable: Bool, + activationState: String) + { + self.supported = supported + self.paired = paired + self.appInstalled = appInstalled + self.reachable = reachable + self.activationState = activationState + } +} + +public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable { + public var title: String + public var body: String + public var priority: OpenClawNotificationPriority? + public var promptId: String? + public var sessionKey: String? + public var kind: String? + public var details: String? + public var expiresAtMs: Int? + public var risk: OpenClawWatchRisk? + public var actions: [OpenClawWatchAction]? + + public init( + title: String, + body: String, + priority: OpenClawNotificationPriority? = nil, + promptId: String? = nil, + sessionKey: String? = nil, + kind: String? = nil, + details: String? = nil, + expiresAtMs: Int? = nil, + risk: OpenClawWatchRisk? = nil, + actions: [OpenClawWatchAction]? = nil) + { + self.title = title + self.body = body + self.priority = priority + self.promptId = promptId + self.sessionKey = sessionKey + self.kind = kind + self.details = details + self.expiresAtMs = expiresAtMs + self.risk = risk + self.actions = actions + } +} + +public struct OpenClawWatchNotifyPayload: Codable, Sendable, Equatable { + public var deliveredImmediately: Bool + public var queuedForDelivery: Bool + public var transport: String + + public init(deliveredImmediately: Bool, queuedForDelivery: Bool, transport: String) { + self.deliveredImmediately = deliveredImmediately + self.queuedForDelivery = queuedForDelivery + self.transport = transport + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift new file mode 100644 index 00000000..4315bb07 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift @@ -0,0 +1,104 @@ +import Foundation + +/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads. +/// +/// Marked `@unchecked Sendable` because it can hold reference types. +public struct AnyCodable: Codable, @unchecked Sendable, Hashable { + public let value: Any + + public init(_ value: Any) { self.value = Self.normalize(value) } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return } + if let intVal = try? container.decode(Int.self) { self.value = intVal; return } + if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return } + if let stringVal = try? container.decode(String.self) { self.value = stringVal; return } + if container.decodeNil() { self.value = NSNull(); return } + if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return } + if let array = try? container.decode([AnyCodable].self) { self.value = array; return } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type") + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self.value { + case let boolVal as Bool: try container.encode(boolVal) + case let intVal as Int: try container.encode(intVal) + case let doubleVal as Double: try container.encode(doubleVal) + case let stringVal as String: try container.encode(stringVal) + case let number as NSNumber where CFGetTypeID(number) == CFBooleanGetTypeID(): + try container.encode(number.boolValue) + case is NSNull: try container.encodeNil() + case let dict as [String: AnyCodable]: try container.encode(dict) + case let array as [AnyCodable]: try container.encode(array) + case let dict as [String: Any]: + try container.encode(dict.mapValues { AnyCodable($0) }) + case let array as [Any]: + try container.encode(array.map { AnyCodable($0) }) + case let dict as NSDictionary: + var converted: [String: AnyCodable] = [:] + for (k, v) in dict { + guard let key = k as? String else { continue } + converted[key] = AnyCodable(v) + } + try container.encode(converted) + case let array as NSArray: + try container.encode(array.map { AnyCodable($0) }) + default: + let context = EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Unsupported type") + throw EncodingError.invalidValue(self.value, context) + } + } + + private static func normalize(_ value: Any) -> Any { + if let number = value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue + } + return value + } + + public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { + switch (lhs.value, rhs.value) { + case let (l as Bool, r as Bool): l == r + case let (l as Int, r as Int): l == r + case let (l as Double, r as Double): l == r + case let (l as String, r as String): l == r + case (_ as NSNull, _ as NSNull): true + case let (l as [String: AnyCodable], r as [String: AnyCodable]): l == r + case let (l as [AnyCodable], r as [AnyCodable]): l == r + default: + false + } + } + + public func hash(into hasher: inout Hasher) { + switch self.value { + case let v as Bool: + hasher.combine(2); hasher.combine(v) + case let v as Int: + hasher.combine(0); hasher.combine(v) + case let v as Double: + hasher.combine(1); hasher.combine(v) + case let v as String: + hasher.combine(3); hasher.combine(v) + case _ as NSNull: + hasher.combine(4) + case let v as [String: AnyCodable]: + hasher.combine(5) + for (k, val) in v.sorted(by: { $0.key < $1.key }) { + hasher.combine(k) + hasher.combine(val) + } + case let v as [AnyCodable]: + hasher.combine(6) + for item in v { + hasher.combine(item) + } + default: + hasher.combine(999) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift new file mode 100644 index 00000000..4e766514 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -0,0 +1,3293 @@ +// Generated by scripts/protocol-gen-swift.ts — do not edit by hand +// swiftlint:disable file_length +import Foundation + +public let GATEWAY_PROTOCOL_VERSION = 3 + +public enum ErrorCode: String, Codable, Sendable { + case notLinked = "NOT_LINKED" + case notPaired = "NOT_PAIRED" + case agentTimeout = "AGENT_TIMEOUT" + case invalidRequest = "INVALID_REQUEST" + case unavailable = "UNAVAILABLE" +} + +public struct ConnectParams: Codable, Sendable { + public let minprotocol: Int + public let maxprotocol: Int + public let client: [String: AnyCodable] + public let caps: [String]? + public let commands: [String]? + public let permissions: [String: AnyCodable]? + public let pathenv: String? + public let role: String? + public let scopes: [String]? + public let device: [String: AnyCodable]? + public let auth: [String: AnyCodable]? + public let locale: String? + public let useragent: String? + + public init( + minprotocol: Int, + maxprotocol: Int, + client: [String: AnyCodable], + caps: [String]?, + commands: [String]?, + permissions: [String: AnyCodable]?, + pathenv: String?, + role: String?, + scopes: [String]?, + device: [String: AnyCodable]?, + auth: [String: AnyCodable]?, + locale: String?, + useragent: String?) + { + self.minprotocol = minprotocol + self.maxprotocol = maxprotocol + self.client = client + self.caps = caps + self.commands = commands + self.permissions = permissions + self.pathenv = pathenv + self.role = role + self.scopes = scopes + self.device = device + self.auth = auth + self.locale = locale + self.useragent = useragent + } + + private enum CodingKeys: String, CodingKey { + case minprotocol = "minProtocol" + case maxprotocol = "maxProtocol" + case client + case caps + case commands + case permissions + case pathenv = "pathEnv" + case role + case scopes + case device + case auth + case locale + case useragent = "userAgent" + } +} + +public struct HelloOk: Codable, Sendable { + public let type: String + public let _protocol: Int + public let server: [String: AnyCodable] + public let features: [String: AnyCodable] + public let snapshot: Snapshot + public let canvashosturl: String? + public let auth: [String: AnyCodable]? + public let policy: [String: AnyCodable] + + public init( + type: String, + _protocol: Int, + server: [String: AnyCodable], + features: [String: AnyCodable], + snapshot: Snapshot, + canvashosturl: String?, + auth: [String: AnyCodable]?, + policy: [String: AnyCodable]) + { + self.type = type + self._protocol = _protocol + self.server = server + self.features = features + self.snapshot = snapshot + self.canvashosturl = canvashosturl + self.auth = auth + self.policy = policy + } + + private enum CodingKeys: String, CodingKey { + case type + case _protocol = "protocol" + case server + case features + case snapshot + case canvashosturl = "canvasHostUrl" + case auth + case policy + } +} + +public struct RequestFrame: Codable, Sendable { + public let type: String + public let id: String + public let method: String + public let params: AnyCodable? + + public init( + type: String, + id: String, + method: String, + params: AnyCodable?) + { + self.type = type + self.id = id + self.method = method + self.params = params + } + + private enum CodingKeys: String, CodingKey { + case type + case id + case method + case params + } +} + +public struct ResponseFrame: Codable, Sendable { + public let type: String + public let id: String + public let ok: Bool + public let payload: AnyCodable? + public let error: [String: AnyCodable]? + + public init( + type: String, + id: String, + ok: Bool, + payload: AnyCodable?, + error: [String: AnyCodable]?) + { + self.type = type + self.id = id + self.ok = ok + self.payload = payload + self.error = error + } + + private enum CodingKeys: String, CodingKey { + case type + case id + case ok + case payload + case error + } +} + +public struct EventFrame: Codable, Sendable { + public let type: String + public let event: String + public let payload: AnyCodable? + public let seq: Int? + public let stateversion: [String: AnyCodable]? + + public init( + type: String, + event: String, + payload: AnyCodable?, + seq: Int?, + stateversion: [String: AnyCodable]?) + { + self.type = type + self.event = event + self.payload = payload + self.seq = seq + self.stateversion = stateversion + } + + private enum CodingKeys: String, CodingKey { + case type + case event + case payload + case seq + case stateversion = "stateVersion" + } +} + +public struct PresenceEntry: Codable, Sendable { + public let host: String? + public let ip: String? + public let version: String? + public let platform: String? + public let devicefamily: String? + public let modelidentifier: String? + public let mode: String? + public let lastinputseconds: Int? + public let reason: String? + public let tags: [String]? + public let text: String? + public let ts: Int + public let deviceid: String? + public let roles: [String]? + public let scopes: [String]? + public let instanceid: String? + + public init( + host: String?, + ip: String?, + version: String?, + platform: String?, + devicefamily: String?, + modelidentifier: String?, + mode: String?, + lastinputseconds: Int?, + reason: String?, + tags: [String]?, + text: String?, + ts: Int, + deviceid: String?, + roles: [String]?, + scopes: [String]?, + instanceid: String?) + { + self.host = host + self.ip = ip + self.version = version + self.platform = platform + self.devicefamily = devicefamily + self.modelidentifier = modelidentifier + self.mode = mode + self.lastinputseconds = lastinputseconds + self.reason = reason + self.tags = tags + self.text = text + self.ts = ts + self.deviceid = deviceid + self.roles = roles + self.scopes = scopes + self.instanceid = instanceid + } + + private enum CodingKeys: String, CodingKey { + case host + case ip + case version + case platform + case devicefamily = "deviceFamily" + case modelidentifier = "modelIdentifier" + case mode + case lastinputseconds = "lastInputSeconds" + case reason + case tags + case text + case ts + case deviceid = "deviceId" + case roles + case scopes + case instanceid = "instanceId" + } +} + +public struct StateVersion: Codable, Sendable { + public let presence: Int + public let health: Int + + public init( + presence: Int, + health: Int) + { + self.presence = presence + self.health = health + } + + private enum CodingKeys: String, CodingKey { + case presence + case health + } +} + +public struct Snapshot: Codable, Sendable { + public let presence: [PresenceEntry] + public let health: AnyCodable + public let stateversion: StateVersion + public let uptimems: Int + public let configpath: String? + public let statedir: String? + public let sessiondefaults: [String: AnyCodable]? + public let authmode: AnyCodable? + public let updateavailable: [String: AnyCodable]? + + public init( + presence: [PresenceEntry], + health: AnyCodable, + stateversion: StateVersion, + uptimems: Int, + configpath: String?, + statedir: String?, + sessiondefaults: [String: AnyCodable]?, + authmode: AnyCodable?, + updateavailable: [String: AnyCodable]?) + { + self.presence = presence + self.health = health + self.stateversion = stateversion + self.uptimems = uptimems + self.configpath = configpath + self.statedir = statedir + self.sessiondefaults = sessiondefaults + self.authmode = authmode + self.updateavailable = updateavailable + } + + private enum CodingKeys: String, CodingKey { + case presence + case health + case stateversion = "stateVersion" + case uptimems = "uptimeMs" + case configpath = "configPath" + case statedir = "stateDir" + case sessiondefaults = "sessionDefaults" + case authmode = "authMode" + case updateavailable = "updateAvailable" + } +} + +public struct ErrorShape: Codable, Sendable { + public let code: String + public let message: String + public let details: AnyCodable? + public let retryable: Bool? + public let retryafterms: Int? + + public init( + code: String, + message: String, + details: AnyCodable?, + retryable: Bool?, + retryafterms: Int?) + { + self.code = code + self.message = message + self.details = details + self.retryable = retryable + self.retryafterms = retryafterms + } + + private enum CodingKeys: String, CodingKey { + case code + case message + case details + case retryable + case retryafterms = "retryAfterMs" + } +} + +public struct AgentEvent: Codable, Sendable { + public let runid: String + public let seq: Int + public let stream: String + public let ts: Int + public let data: [String: AnyCodable] + + public init( + runid: String, + seq: Int, + stream: String, + ts: Int, + data: [String: AnyCodable]) + { + self.runid = runid + self.seq = seq + self.stream = stream + self.ts = ts + self.data = data + } + + private enum CodingKeys: String, CodingKey { + case runid = "runId" + case seq + case stream + case ts + case data + } +} + +public struct SendParams: Codable, Sendable { + public let to: String + public let message: String? + public let mediaurl: String? + public let mediaurls: [String]? + public let gifplayback: Bool? + public let channel: String? + public let accountid: String? + public let threadid: String? + public let sessionkey: String? + public let idempotencykey: String + + public init( + to: String, + message: String?, + mediaurl: String?, + mediaurls: [String]?, + gifplayback: Bool?, + channel: String?, + accountid: String?, + threadid: String?, + sessionkey: String?, + idempotencykey: String) + { + self.to = to + self.message = message + self.mediaurl = mediaurl + self.mediaurls = mediaurls + self.gifplayback = gifplayback + self.channel = channel + self.accountid = accountid + self.threadid = threadid + self.sessionkey = sessionkey + self.idempotencykey = idempotencykey + } + + private enum CodingKeys: String, CodingKey { + case to + case message + case mediaurl = "mediaUrl" + case mediaurls = "mediaUrls" + case gifplayback = "gifPlayback" + case channel + case accountid = "accountId" + case threadid = "threadId" + case sessionkey = "sessionKey" + case idempotencykey = "idempotencyKey" + } +} + +public struct PollParams: Codable, Sendable { + public let to: String + public let question: String + public let options: [String] + public let maxselections: Int? + public let durationseconds: Int? + public let durationhours: Int? + public let silent: Bool? + public let isanonymous: Bool? + public let threadid: String? + public let channel: String? + public let accountid: String? + public let idempotencykey: String + + public init( + to: String, + question: String, + options: [String], + maxselections: Int?, + durationseconds: Int?, + durationhours: Int?, + silent: Bool?, + isanonymous: Bool?, + threadid: String?, + channel: String?, + accountid: String?, + idempotencykey: String) + { + self.to = to + self.question = question + self.options = options + self.maxselections = maxselections + self.durationseconds = durationseconds + self.durationhours = durationhours + self.silent = silent + self.isanonymous = isanonymous + self.threadid = threadid + self.channel = channel + self.accountid = accountid + self.idempotencykey = idempotencykey + } + + private enum CodingKeys: String, CodingKey { + case to + case question + case options + case maxselections = "maxSelections" + case durationseconds = "durationSeconds" + case durationhours = "durationHours" + case silent + case isanonymous = "isAnonymous" + case threadid = "threadId" + case channel + case accountid = "accountId" + case idempotencykey = "idempotencyKey" + } +} + +public struct AgentParams: Codable, Sendable { + public let message: String + public let agentid: String? + public let to: String? + public let replyto: String? + public let sessionid: String? + public let sessionkey: String? + public let thinking: String? + public let deliver: Bool? + public let attachments: [AnyCodable]? + public let channel: String? + public let replychannel: String? + public let accountid: String? + public let replyaccountid: String? + public let threadid: String? + public let groupid: String? + public let groupchannel: String? + public let groupspace: String? + public let timeout: Int? + public let besteffortdeliver: Bool? + public let lane: String? + public let extrasystemprompt: String? + public let inputprovenance: [String: AnyCodable]? + public let idempotencykey: String + public let label: String? + public let spawnedby: String? + + public init( + message: String, + agentid: String?, + to: String?, + replyto: String?, + sessionid: String?, + sessionkey: String?, + thinking: String?, + deliver: Bool?, + attachments: [AnyCodable]?, + channel: String?, + replychannel: String?, + accountid: String?, + replyaccountid: String?, + threadid: String?, + groupid: String?, + groupchannel: String?, + groupspace: String?, + timeout: Int?, + besteffortdeliver: Bool?, + lane: String?, + extrasystemprompt: String?, + inputprovenance: [String: AnyCodable]?, + idempotencykey: String, + label: String?, + spawnedby: String?) + { + self.message = message + self.agentid = agentid + self.to = to + self.replyto = replyto + self.sessionid = sessionid + self.sessionkey = sessionkey + self.thinking = thinking + self.deliver = deliver + self.attachments = attachments + self.channel = channel + self.replychannel = replychannel + self.accountid = accountid + self.replyaccountid = replyaccountid + self.threadid = threadid + self.groupid = groupid + self.groupchannel = groupchannel + self.groupspace = groupspace + self.timeout = timeout + self.besteffortdeliver = besteffortdeliver + self.lane = lane + self.extrasystemprompt = extrasystemprompt + self.inputprovenance = inputprovenance + self.idempotencykey = idempotencykey + self.label = label + self.spawnedby = spawnedby + } + + private enum CodingKeys: String, CodingKey { + case message + case agentid = "agentId" + case to + case replyto = "replyTo" + case sessionid = "sessionId" + case sessionkey = "sessionKey" + case thinking + case deliver + case attachments + case channel + case replychannel = "replyChannel" + case accountid = "accountId" + case replyaccountid = "replyAccountId" + case threadid = "threadId" + case groupid = "groupId" + case groupchannel = "groupChannel" + case groupspace = "groupSpace" + case timeout + case besteffortdeliver = "bestEffortDeliver" + case lane + case extrasystemprompt = "extraSystemPrompt" + case inputprovenance = "inputProvenance" + case idempotencykey = "idempotencyKey" + case label + case spawnedby = "spawnedBy" + } +} + +public struct AgentIdentityParams: Codable, Sendable { + public let agentid: String? + public let sessionkey: String? + + public init( + agentid: String?, + sessionkey: String?) + { + self.agentid = agentid + self.sessionkey = sessionkey + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case sessionkey = "sessionKey" + } +} + +public struct AgentIdentityResult: Codable, Sendable { + public let agentid: String + public let name: String? + public let avatar: String? + public let emoji: String? + + public init( + agentid: String, + name: String?, + avatar: String?, + emoji: String?) + { + self.agentid = agentid + self.name = name + self.avatar = avatar + self.emoji = emoji + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case name + case avatar + case emoji + } +} + +public struct AgentWaitParams: Codable, Sendable { + public let runid: String + public let timeoutms: Int? + + public init( + runid: String, + timeoutms: Int?) + { + self.runid = runid + self.timeoutms = timeoutms + } + + private enum CodingKeys: String, CodingKey { + case runid = "runId" + case timeoutms = "timeoutMs" + } +} + +public struct WakeParams: Codable, Sendable { + public let mode: AnyCodable + public let text: String + + public init( + mode: AnyCodable, + text: String) + { + self.mode = mode + self.text = text + } + + private enum CodingKeys: String, CodingKey { + case mode + case text + } +} + +public struct NodePairRequestParams: Codable, Sendable { + public let nodeid: String + public let displayname: String? + public let platform: String? + public let version: String? + public let coreversion: String? + public let uiversion: String? + public let devicefamily: String? + public let modelidentifier: String? + public let caps: [String]? + public let commands: [String]? + public let remoteip: String? + public let silent: Bool? + + public init( + nodeid: String, + displayname: String?, + platform: String?, + version: String?, + coreversion: String?, + uiversion: String?, + devicefamily: String?, + modelidentifier: String?, + caps: [String]?, + commands: [String]?, + remoteip: String?, + silent: Bool?) + { + self.nodeid = nodeid + self.displayname = displayname + self.platform = platform + self.version = version + self.coreversion = coreversion + self.uiversion = uiversion + self.devicefamily = devicefamily + self.modelidentifier = modelidentifier + self.caps = caps + self.commands = commands + self.remoteip = remoteip + self.silent = silent + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case displayname = "displayName" + case platform + case version + case coreversion = "coreVersion" + case uiversion = "uiVersion" + case devicefamily = "deviceFamily" + case modelidentifier = "modelIdentifier" + case caps + case commands + case remoteip = "remoteIp" + case silent + } +} + +public struct NodePairListParams: Codable, Sendable {} + +public struct NodePairApproveParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String) + { + self.requestid = requestid + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct NodePairRejectParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String) + { + self.requestid = requestid + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct NodePairVerifyParams: Codable, Sendable { + public let nodeid: String + public let token: String + + public init( + nodeid: String, + token: String) + { + self.nodeid = nodeid + self.token = token + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case token + } +} + +public struct NodeRenameParams: Codable, Sendable { + public let nodeid: String + public let displayname: String + + public init( + nodeid: String, + displayname: String) + { + self.nodeid = nodeid + self.displayname = displayname + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case displayname = "displayName" + } +} + +public struct NodeListParams: Codable, Sendable {} + +public struct NodeDescribeParams: Codable, Sendable { + public let nodeid: String + + public init( + nodeid: String) + { + self.nodeid = nodeid + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + } +} + +public struct NodeInvokeParams: Codable, Sendable { + public let nodeid: String + public let command: String + public let params: AnyCodable? + public let timeoutms: Int? + public let idempotencykey: String + + public init( + nodeid: String, + command: String, + params: AnyCodable?, + timeoutms: Int?, + idempotencykey: String) + { + self.nodeid = nodeid + self.command = command + self.params = params + self.timeoutms = timeoutms + self.idempotencykey = idempotencykey + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case command + case params + case timeoutms = "timeoutMs" + case idempotencykey = "idempotencyKey" + } +} + +public struct NodeInvokeResultParams: Codable, Sendable { + public let id: String + public let nodeid: String + public let ok: Bool + public let payload: AnyCodable? + public let payloadjson: String? + public let error: [String: AnyCodable]? + + public init( + id: String, + nodeid: String, + ok: Bool, + payload: AnyCodable?, + payloadjson: String?, + error: [String: AnyCodable]?) + { + self.id = id + self.nodeid = nodeid + self.ok = ok + self.payload = payload + self.payloadjson = payloadjson + self.error = error + } + + private enum CodingKeys: String, CodingKey { + case id + case nodeid = "nodeId" + case ok + case payload + case payloadjson = "payloadJSON" + case error + } +} + +public struct NodeEventParams: Codable, Sendable { + public let event: String + public let payload: AnyCodable? + public let payloadjson: String? + + public init( + event: String, + payload: AnyCodable?, + payloadjson: String?) + { + self.event = event + self.payload = payload + self.payloadjson = payloadjson + } + + private enum CodingKeys: String, CodingKey { + case event + case payload + case payloadjson = "payloadJSON" + } +} + +public struct NodeInvokeRequestEvent: Codable, Sendable { + public let id: String + public let nodeid: String + public let command: String + public let paramsjson: String? + public let timeoutms: Int? + public let idempotencykey: String? + + public init( + id: String, + nodeid: String, + command: String, + paramsjson: String?, + timeoutms: Int?, + idempotencykey: String?) + { + self.id = id + self.nodeid = nodeid + self.command = command + self.paramsjson = paramsjson + self.timeoutms = timeoutms + self.idempotencykey = idempotencykey + } + + private enum CodingKeys: String, CodingKey { + case id + case nodeid = "nodeId" + case command + case paramsjson = "paramsJSON" + case timeoutms = "timeoutMs" + case idempotencykey = "idempotencyKey" + } +} + +public struct PushTestParams: Codable, Sendable { + public let nodeid: String + public let title: String? + public let body: String? + public let environment: String? + + public init( + nodeid: String, + title: String?, + body: String?, + environment: String?) + { + self.nodeid = nodeid + self.title = title + self.body = body + self.environment = environment + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case title + case body + case environment + } +} + +public struct PushTestResult: Codable, Sendable { + public let ok: Bool + public let status: Int + public let apnsid: String? + public let reason: String? + public let tokensuffix: String + public let topic: String + public let environment: String + + public init( + ok: Bool, + status: Int, + apnsid: String?, + reason: String?, + tokensuffix: String, + topic: String, + environment: String) + { + self.ok = ok + self.status = status + self.apnsid = apnsid + self.reason = reason + self.tokensuffix = tokensuffix + self.topic = topic + self.environment = environment + } + + private enum CodingKeys: String, CodingKey { + case ok + case status + case apnsid = "apnsId" + case reason + case tokensuffix = "tokenSuffix" + case topic + case environment + } +} + +public struct SessionsListParams: Codable, Sendable { + public let limit: Int? + public let activeminutes: Int? + public let includeglobal: Bool? + public let includeunknown: Bool? + public let includederivedtitles: Bool? + public let includelastmessage: Bool? + public let label: String? + public let spawnedby: String? + public let agentid: String? + public let search: String? + + public init( + limit: Int?, + activeminutes: Int?, + includeglobal: Bool?, + includeunknown: Bool?, + includederivedtitles: Bool?, + includelastmessage: Bool?, + label: String?, + spawnedby: String?, + agentid: String?, + search: String?) + { + self.limit = limit + self.activeminutes = activeminutes + self.includeglobal = includeglobal + self.includeunknown = includeunknown + self.includederivedtitles = includederivedtitles + self.includelastmessage = includelastmessage + self.label = label + self.spawnedby = spawnedby + self.agentid = agentid + self.search = search + } + + private enum CodingKeys: String, CodingKey { + case limit + case activeminutes = "activeMinutes" + case includeglobal = "includeGlobal" + case includeunknown = "includeUnknown" + case includederivedtitles = "includeDerivedTitles" + case includelastmessage = "includeLastMessage" + case label + case spawnedby = "spawnedBy" + case agentid = "agentId" + case search + } +} + +public struct SessionsPreviewParams: Codable, Sendable { + public let keys: [String] + public let limit: Int? + public let maxchars: Int? + + public init( + keys: [String], + limit: Int?, + maxchars: Int?) + { + self.keys = keys + self.limit = limit + self.maxchars = maxchars + } + + private enum CodingKeys: String, CodingKey { + case keys + case limit + case maxchars = "maxChars" + } +} + +public struct SessionsResolveParams: Codable, Sendable { + public let key: String? + public let sessionid: String? + public let label: String? + public let agentid: String? + public let spawnedby: String? + public let includeglobal: Bool? + public let includeunknown: Bool? + + public init( + key: String?, + sessionid: String?, + label: String?, + agentid: String?, + spawnedby: String?, + includeglobal: Bool?, + includeunknown: Bool?) + { + self.key = key + self.sessionid = sessionid + self.label = label + self.agentid = agentid + self.spawnedby = spawnedby + self.includeglobal = includeglobal + self.includeunknown = includeunknown + } + + private enum CodingKeys: String, CodingKey { + case key + case sessionid = "sessionId" + case label + case agentid = "agentId" + case spawnedby = "spawnedBy" + case includeglobal = "includeGlobal" + case includeunknown = "includeUnknown" + } +} + +public struct SessionsPatchParams: Codable, Sendable { + public let key: String + public let label: AnyCodable? + public let thinkinglevel: AnyCodable? + public let verboselevel: AnyCodable? + public let reasoninglevel: AnyCodable? + public let responseusage: AnyCodable? + public let elevatedlevel: AnyCodable? + public let exechost: AnyCodable? + public let execsecurity: AnyCodable? + public let execask: AnyCodable? + public let execnode: AnyCodable? + public let model: AnyCodable? + public let spawnedby: AnyCodable? + public let spawndepth: AnyCodable? + public let sendpolicy: AnyCodable? + public let groupactivation: AnyCodable? + + public init( + key: String, + label: AnyCodable?, + thinkinglevel: AnyCodable?, + verboselevel: AnyCodable?, + reasoninglevel: AnyCodable?, + responseusage: AnyCodable?, + elevatedlevel: AnyCodable?, + exechost: AnyCodable?, + execsecurity: AnyCodable?, + execask: AnyCodable?, + execnode: AnyCodable?, + model: AnyCodable?, + spawnedby: AnyCodable?, + spawndepth: AnyCodable?, + sendpolicy: AnyCodable?, + groupactivation: AnyCodable?) + { + self.key = key + self.label = label + self.thinkinglevel = thinkinglevel + self.verboselevel = verboselevel + self.reasoninglevel = reasoninglevel + self.responseusage = responseusage + self.elevatedlevel = elevatedlevel + self.exechost = exechost + self.execsecurity = execsecurity + self.execask = execask + self.execnode = execnode + self.model = model + self.spawnedby = spawnedby + self.spawndepth = spawndepth + self.sendpolicy = sendpolicy + self.groupactivation = groupactivation + } + + private enum CodingKeys: String, CodingKey { + case key + case label + case thinkinglevel = "thinkingLevel" + case verboselevel = "verboseLevel" + case reasoninglevel = "reasoningLevel" + case responseusage = "responseUsage" + case elevatedlevel = "elevatedLevel" + case exechost = "execHost" + case execsecurity = "execSecurity" + case execask = "execAsk" + case execnode = "execNode" + case model + case spawnedby = "spawnedBy" + case spawndepth = "spawnDepth" + case sendpolicy = "sendPolicy" + case groupactivation = "groupActivation" + } +} + +public struct SessionsResetParams: Codable, Sendable { + public let key: String + public let reason: AnyCodable? + + public init( + key: String, + reason: AnyCodable?) + { + self.key = key + self.reason = reason + } + + private enum CodingKeys: String, CodingKey { + case key + case reason + } +} + +public struct SessionsDeleteParams: Codable, Sendable { + public let key: String + public let deletetranscript: Bool? + public let emitlifecyclehooks: Bool? + + public init( + key: String, + deletetranscript: Bool?, + emitlifecyclehooks: Bool?) + { + self.key = key + self.deletetranscript = deletetranscript + self.emitlifecyclehooks = emitlifecyclehooks + } + + private enum CodingKeys: String, CodingKey { + case key + case deletetranscript = "deleteTranscript" + case emitlifecyclehooks = "emitLifecycleHooks" + } +} + +public struct SessionsCompactParams: Codable, Sendable { + public let key: String + public let maxlines: Int? + + public init( + key: String, + maxlines: Int?) + { + self.key = key + self.maxlines = maxlines + } + + private enum CodingKeys: String, CodingKey { + case key + case maxlines = "maxLines" + } +} + +public struct SessionsUsageParams: Codable, Sendable { + public let key: String? + public let startdate: String? + public let enddate: String? + public let mode: AnyCodable? + public let utcoffset: String? + public let limit: Int? + public let includecontextweight: Bool? + + public init( + key: String?, + startdate: String?, + enddate: String?, + mode: AnyCodable?, + utcoffset: String?, + limit: Int?, + includecontextweight: Bool?) + { + self.key = key + self.startdate = startdate + self.enddate = enddate + self.mode = mode + self.utcoffset = utcoffset + self.limit = limit + self.includecontextweight = includecontextweight + } + + private enum CodingKeys: String, CodingKey { + case key + case startdate = "startDate" + case enddate = "endDate" + case mode + case utcoffset = "utcOffset" + case limit + case includecontextweight = "includeContextWeight" + } +} + +public struct ConfigGetParams: Codable, Sendable {} + +public struct ConfigSetParams: Codable, Sendable { + public let raw: String + public let basehash: String? + + public init( + raw: String, + basehash: String?) + { + self.raw = raw + self.basehash = basehash + } + + private enum CodingKeys: String, CodingKey { + case raw + case basehash = "baseHash" + } +} + +public struct ConfigApplyParams: Codable, Sendable { + public let raw: String + public let basehash: String? + public let sessionkey: String? + public let note: String? + public let restartdelayms: Int? + + public init( + raw: String, + basehash: String?, + sessionkey: String?, + note: String?, + restartdelayms: Int?) + { + self.raw = raw + self.basehash = basehash + self.sessionkey = sessionkey + self.note = note + self.restartdelayms = restartdelayms + } + + private enum CodingKeys: String, CodingKey { + case raw + case basehash = "baseHash" + case sessionkey = "sessionKey" + case note + case restartdelayms = "restartDelayMs" + } +} + +public struct ConfigPatchParams: Codable, Sendable { + public let raw: String + public let basehash: String? + public let sessionkey: String? + public let note: String? + public let restartdelayms: Int? + + public init( + raw: String, + basehash: String?, + sessionkey: String?, + note: String?, + restartdelayms: Int?) + { + self.raw = raw + self.basehash = basehash + self.sessionkey = sessionkey + self.note = note + self.restartdelayms = restartdelayms + } + + private enum CodingKeys: String, CodingKey { + case raw + case basehash = "baseHash" + case sessionkey = "sessionKey" + case note + case restartdelayms = "restartDelayMs" + } +} + +public struct ConfigSchemaParams: Codable, Sendable {} + +public struct ConfigSchemaResponse: Codable, Sendable { + public let schema: AnyCodable + public let uihints: [String: AnyCodable] + public let version: String + public let generatedat: String + + public init( + schema: AnyCodable, + uihints: [String: AnyCodable], + version: String, + generatedat: String) + { + self.schema = schema + self.uihints = uihints + self.version = version + self.generatedat = generatedat + } + + private enum CodingKeys: String, CodingKey { + case schema + case uihints = "uiHints" + case version + case generatedat = "generatedAt" + } +} + +public struct WizardStartParams: Codable, Sendable { + public let mode: AnyCodable? + public let workspace: String? + + public init( + mode: AnyCodable?, + workspace: String?) + { + self.mode = mode + self.workspace = workspace + } + + private enum CodingKeys: String, CodingKey { + case mode + case workspace + } +} + +public struct WizardNextParams: Codable, Sendable { + public let sessionid: String + public let answer: [String: AnyCodable]? + + public init( + sessionid: String, + answer: [String: AnyCodable]?) + { + self.sessionid = sessionid + self.answer = answer + } + + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + case answer + } +} + +public struct WizardCancelParams: Codable, Sendable { + public let sessionid: String + + public init( + sessionid: String) + { + self.sessionid = sessionid + } + + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + } +} + +public struct WizardStatusParams: Codable, Sendable { + public let sessionid: String + + public init( + sessionid: String) + { + self.sessionid = sessionid + } + + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + } +} + +public struct WizardStep: Codable, Sendable { + public let id: String + public let type: AnyCodable + public let title: String? + public let message: String? + public let options: [[String: AnyCodable]]? + public let initialvalue: AnyCodable? + public let placeholder: String? + public let sensitive: Bool? + public let executor: AnyCodable? + + public init( + id: String, + type: AnyCodable, + title: String?, + message: String?, + options: [[String: AnyCodable]]?, + initialvalue: AnyCodable?, + placeholder: String?, + sensitive: Bool?, + executor: AnyCodable?) + { + self.id = id + self.type = type + self.title = title + self.message = message + self.options = options + self.initialvalue = initialvalue + self.placeholder = placeholder + self.sensitive = sensitive + self.executor = executor + } + + private enum CodingKeys: String, CodingKey { + case id + case type + case title + case message + case options + case initialvalue = "initialValue" + case placeholder + case sensitive + case executor + } +} + +public struct WizardNextResult: Codable, Sendable { + public let done: Bool + public let step: [String: AnyCodable]? + public let status: AnyCodable? + public let error: String? + + public init( + done: Bool, + step: [String: AnyCodable]?, + status: AnyCodable?, + error: String?) + { + self.done = done + self.step = step + self.status = status + self.error = error + } + + private enum CodingKeys: String, CodingKey { + case done + case step + case status + case error + } +} + +public struct WizardStartResult: Codable, Sendable { + public let sessionid: String + public let done: Bool + public let step: [String: AnyCodable]? + public let status: AnyCodable? + public let error: String? + + public init( + sessionid: String, + done: Bool, + step: [String: AnyCodable]?, + status: AnyCodable?, + error: String?) + { + self.sessionid = sessionid + self.done = done + self.step = step + self.status = status + self.error = error + } + + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + case done + case step + case status + case error + } +} + +public struct WizardStatusResult: Codable, Sendable { + public let status: AnyCodable + public let error: String? + + public init( + status: AnyCodable, + error: String?) + { + self.status = status + self.error = error + } + + private enum CodingKeys: String, CodingKey { + case status + case error + } +} + +public struct TalkModeParams: Codable, Sendable { + public let enabled: Bool + public let phase: String? + + public init( + enabled: Bool, + phase: String?) + { + self.enabled = enabled + self.phase = phase + } + + private enum CodingKeys: String, CodingKey { + case enabled + case phase + } +} + +public struct TalkConfigParams: Codable, Sendable { + public let includesecrets: Bool? + + public init( + includesecrets: Bool?) + { + self.includesecrets = includesecrets + } + + private enum CodingKeys: String, CodingKey { + case includesecrets = "includeSecrets" + } +} + +public struct TalkConfigResult: Codable, Sendable { + public let config: [String: AnyCodable] + + public init( + config: [String: AnyCodable]) + { + self.config = config + } + + private enum CodingKeys: String, CodingKey { + case config + } +} + +public struct ChannelsStatusParams: Codable, Sendable { + public let probe: Bool? + public let timeoutms: Int? + + public init( + probe: Bool?, + timeoutms: Int?) + { + self.probe = probe + self.timeoutms = timeoutms + } + + private enum CodingKeys: String, CodingKey { + case probe + case timeoutms = "timeoutMs" + } +} + +public struct ChannelsStatusResult: Codable, Sendable { + public let ts: Int + public let channelorder: [String] + public let channellabels: [String: AnyCodable] + public let channeldetaillabels: [String: AnyCodable]? + public let channelsystemimages: [String: AnyCodable]? + public let channelmeta: [[String: AnyCodable]]? + public let channels: [String: AnyCodable] + public let channelaccounts: [String: AnyCodable] + public let channeldefaultaccountid: [String: AnyCodable] + + public init( + ts: Int, + channelorder: [String], + channellabels: [String: AnyCodable], + channeldetaillabels: [String: AnyCodable]?, + channelsystemimages: [String: AnyCodable]?, + channelmeta: [[String: AnyCodable]]?, + channels: [String: AnyCodable], + channelaccounts: [String: AnyCodable], + channeldefaultaccountid: [String: AnyCodable]) + { + self.ts = ts + self.channelorder = channelorder + self.channellabels = channellabels + self.channeldetaillabels = channeldetaillabels + self.channelsystemimages = channelsystemimages + self.channelmeta = channelmeta + self.channels = channels + self.channelaccounts = channelaccounts + self.channeldefaultaccountid = channeldefaultaccountid + } + + private enum CodingKeys: String, CodingKey { + case ts + case channelorder = "channelOrder" + case channellabels = "channelLabels" + case channeldetaillabels = "channelDetailLabels" + case channelsystemimages = "channelSystemImages" + case channelmeta = "channelMeta" + case channels + case channelaccounts = "channelAccounts" + case channeldefaultaccountid = "channelDefaultAccountId" + } +} + +public struct ChannelsLogoutParams: Codable, Sendable { + public let channel: String + public let accountid: String? + + public init( + channel: String, + accountid: String?) + { + self.channel = channel + self.accountid = accountid + } + + private enum CodingKeys: String, CodingKey { + case channel + case accountid = "accountId" + } +} + +public struct WebLoginStartParams: Codable, Sendable { + public let force: Bool? + public let timeoutms: Int? + public let verbose: Bool? + public let accountid: String? + + public init( + force: Bool?, + timeoutms: Int?, + verbose: Bool?, + accountid: String?) + { + self.force = force + self.timeoutms = timeoutms + self.verbose = verbose + self.accountid = accountid + } + + private enum CodingKeys: String, CodingKey { + case force + case timeoutms = "timeoutMs" + case verbose + case accountid = "accountId" + } +} + +public struct WebLoginWaitParams: Codable, Sendable { + public let timeoutms: Int? + public let accountid: String? + + public init( + timeoutms: Int?, + accountid: String?) + { + self.timeoutms = timeoutms + self.accountid = accountid + } + + private enum CodingKeys: String, CodingKey { + case timeoutms = "timeoutMs" + case accountid = "accountId" + } +} + +public struct AgentSummary: Codable, Sendable { + public let id: String + public let name: String? + public let identity: [String: AnyCodable]? + + public init( + id: String, + name: String?, + identity: [String: AnyCodable]?) + { + self.id = id + self.name = name + self.identity = identity + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case identity + } +} + +public struct AgentsCreateParams: Codable, Sendable { + public let name: String + public let workspace: String + public let emoji: String? + public let avatar: String? + + public init( + name: String, + workspace: String, + emoji: String?, + avatar: String?) + { + self.name = name + self.workspace = workspace + self.emoji = emoji + self.avatar = avatar + } + + private enum CodingKeys: String, CodingKey { + case name + case workspace + case emoji + case avatar + } +} + +public struct AgentsCreateResult: Codable, Sendable { + public let ok: Bool + public let agentid: String + public let name: String + public let workspace: String + + public init( + ok: Bool, + agentid: String, + name: String, + workspace: String) + { + self.ok = ok + self.agentid = agentid + self.name = name + self.workspace = workspace + } + + private enum CodingKeys: String, CodingKey { + case ok + case agentid = "agentId" + case name + case workspace + } +} + +public struct AgentsUpdateParams: Codable, Sendable { + public let agentid: String + public let name: String? + public let workspace: String? + public let model: String? + public let avatar: String? + + public init( + agentid: String, + name: String?, + workspace: String?, + model: String?, + avatar: String?) + { + self.agentid = agentid + self.name = name + self.workspace = workspace + self.model = model + self.avatar = avatar + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case name + case workspace + case model + case avatar + } +} + +public struct AgentsUpdateResult: Codable, Sendable { + public let ok: Bool + public let agentid: String + + public init( + ok: Bool, + agentid: String) + { + self.ok = ok + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case ok + case agentid = "agentId" + } +} + +public struct AgentsDeleteParams: Codable, Sendable { + public let agentid: String + public let deletefiles: Bool? + + public init( + agentid: String, + deletefiles: Bool?) + { + self.agentid = agentid + self.deletefiles = deletefiles + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case deletefiles = "deleteFiles" + } +} + +public struct AgentsDeleteResult: Codable, Sendable { + public let ok: Bool + public let agentid: String + public let removedbindings: Int + + public init( + ok: Bool, + agentid: String, + removedbindings: Int) + { + self.ok = ok + self.agentid = agentid + self.removedbindings = removedbindings + } + + private enum CodingKeys: String, CodingKey { + case ok + case agentid = "agentId" + case removedbindings = "removedBindings" + } +} + +public struct AgentsFileEntry: Codable, Sendable { + public let name: String + public let path: String + public let missing: Bool + public let size: Int? + public let updatedatms: Int? + public let content: String? + + public init( + name: String, + path: String, + missing: Bool, + size: Int?, + updatedatms: Int?, + content: String?) + { + self.name = name + self.path = path + self.missing = missing + self.size = size + self.updatedatms = updatedatms + self.content = content + } + + private enum CodingKeys: String, CodingKey { + case name + case path + case missing + case size + case updatedatms = "updatedAtMs" + case content + } +} + +public struct AgentsFilesListParams: Codable, Sendable { + public let agentid: String + + public init( + agentid: String) + { + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + } +} + +public struct AgentsFilesListResult: Codable, Sendable { + public let agentid: String + public let workspace: String + public let files: [AgentsFileEntry] + + public init( + agentid: String, + workspace: String, + files: [AgentsFileEntry]) + { + self.agentid = agentid + self.workspace = workspace + self.files = files + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case workspace + case files + } +} + +public struct AgentsFilesGetParams: Codable, Sendable { + public let agentid: String + public let name: String + + public init( + agentid: String, + name: String) + { + self.agentid = agentid + self.name = name + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case name + } +} + +public struct AgentsFilesGetResult: Codable, Sendable { + public let agentid: String + public let workspace: String + public let file: AgentsFileEntry + + public init( + agentid: String, + workspace: String, + file: AgentsFileEntry) + { + self.agentid = agentid + self.workspace = workspace + self.file = file + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case workspace + case file + } +} + +public struct AgentsFilesSetParams: Codable, Sendable { + public let agentid: String + public let name: String + public let content: String + + public init( + agentid: String, + name: String, + content: String) + { + self.agentid = agentid + self.name = name + self.content = content + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case name + case content + } +} + +public struct AgentsFilesSetResult: Codable, Sendable { + public let ok: Bool + public let agentid: String + public let workspace: String + public let file: AgentsFileEntry + + public init( + ok: Bool, + agentid: String, + workspace: String, + file: AgentsFileEntry) + { + self.ok = ok + self.agentid = agentid + self.workspace = workspace + self.file = file + } + + private enum CodingKeys: String, CodingKey { + case ok + case agentid = "agentId" + case workspace + case file + } +} + +public struct AgentsListParams: Codable, Sendable {} + +public struct AgentsListResult: Codable, Sendable { + public let defaultid: String + public let mainkey: String + public let scope: AnyCodable + public let agents: [AgentSummary] + + public init( + defaultid: String, + mainkey: String, + scope: AnyCodable, + agents: [AgentSummary]) + { + self.defaultid = defaultid + self.mainkey = mainkey + self.scope = scope + self.agents = agents + } + + private enum CodingKeys: String, CodingKey { + case defaultid = "defaultId" + case mainkey = "mainKey" + case scope + case agents + } +} + +public struct ModelChoice: Codable, Sendable { + public let id: String + public let name: String + public let provider: String + public let contextwindow: Int? + public let reasoning: Bool? + + public init( + id: String, + name: String, + provider: String, + contextwindow: Int?, + reasoning: Bool?) + { + self.id = id + self.name = name + self.provider = provider + self.contextwindow = contextwindow + self.reasoning = reasoning + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case provider + case contextwindow = "contextWindow" + case reasoning + } +} + +public struct ModelsListParams: Codable, Sendable {} + +public struct ModelsListResult: Codable, Sendable { + public let models: [ModelChoice] + + public init( + models: [ModelChoice]) + { + self.models = models + } + + private enum CodingKeys: String, CodingKey { + case models + } +} + +public struct SkillsStatusParams: Codable, Sendable { + public let agentid: String? + + public init( + agentid: String?) + { + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + } +} + +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } +} + +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + +public struct SkillsBinsParams: Codable, Sendable {} + +public struct SkillsBinsResult: Codable, Sendable { + public let bins: [String] + + public init( + bins: [String]) + { + self.bins = bins + } + + private enum CodingKeys: String, CodingKey { + case bins + } +} + +public struct SkillsInstallParams: Codable, Sendable { + public let name: String + public let installid: String + public let timeoutms: Int? + + public init( + name: String, + installid: String, + timeoutms: Int?) + { + self.name = name + self.installid = installid + self.timeoutms = timeoutms + } + + private enum CodingKeys: String, CodingKey { + case name + case installid = "installId" + case timeoutms = "timeoutMs" + } +} + +public struct SkillsUpdateParams: Codable, Sendable { + public let skillkey: String + public let enabled: Bool? + public let apikey: String? + public let env: [String: AnyCodable]? + + public init( + skillkey: String, + enabled: Bool?, + apikey: String?, + env: [String: AnyCodable]?) + { + self.skillkey = skillkey + self.enabled = enabled + self.apikey = apikey + self.env = env + } + + private enum CodingKeys: String, CodingKey { + case skillkey = "skillKey" + case enabled + case apikey = "apiKey" + case env + } +} + +public struct CronJob: Codable, Sendable { + public let id: String + public let agentid: String? + public let sessionkey: String? + public let name: String + public let description: String? + public let enabled: Bool + public let deleteafterrun: Bool? + public let createdatms: Int + public let updatedatms: Int + public let schedule: AnyCodable + public let sessiontarget: AnyCodable + public let wakemode: AnyCodable + public let payload: AnyCodable + public let delivery: AnyCodable? + public let state: [String: AnyCodable] + + public init( + id: String, + agentid: String?, + sessionkey: String?, + name: String, + description: String?, + enabled: Bool, + deleteafterrun: Bool?, + createdatms: Int, + updatedatms: Int, + schedule: AnyCodable, + sessiontarget: AnyCodable, + wakemode: AnyCodable, + payload: AnyCodable, + delivery: AnyCodable?, + state: [String: AnyCodable]) + { + self.id = id + self.agentid = agentid + self.sessionkey = sessionkey + self.name = name + self.description = description + self.enabled = enabled + self.deleteafterrun = deleteafterrun + self.createdatms = createdatms + self.updatedatms = updatedatms + self.schedule = schedule + self.sessiontarget = sessiontarget + self.wakemode = wakemode + self.payload = payload + self.delivery = delivery + self.state = state + } + + private enum CodingKeys: String, CodingKey { + case id + case agentid = "agentId" + case sessionkey = "sessionKey" + case name + case description + case enabled + case deleteafterrun = "deleteAfterRun" + case createdatms = "createdAtMs" + case updatedatms = "updatedAtMs" + case schedule + case sessiontarget = "sessionTarget" + case wakemode = "wakeMode" + case payload + case delivery + case state + } +} + +public struct CronListParams: Codable, Sendable { + public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? + + public init( + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) + { + self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" + } +} + +public struct CronStatusParams: Codable, Sendable {} + +public struct CronAddParams: Codable, Sendable { + public let name: String + public let agentid: AnyCodable? + public let sessionkey: AnyCodable? + public let description: String? + public let enabled: Bool? + public let deleteafterrun: Bool? + public let schedule: AnyCodable + public let sessiontarget: AnyCodable + public let wakemode: AnyCodable + public let payload: AnyCodable + public let delivery: AnyCodable? + + public init( + name: String, + agentid: AnyCodable?, + sessionkey: AnyCodable?, + description: String?, + enabled: Bool?, + deleteafterrun: Bool?, + schedule: AnyCodable, + sessiontarget: AnyCodable, + wakemode: AnyCodable, + payload: AnyCodable, + delivery: AnyCodable?) + { + self.name = name + self.agentid = agentid + self.sessionkey = sessionkey + self.description = description + self.enabled = enabled + self.deleteafterrun = deleteafterrun + self.schedule = schedule + self.sessiontarget = sessiontarget + self.wakemode = wakemode + self.payload = payload + self.delivery = delivery + } + + private enum CodingKeys: String, CodingKey { + case name + case agentid = "agentId" + case sessionkey = "sessionKey" + case description + case enabled + case deleteafterrun = "deleteAfterRun" + case schedule + case sessiontarget = "sessionTarget" + case wakemode = "wakeMode" + case payload + case delivery + } +} + +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + +public struct CronRunLogEntry: Codable, Sendable { + public let ts: Int + public let jobid: String + public let action: String + public let status: AnyCodable? + public let error: String? + public let summary: String? + public let delivered: Bool? + public let deliverystatus: AnyCodable? + public let deliveryerror: String? + public let sessionid: String? + public let sessionkey: String? + public let runatms: Int? + public let durationms: Int? + public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? + + public init( + ts: Int, + jobid: String, + action: String, + status: AnyCodable?, + error: String?, + summary: String?, + delivered: Bool?, + deliverystatus: AnyCodable?, + deliveryerror: String?, + sessionid: String?, + sessionkey: String?, + runatms: Int?, + durationms: Int?, + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) + { + self.ts = ts + self.jobid = jobid + self.action = action + self.status = status + self.error = error + self.summary = summary + self.delivered = delivered + self.deliverystatus = deliverystatus + self.deliveryerror = deliveryerror + self.sessionid = sessionid + self.sessionkey = sessionkey + self.runatms = runatms + self.durationms = durationms + self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname + } + + private enum CodingKeys: String, CodingKey { + case ts + case jobid = "jobId" + case action + case status + case error + case summary + case delivered + case deliverystatus = "deliveryStatus" + case deliveryerror = "deliveryError" + case sessionid = "sessionId" + case sessionkey = "sessionKey" + case runatms = "runAtMs" + case durationms = "durationMs" + case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" + } +} + +public struct LogsTailParams: Codable, Sendable { + public let cursor: Int? + public let limit: Int? + public let maxbytes: Int? + + public init( + cursor: Int?, + limit: Int?, + maxbytes: Int?) + { + self.cursor = cursor + self.limit = limit + self.maxbytes = maxbytes + } + + private enum CodingKeys: String, CodingKey { + case cursor + case limit + case maxbytes = "maxBytes" + } +} + +public struct LogsTailResult: Codable, Sendable { + public let file: String + public let cursor: Int + public let size: Int + public let lines: [String] + public let truncated: Bool? + public let reset: Bool? + + public init( + file: String, + cursor: Int, + size: Int, + lines: [String], + truncated: Bool?, + reset: Bool?) + { + self.file = file + self.cursor = cursor + self.size = size + self.lines = lines + self.truncated = truncated + self.reset = reset + } + + private enum CodingKeys: String, CodingKey { + case file + case cursor + case size + case lines + case truncated + case reset + } +} + +public struct ExecApprovalsGetParams: Codable, Sendable {} + +public struct ExecApprovalsSetParams: Codable, Sendable { + public let file: [String: AnyCodable] + public let basehash: String? + + public init( + file: [String: AnyCodable], + basehash: String?) + { + self.file = file + self.basehash = basehash + } + + private enum CodingKeys: String, CodingKey { + case file + case basehash = "baseHash" + } +} + +public struct ExecApprovalsNodeGetParams: Codable, Sendable { + public let nodeid: String + + public init( + nodeid: String) + { + self.nodeid = nodeid + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + } +} + +public struct ExecApprovalsNodeSetParams: Codable, Sendable { + public let nodeid: String + public let file: [String: AnyCodable] + public let basehash: String? + + public init( + nodeid: String, + file: [String: AnyCodable], + basehash: String?) + { + self.nodeid = nodeid + self.file = file + self.basehash = basehash + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case file + case basehash = "baseHash" + } +} + +public struct ExecApprovalsSnapshot: Codable, Sendable { + public let path: String + public let exists: Bool + public let hash: String + public let file: [String: AnyCodable] + + public init( + path: String, + exists: Bool, + hash: String, + file: [String: AnyCodable]) + { + self.path = path + self.exists = exists + self.hash = hash + self.file = file + } + + private enum CodingKeys: String, CodingKey { + case path + case exists + case hash + case file + } +} + +public struct ExecApprovalRequestParams: Codable, Sendable { + public let id: String? + public let command: String + public let cwd: AnyCodable? + public let nodeid: AnyCodable? + public let host: AnyCodable? + public let security: AnyCodable? + public let ask: AnyCodable? + public let agentid: AnyCodable? + public let resolvedpath: AnyCodable? + public let sessionkey: AnyCodable? + public let timeoutms: Int? + public let twophase: Bool? + + public init( + id: String?, + command: String, + cwd: AnyCodable?, + nodeid: AnyCodable?, + host: AnyCodable?, + security: AnyCodable?, + ask: AnyCodable?, + agentid: AnyCodable?, + resolvedpath: AnyCodable?, + sessionkey: AnyCodable?, + timeoutms: Int?, + twophase: Bool?) + { + self.id = id + self.command = command + self.cwd = cwd + self.nodeid = nodeid + self.host = host + self.security = security + self.ask = ask + self.agentid = agentid + self.resolvedpath = resolvedpath + self.sessionkey = sessionkey + self.timeoutms = timeoutms + self.twophase = twophase + } + + private enum CodingKeys: String, CodingKey { + case id + case command + case cwd + case nodeid = "nodeId" + case host + case security + case ask + case agentid = "agentId" + case resolvedpath = "resolvedPath" + case sessionkey = "sessionKey" + case timeoutms = "timeoutMs" + case twophase = "twoPhase" + } +} + +public struct ExecApprovalResolveParams: Codable, Sendable { + public let id: String + public let decision: String + + public init( + id: String, + decision: String) + { + self.id = id + self.decision = decision + } + + private enum CodingKeys: String, CodingKey { + case id + case decision + } +} + +public struct DevicePairListParams: Codable, Sendable {} + +public struct DevicePairApproveParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String) + { + self.requestid = requestid + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct DevicePairRejectParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String) + { + self.requestid = requestid + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct DevicePairRemoveParams: Codable, Sendable { + public let deviceid: String + + public init( + deviceid: String) + { + self.deviceid = deviceid + } + + private enum CodingKeys: String, CodingKey { + case deviceid = "deviceId" + } +} + +public struct DeviceTokenRotateParams: Codable, Sendable { + public let deviceid: String + public let role: String + public let scopes: [String]? + + public init( + deviceid: String, + role: String, + scopes: [String]?) + { + self.deviceid = deviceid + self.role = role + self.scopes = scopes + } + + private enum CodingKeys: String, CodingKey { + case deviceid = "deviceId" + case role + case scopes + } +} + +public struct DeviceTokenRevokeParams: Codable, Sendable { + public let deviceid: String + public let role: String + + public init( + deviceid: String, + role: String) + { + self.deviceid = deviceid + self.role = role + } + + private enum CodingKeys: String, CodingKey { + case deviceid = "deviceId" + case role + } +} + +public struct DevicePairRequestedEvent: Codable, Sendable { + public let requestid: String + public let deviceid: String + public let publickey: String + public let displayname: String? + public let platform: String? + public let clientid: String? + public let clientmode: String? + public let role: String? + public let roles: [String]? + public let scopes: [String]? + public let remoteip: String? + public let silent: Bool? + public let isrepair: Bool? + public let ts: Int + + public init( + requestid: String, + deviceid: String, + publickey: String, + displayname: String?, + platform: String?, + clientid: String?, + clientmode: String?, + role: String?, + roles: [String]?, + scopes: [String]?, + remoteip: String?, + silent: Bool?, + isrepair: Bool?, + ts: Int) + { + self.requestid = requestid + self.deviceid = deviceid + self.publickey = publickey + self.displayname = displayname + self.platform = platform + self.clientid = clientid + self.clientmode = clientmode + self.role = role + self.roles = roles + self.scopes = scopes + self.remoteip = remoteip + self.silent = silent + self.isrepair = isrepair + self.ts = ts + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + case deviceid = "deviceId" + case publickey = "publicKey" + case displayname = "displayName" + case platform + case clientid = "clientId" + case clientmode = "clientMode" + case role + case roles + case scopes + case remoteip = "remoteIp" + case silent + case isrepair = "isRepair" + case ts + } +} + +public struct DevicePairResolvedEvent: Codable, Sendable { + public let requestid: String + public let deviceid: String + public let decision: String + public let ts: Int + + public init( + requestid: String, + deviceid: String, + decision: String, + ts: Int) + { + self.requestid = requestid + self.deviceid = deviceid + self.decision = decision + self.ts = ts + } + + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + case deviceid = "deviceId" + case decision + case ts + } +} + +public struct ChatHistoryParams: Codable, Sendable { + public let sessionkey: String + public let limit: Int? + + public init( + sessionkey: String, + limit: Int?) + { + self.sessionkey = sessionkey + self.limit = limit + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case limit + } +} + +public struct ChatSendParams: Codable, Sendable { + public let sessionkey: String + public let message: String + public let thinking: String? + public let deliver: Bool? + public let attachments: [AnyCodable]? + public let timeoutms: Int? + public let idempotencykey: String + + public init( + sessionkey: String, + message: String, + thinking: String?, + deliver: Bool?, + attachments: [AnyCodable]?, + timeoutms: Int?, + idempotencykey: String) + { + self.sessionkey = sessionkey + self.message = message + self.thinking = thinking + self.deliver = deliver + self.attachments = attachments + self.timeoutms = timeoutms + self.idempotencykey = idempotencykey + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case message + case thinking + case deliver + case attachments + case timeoutms = "timeoutMs" + case idempotencykey = "idempotencyKey" + } +} + +public struct ChatAbortParams: Codable, Sendable { + public let sessionkey: String + public let runid: String? + + public init( + sessionkey: String, + runid: String?) + { + self.sessionkey = sessionkey + self.runid = runid + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case runid = "runId" + } +} + +public struct ChatInjectParams: Codable, Sendable { + public let sessionkey: String + public let message: String + public let label: String? + + public init( + sessionkey: String, + message: String, + label: String?) + { + self.sessionkey = sessionkey + self.message = message + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case message + case label + } +} + +public struct ChatEvent: Codable, Sendable { + public let runid: String + public let sessionkey: String + public let seq: Int + public let state: AnyCodable + public let message: AnyCodable? + public let errormessage: String? + public let usage: AnyCodable? + public let stopreason: String? + + public init( + runid: String, + sessionkey: String, + seq: Int, + state: AnyCodable, + message: AnyCodable?, + errormessage: String?, + usage: AnyCodable?, + stopreason: String?) + { + self.runid = runid + self.sessionkey = sessionkey + self.seq = seq + self.state = state + self.message = message + self.errormessage = errormessage + self.usage = usage + self.stopreason = stopreason + } + + private enum CodingKeys: String, CodingKey { + case runid = "runId" + case sessionkey = "sessionKey" + case seq + case state + case message + case errormessage = "errorMessage" + case usage + case stopreason = "stopReason" + } +} + +public struct UpdateRunParams: Codable, Sendable { + public let sessionkey: String? + public let note: String? + public let restartdelayms: Int? + public let timeoutms: Int? + + public init( + sessionkey: String?, + note: String?, + restartdelayms: Int?, + timeoutms: Int?) + { + self.sessionkey = sessionkey + self.note = note + self.restartdelayms = restartdelayms + self.timeoutms = timeoutms + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case note + case restartdelayms = "restartDelayMs" + case timeoutms = "timeoutMs" + } +} + +public struct TickEvent: Codable, Sendable { + public let ts: Int + + public init( + ts: Int) + { + self.ts = ts + } + + private enum CodingKeys: String, CodingKey { + case ts + } +} + +public struct ShutdownEvent: Codable, Sendable { + public let reason: String + public let restartexpectedms: Int? + + public init( + reason: String, + restartexpectedms: Int?) + { + self.reason = reason + self.restartexpectedms = restartexpectedms + } + + private enum CodingKeys: String, CodingKey { + case reason + case restartexpectedms = "restartExpectedMs" + } +} + +public enum GatewayFrame: Codable, Sendable { + case req(RequestFrame) + case res(ResponseFrame) + case event(EventFrame) + case unknown(type: String, raw: [String: AnyCodable]) + + private enum CodingKeys: String, CodingKey { + case type + } + + public init(from decoder: Decoder) throws { + let typeContainer = try decoder.container(keyedBy: CodingKeys.self) + let type = try typeContainer.decode(String.self, forKey: .type) + switch type { + case "req": + self = try .req(RequestFrame(from: decoder)) + case "res": + self = try .res(ResponseFrame(from: decoder)) + case "event": + self = try .event(EventFrame(from: decoder)) + default: + let container = try decoder.singleValueContainer() + let raw = try container.decode([String: AnyCodable].self) + self = .unknown(type: type, raw: raw) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case let .req(v): + try v.encode(to: encoder) + case let .res(v): + try v.encode(to: encoder) + case let .event(v): + try v.encode(to: encoder) + case let .unknown(_, raw): + var container = encoder.singleValueContainer() + try container.encode(raw) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift new file mode 100644 index 00000000..d410914b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Sources/OpenClawProtocol/WizardHelpers.swift @@ -0,0 +1,106 @@ +import Foundation + +public struct WizardOption: Sendable { + public let value: AnyCodable? + public let label: String + public let hint: String? + + public init(value: AnyCodable?, label: String, hint: String?) { + self.value = value + self.label = label + self.hint = hint + } +} + +public func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? { + guard let raw else { return nil } + do { + let data = try JSONEncoder().encode(raw) + return try JSONDecoder().decode(WizardStep.self, from: data) + } catch { + return nil + } +} + +public func parseWizardOptions(_ raw: [[String: AnyCodable]]?) -> [WizardOption] { + guard let raw else { return [] } + return raw.map { entry in + let value = entry["value"] + let label = (entry["label"]?.value as? String) ?? "" + let hint = entry["hint"]?.value as? String + return WizardOption(value: value, label: label, hint: hint) + } +} + +public func wizardStatusString(_ value: AnyCodable?) -> String? { + (value?.value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() +} + +public func wizardStepType(_ step: WizardStep) -> String { + (step.type.value as? String) ?? "" +} + +public func anyCodableString(_ value: AnyCodable?) -> String { + switch value?.value { + case let string as String: + string + case let int as Int: + String(int) + case let double as Double: + String(double) + case let bool as Bool: + bool ? "true" : "false" + default: + "" + } +} + +public func anyCodableBool(_ value: AnyCodable?) -> Bool { + switch value?.value { + case let bool as Bool: + return bool + case let int as Int: + return int != 0 + case let double as Double: + return double != 0 + case let string as String: + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed == "true" || trimmed == "1" || trimmed == "yes" + default: + return false + } +} + +public func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] { + switch value?.value { + case let arr as [AnyCodable]: + return arr + case let arr as [Any]: + return arr.map { AnyCodable($0) } + default: + return [] + } +} + +public func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool { + switch (lhs?.value, rhs?.value) { + case let (l as String, r as String): + l == r + case let (l as Int, r as Int): + l == r + case let (l as Double, r as Double): + l == r + case let (l as Bool, r as Bool): + l == r + case let (l as String, r as Int): + l == String(r) + case let (l as Int, r as String): + String(l) == r + case let (l as String, r as Double): + l == String(r) + case let (l as Double, r as String): + String(l) == r + default: + false + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift new file mode 100644 index 00000000..3835f118 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +import OpenClawProtocol + +struct AnyCodableTests { + @Test + func encodesNSNumberBooleansAsJSONBooleans() throws { + let trueData = try JSONEncoder().encode(AnyCodable(NSNumber(value: true))) + let falseData = try JSONEncoder().encode(AnyCodable(NSNumber(value: false))) + + #expect(String(data: trueData, encoding: .utf8) == "true") + #expect(String(data: falseData, encoding: .utf8) == "false") + } + + @Test + func preservesBooleanLiteralsFromJSONSerializationBridge() throws { + let raw = try #require( + JSONSerialization.jsonObject(with: Data(#"{"enabled":true,"nested":{"active":false}}"#.utf8)) + as? [String: Any] + ) + let enabled = try #require(raw["enabled"]) + let nested = try #require(raw["nested"]) + + struct RequestEnvelope: Codable { + let params: [String: AnyCodable] + } + + let envelope = RequestEnvelope( + params: [ + "enabled": AnyCodable(enabled), + "nested": AnyCodable(nested), + ] + ) + let data = try JSONEncoder().encode(envelope) + let json = try #require(String(data: data, encoding: .utf8)) + + #expect(json.contains(#""enabled":true"#)) + #expect(json.contains(#""active":false"#)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AssistantTextParserTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AssistantTextParserTests.swift new file mode 100644 index 00000000..5f36bb9c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AssistantTextParserTests.swift @@ -0,0 +1,37 @@ +import Testing +@testable import OpenClawChatUI + +@Suite struct AssistantTextParserTests { + @Test func splitsThinkAndFinalSegments() { + let segments = AssistantTextParser.segments( + from: "internal\n\nHello there") + + #expect(segments.count == 2) + #expect(segments[0].kind == .thinking) + #expect(segments[0].text == "internal") + #expect(segments[1].kind == .response) + #expect(segments[1].text == "Hello there") + } + + @Test func keepsTextWithoutTags() { + let segments = AssistantTextParser.segments(from: "Just text.") + + #expect(segments.count == 1) + #expect(segments[0].kind == .response) + #expect(segments[0].text == "Just text.") + } + + @Test func ignoresThinkingLikeTags() { + let raw = "example\nKeep this." + let segments = AssistantTextParser.segments(from: raw) + + #expect(segments.count == 1) + #expect(segments[0].kind == .response) + #expect(segments[0].text == raw.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + @Test func dropsEmptyTaggedContent() { + let segments = AssistantTextParser.segments(from: "") + #expect(segments.isEmpty) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/BonjourEscapesTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/BonjourEscapesTests.swift new file mode 100644 index 00000000..a7fa1438 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/BonjourEscapesTests.swift @@ -0,0 +1,26 @@ +import OpenClawKit +import Testing + +@Suite struct BonjourEscapesTests { + @Test func decodePassThrough() { + #expect(BonjourEscapes.decode("hello") == "hello") + #expect(BonjourEscapes.decode("") == "") + } + + @Test func decodeSpaces() { + #expect(BonjourEscapes.decode("OpenClaw\\032Gateway") == "OpenClaw Gateway") + } + + @Test func decodeMultipleEscapes() { + #expect(BonjourEscapes.decode("A\\038B\\047C\\032D") == "A&B/C D") + } + + @Test func decodeIgnoresInvalidEscapeSequences() { + #expect(BonjourEscapes.decode("Hello\\03World") == "Hello\\03World") + #expect(BonjourEscapes.decode("Hello\\XYZWorld") == "Hello\\XYZWorld") + } + + @Test func decodeUsesDecimalUnicodeScalarValue() { + #expect(BonjourEscapes.decode("Hello\\065World") == "HelloAWorld") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasA2UIActionTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasA2UIActionTests.swift new file mode 100644 index 00000000..f6070f6d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasA2UIActionTests.swift @@ -0,0 +1,36 @@ +import OpenClawKit +import Foundation +import Testing + +@Suite struct CanvasA2UIActionTests { + @Test func sanitizeTagValueIsStable() { + #expect(OpenClawCanvasA2UIAction.sanitizeTagValue("Hello World!") == "Hello_World_") + #expect(OpenClawCanvasA2UIAction.sanitizeTagValue(" ") == "-") + #expect(OpenClawCanvasA2UIAction.sanitizeTagValue("macOS 26.2") == "macOS_26.2") + } + + @Test func extractActionNameAcceptsNameOrAction() { + #expect(OpenClawCanvasA2UIAction.extractActionName(["name": "Hello"]) == "Hello") + #expect(OpenClawCanvasA2UIAction.extractActionName(["action": "Wave"]) == "Wave") + #expect(OpenClawCanvasA2UIAction.extractActionName(["name": " ", "action": "Fallback"]) == "Fallback") + #expect(OpenClawCanvasA2UIAction.extractActionName(["action": " "]) == nil) + } + + @Test func formatAgentMessageIsTokenEfficientAndUnambiguous() { + let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext( + actionName: "Get Weather", + session: .init(key: "main", surfaceId: "main"), + component: .init(id: "btnWeather", host: "Peter’s iPad", instanceId: "ipad16,6"), + contextJSON: "{\"city\":\"Vienna\"}") + let msg = OpenClawCanvasA2UIAction.formatAgentMessage(messageContext) + + #expect(msg.contains("CANVAS_A2UI ")) + #expect(msg.contains("action=Get_Weather")) + #expect(msg.contains("session=main")) + #expect(msg.contains("surface=main")) + #expect(msg.contains("component=btnWeather")) + #expect(msg.contains("host=Peter_s_iPad")) + #expect(msg.contains("instance=ipad16_6 ctx={\"city\":\"Vienna\"}")) + #expect(msg.hasSuffix(" default=update_canvas")) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasA2UITests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasA2UITests.swift new file mode 100644 index 00000000..4c420cc9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasA2UITests.swift @@ -0,0 +1,42 @@ +import OpenClawKit +import Testing + +@Suite struct CanvasA2UITests { + @Test func commandStringsAreStable() { + #expect(OpenClawCanvasA2UICommand.push.rawValue == "canvas.a2ui.push") + #expect(OpenClawCanvasA2UICommand.pushJSONL.rawValue == "canvas.a2ui.pushJSONL") + #expect(OpenClawCanvasA2UICommand.reset.rawValue == "canvas.a2ui.reset") + } + + @Test func jsonlDecodesAndValidatesV0_8() throws { + let jsonl = """ + {"beginRendering":{"surfaceId":"main","timestamp":1}} + {"surfaceUpdate":{"surfaceId":"main","ops":[]}} + {"dataModelUpdate":{"dataModel":{"title":"Hello"}}} + {"deleteSurface":{"surfaceId":"main"}} + """ + + let messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl) + #expect(messages.count == 4) + } + + @Test func jsonlRejectsV0_9CreateSurface() { + let jsonl = """ + {"createSurface":{"surfaceId":"main"}} + """ + + #expect(throws: Error.self) { + _ = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl) + } + } + + @Test func jsonlRejectsUnknownShape() { + let jsonl = """ + {"wat":{"nope":1}} + """ + + #expect(throws: Error.self) { + _ = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl) + } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasSnapshotFormatTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasSnapshotFormatTests.swift new file mode 100644 index 00000000..ab49a4f4 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/CanvasSnapshotFormatTests.swift @@ -0,0 +1,15 @@ +import OpenClawKit +import Foundation +import Testing + +@Suite struct CanvasSnapshotFormatTests { + @Test func acceptsJpgAlias() throws { + struct Wrapper: Codable { + var format: OpenClawCanvasSnapshotFormat + } + + let data = try #require("{\"format\":\"jpg\"}".data(using: .utf8)) + let decoded = try JSONDecoder().decode(Wrapper.self, from: data) + #expect(decoded.format == .jpeg) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift new file mode 100644 index 00000000..781a325f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift @@ -0,0 +1,107 @@ +import Testing +@testable import OpenClawChatUI + +@Suite("ChatMarkdownPreprocessor") +struct ChatMarkdownPreprocessorTests { + @Test func extractsDataURLImages() { + let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg==" + let markdown = """ + Hello + + ![Pixel](data:image/png;base64,\(base64)) + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == "Hello") + #expect(result.images.count == 1) + #expect(result.images.first?.image != nil) + } + + @Test func stripsInboundUntrustedContextBlocks() { + let markdown = """ + Conversation info (untrusted metadata): + ```json + { + "message_id": "123", + "sender": "openclaw-ios" + } + ``` + + Sender (untrusted metadata): + ```json + { + "label": "Razor" + } + ``` + + Razor? + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == "Razor?") + } + + @Test func stripsSingleConversationInfoBlock() { + let text = """ + Conversation info (untrusted metadata): + ```json + {"x": 1} + ``` + + User message + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: text) + + #expect(result.cleaned == "User message") + } + + @Test func stripsAllKnownInboundMetadataSentinels() { + let sentinels = [ + "Conversation info (untrusted metadata):", + "Sender (untrusted metadata):", + "Thread starter (untrusted, for context):", + "Replied message (untrusted, for context):", + "Forwarded message context (untrusted metadata):", + "Chat history since last reply (untrusted, for context):", + ] + + for sentinel in sentinels { + let markdown = """ + \(sentinel) + ```json + {"x": 1} + ``` + + User content + """ + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + #expect(result.cleaned == "User content") + } + } + + @Test func preservesNonMetadataJsonFence() { + let markdown = """ + Here is some json: + ```json + {"x": 1} + ``` + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == markdown.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + @Test func stripsLeadingTimestampPrefix() { + let markdown = """ + [Fri 2026-02-20 18:45 GMT+1] How's it going? + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == "How's it going?") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatThemeTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatThemeTests.swift new file mode 100644 index 00000000..2c7a5fff --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatThemeTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing +@testable import OpenClawChatUI + +#if os(macOS) +import AppKit +#endif + +#if os(macOS) +private func luminance(_ color: NSColor) throws -> CGFloat { + let rgb = try #require(color.usingColorSpace(.deviceRGB)) + return 0.2126 * rgb.redComponent + 0.7152 * rgb.greenComponent + 0.0722 * rgb.blueComponent +} +#endif + +@Suite struct ChatThemeTests { + @Test func assistantBubbleResolvesForLightAndDark() throws { + #if os(macOS) + let lightAppearance = try #require(NSAppearance(named: .aqua)) + let darkAppearance = try #require(NSAppearance(named: .darkAqua)) + + let lightResolved = OpenClawChatTheme.resolvedAssistantBubbleColor(for: lightAppearance) + let darkResolved = OpenClawChatTheme.resolvedAssistantBubbleColor(for: darkAppearance) + #expect(try luminance(lightResolved) > luminance(darkResolved)) + #else + #expect(Bool(true)) + #endif + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift new file mode 100644 index 00000000..147b80e5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -0,0 +1,720 @@ +import OpenClawKit +import Foundation +import Testing +@testable import OpenClawChatUI + +private struct TimeoutError: Error, CustomStringConvertible { + let label: String + var description: String { "Timeout waiting for: \(self.label)" } +} + +private func waitUntil( + _ label: String, + timeoutSeconds: Double = 2.0, + pollMs: UInt64 = 10, + _ condition: @escaping @Sendable () async -> Bool) async throws +{ + let deadline = Date().addingTimeInterval(timeoutSeconds) + while Date() < deadline { + if await condition() { + return + } + try await Task.sleep(nanoseconds: pollMs * 1_000_000) + } + throw TimeoutError(label: label) +} + +private actor TestChatTransportState { + var historyCallCount: Int = 0 + var sessionsCallCount: Int = 0 + var sentRunIds: [String] = [] + var abortedRunIds: [String] = [] +} + +private final class TestChatTransport: @unchecked Sendable, OpenClawChatTransport { + private let state = TestChatTransportState() + private let historyResponses: [OpenClawChatHistoryPayload] + private let sessionsResponses: [OpenClawChatSessionsListResponse] + + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + init( + historyResponses: [OpenClawChatHistoryPayload], + sessionsResponses: [OpenClawChatSessionsListResponse] = []) + { + self.historyResponses = historyResponses + self.sessionsResponses = sessionsResponses + var cont: AsyncStream.Continuation! + self.stream = AsyncStream { c in + cont = c + } + self.continuation = cont + } + + func events() -> AsyncStream { + self.stream + } + + func setActiveSessionKey(_: String) async throws {} + + func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { + let idx = await self.state.historyCallCount + await self.state.setHistoryCallCount(idx + 1) + if idx < self.historyResponses.count { + return self.historyResponses[idx] + } + return self.historyResponses.last ?? OpenClawChatHistoryPayload( + sessionKey: sessionKey, + sessionId: nil, + messages: [], + thinkingLevel: "off") + } + + func sendMessage( + sessionKey _: String, + message _: String, + thinking _: String, + idempotencyKey: String, + attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + { + await self.state.sentRunIdsAppend(idempotencyKey) + return OpenClawChatSendResponse(runId: idempotencyKey, status: "ok") + } + + func abortRun(sessionKey _: String, runId: String) async throws { + await self.state.abortedRunIdsAppend(runId) + } + + func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse { + let idx = await self.state.sessionsCallCount + await self.state.setSessionsCallCount(idx + 1) + if idx < self.sessionsResponses.count { + return self.sessionsResponses[idx] + } + return self.sessionsResponses.last ?? OpenClawChatSessionsListResponse( + ts: nil, + path: nil, + count: 0, + defaults: nil, + sessions: []) + } + + func requestHealth(timeoutMs _: Int) async throws -> Bool { + true + } + + func emit(_ evt: OpenClawChatTransportEvent) { + self.continuation.yield(evt) + } + + func lastSentRunId() async -> String? { + let ids = await self.state.sentRunIds + return ids.last + } + + func abortedRunIds() async -> [String] { + await self.state.abortedRunIds + } +} + +extension TestChatTransportState { + fileprivate func setHistoryCallCount(_ v: Int) { + self.historyCallCount = v + } + + fileprivate func setSessionsCallCount(_ v: Int) { + self.sessionsCallCount = v + } + + fileprivate func sentRunIdsAppend(_ v: String) { + self.sentRunIds.append(v) + } + + fileprivate func abortedRunIdsAppend(_ v: String) { + self.abortedRunIds.append(v) + } +} + +@Suite struct ChatViewModelTests { + @Test func streamsAssistantAndClearsOnFinal() async throws { + let sessionId = "sess-main" + let history1 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: sessionId, + messages: [], + thinkingLevel: "off") + let history2 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: sessionId, + messages: [ + AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "final answer"]], + "timestamp": Date().timeIntervalSince1970 * 1000, + ]), + ], + thinkingLevel: "off") + + let transport = TestChatTransport(historyResponses: [history1, history2]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } + + await MainActor.run { + vm.input = "hi" + vm.send() + } + try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + + transport.emit( + .agent( + OpenClawAgentEventPayload( + runId: sessionId, + seq: 1, + stream: "assistant", + ts: Int(Date().timeIntervalSince1970 * 1000), + data: ["text": AnyCodable("streaming…")]))) + + try await waitUntil("assistant stream visible") { + await MainActor.run { vm.streamingAssistantText == "streaming…" } + } + + transport.emit( + .agent( + OpenClawAgentEventPayload( + runId: sessionId, + seq: 2, + stream: "tool", + ts: Int(Date().timeIntervalSince1970 * 1000), + data: [ + "phase": AnyCodable("start"), + "name": AnyCodable("demo"), + "toolCallId": AnyCodable("t1"), + "args": AnyCodable(["x": 1]), + ]))) + + try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } } + + let runId = try #require(await transport.lastSentRunId()) + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "main", + state: "final", + message: nil, + errorMessage: nil))) + + try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } } + try await waitUntil("history refresh") { + await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) } + } + #expect(await MainActor.run { vm.streamingAssistantText } == nil) + #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) + } + + @Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws { + let history1 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [], + thinkingLevel: "off") + let history2 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "from history"]], + "timestamp": Date().timeIntervalSince1970 * 1000, + ]), + ], + thinkingLevel: "off") + + let transport = TestChatTransport(historyResponses: [history1, history2]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } } + + await MainActor.run { + vm.input = "hi" + vm.send() + } + try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + + let runId = try #require(await transport.lastSentRunId()) + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "agent:main:main", + state: "final", + message: nil, + errorMessage: nil))) + + try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } } + try await waitUntil("history refresh") { + await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) } + } + } + + @Test func acceptsCanonicalSessionKeyEventsForExternalRuns() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let history1 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": "first"]], + "timestamp": now, + ]), + ], + thinkingLevel: "off") + let history2 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": "first"]], + "timestamp": now, + ]), + AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "from external run"]], + "timestamp": now + 1, + ]), + ], + thinkingLevel: "off") + + let transport = TestChatTransport(historyResponses: [history1, history2]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: "external-run", + sessionKey: "agent:main:main", + state: "final", + message: nil, + errorMessage: nil))) + + try await waitUntil("history refresh after canonical external event") { + await MainActor.run { vm.messages.count == 2 } + } + } + + @Test func preservesMessageIDsAcrossHistoryRefreshes() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let history1 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": "hello"]], + "timestamp": now, + ]), + ], + thinkingLevel: "off") + let history2 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": "hello"]], + "timestamp": now, + ]), + AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "world"]], + "timestamp": now + 1, + ]), + ], + thinkingLevel: "off") + + let transport = TestChatTransport(historyResponses: [history1, history2]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } } + let firstIdBefore = try #require(await MainActor.run { vm.messages.first?.id }) + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: "other-run", + sessionKey: "main", + state: "final", + message: nil, + errorMessage: nil))) + + try await waitUntil("history refresh") { await MainActor.run { vm.messages.count == 2 } } + let firstIdAfter = try #require(await MainActor.run { vm.messages.first?.id }) + #expect(firstIdAfter == firstIdBefore) + } + + @Test func clearsStreamingOnExternalFinalEvent() async throws { + let sessionId = "sess-main" + let history = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: sessionId, + messages: [], + thinkingLevel: "off") + let transport = TestChatTransport(historyResponses: [history, history]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } + + transport.emit( + .agent( + OpenClawAgentEventPayload( + runId: sessionId, + seq: 1, + stream: "assistant", + ts: Int(Date().timeIntervalSince1970 * 1000), + data: ["text": AnyCodable("external stream")]))) + + transport.emit( + .agent( + OpenClawAgentEventPayload( + runId: sessionId, + seq: 2, + stream: "tool", + ts: Int(Date().timeIntervalSince1970 * 1000), + data: [ + "phase": AnyCodable("start"), + "name": AnyCodable("demo"), + "toolCallId": AnyCodable("t1"), + "args": AnyCodable(["x": 1]), + ]))) + + try await waitUntil("streaming active") { + await MainActor.run { vm.streamingAssistantText == "external stream" } + } + try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: "other-run", + sessionKey: "main", + state: "final", + message: nil, + errorMessage: nil))) + + try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } } + #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) + } + + @Test func seqGapClearsPendingRunsAndAutoRefreshesHistory() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let history1 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [], + thinkingLevel: "off") + let history2 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "resynced after gap"]], + "timestamp": now, + ]), + ], + thinkingLevel: "off") + + let transport = TestChatTransport(historyResponses: [history1, history2]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } } + + await MainActor.run { + vm.input = "hello" + vm.send() + } + try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + + transport.emit(.seqGap) + + try await waitUntil("pending run clears on seqGap") { + await MainActor.run { vm.pendingRunCount == 0 } + } + try await waitUntil("history refreshes on seqGap") { + await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) } + } + #expect(await MainActor.run { vm.errorText == nil }) + } + + @Test func sessionChoicesPreferMainAndRecent() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let recent = now - (2 * 60 * 60 * 1000) + let recentOlder = now - (5 * 60 * 60 * 1000) + let stale = now - (26 * 60 * 60 * 1000) + let history = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [], + thinkingLevel: "off") + let sessions = OpenClawChatSessionsListResponse( + ts: now, + path: nil, + count: 4, + defaults: nil, + sessions: [ + OpenClawChatSessionEntry( + key: "recent-1", + kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: recent, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + model: nil, + contextTokens: nil), + OpenClawChatSessionEntry( + key: "main", + kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: stale, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + model: nil, + contextTokens: nil), + OpenClawChatSessionEntry( + key: "recent-2", + kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: recentOlder, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + model: nil, + contextTokens: nil), + OpenClawChatSessionEntry( + key: "old-1", + kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: stale, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + model: nil, + contextTokens: nil), + ]) + + let transport = TestChatTransport( + historyResponses: [history], + sessionsResponses: [sessions]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + await MainActor.run { vm.load() } + try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } } + + let keys = await MainActor.run { vm.sessionChoices.map(\.key) } + #expect(keys == ["main", "recent-1", "recent-2"]) + } + + @Test func sessionChoicesIncludeCurrentWhenMissing() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let recent = now - (30 * 60 * 1000) + let history = OpenClawChatHistoryPayload( + sessionKey: "custom", + sessionId: "sess-custom", + messages: [], + thinkingLevel: "off") + let sessions = OpenClawChatSessionsListResponse( + ts: now, + path: nil, + count: 1, + defaults: nil, + sessions: [ + OpenClawChatSessionEntry( + key: "main", + kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: recent, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + model: nil, + contextTokens: nil), + ]) + + let transport = TestChatTransport( + historyResponses: [history], + sessionsResponses: [sessions]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "custom", transport: transport) } + await MainActor.run { vm.load() } + try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } } + + let keys = await MainActor.run { vm.sessionChoices.map(\.key) } + #expect(keys == ["main", "custom"]) + } + + @Test func clearsStreamingOnExternalErrorEvent() async throws { + let sessionId = "sess-main" + let history = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: sessionId, + messages: [], + thinkingLevel: "off") + let transport = TestChatTransport(historyResponses: [history, history]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } + + transport.emit( + .agent( + OpenClawAgentEventPayload( + runId: sessionId, + seq: 1, + stream: "assistant", + ts: Int(Date().timeIntervalSince1970 * 1000), + data: ["text": AnyCodable("external stream")]))) + + try await waitUntil("streaming active") { + await MainActor.run { vm.streamingAssistantText == "external stream" } + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: "other-run", + sessionKey: "main", + state: "error", + message: nil, + errorMessage: "boom"))) + + try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } } + } + + @Test func stripsInboundMetadataFromHistoryMessages() async throws { + let history = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": """ +Conversation info (untrusted metadata): +```json +{ \"sender\": \"openclaw-ios\" } +``` + +Hello? +"""]], + "timestamp": Date().timeIntervalSince1970 * 1000, + ]), + ], + thinkingLevel: "off") + let transport = TestChatTransport(historyResponses: [history]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("history loaded") { await MainActor.run { !vm.messages.isEmpty } } + + let sanitized = await MainActor.run { vm.messages.first?.content.first?.text } + #expect(sanitized == "Hello?") + } + + @Test func abortRequestsDoNotClearPendingUntilAbortedEvent() async throws { + let sessionId = "sess-main" + let history = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: sessionId, + messages: [], + thinkingLevel: "off") + let transport = TestChatTransport(historyResponses: [history, history]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } + + await MainActor.run { + vm.input = "hi" + vm.send() + } + try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + + let runId = try #require(await transport.lastSentRunId()) + await MainActor.run { vm.abort() } + + try await waitUntil("abortRun called") { + let ids = await transport.abortedRunIds() + return ids == [runId] + } + + // Pending remains until the gateway broadcasts an aborted/final chat event. + #expect(await MainActor.run { vm.pendingRunCount } == 1) + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "main", + state: "aborted", + message: nil, + errorMessage: nil))) + + try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } } + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift new file mode 100644 index 00000000..8bbf4f8a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift @@ -0,0 +1,61 @@ +import Foundation +import OpenClawKit +import Testing + +@Suite struct DeepLinksSecurityTests { + @Test func gatewayDeepLinkRejectsInsecureNonLoopbackWs() { + let url = URL( + string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func gatewayDeepLinkRejectsInsecurePrefixBypassHost() { + let url = URL( + string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func gatewayDeepLinkAllowsLoopbackWs() { + let url = URL( + string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")! + #expect( + DeepLinkParser.parse(url) == .gateway( + .init(host: "127.0.0.1", port: 18789, tls: false, token: "abc", password: nil))) + } + + @Test func setupCodeRejectsInsecureNonLoopbackWs() { + let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + #expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil) + } + + @Test func setupCodeRejectsInsecurePrefixBypassHost() { + let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + #expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil) + } + + @Test func setupCodeAllowsLoopbackWs() { + let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + #expect( + GatewayConnectDeepLink.fromSetupCode(encoded) == .init( + host: "127.0.0.1", + port: 18789, + tls: false, + token: "tok", + password: nil)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ElevenLabsTTSValidationTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ElevenLabsTTSValidationTests.swift new file mode 100644 index 00000000..1d672db3 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ElevenLabsTTSValidationTests.swift @@ -0,0 +1,19 @@ +import XCTest +@testable import OpenClawKit + +final class ElevenLabsTTSValidationTests: XCTestCase { + func testValidatedOutputFormatAllowsOnlyMp3Presets() { + XCTAssertEqual(ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128"), "mp3_44100_128") + XCTAssertEqual(ElevenLabsTTSClient.validatedOutputFormat("pcm_16000"), "pcm_16000") + } + + func testValidatedLanguageAcceptsTwoLetterCodes() { + XCTAssertEqual(ElevenLabsTTSClient.validatedLanguage("EN"), "en") + XCTAssertNil(ElevenLabsTTSClient.validatedLanguage("eng")) + } + + func testValidatedNormalizeAcceptsKnownValues() { + XCTAssertEqual(ElevenLabsTTSClient.validatedNormalize("AUTO"), "auto") + XCTAssertNil(ElevenLabsTTSClient.validatedNormalize("maybe")) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift new file mode 100644 index 00000000..08a6ea21 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -0,0 +1,284 @@ +import Foundation +import Testing +@testable import OpenClawKit +import OpenClawProtocol + +private struct TimeoutError: Error, CustomStringConvertible { + let label: String + var description: String { "Timeout waiting for: \(self.label)" } +} + +private func waitUntil( + _ label: String, + timeoutSeconds: Double = 3.0, + pollMs: UInt64 = 10, + _ condition: @escaping @Sendable () async -> Bool) async throws +{ + let deadline = Date().addingTimeInterval(timeoutSeconds) + while Date() < deadline { + if await condition() { + return + } + try await Task.sleep(nanoseconds: pollMs * 1_000_000) + } + throw TimeoutError(label: label) +} + +private extension NSLock { + func withLock(_ body: () -> T) -> T { + self.lock() + defer { self.unlock() } + return body() + } +} + +private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable { + private let lock = NSLock() + private var _state: URLSessionTask.State = .suspended + private var connectRequestId: String? + private var receivePhase = 0 + private var pendingReceiveHandler: + (@Sendable (Result) -> Void)? + + var state: URLSessionTask.State { + get { self.lock.withLock { self._state } } + set { self.lock.withLock { self._state = newValue } } + } + + func resume() { + self.state = .running + } + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + _ = (closeCode, reason) + self.state = .canceling + let handler = self.lock.withLock { () -> (@Sendable (Result) -> Void)? in + defer { self.pendingReceiveHandler = nil } + return self.pendingReceiveHandler + } + handler?(Result.failure(URLError(.cancelled))) + } + + func send(_ message: URLSessionWebSocketTask.Message) async throws { + let data: Data? = switch message { + case let .data(d): d + case let .string(s): s.data(using: .utf8) + @unknown default: nil + } + guard let data else { return } + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + obj["type"] as? String == "req", + obj["method"] as? String == "connect", + let id = obj["id"] as? String + { + self.lock.withLock { self.connectRequestId = id } + } + } + + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { + pongReceiveHandler(nil) + } + + func receive() async throws -> URLSessionWebSocketTask.Message { + let phase = self.lock.withLock { () -> Int in + let current = self.receivePhase + self.receivePhase += 1 + return current + } + if phase == 0 { + return .data(Self.connectChallengeData(nonce: "nonce-1")) + } + for _ in 0..<50 { + let id = self.lock.withLock { self.connectRequestId } + if let id { + return .data(Self.connectOkData(id: id)) + } + try await Task.sleep(nanoseconds: 1_000_000) + } + return .data(Self.connectOkData(id: "connect")) + } + + func receive( + completionHandler: @escaping @Sendable (Result) -> Void) + { + self.lock.withLock { self.pendingReceiveHandler = completionHandler } + } + + func emitReceiveFailure() { + let handler = self.lock.withLock { () -> (@Sendable (Result) -> Void)? in + self._state = .canceling + defer { self.pendingReceiveHandler = nil } + return self.pendingReceiveHandler + } + handler?(Result.failure(URLError(.networkConnectionLost))) + } + + private static func connectChallengeData(nonce: String) -> Data { + let json = """ + { + "type": "event", + "event": "connect.challenge", + "payload": { "nonce": "\(nonce)" } + } + """ + return Data(json.utf8) + } + + private static func connectOkData(id: String) -> Data { + let json = """ + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": { + "type": "hello-ok", + "protocol": 2, + "server": { "version": "test", "connId": "test" }, + "features": { "methods": [], "events": [] }, + "snapshot": { + "presence": [ { "ts": 1 } ], + "health": {}, + "stateVersion": { "presence": 0, "health": 0 }, + "uptimeMs": 0 + }, + "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 } + } + } + """ + return Data(json.utf8) + } +} + +private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable { + private let lock = NSLock() + private var tasks: [FakeGatewayWebSocketTask] = [] + private var makeCount = 0 + + func snapshotMakeCount() -> Int { + self.lock.withLock { self.makeCount } + } + + func latestTask() -> FakeGatewayWebSocketTask? { + self.lock.withLock { self.tasks.last } + } + + func makeWebSocketTask(url: URL) -> WebSocketTaskBox { + _ = url + return self.lock.withLock { + self.makeCount += 1 + let task = FakeGatewayWebSocketTask() + self.tasks.append(task) + return WebSocketTaskBox(task: task) + } + } +} + +private actor SeqGapProbe { + private var saw = false + func mark() { self.saw = true } + func value() -> Bool { self.saw } +} + +struct GatewayNodeSessionTests { + @Test + func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async { + let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil) + let response = await GatewayNodeSession.invokeWithTimeout( + request: request, + timeoutMs: 50, + onInvoke: { req in + #expect(req.id == "1") + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil) + } + ) + + #expect(response.ok == true) + #expect(response.error == nil) + #expect(response.payloadJSON == "{}") + } + + @Test + func invokeWithTimeoutReturnsTimeoutError() async { + let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil) + let response = await GatewayNodeSession.invokeWithTimeout( + request: request, + timeoutMs: 10, + onInvoke: { _ in + try? await Task.sleep(nanoseconds: 200_000_000) // 200ms + return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil) + } + ) + + #expect(response.ok == false) + #expect(response.error?.code == .unavailable) + #expect(response.error?.message.contains("timed out") == true) + } + + @Test + func invokeWithTimeoutZeroDisablesTimeout() async { + let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil) + let response = await GatewayNodeSession.invokeWithTimeout( + request: request, + timeoutMs: 0, + onInvoke: { req in + try? await Task.sleep(nanoseconds: 5_000_000) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) + } + ) + + #expect(response.ok == true) + #expect(response.error == nil) + } + + @Test + func emitsSyntheticSeqGapAfterReconnectSnapshot() async throws { + let session = FakeGatewayWebSocketSession() + let gateway = GatewayNodeSession() + let options = GatewayConnectOptions( + role: "operator", + scopes: ["operator.read"], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "ui", + clientDisplayName: "iOS Test", + includeDeviceIdentity: false) + + let stream = await gateway.subscribeServerEvents(bufferingNewest: 32) + let probe = SeqGapProbe() + let listenTask = Task { + for await evt in stream { + if evt.event == "seqGap" { + await probe.mark() + return + } + } + } + + try await gateway.connect( + url: URL(string: "ws://example.invalid")!, + token: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) + }) + + let firstTask = try #require(session.latestTask()) + firstTask.emitReceiveFailure() + + try await waitUntil("reconnect socket created") { + session.snapshotMakeCount() >= 2 + } + try await waitUntil("synthetic seqGap broadcast") { + await probe.value() + } + + listenTask.cancel() + await gateway.disconnect() + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/JPEGTranscoderTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/JPEGTranscoderTests.swift new file mode 100644 index 00000000..5070a8b1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/JPEGTranscoderTests.swift @@ -0,0 +1,129 @@ +import OpenClawKit +import CoreGraphics +import ImageIO +import Testing +import UniformTypeIdentifiers + +@Suite struct JPEGTranscoderTests { + private func makeSolidJPEG(width: Int, height: Int, orientation: Int? = nil) throws -> Data { + let cs = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue + guard + let ctx = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: cs, + bitmapInfo: bitmapInfo) + else { + throw NSError(domain: "JPEGTranscoderTests", code: 1) + } + + ctx.setFillColor(red: 1, green: 0, blue: 0, alpha: 1) + ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) + guard let img = ctx.makeImage() else { + throw NSError(domain: "JPEGTranscoderTests", code: 5) + } + + let out = NSMutableData() + guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else { + throw NSError(domain: "JPEGTranscoderTests", code: 2) + } + + var props: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: 1.0, + ] + if let orientation { + props[kCGImagePropertyOrientation] = orientation + } + + CGImageDestinationAddImage(dest, img, props as CFDictionary) + guard CGImageDestinationFinalize(dest) else { + throw NSError(domain: "JPEGTranscoderTests", code: 3) + } + + return out as Data + } + + private func makeNoiseJPEG(width: Int, height: Int) throws -> Data { + let bytesPerPixel = 4 + let byteCount = width * height * bytesPerPixel + var data = Data(count: byteCount) + let cs = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue + + let out = try data.withUnsafeMutableBytes { rawBuffer -> Data in + guard let base = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + throw NSError(domain: "JPEGTranscoderTests", code: 6) + } + for idx in 0.. 0) + } + + @Test func doesNotUpscaleWhenSmallerThanMaxWidthPx() throws { + let input = try makeSolidJPEG(width: 800, height: 600) + let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9) + #expect(out.widthPx == 800) + #expect(out.heightPx == 600) + } + + @Test func normalizesOrientationAndUsesOrientedWidthForMaxWidthPx() throws { + // Encode a landscape image but mark it rotated 90° (orientation 6). Oriented width becomes 1000. + let input = try makeSolidJPEG(width: 2000, height: 1000, orientation: 6) + let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9) + #expect(out.widthPx == 1000) + #expect(out.heightPx == 2000) + } + + @Test func respectsMaxBytes() throws { + let input = try makeNoiseJPEG(width: 1600, height: 1200) + let out = try JPEGTranscoder.transcodeToJPEG( + imageData: input, + maxWidthPx: 1600, + quality: 0.95, + maxBytes: 180_000) + #expect(out.data.count <= 180_000) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkDirectiveTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkDirectiveTests.swift new file mode 100644 index 00000000..11565ac7 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkDirectiveTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import OpenClawKit + +final class TalkDirectiveTests: XCTestCase { + func testParsesDirectiveAndStripsLine() { + let text = """ + {"voice":"abc123","once":true} + Hello there. + """ + let result = TalkDirectiveParser.parse(text) + XCTAssertEqual(result.directive?.voiceId, "abc123") + XCTAssertEqual(result.directive?.once, true) + XCTAssertEqual(result.stripped, "Hello there.") + } + + func testIgnoresNonDirective() { + let text = "Hello world." + let result = TalkDirectiveParser.parse(text) + XCTAssertNil(result.directive) + XCTAssertEqual(result.stripped, text) + } + + func testKeepsDirectiveLineIfNoRecognizedFields() { + let text = """ + {"unknown":"value"} + Hello. + """ + let result = TalkDirectiveParser.parse(text) + XCTAssertNil(result.directive) + XCTAssertEqual(result.stripped, text) + } + + func testParsesExtendedOptions() { + let text = """ + {"voice_id":"v1","model_id":"m1","rate":200,"stability":0.5,"similarity":0.8,"style":0.2,"speaker_boost":true,"seed":1234,"normalize":"auto","lang":"en","output_format":"mp3_44100_128"} + Hello. + """ + let result = TalkDirectiveParser.parse(text) + XCTAssertEqual(result.directive?.voiceId, "v1") + XCTAssertEqual(result.directive?.modelId, "m1") + XCTAssertEqual(result.directive?.rateWPM, 200) + XCTAssertEqual(result.directive?.stability, 0.5) + XCTAssertEqual(result.directive?.similarity, 0.8) + XCTAssertEqual(result.directive?.style, 0.2) + XCTAssertEqual(result.directive?.speakerBoost, true) + XCTAssertEqual(result.directive?.seed, 1234) + XCTAssertEqual(result.directive?.normalize, "auto") + XCTAssertEqual(result.directive?.language, "en") + XCTAssertEqual(result.directive?.outputFormat, "mp3_44100_128") + XCTAssertEqual(result.stripped, "Hello.") + } + + func testSkipsLeadingEmptyLinesWhenParsingDirective() { + let text = """ + + + {"voice":"abc123"} + Hello there. + """ + let result = TalkDirectiveParser.parse(text) + XCTAssertEqual(result.directive?.voiceId, "abc123") + XCTAssertEqual(result.stripped, "Hello there.") + } + + func testTracksUnknownKeys() { + let text = """ + {"voice":"abc","mystery":"value","extra":1} + Hi. + """ + let result = TalkDirectiveParser.parse(text) + XCTAssertEqual(result.directive?.voiceId, "abc") + XCTAssertEqual(result.unknownKeys, ["extra", "mystery"]) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkHistoryTimestampTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkHistoryTimestampTests.swift new file mode 100644 index 00000000..e66c4e1e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkHistoryTimestampTests.swift @@ -0,0 +1,16 @@ +import XCTest +@testable import OpenClawKit + +final class TalkHistoryTimestampTests: XCTestCase { + func testSecondsTimestampsAreAcceptedWithSmallTolerance() { + XCTAssertTrue(TalkHistoryTimestamp.isAfter(999.6, sinceSeconds: 1000)) + XCTAssertFalse(TalkHistoryTimestamp.isAfter(999.4, sinceSeconds: 1000)) + } + + func testMillisecondsTimestampsAreAcceptedWithSmallTolerance() { + let sinceSeconds = 1_700_000_000.0 + let sinceMs = sinceSeconds * 1000 + XCTAssertTrue(TalkHistoryTimestamp.isAfter(sinceMs - 500, sinceSeconds: sinceSeconds)) + XCTAssertFalse(TalkHistoryTimestamp.isAfter(sinceMs - 501, sinceSeconds: sinceSeconds)) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift new file mode 100644 index 00000000..513b60d0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift @@ -0,0 +1,29 @@ +import XCTest +@testable import OpenClawKit + +final class TalkPromptBuilderTests: XCTestCase { + func testBuildIncludesTranscript() { + let prompt = TalkPromptBuilder.build(transcript: "Hello", interruptedAtSeconds: nil) + XCTAssertTrue(prompt.contains("Talk Mode active.")) + XCTAssertTrue(prompt.hasSuffix("\n\nHello")) + } + + func testBuildIncludesInterruptionLineWhenProvided() { + let prompt = TalkPromptBuilder.build(transcript: "Hi", interruptedAtSeconds: 1.234) + XCTAssertTrue(prompt.contains("Assistant speech interrupted at 1.2s.")) + } + + func testBuildIncludesVoiceDirectiveHintByDefault() { + let prompt = TalkPromptBuilder.build(transcript: "Hello", interruptedAtSeconds: nil) + XCTAssertTrue(prompt.contains("ElevenLabs voice")) + } + + func testBuildExcludesVoiceDirectiveHintWhenDisabled() { + let prompt = TalkPromptBuilder.build( + transcript: "Hello", + interruptedAtSeconds: nil, + includeVoiceDirectiveHint: false) + XCTAssertFalse(prompt.contains("ElevenLabs voice")) + XCTAssertTrue(prompt.contains("Talk Mode active.")) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolDisplayRegistryTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolDisplayRegistryTests.swift new file mode 100644 index 00000000..dbf38138 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolDisplayRegistryTests.swift @@ -0,0 +1,16 @@ +import OpenClawKit +import Foundation +import Testing + +@Suite struct ToolDisplayRegistryTests { + @Test func loadsToolDisplayConfigFromBundle() { + let url = OpenClawKitResources.bundle.url(forResource: "tool-display", withExtension: "json") + #expect(url != nil) + } + + @Test func resolvesKnownToolFromConfig() { + let summary = ToolDisplayRegistry.resolve(name: "bash", args: nil) + #expect(summary.emoji == "🛠️") + #expect(summary.title == "Bash") + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolResultTextFormatterTests.swift b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolResultTextFormatterTests.swift new file mode 100644 index 00000000..1688725c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolResultTextFormatterTests.swift @@ -0,0 +1,54 @@ +import Testing +@testable import OpenClawChatUI + +@Suite("ToolResultTextFormatter") +struct ToolResultTextFormatterTests { + @Test func leavesPlainTextUntouched() { + let result = ToolResultTextFormatter.format(text: "All good", toolName: "nodes") + #expect(result == "All good") + } + + @Test func summarizesNodesListJSON() { + let json = """ + { + "ts": 1771610031380, + "nodes": [ + { + "displayName": "iPhone 16 Pro Max", + "connected": true, + "platform": "ios" + } + ] + } + """ + + let result = ToolResultTextFormatter.format(text: json, toolName: "nodes") + #expect(result.contains("1 node found.")) + #expect(result.contains("iPhone 16 Pro Max")) + #expect(result.contains("connected")) + } + + @Test func summarizesErrorJSONAndDropsAgentPrefix() { + let json = """ + { + "status": "error", + "tool": "nodes", + "error": "agent=main node=iPhone gateway=default action=invoke: pairing required" + } + """ + + let result = ToolResultTextFormatter.format(text: json, toolName: "nodes") + #expect(result == "Error: pairing required") + } + + @Test func suppressesUnknownStructuredPayload() { + let json = """ + { + "foo": "bar" + } + """ + + let result = ToolResultTextFormatter.format(text: json, toolName: "nodes") + #expect(result.isEmpty) + } +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js new file mode 100644 index 00000000..530287ca --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js @@ -0,0 +1,549 @@ +import { html, css, LitElement, unsafeCSS } from "lit"; +import { repeat } from "lit/directives/repeat.js"; +import { ContextProvider } from "@lit/context"; + +import { v0_8 } from "@a2ui/lit"; +import "@a2ui/lit/ui"; +import { themeContext } from "@openclaw/a2ui-theme-context"; + +const modalStyles = css` + dialog { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + padding: 24px; + border: none; + background: rgba(5, 8, 16, 0.65); + backdrop-filter: blur(6px); + display: grid; + place-items: center; + } + + dialog::backdrop { + background: rgba(5, 8, 16, 0.65); + backdrop-filter: blur(6px); + } +`; + +const modalElement = customElements.get("a2ui-modal"); +if (modalElement && Array.isArray(modalElement.styles)) { + modalElement.styles = [...modalElement.styles, modalStyles]; +} + +const appendComponentStyles = (tagName, extraStyles) => { + const component = customElements.get(tagName); + if (!component) { + return; + } + + const current = component.styles; + if (!current) { + component.styles = [extraStyles]; + return; + } + + component.styles = Array.isArray(current) ? [...current, extraStyles] : [current, extraStyles]; +}; + +appendComponentStyles( + "a2ui-row", + css` + @media (max-width: 860px) { + section { + flex-wrap: wrap; + align-content: flex-start; + } + + ::slotted(*) { + flex: 1 1 100%; + min-width: 100%; + width: 100%; + max-width: 100%; + } + } + `, +); + +appendComponentStyles( + "a2ui-column", + css` + :host { + min-width: 0; + } + + section { + min-width: 0; + } + `, +); + +appendComponentStyles( + "a2ui-card", + css` + :host { + min-width: 0; + } + + section { + min-width: 0; + } + `, +); + +const emptyClasses = () => ({}); +const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} }); + +const isAndroid = /Android/i.test(globalThis.navigator?.userAgent ?? ""); +const cardShadow = isAndroid ? "0 2px 10px rgba(0,0,0,.18)" : "0 10px 30px rgba(0,0,0,.35)"; +const buttonShadow = isAndroid ? "0 2px 10px rgba(6, 182, 212, 0.14)" : "0 10px 25px rgba(6, 182, 212, 0.18)"; +const statusShadow = isAndroid ? "0 2px 10px rgba(0, 0, 0, 0.18)" : "0 10px 24px rgba(0, 0, 0, 0.25)"; +const statusBlur = isAndroid ? "10px" : "14px"; + +const openclawTheme = { + components: { + AudioPlayer: emptyClasses(), + Button: emptyClasses(), + Card: emptyClasses(), + Column: emptyClasses(), + CheckBox: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() }, + DateTimeInput: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() }, + Divider: emptyClasses(), + Image: { + all: emptyClasses(), + icon: emptyClasses(), + avatar: emptyClasses(), + smallFeature: emptyClasses(), + mediumFeature: emptyClasses(), + largeFeature: emptyClasses(), + header: emptyClasses(), + }, + Icon: emptyClasses(), + List: emptyClasses(), + Modal: { backdrop: emptyClasses(), element: emptyClasses() }, + MultipleChoice: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() }, + Row: emptyClasses(), + Slider: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() }, + Tabs: { container: emptyClasses(), element: emptyClasses(), controls: { all: emptyClasses(), selected: emptyClasses() } }, + Text: { + all: emptyClasses(), + h1: emptyClasses(), + h2: emptyClasses(), + h3: emptyClasses(), + h4: emptyClasses(), + h5: emptyClasses(), + caption: emptyClasses(), + body: emptyClasses(), + }, + TextField: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() }, + Video: emptyClasses(), + }, + elements: { + a: emptyClasses(), + audio: emptyClasses(), + body: emptyClasses(), + button: emptyClasses(), + h1: emptyClasses(), + h2: emptyClasses(), + h3: emptyClasses(), + h4: emptyClasses(), + h5: emptyClasses(), + iframe: emptyClasses(), + input: emptyClasses(), + p: emptyClasses(), + pre: emptyClasses(), + textarea: emptyClasses(), + video: emptyClasses(), + }, + markdown: { + p: [], + h1: [], + h2: [], + h3: [], + h4: [], + h5: [], + ul: [], + ol: [], + li: [], + a: [], + strong: [], + em: [], + }, + additionalStyles: { + Card: { + background: "linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03))", + border: "1px solid rgba(255,255,255,.09)", + borderRadius: "14px", + padding: "14px", + boxShadow: cardShadow, + }, + Modal: { + background: "rgba(12, 16, 24, 0.92)", + border: "1px solid rgba(255,255,255,.12)", + borderRadius: "16px", + padding: "16px", + boxShadow: "0 30px 80px rgba(0,0,0,.6)", + width: "min(520px, calc(100vw - 48px))", + }, + Column: { gap: "10px" }, + Row: { gap: "10px", alignItems: "center" }, + Divider: { opacity: "0.25" }, + Button: { + background: "linear-gradient(135deg, #22c55e 0%, #06b6d4 100%)", + border: "0", + borderRadius: "12px", + padding: "10px 14px", + color: "#071016", + fontWeight: "650", + cursor: "pointer", + boxShadow: buttonShadow, + }, + Text: { + ...textHintStyles(), + h1: { fontSize: "20px", fontWeight: "750", margin: "0 0 6px 0" }, + h2: { fontSize: "16px", fontWeight: "700", margin: "0 0 6px 0" }, + body: { fontSize: "13px", lineHeight: "1.4" }, + caption: { opacity: "0.8" }, + }, + TextField: { display: "grid", gap: "6px" }, + Image: { borderRadius: "12px" }, + }, +}; + +class OpenClawA2UIHost extends LitElement { + static properties = { + surfaces: { state: true }, + pendingAction: { state: true }, + toast: { state: true }, + }; + + #processor = v0_8.Data.createSignalA2uiMessageProcessor(); + themeProvider = new ContextProvider(this, { + context: themeContext, + initialValue: openclawTheme, + }); + + surfaces = []; + pendingAction = null; + toast = null; + #statusListener = null; + + static styles = css` + :host { + display: block; + height: 100%; + position: relative; + box-sizing: border-box; + padding: + var(--openclaw-a2ui-inset-top, 0px) + var(--openclaw-a2ui-inset-right, 0px) + var(--openclaw-a2ui-inset-bottom, 0px) + var(--openclaw-a2ui-inset-left, 0px); + } + + #surfaces { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + height: 100%; + overflow: auto; + padding-bottom: var(--openclaw-a2ui-scroll-pad-bottom, 0px); + } + + .status { + position: absolute; + left: 50%; + transform: translateX(-50%); + top: var(--openclaw-a2ui-status-top, 12px); + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.45); + border: 1px solid rgba(255, 255, 255, 0.18); + color: rgba(255, 255, 255, 0.92); + font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif; + pointer-events: none; + backdrop-filter: blur(${unsafeCSS(statusBlur)}); + -webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)}); + box-shadow: ${unsafeCSS(statusShadow)}; + z-index: 5; + } + + .toast { + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: var(--openclaw-a2ui-toast-bottom, 12px); + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.45); + border: 1px solid rgba(255, 255, 255, 0.18); + color: rgba(255, 255, 255, 0.92); + font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif; + pointer-events: none; + backdrop-filter: blur(${unsafeCSS(statusBlur)}); + -webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)}); + box-shadow: ${unsafeCSS(statusShadow)}; + z-index: 5; + } + + .toast.error { + border-color: rgba(255, 109, 109, 0.35); + color: rgba(255, 223, 223, 0.98); + } + + .empty { + position: absolute; + left: 50%; + transform: translateX(-50%); + top: var(--openclaw-a2ui-empty-top, var(--openclaw-a2ui-status-top, 12px)); + text-align: center; + opacity: 0.8; + padding: 10px 12px; + pointer-events: none; + } + + .empty-title { + font-weight: 700; + margin-bottom: 6px; + } + + .spinner { + width: 12px; + height: 12px; + border-radius: 999px; + border: 2px solid rgba(255, 255, 255, 0.25); + border-top-color: rgba(255, 255, 255, 0.92); + animation: spin 0.75s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + `; + + connectedCallback() { + super.connectedCallback(); + const api = { + applyMessages: (messages) => this.applyMessages(messages), + reset: () => this.reset(), + getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()), + }; + globalThis.openclawA2UI = api; + this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt)); + this.#statusListener = (evt) => this.#handleActionStatus(evt); + for (const eventName of ["openclaw:a2ui-action-status"]) { + globalThis.addEventListener(eventName, this.#statusListener); + } + this.#syncSurfaces(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.#statusListener) { + for (const eventName of ["openclaw:a2ui-action-status"]) { + globalThis.removeEventListener(eventName, this.#statusListener); + } + this.#statusListener = null; + } + } + + #makeActionId() { + return globalThis.crypto?.randomUUID?.() ?? `a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`; + } + + #setToast(text, kind = "ok", timeoutMs = 1400) { + const toast = { text, kind, expiresAt: Date.now() + timeoutMs }; + this.toast = toast; + this.requestUpdate(); + setTimeout(() => { + if (this.toast === toast) { + this.toast = null; + this.requestUpdate(); + } + }, timeoutMs + 30); + } + + #handleActionStatus(evt) { + const detail = evt?.detail ?? null; + if (!detail || typeof detail.id !== "string") {return;} + if (!this.pendingAction || this.pendingAction.id !== detail.id) {return;} + + if (detail.ok) { + this.pendingAction = { ...this.pendingAction, phase: "sent", sentAt: Date.now() }; + } else { + const msg = typeof detail.error === "string" && detail.error ? detail.error : "send failed"; + this.pendingAction = { ...this.pendingAction, phase: "error", error: msg }; + this.#setToast(`Failed: ${msg}`, "error", 4500); + } + this.requestUpdate(); + } + + #handleA2UIAction(evt) { + const payload = evt?.detail ?? evt?.payload ?? null; + if (!payload || payload.eventType !== "a2ui.action") { + return; + } + + const action = payload.action; + const name = action?.name; + if (!name) { + return; + } + + const sourceComponentId = payload.sourceComponentId ?? ""; + const surfaces = this.#processor.getSurfaces(); + + let surfaceId = null; + let sourceNode = null; + for (const [sid, surface] of surfaces.entries()) { + const node = surface?.components?.get?.(sourceComponentId) ?? null; + if (node) { + surfaceId = sid; + sourceNode = node; + break; + } + } + + const context = {}; + const ctxItems = Array.isArray(action?.context) ? action.context : []; + for (const item of ctxItems) { + const key = item?.key; + const value = item?.value ?? null; + if (!key || !value) {continue;} + + if (typeof value.path === "string") { + const resolved = sourceNode + ? this.#processor.getData(sourceNode, value.path, surfaceId ?? undefined) + : null; + context[key] = resolved; + continue; + } + if (Object.prototype.hasOwnProperty.call(value, "literalString")) { + context[key] = value.literalString ?? ""; + continue; + } + if (Object.prototype.hasOwnProperty.call(value, "literalNumber")) { + context[key] = value.literalNumber ?? 0; + continue; + } + if (Object.prototype.hasOwnProperty.call(value, "literalBoolean")) { + context[key] = value.literalBoolean ?? false; + continue; + } + } + + const actionId = this.#makeActionId(); + this.pendingAction = { id: actionId, name, phase: "sending", startedAt: Date.now() }; + this.requestUpdate(); + + const userAction = { + id: actionId, + name, + surfaceId: surfaceId ?? "main", + sourceComponentId, + timestamp: new Date().toISOString(), + ...(Object.keys(context).length ? { context } : {}), + }; + + globalThis.__openclawLastA2UIAction = userAction; + + const handler = + globalThis.webkit?.messageHandlers?.openclawCanvasA2UIAction ?? + globalThis.openclawCanvasA2UIAction; + if (handler?.postMessage) { + try { + // WebKit message handlers support structured objects; Android's JS interface expects strings. + if (handler === globalThis.openclawCanvasA2UIAction) { + handler.postMessage(JSON.stringify({ userAction })); + } else { + handler.postMessage({ userAction }); + } + } catch (e) { + const msg = String(e?.message ?? e); + this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg }; + this.#setToast(`Failed: ${msg}`, "error", 4500); + } + } else { + this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: "missing native bridge" }; + this.#setToast("Failed: missing native bridge", "error", 4500); + } + } + + applyMessages(messages) { + if (!Array.isArray(messages)) { + throw new Error("A2UI: expected messages array"); + } + this.#processor.processMessages(messages); + this.#syncSurfaces(); + if (this.pendingAction?.phase === "sent") { + this.#setToast(`Updated: ${this.pendingAction.name}`, "ok", 1100); + this.pendingAction = null; + } + this.requestUpdate(); + return { ok: true, surfaces: this.surfaces.map(([id]) => id) }; + } + + reset() { + this.#processor.clearSurfaces(); + this.#syncSurfaces(); + this.pendingAction = null; + this.requestUpdate(); + return { ok: true }; + } + + #syncSurfaces() { + this.surfaces = Array.from(this.#processor.getSurfaces().entries()); + } + + render() { + if (this.surfaces.length === 0) { + return html`
+
Canvas (A2UI)
+
`; + } + + const statusText = + this.pendingAction?.phase === "sent" + ? `Working: ${this.pendingAction.name}` + : this.pendingAction?.phase === "sending" + ? `Sending: ${this.pendingAction.name}` + : this.pendingAction?.phase === "error" + ? `Failed: ${this.pendingAction.name}` + : ""; + + return html` + ${this.pendingAction && this.pendingAction.phase !== "error" + ? html`
${statusText}
` + : ""} + ${this.toast + ? html`
${this.toast.text}
` + : ""} +
+ ${repeat( + this.surfaces, + ([surfaceId]) => surfaceId, + ([surfaceId, surface]) => html`` + )} +
`; + } +} + +if (!customElements.get("openclaw-a2ui-host")) { + customElements.define("openclaw-a2ui-host", OpenClawA2UIHost); +} diff --git a/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs new file mode 100644 index 00000000..ccf1683d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs @@ -0,0 +1,67 @@ +import path from "node:path"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "../../../../.."); +const uiRoot = path.resolve(repoRoot, "ui"); +const fromHere = (p) => path.resolve(here, p); +const outputFile = path.resolve( + here, + "../../../../..", + "src", + "canvas-host", + "a2ui", + "a2ui.bundle.js", +); + +const a2uiLitDist = path.resolve(repoRoot, "vendor/a2ui/renderers/lit/dist/src"); +const a2uiThemeContext = path.resolve(a2uiLitDist, "0.8/ui/context/theme.js"); +const uiNodeModules = path.resolve(uiRoot, "node_modules"); +const repoNodeModules = path.resolve(repoRoot, "node_modules"); + +function resolveUiDependency(moduleId) { + const candidates = [ + path.resolve(uiNodeModules, moduleId), + path.resolve(repoNodeModules, moduleId), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + const fallbackCandidates = candidates.join(", "); + throw new Error( + `A2UI bundle config cannot resolve ${moduleId}. Checked: ${fallbackCandidates}. ` + + "Keep dependency installed in ui workspace or repo root before bundling.", + ); +} + +export default { + input: fromHere("bootstrap.js"), + experimental: { + attachDebugInfo: "none", + }, + treeshake: false, + resolve: { + alias: { + "@a2ui/lit": path.resolve(a2uiLitDist, "index.js"), + "@a2ui/lit/ui": path.resolve(a2uiLitDist, "0.8/ui/ui.js"), + "@openclaw/a2ui-theme-context": a2uiThemeContext, + "@lit/context": resolveUiDependency("@lit/context"), + "@lit/context/": resolveUiDependency("@lit/context/"), + "@lit-labs/signals": resolveUiDependency("@lit-labs/signals"), + "@lit-labs/signals/": resolveUiDependency("@lit-labs/signals/"), + lit: resolveUiDependency("lit"), + "lit/": resolveUiDependency("lit/"), + "signal-utils/": resolveUiDependency("signal-utils/"), + }, + }, + output: { + file: outputFile, + format: "esm", + codeSplitting: false, + sourcemap: false, + }, +}; diff --git a/backend/app/one_person_security_dept/openclaw/assets/avatar-placeholder.svg b/backend/app/one_person_security_dept/openclaw/assets/avatar-placeholder.svg new file mode 100644 index 00000000..d0a6999a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/assets/avatar-placeholder.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/README.md b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/README.md new file mode 100644 index 00000000..4ee072c1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/README.md @@ -0,0 +1,23 @@ +# OpenClaw Chrome Extension (Browser Relay) + +Purpose: attach OpenClaw to an existing Chrome tab so the Gateway can automate it (via the local CDP relay server). + +## Dev / load unpacked + +1. Build/run OpenClaw Gateway with browser control enabled. +2. Ensure the relay server is reachable at `http://127.0.0.1:18792/` (default). +3. Install the extension to a stable path: + + ```bash + openclaw browser extension install + openclaw browser extension path + ``` + +4. Chrome → `chrome://extensions` → enable “Developer mode”. +5. “Load unpacked” → select the path printed above. +6. Pin the extension. Click the icon on a tab to attach/detach. + +## Options + +- `Relay port`: defaults to `18792`. +- `Gateway token`: required. Set this to `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). diff --git a/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/background-utils.js b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/background-utils.js new file mode 100644 index 00000000..fe32d2c0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/background-utils.js @@ -0,0 +1,48 @@ +export function reconnectDelayMs( + attempt, + opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random }, +) { + const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000; + const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000; + const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000; + const random = typeof opts.random === "function" ? opts.random : Math.random; + const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0); + const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs); + return backoff + Math.max(0, jitterMs) * random(); +} + +export async function deriveRelayToken(gatewayToken, port) { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + enc.encode(gatewayToken), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + "HMAC", + key, + enc.encode(`openclaw-extension-relay-v1:${port}`), + ); + return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +export async function buildRelayWsUrl(port, gatewayToken) { + const token = String(gatewayToken || "").trim(); + if (!token) { + throw new Error( + "Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)", + ); + } + const relayToken = await deriveRelayToken(token, port); + return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(relayToken)}`; +} + +export function isRetryableReconnectError(err) { + const message = err instanceof Error ? err.message : String(err || ""); + if (message.includes("Missing gatewayToken")) { + return false; + } + return true; +} diff --git a/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/background.js b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/background.js new file mode 100644 index 00000000..60f50d65 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/background.js @@ -0,0 +1,899 @@ +import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' + +const DEFAULT_PORT = 18792 + +const BADGE = { + on: { text: 'ON', color: '#FF5A36' }, + off: { text: '', color: '#000000' }, + connecting: { text: '…', color: '#F59E0B' }, + error: { text: '!', color: '#B91C1C' }, +} + +/** @type {WebSocket|null} */ +let relayWs = null +/** @type {Promise|null} */ +let relayConnectPromise = null + +let nextSession = 1 + +/** @type {Map} */ +const tabs = new Map() +/** @type {Map} */ +const tabBySession = new Map() +/** @type {Map} */ +const childSessionToTab = new Map() + +/** @type {Mapvoid, reject:(e:Error)=>void}>} */ +const pending = new Map() + +// Per-tab operation locks prevent double-attach races. +/** @type {Set} */ +const tabOperationLocks = new Set() + +// Tabs currently in a detach/re-attach cycle after navigation. +/** @type {Set} */ +const reattachPending = new Set() + +// Reconnect state for exponential backoff. +let reconnectAttempt = 0 +let reconnectTimer = null + +function nowStack() { + try { + return new Error().stack || '' + } catch { + return '' + } +} + +async function getRelayPort() { + const stored = await chrome.storage.local.get(['relayPort']) + const raw = stored.relayPort + const n = Number.parseInt(String(raw || ''), 10) + if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT + return n +} + +async function getGatewayToken() { + const stored = await chrome.storage.local.get(['gatewayToken']) + const token = String(stored.gatewayToken || '').trim() + return token || '' +} + +function setBadge(tabId, kind) { + const cfg = BADGE[kind] + void chrome.action.setBadgeText({ tabId, text: cfg.text }) + void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color }) + void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {}) +} + +// Persist attached tab state to survive MV3 service worker restarts. +async function persistState() { + try { + const tabEntries = [] + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected' && tab.sessionId && tab.targetId) { + tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder }) + } + } + await chrome.storage.session.set({ + persistedTabs: tabEntries, + nextSession, + }) + } catch { + // chrome.storage.session may not be available in all contexts. + } +} + +// Rehydrate tab state on service worker startup. Fast path — just restores +// maps and badges. Relay reconnect happens separately in background. +async function rehydrateState() { + try { + const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession']) + if (stored.nextSession) { + nextSession = Math.max(nextSession, stored.nextSession) + } + const entries = stored.persistedTabs || [] + // Phase 1: optimistically restore state and badges. + for (const entry of entries) { + tabs.set(entry.tabId, { + state: 'connected', + sessionId: entry.sessionId, + targetId: entry.targetId, + attachOrder: entry.attachOrder, + }) + tabBySession.set(entry.sessionId, entry.tabId) + setBadge(entry.tabId, 'on') + } + // Phase 2: validate asynchronously, remove dead tabs. + for (const entry of entries) { + try { + await chrome.tabs.get(entry.tabId) + await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', { + expression: '1', + returnByValue: true, + }) + } catch { + tabs.delete(entry.tabId) + tabBySession.delete(entry.sessionId) + setBadge(entry.tabId, 'off') + } + } + } catch { + // Ignore rehydration errors. + } +} + +async function ensureRelayConnection() { + if (relayWs && relayWs.readyState === WebSocket.OPEN) return + if (relayConnectPromise) return await relayConnectPromise + + relayConnectPromise = (async () => { + const port = await getRelayPort() + const gatewayToken = await getGatewayToken() + const httpBase = `http://127.0.0.1:${port}` + const wsUrl = await buildRelayWsUrl(port, gatewayToken) + + // Fast preflight: is the relay server up? + try { + await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) }) + } catch (err) { + throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) + } + + const ws = new WebSocket(wsUrl) + relayWs = ws + + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000) + ws.onopen = () => { + clearTimeout(t) + resolve() + } + ws.onerror = () => { + clearTimeout(t) + reject(new Error('WebSocket connect failed')) + } + ws.onclose = (ev) => { + clearTimeout(t) + reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`)) + } + }) + + // Bind permanent handlers. Guard against stale socket: if this WS was + // replaced before its close fires, the handler is a no-op. + ws.onmessage = (event) => { + if (ws !== relayWs) return + void whenReady(() => onRelayMessage(String(event.data || ''))) + } + ws.onclose = () => { + if (ws !== relayWs) return + onRelayClosed('closed') + } + ws.onerror = () => { + if (ws !== relayWs) return + onRelayClosed('error') + } + })() + + try { + await relayConnectPromise + reconnectAttempt = 0 + } finally { + relayConnectPromise = null + } +} + +// Relay closed — update badges, reject pending requests, auto-reconnect. +// Debugger sessions are kept alive so they survive transient WS drops. +function onRelayClosed(reason) { + relayWs = null + + for (const [id, p] of pending.entries()) { + pending.delete(id) + p.reject(new Error(`Relay disconnected (${reason})`)) + } + + reattachPending.clear() + + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected') { + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay reconnecting…', + }) + } + } + + scheduleReconnect() +} + +function scheduleReconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + + const delay = reconnectDelayMs(reconnectAttempt) + reconnectAttempt++ + + console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`) + + reconnectTimer = setTimeout(async () => { + reconnectTimer = null + try { + await ensureRelayConnection() + reconnectAttempt = 0 + console.log('Reconnected successfully') + await reannounceAttachedTabs() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`) + if (!isRetryableReconnectError(err)) { + return + } + scheduleReconnect() + } + }, delay) +} + +function cancelReconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + reconnectAttempt = 0 +} + +// Re-announce all attached tabs to the relay after reconnect. +async function reannounceAttachedTabs() { + for (const [tabId, tab] of tabs.entries()) { + if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue + + // Verify debugger is still attached. + try { + await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + expression: '1', + returnByValue: true, + }) + } catch { + tabs.delete(tabId) + if (tab.sessionId) tabBySession.delete(tab.sessionId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay (click to attach/detach)', + }) + continue + } + + // Send fresh attach event to relay. + try { + const info = /** @type {any} */ ( + await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo') + ) + const targetInfo = info?.targetInfo + + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.attachedToTarget', + params: { + sessionId: tab.sessionId, + targetInfo: { ...targetInfo, attached: true }, + waitingForDebugger: false, + }, + }, + }) + + setBadge(tabId, 'on') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: attached (click to detach)', + }) + } catch { + setBadge(tabId, 'on') + } + } + + await persistState() +} + +function sendToRelay(payload) { + const ws = relayWs + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error('Relay not connected') + } + ws.send(JSON.stringify(payload)) +} + +async function maybeOpenHelpOnce() { + try { + const stored = await chrome.storage.local.get(['helpOnErrorShown']) + if (stored.helpOnErrorShown === true) return + await chrome.storage.local.set({ helpOnErrorShown: true }) + await chrome.runtime.openOptionsPage() + } catch { + // ignore + } +} + +function requestFromRelay(command) { + const id = command.id + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id) + reject(new Error('Relay request timeout (30s)')) + }, 30000) + pending.set(id, { + resolve: (v) => { clearTimeout(timer); resolve(v) }, + reject: (e) => { clearTimeout(timer); reject(e) }, + }) + try { + sendToRelay(command) + } catch (err) { + clearTimeout(timer) + pending.delete(id) + reject(err instanceof Error ? err : new Error(String(err))) + } + }) +} + +async function onRelayMessage(text) { + /** @type {any} */ + let msg + try { + msg = JSON.parse(text) + } catch { + return + } + + if (msg && msg.method === 'ping') { + try { + sendToRelay({ method: 'pong' }) + } catch { + // ignore + } + return + } + + if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) { + const p = pending.get(msg.id) + if (!p) return + pending.delete(msg.id) + if (msg.error) p.reject(new Error(String(msg.error))) + else p.resolve(msg.result) + return + } + + if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') { + try { + const result = await handleForwardCdpCommand(msg) + sendToRelay({ id: msg.id, result }) + } catch (err) { + sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) }) + } + } +} + +function getTabBySessionId(sessionId) { + const direct = tabBySession.get(sessionId) + if (direct) return { tabId: direct, kind: 'main' } + const child = childSessionToTab.get(sessionId) + if (child) return { tabId: child, kind: 'child' } + return null +} + +function getTabByTargetId(targetId) { + for (const [tabId, tab] of tabs.entries()) { + if (tab.targetId === targetId) return tabId + } + return null +} + +async function attachTab(tabId, opts = {}) { + const debuggee = { tabId } + await chrome.debugger.attach(debuggee, '1.3') + await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {}) + + const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo')) + const targetInfo = info?.targetInfo + const targetId = String(targetInfo?.targetId || '').trim() + if (!targetId) { + throw new Error('Target.getTargetInfo returned no targetId') + } + + const sid = nextSession++ + const sessionId = `cb-tab-${sid}` + const attachOrder = sid + + tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder }) + tabBySession.set(sessionId, tabId) + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: attached (click to detach)', + }) + + if (!opts.skipAttachedEvent) { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.attachedToTarget', + params: { + sessionId, + targetInfo: { ...targetInfo, attached: true }, + waitingForDebugger: false, + }, + }, + }) + } + + setBadge(tabId, 'on') + await persistState() + + return { sessionId, targetId } +} + +async function detachTab(tabId, reason) { + const tab = tabs.get(tabId) + + // Send detach events for child sessions first. + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: childSessionId, reason: 'parent_detached' }, + }, + }) + } catch { + // Relay may be down. + } + childSessionToTab.delete(childSessionId) + } + } + + // Send detach event for main session. + if (tab?.sessionId && tab?.targetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: tab.sessionId, targetId: tab.targetId, reason }, + }, + }) + } catch { + // Relay may be down. + } + } + + if (tab?.sessionId) tabBySession.delete(tab.sessionId) + tabs.delete(tabId) + + try { + await chrome.debugger.detach({ tabId }) + } catch { + // May already be detached. + } + + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay (click to attach/detach)', + }) + + await persistState() +} + +async function connectOrToggleForActiveTab() { + const [active] = await chrome.tabs.query({ active: true, currentWindow: true }) + const tabId = active?.id + if (!tabId) return + + // Prevent concurrent operations on the same tab. + if (tabOperationLocks.has(tabId)) return + tabOperationLocks.add(tabId) + + try { + if (reattachPending.has(tabId)) { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay (click to attach/detach)', + }) + return + } + + const existing = tabs.get(tabId) + if (existing?.state === 'connected') { + await detachTab(tabId, 'toggle') + return + } + + // User is manually connecting — cancel any pending reconnect. + cancelReconnect() + + tabs.set(tabId, { state: 'connecting' }) + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: connecting to local relay…', + }) + + try { + await ensureRelayConnection() + await attachTab(tabId) + } catch (err) { + tabs.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay not running (open options for setup)', + }) + void maybeOpenHelpOnce() + const message = err instanceof Error ? err.message : String(err) + console.warn('attach failed', message, nowStack()) + } + } finally { + tabOperationLocks.delete(tabId) + } +} + +async function handleForwardCdpCommand(msg) { + const method = String(msg?.params?.method || '').trim() + const params = msg?.params?.params || undefined + const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined + + const bySession = sessionId ? getTabBySessionId(sessionId) : null + const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined + const tabId = + bySession?.tabId || + (targetId ? getTabByTargetId(targetId) : null) || + (() => { + for (const [id, tab] of tabs.entries()) { + if (tab.state === 'connected') return id + } + return null + })() + + if (!tabId) throw new Error(`No attached tab for method ${method}`) + + /** @type {chrome.debugger.DebuggerSession} */ + const debuggee = { tabId } + + if (method === 'Runtime.enable') { + try { + await chrome.debugger.sendCommand(debuggee, 'Runtime.disable') + await new Promise((r) => setTimeout(r, 50)) + } catch { + // ignore + } + return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params) + } + + if (method === 'Target.createTarget') { + const url = typeof params?.url === 'string' ? params.url : 'about:blank' + const tab = await chrome.tabs.create({ url, active: false }) + if (!tab.id) throw new Error('Failed to create tab') + await new Promise((r) => setTimeout(r, 100)) + const attached = await attachTab(tab.id) + return { targetId: attached.targetId } + } + + if (method === 'Target.closeTarget') { + const target = typeof params?.targetId === 'string' ? params.targetId : '' + const toClose = target ? getTabByTargetId(target) : tabId + if (!toClose) return { success: false } + try { + await chrome.tabs.remove(toClose) + } catch { + return { success: false } + } + return { success: true } + } + + if (method === 'Target.activateTarget') { + const target = typeof params?.targetId === 'string' ? params.targetId : '' + const toActivate = target ? getTabByTargetId(target) : tabId + if (!toActivate) return {} + const tab = await chrome.tabs.get(toActivate).catch(() => null) + if (!tab) return {} + if (tab.windowId) { + await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}) + } + await chrome.tabs.update(toActivate, { active: true }).catch(() => {}) + return {} + } + + const tabState = tabs.get(tabId) + const mainSessionId = tabState?.sessionId + const debuggerSession = + sessionId && mainSessionId && sessionId !== mainSessionId + ? { ...debuggee, sessionId } + : debuggee + + return await chrome.debugger.sendCommand(debuggerSession, method, params) +} + +function onDebuggerEvent(source, method, params) { + const tabId = source.tabId + if (!tabId) return + const tab = tabs.get(tabId) + if (!tab?.sessionId) return + + if (method === 'Target.attachedToTarget' && params?.sessionId) { + childSessionToTab.set(String(params.sessionId), tabId) + } + + if (method === 'Target.detachedFromTarget' && params?.sessionId) { + childSessionToTab.delete(String(params.sessionId)) + } + + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + sessionId: source.sessionId || tab.sessionId, + method, + params, + }, + }) + } catch { + // Relay may be down. + } +} + +async function onDebuggerDetach(source, reason) { + const tabId = source.tabId + if (!tabId) return + if (!tabs.has(tabId)) return + + // User explicitly cancelled or DevTools replaced the connection — respect their intent + if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { + void detachTab(tabId, reason) + return + } + + // Check if tab still exists — distinguishes navigation from tab close + let tabInfo + try { + tabInfo = await chrome.tabs.get(tabId) + } catch { + // Tab is gone (closed) — normal cleanup + void detachTab(tabId, reason) + return + } + + if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) { + void detachTab(tabId, reason) + return + } + + if (reattachPending.has(tabId)) return + + const oldTab = tabs.get(tabId) + const oldSessionId = oldTab?.sessionId + const oldTargetId = oldTab?.targetId + + if (oldSessionId) tabBySession.delete(oldSessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + + if (oldSessionId && oldTargetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' }, + }, + }) + } catch { + // Relay may be down. + } + } + + reattachPending.add(tabId) + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attaching after navigation…', + }) + + const delays = [300, 700, 1500] + for (let attempt = 0; attempt < delays.length; attempt++) { + await new Promise((r) => setTimeout(r, delays[attempt])) + + if (!reattachPending.has(tabId)) return + + try { + await chrome.tabs.get(tabId) + } catch { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + return + } + + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + reattachPending.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay disconnected during re-attach', + }) + return + } + + try { + await attachTab(tabId) + reattachPending.delete(tabId) + return + } catch { + // continue retries + } + } + + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attach failed (click to retry)', + }) +} + +// Tab lifecycle listeners — clean up stale entries. +chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { + reattachPending.delete(tabId) + if (!tabs.has(tabId)) return + const tab = tabs.get(tabId) + if (tab?.sessionId) tabBySession.delete(tab.sessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + if (tab?.sessionId && tab?.targetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' }, + }, + }) + } catch { + // Relay may be down. + } + } + void persistState() +})) + +chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => { + const tab = tabs.get(removedTabId) + if (!tab) return + tabs.delete(removedTabId) + tabs.set(addedTabId, tab) + if (tab.sessionId) { + tabBySession.set(tab.sessionId, addedTabId) + } + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === removedTabId) { + childSessionToTab.set(childSessionId, addedTabId) + } + } + setBadge(addedTabId, 'on') + void persistState() +})) + +// Register debugger listeners at module scope so detach/event handling works +// even when the relay WebSocket is down. +chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args))) +chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args))) + +chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab())) + +// Refresh badge after navigation completes — service worker may have restarted +// during navigation, losing ephemeral badge state. +chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => { + if (frameId !== 0) return + const tab = tabs.get(tabId) + if (tab?.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } +})) + +// Refresh badge when user switches to an attached tab. +chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => { + const tab = tabs.get(tabId) + if (tab?.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } +})) + +chrome.runtime.onInstalled.addListener(() => { + void chrome.runtime.openOptionsPage() +}) + +// MV3 keepalive via chrome.alarms — more reliable than setInterval across +// service worker restarts. Checks relay health and refreshes badges. +chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 }) + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name !== 'relay-keepalive') return + await initPromise + + if (tabs.size === 0) return + + // Refresh badges (ephemeral in MV3). + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } + } + + // If relay is down and no reconnect is in progress, trigger one. + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + if (!relayConnectPromise && !reconnectTimer) { + console.log('Keepalive: WebSocket unhealthy, triggering reconnect') + await ensureRelayConnection().catch(() => { + // ensureRelayConnection may throw without triggering onRelayClosed + // (e.g. preflight fetch fails before WS is created), so ensure + // reconnect is always scheduled on failure. + if (!reconnectTimer) { + scheduleReconnect() + } + }) + } + } +}) + +// Rehydrate state on service worker startup. Split: rehydration is the gate +// (fast), relay reconnect runs in background (slow, non-blocking). +const initPromise = rehydrateState() + +initPromise.then(() => { + if (tabs.size > 0) { + ensureRelayConnection().then(() => { + reconnectAttempt = 0 + return reannounceAttachedTabs() + }).catch(() => { + scheduleReconnect() + }) + } +}) + +// Shared gate: all state-dependent handlers await this before accessing maps. +async function whenReady(fn) { + await initPromise + return fn() +} + +// Relay check handler for the options page. The service worker has +// host_permissions and bypasses CORS preflight, so the options page +// delegates token-validation requests here. +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type !== 'relayCheck') return false + const { url, token } = msg + const headers = token ? { 'x-openclaw-relay-token': token } : {} + fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) }) + .then(async (res) => { + const contentType = String(res.headers.get('content-type') || '') + let json = null + if (contentType.includes('application/json')) { + try { + json = await res.json() + } catch { + json = null + } + } + sendResponse({ status: res.status, ok: res.ok, contentType, json }) + }) + .catch((err) => sendResponse({ status: 0, ok: false, error: String(err) })) + return true +}) diff --git a/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/icons/icon128.png b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/icons/icon128.png new file mode 100644 index 00000000..533cc812 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/icons/icon128.png differ diff --git a/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/icons/icon16.png b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/icons/icon16.png new file mode 100644 index 00000000..1be23ae8 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/icons/icon16.png differ diff --git a/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/icons/icon32.png b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/icons/icon32.png new file mode 100644 index 00000000..f4c1be8a Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/icons/icon32.png differ diff --git a/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/icons/icon48.png b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/icons/icon48.png new file mode 100644 index 00000000..d2a278af Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/icons/icon48.png differ diff --git a/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/manifest.json b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/manifest.json new file mode 100644 index 00000000..62038276 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "OpenClaw Browser Relay", + "version": "0.1.0", + "description": "Attach OpenClaw to your existing Chrome tab via a local CDP relay server.", + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"], + "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"], + "background": { "service_worker": "background.js", "type": "module" }, + "action": { + "default_title": "OpenClaw Browser Relay (click to attach/detach)", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "options_ui": { "page": "options.html", "open_in_tab": true } +} diff --git a/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/options-validation.js b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/options-validation.js new file mode 100644 index 00000000..53e2cd55 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/options-validation.js @@ -0,0 +1,57 @@ +const PORT_GUIDANCE = 'Use gateway port + 3 (for gateway 18789, relay is 18792).' + +function hasCdpVersionShape(data) { + return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data +} + +export function classifyRelayCheckResponse(res, port) { + if (!res) { + return { action: 'throw', error: 'No response from service worker' } + } + + if (res.status === 401) { + return { action: 'status', kind: 'error', message: 'Gateway token rejected. Check token and save again.' } + } + + if (res.error) { + return { action: 'throw', error: res.error } + } + + if (!res.ok) { + return { action: 'throw', error: `HTTP ${res.status}` } + } + + const contentType = String(res.contentType || '') + if (!contentType.includes('application/json')) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: this is likely the gateway, not the relay. ${PORT_GUIDANCE}`, + } + } + + if (!hasCdpVersionShape(res.json)) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: expected relay /json/version response. ${PORT_GUIDANCE}`, + } + } + + return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://127.0.0.1:${port}/` } +} + +export function classifyRelayCheckException(err, port) { + const message = String(err || '').toLowerCase() + if (message.includes('json') || message.includes('syntax')) { + return { + kind: 'error', + message: `Wrong port: this is not a relay endpoint. ${PORT_GUIDANCE}`, + } + } + + return { + kind: 'error', + message: `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, + } +} diff --git a/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/options.html b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/options.html new file mode 100644 index 00000000..17fc6a79 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/options.html @@ -0,0 +1,200 @@ + + + + + + OpenClaw Browser Relay + + + +
+
+ +
+

OpenClaw Browser Relay

+

Click the toolbar button on a tab to attach / detach.

+
+
+ +
+
+

Getting started

+

+ If you see a red ! badge on the extension icon, the relay server is not reachable. + Start OpenClaw’s browser relay on this machine (Gateway or node host), then click the toolbar button again. +

+

+ Full guide (install, remote Gateway, security): docs.openclaw.ai/tools/chrome-extension +

+
+ +
+

Relay connection

+ +
+ +
+ +
+ + +
+
+ Default port: 18792. Extension connects to: http://127.0.0.1:<port>/. + Gateway token must match gateway.auth.token (or OPENCLAW_GATEWAY_TOKEN). +
+
+
+
+ + +
+ + diff --git a/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/options.js b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/options.js new file mode 100644 index 00000000..aa6fcc49 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/assets/chrome-extension/options.js @@ -0,0 +1,74 @@ +import { deriveRelayToken } from './background-utils.js' +import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js' + +const DEFAULT_PORT = 18792 + +function clampPort(value) { + const n = Number.parseInt(String(value || ''), 10) + if (!Number.isFinite(n)) return DEFAULT_PORT + if (n <= 0 || n > 65535) return DEFAULT_PORT + return n +} + +function updateRelayUrl(port) { + const el = document.getElementById('relay-url') + if (!el) return + el.textContent = `http://127.0.0.1:${port}/` +} + +function setStatus(kind, message) { + const status = document.getElementById('status') + if (!status) return + status.dataset.kind = kind || '' + status.textContent = message || '' +} + +async function checkRelayReachable(port, token) { + const url = `http://127.0.0.1:${port}/json/version` + const trimmedToken = String(token || '').trim() + if (!trimmedToken) { + setStatus('error', 'Gateway token required. Save your gateway token to connect.') + return + } + try { + const relayToken = await deriveRelayToken(trimmedToken, port) + // Delegate the fetch to the background service worker to bypass + // CORS preflight on the custom x-openclaw-relay-token header. + const res = await chrome.runtime.sendMessage({ + type: 'relayCheck', + url, + token: relayToken, + }) + const result = classifyRelayCheckResponse(res, port) + if (result.action === 'throw') throw new Error(result.error) + setStatus(result.kind, result.message) + } catch (err) { + const result = classifyRelayCheckException(err, port) + setStatus(result.kind, result.message) + } +} + +async function load() { + const stored = await chrome.storage.local.get(['relayPort', 'gatewayToken']) + const port = clampPort(stored.relayPort) + const token = String(stored.gatewayToken || '').trim() + document.getElementById('port').value = String(port) + document.getElementById('token').value = token + updateRelayUrl(port) + await checkRelayReachable(port, token) +} + +async function save() { + const portInput = document.getElementById('port') + const tokenInput = document.getElementById('token') + const port = clampPort(portInput.value) + const token = String(tokenInput.value || '').trim() + await chrome.storage.local.set({ relayPort: port, gatewayToken: token }) + portInput.value = String(port) + tokenInput.value = token + updateRelayUrl(port) + await checkRelayReachable(port, token) +} + +document.getElementById('save').addEventListener('click', () => void save()) +void load() diff --git a/backend/app/one_person_security_dept/openclaw/assets/dmg-background-small.png b/backend/app/one_person_security_dept/openclaw/assets/dmg-background-small.png new file mode 100644 index 00000000..74fc56a9 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/assets/dmg-background-small.png differ diff --git a/backend/app/one_person_security_dept/openclaw/assets/dmg-background.png b/backend/app/one_person_security_dept/openclaw/assets/dmg-background.png new file mode 100644 index 00000000..a3bff038 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/assets/dmg-background.png differ diff --git a/backend/app/one_person_security_dept/openclaw/curriculum/casebook.md b/backend/app/one_person_security_dept/openclaw/curriculum/casebook.md new file mode 100644 index 00000000..5c026938 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/curriculum/casebook.md @@ -0,0 +1,82 @@ +# 双班型案例手册(增强版) + +## 使用方式 +- 启航班: 每讲 1 个主案例 + 1 个微练习 +- 精英班: 每讲 1 个主案例 + 1 个反例 + 1 个复盘任务 +- 复盘统一口径: 根因 -> 证据 -> 修复 -> 回归 -> 预防(不写感想, 只写可执行动作) + +## 启航班案例(简单, 快反馈) + +| 讲次 | 主案例 | 场景目标 | 微练习(10-15min) | 学员输出 | 验收点 | +|---|---|---|---|---|---| +| 1 | 一页式需求说明 | 20 分钟内把口头需求转成结构化文档 | 给一段“模糊需求”, 补齐范围与风险 | 1 页需求说明(背景/目标/范围/风险) | 结构完整; 风险具体 | +| 2 | 周报压缩器 | 把 12 条流水账变成可汇报周报 | 把 1 条成果改成“动作+结果+指标” | 周报双版本(团队版+管理层版) | 双版本差异明显; CTA 明确 | +| 3 | 英文邮件 3 语气改写 | 一封邮件生成中性/礼貌/升级三版 | 给同一邮件补齐“截止时间+下一步” | 英文邮件模板 1 套 | 语气分级清晰; 无虚词 | +| 4 | 去 AI 味改稿 | 把“机器味”总结改成真人表达 | 删掉 5 句套话并补 3 个事实 | A/B 两版改写结果 | 事实充分; 口吻一致 | +| 5 | 图片说明文案 | 给产品图生成投放文案与禁用词 | 写一版“合规改写”避免夸张词 | 图文说明卡片 | 禁用词清单可执行 | +| 6 | 什么是知识库 | 对比“无知识库回答”和“有知识库回答” | 给一个回答补上“来源/不确定” | 差异对照表 | 能指出幻觉点与缺证据点 | +| 7 | OpenClaw RAG 演示 | 演示 RAG 前后效果与引用 | 修改切片大小看召回变化 | RAG 价值说明单 | 能说清“检索失败”原因 | +| 8 | 轻项目答辩 | 文档+周报+邮件助手联动 | 现场加一个风格约束并复测 | 可复用 Prompt 包 | 可演示; 模板可复用 | + +## 精英班案例(复杂, 强实战) + +| 讲次 | 主案例(成功路径) | 反例(故意失败) | 复盘任务(必须提交) | 学员输出 | 验收点 | +|---|---|---|---|---|---| +| 1 | AI 机会点拆解 | 选一个“数据拿不到/合规过不了”的炫技场景 | 用约束重写机会点清单并给验证计划 | AI 机会点清单 | 可验证; 有边界; 风险清晰 | +| 2 | 去 AI 味公文改写 | 输出“赋能/闭环/抓手”堆砌版 | 用风格约束做 A/B 改写并解释差异 | 风格约束模板 | 去套话; 事实密度提升 | +| 3 | RLHF 直觉模拟 | 把 RLHF 当“随便打分就行” | 写清偏好数据来源与标注规则 | 范式应用图 | 能说清对齐目标与代价 | +| 4 | 指标冲突复盘 | 只追 accuracy 导致漏召回严重 | 改成“召回优先+重排”的指标组合 | 指标治理表 | 指标能映射业务 KPI | +| 5 | 训练稳定性排障 | 学习率过大导致发散 | 给出 3 个可复现实验与修复方案 | 训练稳定性清单 | 根因有证据; 修复可回归 | +| 6 | 模型家族选型会 | 盲上大模型导致成本失控 | 用成本/时延约束重做选型 | 模型选型备忘录 | 取舍清楚; 成本可估 | +| 7 | 长文记忆失败故障 | 把所有材料全塞上下文导致跑偏 | 做“裁剪+摘要+引用”的上下文策略 | 上下文故障报告 | 能解释忘与乱的机制 | +| 8 | 多轮推理任务链 | CoT 变啰嗦且不提升成功率 | 做纠偏:约束步骤与验收格式 | 推理模板库 | 通过率可量化提升 | +| 9 | OpenClaw Context 改造 | 记忆写入过多导致污染 | 设计会话/摘要/知识三层记忆策略 | Context 设计文档 | 写入/检索策略合理 | +| 10 | OpenClaw RAG 基线搭建 | 不引用来源导致不可追溯 | 增加引用约束并做一次误召回复盘 | RAG 基线方案 | 必须引用; 可回放复盘 | +| 11 | RAG 重排与 Self-RAG | Re-ranking 接入后效果变差 | 做评估对比并给参数/切片修复 | RAG 评估报告 | 评估方法正确; 提升可证 | +| 12 | OpenClaw Agent 任务链 | 无确认直接调用高风险工具 | 加确认与白名单并记录审计日志 | Agent 编排图 | 有回滚; 有审计; 边界清晰 | +| 13 | 多 Agent 协作仲裁 | 多 Agent 冲突导致互相推诿 | 引入仲裁策略与一致性约束 | 协作协议文档 | 冲突可收敛; 状态可追踪 | +| 14 | 微调路线选型 | 用脏数据微调导致整体退化 | 制定数据筛选与回归集, 决策是否微调 | 微调决策树 | 数据约束写清; 有回归集 | +| 15 | 推理性能攻防 | 量化后准确率大幅下降 | 做压测与质量对比, 选择可用组合 | 性能压测与复盘 | 指标达标; 结论可复现 | +| 16 | 全链路上线答辩 | 线上故障无监控无法定位 | 写 1 页事故复盘 + runbook + 回滚策略 | 结课交付包 | 可部署; 可观测; 可回滚 | + +## 精英班能否独立构造 OpenClaw(能力映射) + +| 构造闯关 | 关联讲次 | 必须做出的东西 | 通过标准 | +|---|---|---|---| +| G1 Gateway 最小内核 | 7,16 | 最小请求/响应 + 事件广播链路 | 可稳定跑通最小链路并有日志 | +| G2 会话与路由内核 | 9,16 | 会话键设计 + 路由策略 + 状态持久化 | 3 类消息路由正确且可复盘 | +| G3 模型执行与降级 | 12,15,16 | provider 适配 + fallback 策略 | 主模型失败可自动降级且有原因说明 | +| G4 RAG 内核 | 10,11 | 索引/切片/召回/重排/引用回答 | 答案可追溯来源且误召回可定位 | +| G5 Agent 工具内核 | 12,13 | Function Calling + 工具白名单 + 沙箱 | 高风险工具被拦截, 合法任务可完成 | +| G6 企业化交付 | 15,16 | Docker/API/监控/回滚/合规清单 | 可部署可观测可回滚并完成答辩 | + +## OpenClaw 专题讲解脚本(RAG 与知识库) + +### 专题 A: 什么是知识库 +- 定义: 知识库是可维护、可检索、可更新的组织知识资产 +- 误区: 不是把 PDF 丢进文件夹就叫知识库 +- 标准: 来源清楚、版本清楚、过期可追踪 + +### 专题 B: 什么是 RAG +- 公式: RAG = 检索(Retrieve)+ 生成(Generate) +- 价值: 降低幻觉, 提升可追溯性 +- 边界: 检索质量差时, 生成再强也会答错 + +### 专题 C: OpenClaw 上的落地路径 +1. 准备知识文档(结构化命名 + 版本标记) +2. 建立检索策略(切片、召回、重排) +3. 配置问答约束(必须引用来源, 无依据要明确不确定) +4. 做失败复盘(误召回、漏召回、上下文污染) + +## 训练与推理优化专题讲法(精英班) +- 动画: 梯度下降马鞍图, 展示局部最优与震荡 +- 对比: 量化前后精度/吞吐/时延三轴对比 +- 案例: 同一业务目标下, “SFT+LoRA”与“纯 Prompt 工程”的成本效果对比 + +## 班级节奏差异(讲师执行) +- 启航班: 讲清楚 + 会用 + 能复用 +- 精英班: 讲透彻 + 能排障 + 能上线 + +## 课堂评分建议 +- 启航班: 模板复用率 40%, 输出质量 40%, 表达清晰 20% +- 精英班: 方案完整度 30%, 实战结果 40%, 复盘质量 30% diff --git a/backend/app/one_person_security_dept/openclaw/curriculum/dual-track-course-outline.md b/backend/app/one_person_security_dept/openclaw/curriculum/dual-track-course-outline.md new file mode 100644 index 00000000..549dea1e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/curriculum/dual-track-course-outline.md @@ -0,0 +1,111 @@ +# 双班型课程总纲(可交付版) + +## 0) 使用说明(讲师与运营) +- 目标: 让学员从“会用 AI”走到“能把 AI 做成可复用交付” +- 班型: 启航班(轻实战, 动画多)+ 精英班(重实战, 案例多) +- 结构: 两个班分开写, 不做同表横向对比 +- 主线: 课程之间强关联, 按“电视剧式任务线”推进 +- 交付包: 课表总纲(本文件)+ 案例手册(`casebook.md`)+ 私有 Prompt 包(`private-prompt-pack.md`)+ 评分量表(`rubrics-and-checklists.md`) +- 精英班结课包: 见 `elite-capstone-kit.md`(mini-openclaw + OpenClaw 深度集成验收口径) +- PPT 版课程内容表: + - 启航班: `launch-class-content-table.md` + - 精英班: `elite-class-content-table.md` + +## 1) 启航班(8讲, 2h/讲, 可交付) + +### 启航班定位 +- 学员画像: 非工程为主, 以工作提效与业务交付为目标 +- 教学原则: 只讲“大模型必需理解的那部分”, 不堆公式; 每讲都有可复用模板 +- 结课产出: 1 个“工作三件套”AI 助手(文档+周报+英文邮件)+ 1 份最小 RAG/Agent 认知与复盘卡 + +### 启航班每讲课件大纲(含验收要点) + +| 对应模块 | 知识板块 | 学习目标 | 必讲内容(只保留 LLM 相关) | 课堂案例/演示 | 练习与作业(可交付) | 验收要点 | +|:--|:-:|---|---|---|---|---| +| 1 | AI 发展与 Prompt 认知 | 建立 Agentic AI 直觉, 明确边界 | 用简史串起传统 AI 到大模型的关键拐点, 让学员知道“为什么今天能生成”。
用正反例说明能力边界: 缺事实会编、缺约束会跑偏、超窗口会遗忘。
把 Prompt 定义为“任务说明书”, 固定包含目标、输入、约束与验收。
总结常见失败模式并给出纠偏动作: 补信息、加约束、定格式、做自检。 | 口头需求 -> 一页式需求说明 | 作业: 个人 Prompt 入门卡 + 1 页需求说明 | 结构完整; 无空话; 结论可追溯输入 | +| 4 | Token 与上下文(动画) | 解释“为什么会忘/为什么会乱” | 讲清 Token 如何拆分与计数输入, 让学员能估算长度与成本。
用动画演示上下文窗口的截断机制, 解释遗忘与指令冲突的根因。
给出约束优先级写法, 让模型先遵守规则再生成内容。
演示三种稳态输出手段: 先结构后内容、固定字段、字段校验。 | PRD/方案结构化生成演示 | 作业: 团队文档 Prompt 卡片(PRD/方案/纪要) | 固定结构; 约束明确; 可复用 | +| 4 | Prompt 工程入门 | 让输出稳定可控 | 解释 Zero-shot 与 Few-shot 的适用边界, 让学员知道何时必须提供示例。
用 ICL 直觉说明“给例子”如何改变推理路径, 以及例子为什么会污染风格。
给出错误纠偏 3 步: 先定位失败类型, 再补约束/补信息, 最后做自检回归。 | 周报两版生成(团队版/管理层版) | 作业: 个人周报工作流 Prompt(含风险模板) | 输出双版本; 风险三要素齐全 | +| 4 | 去 AI 味表达 | 把“像 AI”改成“像人写” | 定义可识别的 AI 味特征, 让学员能用检查清单快速定位问题句。
用“删套话+补事实/数字+加对象与边界”把空泛输出改成可落地表达。
用口吻约束让团队输出一致且可控, 避免同一任务风格漂移。
用禁用词清单做质量门禁, 把不合规或夸张表达在生成阶段拦截。 | 英文邮件 3 语气改写 | 作业: 英文邮件模板包 + 去 AI 味检查清单 | CTA 明确; 语气区分; 无模板腔 | +| 5 | 什么是知识库与 RAG(最小版) | 讲清 RAG = 检索+生成 | 用 Embeddings 的直觉解释“相似度检索”为什么能把问题对到相关片段。
讲清切片策略如何影响召回质量, 以及切片过大/过小分别会造成什么错误。
把 RAG 拆成召回与生成两段, 强制回答引用来源来降低幻觉。
用复盘模板把误召回/漏召回定位到具体环节, 为下一次优化提供证据。 | 智能客服最小问答 Demo | 作业: RAG 术语与流程卡 + 1 次失败复盘 | 答案带来源; 复盘能定位到链路环节 | +| 6 | Agent 与工具调用(最小版) | 让 Agent 能做“可控任务链” | 讲清 Function Calling 是模型产出结构化参数并触发工具调用, 而不是“让模型直接执行”。
用工具白名单约束 Agent 的可用能力, 避免越权和不可控副作用。
在人机确认点强制停住高风险动作, 把“自动化”变成“可审计的半自动”。
用会话/摘要/知识三层记忆降低上下文成本, 同时控制记忆污染。 | 单 Agent + 单工具任务链 | 作业: 1 个任务链脚本(含确认与回滚) | 工具边界清晰; 有失败退出路径 | +| 7 | 评估与优化(决策版) | 学会“何时用 Prompt/RAG/微调” | 只讲检索相关的指标直觉, 用精度/召回/F1 解释“为什么看起来对, 实际不好用”。
把微调谱系串成一条路线图, 让学员知道 SFT/LoRA/QLoRA/DPO/RLHF 分别解决什么问题与代价。
用推理优化认知建立成本观, 讲清量化、vLLM、Flash Attention 影响的是吞吐/时延/费用的哪一边。
给出选型决策法: 先 Prompt 稳定, 再 RAG 补知识, 最后才考虑微调与推理优化。 | 3 种路线成本效果对比 | 作业: 选型对照表(业务目标->路线->成本) | 能说清取舍; 指标与 KPI 映射合理 | +| 8 | 部署与结课轻项目 | 把模板变成“可交付助手” | 讲清本地与私有化部署的适用场景, 以及对成本、数据与合规的影响。
用 API/服务化直觉解释“怎么让团队可用”, 包括入口、权限与日志。
把合规与素材边界做成口播与清单, 在演示与作业里强制执行。 | OpenClaw 轻项目答辩演示 | 结课: 工作三件套 AI 助手(可演示) | 演示可跑; 产出可复用; 合规声明完整 | + +### 启航班讲师备课清单(每讲通用) +- 必备素材: Token/上下文动画; 马鞍图(梯度下降直觉); 量化前后时延对比图 +- 必备模板: 文档/周报/邮件 Prompt 卡; 去 AI 味清单; RAG 引用回答 Prompt +- 必备反例: 1 个“格式不稳”反例 + 1 个“瞎编”反例(用于讲失败模式) + +## 2) 精英班(16讲, 2h/讲, 可交付) + +### 精英班定位 +- 学员画像: 能动手做简单工程或能组织工程落地的负责人 +- 教学原则: 每讲都要“主案例 + 反例 + 复盘”, 用失败驱动掌握排障与治理 +- 结课产出: 1 个可部署的 mini-openclaw(教育用最小实现)+ 1 个基于 OpenClaw 的 24/7 数字分身方案(自托管与技能扩展) + +### 精英班每讲课件大纲(含验收要点) + +| 对应模块 | 知识板块 | 学习目标 | 必讲内容(全量项覆盖, 以实战为主) | 课堂案例 | +|:-:|---|---|---|---| +| 1 | AI 发展与业务定位 | 找到可落地机会点与风险 | 用产业图谱让学员知道“能做什么生意”, 并把能力边界翻译成产品边界。
用场景筛选法把需求从“想做”变成“可验证交付”, 明确数据、合规与成本约束。
把交付物定义成可运行、可观测、可回滚的最小闭环, 避免只交 PPT。 | 业务场景拆解工作坊 | +| 1 | Prompt 方法论 | 让输出稳定且“像人” | 用结构化 Prompt 把目标、输入、约束与验收写清楚, 让输出可控可复用。
用失败模式分类(跑偏、幻觉、格式不稳、风格漂移)指导纠偏策略, 形成可复盘方法。
把去 AI 味做成质量门禁, 用禁用词与事实核对在生成阶段拦截低质输出。 | 公文改写与去 AI 味演练 | +| 2 | 机器学习范式 | 讲清对齐与 RLHF 直觉 | 把监督/无监督/半监督放到大模型训练链路里解释, 让学员知道每种范式解决哪类问题。
用强化学习的直觉解释“为什么需要对齐”, 以及对齐目标如何影响最终行为。
用 RLHF 直觉说明偏好数据如何驱动输出风格与拒答策略, 并指出其成本与风险。 | RLHF 直觉模拟 | +| 2 | 评估指标体系 | 把指标变成治理体系 | 把精度/召回/F1/AUC/ROC/混淆矩阵讲成“可用性视角”的指标工具箱, 而不是考试题。
用指标到 KPI 的映射说明为什么指标会互相冲突, 并给出取舍原则与监控口径。
把错误分析固化为流程, 让每次失败都能沉淀为可回归用例。 | 指标冲突复盘 | +| 3 | 深度学习基础 | 具备训练稳定性直觉 | 用激活函数与损失函数解释训练目标如何被定义, 以及错误目标会导致什么行为偏差。
用反向传播与优化器建立“为什么会不收敛/发散”的直觉, 指导排障而非盲调参。
用正则化解释过拟合与泛化的取舍, 并把这些取舍映射到业务风险。 | 训练发散排障动画 | +| 3 | 表示学习演进 | 理解“为什么是 Transformer” | 用 CNN/RNN 到 Transformer 的演进说明“并行计算+长依赖”为什么关键, 并对应到工程成本。
用 Embedding 的业务意义说明检索、聚类与推荐为什么能用同一表示空间解决, 形成统一视角。 | 模型家族选型会 | +| 4 | Transformer 核心 | 掌握 Token/窗口与注意力 | 用注意力与多头注意力解释模型如何“分配关注”, 以及为什么会出现指令冲突与幻觉放大。
把 Token 与窗口限制讲成可操作的约束条件, 让学员会做裁剪、摘要与引用策略。
把长文本故障拆成可定位问题, 用日志与对比实验验证根因。 | 长文记忆失败排障 | +| 4 | Prompt 与推理策略 | 把推理链做稳定 | 解释 Zero-shot/Few-shot/ICL 何时有效, 以及示例如何改变推理路径与输出风格。
把思维链当成“可控过程”, 用步骤约束、格式验收与自检点减少随机性。
把纠偏做成可执行动作, 包括补约束、补信息、降级任务与请求澄清。 | 多轮推理任务实战 | +| 4 | Context Engineering | 设计 Skills 与长记忆 | 把 Skills 作为可复用能力单元来设计, 每个 Skill 都要有输入、输出与失败信号。
用窗口优化策略控制“必带上下文”和“运行态上下文”的大小, 避免越用越乱。
用长记忆三层(会话/摘要/知识)控制写入与检索, 并把 Alignment 落到拒答与边界规则。 | OpenClaw 上下文改造 | +| 5 | RAG 基础 | 搭建 RAG 基线 | 把 RAG 作为“知识可追溯”的工程链路来讲, 重点是召回质量与引用回答的约束。
用切片策略解释为什么同一文档会出现误召回/漏召回, 并给出可复盘的修复手段。
用向量库选型直觉讲清数据规模、成本、更新频率与一致性之间的取舍。 | 客服问答链路搭建 | +| 5 | RAG 进阶(2026) | 解决误召回与幻觉 | 用 Advanced RAG 讲清“召回失败时生成一定会错”, 并用评估把失败类型量化出来。
用 Hybrid 检索把结构化与向量检索拼起来, 让高精度场景不再只靠相似度猜。
用 Re-ranking 提升相关性排序, 并用对比实验验证提升是否真实而非偶然。
用 Self-RAG 把“自检与再检索”做成闭环, 让模型在证据不足时主动补证据或拒答。 | 重排优化复盘 | +| 6 | Agent 核心架构 | 从单 Agent 到可控工作流 | 把 Workflow 拆成可观测的节点与状态, 让 Agent 从“聊天”变成“可运行流程”。
用 Function Calling 规范工具输入输出, 让调用逻辑可审计、可回滚、可复盘。
用 Planner 生成计划并用 ReAct 在执行中纠偏, 最后用 MCP 把工具接入标准化。 | OpenClaw Agent 任务链 | +| 6 | Agent 治理与安全 | 让 Agent 可上线 | 用 Multi-Agent 的分工与仲裁模式提升复杂任务吞吐, 同时控制冲突与一致性。
把 Safety Boundary 落到权限分级与拒绝策略, 明确什么能做、什么必须确认、什么永远禁止。
用 Tool Sandboxing 隔离高风险工具, 让即使提示注入成功也无法越权。
把评估做成体系, 用成功率、失败类型、稳定性与可解释性做持续治理。 | 多 Agent 冲突仲裁 | +| 7 | 训练与微调体系 | 讲透微调与对齐 | 把 Fine-tuning 拆成 SFT 与偏好对齐两阶段, 让学员知道各自目标与输入数据形态。
用 PEFT 的视角解释 LoRA/QLoRA 如何在成本可控下改模型行为, 以及适用边界。
用 DPO 与 RLHF 的对比讲清对齐的权衡, 并补充 MoE 在规模与成本上的意义。 | 微调路线选型工作坊 | +| 7 | 推理优化体系 | 达到生产性能目标 | 用量化/GPTQ 解释“用更低精度换速度”的边界, 并强调质量回归测试的必要性。
用剪枝与蒸馏解释“变小变快”的两条路线, 以及对可解释性与维护成本的影响。
用 DeepSpeed 与 Megatron-LM 说明大规模训练与推理的并行化思路, 让学员能与工程团队对齐。
用 Flash Attention 与 vLLM 讲清吞吐/时延的优化抓手, 并补充 LLaMA-Factory 的落地位置。 | 吞吐/时延/成本优化 | +| 8 | 部署治理与答辩 | 完成上线与复盘 | 把 Docker/API 服务化讲成最小上线形态, 让系统具备入口、权限、日志与健康检查。
把生命周期管理落到版本、回滚与数据更新策略, 避免“上线即失控”。
把监控与成本做成可观测指标, 并把合规与安全做成清单与审计流程。 | OpenClaw 复杂项目答辩 | + +## 3) 统一“电视剧式”课程主线(两班共用) +1. 第 1 集: 团队写作效率低, 用 Prompt 先解决文档、周报、邮件。 +2. 第 2 集: 内容质量不稳, 引入去 AI 味与风格约束。 +3. 第 3 集: 回答经常不准, 引入知识库与 RAG。 +4. 第 4 集: 任务复杂化, 引入 Agent、工具调用、MCP。 +5. 第 5 集: 协作与风险上升, 引入多 Agent、安全边界、工具沙箱。 +6. 大结局: 部署上线, 做监控、成本、合规与复盘。 + +## 4) 教学执行建议(可直接给讲师) +- 启航班: 每讲 25% 概念 + 55% 演示 + 20% 练习(强调“立刻可用”) +- 精英班: 每讲 15% 概念 + 65% 实操 + 20% 复盘(强调“可上线”) +- 每讲必带: 1 个反例(故意失败)+ 1 个复盘模板(写清根因、证据、修复、回归) + +## 5) 素材与合规说明(必须口播) +- 素材“去水印/去标识”仅限自有版权或明确授权内容 +- 涉及第三方版权标识的内容, 统一使用授权样例或自制素材 +- RAG 问答必须标注来源, 无依据必须明确“不确定” + +## 6) 教学验收(结课标准) +1. 启航班学员能独立完成“文档 + 周报 + 英文邮件”三类痛点任务, 并输出可复用模板。 +2. 启航班学员能解释 RAG 与知识库差异, 并完成最小问答 Demo 与 1 次失败复盘。 +3. 精英班学员能描述并落地至少一种 Agent 安全边界与工具沙箱策略(含审计与回滚)。 +4. 精英班学员能完成复杂 case: RAG + Agent + MCP + 部署治理, 且可观测可复盘。 + +## 7) 精英班“能否自己构造一个 OpenClaw”审查结论 +- 结论: 16 讲框架具备基础条件, 但必须用“构造闯关 + 作业验收”把目标从“会讲”升级为“会做”。 +- 当前优势: 2/4/5/6/7 全量项已覆盖, 且有 OpenClaw 相关案例与治理内容。 +- 必要补位: 明确从零构造路径、每关代码级交付物、验收与回归测试要求。 + +## 8) 精英班 mini-openclaw 构造闯关(必须完成) + +| 闯关 | 构造目标 | 必做能力 | 代码级交付物(最小) | 验收标准 | +|---|---|---|---|---| +| G1 | Gateway 最小内核 | connect、req/res、event | 消息协议草案 + 最小 gateway demo + 集成测试 | 能稳定跑通最小链路并输出结构化日志 | +| G2 | 会话与路由内核 | session key、线程/群聊路由、持久化 | session store + router + 回归用样例消息集 | 3 类消息路由正确, session 命中可复盘 | +| G3 | 模型执行与降级 | provider 适配、fallback、指标 | provider 接口 + fallback 策略 + 指标埋点 | 主模型失败可自动降级且说明失败原因 | +| G4 | RAG 内核 | 索引、切片、召回、重排、引用 | indexing pipeline + 引用回答模板 + 评估脚本 | 问答必须带来源; 误召回可定位并修复 | +| G5 | Agent 工具内核 | tool 白名单、沙箱、确认 | tool registry + 权限分级 + 审计日志 | 高风险工具被拦截; 合法任务可完成 | +| G6 | 企业化交付 | Docker/API、监控、回滚、合规 | docker compose + health check + 监控面板草案 | 可部署可观测可回滚, 并完成答辩 | + +## 9) 精英班最终通过条件(构造型) +1. 必须完成 G1-G6 全部闯关并通过验收。 +2. 必须提交一份“可运行”的 mini-openclaw 项目(非 PPT 方案)。 +3. 必须提交故障复盘(至少 2 个真实失败场景与修复记录, 含回归)。 +4. 必须提交安全清单(权限边界、工具沙箱、审计与回滚策略)。 diff --git a/backend/app/one_person_security_dept/openclaw/curriculum/elite-capstone-kit.md b/backend/app/one_person_security_dept/openclaw/curriculum/elite-capstone-kit.md new file mode 100644 index 00000000..dc69af2d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/curriculum/elite-capstone-kit.md @@ -0,0 +1,56 @@ +# 精英班结课交付包(mini-openclaw + OpenClaw 深度集成) + +## 1) 交付目标(讲师口径) +- 产出 1: `mini-openclaw`(教育用最小实现), 能在本地或服务器稳定跑 24/7 +- 产出 2: 基于 OpenClaw 的“数字分身”配置与技能扩展方案(自托管可复现) +- 关键要求: 可演示、可观测、可回滚、可复盘、有安全边界 + +## 2) mini-openclaw 项目结构(建议, 便于验收) + +| 目录/文件 | 必须包含 | 验收点 | +|---|---|---| +| `README.md` | 一键运行、配置说明、演示脚本、已知限制 | 新人 30 分钟内能跑起来 | +| `docs/` | 架构图、消息协议、权限模型、故障复盘 | 文档和代码一致 | +| `src/gateway/` | 最小连接层(connect/req-res/event) | G1 通过 | +| `src/routing/` | session key、路由策略、持久化接口 | G2 通过 | +| `src/providers/` | 模型调用接口、fallback、超时与重试 | G3 通过 | +| `src/rag/` | 切片、索引、召回、重排、引用回答 | G4 通过 | +| `src/agent/` | planner、tool registry、确认与回滚 | G5 通过 | +| `src/ops/` | 指标、日志、健康检查、成本统计 | G6 通过 | +| `tests/` | 端到端集成测试 + 回归用样例集 | 关键链路可自动验收 | +| `docker/` | docker compose、持久化卷、healthcheck | 可部署可回滚 | + +## 3) G1-G6 闯关拆解(每关必测失败场景) + +| 闯关 | 必做子任务(最小) | 必测失败场景(至少 2 个) | 必交文档 | +|---|---|---|---| +| G1 Gateway 最小内核 | 定义消息协议; 连接握手; req/res; event 广播 | 断连重连; 重复消息/乱序 | `docs/protocol.md` | +| G2 会话与路由内核 | session key 规则; 线程/群聊路由; store 接口 | session 污染; 路由误判 | `docs/session-routing.md` | +| G3 模型执行与降级 | provider 接口; 超时/重试; fallback; 结构化日志 | 主模型 5xx; 响应超时 | `docs/provider-fallback.md` | +| G4 RAG 内核 | 切片策略; embedding; 召回; 重排; 引用回答 | 误召回; 漏召回 | `docs/rag-design.md` | +| G5 Agent 工具内核 | tool schema; 白名单; 人机确认; 审计日志 | 提示注入诱导; 越权调用 | `docs/security-boundary.md` | +| G6 企业化交付 | docker 化; healthcheck; 指标面板草案; 回滚策略 | 依赖不可用; 成本异常飙升 | `docs/ops-runbook.md` | + +## 3.1) 建议排期(和 16 讲对齐, 便于讲师验收) + +| 阶段 | 建议讲次 | 当讲必须推进的东西 | 对应闯关 | +|---|---|---|---| +| S1 | 7 | 先定协议与日志格式, 画清最小链路 | G1 | +| S2 | 9 | session key 与路由策略落到可跑样例 | G2 | +| S3 | 10 | RAG 基线跑通, 回答强制引用来源 | G4 | +| S4 | 11 | 引入评估与重排, 做一次“修复前后对比” | G4 | +| S5 | 12 | provider 接口与 fallback 落地, tool registry 初版 | G3,G5 | +| S6 | 13 | 权限边界、确认与审计补齐, 做一次注入攻防演练 | G5 | +| S7 | 15 | 性能压测与指标面板草案, 固化可观测性 | G3,G6 | +| S8 | 16 | docker 化、runbook、回滚开关, 走完整演示脚本 | G6 | + +## 4) 结课演示脚本(15 分钟, 必须覆盖) +1. 知识库问答: 回答必须引用来源, 并展示一次“误召回 -> 复盘 -> 修复” +2. 工具调用: 展示一次“需要用户确认”的高风险工具, 被拦截或二次确认通过 +3. 多轮任务: planner 生成计划, 中途失败后能降级或回滚, 输出可读的复盘信息 +4. 部署与观测: 展示健康检查、核心指标(吞吐/时延/失败率/成本)与回滚开关 + +## 5) OpenClaw 深度集成(不重写, 重点在扩展) +- 目标: 用 OpenClaw 做“多通道入口 + 持久记忆 + Skills 扩展”, 让数字分身 24/7 可用 +- 必做: 3 个技能(信息检索类/写作类/运维类各 1 个), 且具备权限边界与审计 +- 验收: 7 天内稳定运行, 至少 2 次真实失败复盘并修复 diff --git a/backend/app/one_person_security_dept/openclaw/curriculum/elite-class-content-table.md b/backend/app/one_person_security_dept/openclaw/curriculum/elite-class-content-table.md new file mode 100644 index 00000000..c4c88283 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/curriculum/elite-class-content-table.md @@ -0,0 +1,27 @@ +# 精英班课程内容表(知识板块|具体内容) + +> 16 讲|2h/讲|案例多、实战多、排障与上线为主。结课目标:能独立交付 `mini-openclaw`(教育用最小实现)并完成 OpenClaw 深度集成。 + +| 知识板块 | 具体内容 | +|---|---| +| 第 1 讲:AI 发展与业务定位 | - 概念:我会用对照例子解释 Agentic AI 与传统自动化的差异,并用数据、合规、成本和时延四条边界说明企业里什么能做什么不能做。
- 方法:我会提供一套场景筛选表(价值/可行性/风险)并用“能跑、能测、能复盘”的交付物定义把需求落到可验收。
- 课堂案例:我们用工作坊把一个模糊的“想做 AI”场景拆成可落地需求、数据来源和验收指标。
- 作业交付:学员提交 AI 机会点清单,必须写清边界、数据来源和验收指标。 | +| 第 2 讲:Prompt 方法论与去 AI 味(治理版) | - 概念:我会把 Prompt 定义为需求说明书加质量门禁,并解释去 AI 味的目标是提升可信度而不是美化文本。
- 方法:我会教结构化 Prompt(角色/任务/约束/输出/验收)与风格约束(禁用词、事实密度、口吻),并用反例驱动迭代把输出稳定下来。
- 课堂案例:我们用同一份公文做 A/B 改写与讲评,让学员看到约束如何改变质量。
- 作业交付:学员交付可复用的风格约束模板并附 2 条测试输入验证稳定性。 | +| 第 3 讲:机器学习范式(只讲与 LLM 对齐有关的部分) | - 概念:我会说明监督、无监督、半监督在 LLM 训练链路中的位置,并解释强化学习如何服务对齐目标。
- 方法:我会用偏好数据直觉解释 RLHF 与 DPO 的差异与代价,让学员知道何时该选哪条路。
- 课堂案例:我们做 RLHF 直觉模拟,用偏好排序观察输出如何改变。
- 作业交付:学员交付范式应用图,把训练、微调与对齐各阶段的输入输出标清。 | +| 第 4 讲:评估指标体系与错误分析(生产必修) | - 概念:我会强调指标是治理工具而不是分数,并教你把任务指标映射到业务 KPI 以避免“好看但不好用”。
- 方法:我会提供 Component-level Evals 的拆解方式,并用错误分析流程(失败分类、Top 根因、修复与回归)把改进变成闭环。
- 课堂案例:我们复盘一次指标冲突案例,解释为什么 accuracy 高但业务差。
- 作业交付:学员提交指标治理表和失败类型字典初版,用于后续所有实验的统一口径。 | +| 第 5 讲:深度学习基础(用于训练稳定性与排障) | - 概念:我会用直觉和动画解释损失、梯度、优化器与正则化如何影响训练稳定性,从而帮助你判断何时该微调。
- 方法:我会给出一套常见训练故障定位路径,覆盖发散、过拟合、震荡以及梯度爆炸/消失。
- 课堂案例:我们演练一条从现象到参数调整的排障推理链,并记录证据与回归用例。
- 作业交付:学员提交训练稳定性清单,要求能被他人按步骤复现排障。 | +| 第 6 讲:表示学习与生态定位(工程选型视角) | - 概念:我会用模型演进解释表示学习的关键决策点,并说明 Embedding 为什么是检索与 RAG 的底座能力。
- 方法:我会对齐三大生态(PyTorch/TensorFlow/MindSpore)的定位与落地边界,并给出混合精度与加速的决策直觉。
- 课堂案例:我们做一次模型家族选型会,用能力、成本和时延三角做决策。
- 作业交付:学员提交模型选型备忘录,必须写清取舍理由与风险控制措施。 | +| 第 7 讲:Transformer 核心与 Token 机制(排障版) | - 概念:我会讲清注意力与多头注意力如何在 Token 级别分配关注,并把窗口限制解释为可操作的工程约束。
- 方法:我会教长文任务的裁剪、摘要与引用三策略,并示范如何识别与治理上下文污染。
- 课堂案例:我们排障一个长文记忆失败案例,把跑偏原因定位到窗口、指令冲突或污染。
- 作业交付:学员提交上下文故障报告,包含根因、证据、修复与回归。 | +| 第 8 讲:推理策略与 Prompt Engineering(稳定性版) | - 概念:我会说明 Zero-shot、Few-shot、ICL 和思维链各自的适用边界,并强调思维链必须受约束才会稳定。
- 方法:我会给出推理链纠偏机制(约束步骤、验收格式、自检点)和失败降级策略(简化、改写、补问)。
- 课堂案例:我们做多轮推理任务链实战,对比加入纠偏前后的成功率与成本。
- 作业交付:学员交付推理模板库,必须包含纠偏与降级规则并配 10 条测试输入。 | +| 第 9 讲(项目实战):Context Engineering 与 Skills 设计(OpenClaw) | - 概念:我会把 Context Window 优化和三层长记忆(会话/摘要/知识)讲成可落地的写入与检索规则,并明确对齐边界如何体现在拒答与安全约束上。
- 方法:我会指导把任务拆成 Skills(输入、输出、失败信号),再设计写入/检索/丢弃策略以控制上下文最小集合。
- 课堂实战:我们在 OpenClaw 上做上下文改造,用同一任务对比改造前后的稳定性。
- 作业交付:学员提交 Context 设计文档与 10 条验收测试集,要求能复现改造收益。 | +| 第 10 讲(项目实战):RAG 基线(从知识文档到可检索问答) | - 概念:我会把 RAG 基线拆成切片、Embedding、召回与引用回答四段,并给出向量数据库选型的关键判断点。
- 方法:我会教切片长度、重叠和结构化切片的设计方法,并要求回答强制引用来源且可复盘失败。
- 课堂实战:我们搭建客服知识问答链路并端到端跑通检索与引用回答。
- 作业交付:学员提交 RAG 基线方案,必须包含数据治理、版本策略和复盘模板。 | +| 第 11 讲(项目实战):Advanced RAG(2026:Hybrid/Re-ranking/Self-RAG) | - 概念:我会解释 Hybrid RAG 为什么要融合结构化检索与向量检索,并讲清 Re-ranking 和 Self-RAG 在减少误召回与幻觉中的作用与风险。
- 方法:我会用 RAG 评估体系(召回、引用正确率、失败类型)驱动重排器接入,并要求用修复前后对比实验验证提升。
- 课堂实战:我们用真实失败样例做召回失败到重排优化复盘,输出可复现的改进记录。
- 作业交付:学员提交 RAG 评估报告,必须包含对比实验、指标与结论。 | +| 第 12 讲(项目实战):Agent 核心架构(Workflow/Planner/ReAct/MCP) | - 概念:我会用规划、行动、记忆与知识四要素解释 Agent 架构,并说明 MCP 如何把工具接入标准化。
- 方法:我会把 Function Calling 落到 tool schema、参数校验与自动调用逻辑上,并加入回滚策略与审计日志让执行可控。
- 课堂实战:我们在 OpenClaw 上搭建单 Agent 多工具任务链并演练一次失败回滚。
- 作业交付:学员交付 Agent 编排图和 1 份失败回滚复盘,要求能按图复现。 | +| 第 13 讲(项目实战):Multi-Agent 协作与安全治理 | - 概念:我会讲清分工、并行、分层和仲裁四种多 Agent 协作模式,并说明为什么生产环境必须做能力最小化和安全边界。
- 方法:我会设计工具沙箱与权限分级,并用显式确认和提示注入防护把高风险动作拦在边界外,同时建立 Agent 评估指标体系。
- 课堂实战:我们做多 Agent 冲突仲裁实战,让冲突可收敛并可追踪决策。
- 作业交付:学员提交 Agent 治理方案,必须覆盖权限、审计、回滚和评估四块。 | +| 第 14 讲:训练与微调体系(覆盖全量项, 以决策为主) | - 概念:我会把 Fine-tuning、SFT、PEFT、LoRA/QLoRA、DPO、RLHF 和 MoE 串成一条训练与对齐路线图,明确每一段解决的问题与代价。
- 方法:我会用“业务目标-数据条件-成本上限-回归集”四步法判断是否微调,并估算预期收益与风险。
- 课堂案例:我们用同一业务目标做微调路线选型工作坊,对比多路线的成本与效果。
- 作业交付:学员提交微调决策树和回归集草案,要求能支撑后续对比实验。 | +| 第 15 讲:推理优化体系(覆盖全量项, 以压测为主) | - 概念:我会讲清量化/GPTQ、剪枝、蒸馏、DeepSpeed、Megatron-LM、Flash Attention、vLLM 和 LLaMA-Factory 在推理链路里各自解决的性能瓶颈。
- 方法:我会用吞吐、时延与成本三角设计压测并定义 SLO/SLA,然后用回归测试保证优化不以质量为代价。
- 课堂案例:我们复盘一次量化后质量退化的攻防案例,定位根因并给出修复策略。
- 作业交付:学员提交性能压测与复盘报告,必须包含指标、对比结果与结论。 | +| 第 16 讲(项目实战):企业部署与结课答辩(mini-openclaw + OpenClaw 集成) | - 核心任务:学员需要完成 `mini-openclaw` 的 G1-G6 闯关并交付可运行版本,同时在 OpenClaw 侧完成 24/7 数字分身配置和 3 个 Skills。
- 部署要点:我们要求以 Docker/API 服务化落地,并提供 healthcheck、核心监控指标(失败率、时延、成本)、回滚开关和合规基线。
- 答辩要求:答辩必须按 15 分钟脚本演示引用回答、工具确认/拦截、失败降级/回滚以及观测与回滚。
- 交付物:最终交付物是结课交付包,包含 repo、docs、tests、docker、2 份复盘、安全清单和 evals 报告。 | + +## 关联交付标准 +- 案例手册:见 `casebook.md` 的“精英班案例”与“构造能力映射”。 +- 结课交付包:见 `elite-capstone-kit.md`。 +- 评分与清单:见 `rubrics-and-checklists.md` 的“精英班评分量表/交付清单”。 diff --git a/backend/app/one_person_security_dept/openclaw/curriculum/launch-class-content-table.md b/backend/app/one_person_security_dept/openclaw/curriculum/launch-class-content-table.md new file mode 100644 index 00000000..7d4ebc14 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/curriculum/launch-class-content-table.md @@ -0,0 +1,18 @@ +# 启航班课程内容表(知识板块|具体内容) + +> 8 讲|2h/讲|动画多、案例少、上手快、解决工作痛点为主。 + +| 知识板块 | 具体内容 | +|---|---| +| 第 1 讲:AI 发展与 Prompt 本质 | - 概念:我会用简史和正反例说明大模型擅长生成与归纳但在缺事实时会编造,并让学员先建立能做与不能做的边界感。
- 方法:我会教你用“对象-动作-约束-验收”四要素把 Prompt 写成任务说明书,并用失败模式清单快速定位问题所在。
- 课堂演示:我会把一段口头需求现场改写成一页式需求说明(背景/目标/范围/风险/里程碑/待确认),并展示如何用验收标准反推补问。
- 作业交付:学员提交个人 Prompt 入门卡和一页式需求说明各 1 份,作为后续练习的模板基线。
- 验收要点:交付物必须结构完整、避免套话,并且每个关键结论都能在输入信息中找到依据。 | +| 第 2 讲:Token 与上下文窗口(动画) | - 概念:我会讲清 Token 的拆分与计数方式,并用动画解释上下文窗口截断为什么会导致遗忘、冲突和跑偏。
- 方法:我会给出约束优先级写法和“先结构后内容”的流程,并用表格/JSON/大纲三种格式化手段让输出稳定可验收。
- 课堂演示:我会用同一份输入分别生成 PRD 与方案结构化大纲,对比加入格式约束前后的稳定性差异。
- 作业交付:学员交付三套团队文档 Prompt 卡片(PRD、方案、会议纪要),并附一条测试输入验证可复用。
- 验收要点:输出必须结构固定、变量位清晰、格式稳定可复用,且不因输入轻微变化而崩坏。 | +| 第 3 讲:Prompt 工作流(写文档) | - 概念:我会把“单次生成”升级为“可复用工作流”,并教会学员在信息不足时如何用补问把上下文补齐。
- 方法:我会提供需求说明、方案对比、会议纪要三件套模板,并配套自检清单确保事实、约束、风险和待确认项不缺失。
- 课堂演示:我会演示草稿到评审稿的两轮迭代,让学员看到如何保留证据、标注修改点并收敛结论。
- 作业交付:学员交付团队文档模板包 v1(含自检清单与变量位说明),并用一条真实材料跑通生成流程。
- 验收要点:每节必须先给结论再给依据,且所有无依据判断都要显式标注为待确认。 | +| 第 4 讲:周报与汇报(两版本) | - 概念:我会解释同一材料为什么要给不同受众输出两个版本,并用“可汇报”标准定义周报应该长什么样。
- 方法:我会教成果写成“动作+结果+指标”、风险写成“触发条件+影响+应对+截止时间”,并用 3-5 条管理层摘要形成决策输入。
- 课堂演示:我会把 12 条流水账压缩成团队版与管理层版周报,并现场做一次风险项的影响优先级排序。
- 作业交付:学员交付个人周报 Prompt 工作流(含风险模板与摘要模板),并用本周真实材料生成两版周报。
- 验收要点:两版输出必须差异明显、CTA 清晰、风险可执行,并能定位到责任人和截止时间。 | +| 第 5 讲:英文邮件(语气分级) | - 概念:我会对比请求、催办、道歉和升级四类邮件的目标差异,并强调邮件的价值在于明确行动而不是表达情绪。
- 方法:我会提供三档语气模板(Neutral、Polite but firm、Escalation-ready)并用固定结构(Context、Request、Deadline、CTA)保证邮件可执行。
- 课堂演示:我会用同一封邮件生成三种语气版本并优化主题行,让学员看到语气与行动项如何同步变化。
- 作业交付:学员交付英文邮件私有模板包,包含常用句库、禁用表达和四类场景的样例。
- 验收要点:邮件必须有明确截止时间与行动项,并且避免 soon/as discussed 等模糊表达导致不可追责。 | +| 第 6 讲:去 AI 味表达(质量门禁) | - 概念:我会总结 AI 味的可观察特征(套话、空泛、排比、缺事实、过度抽象),让学员能快速定位需要改写的问题句。
- 方法:我会教去 AI 味四步法(删套话、补事实/数字、加对象与边界、保留个人口吻),并用质量门禁强制执行事实核对与来源/不确定声明。
- 课堂演示:我会把一段项目总结做稳健商务版与口语自然版 A/B 改写,并讲清每一步改写对应解决的具体问题。
- 作业交付:学员交付去 AI 味检查清单并提交两篇改写样例,要求能说明每处修改的理由。
- 验收要点:改写后必须事实密度更高、口吻一致且不夸大不营销,并能通过检查清单逐条自证。 | +| 第 7 讲:知识库与 RAG(最小闭环) | - 概念:我会把知识库定义为可维护、可检索、可更新的组织资产,并用一句话讲清 RAG 是把检索证据带入生成以降低幻觉。
- 方法:我会演示最小 RAG 链路(切片、召回、引用回答)并用失败类型(误召回、漏召回、片段质量差)指导复盘定位。
- 课堂演示:我会用智能客服 Demo 对比 RAG 前后的回答差异,并强制要求每个结论都带来源引用。
- 作业交付:学员交付 RAG 术语与流程卡并完成一次一页式失败复盘,明确根因、证据、修复与回归。
- 验收要点:回答必须带来源、证据不足时必须写不确定,并且复盘能定位到具体链路环节而不是泛泛而谈。 | +| 第 8 讲(项目实战):工作三件套 AI 助手(OpenClaw 轻集成) | - 核心任务:本讲的核心任务是把文档、周报和英文邮件三条工作流做成可复用助手,并按需接入 OpenClaw 作为统一入口。
- 设计要点:我会要求统一输入表单与固定输出格式,并在流程中加入去 AI 味门禁和失败兜底(改写、补问或降级)。
- 交付物:学员最终交付团队 Prompt 模板包 v2、10 分钟演示脚本以及一次失败复盘记录。
- 交付效果:该交付应当能显著降低写作时间成本并提升表达一致性,使团队可以重复复用同一套模板完成日常输出。
- 合规边界:所有素材处理必须基于自有或授权内容,且任何政策、数据、时间或价格信息都不得编造。 | + +## 关联交付标准 +- 案例手册:见 `casebook.md` 的“启航班案例”。 +- 评分与清单:见 `rubrics-and-checklists.md` 的“启航班评分量表/交付清单”。 diff --git a/backend/app/one_person_security_dept/openclaw/curriculum/private-prompt-pack.md b/backend/app/one_person_security_dept/openclaw/curriculum/private-prompt-pack.md new file mode 100644 index 00000000..07524265 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/curriculum/private-prompt-pack.md @@ -0,0 +1,218 @@ +# 课堂私有 Prompt 包 + +> 用法建议: 先直接套用, 再按团队术语和行业规则做二次改写。 + +## 1) 文档 Prompt(需求说明/方案对比/会议纪要) + +```text +你是我的项目文档助手。 + +目标: +- 产出一份可直接进入评审的文档。 + +输入: +- 背景: {{背景}} +- 目标: {{目标}} +- 约束: {{约束}} +- 时间: {{时间}} +- 干系人: {{干系人}} + +输出要求: +1. 用中文输出, 结构固定为: 背景 -> 目标 -> 范围 -> 方案 -> 风险 -> 里程碑 -> 待确认事项。 +2. 每一节先给结论, 再给 2-4 条支撑点。 +3. 不要空话, 不要“赋能/闭环/抓手”这类套话。 +4. 所有判断都要可追溯到输入信息, 无依据的地方写“待确认”。 +``` + +## 2) 周报 Prompt(个人版 + 管理层摘要) + +```text +你是我的周报助手。 + +请基于以下原始材料, 生成两个版本: +- 版本 A: 团队协作版(详细) +- 版本 B: 管理层摘要版(精简) + +原始材料: +{{本周事项清单}} + +固定输出结构: +1. 本周成果(量化) +2. 本周风险(按影响排序) +3. 下周计划(按优先级) +4. 需要支持(明确到人/部门) + +写作要求: +- 用事实和结果说话, 不要写感受性空话。 +- 每条成果最好包含“动作 + 结果 + 指标”。 +- 风险要给出“触发条件 + 应对动作 + 截止时间”。 +``` + +## 3) 英文邮件 Prompt(请求/催办/升级) + +```text +You are my business email co-writer. + +Task: +Rewrite my draft email into 3 tones: +1) Neutral professional +2) Polite but firm +3) Escalation-ready + +Context: +- Audience: {{收件人角色}} +- Goal: {{邮件目标}} +- Deadline: {{截止时间}} +- My draft: {{草稿}} + +Rules: +- Keep it concise, concrete, and action-oriented. +- Include a clear CTA and deadline in each version. +- Avoid vague words like "soon" or "as discussed" without specifics. +- Output in English only. +``` + +## 4) 去 AI 味 Prompt(口吻保留版) + +```text +你是我的改写编辑。 + +请把下面内容改成“像真人写的工作表达”, 但保持原意不变。 + +原文: +{{原文}} + +改写规则: +1. 删除套话和重复句式, 减少“首先/其次/最后”模板痕迹。 +2. 加入具体事实、数字、对象, 避免空泛表达。 +3. 保留我的语气风格: {{语气关键词, 如: 干脆/克制/礼貌}}。 +4. 不夸大, 不营销, 不喊口号。 +5. 输出两版: + - A: 稳健商务版 + - B: 更口语自然版 +``` + +## 5) RAG 问答 Prompt(来源约束版) + +```text +你是企业知识库问答助手。 + +任务: +根据检索片段回答问题, 并严格标注依据。 + +用户问题: +{{问题}} + +检索片段: +{{检索结果}} + +回答规则: +1. 只能基于检索片段回答。 +2. 每个关键结论后都加来源标记: [来源: 文档名/段落号]。 +3. 如果证据不足, 明确写“当前资料不足以确认”, 并给出还需检索的关键词。 +4. 禁止编造政策、数据、时间、价格。 +``` + +## 6) Agent 任务 Prompt(工具白名单版) + +```text +你是我的任务代理(Agent)。 + +任务目标: +{{任务目标}} + +边界约束: +- 可用工具: {{工具白名单}} +- 禁止操作: {{禁止项}} +- 时间上限: {{时限}} +- 成功标准: {{验收标准}} + +执行要求: +1. 先输出执行计划(不超过 5 步)。 +2. 每步执行后输出“结果 + 证据 + 下一步”。 +3. 遇到权限或信息不足时, 立即暂停并提出最小补充请求。 +4. 最终输出固定格式: + - 结论 + - 关键证据 + - 风险与限制 + - 后续建议 +``` + +## 7) 素材规范化 Prompt(仅自有或授权素材) + +```text +你是我的视觉素材整理助手。 + +任务: +对素材做规范化处理建议, 仅用于自有版权或明确授权素材。 + +输入: +{{素材描述}} + +输出: +1. 先给“可处理项/不可处理项”清单。 +2. 可处理项仅包含: 构图优化、颜色统一、版式清理、噪点清理、文案重排。 +3. 若素材存在第三方版权标识且无授权, 必须提示“停止处理并补充授权证明”。 +``` + +## 8) 故障复盘 Prompt(根因/证据/修复/回归) + +```text +你是我的故障复盘助理。 + +目标: +- 把一次失败写成“一页复盘”, 便于复现、修复和回归。 + +输入: +- 现象: {{现象}} +- 影响: {{影响}} +- 触发条件: {{触发条件}} +- 日志/截图/证据: {{证据}} +- 已尝试动作: {{已尝试}} + +输出结构(固定): +1. 现象(用 2-3 句描述, 不写感受) +2. 影响(用户/业务/成本, 量化优先) +3. 触发条件(输入、环境、版本) +4. 根因(最小可解释原因, 不要泛泛而谈) +5. 证据(引用输入里的日志/截图点) +6. 修复(可执行步骤, 包含回滚方案) +7. 回归(新增或更新哪些用例, 如何验证不复发) +8. 预防(监控/告警/权限/文档补齐) + +规则: +- 不允许写“赋能/闭环/抓手/全面提升”等套话。 +- 不确定的地方必须标注“待确认”并给出验证方法。 +``` + +## 9) Skills/Context 设计 Prompt(长记忆与窗口优化) + +```text +你是我的 Agent 设计顾问。 + +任务: +为一个真实业务任务设计 Skills 组合与上下文策略, 让 Agent 输出稳定且可控。 + +输入: +- 任务描述: {{任务描述}} +- 目标与成功标准: {{成功标准}} +- 已知约束: {{约束}} +- 可用工具: {{工具}} +- 风险与禁止项: {{禁止项}} + +输出: +1. Skills 拆分(3-7 个, 每个写清输入/输出/失败信号) +2. 上下文策略 + - 必带上下文(固定) + - 运行态上下文(每步更新) + - 长期记忆(会话/摘要/知识三层, 何时写入/检索/丢弃) +3. 防跑偏机制 + - 输出格式约束 + - 自检点(每 2-3 步检查一次) + - 失败兜底(降级/停止/请求澄清) +4. 验收用测试集(至少 10 条, 覆盖 3 类边界情况) +``` + +## 课堂发放建议 +- 启航班: 发 1-4 号模板, 先解日常痛点。 +- 精英班: 发全量模板, 并要求在 OpenClaw/mini-openclaw 案例里结合 5-9 号模板落地。 diff --git a/backend/app/one_person_security_dept/openclaw/curriculum/rubrics-and-checklists.md b/backend/app/one_person_security_dept/openclaw/curriculum/rubrics-and-checklists.md new file mode 100644 index 00000000..84adbf0a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/curriculum/rubrics-and-checklists.md @@ -0,0 +1,64 @@ +# 评分量表与交付清单(双班通用) + +## 1) 启航班评分量表(100 分) + +| 维度 | 分值 | 评分要点(可操作) | +|---|---:|---| +| 模板可复用性 | 30 | 结构固定; 变量位清晰; 约束明确; 输出格式可直接复用 | +| 输出质量 | 40 | 事实密度高; 无套话; 结论可追溯输入; 有清晰 CTA | +| 工作流完整性 | 20 | 能跑通“输入 -> 生成 -> 校对 -> 交付”; 有失败兜底写法 | +| 合规与边界 | 10 | 不处理未授权素材; 不编造数据/政策; 关键结论可追溯 | + +启航班通过线: +- 总分不低于 70 +- “合规与边界”不得低于 8 + +## 2) 启航班结课交付清单(讲师验收用) + +| 交付物 | 必须包含 | 通过标准 | +|---|---|---| +| 团队 Prompt 模板包 | 文档/周报/邮件/去 AI 味四类模板 | 现场换输入可稳定复用 | +| 轻项目演示脚本 | 3 个固定场景(文档/周报/邮件) | 10 分钟内可演示完整流程 | +| 最小 RAG 复盘卡 | 1 次失败复盘(根因+证据+修复+回归) | 能指出错误发生在哪一环 | + +## 3) 精英班评分量表(100 分) + +| 维度 | 分值 | 评分要点(可操作) | +|---|---:|---| +| 构造闯关(G1-G6) | 40 | 每关都有可运行产物与自动化验收; 失败场景可复现 | +| 可靠性与可观测 | 20 | 指标/日志/健康检查齐全; 能定位常见故障; 有回滚开关 | +| 安全与权限边界 | 15 | 工具白名单; 沙箱/确认; 审计日志; 提示注入防护 | +| 评估与复盘质量 | 15 | 有 Evals 基线; 2 次以上真实失败复盘与回归记录 | +| 文档与可复现 | 10 | 新人可按 README 复现; 架构与协议文档清晰一致 | + +精英班通过线: +- 总分不低于 80 +- “安全与权限边界”不得低于 12 +- 必须提交可运行的 `mini-openclaw`(不能只交方案) + +## 4) 精英班结课交付清单(讲师验收用) + +| 交付物 | 必须包含 | 通过标准 | +|---|---|---| +| mini-openclaw 仓库 | README; docs; 源码; tests; docker | 30 分钟内可跑通演示脚本 | +| 2 份故障复盘 | 根因/证据/修复/回归/预防 | 修复后能通过回归测试 | +| 安全清单 | 权限分级; 工具沙箱; 审计与回滚 | 高风险工具被默认拦截或确认 | +| 评估报告 | 基线指标; 对比实验; 结论与取舍 | 方法正确; 结论可复现 | +| 上线 runbook | 常见故障处理; 监控指标; 回滚策略 | 可用于值班交接 | + +## 5) 一页复盘模板(统一口径) +1. 现象(发生了什么) +2. 影响(对用户/业务/成本的影响) +3. 触发条件(输入、环境、版本) +4. 根因(最小可解释原因) +5. 证据(日志、截图、复现实验) +6. 修复(代码/配置/流程) +7. 回归(怎么确保不再复发) +8. 预防(监控、告警、权限、文档) + +## 6) 最小 Evals 模板(精英班必用) +1. 任务定义(成功标准, 失败定义) +2. 测试集(至少 30 条, 覆盖 3 类边界情况) +3. 指标(成功率, 失败类型分布, 召回/引用正确率, 时延/成本) +4. 误差分析(Top 5 失败模式 + 对应修复) +5. 对比实验(修复前 vs 修复后) diff --git a/backend/app/one_person_security_dept/openclaw/docker-compose.yml b/backend/app/one_person_security_dept/openclaw/docker-compose.yml new file mode 100644 index 00000000..614a1f8d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docker-compose.yml @@ -0,0 +1,46 @@ +services: + openclaw-gateway: + image: ${OPENCLAW_IMAGE:-openclaw:local} + environment: + HOME: /home/node + TERM: xterm-256color + OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN} + CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY} + CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY} + CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE} + volumes: + - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw + - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace + ports: + - "${OPENCLAW_GATEWAY_PORT:-18789}:18789" + - "${OPENCLAW_BRIDGE_PORT:-18790}:18790" + init: true + restart: unless-stopped + command: + [ + "node", + "dist/index.js", + "gateway", + "--bind", + "${OPENCLAW_GATEWAY_BIND:-lan}", + "--port", + "18789", + ] + + openclaw-cli: + image: ${OPENCLAW_IMAGE:-openclaw:local} + environment: + HOME: /home/node + TERM: xterm-256color + OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN} + BROWSER: echo + CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY} + CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY} + CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE} + volumes: + - ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw + - ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace + stdin_open: true + tty: true + init: true + entrypoint: ["node", "dist/index.js"] diff --git a/backend/app/one_person_security_dept/openclaw/docker-setup.sh b/backend/app/one_person_security_dept/openclaw/docker-setup.sh new file mode 100755 index 00000000..8c67dc09 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docker-setup.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="$ROOT_DIR/docker-compose.yml" +EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml" +IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}" +EXTRA_MOUNTS="${OPENCLAW_EXTRA_MOUNTS:-}" +HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing dependency: $1" >&2 + exit 1 + fi +} + +contains_disallowed_chars() { + local value="$1" + [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] +} + +validate_mount_path_value() { + local label="$1" + local value="$2" + if [[ -z "$value" ]]; then + fail "$label cannot be empty." + fi + if contains_disallowed_chars "$value"; then + fail "$label contains unsupported control characters." + fi + if [[ "$value" =~ [[:space:]] ]]; then + fail "$label cannot contain whitespace." + fi +} + +validate_named_volume() { + local value="$1" + if [[ ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then + fail "OPENCLAW_HOME_VOLUME must match [A-Za-z0-9][A-Za-z0-9_.-]* when using a named volume." + fi +} + +validate_mount_spec() { + local mount="$1" + if contains_disallowed_chars "$mount"; then + fail "OPENCLAW_EXTRA_MOUNTS entries cannot contain control characters." + fi + # Keep mount specs strict to avoid YAML structure injection. + # Expected format: source:target[:options] + if [[ ! "$mount" =~ ^[^[:space:],:]+:[^[:space:],:]+(:[^[:space:],:]+)?$ ]]; then + fail "Invalid mount format '$mount'. Expected source:target[:options] without spaces." + fi +} + +require_cmd docker +if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose not available (try: docker compose version)" >&2 + exit 1 +fi + +OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}" +OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}" + +validate_mount_path_value "OPENCLAW_CONFIG_DIR" "$OPENCLAW_CONFIG_DIR" +validate_mount_path_value "OPENCLAW_WORKSPACE_DIR" "$OPENCLAW_WORKSPACE_DIR" +if [[ -n "$HOME_VOLUME_NAME" ]]; then + if [[ "$HOME_VOLUME_NAME" == *"/"* ]]; then + validate_mount_path_value "OPENCLAW_HOME_VOLUME" "$HOME_VOLUME_NAME" + else + validate_named_volume "$HOME_VOLUME_NAME" + fi +fi +if contains_disallowed_chars "$EXTRA_MOUNTS"; then + fail "OPENCLAW_EXTRA_MOUNTS cannot contain control characters." +fi + +mkdir -p "$OPENCLAW_CONFIG_DIR" +mkdir -p "$OPENCLAW_WORKSPACE_DIR" +# Seed device-identity parent eagerly for Docker Desktop/Windows bind mounts +# that reject creating new subdirectories from inside the container. +mkdir -p "$OPENCLAW_CONFIG_DIR/identity" + +export OPENCLAW_CONFIG_DIR +export OPENCLAW_WORKSPACE_DIR +export OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}" +export OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}" +export OPENCLAW_GATEWAY_BIND="${OPENCLAW_GATEWAY_BIND:-lan}" +export OPENCLAW_IMAGE="$IMAGE_NAME" +export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}" +export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS" +export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME" + +if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then + if command -v openssl >/dev/null 2>&1; then + OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)" + else + OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY' +import secrets +print(secrets.token_hex(32)) +PY +)" + fi +fi +export OPENCLAW_GATEWAY_TOKEN + +COMPOSE_FILES=("$COMPOSE_FILE") +COMPOSE_ARGS=() + +write_extra_compose() { + local home_volume="$1" + shift + local mount + local gateway_home_mount + local gateway_config_mount + local gateway_workspace_mount + + cat >"$EXTRA_COMPOSE_FILE" <<'YAML' +services: + openclaw-gateway: + volumes: +YAML + + if [[ -n "$home_volume" ]]; then + gateway_home_mount="${home_volume}:/home/node" + gateway_config_mount="${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw" + gateway_workspace_mount="${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace" + validate_mount_spec "$gateway_home_mount" + validate_mount_spec "$gateway_config_mount" + validate_mount_spec "$gateway_workspace_mount" + printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE" + fi + + for mount in "$@"; do + validate_mount_spec "$mount" + printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" + done + + cat >>"$EXTRA_COMPOSE_FILE" <<'YAML' + openclaw-cli: + volumes: +YAML + + if [[ -n "$home_volume" ]]; then + printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE" + fi + + for mount in "$@"; do + validate_mount_spec "$mount" + printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" + done + + if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then + validate_named_volume "$home_volume" + cat >>"$EXTRA_COMPOSE_FILE" <>"$tmp" + seen="$seen$k " + replaced=true + break + fi + done + if [[ "$replaced" == false ]]; then + printf '%s\n' "$line" >>"$tmp" + fi + done <"$file" + fi + + for k in "${keys[@]}"; do + if [[ "$seen" != *" $k "* ]]; then + printf '%s=%s\n' "$k" "${!k-}" >>"$tmp" + fi + done + + mv "$tmp" "$file" +} + +upsert_env "$ENV_FILE" \ + OPENCLAW_CONFIG_DIR \ + OPENCLAW_WORKSPACE_DIR \ + OPENCLAW_GATEWAY_PORT \ + OPENCLAW_BRIDGE_PORT \ + OPENCLAW_GATEWAY_BIND \ + OPENCLAW_GATEWAY_TOKEN \ + OPENCLAW_IMAGE \ + OPENCLAW_EXTRA_MOUNTS \ + OPENCLAW_HOME_VOLUME \ + OPENCLAW_DOCKER_APT_PACKAGES + +echo "==> Building Docker image: $IMAGE_NAME" +docker build \ + --build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \ + -t "$IMAGE_NAME" \ + -f "$ROOT_DIR/Dockerfile" \ + "$ROOT_DIR" + +echo "" +echo "==> Onboarding (interactive)" +echo "When prompted:" +echo " - Gateway bind: lan" +echo " - Gateway auth: token" +echo " - Gateway token: $OPENCLAW_GATEWAY_TOKEN" +echo " - Tailscale exposure: Off" +echo " - Install Gateway daemon: No" +echo "" +docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --no-install-daemon + +echo "" +echo "==> Provider setup (optional)" +echo "WhatsApp (QR):" +echo " ${COMPOSE_HINT} run --rm openclaw-cli channels login" +echo "Telegram (bot token):" +echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel telegram --token " +echo "Discord (bot token):" +echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel discord --token " +echo "Docs: https://docs.openclaw.ai/channels" + +echo "" +echo "==> Starting gateway" +docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway + +echo "" +echo "Gateway running with host port mapping." +echo "Access from tailnet devices via the host's tailnet IP." +echo "Config: $OPENCLAW_CONFIG_DIR" +echo "Workspace: $OPENCLAW_WORKSPACE_DIR" +echo "Token: $OPENCLAW_GATEWAY_TOKEN" +echo "" +echo "Commands:" +echo " ${COMPOSE_HINT} logs -f openclaw-gateway" +echo " ${COMPOSE_HINT} exec openclaw-gateway node dist/index.js health --token \"$OPENCLAW_GATEWAY_TOKEN\"" diff --git a/backend/app/one_person_security_dept/openclaw/docs.acp.md b/backend/app/one_person_security_dept/openclaw/docs.acp.md new file mode 100644 index 00000000..cfe7349c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs.acp.md @@ -0,0 +1,197 @@ +# OpenClaw ACP Bridge + +This document describes how the OpenClaw ACP (Agent Client Protocol) bridge works, +how it maps ACP sessions to Gateway sessions, and how IDEs should invoke it. + +## Overview + +`openclaw acp` exposes an ACP agent over stdio and forwards prompts to a running +OpenClaw Gateway over WebSocket. It keeps ACP session ids mapped to Gateway +session keys so IDEs can reconnect to the same agent transcript or reset it on +request. + +Key goals: + +- Minimal ACP surface area (stdio, NDJSON). +- Stable session mapping across reconnects. +- Works with existing Gateway session store (list/resolve/reset). +- Safe defaults (isolated ACP session keys by default). + +## How can I use this + +Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to +drive a OpenClaw Gateway session. + +Quick steps: + +1. Run a Gateway (local or remote). +2. Configure the Gateway target (`gateway.remote.url` + auth) or pass flags. +3. Point the IDE to run `openclaw acp` over stdio. + +Example config: + +```bash +openclaw config set gateway.remote.url wss://gateway-host:18789 +openclaw config set gateway.remote.token +``` + +Example run: + +```bash +openclaw acp --url wss://gateway-host:18789 --token +``` + +## Selecting agents + +ACP does not pick agents directly. It routes by the Gateway session key. + +Use agent-scoped session keys to target a specific agent: + +```bash +openclaw acp --session agent:main:main +openclaw acp --session agent:design:main +openclaw acp --session agent:qa:bug-123 +``` + +Each ACP session maps to a single Gateway session key. One agent can have many +sessions; ACP defaults to an isolated `acp:` session unless you override +the key or label. + +## Zed editor setup + +Add a custom ACP agent in `~/.config/zed/settings.json`: + +```json +{ + "agent_servers": { + "OpenClaw ACP": { + "type": "custom", + "command": "openclaw", + "args": ["acp"], + "env": {} + } + } +} +``` + +To target a specific Gateway or agent: + +```json +{ + "agent_servers": { + "OpenClaw ACP": { + "type": "custom", + "command": "openclaw", + "args": [ + "acp", + "--url", + "wss://gateway-host:18789", + "--token", + "", + "--session", + "agent:design:main" + ], + "env": {} + } + } +} +``` + +In Zed, open the Agent panel and select “OpenClaw ACP” to start a thread. + +## Execution Model + +- ACP client spawns `openclaw acp` and speaks ACP messages over stdio. +- The bridge connects to the Gateway using existing auth config (or CLI flags). +- ACP `prompt` translates to Gateway `chat.send`. +- Gateway streaming events are translated back into ACP streaming events. +- ACP `cancel` maps to Gateway `chat.abort` for the active run. + +## Session Mapping + +By default each ACP session is mapped to a dedicated Gateway session key: + +- `acp:` unless overridden. + +You can override or reuse sessions in two ways: + +1. CLI defaults + +```bash +openclaw acp --session agent:main:main +openclaw acp --session-label "support inbox" +openclaw acp --reset-session +``` + +2. ACP metadata per session + +```json +{ + "_meta": { + "sessionKey": "agent:main:main", + "sessionLabel": "support inbox", + "resetSession": true, + "requireExisting": false + } +} +``` + +Rules: + +- `sessionKey`: direct Gateway session key. +- `sessionLabel`: resolve an existing session by label. +- `resetSession`: mint a new transcript for the key before first use. +- `requireExisting`: fail if the key/label does not exist. + +### Session Listing + +ACP `listSessions` maps to Gateway `sessions.list` and returns a filtered +summary suitable for IDE session pickers. `_meta.limit` can cap the number of +sessions returned. + +## Prompt Translation + +ACP prompt inputs are converted into a Gateway `chat.send`: + +- `text` and `resource` blocks become prompt text. +- `resource_link` with image mime types become attachments. +- The working directory can be prefixed into the prompt (default on, can be + disabled with `--no-prefix-cwd`). + +Gateway streaming events are translated into ACP `message` and `tool_call` +updates. Terminal Gateway states map to ACP `done` with stop reasons: + +- `complete` -> `stop` +- `aborted` -> `cancel` +- `error` -> `error` + +## Auth + Gateway Discovery + +`openclaw acp` resolves the Gateway URL and auth from CLI flags or config: + +- `--url` / `--token` / `--password` take precedence. +- Otherwise use configured `gateway.remote.*` settings. + +## Operational Notes + +- ACP sessions are stored in memory for the bridge process lifetime. +- Gateway session state is persisted by the Gateway itself. +- `--verbose` logs ACP/Gateway bridge events to stderr (never stdout). +- ACP runs can be canceled and the active run id is tracked per session. + +## Compatibility + +- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x). +- Works with ACP clients that implement `initialize`, `newSession`, + `loadSession`, `prompt`, `cancel`, and `listSessions`. + +## Testing + +- Unit: `src/acp/session.test.ts` covers run id lifecycle. +- Full gate: `pnpm build && pnpm check && pnpm test && pnpm docs:build`. + +## Related Docs + +- CLI usage: `docs/cli/acp.md` +- Session model: `docs/concepts/session.md` +- Session management internals: `docs/reference/session-management-compaction.md` diff --git a/backend/app/one_person_security_dept/openclaw/docs/.i18n/README.md b/backend/app/one_person_security_dept/openclaw/docs/.i18n/README.md new file mode 100644 index 00000000..8e751a11 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/.i18n/README.md @@ -0,0 +1,31 @@ +# OpenClaw docs i18n assets + +This folder stores **generated** and **config** files for documentation translations. + +## Files + +- `glossary..json` — preferred term mappings (used in prompt guidance). +- `.tm.jsonl` — translation memory (cache) keyed by workflow + model + text hash. + +## Glossary format + +`glossary..json` is an array of entries: + +```json +{ + "source": "troubleshooting", + "target": "故障排除", + "ignore_case": true, + "whole_word": false +} +``` + +Fields: + +- `source`: English (or source) phrase to prefer. +- `target`: preferred translation output. + +## Notes + +- Glossary entries are passed to the model as **prompt guidance** (no deterministic rewrites). +- The translation memory is updated by `scripts/docs-i18n`. diff --git a/backend/app/one_person_security_dept/openclaw/docs/.i18n/glossary.ja-JP.json b/backend/app/one_person_security_dept/openclaw/docs/.i18n/glossary.ja-JP.json new file mode 100644 index 00000000..f7c59a18 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/.i18n/glossary.ja-JP.json @@ -0,0 +1,14 @@ +[ + { "source": "OpenClaw", "target": "OpenClaw" }, + { "source": "Gateway", "target": "Gateway" }, + { "source": "Pi", "target": "Pi" }, + { "source": "Skills", "target": "Skills" }, + { "source": "local loopback", "target": "local loopback" }, + { "source": "Tailscale", "target": "Tailscale" }, + { "source": "Getting Started", "target": "はじめに" }, + { "source": "Getting started", "target": "はじめに" }, + { "source": "Quick start", "target": "クイックスタート" }, + { "source": "Quick Start", "target": "クイックスタート" }, + { "source": "Onboarding", "target": "オンボーディング" }, + { "source": "wizard", "target": "ウィザード" } +] diff --git a/backend/app/one_person_security_dept/openclaw/docs/.i18n/glossary.zh-CN.json b/backend/app/one_person_security_dept/openclaw/docs/.i18n/glossary.zh-CN.json new file mode 100644 index 00000000..bde10807 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/.i18n/glossary.zh-CN.json @@ -0,0 +1,210 @@ +[ + { + "source": "OpenClaw", + "target": "OpenClaw" + }, + { + "source": "Gateway", + "target": "Gateway 网关" + }, + { + "source": "Pi", + "target": "Pi" + }, + { + "source": "Skills", + "target": "Skills" + }, + { + "source": "Skills config", + "target": "Skills 配置" + }, + { + "source": "Skills Config", + "target": "Skills 配置" + }, + { + "source": "local loopback", + "target": "local loopback" + }, + { + "source": "Tailscale", + "target": "Tailscale" + }, + { + "source": "Getting Started", + "target": "入门指南" + }, + { + "source": "Getting started", + "target": "入门指南" + }, + { + "source": "Quick start", + "target": "快速开始" + }, + { + "source": "Quick Start", + "target": "快速开始" + }, + { + "source": "Docs directory", + "target": "文档目录" + }, + { + "source": "Credits", + "target": "致谢" + }, + { + "source": "Features", + "target": "功能" + }, + { + "source": "DMs", + "target": "私信" + }, + { + "source": "DM", + "target": "私信" + }, + { + "source": "sandbox", + "target": "沙箱" + }, + { + "source": "Sandbox", + "target": "沙箱" + }, + { + "source": "sandboxing", + "target": "沙箱隔离" + }, + { + "source": "Sandboxing", + "target": "沙箱隔离" + }, + { + "source": "sandboxed", + "target": "沙箱隔离" + }, + { + "source": "Sandboxed", + "target": "沙箱隔离" + }, + { + "source": "Sandboxing note", + "target": "沙箱注意事项" + }, + { + "source": "Companion apps", + "target": "配套应用" + }, + { + "source": "expected keys", + "target": "预期键名" + }, + { + "source": "block streaming", + "target": "分块流式传输" + }, + { + "source": "Block streaming", + "target": "分块流式传输" + }, + { + "source": "Discovery + transports", + "target": "设备发现 + 传输协议" + }, + { + "source": "Discovery", + "target": "设备发现" + }, + { + "source": "Network model", + "target": "网络模型" + }, + { + "source": "for full details", + "target": "了解详情" + }, + { + "source": "First 60 seconds", + "target": "最初的六十秒" + }, + { + "source": "Auth: where it lives (important)", + "target": "凭证:存储位置(重要)" + }, + { + "source": "agent", + "target": "智能体" + }, + { + "source": "channel", + "target": "渠道" + }, + { + "source": "session", + "target": "会话" + }, + { + "source": "provider", + "target": "提供商" + }, + { + "source": "model", + "target": "模型" + }, + { + "source": "tool", + "target": "工具" + }, + { + "source": "CLI", + "target": "CLI" + }, + { + "source": "install sanity", + "target": "安装完整性检查" + }, + { + "source": "get unstuck", + "target": "解决问题" + }, + { + "source": "troubleshooting", + "target": "故障排除" + }, + { + "source": "FAQ", + "target": "常见问题" + }, + { + "source": "onboarding", + "target": "新手引导" + }, + { + "source": "Onboarding", + "target": "新手引导" + }, + { + "source": "wizard", + "target": "向导" + }, + { + "source": "environment variables", + "target": "环境变量" + }, + { + "source": "environment variable", + "target": "环境变量" + }, + { + "source": "env vars", + "target": "环境变量" + }, + { + "source": "env var", + "target": "环境变量" + } +] diff --git a/backend/app/one_person_security_dept/openclaw/docs/CNAME b/backend/app/one_person_security_dept/openclaw/docs/CNAME new file mode 100644 index 00000000..715bc9df --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/CNAME @@ -0,0 +1 @@ +docs.openclaw.ai diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/install-script.svg b/backend/app/one_person_security_dept/openclaw/docs/assets/install-script.svg new file mode 100644 index 00000000..78a6f975 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/assets/install-script.svg @@ -0,0 +1 @@ +seb@ubuntu:~$curl-fsSLhttps://openclaw.ai/install.sh|bash╭─────────────────────────────────────────╮🦞OpenClawInstallerBecauseSiriwasn'tansweringat3AM.moderninstallermode╰─────────────────────────────────────────╯gumbootstrapped(temp,verified,v0.17.0)Detected:linuxInstallplanOSlinuxInstallmethodnpmRequestedversionlatest[1/3]PreparingenvironmentINFONode.jsnotfound,installingitnowINFOInstallingNode.jsviaNodeSourceConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryConfiguringNodeSourcerepositoryInstallingNode.jsInstallingNode.jsInstallingNode.jsInstallingNode.jsInstallingNode.jsInstallingNode.jsInstallingNode.jsInstallingNode.jsNode.jsv22installed[2/3]InstallingOpenClawINFOGitnotfound,installingitnowUpdatingpackageindexInstallingGitInstallingGitInstallingGitInstallingGitInstallingGitInstallingGitInstallingGitInstallingGitGitinstalledINFOConfiguringnpmforuser-localinstallsnpmconfiguredforuserinstallsINFOInstallingOpenClawv2026.2.9InstallingOpenClawpackageInstallingOpenClawpackageInstallingOpenClawpackageInstallingOpenClawpackageInstallingOpenClawpackageInstallingOpenClawpackageInstallingOpenClawpackageInstallingOpenClawpackageOpenClawnpmpackageinstalledOpenClawinstalled[3/3]FinalizingsetupWARNPATHmissingnpmglobalbindir:/home/seb/.npm-global/binThiscanmakeopenclawshowas"commandnotfound"innewterminals.Fix(zsh:~/.zshrc,bash:~/.bashrc):exportPATH="/home/seb/.npm-global/bin:$PATH"🦞OpenClawinstalledsuccessfully(2026.2.9)!Finallyunpacked.Nowpointmeatyourproblems.INFOStartingsetup🦞OpenClaw2026.2.9(33c75cb)Thinkdifferent.Actuallythink.▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄██░▄▄▄░██░▄▄░██░▄▄▄██░▀██░██░▄▄▀██░████░▄▄▀██░███░████░███░██░▀▀░██░▄▄▄██░█░█░██░█████░████░▀▀░██░█░█░████░▀▀▀░██░█████░▀▀▀██░██▄░██░▀▀▄██░▀▀░█░██░██▄▀▄▀▄██▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀🦞OPENCLAW🦞OpenClawonboardingSecurity──────────────────────────────────────────────────────────────────────────────╮Securitywarningpleaseread.OpenClawisahobbyprojectandstillinbeta.Expectsharpedges.Thisbotcanreadfilesandrunactionsiftoolsareenabled.Abadpromptcantrickitintodoingunsafethings.Ifyou’renotcomfortablewithbasicsecurityandaccesscontrol,don’trunOpenClaw.Asksomeoneexperiencedtohelpbeforeenablingtoolsorexposingittotheinternet.Recommendedbaseline:-Pairing/allowlists+mentiongating.-Sandbox+least-privilegetools.-Keepsecretsoutoftheagent’sreachablefilesystem.-Usethestrongestavailablemodelforanybotwithtoolsoruntrustedinboxes.Runregularly:openclawsecurityaudit--deepopenclawsecurityaudit--fixMustread:https://docs.openclaw.ai/gateway/security├─────────────────────────────────────────────────────────────────────────────────────────╯Iunderstandthisispowerfulandinherentlyrisky.Continue?Yes/NoYes/Noseb@ubuntu:~$asciinemaseb@ubuntu:~$asciinemauploadseb@ubuntu:~$asciinemauploaddemo.castseb@ubuntu:~$seb@ubuntu:~$curl -fsSL https://openclaw.ai/install.sh | bashUpdatingpackageindexUpdatingpackageindexUpdatingpackageindexUpdatingpackageindexUpdatingpackageindexUpdatingpackageindexUpdatingpackageindexAbadpromptcantrickitintodoingunsafethings.-Keepsecretsoutoftheagent’sreachablefilesystem.seb@ubuntu:~$seb@ubuntu:~$aseb@ubuntu:~$asseb@ubuntu:~$ascseb@ubuntu:~$asciseb@ubuntu:~$asciiseb@ubuntu:~$asciinseb@ubuntu:~$asciineseb@ubuntu:~$asciinemseb@ubuntu:~$asciinemauseb@ubuntu:~$asciinemaupseb@ubuntu:~$asciinemauplseb@ubuntu:~$asciinemauploseb@ubuntu:~$asciinemauploaseb@ubuntu:~$asciinemauploaddseb@ubuntu:~$asciinemauploaddeseb@ubuntu:~$asciinemauploaddemseb@ubuntu:~$asciinemauploaddemoseb@ubuntu:~$asciinemauploaddemo.seb@ubuntu:~$asciinemauploaddemo.cseb@ubuntu:~$asciinemauploaddemo.caseb@ubuntu:~$asciinemauploaddemo.cas \ No newline at end of file diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/01-macos-warning.jpeg b/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/01-macos-warning.jpeg new file mode 100644 index 00000000..255976fe Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/01-macos-warning.jpeg differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/02-local-networks.jpeg b/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/02-local-networks.jpeg new file mode 100644 index 00000000..0135e38f Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/02-local-networks.jpeg differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/03-security-notice.png b/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/03-security-notice.png new file mode 100644 index 00000000..ca0dac96 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/03-security-notice.png differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/04-choose-gateway.png b/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/04-choose-gateway.png new file mode 100644 index 00000000..4e0233c2 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/04-choose-gateway.png differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/05-permissions.png b/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/05-permissions.png new file mode 100644 index 00000000..910a5f8d Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/macos-onboarding/05-permissions.png differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/openclaw-logo-text-dark.png b/backend/app/one_person_security_dept/openclaw/docs/assets/openclaw-logo-text-dark.png new file mode 100644 index 00000000..b14e4233 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/openclaw-logo-text-dark.png differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/openclaw-logo-text.png b/backend/app/one_person_security_dept/openclaw/docs/assets/openclaw-logo-text.png new file mode 100644 index 00000000..705d2c0b Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/openclaw-logo-text.png differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/pixel-lobster.svg b/backend/app/one_person_security_dept/openclaw/docs/assets/pixel-lobster.svg new file mode 100644 index 00000000..7bfb7fc4 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/assets/pixel-lobster.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/agents-ui.jpg b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/agents-ui.jpg new file mode 100644 index 00000000..ae6ab6d1 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/agents-ui.jpg differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/bambu-cli.png b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/bambu-cli.png new file mode 100644 index 00000000..046f627b Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/bambu-cli.png differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/codexmonitor.png b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/codexmonitor.png new file mode 100644 index 00000000..43952b92 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/codexmonitor.png differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/gohome-grafana.png b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/gohome-grafana.png new file mode 100644 index 00000000..bd7cf077 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/gohome-grafana.png differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/ios-testflight.jpg b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/ios-testflight.jpg new file mode 100644 index 00000000..4e19768f Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/ios-testflight.jpg differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/oura-health.png b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/oura-health.png new file mode 100644 index 00000000..b1e9f707 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/oura-health.png differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/padel-cli.svg b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/padel-cli.svg new file mode 100644 index 00000000..61eb6334 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/padel-cli.svg @@ -0,0 +1,11 @@ + + + + + $ padel search --location "Barcelona" --date 2026-01-08 --time 18:00-22:00 + Available courts (3): + - Vall d'Hebron 19:00 Court 2 (90m) EUR 34 + - Badalona 20:30 Court 1 (60m) EUR 28 + - Gracia 21:00 Court 4 (90m) EUR 36 + + diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/padel-screenshot.jpg b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/padel-screenshot.jpg new file mode 100644 index 00000000..eb1ae39e Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/padel-screenshot.jpg differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/papla-tts.jpg b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/papla-tts.jpg new file mode 100644 index 00000000..3e7af383 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/papla-tts.jpg differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/pr-review-telegram.jpg b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/pr-review-telegram.jpg new file mode 100644 index 00000000..888a4132 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/pr-review-telegram.jpg differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/roborock-screenshot.jpg b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/roborock-screenshot.jpg new file mode 100644 index 00000000..e31ba11e Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/roborock-screenshot.jpg differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/roborock-status.svg b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/roborock-status.svg new file mode 100644 index 00000000..47084042 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/roborock-status.svg @@ -0,0 +1,13 @@ + + + + + $ gohome roborock status --device "Living Room" + Device: Roborock Q Revo + State: cleaning (zone) + Battery: 78% + Dustbin: 42% + Water tank: 61% + Last clean: 2026-01-06 19:42 + + diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/roof-camera-sky.jpg b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/roof-camera-sky.jpg new file mode 100644 index 00000000..3396f140 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/roof-camera-sky.jpg differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/snag.png b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/snag.png new file mode 100644 index 00000000..c82c47ac Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/snag.png differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/tesco-shop.jpg b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/tesco-shop.jpg new file mode 100644 index 00000000..66af85d3 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/tesco-shop.jpg differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/wienerlinien.png b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/wienerlinien.png new file mode 100644 index 00000000..8bdf5ae6 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/wienerlinien.png differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/wine-cellar-skill.jpg b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/wine-cellar-skill.jpg new file mode 100644 index 00000000..7cd2016c Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/wine-cellar-skill.jpg differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/winix-air-purifier.jpg b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/winix-air-purifier.jpg new file mode 100644 index 00000000..c8b99540 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/winix-air-purifier.jpg differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/xuezh-pronunciation.jpeg b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/xuezh-pronunciation.jpeg new file mode 100644 index 00000000..7f7d86a8 Binary files /dev/null and b/backend/app/one_person_security_dept/openclaw/docs/assets/showcase/xuezh-pronunciation.jpeg differ diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/sponsors/blacksmith.svg b/backend/app/one_person_security_dept/openclaw/docs/assets/sponsors/blacksmith.svg new file mode 100644 index 00000000..5bb1bc2e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/assets/sponsors/blacksmith.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/backend/app/one_person_security_dept/openclaw/docs/assets/sponsors/openai.svg b/backend/app/one_person_security_dept/openclaw/docs/assets/sponsors/openai.svg new file mode 100644 index 00000000..1c3491b9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/assets/sponsors/openai.svg @@ -0,0 +1,3 @@ + + + diff --git a/backend/app/one_person_security_dept/openclaw/docs/automation/auth-monitoring.md b/backend/app/one_person_security_dept/openclaw/docs/automation/auth-monitoring.md new file mode 100644 index 00000000..877a1c2c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/automation/auth-monitoring.md @@ -0,0 +1,44 @@ +--- +summary: "Monitor OAuth expiry for model providers" +read_when: + - Setting up auth expiry monitoring or alerts + - Automating Claude Code / Codex OAuth refresh checks +title: "Auth Monitoring" +--- + +# Auth monitoring + +OpenClaw exposes OAuth expiry health via `openclaw models status`. Use that for +automation and alerting; scripts are optional extras for phone workflows. + +## Preferred: CLI check (portable) + +```bash +openclaw models status --check +``` + +Exit codes: + +- `0`: OK +- `1`: expired or missing credentials +- `2`: expiring soon (within 24h) + +This works in cron/systemd and requires no extra scripts. + +## Optional scripts (ops / phone workflows) + +These live under `scripts/` and are **optional**. They assume SSH access to the +gateway host and are tuned for systemd + Termux. + +- `scripts/claude-auth-status.sh` now uses `openclaw models status --json` as the + source of truth (falling back to direct file reads if the CLI is unavailable), + so keep `openclaw` on `PATH` for timers. +- `scripts/auth-monitor.sh`: cron/systemd timer target; sends alerts (ntfy or phone). +- `scripts/systemd/openclaw-auth-monitor.{service,timer}`: systemd user timer. +- `scripts/claude-auth-status.sh`: Claude Code + OpenClaw auth checker (full/json/simple). +- `scripts/mobile-reauth.sh`: guided re‑auth flow over SSH. +- `scripts/termux-quick-auth.sh`: one‑tap widget status + open auth URL. +- `scripts/termux-auth-widget.sh`: full guided widget flow. +- `scripts/termux-sync-widget.sh`: sync Claude Code creds → OpenClaw. + +If you don’t need phone automation or systemd timers, skip these scripts. diff --git a/backend/app/one_person_security_dept/openclaw/docs/automation/cron-jobs.md b/backend/app/one_person_security_dept/openclaw/docs/automation/cron-jobs.md new file mode 100644 index 00000000..8d140192 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/automation/cron-jobs.md @@ -0,0 +1,633 @@ +--- +summary: "Cron jobs + wakeups for the Gateway scheduler" +read_when: + - Scheduling background jobs or wakeups + - Wiring automation that should run with or alongside heartbeats + - Deciding between heartbeat and cron for scheduled tasks +title: "Cron Jobs" +--- + +# Cron jobs (Gateway scheduler) + +> **Cron vs Heartbeat?** See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for guidance on when to use each. + +Cron is the Gateway’s built-in scheduler. It persists jobs, wakes the agent at +the right time, and can optionally deliver output back to a chat. + +If you want _“run this every morning”_ or _“poke the agent in 20 minutes”_, +cron is the mechanism. + +Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) + +## TL;DR + +- Cron runs **inside the Gateway** (not inside the model). +- Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules. +- Two execution styles: + - **Main session**: enqueue a system event, then run on the next heartbeat. + - **Isolated**: run a dedicated agent turn in `cron:`, with delivery (announce by default or none). +- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. +- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = ""`. +- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode. + +## Quick start (actionable) + +Create a one-shot reminder, verify it exists, and run it immediately: + +```bash +openclaw cron add \ + --name "Reminder" \ + --at "2026-02-01T16:00:00Z" \ + --session main \ + --system-event "Reminder: check the cron docs draft" \ + --wake now \ + --delete-after-run + +openclaw cron list +openclaw cron run +openclaw cron runs --id +``` + +Schedule a recurring isolated job with delivery: + +```bash +openclaw cron add \ + --name "Morning brief" \ + --cron "0 7 * * *" \ + --tz "America/Los_Angeles" \ + --session isolated \ + --message "Summarize overnight updates." \ + --announce \ + --channel slack \ + --to "channel:C1234567890" +``` + +## Tool-call equivalents (Gateway cron tool) + +For the canonical JSON shapes and examples, see [JSON schema for tool calls](/automation/cron-jobs#json-schema-for-tool-calls). + +## Where cron jobs are stored + +Cron jobs are persisted on the Gateway host at `~/.openclaw/cron/jobs.json` by default. +The Gateway loads the file into memory and writes it back on changes, so manual edits +are only safe when the Gateway is stopped. Prefer `openclaw cron add/edit` or the cron +tool call API for changes. + +## Beginner-friendly overview + +Think of a cron job as: **when** to run + **what** to do. + +1. **Choose a schedule** + - One-shot reminder → `schedule.kind = "at"` (CLI: `--at`) + - Repeating job → `schedule.kind = "every"` or `schedule.kind = "cron"` + - If your ISO timestamp omits a timezone, it is treated as **UTC**. + +2. **Choose where it runs** + - `sessionTarget: "main"` → run during the next heartbeat with main context. + - `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:`. + +3. **Choose the payload** + - Main session → `payload.kind = "systemEvent"` + - Isolated session → `payload.kind = "agentTurn"` + +Optional: one-shot jobs (`schedule.kind = "at"`) delete after success by default. Set +`deleteAfterRun: false` to keep them (they will disable after success). + +## Concepts + +### Jobs + +A cron job is a stored record with: + +- a **schedule** (when it should run), +- a **payload** (what it should do), +- optional **delivery mode** (`announce`, `webhook`, or `none`). +- optional **agent binding** (`agentId`): run the job under a specific agent; if + missing or unknown, the gateway falls back to the default agent. + +Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs). +In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility. +One-shot jobs auto-delete after success by default; set `deleteAfterRun: false` to keep them. + +### Schedules + +Cron supports three schedule kinds: + +- `at`: one-shot timestamp via `schedule.at` (ISO 8601). +- `every`: fixed interval (ms). +- `cron`: 5-field cron expression (or 6-field with seconds) with optional IANA timezone. + +Cron expressions use `croner`. If a timezone is omitted, the Gateway host’s +local timezone is used. + +To reduce top-of-hour load spikes across many gateways, OpenClaw applies a +deterministic per-job stagger window of up to 5 minutes for recurring +top-of-hour expressions (for example `0 * * * *`, `0 */2 * * *`). Fixed-hour +expressions such as `0 7 * * *` remain exact. + +For any cron schedule, you can set an explicit stagger window with `schedule.staggerMs` +(`0` keeps exact timing). CLI shortcuts: + +- `--stagger 30s` (or `1m`, `5m`) to set an explicit stagger window. +- `--exact` to force `staggerMs = 0`. + +### Main vs isolated execution + +#### Main session jobs (system events) + +Main jobs enqueue a system event and optionally wake the heartbeat runner. +They must use `payload.kind = "systemEvent"`. + +- `wakeMode: "now"` (default): event triggers an immediate heartbeat run. +- `wakeMode: "next-heartbeat"`: event waits for the next scheduled heartbeat. + +This is the best fit when you want the normal heartbeat prompt + main-session context. +See [Heartbeat](/gateway/heartbeat). + +#### Isolated jobs (dedicated cron sessions) + +Isolated jobs run a dedicated agent turn in session `cron:`. + +Key behaviors: + +- Prompt is prefixed with `[cron: ]` for traceability. +- Each run starts a **fresh session id** (no prior conversation carry-over). +- Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`). +- `delivery.mode` chooses what happens: + - `announce`: deliver a summary to the target channel and post a brief summary to the main session. + - `webhook`: POST the finished event payload to `delivery.to` when the finished event includes a summary. + - `none`: internal only (no delivery, no main-session summary). +- `wakeMode` controls when the main-session summary posts: + - `now`: immediate heartbeat. + - `next-heartbeat`: waits for the next scheduled heartbeat. + +Use isolated jobs for noisy, frequent, or "background chores" that shouldn't spam +your main chat history. + +### Payload shapes (what runs) + +Two payload kinds are supported: + +- `systemEvent`: main-session only, routed through the heartbeat prompt. +- `agentTurn`: isolated-session only, runs a dedicated agent turn. + +Common `agentTurn` fields: + +- `message`: required text prompt. +- `model` / `thinking`: optional overrides (see below). +- `timeoutSeconds`: optional timeout override. + +Delivery config: + +- `delivery.mode`: `none` | `announce` | `webhook`. +- `delivery.channel`: `last` or a specific channel. +- `delivery.to`: channel-specific target (announce) or webhook URL (webhook mode). +- `delivery.bestEffort`: avoid failing the job if announce delivery fails. + +Announce delivery suppresses messaging tool sends for the run; use `delivery.channel`/`delivery.to` +to target the chat instead. When `delivery.mode = "none"`, no summary is posted to the main session. + +If `delivery` is omitted for isolated jobs, OpenClaw defaults to `announce`. + +#### Announce delivery flow + +When `delivery.mode = "announce"`, cron delivers directly via the outbound channel adapters. +The main agent is not spun up to craft or forward the message. + +Behavior details: + +- Content: delivery uses the isolated run's outbound payloads (text/media) with normal chunking and + channel formatting. +- Heartbeat-only responses (`HEARTBEAT_OK` with no real content) are not delivered. +- If the isolated run already sent a message to the same target via the message tool, delivery is + skipped to avoid duplicates. +- Missing or invalid delivery targets fail the job unless `delivery.bestEffort = true`. +- A short summary is posted to the main session only when `delivery.mode = "announce"`. +- The main-session summary respects `wakeMode`: `now` triggers an immediate heartbeat and + `next-heartbeat` waits for the next scheduled heartbeat. + +#### Webhook delivery flow + +When `delivery.mode = "webhook"`, cron posts the finished event payload to `delivery.to` when the finished event includes a summary. + +Behavior details: + +- The endpoint must be a valid HTTP(S) URL. +- No channel delivery is attempted in webhook mode. +- No main-session summary is posted in webhook mode. +- If `cron.webhookToken` is set, auth header is `Authorization: Bearer `. +- Deprecated fallback: stored legacy jobs with `notify: true` still post to `cron.webhook` (if configured), with a warning so you can migrate to `delivery.mode = "webhook"`. + +### Model and thinking overrides + +Isolated jobs (`agentTurn`) can override the model and thinking level: + +- `model`: Provider/model string (e.g., `anthropic/claude-sonnet-4-20250514`) or alias (e.g., `opus`) +- `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`; GPT-5.2 + Codex models only) + +Note: You can set `model` on main-session jobs too, but it changes the shared main +session model. We recommend model overrides only for isolated jobs to avoid +unexpected context shifts. + +Resolution priority: + +1. Job payload override (highest) +2. Hook-specific defaults (e.g., `hooks.gmail.model`) +3. Agent config default + +### Delivery (channel + target) + +Isolated jobs can deliver output to a channel via the top-level `delivery` config: + +- `delivery.mode`: `announce` (channel delivery), `webhook` (HTTP POST), or `none`. +- `delivery.channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`. +- `delivery.to`: channel-specific recipient target. + +`announce` delivery is only valid for isolated jobs (`sessionTarget: "isolated"`). +`webhook` delivery is valid for both main and isolated jobs. + +If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main session’s +“last route” (the last place the agent replied). + +Target format reminders: + +- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:`, `user:`) to avoid ambiguity. +- Telegram topics should use the `:topic:` form (see below). + +#### Telegram delivery targets (topics / forum threads) + +Telegram supports forum topics via `message_thread_id`. For cron delivery, you can encode +the topic/thread into the `to` field: + +- `-1001234567890` (chat id only) +- `-1001234567890:topic:123` (preferred: explicit topic marker) +- `-1001234567890:123` (shorthand: numeric suffix) + +Prefixed targets like `telegram:...` / `telegram:group:...` are also accepted: + +- `telegram:group:-1001234567890:topic:123` + +## JSON schema for tool calls + +Use these shapes when calling Gateway `cron.*` tools directly (agent tool calls or RPC). +CLI flags accept human durations like `20m`, but tool calls should use an ISO 8601 string +for `schedule.at` and milliseconds for `schedule.everyMs`. + +### cron.add params + +One-shot, main session job (system event): + +```json +{ + "name": "Reminder", + "schedule": { "kind": "at", "at": "2026-02-01T16:00:00Z" }, + "sessionTarget": "main", + "wakeMode": "now", + "payload": { "kind": "systemEvent", "text": "Reminder text" }, + "deleteAfterRun": true +} +``` + +Recurring, isolated job with delivery: + +```json +{ + "name": "Morning brief", + "schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "America/Los_Angeles" }, + "sessionTarget": "isolated", + "wakeMode": "next-heartbeat", + "payload": { + "kind": "agentTurn", + "message": "Summarize overnight updates." + }, + "delivery": { + "mode": "announce", + "channel": "slack", + "to": "channel:C1234567890", + "bestEffort": true + } +} +``` + +Notes: + +- `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`). +- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted). +- `everyMs` is milliseconds. +- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`. +- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`), + `delivery`. +- `wakeMode` defaults to `"now"` when omitted. + +### cron.update params + +```json +{ + "jobId": "job-123", + "patch": { + "enabled": false, + "schedule": { "kind": "every", "everyMs": 3600000 } + } +} +``` + +Notes: + +- `jobId` is canonical; `id` is accepted for compatibility. +- Use `agentId: null` in the patch to clear an agent binding. + +### cron.run and cron.remove params + +```json +{ "jobId": "job-123", "mode": "force" } +``` + +```json +{ "jobId": "job-123" } +``` + +## Storage & history + +- Job store: `~/.openclaw/cron/jobs.json` (Gateway-managed JSON). +- Run history: `~/.openclaw/cron/runs/.jsonl` (JSONL, auto-pruned by size and line count). +- Isolated cron run sessions in `sessions.json` are pruned by `cron.sessionRetention` (default `24h`; set `false` to disable). +- Override store path: `cron.store` in config. + +## Configuration + +```json5 +{ + cron: { + enabled: true, // default true + store: "~/.openclaw/cron/jobs.json", + maxConcurrentRuns: 1, // default 1 + webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs + webhookToken: "replace-with-dedicated-webhook-token", // optional bearer token for webhook mode + sessionRetention: "24h", // duration string or false + runLog: { + maxBytes: "2mb", // default 2_000_000 bytes + keepLines: 2000, // default 2000 + }, + }, +} +``` + +Run-log pruning behavior: + +- `cron.runLog.maxBytes`: max run-log file size before pruning. +- `cron.runLog.keepLines`: when pruning, keep only the newest N lines. +- Both apply to `cron/runs/.jsonl` files. + +Webhook behavior: + +- Preferred: set `delivery.mode: "webhook"` with `delivery.to: "https://..."` per job. +- Webhook URLs must be valid `http://` or `https://` URLs. +- When posted, payload is the cron finished event JSON. +- If `cron.webhookToken` is set, auth header is `Authorization: Bearer `. +- If `cron.webhookToken` is not set, no `Authorization` header is sent. +- Deprecated fallback: stored legacy jobs with `notify: true` still use `cron.webhook` when present. + +Disable cron entirely: + +- `cron.enabled: false` (config) +- `OPENCLAW_SKIP_CRON=1` (env) + +## Maintenance + +Cron has two built-in maintenance paths: isolated run-session retention and run-log pruning. + +### Defaults + +- `cron.sessionRetention`: `24h` (set `false` to disable run-session pruning) +- `cron.runLog.maxBytes`: `2_000_000` bytes +- `cron.runLog.keepLines`: `2000` + +### How it works + +- Isolated runs create session entries (`...:cron::run:`) and transcript files. +- The reaper removes expired run-session entries older than `cron.sessionRetention`. +- For removed run sessions no longer referenced by the session store, OpenClaw archives transcript files and purges old deleted archives on the same retention window. +- After each run append, `cron/runs/.jsonl` is size-checked: + - if file size exceeds `runLog.maxBytes`, it is trimmed to the newest `runLog.keepLines` lines. + +### Performance caveat for high volume schedulers + +High-frequency cron setups can generate large run-session and run-log footprints. Maintenance is built in, but loose limits can still create avoidable IO and cleanup work. + +What to watch: + +- long `cron.sessionRetention` windows with many isolated runs +- high `cron.runLog.keepLines` combined with large `runLog.maxBytes` +- many noisy recurring jobs writing to the same `cron/runs/.jsonl` + +What to do: + +- keep `cron.sessionRetention` as short as your debugging/audit needs allow +- keep run logs bounded with moderate `runLog.maxBytes` and `runLog.keepLines` +- move noisy background jobs to isolated mode with delivery rules that avoid unnecessary chatter +- review growth periodically with `openclaw cron runs` and adjust retention before logs become large + +### Customize examples + +Keep run sessions for a week and allow bigger run logs: + +```json5 +{ + cron: { + sessionRetention: "7d", + runLog: { + maxBytes: "10mb", + keepLines: 5000, + }, + }, +} +``` + +Disable isolated run-session pruning but keep run-log pruning: + +```json5 +{ + cron: { + sessionRetention: false, + runLog: { + maxBytes: "5mb", + keepLines: 3000, + }, + }, +} +``` + +Tune for high-volume cron usage (example): + +```json5 +{ + cron: { + sessionRetention: "12h", + runLog: { + maxBytes: "3mb", + keepLines: 1500, + }, + }, +} +``` + +## CLI quickstart + +One-shot reminder (UTC ISO, auto-delete after success): + +```bash +openclaw cron add \ + --name "Send reminder" \ + --at "2026-01-12T18:00:00Z" \ + --session main \ + --system-event "Reminder: submit expense report." \ + --wake now \ + --delete-after-run +``` + +One-shot reminder (main session, wake immediately): + +```bash +openclaw cron add \ + --name "Calendar check" \ + --at "20m" \ + --session main \ + --system-event "Next heartbeat: check calendar." \ + --wake now +``` + +Recurring isolated job (announce to WhatsApp): + +```bash +openclaw cron add \ + --name "Morning status" \ + --cron "0 7 * * *" \ + --tz "America/Los_Angeles" \ + --session isolated \ + --message "Summarize inbox + calendar for today." \ + --announce \ + --channel whatsapp \ + --to "+15551234567" +``` + +Recurring cron job with explicit 30-second stagger: + +```bash +openclaw cron add \ + --name "Minute watcher" \ + --cron "0 * * * * *" \ + --tz "UTC" \ + --stagger 30s \ + --session isolated \ + --message "Run minute watcher checks." \ + --announce +``` + +Recurring isolated job (deliver to a Telegram topic): + +```bash +openclaw cron add \ + --name "Nightly summary (topic)" \ + --cron "0 22 * * *" \ + --tz "America/Los_Angeles" \ + --session isolated \ + --message "Summarize today; send to the nightly topic." \ + --announce \ + --channel telegram \ + --to "-1001234567890:topic:123" +``` + +Isolated job with model and thinking override: + +```bash +openclaw cron add \ + --name "Deep analysis" \ + --cron "0 6 * * 1" \ + --tz "America/Los_Angeles" \ + --session isolated \ + --message "Weekly deep analysis of project progress." \ + --model "opus" \ + --thinking high \ + --announce \ + --channel whatsapp \ + --to "+15551234567" +``` + +Agent selection (multi-agent setups): + +```bash +# Pin a job to agent "ops" (falls back to default if that agent is missing) +openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops + +# Switch or clear the agent on an existing job +openclaw cron edit --agent ops +openclaw cron edit --clear-agent +``` + +Manual run (force is the default, use `--due` to only run when due): + +```bash +openclaw cron run +openclaw cron run --due +``` + +Edit an existing job (patch fields): + +```bash +openclaw cron edit \ + --message "Updated prompt" \ + --model "opus" \ + --thinking low +``` + +Force an existing cron job to run exactly on schedule (no stagger): + +```bash +openclaw cron edit --exact +``` + +Run history: + +```bash +openclaw cron runs --id --limit 50 +``` + +Immediate system event without creating a job: + +```bash +openclaw system event --mode now --text "Next heartbeat: check battery." +``` + +## Gateway API surface + +- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove` +- `cron.run` (force or due), `cron.runs` + For immediate system events without a job, use [`openclaw system event`](/cli/system). + +## Troubleshooting + +### “Nothing runs” + +- Check cron is enabled: `cron.enabled` and `OPENCLAW_SKIP_CRON`. +- Check the Gateway is running continuously (cron runs inside the Gateway process). +- For `cron` schedules: confirm timezone (`--tz`) vs the host timezone. + +### A recurring job keeps delaying after failures + +- OpenClaw applies exponential retry backoff for recurring jobs after consecutive errors: + 30s, 1m, 5m, 15m, then 60m between retries. +- Backoff resets automatically after the next successful run. +- One-shot (`at`) jobs disable after a terminal run (`ok`, `error`, or `skipped`) and do not retry. + +### Telegram delivers to the wrong place + +- For forum topics, use `-100…:topic:` so it’s explicit and unambiguous. +- If you see `telegram:...` prefixes in logs or stored “last route” targets, that’s normal; + cron delivery accepts them and still parses topic IDs correctly. + +### Subagent announce delivery retries + +- When a subagent run completes, the gateway announces the result to the requester session. +- If the announce flow returns `false` (e.g. requester session is busy), the gateway retries up to 3 times with tracking via `announceRetryCount`. +- Announces older than 5 minutes past `endedAt` are force-expired to prevent stale entries from looping indefinitely. +- If you see repeated announce deliveries in logs, check the subagent registry for entries with high `announceRetryCount` values. diff --git a/backend/app/one_person_security_dept/openclaw/docs/automation/cron-vs-heartbeat.md b/backend/app/one_person_security_dept/openclaw/docs/automation/cron-vs-heartbeat.md new file mode 100644 index 00000000..9676d960 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/automation/cron-vs-heartbeat.md @@ -0,0 +1,286 @@ +--- +summary: "Guidance for choosing between heartbeat and cron jobs for automation" +read_when: + - Deciding how to schedule recurring tasks + - Setting up background monitoring or notifications + - Optimizing token usage for periodic checks +title: "Cron vs Heartbeat" +--- + +# Cron vs Heartbeat: When to Use Each + +Both heartbeats and cron jobs let you run tasks on a schedule. This guide helps you choose the right mechanism for your use case. + +## Quick Decision Guide + +| Use Case | Recommended | Why | +| ------------------------------------ | ------------------- | ---------------------------------------- | +| Check inbox every 30 min | Heartbeat | Batches with other checks, context-aware | +| Send daily report at 9am sharp | Cron (isolated) | Exact timing needed | +| Monitor calendar for upcoming events | Heartbeat | Natural fit for periodic awareness | +| Run weekly deep analysis | Cron (isolated) | Standalone task, can use different model | +| Remind me in 20 minutes | Cron (main, `--at`) | One-shot with precise timing | +| Background project health check | Heartbeat | Piggybacks on existing cycle | + +## Heartbeat: Periodic Awareness + +Heartbeats run in the **main session** at a regular interval (default: 30 min). They're designed for the agent to check on things and surface anything important. + +### When to use heartbeat + +- **Multiple periodic checks**: Instead of 5 separate cron jobs checking inbox, calendar, weather, notifications, and project status, a single heartbeat can batch all of these. +- **Context-aware decisions**: The agent has full main-session context, so it can make smart decisions about what's urgent vs. what can wait. +- **Conversational continuity**: Heartbeat runs share the same session, so the agent remembers recent conversations and can follow up naturally. +- **Low-overhead monitoring**: One heartbeat replaces many small polling tasks. + +### Heartbeat advantages + +- **Batches multiple checks**: One agent turn can review inbox, calendar, and notifications together. +- **Reduces API calls**: A single heartbeat is cheaper than 5 isolated cron jobs. +- **Context-aware**: The agent knows what you've been working on and can prioritize accordingly. +- **Smart suppression**: If nothing needs attention, the agent replies `HEARTBEAT_OK` and no message is delivered. +- **Natural timing**: Drifts slightly based on queue load, which is fine for most monitoring. + +### Heartbeat example: HEARTBEAT.md checklist + +```md +# Heartbeat checklist + +- Check email for urgent messages +- Review calendar for events in next 2 hours +- If a background task finished, summarize results +- If idle for 8+ hours, send a brief check-in +``` + +The agent reads this on each heartbeat and handles all items in one turn. + +### Configuring heartbeat + +```json5 +{ + agents: { + defaults: { + heartbeat: { + every: "30m", // interval + target: "last", // explicit alert delivery target (default is "none") + activeHours: { start: "08:00", end: "22:00" }, // optional + }, + }, + }, +} +``` + +See [Heartbeat](/gateway/heartbeat) for full configuration. + +## Cron: Precise Scheduling + +Cron jobs run at precise times and can run in isolated sessions without affecting main context. +Recurring top-of-hour schedules are automatically spread by a deterministic +per-job offset in a 0-5 minute window. + +### When to use cron + +- **Exact timing required**: "Send this at 9:00 AM every Monday" (not "sometime around 9"). +- **Standalone tasks**: Tasks that don't need conversational context. +- **Different model/thinking**: Heavy analysis that warrants a more powerful model. +- **One-shot reminders**: "Remind me in 20 minutes" with `--at`. +- **Noisy/frequent tasks**: Tasks that would clutter main session history. +- **External triggers**: Tasks that should run independently of whether the agent is otherwise active. + +### Cron advantages + +- **Precise timing**: 5-field or 6-field (seconds) cron expressions with timezone support. +- **Built-in load spreading**: recurring top-of-hour schedules are staggered by up to 5 minutes by default. +- **Per-job control**: override stagger with `--stagger ` or force exact timing with `--exact`. +- **Session isolation**: Runs in `cron:` without polluting main history. +- **Model overrides**: Use a cheaper or more powerful model per job. +- **Delivery control**: Isolated jobs default to `announce` (summary); choose `none` as needed. +- **Immediate delivery**: Announce mode posts directly without waiting for heartbeat. +- **No agent context needed**: Runs even if main session is idle or compacted. +- **One-shot support**: `--at` for precise future timestamps. + +### Cron example: Daily morning briefing + +```bash +openclaw cron add \ + --name "Morning briefing" \ + --cron "0 7 * * *" \ + --tz "America/New_York" \ + --session isolated \ + --message "Generate today's briefing: weather, calendar, top emails, news summary." \ + --model opus \ + --announce \ + --channel whatsapp \ + --to "+15551234567" +``` + +This runs at exactly 7:00 AM New York time, uses Opus for quality, and announces a summary directly to WhatsApp. + +### Cron example: One-shot reminder + +```bash +openclaw cron add \ + --name "Meeting reminder" \ + --at "20m" \ + --session main \ + --system-event "Reminder: standup meeting starts in 10 minutes." \ + --wake now \ + --delete-after-run +``` + +See [Cron jobs](/automation/cron-jobs) for full CLI reference. + +## Decision Flowchart + +``` +Does the task need to run at an EXACT time? + YES -> Use cron + NO -> Continue... + +Does the task need isolation from main session? + YES -> Use cron (isolated) + NO -> Continue... + +Can this task be batched with other periodic checks? + YES -> Use heartbeat (add to HEARTBEAT.md) + NO -> Use cron + +Is this a one-shot reminder? + YES -> Use cron with --at + NO -> Continue... + +Does it need a different model or thinking level? + YES -> Use cron (isolated) with --model/--thinking + NO -> Use heartbeat +``` + +## Combining Both + +The most efficient setup uses **both**: + +1. **Heartbeat** handles routine monitoring (inbox, calendar, notifications) in one batched turn every 30 minutes. +2. **Cron** handles precise schedules (daily reports, weekly reviews) and one-shot reminders. + +### Example: Efficient automation setup + +**HEARTBEAT.md** (checked every 30 min): + +```md +# Heartbeat checklist + +- Scan inbox for urgent emails +- Check calendar for events in next 2h +- Review any pending tasks +- Light check-in if quiet for 8+ hours +``` + +**Cron jobs** (precise timing): + +```bash +# Daily morning briefing at 7am +openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --announce + +# Weekly project review on Mondays at 9am +openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus + +# One-shot reminder +openclaw cron add --name "Call back" --at "2h" --session main --system-event "Call back the client" --wake now +``` + +## Lobster: Deterministic workflows with approvals + +Lobster is the workflow runtime for **multi-step tool pipelines** that need deterministic execution and explicit approvals. +Use it when the task is more than a single agent turn, and you want a resumable workflow with human checkpoints. + +### When Lobster fits + +- **Multi-step automation**: You need a fixed pipeline of tool calls, not a one-off prompt. +- **Approval gates**: Side effects should pause until you approve, then resume. +- **Resumable runs**: Continue a paused workflow without re-running earlier steps. + +### How it pairs with heartbeat and cron + +- **Heartbeat/cron** decide _when_ a run happens. +- **Lobster** defines _what steps_ happen once the run starts. + +For scheduled workflows, use cron or heartbeat to trigger an agent turn that calls Lobster. +For ad-hoc workflows, call Lobster directly. + +### Operational notes (from the code) + +- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**. +- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag. +- The tool is an **optional plugin**; enable it additively via `tools.alsoAllow: ["lobster"]` (recommended). +- Lobster expects the `lobster` CLI to be available on `PATH`. + +See [Lobster](/tools/lobster) for full usage and examples. + +## Main Session vs Isolated Session + +Both heartbeat and cron can interact with the main session, but differently: + +| | Heartbeat | Cron (main) | Cron (isolated) | +| ------- | ------------------------------- | ------------------------ | -------------------------- | +| Session | Main | Main (via system event) | `cron:` | +| History | Shared | Shared | Fresh each run | +| Context | Full | Full | None (starts clean) | +| Model | Main session model | Main session model | Can override | +| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) | + +### When to use main session cron + +Use `--session main` with `--system-event` when you want: + +- The reminder/event to appear in main session context +- The agent to handle it during the next heartbeat with full context +- No separate isolated run + +```bash +openclaw cron add \ + --name "Check project" \ + --every "4h" \ + --session main \ + --system-event "Time for a project health check" \ + --wake now +``` + +### When to use isolated cron + +Use `--session isolated` when you want: + +- A clean slate without prior context +- Different model or thinking settings +- Announce summaries directly to a channel +- History that doesn't clutter main session + +```bash +openclaw cron add \ + --name "Deep analysis" \ + --cron "0 6 * * 0" \ + --session isolated \ + --message "Weekly codebase analysis..." \ + --model opus \ + --thinking high \ + --announce +``` + +## Cost Considerations + +| Mechanism | Cost Profile | +| --------------- | ------------------------------------------------------- | +| Heartbeat | One turn every N minutes; scales with HEARTBEAT.md size | +| Cron (main) | Adds event to next heartbeat (no isolated turn) | +| Cron (isolated) | Full agent turn per job; can use cheaper model | + +**Tips**: + +- Keep `HEARTBEAT.md` small to minimize token overhead. +- Batch similar checks into heartbeat instead of multiple cron jobs. +- Use `target: "none"` on heartbeat if you only want internal processing. +- Use isolated cron with a cheaper model for routine tasks. + +## Related + +- [Heartbeat](/gateway/heartbeat) - full heartbeat configuration +- [Cron jobs](/automation/cron-jobs) - full cron CLI and API reference +- [System](/cli/system) - system events + heartbeat controls diff --git a/backend/app/one_person_security_dept/openclaw/docs/automation/gmail-pubsub.md b/backend/app/one_person_security_dept/openclaw/docs/automation/gmail-pubsub.md new file mode 100644 index 00000000..b853b995 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/automation/gmail-pubsub.md @@ -0,0 +1,256 @@ +--- +summary: "Gmail Pub/Sub push wired into OpenClaw webhooks via gogcli" +read_when: + - Wiring Gmail inbox triggers to OpenClaw + - Setting up Pub/Sub push for agent wake +title: "Gmail PubSub" +--- + +# Gmail Pub/Sub -> OpenClaw + +Goal: Gmail watch -> Pub/Sub push -> `gog gmail watch serve` -> OpenClaw webhook. + +## Prereqs + +- `gcloud` installed and logged in ([install guide](https://docs.cloud.google.com/sdk/docs/install-sdk)). +- `gog` (gogcli) installed and authorized for the Gmail account ([gogcli.sh](https://gogcli.sh/)). +- OpenClaw hooks enabled (see [Webhooks](/automation/webhook)). +- `tailscale` logged in ([tailscale.com](https://tailscale.com/)). Supported setup uses Tailscale Funnel for the public HTTPS endpoint. + Other tunnel services can work, but are DIY/unsupported and require manual wiring. + Right now, Tailscale is what we support. + +Example hook config (enable Gmail preset mapping): + +```json5 +{ + hooks: { + enabled: true, + token: "OPENCLAW_HOOK_TOKEN", + path: "/hooks", + presets: ["gmail"], + }, +} +``` + +To deliver the Gmail summary to a chat surface, override the preset with a mapping +that sets `deliver` + optional `channel`/`to`: + +```json5 +{ + hooks: { + enabled: true, + token: "OPENCLAW_HOOK_TOKEN", + presets: ["gmail"], + mappings: [ + { + match: { path: "gmail" }, + action: "agent", + wakeMode: "now", + name: "Gmail", + sessionKey: "hook:gmail:{{messages[0].id}}", + messageTemplate: "New email from {{messages[0].from}}\nSubject: {{messages[0].subject}}\n{{messages[0].snippet}}\n{{messages[0].body}}", + model: "openai/gpt-5.2-mini", + deliver: true, + channel: "last", + // to: "+15551234567" + }, + ], + }, +} +``` + +If you want a fixed channel, set `channel` + `to`. Otherwise `channel: "last"` +uses the last delivery route (falls back to WhatsApp). + +To force a cheaper model for Gmail runs, set `model` in the mapping +(`provider/model` or alias). If you enforce `agents.defaults.models`, include it there. + +To set a default model and thinking level specifically for Gmail hooks, add +`hooks.gmail.model` / `hooks.gmail.thinking` in your config: + +```json5 +{ + hooks: { + gmail: { + model: "openrouter/meta-llama/llama-3.3-70b-instruct:free", + thinking: "off", + }, + }, +} +``` + +Notes: + +- Per-hook `model`/`thinking` in the mapping still overrides these defaults. +- Fallback order: `hooks.gmail.model` → `agents.defaults.model.fallbacks` → primary (auth/rate-limit/timeouts). +- If `agents.defaults.models` is set, the Gmail model must be in the allowlist. +- Gmail hook content is wrapped with external-content safety boundaries by default. + To disable (dangerous), set `hooks.gmail.allowUnsafeExternalContent: true`. + +To customize payload handling further, add `hooks.mappings` or a JS/TS transform module +under `~/.openclaw/hooks/transforms` (see [Webhooks](/automation/webhook)). + +## Wizard (recommended) + +Use the OpenClaw helper to wire everything together (installs deps on macOS via brew): + +```bash +openclaw webhooks gmail setup \ + --account openclaw@gmail.com +``` + +Defaults: + +- Uses Tailscale Funnel for the public push endpoint. +- Writes `hooks.gmail` config for `openclaw webhooks gmail run`. +- Enables the Gmail hook preset (`hooks.presets: ["gmail"]`). + +Path note: when `tailscale.mode` is enabled, OpenClaw automatically sets +`hooks.gmail.serve.path` to `/` and keeps the public path at +`hooks.gmail.tailscale.path` (default `/gmail-pubsub`) because Tailscale +strips the set-path prefix before proxying. +If you need the backend to receive the prefixed path, set +`hooks.gmail.tailscale.target` (or `--tailscale-target`) to a full URL like +`http://127.0.0.1:8788/gmail-pubsub` and match `hooks.gmail.serve.path`. + +Want a custom endpoint? Use `--push-endpoint ` or `--tailscale off`. + +Platform note: on macOS the wizard installs `gcloud`, `gogcli`, and `tailscale` +via Homebrew; on Linux install them manually first. + +Gateway auto-start (recommended): + +- When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts + `gog gmail watch serve` on boot and auto-renews the watch. +- Set `OPENCLAW_SKIP_GMAIL_WATCHER=1` to opt out (useful if you run the daemon yourself). +- Do not run the manual daemon at the same time, or you will hit + `listen tcp 127.0.0.1:8788: bind: address already in use`. + +Manual daemon (starts `gog gmail watch serve` + auto-renew): + +```bash +openclaw webhooks gmail run +``` + +## One-time setup + +1. Select the GCP project **that owns the OAuth client** used by `gog`. + +```bash +gcloud auth login +gcloud config set project +``` + +Note: Gmail watch requires the Pub/Sub topic to live in the same project as the OAuth client. + +2. Enable APIs: + +```bash +gcloud services enable gmail.googleapis.com pubsub.googleapis.com +``` + +3. Create a topic: + +```bash +gcloud pubsub topics create gog-gmail-watch +``` + +4. Allow Gmail push to publish: + +```bash +gcloud pubsub topics add-iam-policy-binding gog-gmail-watch \ + --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \ + --role=roles/pubsub.publisher +``` + +## Start the watch + +```bash +gog gmail watch start \ + --account openclaw@gmail.com \ + --label INBOX \ + --topic projects//topics/gog-gmail-watch +``` + +Save the `history_id` from the output (for debugging). + +## Run the push handler + +Local example (shared token auth): + +```bash +gog gmail watch serve \ + --account openclaw@gmail.com \ + --bind 127.0.0.1 \ + --port 8788 \ + --path /gmail-pubsub \ + --token \ + --hook-url http://127.0.0.1:18789/hooks/gmail \ + --hook-token OPENCLAW_HOOK_TOKEN \ + --include-body \ + --max-bytes 20000 +``` + +Notes: + +- `--token` protects the push endpoint (`x-gog-token` or `?token=`). +- `--hook-url` points to OpenClaw `/hooks/gmail` (mapped; isolated run + summary to main). +- `--include-body` and `--max-bytes` control the body snippet sent to OpenClaw. + +Recommended: `openclaw webhooks gmail run` wraps the same flow and auto-renews the watch. + +## Expose the handler (advanced, unsupported) + +If you need a non-Tailscale tunnel, wire it manually and use the public URL in the push +subscription (unsupported, no guardrails): + +```bash +cloudflared tunnel --url http://127.0.0.1:8788 --no-autoupdate +``` + +Use the generated URL as the push endpoint: + +```bash +gcloud pubsub subscriptions create gog-gmail-watch-push \ + --topic gog-gmail-watch \ + --push-endpoint "https:///gmail-pubsub?token=" +``` + +Production: use a stable HTTPS endpoint and configure Pub/Sub OIDC JWT, then run: + +```bash +gog gmail watch serve --verify-oidc --oidc-email +``` + +## Test + +Send a message to the watched inbox: + +```bash +gog gmail send \ + --account openclaw@gmail.com \ + --to openclaw@gmail.com \ + --subject "watch test" \ + --body "ping" +``` + +Check watch state and history: + +```bash +gog gmail watch status --account openclaw@gmail.com +gog gmail history --account openclaw@gmail.com --since +``` + +## Troubleshooting + +- `Invalid topicName`: project mismatch (topic not in the OAuth client project). +- `User not authorized`: missing `roles/pubsub.publisher` on the topic. +- Empty messages: Gmail push only provides `historyId`; fetch via `gog gmail history`. + +## Cleanup + +```bash +gog gmail watch stop --account openclaw@gmail.com +gcloud pubsub subscriptions delete gog-gmail-watch-push +gcloud pubsub topics delete gog-gmail-watch +``` diff --git a/backend/app/one_person_security_dept/openclaw/docs/automation/hooks.md b/backend/app/one_person_security_dept/openclaw/docs/automation/hooks.md new file mode 100644 index 00000000..0f561741 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/automation/hooks.md @@ -0,0 +1,1003 @@ +--- +summary: "Hooks: event-driven automation for commands and lifecycle events" +read_when: + - You want event-driven automation for /new, /reset, /stop, and agent lifecycle events + - You want to build, install, or debug hooks +title: "Hooks" +--- + +# Hooks + +Hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in OpenClaw. + +## Getting Oriented + +Hooks are small scripts that run when something happens. There are two kinds: + +- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events. +- **Webhooks**: external HTTP webhooks that let other systems trigger work in OpenClaw. See [Webhook Hooks](/automation/webhook) or use `openclaw webhooks` for Gmail helper commands. + +Hooks can also be bundled inside plugins; see [Plugins](/tools/plugin#plugin-hooks). + +Common uses: + +- Save a memory snapshot when you reset a session +- Keep an audit trail of commands for troubleshooting or compliance +- Trigger follow-up automation when a session starts or ends +- Write files into the agent workspace or call external APIs when events fire + +If you can write a small TypeScript function, you can write a hook. Hooks are discovered automatically, and you enable or disable them via the CLI. + +## Overview + +The hooks system allows you to: + +- Save session context to memory when `/new` is issued +- Log all commands for auditing +- Trigger custom automations on agent lifecycle events +- Extend OpenClaw's behavior without modifying core code + +## Getting Started + +### Bundled Hooks + +OpenClaw ships with four bundled hooks that are automatically discovered: + +- **💾 session-memory**: Saves session context to your agent workspace (default `~/.openclaw/workspace/memory/`) when you issue `/new` +- **📎 bootstrap-extra-files**: Injects additional workspace bootstrap files from configured glob/path patterns during `agent:bootstrap` +- **📝 command-logger**: Logs all command events to `~/.openclaw/logs/commands.log` +- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled) + +List available hooks: + +```bash +openclaw hooks list +``` + +Enable a hook: + +```bash +openclaw hooks enable session-memory +``` + +Check hook status: + +```bash +openclaw hooks check +``` + +Get detailed information: + +```bash +openclaw hooks info session-memory +``` + +### Onboarding + +During onboarding (`openclaw onboard`), you'll be prompted to enable recommended hooks. The wizard automatically discovers eligible hooks and presents them for selection. + +## Hook Discovery + +Hooks are automatically discovered from three directories (in order of precedence): + +1. **Workspace hooks**: `/hooks/` (per-agent, highest precedence) +2. **Managed hooks**: `~/.openclaw/hooks/` (user-installed, shared across workspaces) +3. **Bundled hooks**: `/dist/hooks/bundled/` (shipped with OpenClaw) + +Managed hook directories can be either a **single hook** or a **hook pack** (package directory). + +Each hook is a directory containing: + +``` +my-hook/ +├── HOOK.md # Metadata + documentation +└── handler.ts # Handler implementation +``` + +## Hook Packs (npm/archives) + +Hook packs are standard npm packages that export one or more hooks via `openclaw.hooks` in +`package.json`. Install them with: + +```bash +openclaw hooks install +``` + +Npm specs are registry-only (package name + optional version/tag). Git/URL/file specs are rejected. + +Example `package.json`: + +```json +{ + "name": "@acme/my-hooks", + "version": "0.1.0", + "openclaw": { + "hooks": ["./hooks/my-hook", "./hooks/other-hook"] + } +} +``` + +Each entry points to a hook directory containing `HOOK.md` and `handler.ts` (or `index.ts`). +Hook packs can ship dependencies; they will be installed under `~/.openclaw/hooks/`. +Each `openclaw.hooks` entry must stay inside the package directory after symlink +resolution; entries that escape are rejected. + +Security note: `openclaw hooks install` installs dependencies with `npm install --ignore-scripts` +(no lifecycle scripts). Keep hook pack dependency trees "pure JS/TS" and avoid packages that rely +on `postinstall` builds. + +## Hook Structure + +### HOOK.md Format + +The `HOOK.md` file contains metadata in YAML frontmatter plus Markdown documentation: + +```markdown +--- +name: my-hook +description: "Short description of what this hook does" +homepage: https://docs.openclaw.ai/automation/hooks#my-hook +metadata: + { "openclaw": { "emoji": "🔗", "events": ["command:new"], "requires": { "bins": ["node"] } } } +--- + +# My Hook + +Detailed documentation goes here... + +## What It Does + +- Listens for `/new` commands +- Performs some action +- Logs the result + +## Requirements + +- Node.js must be installed + +## Configuration + +No configuration needed. +``` + +### Metadata Fields + +The `metadata.openclaw` object supports: + +- **`emoji`**: Display emoji for CLI (e.g., `"💾"`) +- **`events`**: Array of events to listen for (e.g., `["command:new", "command:reset"]`) +- **`export`**: Named export to use (defaults to `"default"`) +- **`homepage`**: Documentation URL +- **`requires`**: Optional requirements + - **`bins`**: Required binaries on PATH (e.g., `["git", "node"]`) + - **`anyBins`**: At least one of these binaries must be present + - **`env`**: Required environment variables + - **`config`**: Required config paths (e.g., `["workspace.dir"]`) + - **`os`**: Required platforms (e.g., `["darwin", "linux"]`) +- **`always`**: Bypass eligibility checks (boolean) +- **`install`**: Installation methods (for bundled hooks: `[{"id":"bundled","kind":"bundled"}]`) + +### Handler Implementation + +The `handler.ts` file exports a `HookHandler` function: + +```typescript +const myHandler = async (event) => { + // Only trigger on 'new' command + if (event.type !== "command" || event.action !== "new") { + return; + } + + console.log(`[my-hook] New command triggered`); + console.log(` Session: ${event.sessionKey}`); + console.log(` Timestamp: ${event.timestamp.toISOString()}`); + + // Your custom logic here + + // Optionally send message to user + event.messages.push("✨ My hook executed!"); +}; + +export default myHandler; +``` + +#### Event Context + +Each event includes: + +```typescript +{ + type: 'command' | 'session' | 'agent' | 'gateway' | 'message', + action: string, // e.g., 'new', 'reset', 'stop', 'received', 'sent' + sessionKey: string, // Session identifier + timestamp: Date, // When the event occurred + messages: string[], // Push messages here to send to user + context: { + // Command events: + sessionEntry?: SessionEntry, + sessionId?: string, + sessionFile?: string, + commandSource?: string, // e.g., 'whatsapp', 'telegram' + senderId?: string, + workspaceDir?: string, + bootstrapFiles?: WorkspaceBootstrapFile[], + cfg?: OpenClawConfig, + // Message events (see Message Events section for full details): + from?: string, // message:received + to?: string, // message:sent + content?: string, + channelId?: string, + success?: boolean, // message:sent + } +} +``` + +## Event Types + +### Command Events + +Triggered when agent commands are issued: + +- **`command`**: All command events (general listener) +- **`command:new`**: When `/new` command is issued +- **`command:reset`**: When `/reset` command is issued +- **`command:stop`**: When `/stop` command is issued + +### Agent Events + +- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`) + +### Gateway Events + +Triggered when the gateway starts: + +- **`gateway:startup`**: After channels start and hooks are loaded + +### Message Events + +Triggered when messages are received or sent: + +- **`message`**: All message events (general listener) +- **`message:received`**: When an inbound message is received from any channel +- **`message:sent`**: When an outbound message is successfully sent + +#### Message Event Context + +Message events include rich context about the message: + +```typescript +// message:received context +{ + from: string, // Sender identifier (phone number, user ID, etc.) + content: string, // Message content + timestamp?: number, // Unix timestamp when received + channelId: string, // Channel (e.g., "whatsapp", "telegram", "discord") + accountId?: string, // Provider account ID for multi-account setups + conversationId?: string, // Chat/conversation ID + messageId?: string, // Message ID from the provider + metadata?: { // Additional provider-specific data + to?: string, + provider?: string, + surface?: string, + threadId?: string, + senderId?: string, + senderName?: string, + senderUsername?: string, + senderE164?: string, + } +} + +// message:sent context +{ + to: string, // Recipient identifier + content: string, // Message content that was sent + success: boolean, // Whether the send succeeded + error?: string, // Error message if sending failed + channelId: string, // Channel (e.g., "whatsapp", "telegram", "discord") + accountId?: string, // Provider account ID + conversationId?: string, // Chat/conversation ID + messageId?: string, // Message ID returned by the provider +} +``` + +#### Example: Message Logger Hook + +```typescript +const isMessageReceivedEvent = (event: { type: string; action: string }) => + event.type === "message" && event.action === "received"; +const isMessageSentEvent = (event: { type: string; action: string }) => + event.type === "message" && event.action === "sent"; + +const handler = async (event) => { + if (isMessageReceivedEvent(event as { type: string; action: string })) { + console.log(`[message-logger] Received from ${event.context.from}: ${event.context.content}`); + } else if (isMessageSentEvent(event as { type: string; action: string })) { + console.log(`[message-logger] Sent to ${event.context.to}: ${event.context.content}`); + } +}; + +export default handler; +``` + +### Tool Result Hooks (Plugin API) + +These hooks are not event-stream listeners; they let plugins synchronously adjust tool results before OpenClaw persists them. + +- **`tool_result_persist`**: transform tool results before they are written to the session transcript. Must be synchronous; return the updated tool result payload or `undefined` to keep it as-is. See [Agent Loop](/concepts/agent-loop). + +### Future Events + +Planned event types: + +- **`session:start`**: When a new session begins +- **`session:end`**: When a session ends +- **`agent:error`**: When an agent encounters an error + +## Creating Custom Hooks + +### 1. Choose Location + +- **Workspace hooks** (`/hooks/`): Per-agent, highest precedence +- **Managed hooks** (`~/.openclaw/hooks/`): Shared across workspaces + +### 2. Create Directory Structure + +```bash +mkdir -p ~/.openclaw/hooks/my-hook +cd ~/.openclaw/hooks/my-hook +``` + +### 3. Create HOOK.md + +```markdown +--- +name: my-hook +description: "Does something useful" +metadata: { "openclaw": { "emoji": "🎯", "events": ["command:new"] } } +--- + +# My Custom Hook + +This hook does something useful when you issue `/new`. +``` + +### 4. Create handler.ts + +```typescript +const handler = async (event) => { + if (event.type !== "command" || event.action !== "new") { + return; + } + + console.log("[my-hook] Running!"); + // Your logic here +}; + +export default handler; +``` + +### 5. Enable and Test + +```bash +# Verify hook is discovered +openclaw hooks list + +# Enable it +openclaw hooks enable my-hook + +# Restart your gateway process (menu bar app restart on macOS, or restart your dev process) + +# Trigger the event +# Send /new via your messaging channel +``` + +## Configuration + +### New Config Format (Recommended) + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "entries": { + "session-memory": { "enabled": true }, + "command-logger": { "enabled": false } + } + } + } +} +``` + +### Per-Hook Configuration + +Hooks can have custom configuration: + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "entries": { + "my-hook": { + "enabled": true, + "env": { + "MY_CUSTOM_VAR": "value" + } + } + } + } + } +} +``` + +### Extra Directories + +Load hooks from additional directories: + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "load": { + "extraDirs": ["/path/to/more/hooks"] + } + } + } +} +``` + +### Legacy Config Format (Still Supported) + +The old config format still works for backwards compatibility: + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "handlers": [ + { + "event": "command:new", + "module": "./hooks/handlers/my-handler.ts", + "export": "default" + } + ] + } + } +} +``` + +Note: `module` must be a workspace-relative path. Absolute paths and traversal outside the workspace are rejected. + +**Migration**: Use the new discovery-based system for new hooks. Legacy handlers are loaded after directory-based hooks. + +## CLI Commands + +### List Hooks + +```bash +# List all hooks +openclaw hooks list + +# Show only eligible hooks +openclaw hooks list --eligible + +# Verbose output (show missing requirements) +openclaw hooks list --verbose + +# JSON output +openclaw hooks list --json +``` + +### Hook Information + +```bash +# Show detailed info about a hook +openclaw hooks info session-memory + +# JSON output +openclaw hooks info session-memory --json +``` + +### Check Eligibility + +```bash +# Show eligibility summary +openclaw hooks check + +# JSON output +openclaw hooks check --json +``` + +### Enable/Disable + +```bash +# Enable a hook +openclaw hooks enable session-memory + +# Disable a hook +openclaw hooks disable command-logger +``` + +## Bundled hook reference + +### session-memory + +Saves session context to memory when you issue `/new`. + +**Events**: `command:new` + +**Requirements**: `workspace.dir` must be configured + +**Output**: `/memory/YYYY-MM-DD-slug.md` (defaults to `~/.openclaw/workspace`) + +**What it does**: + +1. Uses the pre-reset session entry to locate the correct transcript +2. Extracts the last 15 lines of conversation +3. Uses LLM to generate a descriptive filename slug +4. Saves session metadata to a dated memory file + +**Example output**: + +```markdown +# Session: 2026-01-16 14:30:00 UTC + +- **Session Key**: agent:main:main +- **Session ID**: abc123def456 +- **Source**: telegram +``` + +**Filename examples**: + +- `2026-01-16-vendor-pitch.md` +- `2026-01-16-api-design.md` +- `2026-01-16-1430.md` (fallback timestamp if slug generation fails) + +**Enable**: + +```bash +openclaw hooks enable session-memory +``` + +### bootstrap-extra-files + +Injects additional bootstrap files (for example monorepo-local `AGENTS.md` / `TOOLS.md`) during `agent:bootstrap`. + +**Events**: `agent:bootstrap` + +**Requirements**: `workspace.dir` must be configured + +**Output**: No files written; bootstrap context is modified in-memory only. + +**Config**: + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "entries": { + "bootstrap-extra-files": { + "enabled": true, + "paths": ["packages/*/AGENTS.md", "packages/*/TOOLS.md"] + } + } + } + } +} +``` + +**Notes**: + +- Paths are resolved relative to workspace. +- Files must stay inside workspace (realpath-checked). +- Only recognized bootstrap basenames are loaded. +- Subagent allowlist is preserved (`AGENTS.md` and `TOOLS.md` only). + +**Enable**: + +```bash +openclaw hooks enable bootstrap-extra-files +``` + +### command-logger + +Logs all command events to a centralized audit file. + +**Events**: `command` + +**Requirements**: None + +**Output**: `~/.openclaw/logs/commands.log` + +**What it does**: + +1. Captures event details (command action, timestamp, session key, sender ID, source) +2. Appends to log file in JSONL format +3. Runs silently in the background + +**Example log entries**: + +```jsonl +{"timestamp":"2026-01-16T14:30:00.000Z","action":"new","sessionKey":"agent:main:main","senderId":"+1234567890","source":"telegram"} +{"timestamp":"2026-01-16T15:45:22.000Z","action":"stop","sessionKey":"agent:main:main","senderId":"user@example.com","source":"whatsapp"} +``` + +**View logs**: + +```bash +# View recent commands +tail -n 20 ~/.openclaw/logs/commands.log + +# Pretty-print with jq +cat ~/.openclaw/logs/commands.log | jq . + +# Filter by action +grep '"action":"new"' ~/.openclaw/logs/commands.log | jq . +``` + +**Enable**: + +```bash +openclaw hooks enable command-logger +``` + +### boot-md + +Runs `BOOT.md` when the gateway starts (after channels start). +Internal hooks must be enabled for this to run. + +**Events**: `gateway:startup` + +**Requirements**: `workspace.dir` must be configured + +**What it does**: + +1. Reads `BOOT.md` from your workspace +2. Runs the instructions via the agent runner +3. Sends any requested outbound messages via the message tool + +**Enable**: + +```bash +openclaw hooks enable boot-md +``` + +## Best Practices + +### Keep Handlers Fast + +Hooks run during command processing. Keep them lightweight: + +```typescript +// ✓ Good - async work, returns immediately +const handler: HookHandler = async (event) => { + void processInBackground(event); // Fire and forget +}; + +// ✗ Bad - blocks command processing +const handler: HookHandler = async (event) => { + await slowDatabaseQuery(event); + await evenSlowerAPICall(event); +}; +``` + +### Handle Errors Gracefully + +Always wrap risky operations: + +```typescript +const handler: HookHandler = async (event) => { + try { + await riskyOperation(event); + } catch (err) { + console.error("[my-handler] Failed:", err instanceof Error ? err.message : String(err)); + // Don't throw - let other handlers run + } +}; +``` + +### Filter Events Early + +Return early if the event isn't relevant: + +```typescript +const handler: HookHandler = async (event) => { + // Only handle 'new' commands + if (event.type !== "command" || event.action !== "new") { + return; + } + + // Your logic here +}; +``` + +### Use Specific Event Keys + +Specify exact events in metadata when possible: + +```yaml +metadata: { "openclaw": { "events": ["command:new"] } } # Specific +``` + +Rather than: + +```yaml +metadata: { "openclaw": { "events": ["command"] } } # General - more overhead +``` + +## Debugging + +### Enable Hook Logging + +The gateway logs hook loading at startup: + +``` +Registered hook: session-memory -> command:new +Registered hook: bootstrap-extra-files -> agent:bootstrap +Registered hook: command-logger -> command +Registered hook: boot-md -> gateway:startup +``` + +### Check Discovery + +List all discovered hooks: + +```bash +openclaw hooks list --verbose +``` + +### Check Registration + +In your handler, log when it's called: + +```typescript +const handler: HookHandler = async (event) => { + console.log("[my-handler] Triggered:", event.type, event.action); + // Your logic +}; +``` + +### Verify Eligibility + +Check why a hook isn't eligible: + +```bash +openclaw hooks info my-hook +``` + +Look for missing requirements in the output. + +## Testing + +### Gateway Logs + +Monitor gateway logs to see hook execution: + +```bash +# macOS +./scripts/clawlog.sh -f + +# Other platforms +tail -f ~/.openclaw/gateway.log +``` + +### Test Hooks Directly + +Test your handlers in isolation: + +```typescript +import { test } from "vitest"; +import myHandler from "./hooks/my-hook/handler.js"; + +test("my handler works", async () => { + const event = { + type: "command", + action: "new", + sessionKey: "test-session", + timestamp: new Date(), + messages: [], + context: { foo: "bar" }, + }; + + await myHandler(event); + + // Assert side effects +}); +``` + +## Architecture + +### Core Components + +- **`src/hooks/types.ts`**: Type definitions +- **`src/hooks/workspace.ts`**: Directory scanning and loading +- **`src/hooks/frontmatter.ts`**: HOOK.md metadata parsing +- **`src/hooks/config.ts`**: Eligibility checking +- **`src/hooks/hooks-status.ts`**: Status reporting +- **`src/hooks/loader.ts`**: Dynamic module loader +- **`src/cli/hooks-cli.ts`**: CLI commands +- **`src/gateway/server-startup.ts`**: Loads hooks at gateway start +- **`src/auto-reply/reply/commands-core.ts`**: Triggers command events + +### Discovery Flow + +``` +Gateway startup + ↓ +Scan directories (workspace → managed → bundled) + ↓ +Parse HOOK.md files + ↓ +Check eligibility (bins, env, config, os) + ↓ +Load handlers from eligible hooks + ↓ +Register handlers for events +``` + +### Event Flow + +``` +User sends /new + ↓ +Command validation + ↓ +Create hook event + ↓ +Trigger hook (all registered handlers) + ↓ +Command processing continues + ↓ +Session reset +``` + +## Troubleshooting + +### Hook Not Discovered + +1. Check directory structure: + + ```bash + ls -la ~/.openclaw/hooks/my-hook/ + # Should show: HOOK.md, handler.ts + ``` + +2. Verify HOOK.md format: + + ```bash + cat ~/.openclaw/hooks/my-hook/HOOK.md + # Should have YAML frontmatter with name and metadata + ``` + +3. List all discovered hooks: + + ```bash + openclaw hooks list + ``` + +### Hook Not Eligible + +Check requirements: + +```bash +openclaw hooks info my-hook +``` + +Look for missing: + +- Binaries (check PATH) +- Environment variables +- Config values +- OS compatibility + +### Hook Not Executing + +1. Verify hook is enabled: + + ```bash + openclaw hooks list + # Should show ✓ next to enabled hooks + ``` + +2. Restart your gateway process so hooks reload. + +3. Check gateway logs for errors: + + ```bash + ./scripts/clawlog.sh | grep hook + ``` + +### Handler Errors + +Check for TypeScript/import errors: + +```bash +# Test import directly +node -e "import('./path/to/handler.ts').then(console.log)" +``` + +## Migration Guide + +### From Legacy Config to Discovery + +**Before**: + +```json +{ + "hooks": { + "internal": { + "enabled": true, + "handlers": [ + { + "event": "command:new", + "module": "./hooks/handlers/my-handler.ts" + } + ] + } + } +} +``` + +**After**: + +1. Create hook directory: + + ```bash + mkdir -p ~/.openclaw/hooks/my-hook + mv ./hooks/handlers/my-handler.ts ~/.openclaw/hooks/my-hook/handler.ts + ``` + +2. Create HOOK.md: + + ```markdown + --- + name: my-hook + description: "My custom hook" + metadata: { "openclaw": { "emoji": "🎯", "events": ["command:new"] } } + --- + + # My Hook + + Does something useful. + ``` + +3. Update config: + + ```json + { + "hooks": { + "internal": { + "enabled": true, + "entries": { + "my-hook": { "enabled": true } + } + } + } + } + ``` + +4. Verify and restart your gateway process: + + ```bash + openclaw hooks list + # Should show: 🎯 my-hook ✓ + ``` + +**Benefits of migration**: + +- Automatic discovery +- CLI management +- Eligibility checking +- Better documentation +- Consistent structure + +## See Also + +- [CLI Reference: hooks](/cli/hooks) +- [Bundled Hooks README](https://github.com/openclaw/openclaw/tree/main/src/hooks/bundled) +- [Webhook Hooks](/automation/webhook) +- [Configuration](/gateway/configuration#hooks) diff --git a/backend/app/one_person_security_dept/openclaw/docs/automation/poll.md b/backend/app/one_person_security_dept/openclaw/docs/automation/poll.md new file mode 100644 index 00000000..fab0b0e0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/automation/poll.md @@ -0,0 +1,69 @@ +--- +summary: "Poll sending via gateway + CLI" +read_when: + - Adding or modifying poll support + - Debugging poll sends from the CLI or gateway +title: "Polls" +--- + +# Polls + +## Supported channels + +- WhatsApp (web channel) +- Discord +- MS Teams (Adaptive Cards) + +## CLI + +```bash +# WhatsApp +openclaw message poll --target +15555550123 \ + --poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe" +openclaw message poll --target 123456789@g.us \ + --poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi + +# Discord +openclaw message poll --channel discord --target channel:123456789 \ + --poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi" +openclaw message poll --channel discord --target channel:123456789 \ + --poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48 + +# MS Teams +openclaw message poll --channel msteams --target conversation:19:abc@thread.tacv2 \ + --poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi" +``` + +Options: + +- `--channel`: `whatsapp` (default), `discord`, or `msteams` +- `--poll-multi`: allow selecting multiple options +- `--poll-duration-hours`: Discord-only (defaults to 24 when omitted) + +## Gateway RPC + +Method: `poll` + +Params: + +- `to` (string, required) +- `question` (string, required) +- `options` (string[], required) +- `maxSelections` (number, optional) +- `durationHours` (number, optional) +- `channel` (string, optional, default: `whatsapp`) +- `idempotencyKey` (string, required) + +## Channel differences + +- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`. +- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count. +- MS Teams: Adaptive Card polls (OpenClaw-managed). No native poll API; `durationHours` is ignored. + +## Agent tool (Message) + +Use the `message` tool with `poll` action (`to`, `pollQuestion`, `pollOption`, optional `pollMulti`, `pollDurationHours`, `channel`). + +Note: Discord has no “pick exactly N” mode; `pollMulti` maps to multi-select. +Teams polls are rendered as Adaptive Cards and require the gateway to stay online +to record votes in `~/.openclaw/msteams-polls.json`. diff --git a/backend/app/one_person_security_dept/openclaw/docs/automation/troubleshooting.md b/backend/app/one_person_security_dept/openclaw/docs/automation/troubleshooting.md new file mode 100644 index 00000000..9190855d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/automation/troubleshooting.md @@ -0,0 +1,122 @@ +--- +summary: "Troubleshoot cron and heartbeat scheduling and delivery" +read_when: + - Cron did not run + - Cron ran but no message was delivered + - Heartbeat seems silent or skipped +title: "Automation Troubleshooting" +--- + +# Automation troubleshooting + +Use this page for scheduler and delivery issues (`cron` + `heartbeat`). + +## Command ladder + +```bash +openclaw status +openclaw gateway status +openclaw logs --follow +openclaw doctor +openclaw channels status --probe +``` + +Then run automation checks: + +```bash +openclaw cron status +openclaw cron list +openclaw system heartbeat last +``` + +## Cron not firing + +```bash +openclaw cron status +openclaw cron list +openclaw cron runs --id --limit 20 +openclaw logs --follow +``` + +Good output looks like: + +- `cron status` reports enabled and a future `nextWakeAtMs`. +- Job is enabled and has a valid schedule/timezone. +- `cron runs` shows `ok` or explicit skip reason. + +Common signatures: + +- `cron: scheduler disabled; jobs will not run automatically` → cron disabled in config/env. +- `cron: timer tick failed` → scheduler tick crashed; inspect surrounding stack/log context. +- `reason: not-due` in run output → manual run called without `--force` and job not due yet. + +## Cron fired but no delivery + +```bash +openclaw cron runs --id --limit 20 +openclaw cron list +openclaw channels status --probe +openclaw logs --follow +``` + +Good output looks like: + +- Run status is `ok`. +- Delivery mode/target are set for isolated jobs. +- Channel probe reports target channel connected. + +Common signatures: + +- Run succeeded but delivery mode is `none` → no external message is expected. +- Delivery target missing/invalid (`channel`/`to`) → run may succeed internally but skip outbound. +- Channel auth errors (`unauthorized`, `missing_scope`, `Forbidden`) → delivery blocked by channel credentials/permissions. + +## Heartbeat suppressed or skipped + +```bash +openclaw system heartbeat last +openclaw logs --follow +openclaw config get agents.defaults.heartbeat +openclaw channels status --probe +``` + +Good output looks like: + +- Heartbeat enabled with non-zero interval. +- Last heartbeat result is `ran` (or skip reason is understood). + +Common signatures: + +- `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`. +- `requests-in-flight` → main lane busy; heartbeat deferred. +- `empty-heartbeat-file` → interval heartbeat skipped because `HEARTBEAT.md` has no actionable content and no tagged cron event is queued. +- `alerts-disabled` → visibility settings suppress outbound heartbeat messages. + +## Timezone and activeHours gotchas + +```bash +openclaw config get agents.defaults.heartbeat.activeHours +openclaw config get agents.defaults.heartbeat.activeHours.timezone +openclaw config get agents.defaults.userTimezone || echo "agents.defaults.userTimezone not set" +openclaw cron list +openclaw logs --follow +``` + +Quick rules: + +- `Config path not found: agents.defaults.userTimezone` means the key is unset; heartbeat falls back to host timezone (or `activeHours.timezone` if set). +- Cron without `--tz` uses gateway host timezone. +- Heartbeat `activeHours` uses configured timezone resolution (`user`, `local`, or explicit IANA tz). +- ISO timestamps without timezone are treated as UTC for cron `at` schedules. + +Common signatures: + +- Jobs run at the wrong wall-clock time after host timezone changes. +- Heartbeat always skipped during your daytime because `activeHours.timezone` is wrong. + +Related: + +- [/automation/cron-jobs](/automation/cron-jobs) +- [/gateway/heartbeat](/gateway/heartbeat) +- [/automation/cron-vs-heartbeat](/automation/cron-vs-heartbeat) +- [/concepts/timezone](/concepts/timezone) diff --git a/backend/app/one_person_security_dept/openclaw/docs/automation/webhook.md b/backend/app/one_person_security_dept/openclaw/docs/automation/webhook.md new file mode 100644 index 00000000..8072b4a1 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/automation/webhook.md @@ -0,0 +1,215 @@ +--- +summary: "Webhook ingress for wake and isolated agent runs" +read_when: + - Adding or changing webhook endpoints + - Wiring external systems into OpenClaw +title: "Webhooks" +--- + +# Webhooks + +Gateway can expose a small HTTP webhook endpoint for external triggers. + +## Enable + +```json5 +{ + hooks: { + enabled: true, + token: "shared-secret", + path: "/hooks", + // Optional: restrict explicit `agentId` routing to this allowlist. + // Omit or include "*" to allow any agent. + // Set [] to deny all explicit `agentId` routing. + allowedAgentIds: ["hooks", "main"], + }, +} +``` + +Notes: + +- `hooks.token` is required when `hooks.enabled=true`. +- `hooks.path` defaults to `/hooks`. + +## Auth + +Every request must include the hook token. Prefer headers: + +- `Authorization: Bearer ` (recommended) +- `x-openclaw-token: ` +- Query-string tokens are rejected (`?token=...` returns `400`). + +## Endpoints + +### `POST /hooks/wake` + +Payload: + +```json +{ "text": "System line", "mode": "now" } +``` + +- `text` **required** (string): The description of the event (e.g., "New email received"). +- `mode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check. + +Effect: + +- Enqueues a system event for the **main** session +- If `mode=now`, triggers an immediate heartbeat + +### `POST /hooks/agent` + +Payload: + +```json +{ + "message": "Run this", + "name": "Email", + "agentId": "hooks", + "sessionKey": "hook:email:msg-123", + "wakeMode": "now", + "deliver": true, + "channel": "last", + "to": "+15551234567", + "model": "openai/gpt-5.2-mini", + "thinking": "low", + "timeoutSeconds": 120 +} +``` + +- `message` **required** (string): The prompt or message for the agent to process. +- `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries. +- `agentId` optional (string): Route this hook to a specific agent. Unknown IDs fall back to the default agent. When set, the hook runs using the resolved agent's workspace and configuration. +- `sessionKey` optional (string): The key used to identify the agent's session. By default this field is rejected unless `hooks.allowRequestSessionKey=true`. +- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check. +- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped. +- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`. +- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for MS Teams). Defaults to the last recipient in the main session. +- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted. +- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`). +- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds. + +Effect: + +- Runs an **isolated** agent turn (own session key) +- Always posts a summary into the **main** session +- If `wakeMode=now`, triggers an immediate heartbeat + +## Session key policy (breaking change) + +`/hooks/agent` payload `sessionKey` overrides are disabled by default. + +- Recommended: set a fixed `hooks.defaultSessionKey` and keep request overrides off. +- Optional: allow request overrides only when needed, and restrict prefixes. + +Recommended config: + +```json5 +{ + hooks: { + enabled: true, + token: "${OPENCLAW_HOOKS_TOKEN}", + defaultSessionKey: "hook:ingress", + allowRequestSessionKey: false, + allowedSessionKeyPrefixes: ["hook:"], + }, +} +``` + +Compatibility config (legacy behavior): + +```json5 +{ + hooks: { + enabled: true, + token: "${OPENCLAW_HOOKS_TOKEN}", + allowRequestSessionKey: true, + allowedSessionKeyPrefixes: ["hook:"], // strongly recommended + }, +} +``` + +### `POST /hooks/` (mapped) + +Custom hook names are resolved via `hooks.mappings` (see configuration). A mapping can +turn arbitrary payloads into `wake` or `agent` actions, with optional templates or +code transforms. + +Mapping options (summary): + +- `hooks.presets: ["gmail"]` enables the built-in Gmail mapping. +- `hooks.mappings` lets you define `match`, `action`, and templates in config. +- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic. + - `hooks.transformsDir` (if set) must stay within the transforms root under your OpenClaw config directory (typically `~/.openclaw/hooks/transforms`). + - `transform.module` must resolve within the effective transforms directory (traversal/escape paths are rejected). +- Use `match.source` to keep a generic ingest endpoint (payload-driven routing). +- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime. +- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface + (`channel` defaults to `last` and falls back to WhatsApp). +- `agentId` routes the hook to a specific agent; unknown IDs fall back to the default agent. +- `hooks.allowedAgentIds` restricts explicit `agentId` routing. Omit it (or include `*`) to allow any agent. Set `[]` to deny explicit `agentId` routing. +- `hooks.defaultSessionKey` sets the default session for hook agent runs when no explicit key is provided. +- `hooks.allowRequestSessionKey` controls whether `/hooks/agent` payloads may set `sessionKey` (default: `false`). +- `hooks.allowedSessionKeyPrefixes` optionally restricts explicit `sessionKey` values from request payloads and mappings. +- `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook + (dangerous; only for trusted internal sources). +- `openclaw webhooks gmail setup` writes `hooks.gmail` config for `openclaw webhooks gmail run`. + See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow. + +## Responses + +- `200` for `/hooks/wake` +- `202` for `/hooks/agent` (async run started) +- `401` on auth failure +- `429` after repeated auth failures from the same client (check `Retry-After`) +- `400` on invalid payload +- `413` on oversized payloads + +## Examples + +```bash +curl -X POST http://127.0.0.1:18789/hooks/wake \ + -H 'Authorization: Bearer SECRET' \ + -H 'Content-Type: application/json' \ + -d '{"text":"New email received","mode":"now"}' +``` + +```bash +curl -X POST http://127.0.0.1:18789/hooks/agent \ + -H 'x-openclaw-token: SECRET' \ + -H 'Content-Type: application/json' \ + -d '{"message":"Summarize inbox","name":"Email","wakeMode":"next-heartbeat"}' +``` + +### Use a different model + +Add `model` to the agent payload (or mapping) to override the model for that run: + +```bash +curl -X POST http://127.0.0.1:18789/hooks/agent \ + -H 'x-openclaw-token: SECRET' \ + -H 'Content-Type: application/json' \ + -d '{"message":"Summarize inbox","name":"Email","model":"openai/gpt-5.2-mini"}' +``` + +If you enforce `agents.defaults.models`, make sure the override model is included there. + +```bash +curl -X POST http://127.0.0.1:18789/hooks/gmail \ + -H 'Authorization: Bearer SECRET' \ + -H 'Content-Type: application/json' \ + -d '{"source":"gmail","messages":[{"from":"Ada","subject":"Hello","snippet":"Hi"}]}' +``` + +## Security + +- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy. +- Use a dedicated hook token; do not reuse gateway auth tokens. +- Repeated auth failures are rate-limited per client address to slow brute-force attempts. +- If you use multi-agent routing, set `hooks.allowedAgentIds` to limit explicit `agentId` selection. +- Keep `hooks.allowRequestSessionKey=false` unless you require caller-selected sessions. +- If you enable request `sessionKey`, restrict `hooks.allowedSessionKeyPrefixes` (for example, `["hook:"]`). +- Avoid including sensitive raw payloads in webhook logs. +- Hook payloads are treated as untrusted and wrapped with safety boundaries by default. + If you must disable this for a specific hook, set `allowUnsafeExternalContent: true` + in that hook's mapping (dangerous). diff --git a/backend/app/one_person_security_dept/openclaw/docs/brave-search.md b/backend/app/one_person_security_dept/openclaw/docs/brave-search.md new file mode 100644 index 00000000..ba18a6c5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/brave-search.md @@ -0,0 +1,41 @@ +--- +summary: "Brave Search API setup for web_search" +read_when: + - You want to use Brave Search for web_search + - You need a BRAVE_API_KEY or plan details +title: "Brave Search" +--- + +# Brave Search API + +OpenClaw uses Brave Search as the default provider for `web_search`. + +## Get an API key + +1. Create a Brave Search API account at [https://brave.com/search/api/](https://brave.com/search/api/) +2. In the dashboard, choose the **Data for Search** plan and generate an API key. +3. Store the key in config (recommended) or set `BRAVE_API_KEY` in the Gateway environment. + +## Config example + +```json5 +{ + tools: { + web: { + search: { + provider: "brave", + apiKey: "BRAVE_API_KEY_HERE", + maxResults: 5, + timeoutSeconds: 30, + }, + }, + }, +} +``` + +## Notes + +- The Data for AI plan is **not** compatible with `web_search`. +- Brave provides a free tier plus paid plans; check the Brave API portal for current limits. + +See [Web tools](/tools/web) for the full web_search configuration. diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/bluebubbles.md b/backend/app/one_person_security_dept/openclaw/docs/channels/bluebubbles.md new file mode 100644 index 00000000..8c826749 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/bluebubbles.md @@ -0,0 +1,346 @@ +--- +summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing, advanced actions)." +read_when: + - Setting up BlueBubbles channel + - Troubleshooting webhook pairing + - Configuring iMessage on macOS +title: "BlueBubbles" +--- + +# BlueBubbles (macOS REST) + +Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **Recommended for iMessage integration** due to its richer API and easier setup compared to the legacy imsg channel. + +## Overview + +- Runs on macOS via the BlueBubbles helper app ([bluebubbles.app](https://bluebubbles.app)). +- Recommended/tested: macOS Sequoia (15). macOS Tahoe (26) works; edit is currently broken on Tahoe, and group icon updates may report success but not sync. +- OpenClaw talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`). +- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls. +- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible). +- Pairing/allowlist works the same way as other channels (`/channels/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes. +- Reactions are surfaced as system events just like Slack/Telegram so agents can "mention" them before replying. +- Advanced features: edit, unsend, reply threading, message effects, group management. + +## Quick start + +1. Install the BlueBubbles server on your Mac (follow the instructions at [bluebubbles.app/install](https://bluebubbles.app/install)). +2. In the BlueBubbles config, enable the web API and set a password. +3. Run `openclaw onboard` and select BlueBubbles, or configure manually: + + ```json5 + { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://192.168.1.100:1234", + password: "example-password", + webhookPath: "/bluebubbles-webhook", + }, + }, + } + ``` + +4. Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=`). +5. Start the gateway; it will register the webhook handler and start pairing. + +Security note: + +- Always set a webhook password. +- Webhook authentication is always required. OpenClaw rejects BlueBubbles webhook requests unless they include a password/guid that matches `channels.bluebubbles.password` (for example `?password=` or `x-password`), regardless of loopback/proxy topology. + +## Keeping Messages.app alive (VM / headless setups) + +Some macOS VM / always-on setups can end up with Messages.app going “idle” (incoming events stop until the app is opened/foregrounded). A simple workaround is to **poke Messages every 5 minutes** using an AppleScript + LaunchAgent. + +### 1) Save the AppleScript + +Save this as: + +- `~/Scripts/poke-messages.scpt` + +Example script (non-interactive; does not steal focus): + +```applescript +try + tell application "Messages" + if not running then + launch + end if + + -- Touch the scripting interface to keep the process responsive. + set _chatCount to (count of chats) + end tell +on error + -- Ignore transient failures (first-run prompts, locked session, etc). +end try +``` + +### 2) Install a LaunchAgent + +Save this as: + +- `~/Library/LaunchAgents/com.user.poke-messages.plist` + +```xml + + + + + Label + com.user.poke-messages + + ProgramArguments + + /bin/bash + -lc + /usr/bin/osascript "$HOME/Scripts/poke-messages.scpt" + + + RunAtLoad + + + StartInterval + 300 + + StandardOutPath + /tmp/poke-messages.log + StandardErrorPath + /tmp/poke-messages.err + + +``` + +Notes: + +- This runs **every 300 seconds** and **on login**. +- The first run may trigger macOS **Automation** prompts (`osascript` → Messages). Approve them in the same user session that runs the LaunchAgent. + +Load it: + +```bash +launchctl unload ~/Library/LaunchAgents/com.user.poke-messages.plist 2>/dev/null || true +launchctl load ~/Library/LaunchAgents/com.user.poke-messages.plist +``` + +## Onboarding + +BlueBubbles is available in the interactive setup wizard: + +``` +openclaw onboard +``` + +The wizard prompts for: + +- **Server URL** (required): BlueBubbles server address (e.g., `http://192.168.1.100:1234`) +- **Password** (required): API password from BlueBubbles Server settings +- **Webhook path** (optional): Defaults to `/bluebubbles-webhook` +- **DM policy**: pairing, allowlist, open, or disabled +- **Allow list**: Phone numbers, emails, or chat targets + +You can also add BlueBubbles via CLI: + +``` +openclaw channels add bluebubbles --http-url http://192.168.1.100:1234 --password +``` + +## Access control (DMs + groups) + +DMs: + +- Default: `channels.bluebubbles.dmPolicy = "pairing"`. +- Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour). +- Approve via: + - `openclaw pairing list bluebubbles` + - `openclaw pairing approve bluebubbles ` +- Pairing is the default token exchange. Details: [Pairing](/channels/pairing) + +Groups: + +- `channels.bluebubbles.groupPolicy = open | allowlist | disabled` (default: `allowlist`). +- `channels.bluebubbles.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. + +### Mention gating (groups) + +BlueBubbles supports mention gating for group chats, matching iMessage/WhatsApp behavior: + +- Uses `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) to detect mentions. +- When `requireMention` is enabled for a group, the agent only responds when mentioned. +- Control commands from authorized senders bypass mention gating. + +Per-group configuration: + +```json5 +{ + channels: { + bluebubbles: { + groupPolicy: "allowlist", + groupAllowFrom: ["+15555550123"], + groups: { + "*": { requireMention: true }, // default for all groups + "iMessage;-;chat123": { requireMention: false }, // override for specific group + }, + }, + }, +} +``` + +### Command gating + +- Control commands (e.g., `/config`, `/model`) require authorization. +- Uses `allowFrom` and `groupAllowFrom` to determine command authorization. +- Authorized senders can run control commands even without mentioning in groups. + +## Typing + read receipts + +- **Typing indicators**: Sent automatically before and during response generation. +- **Read receipts**: Controlled by `channels.bluebubbles.sendReadReceipts` (default: `true`). +- **Typing indicators**: OpenClaw sends typing start events; BlueBubbles clears typing automatically on send or timeout (manual stop via DELETE is unreliable). + +```json5 +{ + channels: { + bluebubbles: { + sendReadReceipts: false, // disable read receipts + }, + }, +} +``` + +## Advanced actions + +BlueBubbles supports advanced message actions when enabled in config: + +```json5 +{ + channels: { + bluebubbles: { + actions: { + reactions: true, // tapbacks (default: true) + edit: true, // edit sent messages (macOS 13+, broken on macOS 26 Tahoe) + unsend: true, // unsend messages (macOS 13+) + reply: true, // reply threading by message GUID + sendWithEffect: true, // message effects (slam, loud, etc.) + renameGroup: true, // rename group chats + setGroupIcon: true, // set group chat icon/photo (flaky on macOS 26 Tahoe) + addParticipant: true, // add participants to groups + removeParticipant: true, // remove participants from groups + leaveGroup: true, // leave group chats + sendAttachment: true, // send attachments/media + }, + }, + }, +} +``` + +Available actions: + +- **react**: Add/remove tapback reactions (`messageId`, `emoji`, `remove`) +- **edit**: Edit a sent message (`messageId`, `text`) +- **unsend**: Unsend a message (`messageId`) +- **reply**: Reply to a specific message (`messageId`, `text`, `to`) +- **sendWithEffect**: Send with iMessage effect (`text`, `to`, `effectId`) +- **renameGroup**: Rename a group chat (`chatGuid`, `displayName`) +- **setGroupIcon**: Set a group chat's icon/photo (`chatGuid`, `media`) — flaky on macOS 26 Tahoe (API may return success but the icon does not sync). +- **addParticipant**: Add someone to a group (`chatGuid`, `address`) +- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`) +- **leaveGroup**: Leave a group chat (`chatGuid`) +- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`) + - Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos. + +### Message IDs (short vs full) + +OpenClaw may surface _short_ message IDs (e.g., `1`, `2`) to save tokens. + +- `MessageSid` / `ReplyToId` can be short IDs. +- `MessageSidFull` / `ReplyToIdFull` contain the provider full IDs. +- Short IDs are in-memory; they can expire on restart or cache eviction. +- Actions accept short or full `messageId`, but short IDs will error if no longer available. + +Use full IDs for durable automations and storage: + +- Templates: `{{MessageSidFull}}`, `{{ReplyToIdFull}}` +- Context: `MessageSidFull` / `ReplyToIdFull` in inbound payloads + +See [Configuration](/gateway/configuration) for template variables. + +## Block streaming + +Control whether responses are sent as a single message or streamed in blocks: + +```json5 +{ + channels: { + bluebubbles: { + blockStreaming: true, // enable block streaming (off by default) + }, + }, +} +``` + +## Media + limits + +- Inbound attachments are downloaded and stored in the media cache. +- Media cap via `channels.bluebubbles.mediaMaxMb` (default: 8 MB). +- Outbound text is chunked to `channels.bluebubbles.textChunkLimit` (default: 4000 chars). + +## Configuration reference + +Full configuration: [Configuration](/gateway/configuration) + +Provider options: + +- `channels.bluebubbles.enabled`: Enable/disable the channel. +- `channels.bluebubbles.serverUrl`: BlueBubbles REST API base URL. +- `channels.bluebubbles.password`: API password. +- `channels.bluebubbles.webhookPath`: Webhook endpoint path (default: `/bluebubbles-webhook`). +- `channels.bluebubbles.dmPolicy`: `pairing | allowlist | open | disabled` (default: `pairing`). +- `channels.bluebubbles.allowFrom`: DM allowlist (handles, emails, E.164 numbers, `chat_id:*`, `chat_guid:*`). +- `channels.bluebubbles.groupPolicy`: `open | allowlist | disabled` (default: `allowlist`). +- `channels.bluebubbles.groupAllowFrom`: Group sender allowlist. +- `channels.bluebubbles.groups`: Per-group config (`requireMention`, etc.). +- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`). +- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `false`; required for streaming replies). +- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000). +- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking. +- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8). +- `channels.bluebubbles.mediaLocalRoots`: Explicit allowlist of absolute local directories permitted for outbound local media paths. Local path sends are denied by default unless this is configured. Per-account override: `channels.bluebubbles.accounts..mediaLocalRoots`. +- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables). +- `channels.bluebubbles.dmHistoryLimit`: DM history limit. +- `channels.bluebubbles.actions`: Enable/disable specific actions. +- `channels.bluebubbles.accounts`: Multi-account configuration. + +Related global options: + +- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`). +- `messages.responsePrefix`. + +## Addressing / delivery targets + +Prefer `chat_guid` for stable routing: + +- `chat_guid:iMessage;-;+15555550123` (preferred for groups) +- `chat_id:123` +- `chat_identifier:...` +- Direct handles: `+15555550123`, `user@example.com` + - If a direct handle does not have an existing DM chat, OpenClaw will create one via `POST /api/v1/chat/new`. This requires the BlueBubbles Private API to be enabled. + +## Security + +- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted. +- Keep the API password and webhook endpoint secret (treat them like credentials). +- Localhost trust means a same-host reverse proxy can unintentionally bypass the password. If you proxy the gateway, require auth at the proxy and configure `gateway.trustedProxies`. See [Gateway security](/gateway/security#reverse-proxy-configuration). +- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN. + +## Troubleshooting + +- If typing/read events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`. +- Pairing codes expire after one hour; use `openclaw pairing list bluebubbles` and `openclaw pairing approve bluebubbles `. +- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it. +- Edit/unsend require macOS 13+ and a compatible BlueBubbles server version. On macOS 26 (Tahoe), edit is currently broken due to private API changes. +- Group icon updates can be flaky on macOS 26 (Tahoe): the API may return success but the new icon does not sync. +- OpenClaw auto-hides known-broken actions based on the BlueBubbles server's macOS version. If edit still appears on macOS 26 (Tahoe), disable it manually with `channels.bluebubbles.actions.edit=false`. +- For status/health info: `openclaw status --all` or `openclaw status --deep`. + +For general channel workflow reference, see [Channels](/channels) and the [Plugins](/tools/plugin) guide. diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/broadcast-groups.md b/backend/app/one_person_security_dept/openclaw/docs/channels/broadcast-groups.md new file mode 100644 index 00000000..2d47d7c5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/broadcast-groups.md @@ -0,0 +1,442 @@ +--- +summary: "Broadcast a WhatsApp message to multiple agents" +read_when: + - Configuring broadcast groups + - Debugging multi-agent replies in WhatsApp +status: experimental +title: "Broadcast Groups" +--- + +# Broadcast Groups + +**Status:** Experimental +**Version:** Added in 2026.1.9 + +## Overview + +Broadcast Groups enable multiple agents to process and respond to the same message simultaneously. This allows you to create specialized agent teams that work together in a single WhatsApp group or DM — all using one phone number. + +Current scope: **WhatsApp only** (web channel). + +Broadcast groups are evaluated after channel allowlists and group activation rules. In WhatsApp groups, this means broadcasts happen when OpenClaw would normally reply (for example: on mention, depending on your group settings). + +## Use Cases + +### 1. Specialized Agent Teams + +Deploy multiple agents with atomic, focused responsibilities: + +``` +Group: "Development Team" +Agents: + - CodeReviewer (reviews code snippets) + - DocumentationBot (generates docs) + - SecurityAuditor (checks for vulnerabilities) + - TestGenerator (suggests test cases) +``` + +Each agent processes the same message and provides its specialized perspective. + +### 2. Multi-Language Support + +``` +Group: "International Support" +Agents: + - Agent_EN (responds in English) + - Agent_DE (responds in German) + - Agent_ES (responds in Spanish) +``` + +### 3. Quality Assurance Workflows + +``` +Group: "Customer Support" +Agents: + - SupportAgent (provides answer) + - QAAgent (reviews quality, only responds if issues found) +``` + +### 4. Task Automation + +``` +Group: "Project Management" +Agents: + - TaskTracker (updates task database) + - TimeLogger (logs time spent) + - ReportGenerator (creates summaries) +``` + +## Configuration + +### Basic Setup + +Add a top-level `broadcast` section (next to `bindings`). Keys are WhatsApp peer ids: + +- group chats: group JID (e.g. `120363403215116621@g.us`) +- DMs: E.164 phone number (e.g. `+15551234567`) + +```json +{ + "broadcast": { + "120363403215116621@g.us": ["alfred", "baerbel", "assistant3"] + } +} +``` + +**Result:** When OpenClaw would reply in this chat, it will run all three agents. + +### Processing Strategy + +Control how agents process messages: + +#### Parallel (Default) + +All agents process simultaneously: + +```json +{ + "broadcast": { + "strategy": "parallel", + "120363403215116621@g.us": ["alfred", "baerbel"] + } +} +``` + +#### Sequential + +Agents process in order (one waits for previous to finish): + +```json +{ + "broadcast": { + "strategy": "sequential", + "120363403215116621@g.us": ["alfred", "baerbel"] + } +} +``` + +### Complete Example + +```json +{ + "agents": { + "list": [ + { + "id": "code-reviewer", + "name": "Code Reviewer", + "workspace": "/path/to/code-reviewer", + "sandbox": { "mode": "all" } + }, + { + "id": "security-auditor", + "name": "Security Auditor", + "workspace": "/path/to/security-auditor", + "sandbox": { "mode": "all" } + }, + { + "id": "docs-generator", + "name": "Documentation Generator", + "workspace": "/path/to/docs-generator", + "sandbox": { "mode": "all" } + } + ] + }, + "broadcast": { + "strategy": "parallel", + "120363403215116621@g.us": ["code-reviewer", "security-auditor", "docs-generator"], + "120363424282127706@g.us": ["support-en", "support-de"], + "+15555550123": ["assistant", "logger"] + } +} +``` + +## How It Works + +### Message Flow + +1. **Incoming message** arrives in a WhatsApp group +2. **Broadcast check**: System checks if peer ID is in `broadcast` +3. **If in broadcast list**: + - All listed agents process the message + - Each agent has its own session key and isolated context + - Agents process in parallel (default) or sequentially +4. **If not in broadcast list**: + - Normal routing applies (first matching binding) + +Note: broadcast groups do not bypass channel allowlists or group activation rules (mentions/commands/etc). They only change _which agents run_ when a message is eligible for processing. + +### Session Isolation + +Each agent in a broadcast group maintains completely separate: + +- **Session keys** (`agent:alfred:whatsapp:group:120363...` vs `agent:baerbel:whatsapp:group:120363...`) +- **Conversation history** (agent doesn't see other agents' messages) +- **Workspace** (separate sandboxes if configured) +- **Tool access** (different allow/deny lists) +- **Memory/context** (separate IDENTITY.md, SOUL.md, etc.) +- **Group context buffer** (recent group messages used for context) is shared per peer, so all broadcast agents see the same context when triggered + +This allows each agent to have: + +- Different personalities +- Different tool access (e.g., read-only vs. read-write) +- Different models (e.g., opus vs. sonnet) +- Different skills installed + +### Example: Isolated Sessions + +In group `120363403215116621@g.us` with agents `["alfred", "baerbel"]`: + +**Alfred's context:** + +``` +Session: agent:alfred:whatsapp:group:120363403215116621@g.us +History: [user message, alfred's previous responses] +Workspace: /Users/pascal/openclaw-alfred/ +Tools: read, write, exec +``` + +**Bärbel's context:** + +``` +Session: agent:baerbel:whatsapp:group:120363403215116621@g.us +History: [user message, baerbel's previous responses] +Workspace: /Users/pascal/openclaw-baerbel/ +Tools: read only +``` + +## Best Practices + +### 1. Keep Agents Focused + +Design each agent with a single, clear responsibility: + +```json +{ + "broadcast": { + "DEV_GROUP": ["formatter", "linter", "tester"] + } +} +``` + +✅ **Good:** Each agent has one job +❌ **Bad:** One generic "dev-helper" agent + +### 2. Use Descriptive Names + +Make it clear what each agent does: + +```json +{ + "agents": { + "security-scanner": { "name": "Security Scanner" }, + "code-formatter": { "name": "Code Formatter" }, + "test-generator": { "name": "Test Generator" } + } +} +``` + +### 3. Configure Different Tool Access + +Give agents only the tools they need: + +```json +{ + "agents": { + "reviewer": { + "tools": { "allow": ["read", "exec"] } // Read-only + }, + "fixer": { + "tools": { "allow": ["read", "write", "edit", "exec"] } // Read-write + } + } +} +``` + +### 4. Monitor Performance + +With many agents, consider: + +- Using `"strategy": "parallel"` (default) for speed +- Limiting broadcast groups to 5-10 agents +- Using faster models for simpler agents + +### 5. Handle Failures Gracefully + +Agents fail independently. One agent's error doesn't block others: + +``` +Message → [Agent A ✓, Agent B ✗ error, Agent C ✓] +Result: Agent A and C respond, Agent B logs error +``` + +## Compatibility + +### Providers + +Broadcast groups currently work with: + +- ✅ WhatsApp (implemented) +- 🚧 Telegram (planned) +- 🚧 Discord (planned) +- 🚧 Slack (planned) + +### Routing + +Broadcast groups work alongside existing routing: + +```json +{ + "bindings": [ + { + "match": { "channel": "whatsapp", "peer": { "kind": "group", "id": "GROUP_A" } }, + "agentId": "alfred" + } + ], + "broadcast": { + "GROUP_B": ["agent1", "agent2"] + } +} +``` + +- `GROUP_A`: Only alfred responds (normal routing) +- `GROUP_B`: agent1 AND agent2 respond (broadcast) + +**Precedence:** `broadcast` takes priority over `bindings`. + +## Troubleshooting + +### Agents Not Responding + +**Check:** + +1. Agent IDs exist in `agents.list` +2. Peer ID format is correct (e.g., `120363403215116621@g.us`) +3. Agents are not in deny lists + +**Debug:** + +```bash +tail -f ~/.openclaw/logs/gateway.log | grep broadcast +``` + +### Only One Agent Responding + +**Cause:** Peer ID might be in `bindings` but not `broadcast`. + +**Fix:** Add to broadcast config or remove from bindings. + +### Performance Issues + +**If slow with many agents:** + +- Reduce number of agents per group +- Use lighter models (sonnet instead of opus) +- Check sandbox startup time + +## Examples + +### Example 1: Code Review Team + +```json +{ + "broadcast": { + "strategy": "parallel", + "120363403215116621@g.us": [ + "code-formatter", + "security-scanner", + "test-coverage", + "docs-checker" + ] + }, + "agents": { + "list": [ + { + "id": "code-formatter", + "workspace": "~/agents/formatter", + "tools": { "allow": ["read", "write"] } + }, + { + "id": "security-scanner", + "workspace": "~/agents/security", + "tools": { "allow": ["read", "exec"] } + }, + { + "id": "test-coverage", + "workspace": "~/agents/testing", + "tools": { "allow": ["read", "exec"] } + }, + { "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } } + ] + } +} +``` + +**User sends:** Code snippet +**Responses:** + +- code-formatter: "Fixed indentation and added type hints" +- security-scanner: "⚠️ SQL injection vulnerability in line 12" +- test-coverage: "Coverage is 45%, missing tests for error cases" +- docs-checker: "Missing docstring for function `process_data`" + +### Example 2: Multi-Language Support + +```json +{ + "broadcast": { + "strategy": "sequential", + "+15555550123": ["detect-language", "translator-en", "translator-de"] + }, + "agents": { + "list": [ + { "id": "detect-language", "workspace": "~/agents/lang-detect" }, + { "id": "translator-en", "workspace": "~/agents/translate-en" }, + { "id": "translator-de", "workspace": "~/agents/translate-de" } + ] + } +} +``` + +## API Reference + +### Config Schema + +```typescript +interface OpenClawConfig { + broadcast?: { + strategy?: "parallel" | "sequential"; + [peerId: string]: string[]; + }; +} +``` + +### Fields + +- `strategy` (optional): How to process agents + - `"parallel"` (default): All agents process simultaneously + - `"sequential"`: Agents process in array order +- `[peerId]`: WhatsApp group JID, E.164 number, or other peer ID + - Value: Array of agent IDs that should process messages + +## Limitations + +1. **Max agents:** No hard limit, but 10+ agents may be slow +2. **Shared context:** Agents don't see each other's responses (by design) +3. **Message ordering:** Parallel responses may arrive in any order +4. **Rate limits:** All agents count toward WhatsApp rate limits + +## Future Enhancements + +Planned features: + +- [ ] Shared context mode (agents see each other's responses) +- [ ] Agent coordination (agents can signal each other) +- [ ] Dynamic agent selection (choose agents based on message content) +- [ ] Agent priorities (some agents respond before others) + +## See Also + +- [Multi-Agent Configuration](/tools/multi-agent-sandbox-tools) +- [Routing Configuration](/channels/channel-routing) +- [Session Management](/concepts/sessions) diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/channel-routing.md b/backend/app/one_person_security_dept/openclaw/docs/channels/channel-routing.md new file mode 100644 index 00000000..49c4a612 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/channel-routing.md @@ -0,0 +1,118 @@ +--- +summary: "Routing rules per channel (WhatsApp, Telegram, Discord, Slack) and shared context" +read_when: + - Changing channel routing or inbox behavior +title: "Channel Routing" +--- + +# Channels & routing + +OpenClaw routes replies **back to the channel where a message came from**. The +model does not choose a channel; routing is deterministic and controlled by the +host configuration. + +## Key terms + +- **Channel**: `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `webchat`. +- **AccountId**: per‑channel account instance (when supported). +- **AgentId**: an isolated workspace + session store (“brain”). +- **SessionKey**: the bucket key used to store context and control concurrency. + +## Session key shapes (examples) + +Direct messages collapse to the agent’s **main** session: + +- `agent::` (default: `agent:main:main`) + +Groups and channels remain isolated per channel: + +- Groups: `agent:::group:` +- Channels/rooms: `agent:::channel:` + +Threads: + +- Slack/Discord threads append `:thread:` to the base key. +- Telegram forum topics embed `:topic:` in the group key. + +Examples: + +- `agent:main:telegram:group:-1001234567890:topic:42` +- `agent:main:discord:channel:123456:thread:987654` + +## Routing rules (how an agent is chosen) + +Routing picks **one agent** for each inbound message: + +1. **Exact peer match** (`bindings` with `peer.kind` + `peer.id`). +2. **Parent peer match** (thread inheritance). +3. **Guild + roles match** (Discord) via `guildId` + `roles`. +4. **Guild match** (Discord) via `guildId`. +5. **Team match** (Slack) via `teamId`. +6. **Account match** (`accountId` on the channel). +7. **Channel match** (any account on that channel, `accountId: "*"`). +8. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`). + +When a binding includes multiple match fields (`peer`, `guildId`, `teamId`, `roles`), **all provided fields must match** for that binding to apply. + +The matched agent determines which workspace and session store are used. + +## Broadcast groups (run multiple agents) + +Broadcast groups let you run **multiple agents** for the same peer **when OpenClaw would normally reply** (for example: in WhatsApp groups, after mention/activation gating). + +Config: + +```json5 +{ + broadcast: { + strategy: "parallel", + "120363403215116621@g.us": ["alfred", "baerbel"], + "+15555550123": ["support", "logger"], + }, +} +``` + +See: [Broadcast Groups](/channels/broadcast-groups). + +## Config overview + +- `agents.list`: named agent definitions (workspace, model, etc.). +- `bindings`: map inbound channels/accounts/peers to agents. + +Example: + +```json5 +{ + agents: { + list: [{ id: "support", name: "Support", workspace: "~/.openclaw/workspace-support" }], + }, + bindings: [ + { match: { channel: "slack", teamId: "T123" }, agentId: "support" }, + { match: { channel: "telegram", peer: { kind: "group", id: "-100123" } }, agentId: "support" }, + ], +} +``` + +## Session storage + +Session stores live under the state directory (default `~/.openclaw`): + +- `~/.openclaw/agents//sessions/sessions.json` +- JSONL transcripts live alongside the store + +You can override the store path via `session.store` and `{agentId}` templating. + +## WebChat behavior + +WebChat attaches to the **selected agent** and defaults to the agent’s main +session. Because of this, WebChat lets you see cross‑channel context for that +agent in one place. + +## Reply context + +Inbound replies include: + +- `ReplyToId`, `ReplyToBody`, and `ReplyToSender` when available. +- Quoted context is appended to `Body` as a `[Replying to ...]` block. + +This is consistent across channels. diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/discord.md b/backend/app/one_person_security_dept/openclaw/docs/channels/discord.md new file mode 100644 index 00000000..31913842 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/discord.md @@ -0,0 +1,1063 @@ +--- +summary: "Discord bot support status, capabilities, and configuration" +read_when: + - Working on Discord channel features +title: "Discord" +--- + +# Discord (Bot API) + +Status: ready for DMs and guild channels via the official Discord gateway. + + + + Discord DMs default to pairing mode. + + + Native command behavior and command catalog. + + + Cross-channel diagnostics and repair flow. + + + +## Quick setup + +You will need to create a new application with a bot, add the bot to your server, and pair it to OpenClaw. We recommend adding your bot to your own private server. If you don't have one yet, [create one first](https://support.discord.com/hc/en-us/articles/204849977-How-do-I-create-a-server) (choose **Create My Own > For me and my friends**). + + + + Go to the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. Name it something like "OpenClaw". + + Click **Bot** on the sidebar. Set the **Username** to whatever you call your OpenClaw agent. + + + + + Still on the **Bot** page, scroll down to **Privileged Gateway Intents** and enable: + + - **Message Content Intent** (required) + - **Server Members Intent** (recommended; required for role allowlists and name-to-ID matching) + - **Presence Intent** (optional; only needed for presence updates) + + + + + Scroll back up on the **Bot** page and click **Reset Token**. + + + Despite the name, this generates your first token — nothing is being "reset." + + + Copy the token and save it somewhere. This is your **Bot Token** and you will need it shortly. + + + + + Click **OAuth2** on the sidebar. You'll generate an invite URL with the right permissions to add the bot to your server. + + Scroll down to **OAuth2 URL Generator** and enable: + + - `bot` + - `applications.commands` + + A **Bot Permissions** section will appear below. Enable: + + - View Channels + - Send Messages + - Read Message History + - Embed Links + - Attach Files + - Add Reactions (optional) + + Copy the generated URL at the bottom, paste it into your browser, select your server, and click **Continue** to connect. You should now see your bot in the Discord server. + + + + + Back in the Discord app, you need to enable Developer Mode so you can copy internal IDs. + + 1. Click **User Settings** (gear icon next to your avatar) → **Advanced** → toggle on **Developer Mode** + 2. Right-click your **server icon** in the sidebar → **Copy Server ID** + 3. Right-click your **own avatar** → **Copy User ID** + + Save your **Server ID** and **User ID** alongside your Bot Token — you'll send all three to OpenClaw in the next step. + + + + + For pairing to work, Discord needs to allow your bot to DM you. Right-click your **server icon** → **Privacy Settings** → toggle on **Direct Messages**. + + This lets server members (including bots) send you DMs. Keep this enabled if you want to use Discord DMs with OpenClaw. If you only plan to use guild channels, you can disable DMs after pairing. + + + + + Your Discord bot token is a secret (like a password). Set it on the machine running OpenClaw before messaging your agent. + +```bash +openclaw config set channels.discord.token '"YOUR_BOT_TOKEN"' --json +openclaw config set channels.discord.enabled true --json +openclaw gateway +``` + + If OpenClaw is already running as a background service, use `openclaw gateway restart` instead. + + + + + + + + Chat with your OpenClaw agent on any existing channel (e.g. Telegram) and tell it. If Discord is your first channel, use the CLI / config tab instead. + + > "I already set my Discord bot token in config. Please finish Discord setup with User ID `` and Server ID ``." + + + If you prefer file-based config, set: + +```json5 +{ + channels: { + discord: { + enabled: true, + token: "YOUR_BOT_TOKEN", + }, + }, +} +``` + + Env fallback for the default account: + +```bash +DISCORD_BOT_TOKEN=... +``` + + + + + + + + Wait until the gateway is running, then DM your bot in Discord. It will respond with a pairing code. + + + + Send the pairing code to your agent on your existing channel: + + > "Approve this Discord pairing code: ``" + + + +```bash +openclaw pairing list discord +openclaw pairing approve discord +``` + + + + + Pairing codes expire after 1 hour. + + You should now be able to chat with your agent in Discord via DM. + + + + + +Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account. + + +## Recommended: Set up a guild workspace + +Once DMs are working, you can set up your Discord server as a full workspace where each channel gets its own agent session with its own context. This is recommended for private servers where it's just you and your bot. + + + + This enables your agent to respond in any channel on your server, not just DMs. + + + + > "Add my Discord Server ID `` to the guild allowlist" + + + +```json5 +{ + channels: { + discord: { + groupPolicy: "allowlist", + guilds: { + YOUR_SERVER_ID: { + requireMention: true, + users: ["YOUR_USER_ID"], + }, + }, + }, + }, +} +``` + + + + + + + + By default, your agent only responds in guild channels when @mentioned. For a private server, you probably want it to respond to every message. + + + + > "Allow my agent to respond on this server without having to be @mentioned" + + + Set `requireMention: false` in your guild config: + +```json5 +{ + channels: { + discord: { + guilds: { + YOUR_SERVER_ID: { + requireMention: false, + }, + }, + }, + }, +} +``` + + + + + + + + By default, long-term memory (MEMORY.md) only loads in DM sessions. Guild channels do not auto-load MEMORY.md. + + + + > "When I ask questions in Discord channels, use memory_search or memory_get if you need long-term context from MEMORY.md." + + + If you need shared context in every channel, put the stable instructions in `AGENTS.md` or `USER.md` (they are injected for every session). Keep long-term notes in `MEMORY.md` and access them on demand with memory tools. + + + + + + +Now create some channels on your Discord server and start chatting. Your agent can see the channel name, and each channel gets its own isolated session — so you can set up `#coding`, `#home`, `#research`, or whatever fits your workflow. + +## Runtime model + +- Gateway owns the Discord connection. +- Reply routing is deterministic: Discord inbound replies back to Discord. +- By default (`session.dmScope=main`), direct chats share the agent main session (`agent:main:main`). +- Guild channels are isolated session keys (`agent::discord:channel:`). +- Group DMs are ignored by default (`channels.discord.dm.groupEnabled=false`). +- Native slash commands run in isolated command sessions (`agent::discord:slash:`), while still carrying `CommandTargetSessionKey` to the routed conversation session. + +## Forum channels + +Discord forum and media channels only accept thread posts. OpenClaw supports two ways to create them: + +- Send a message to the forum parent (`channel:`) to auto-create a thread. The thread title uses the first non-empty line of your message. +- Use `openclaw message thread create` to create a thread directly. Do not pass `--message-id` for forum channels. + +Example: send to forum parent to create a thread + +```bash +openclaw message send --channel discord --target channel: \ + --message "Topic title\nBody of the post" +``` + +Example: create a forum thread explicitly + +```bash +openclaw message thread create --channel discord --target channel: \ + --thread-name "Topic title" --message "Body of the post" +``` + +Forum parents do not accept Discord components. If you need components, send to the thread itself (`channel:`). + +## Interactive components + +OpenClaw supports Discord components v2 containers for agent messages. Use the message tool with a `components` payload. Interaction results are routed back to the agent as normal inbound messages and follow the existing Discord `replyToMode` settings. + +Supported blocks: + +- `text`, `section`, `separator`, `actions`, `media-gallery`, `file` +- Action rows allow up to 5 buttons or a single select menu +- Select types: `string`, `user`, `role`, `mentionable`, `channel` + +By default, components are single use. Set `components.reusable=true` to allow buttons, selects, and forms to be used multiple times until they expire. + +To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial. + +The `/model` and `/models` slash commands open an interactive model picker with provider and model dropdowns plus a Submit step. The picker reply is ephemeral and only the invoking user can use it. + +File attachments: + +- `file` blocks must point to an attachment reference (`attachment://`) +- Provide the attachment via `media`/`path`/`filePath` (single file); use `media-gallery` for multiple files +- Use `filename` to override the upload name when it should match the attachment reference + +Modal forms: + +- Add `components.modal` with up to 5 fields +- Field types: `text`, `checkbox`, `radio`, `select`, `role-select`, `user-select` +- OpenClaw adds a trigger button automatically + +Example: + +```json5 +{ + channel: "discord", + action: "send", + to: "channel:123456789012345678", + message: "Optional fallback text", + components: { + reusable: true, + text: "Choose a path", + blocks: [ + { + type: "actions", + buttons: [ + { + label: "Approve", + style: "success", + allowedUsers: ["123456789012345678"], + }, + { label: "Decline", style: "danger" }, + ], + }, + { + type: "actions", + select: { + type: "string", + placeholder: "Pick an option", + options: [ + { label: "Option A", value: "a" }, + { label: "Option B", value: "b" }, + ], + }, + }, + ], + modal: { + title: "Details", + triggerLabel: "Open form", + fields: [ + { type: "text", label: "Requester" }, + { + type: "select", + label: "Priority", + options: [ + { label: "Low", value: "low" }, + { label: "High", value: "high" }, + ], + }, + ], + }, + }, +} +``` + +## Access control and routing + + + + `channels.discord.dmPolicy` controls DM access (legacy: `channels.discord.dm.policy`): + + - `pairing` (default) + - `allowlist` + - `open` (requires `channels.discord.allowFrom` to include `"*"`; legacy: `channels.discord.dm.allowFrom`) + - `disabled` + + If DM policy is not open, unknown users are blocked (or prompted for pairing in `pairing` mode). + + DM target format for delivery: + + - `user:` + - `<@id>` mention + + Bare numeric IDs are ambiguous and rejected unless an explicit user/channel target kind is provided. + + + + + Guild handling is controlled by `channels.discord.groupPolicy`: + + - `open` + - `allowlist` + - `disabled` + + Secure baseline when `channels.discord` exists is `allowlist`. + + `allowlist` behavior: + + - guild must match `channels.discord.guilds` (`id` preferred, slug accepted) + - optional sender allowlists: `users` (stable IDs recommended) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles` + - direct name/tag matching is disabled by default; enable `channels.discord.dangerouslyAllowNameMatching: true` only as break-glass compatibility mode + - names/tags are supported for `users`, but IDs are safer; `openclaw security audit` warns when name/tag entries are used + - if a guild has `channels` configured, non-listed channels are denied + - if a guild has no `channels` block, all channels in that allowlisted guild are allowed + + Example: + +```json5 +{ + channels: { + discord: { + groupPolicy: "allowlist", + guilds: { + "123456789012345678": { + requireMention: true, + users: ["987654321098765432"], + roles: ["123456789012345678"], + channels: { + general: { allow: true }, + help: { allow: true, requireMention: true }, + }, + }, + }, + }, + }, +} +``` + + If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="allowlist"` (with a warning in logs), even if `channels.defaults.groupPolicy` is `open`. + + + + + Guild messages are mention-gated by default. + + Mention detection includes: + + - explicit bot mention + - configured mention patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) + - implicit reply-to-bot behavior in supported cases + + `requireMention` is configured per guild/channel (`channels.discord.guilds...`). + + Group DMs: + + - default: ignored (`dm.groupEnabled=false`) + - optional allowlist via `dm.groupChannels` (channel IDs or slugs) + + + + +### Role-based agent routing + +Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings. If a binding also sets other match fields (for example `peer` + `guildId` + `roles`), all configured fields must match. + +```json5 +{ + bindings: [ + { + agentId: "opus", + match: { + channel: "discord", + guildId: "123456789012345678", + roles: ["111111111111111111"], + }, + }, + { + agentId: "sonnet", + match: { + channel: "discord", + guildId: "123456789012345678", + }, + }, + ], +} +``` + +## Developer Portal setup + + + + + 1. Discord Developer Portal -> **Applications** -> **New Application** + 2. **Bot** -> **Add Bot** + 3. Copy bot token + + + + + In **Bot -> Privileged Gateway Intents**, enable: + + - Message Content Intent + - Server Members Intent (recommended) + + Presence intent is optional and only required if you want to receive presence updates. Setting bot presence (`setPresence`) does not require enabling presence updates for members. + + + + + OAuth URL generator: + + - scopes: `bot`, `applications.commands` + + Typical baseline permissions: + + - View Channels + - Send Messages + - Read Message History + - Embed Links + - Attach Files + - Add Reactions (optional) + + Avoid `Administrator` unless explicitly needed. + + + + + Enable Discord Developer Mode, then copy: + + - server ID + - channel ID + - user ID + + Prefer numeric IDs in OpenClaw config for reliable audits and probes. + + + + +## Native commands and command auth + +- `commands.native` defaults to `"auto"` and is enabled for Discord. +- Per-channel override: `channels.discord.commands.native`. +- `commands.native=false` explicitly clears previously registered Discord native commands. +- Native command auth uses the same Discord allowlists/policies as normal message handling. +- Commands may still be visible in Discord UI for users who are not authorized; execution still enforces OpenClaw auth and returns "not authorized". + +See [Slash commands](/tools/slash-commands) for command catalog and behavior. + +Default slash command settings: + +- `ephemeral: true` + +## Feature details + + + + Discord supports reply tags in agent output: + + - `[[reply_to_current]]` + - `[[reply_to:]]` + + Controlled by `channels.discord.replyToMode`: + + - `off` (default) + - `first` + - `all` + + Note: `off` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored. + + Message IDs are surfaced in context/history so agents can target specific messages. + + + + + OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. + + - `channels.discord.streaming` controls preview streaming (`off` | `partial` | `block` | `progress`, default: `off`). + - `progress` is accepted for cross-channel consistency and maps to `partial` on Discord. + - `channels.discord.streamMode` is a legacy alias and is auto-migrated. + - `partial` edits a single preview message as tokens arrive. + - `block` emits draft-sized chunks (use `draftChunk` to tune size and breakpoints). + + Example: + +```json5 +{ + channels: { + discord: { + streaming: "partial", + }, + }, +} +``` + + `block` mode chunking defaults (clamped to `channels.discord.textChunkLimit`): + +```json5 +{ + channels: { + discord: { + streaming: "block", + draftChunk: { + minChars: 200, + maxChars: 800, + breakPreference: "paragraph", + }, + }, + }, +} +``` + + Preview streaming is text-only; media replies fall back to normal delivery. + + Note: preview streaming is separate from block streaming. When block streaming is explicitly + enabled for Discord, OpenClaw skips the preview stream to avoid double streaming. + + + + + Guild history context: + + - `channels.discord.historyLimit` default `20` + - fallback: `messages.groupChat.historyLimit` + - `0` disables + + DM history controls: + + - `channels.discord.dmHistoryLimit` + - `channels.discord.dms[""].historyLimit` + + Thread behavior: + + - Discord threads are routed as channel sessions + - parent thread metadata can be used for parent-session linkage + - thread config inherits parent channel config unless a thread-specific entry exists + + Channel topics are injected as **untrusted** context (not as system prompt). + + + + + Discord can bind a thread to a session target so follow-up messages in that thread keep routing to the same session (including subagent sessions). + + Commands: + + - `/focus ` bind current/new thread to a subagent/session target + - `/unfocus` remove current thread binding + - `/agents` show active runs and binding state + - `/session ttl ` inspect/update auto-unfocus TTL for focused bindings + + Config: + +```json5 +{ + session: { + threadBindings: { + enabled: true, + ttlHours: 24, + }, + }, + channels: { + discord: { + threadBindings: { + enabled: true, + ttlHours: 24, + spawnSubagentSessions: false, // opt-in + }, + }, + }, +} +``` + + Notes: + + - `session.threadBindings.*` sets global defaults. + - `channels.discord.threadBindings.*` overrides Discord behavior. + - `spawnSubagentSessions` must be true to auto-create/bind threads for `sessions_spawn({ thread: true })`. + - If thread bindings are disabled for an account, `/focus` and related thread binding operations are unavailable. + + See [Sub-agents](/tools/subagents) and [Configuration Reference](/gateway/configuration-reference). + + + + + Per-guild reaction notification mode: + + - `off` + - `own` (default) + - `all` + - `allowlist` (uses `guilds..users`) + + Reaction events are turned into system events and attached to the routed Discord session. + + + + + `ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message. + + Resolution order: + + - `channels.discord.accounts..ackReaction` + - `channels.discord.ackReaction` + - `messages.ackReaction` + - agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀") + + Notes: + + - Discord accepts unicode emoji or custom emoji names. + - Use `""` to disable the reaction for a channel or account. + + + + + Channel-initiated config writes are enabled by default. + + This affects `/config set|unset` flows (when command features are enabled). + + Disable: + +```json5 +{ + channels: { + discord: { + configWrites: false, + }, + }, +} +``` + + + + + Route Discord gateway WebSocket traffic and startup REST lookups (application ID + allowlist resolution) through an HTTP(S) proxy with `channels.discord.proxy`. + +```json5 +{ + channels: { + discord: { + proxy: "http://proxy.example:8080", + }, + }, +} +``` + + Per-account override: + +```json5 +{ + channels: { + discord: { + accounts: { + primary: { + proxy: "http://proxy.example:8080", + }, + }, + }, + }, +} +``` + + + + + Enable PluralKit resolution to map proxied messages to system member identity: + +```json5 +{ + channels: { + discord: { + pluralkit: { + enabled: true, + token: "pk_live_...", // optional; needed for private systems + }, + }, + }, +} +``` + + Notes: + + - allowlists can use `pk:` + - member display names are matched by name/slug only when `channels.discord.dangerouslyAllowNameMatching: true` + - lookups use original message ID and are time-window constrained + - if lookup fails, proxied messages are treated as bot messages and dropped unless `allowBots=true` + + + + + Presence updates are applied only when you set a status or activity field. + + Status only example: + +```json5 +{ + channels: { + discord: { + status: "idle", + }, + }, +} +``` + + Activity example (custom status is the default activity type): + +```json5 +{ + channels: { + discord: { + activity: "Focus time", + activityType: 4, + }, + }, +} +``` + + Streaming example: + +```json5 +{ + channels: { + discord: { + activity: "Live coding", + activityType: 1, + activityUrl: "https://twitch.tv/openclaw", + }, + }, +} +``` + + Activity type map: + + - 0: Playing + - 1: Streaming (requires `activityUrl`) + - 2: Listening + - 3: Watching + - 4: Custom (uses the activity text as the status state; emoji is optional) + - 5: Competing + + + + + Discord supports button-based exec approvals in DMs and can optionally post approval prompts in the originating channel. + + Config path: + + - `channels.discord.execApprovals.enabled` + - `channels.discord.execApprovals.approvers` + - `channels.discord.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`) + - `agentFilter`, `sessionFilter`, `cleanupAfterResolve` + + When `target` is `channel` or `both`, the approval prompt is visible in the channel. Only configured approvers can use the buttons; other users receive an ephemeral denial. Approval prompts include the command text, so only enable channel delivery in trusted channels. If the channel ID cannot be derived from the session key, OpenClaw falls back to DM delivery. + + If approvals fail with unknown approval IDs, verify approver list and feature enablement. + + Related docs: [Exec approvals](/tools/exec-approvals) + + + + +## Tools and action gates + +Discord message actions include messaging, channel admin, moderation, presence, and metadata actions. + +Core examples: + +- messaging: `sendMessage`, `readMessages`, `editMessage`, `deleteMessage`, `threadReply` +- reactions: `react`, `reactions`, `emojiList` +- moderation: `timeout`, `kick`, `ban` +- presence: `setPresence` + +Action gates live under `channels.discord.actions.*`. + +Default gate behavior: + +| Action group | Default | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | +| reactions, messages, threads, pins, polls, search, memberInfo, roleInfo, channelInfo, channels, voiceStatus, events, stickers, emojiUploads, stickerUploads, permissions | enabled | +| roles | disabled | +| moderation | disabled | +| presence | disabled | + +## Components v2 UI + +OpenClaw uses Discord components v2 for exec approvals and cross-context markers. Discord message actions can also accept `components` for custom UI (advanced; requires Carbon component instances), while legacy `embeds` remain available but are not recommended. + +- `channels.discord.ui.components.accentColor` sets the accent color used by Discord component containers (hex). +- Set per account with `channels.discord.accounts..ui.components.accentColor`. +- `embeds` are ignored when components v2 are present. + +Example: + +```json5 +{ + channels: { + discord: { + ui: { + components: { + accentColor: "#5865F2", + }, + }, + }, + }, +} +``` + +## Voice channels + +OpenClaw can join Discord voice channels for realtime, continuous conversations. This is separate from voice message attachments. + +Requirements: + +- Enable native commands (`commands.native` or `channels.discord.commands.native`). +- Configure `channels.discord.voice`. +- The bot needs Connect + Speak permissions in the target voice channel. + +Use the Discord-only native command `/vc join|leave|status` to control sessions. The command uses the account default agent and follows the same allowlist and group policy rules as other Discord commands. + +Auto-join example: + +```json5 +{ + channels: { + discord: { + voice: { + enabled: true, + autoJoin: [ + { + guildId: "123456789012345678", + channelId: "234567890123456789", + }, + ], + daveEncryption: true, + decryptionFailureTolerance: 24, + tts: { + provider: "openai", + openai: { voice: "alloy" }, + }, + }, + }, + }, +} +``` + +Notes: + +- `voice.tts` overrides `messages.tts` for voice playback only. +- Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it. +- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options. +- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset. +- OpenClaw also watches receive decrypt failures and auto-recovers by leaving/rejoining the voice channel after repeated failures in a short window. +- If receive logs repeatedly show `DecryptionFailed(UnencryptedWhenPassthroughDisabled)`, this may be the upstream `@discordjs/voice` receive bug tracked in [discord.js #11419](https://github.com/discordjs/discord.js/issues/11419). + +## Voice messages + +Discord voice messages show a waveform preview and require OGG/Opus audio plus metadata. OpenClaw generates the waveform automatically, but it needs `ffmpeg` and `ffprobe` available on the gateway host to inspect and convert audio files. + +Requirements and constraints: + +- Provide a **local file path** (URLs are rejected). +- Omit text content (Discord does not allow text + voice message in the same payload). +- Any audio format is accepted; OpenClaw converts to OGG/Opus when needed. + +Example: + +```bash +message(action="send", channel="discord", target="channel:123", path="/path/to/audio.mp3", asVoice=true) +``` + +## Troubleshooting + + + + + - enable Message Content Intent + - enable Server Members Intent when you depend on user/member resolution + - restart gateway after changing intents + + + + + + - verify `groupPolicy` + - verify guild allowlist under `channels.discord.guilds` + - if guild `channels` map exists, only listed channels are allowed + - verify `requireMention` behavior and mention patterns + + Useful checks: + +```bash +openclaw doctor +openclaw channels status --probe +openclaw logs --follow +``` + + + + + Common causes: + + - `groupPolicy="allowlist"` without matching guild/channel allowlist + - `requireMention` configured in the wrong place (must be under `channels.discord.guilds` or channel entry) + - sender blocked by guild/channel `users` allowlist + + + + + `channels status --probe` permission checks only work for numeric channel IDs. + + If you use slug keys, runtime matching can still work, but probe cannot fully verify permissions. + + + + + + - DM disabled: `channels.discord.dm.enabled=false` + - DM policy disabled: `channels.discord.dmPolicy="disabled"` (legacy: `channels.discord.dm.policy`) + - awaiting pairing approval in `pairing` mode + + + + + By default bot-authored messages are ignored. + + If you set `channels.discord.allowBots=true`, use strict mention and allowlist rules to avoid loop behavior. + + + + + + - keep OpenClaw current (`openclaw update`) so the Discord voice receive recovery logic is present + - confirm `channels.discord.voice.daveEncryption=true` (default) + - start from `channels.discord.voice.decryptionFailureTolerance=24` (upstream default) and tune only if needed + - watch logs for: + - `discord voice: DAVE decrypt failures detected` + - `discord voice: repeated decrypt failures; attempting rejoin` + - if failures continue after automatic rejoin, collect logs and compare against [discord.js #11419](https://github.com/discordjs/discord.js/issues/11419) + + + + +## Configuration reference pointers + +Primary reference: + +- [Configuration reference - Discord](/gateway/configuration-reference#discord) + +High-signal Discord fields: + +- startup/auth: `enabled`, `token`, `accounts.*`, `allowBots` +- policy: `groupPolicy`, `dm.*`, `guilds.*`, `guilds.*.channels.*` +- command: `commands.native`, `commands.useAccessGroups`, `configWrites`, `slashCommand.*` +- reply/history: `replyToMode`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit` +- delivery: `textChunkLimit`, `chunkMode`, `maxLinesPerMessage` +- streaming: `streaming` (legacy alias: `streamMode`), `draftChunk`, `blockStreaming`, `blockStreamingCoalesce` +- media/retry: `mediaMaxMb`, `retry` +- actions: `actions.*` +- presence: `activity`, `status`, `activityType`, `activityUrl` +- UI: `ui.components.accentColor` +- features: `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix` + +## Safety and operations + +- Treat bot tokens as secrets (`DISCORD_BOT_TOKEN` preferred in supervised environments). +- Grant least-privilege Discord permissions. +- If command deploy/state is stale, restart gateway and re-check with `openclaw channels status --probe`. + +## Related + +- [Pairing](/channels/pairing) +- [Channel routing](/channels/channel-routing) +- [Multi-agent routing](/concepts/multi-agent) +- [Troubleshooting](/channels/troubleshooting) +- [Slash commands](/tools/slash-commands) diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/feishu.md b/backend/app/one_person_security_dept/openclaw/docs/channels/feishu.md new file mode 100644 index 00000000..e92f8446 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/feishu.md @@ -0,0 +1,586 @@ +--- +summary: "Feishu bot overview, features, and configuration" +read_when: + - You want to connect a Feishu/Lark bot + - You are configuring the Feishu channel +title: Feishu +--- + +# Feishu bot + +Feishu (Lark) is a team chat platform used by companies for messaging and collaboration. This plugin connects OpenClaw to a Feishu/Lark bot using the platform’s WebSocket event subscription so messages can be received without exposing a public webhook URL. + +--- + +## Plugin required + +Install the Feishu plugin: + +```bash +openclaw plugins install @openclaw/feishu +``` + +Local checkout (when running from a git repo): + +```bash +openclaw plugins install ./extensions/feishu +``` + +--- + +## Quickstart + +There are two ways to add the Feishu channel: + +### Method 1: onboarding wizard (recommended) + +If you just installed OpenClaw, run the wizard: + +```bash +openclaw onboard +``` + +The wizard guides you through: + +1. Creating a Feishu app and collecting credentials +2. Configuring app credentials in OpenClaw +3. Starting the gateway + +✅ **After configuration**, check gateway status: + +- `openclaw gateway status` +- `openclaw logs --follow` + +### Method 2: CLI setup + +If you already completed initial install, add the channel via CLI: + +```bash +openclaw channels add +``` + +Choose **Feishu**, then enter the App ID and App Secret. + +✅ **After configuration**, manage the gateway: + +- `openclaw gateway status` +- `openclaw gateway restart` +- `openclaw logs --follow` + +--- + +## Step 1: Create a Feishu app + +### 1. Open Feishu Open Platform + +Visit [Feishu Open Platform](https://open.feishu.cn/app) and sign in. + +Lark (global) tenants should use [https://open.larksuite.com/app](https://open.larksuite.com/app) and set `domain: "lark"` in the Feishu config. + +### 2. Create an app + +1. Click **Create enterprise app** +2. Fill in the app name + description +3. Choose an app icon + +![Create enterprise app](../images/feishu-step2-create-app.png) + +### 3. Copy credentials + +From **Credentials & Basic Info**, copy: + +- **App ID** (format: `cli_xxx`) +- **App Secret** + +❗ **Important:** keep the App Secret private. + +![Get credentials](../images/feishu-step3-credentials.png) + +### 4. Configure permissions + +On **Permissions**, click **Batch import** and paste: + +```json +{ + "scopes": { + "tenant": [ + "aily:file:read", + "aily:file:write", + "application:application.app_message_stats.overview:readonly", + "application:application:self_manage", + "application:bot.menu:write", + "contact:user.employee_id:readonly", + "corehr:file:download", + "event:ip_list", + "im:chat.access_event.bot_p2p_chat:read", + "im:chat.members:bot_access", + "im:message", + "im:message.group_at_msg:readonly", + "im:message.p2p_msg:readonly", + "im:message:readonly", + "im:message:send_as_bot", + "im:resource" + ], + "user": ["aily:file:read", "aily:file:write", "im:chat.access_event.bot_p2p_chat:read"] + } +} +``` + +![Configure permissions](../images/feishu-step4-permissions.png) + +### 5. Enable bot capability + +In **App Capability** > **Bot**: + +1. Enable bot capability +2. Set the bot name + +![Enable bot capability](../images/feishu-step5-bot-capability.png) + +### 6. Configure event subscription + +⚠️ **Important:** before setting event subscription, make sure: + +1. You already ran `openclaw channels add` for Feishu +2. The gateway is running (`openclaw gateway status`) + +In **Event Subscription**: + +1. Choose **Use long connection to receive events** (WebSocket) +2. Add the event: `im.message.receive_v1` + +⚠️ If the gateway is not running, the long-connection setup may fail to save. + +![Configure event subscription](../images/feishu-step6-event-subscription.png) + +### 7. Publish the app + +1. Create a version in **Version Management & Release** +2. Submit for review and publish +3. Wait for admin approval (enterprise apps usually auto-approve) + +--- + +## Step 2: Configure OpenClaw + +### Configure with the wizard (recommended) + +```bash +openclaw channels add +``` + +Choose **Feishu** and paste your App ID + App Secret. + +### Configure via config file + +Edit `~/.openclaw/openclaw.json`: + +```json5 +{ + channels: { + feishu: { + enabled: true, + dmPolicy: "pairing", + accounts: { + main: { + appId: "cli_xxx", + appSecret: "xxx", + botName: "My AI assistant", + }, + }, + }, + }, +} +``` + +If you use `connectionMode: "webhook"`, set `verificationToken`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address. + +### Configure via environment variables + +```bash +export FEISHU_APP_ID="cli_xxx" +export FEISHU_APP_SECRET="xxx" +``` + +### Lark (global) domain + +If your tenant is on Lark (international), set the domain to `lark` (or a full domain string). You can set it at `channels.feishu.domain` or per account (`channels.feishu.accounts..domain`). + +```json5 +{ + channels: { + feishu: { + domain: "lark", + accounts: { + main: { + appId: "cli_xxx", + appSecret: "xxx", + }, + }, + }, + }, +} +``` + +--- + +## Step 3: Start + test + +### 1. Start the gateway + +```bash +openclaw gateway +``` + +### 2. Send a test message + +In Feishu, find your bot and send a message. + +### 3. Approve pairing + +By default, the bot replies with a pairing code. Approve it: + +```bash +openclaw pairing approve feishu +``` + +After approval, you can chat normally. + +--- + +## Overview + +- **Feishu bot channel**: Feishu bot managed by the gateway +- **Deterministic routing**: replies always return to Feishu +- **Session isolation**: DMs share a main session; groups are isolated +- **WebSocket connection**: long connection via Feishu SDK, no public URL needed + +--- + +## Access control + +### Direct messages + +- **Default**: `dmPolicy: "pairing"` (unknown users get a pairing code) +- **Approve pairing**: + + ```bash + openclaw pairing list feishu + openclaw pairing approve feishu + ``` + +- **Allowlist mode**: set `channels.feishu.allowFrom` with allowed Open IDs + +### Group chats + +**1. Group policy** (`channels.feishu.groupPolicy`): + +- `"open"` = allow everyone in groups (default) +- `"allowlist"` = only allow `groupAllowFrom` +- `"disabled"` = disable group messages + +**2. Mention requirement** (`channels.feishu.groups..requireMention`): + +- `true` = require @mention (default) +- `false` = respond without mentions + +--- + +## Group configuration examples + +### Allow all groups, require @mention (default) + +```json5 +{ + channels: { + feishu: { + groupPolicy: "open", + // Default requireMention: true + }, + }, +} +``` + +### Allow all groups, no @mention required + +```json5 +{ + channels: { + feishu: { + groups: { + oc_xxx: { requireMention: false }, + }, + }, + }, +} +``` + +### Allow specific users in groups only + +```json5 +{ + channels: { + feishu: { + groupPolicy: "allowlist", + groupAllowFrom: ["ou_xxx", "ou_yyy"], + }, + }, +} +``` + +--- + +## Get group/user IDs + +### Group IDs (chat_id) + +Group IDs look like `oc_xxx`. + +**Method 1 (recommended)** + +1. Start the gateway and @mention the bot in the group +2. Run `openclaw logs --follow` and look for `chat_id` + +**Method 2** + +Use the Feishu API debugger to list group chats. + +### User IDs (open_id) + +User IDs look like `ou_xxx`. + +**Method 1 (recommended)** + +1. Start the gateway and DM the bot +2. Run `openclaw logs --follow` and look for `open_id` + +**Method 2** + +Check pairing requests for user Open IDs: + +```bash +openclaw pairing list feishu +``` + +--- + +## Common commands + +| Command | Description | +| --------- | ----------------- | +| `/status` | Show bot status | +| `/reset` | Reset the session | +| `/model` | Show/switch model | + +> Note: Feishu does not support native command menus yet, so commands must be sent as text. + +## Gateway management commands + +| Command | Description | +| -------------------------- | ----------------------------- | +| `openclaw gateway status` | Show gateway status | +| `openclaw gateway install` | Install/start gateway service | +| `openclaw gateway stop` | Stop gateway service | +| `openclaw gateway restart` | Restart gateway service | +| `openclaw logs --follow` | Tail gateway logs | + +--- + +## Troubleshooting + +### Bot does not respond in group chats + +1. Ensure the bot is added to the group +2. Ensure you @mention the bot (default behavior) +3. Check `groupPolicy` is not set to `"disabled"` +4. Check logs: `openclaw logs --follow` + +### Bot does not receive messages + +1. Ensure the app is published and approved +2. Ensure event subscription includes `im.message.receive_v1` +3. Ensure **long connection** is enabled +4. Ensure app permissions are complete +5. Ensure the gateway is running: `openclaw gateway status` +6. Check logs: `openclaw logs --follow` + +### App Secret leak + +1. Reset the App Secret in Feishu Open Platform +2. Update the App Secret in your config +3. Restart the gateway + +### Message send failures + +1. Ensure the app has `im:message:send_as_bot` permission +2. Ensure the app is published +3. Check logs for detailed errors + +--- + +## Advanced configuration + +### Multiple accounts + +```json5 +{ + channels: { + feishu: { + accounts: { + main: { + appId: "cli_xxx", + appSecret: "xxx", + botName: "Primary bot", + }, + backup: { + appId: "cli_yyy", + appSecret: "yyy", + botName: "Backup bot", + enabled: false, + }, + }, + }, + }, +} +``` + +### Message limits + +- `textChunkLimit`: outbound text chunk size (default: 2000 chars) +- `mediaMaxMb`: media upload/download limit (default: 30MB) + +### Streaming + +Feishu supports streaming replies via interactive cards. When enabled, the bot updates a card as it generates text. + +```json5 +{ + channels: { + feishu: { + streaming: true, // enable streaming card output (default true) + blockStreaming: true, // enable block-level streaming (default true) + }, + }, +} +``` + +Set `streaming: false` to wait for the full reply before sending. + +### Multi-agent routing + +Use `bindings` to route Feishu DMs or groups to different agents. + +```json5 +{ + agents: { + list: [ + { id: "main" }, + { + id: "clawd-fan", + workspace: "/home/user/clawd-fan", + agentDir: "/home/user/.openclaw/agents/clawd-fan/agent", + }, + { + id: "clawd-xi", + workspace: "/home/user/clawd-xi", + agentDir: "/home/user/.openclaw/agents/clawd-xi/agent", + }, + ], + }, + bindings: [ + { + agentId: "main", + match: { + channel: "feishu", + peer: { kind: "direct", id: "ou_xxx" }, + }, + }, + { + agentId: "clawd-fan", + match: { + channel: "feishu", + peer: { kind: "direct", id: "ou_yyy" }, + }, + }, + { + agentId: "clawd-xi", + match: { + channel: "feishu", + peer: { kind: "group", id: "oc_zzz" }, + }, + }, + ], +} +``` + +Routing fields: + +- `match.channel`: `"feishu"` +- `match.peer.kind`: `"direct"` or `"group"` +- `match.peer.id`: user Open ID (`ou_xxx`) or group ID (`oc_xxx`) + +See [Get group/user IDs](#get-groupuser-ids) for lookup tips. + +--- + +## Configuration reference + +Full configuration: [Gateway configuration](/gateway/configuration) + +Key options: + +| Setting | Description | Default | +| ------------------------------------------------- | ------------------------------- | ---------------- | +| `channels.feishu.enabled` | Enable/disable channel | `true` | +| `channels.feishu.domain` | API domain (`feishu` or `lark`) | `feishu` | +| `channels.feishu.connectionMode` | Event transport mode | `websocket` | +| `channels.feishu.verificationToken` | Required for webhook mode | - | +| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` | +| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` | +| `channels.feishu.webhookPort` | Webhook bind port | `3000` | +| `channels.feishu.accounts..appId` | App ID | - | +| `channels.feishu.accounts..appSecret` | App Secret | - | +| `channels.feishu.accounts..domain` | Per-account API domain override | `feishu` | +| `channels.feishu.dmPolicy` | DM policy | `pairing` | +| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - | +| `channels.feishu.groupPolicy` | Group policy | `open` | +| `channels.feishu.groupAllowFrom` | Group allowlist | - | +| `channels.feishu.groups..requireMention` | Require @mention | `true` | +| `channels.feishu.groups..enabled` | Enable group | `true` | +| `channels.feishu.textChunkLimit` | Message chunk size | `2000` | +| `channels.feishu.mediaMaxMb` | Media size limit | `30` | +| `channels.feishu.streaming` | Enable streaming card output | `true` | +| `channels.feishu.blockStreaming` | Enable block streaming | `true` | + +--- + +## dmPolicy reference + +| Value | Behavior | +| ------------- | --------------------------------------------------------------- | +| `"pairing"` | **Default.** Unknown users get a pairing code; must be approved | +| `"allowlist"` | Only users in `allowFrom` can chat | +| `"open"` | Allow all users (requires `"*"` in allowFrom) | +| `"disabled"` | Disable DMs | + +--- + +## Supported message types + +### Receive + +- ✅ Text +- ✅ Rich text (post) +- ✅ Images +- ✅ Files +- ✅ Audio +- ✅ Video +- ✅ Stickers + +### Send + +- ✅ Text +- ✅ Images +- ✅ Files +- ✅ Audio +- ⚠️ Rich text (partial support) diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/googlechat.md b/backend/app/one_person_security_dept/openclaw/docs/channels/googlechat.md new file mode 100644 index 00000000..13729257 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/googlechat.md @@ -0,0 +1,255 @@ +--- +summary: "Google Chat app support status, capabilities, and configuration" +read_when: + - Working on Google Chat channel features +title: "Google Chat" +--- + +# Google Chat (Chat API) + +Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only). + +## Quick setup (beginner) + +1. Create a Google Cloud project and enable the **Google Chat API**. + - Go to: [Google Chat API Credentials](https://console.cloud.google.com/apis/api/chat.googleapis.com/credentials) + - Enable the API if it is not already enabled. +2. Create a **Service Account**: + - Press **Create Credentials** > **Service Account**. + - Name it whatever you want (e.g., `openclaw-chat`). + - Leave permissions blank (press **Continue**). + - Leave principals with access blank (press **Done**). +3. Create and download the **JSON Key**: + - In the list of service accounts, click on the one you just created. + - Go to the **Keys** tab. + - Click **Add Key** > **Create new key**. + - Select **JSON** and press **Create**. +4. Store the downloaded JSON file on your gateway host (e.g., `~/.openclaw/googlechat-service-account.json`). +5. Create a Google Chat app in the [Google Cloud Console Chat Configuration](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat): + - Fill in the **Application info**: + - **App name**: (e.g. `OpenClaw`) + - **Avatar URL**: (e.g. `https://openclaw.ai/logo.png`) + - **Description**: (e.g. `Personal AI Assistant`) + - Enable **Interactive features**. + - Under **Functionality**, check **Join spaces and group conversations**. + - Under **Connection settings**, select **HTTP endpoint URL**. + - Under **Triggers**, select **Use a common HTTP endpoint URL for all triggers** and set it to your gateway's public URL followed by `/googlechat`. + - _Tip: Run `openclaw status` to find your gateway's public URL._ + - Under **Visibility**, check **Make this Chat app available to specific people and groups in <Your Domain>**. + - Enter your email address (e.g. `user@example.com`) in the text box. + - Click **Save** at the bottom. +6. **Enable the app status**: + - After saving, **refresh the page**. + - Look for the **App status** section (usually near the top or bottom after saving). + - Change the status to **Live - available to users**. + - Click **Save** again. +7. Configure OpenClaw with the service account path + webhook audience: + - Env: `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE=/path/to/service-account.json` + - Or config: `channels.googlechat.serviceAccountFile: "/path/to/service-account.json"`. +8. Set the webhook audience type + value (matches your Chat app config). +9. Start the gateway. Google Chat will POST to your webhook path. + +## Add to Google Chat + +Once the gateway is running and your email is added to the visibility list: + +1. Go to [Google Chat](https://chat.google.com/). +2. Click the **+** (plus) icon next to **Direct Messages**. +3. In the search bar (where you usually add people), type the **App name** you configured in the Google Cloud Console. + - **Note**: The bot will _not_ appear in the "Marketplace" browse list because it is a private app. You must search for it by name. +4. Select your bot from the results. +5. Click **Add** or **Chat** to start a 1:1 conversation. +6. Send "Hello" to trigger the assistant! + +## Public URL (Webhook-only) + +Google Chat webhooks require a public HTTPS endpoint. For security, **only expose the `/googlechat` path** to the internet. Keep the OpenClaw dashboard and other sensitive endpoints on your private network. + +### Option A: Tailscale Funnel (Recommended) + +Use Tailscale Serve for the private dashboard and Funnel for the public webhook path. This keeps `/` private while exposing only `/googlechat`. + +1. **Check what address your gateway is bound to:** + + ```bash + ss -tlnp | grep 18789 + ``` + + Note the IP address (e.g., `127.0.0.1`, `0.0.0.0`, or your Tailscale IP like `100.x.x.x`). + +2. **Expose the dashboard to the tailnet only (port 8443):** + + ```bash + # If bound to localhost (127.0.0.1 or 0.0.0.0): + tailscale serve --bg --https 8443 http://127.0.0.1:18789 + + # If bound to Tailscale IP only (e.g., 100.106.161.80): + tailscale serve --bg --https 8443 http://100.106.161.80:18789 + ``` + +3. **Expose only the webhook path publicly:** + + ```bash + # If bound to localhost (127.0.0.1 or 0.0.0.0): + tailscale funnel --bg --set-path /googlechat http://127.0.0.1:18789/googlechat + + # If bound to Tailscale IP only (e.g., 100.106.161.80): + tailscale funnel --bg --set-path /googlechat http://100.106.161.80:18789/googlechat + ``` + +4. **Authorize the node for Funnel access:** + If prompted, visit the authorization URL shown in the output to enable Funnel for this node in your tailnet policy. + +5. **Verify the configuration:** + + ```bash + tailscale serve status + tailscale funnel status + ``` + +Your public webhook URL will be: +`https://..ts.net/googlechat` + +Your private dashboard stays tailnet-only: +`https://..ts.net:8443/` + +Use the public URL (without `:8443`) in the Google Chat app config. + +> Note: This configuration persists across reboots. To remove it later, run `tailscale funnel reset` and `tailscale serve reset`. + +### Option B: Reverse Proxy (Caddy) + +If you use a reverse proxy like Caddy, only proxy the specific path: + +```caddy +your-domain.com { + reverse_proxy /googlechat* localhost:18789 +} +``` + +With this config, any request to `your-domain.com/` will be ignored or returned as 404, while `your-domain.com/googlechat` is safely routed to OpenClaw. + +### Option C: Cloudflare Tunnel + +Configure your tunnel's ingress rules to only route the webhook path: + +- **Path**: `/googlechat` -> `http://localhost:18789/googlechat` +- **Default Rule**: HTTP 404 (Not Found) + +## How it works + +1. Google Chat sends webhook POSTs to the gateway. Each request includes an `Authorization: Bearer ` header. +2. OpenClaw verifies the token against the configured `audienceType` + `audience`: + - `audienceType: "app-url"` → audience is your HTTPS webhook URL. + - `audienceType: "project-number"` → audience is the Cloud project number. +3. Messages are routed by space: + - DMs use session key `agent::googlechat:dm:`. + - Spaces use session key `agent::googlechat:group:`. +4. DM access is pairing by default. Unknown senders receive a pairing code; approve with: + - `openclaw pairing approve googlechat ` +5. Group spaces require @-mention by default. Use `botUser` if mention detection needs the app’s user name. + +## Targets + +Use these identifiers for delivery and allowlists: + +- Direct messages: `users/` (recommended). +- Raw email `name@example.com` is mutable and only used for direct allowlist matching when `channels.googlechat.dangerouslyAllowNameMatching: true`. +- Deprecated: `users/` is treated as a user id, not an email allowlist. +- Spaces: `spaces/`. + +## Config highlights + +```json5 +{ + channels: { + googlechat: { + enabled: true, + serviceAccountFile: "/path/to/service-account.json", + audienceType: "app-url", + audience: "https://gateway.example.com/googlechat", + webhookPath: "/googlechat", + botUser: "users/1234567890", // optional; helps mention detection + dm: { + policy: "pairing", + allowFrom: ["users/1234567890"], + }, + groupPolicy: "allowlist", + groups: { + "spaces/AAAA": { + allow: true, + requireMention: true, + users: ["users/1234567890"], + systemPrompt: "Short answers only.", + }, + }, + actions: { reactions: true }, + typingIndicator: "message", + mediaMaxMb: 20, + }, + }, +} +``` + +Notes: + +- Service account credentials can also be passed inline with `serviceAccount` (JSON string). +- Default webhook path is `/googlechat` if `webhookPath` isn’t set. +- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode). +- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled. +- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth). +- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`). + +## Troubleshooting + +### 405 Method Not Allowed + +If Google Cloud Logs Explorer shows errors like: + +``` +status code: 405, reason phrase: HTTP error response: HTTP/1.1 405 Method Not Allowed +``` + +This means the webhook handler isn't registered. Common causes: + +1. **Channel not configured**: The `channels.googlechat` section is missing from your config. Verify with: + + ```bash + openclaw config get channels.googlechat + ``` + + If it returns "Config path not found", add the configuration (see [Config highlights](#config-highlights)). + +2. **Plugin not enabled**: Check plugin status: + + ```bash + openclaw plugins list | grep googlechat + ``` + + If it shows "disabled", add `plugins.entries.googlechat.enabled: true` to your config. + +3. **Gateway not restarted**: After adding config, restart the gateway: + + ```bash + openclaw gateway restart + ``` + +Verify the channel is running: + +```bash +openclaw channels status +# Should show: Google Chat default: enabled, configured, ... +``` + +### Other issues + +- Check `openclaw channels status --probe` for auth errors or missing audience config. +- If no messages arrive, confirm the Chat app's webhook URL + event subscriptions. +- If mention gating blocks replies, set `botUser` to the app's user resource name and verify `requireMention`. +- Use `openclaw logs --follow` while sending a test message to see if requests reach the gateway. + +Related docs: + +- [Gateway configuration](/gateway/configuration) +- [Security](/gateway/security) +- [Reactions](/tools/reactions) diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/grammy.md b/backend/app/one_person_security_dept/openclaw/docs/channels/grammy.md new file mode 100644 index 00000000..25c19711 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/grammy.md @@ -0,0 +1,31 @@ +--- +summary: "Telegram Bot API integration via grammY with setup notes" +read_when: + - Working on Telegram or grammY pathways +title: grammY +--- + +# grammY Integration (Telegram Bot API) + +# Why grammY + +- TS-first Bot API client with built-in long-poll + webhook helpers, middleware, error handling, rate limiter. +- Cleaner media helpers than hand-rolling fetch + FormData; supports all Bot API methods. +- Extensible: proxy support via custom fetch, session middleware (optional), type-safe context. + +# What we shipped + +- **Single client path:** fetch-based implementation removed; grammY is now the sole Telegram client (send + gateway) with the grammY throttler enabled by default. +- **Gateway:** `monitorTelegramProvider` builds a grammY `Bot`, wires mention/allowlist gating, media download via `getFile`/`download`, and delivers replies with `sendMessage/sendPhoto/sendVideo/sendAudio/sendDocument`. Supports long-poll or webhook via `webhookCallback`. +- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. +- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls). +- **Sessions:** direct chats collapse into the agent main session (`agent::`); groups use `agent::telegram:group:`; replies route back to the same channel. +- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`. +- **Live stream preview:** `channels.telegram.streaming` (`off | partial | block | progress`) sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming. +- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. + +Open questions + +- Optional grammY plugins (throttler) if we hit Bot API 429s. +- Add more structured media tests (stickers, voice notes). +- Make webhook listen port configurable (currently fixed to 8787 unless wired through the gateway). diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/group-messages.md b/backend/app/one_person_security_dept/openclaw/docs/channels/group-messages.md new file mode 100644 index 00000000..e6a00ab5 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/group-messages.md @@ -0,0 +1,84 @@ +--- +summary: "Behavior and config for WhatsApp group message handling (mentionPatterns are shared across surfaces)" +read_when: + - Changing group message rules or mentions +title: "Group Messages" +--- + +# Group messages (WhatsApp web channel) + +Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session. + +Note: `agents.list[].groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. For multi-agent setups, set `agents.list[].groupChat.mentionPatterns` per agent (or use `messages.groupChat.mentionPatterns` as a global fallback). + +## What’s implemented (2025-12-03) + +- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`channels.whatsapp.groups`) and overridden per group via `/activation`. When `channels.whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all). +- Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders). +- Per-group sessions: session keys look like `agent::whatsapp:group:` so commands such as `/verbose on` or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. +- Context injection: **pending-only** group messages (default 50) that _did not_ trigger a run are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. Messages already in the session are not re-injected. +- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking. +- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger. +- Group system prompt: on the first turn of a group session (and whenever `/activation` changes the mode) we inject a short blurb into the system prompt like `You are replying inside the WhatsApp group "". Group members: Alice (+44...), Bob (+43...), … Activation: trigger-only … Address the specific sender noted in the message context.` If metadata isn’t available we still tell the agent it’s a group chat. + +## Config example (WhatsApp) + +Add a `groupChat` block to `~/.openclaw/openclaw.json` so display-name pings work even when WhatsApp strips the visual `@` in the text body: + +```json5 +{ + channels: { + whatsapp: { + groups: { + "*": { requireMention: true }, + }, + }, + }, + agents: { + list: [ + { + id: "main", + groupChat: { + historyLimit: 50, + mentionPatterns: ["@?openclaw", "\\+?15555550123"], + }, + }, + ], + }, +} +``` + +Notes: + +- The regexes are case-insensitive; they cover a display-name ping like `@openclaw` and the raw number with or without `+`/spaces. +- WhatsApp still sends canonical mentions via `mentionedJids` when someone taps the contact, so the number fallback is rarely needed but is a useful safety net. + +### Activation command (owner-only) + +Use the group chat command: + +- `/activation mention` +- `/activation always` + +Only the owner number (from `channels.whatsapp.allowFrom`, or the bot’s own E.164 when unset) can change this. Send `/status` as a standalone message in the group to see the current activation mode. + +## How to use + +1. Add your WhatsApp account (the one running OpenClaw) to the group. +2. Say `@openclaw …` (or include the number). Only allowlisted senders can trigger it unless you set `groupPolicy: "open"`. +3. The agent prompt will include recent group context plus the trailing `[from: …]` marker so it can address the right person. +4. Session-level directives (`/verbose on`, `/think high`, `/new` or `/reset`, `/compact`) apply only to that group’s session; send them as standalone messages so they register. Your personal DM session remains independent. + +## Testing / verification + +- Manual smoke: + - Send an `@openclaw` ping in the group and confirm a reply that references the sender name. + - Send a second ping and verify the history block is included then cleared on the next turn. +- Check gateway logs (run with `--verbose`) to see `inbound web message` entries showing `from: ` and the `[from: …]` suffix. + +## Known considerations + +- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts. +- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response. +- Session store entries will appear as `agent::whatsapp:group:` in the session store (`~/.openclaw/agents//sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet. +- Typing indicators in groups follow `agents.defaults.typingMode` (default: `message` when unmentioned). diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/groups.md b/backend/app/one_person_security_dept/openclaw/docs/channels/groups.md new file mode 100644 index 00000000..8b8af64b --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/groups.md @@ -0,0 +1,378 @@ +--- +summary: "Group chat behavior across surfaces (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/Microsoft Teams/Zalo)" +read_when: + - Changing group chat behavior or mention gating +title: "Groups" +--- + +# Groups + +OpenClaw treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Microsoft Teams, Zalo. + +## Beginner intro (2 minutes) + +OpenClaw “lives” on your own messaging accounts. There is no separate WhatsApp bot user. +If **you** are in a group, OpenClaw can see that group and respond there. + +Default behavior: + +- Groups are restricted (`groupPolicy: "allowlist"`). +- Replies require a mention unless you explicitly disable mention gating. + +Translation: allowlisted senders can trigger OpenClaw by mentioning it. + +> TL;DR +> +> - **DM access** is controlled by `*.allowFrom`. +> - **Group access** is controlled by `*.groupPolicy` + allowlists (`*.groups`, `*.groupAllowFrom`). +> - **Reply triggering** is controlled by mention gating (`requireMention`, `/activation`). + +Quick flow (what happens to a group message): + +``` +groupPolicy? disabled -> drop +groupPolicy? allowlist -> group allowed? no -> drop +requireMention? yes -> mentioned? no -> store for context only +otherwise -> reply +``` + +![Group message flow](/images/groups-flow.svg) + +If you want... + +| Goal | What to set | +| -------------------------------------------- | ---------------------------------------------------------- | +| Allow all groups but only reply on @mentions | `groups: { "*": { requireMention: true } }` | +| Disable all group replies | `groupPolicy: "disabled"` | +| Only specific groups | `groups: { "": { ... } }` (no `"*"` key) | +| Only you can trigger in groups | `groupPolicy: "allowlist"`, `groupAllowFrom: ["+1555..."]` | + +## Session keys + +- Group sessions use `agent:::group:` session keys (rooms/channels use `agent:::channel:`). +- Telegram forum topics add `:topic:` to the group id so each topic has its own session. +- Direct chats use the main session (or per-sender if configured). +- Heartbeats are skipped for group sessions. + +## Pattern: personal DMs + public groups (single agent) + +Yes — this works well if your “personal” traffic is **DMs** and your “public” traffic is **groups**. + +Why: in single-agent mode, DMs typically land in the **main** session key (`agent:main:main`), while groups always use **non-main** session keys (`agent:main::group:`). If you enable sandboxing with `mode: "non-main"`, those group sessions run in Docker while your main DM session stays on-host. + +This gives you one agent “brain” (shared workspace + memory), but two execution postures: + +- **DMs**: full tools (host) +- **Groups**: sandbox + restricted tools (Docker) + +> If you need truly separate workspaces/personas (“personal” and “public” must never mix), use a second agent + bindings. See [Multi-Agent Routing](/concepts/multi-agent). + +Example (DMs on host, groups sandboxed + messaging-only tools): + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "non-main", // groups/channels are non-main -> sandboxed + scope: "session", // strongest isolation (one container per group/channel) + workspaceAccess: "none", + }, + }, + }, + tools: { + sandbox: { + tools: { + // If allow is non-empty, everything else is blocked (deny still wins). + allow: ["group:messaging", "group:sessions"], + deny: ["group:runtime", "group:fs", "group:ui", "nodes", "cron", "gateway"], + }, + }, + }, +} +``` + +Want “groups can only see folder X” instead of “no host access”? Keep `workspaceAccess: "none"` and mount only allowlisted paths into the sandbox: + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "non-main", + scope: "session", + workspaceAccess: "none", + docker: { + binds: [ + // hostPath:containerPath:mode + "/home/user/FriendsShared:/data:ro", + ], + }, + }, + }, + }, +} +``` + +Related: + +- Configuration keys and defaults: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox) +- Debugging why a tool is blocked: [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) +- Bind mounts details: [Sandboxing](/gateway/sandboxing#custom-bind-mounts) + +## Display labels + +- UI labels use `displayName` when available, formatted as `:`. +- `#room` is reserved for rooms/channels; group chats use `g-` (lowercase, spaces -> `-`, keep `#@+._-`). + +## Group policy + +Control how group/room messages are handled per channel: + +```json5 +{ + channels: { + whatsapp: { + groupPolicy: "disabled", // "open" | "disabled" | "allowlist" + groupAllowFrom: ["+15551234567"], + }, + telegram: { + groupPolicy: "disabled", + groupAllowFrom: ["123456789"], // numeric Telegram user id (wizard can resolve @username) + }, + signal: { + groupPolicy: "disabled", + groupAllowFrom: ["+15551234567"], + }, + imessage: { + groupPolicy: "disabled", + groupAllowFrom: ["chat_id:123"], + }, + msteams: { + groupPolicy: "disabled", + groupAllowFrom: ["user@org.com"], + }, + discord: { + groupPolicy: "allowlist", + guilds: { + GUILD_ID: { channels: { help: { allow: true } } }, + }, + }, + slack: { + groupPolicy: "allowlist", + channels: { "#general": { allow: true } }, + }, + matrix: { + groupPolicy: "allowlist", + groupAllowFrom: ["@owner:example.org"], + groups: { + "!roomId:example.org": { allow: true }, + "#alias:example.org": { allow: true }, + }, + }, + }, +} +``` + +| Policy | Behavior | +| ------------- | ------------------------------------------------------------ | +| `"open"` | Groups bypass allowlists; mention-gating still applies. | +| `"disabled"` | Block all group messages entirely. | +| `"allowlist"` | Only allow groups/rooms that match the configured allowlist. | + +Notes: + +- `groupPolicy` is separate from mention-gating (which requires @mentions). +- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`). +- Discord: allowlist uses `channels.discord.guilds..channels`. +- Slack: allowlist uses `channels.slack.channels`. +- Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported. +- Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`). +- Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive. +- Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked. +- Runtime safety: when a provider block is completely missing (`channels.` absent), group policy falls back to a fail-closed mode (typically `allowlist`) instead of inheriting `channels.defaults.groupPolicy`. + +Quick mental model (evaluation order for group messages): + +1. `groupPolicy` (open/disabled/allowlist) +2. group allowlists (`*.groups`, `*.groupAllowFrom`, channel-specific allowlist) +3. mention gating (`requireMention`, `/activation`) + +## Mention gating (default) + +Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`. + +Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams. + +```json5 +{ + channels: { + whatsapp: { + groups: { + "*": { requireMention: true }, + "123@g.us": { requireMention: false }, + }, + }, + telegram: { + groups: { + "*": { requireMention: true }, + "123456789": { requireMention: false }, + }, + }, + imessage: { + groups: { + "*": { requireMention: true }, + "123": { requireMention: false }, + }, + }, + }, + agents: { + list: [ + { + id: "main", + groupChat: { + mentionPatterns: ["@openclaw", "openclaw", "\\+15555550123"], + historyLimit: 50, + }, + }, + ], + }, +} +``` + +Notes: + +- `mentionPatterns` are case-insensitive regexes. +- Surfaces that provide explicit mentions still pass; patterns are a fallback. +- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group). +- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured). +- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel). +- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels..historyLimit` (or `channels..accounts.*.historyLimit`) for overrides. Set `0` to disable. + +## Group/channel tool restrictions (optional) + +Some channel configs support restricting which tools are available **inside a specific group/room/channel**. + +- `tools`: allow/deny tools for the whole group. +- `toolsBySender`: per-sender overrides within the group. + Use explicit key prefixes: + `id:`, `e164:`, `username:`, `name:`, and `"*"` wildcard. + Legacy unprefixed keys are still accepted and matched as `id:` only. + +Resolution order (most specific wins): + +1. group/channel `toolsBySender` match +2. group/channel `tools` +3. default (`"*"`) `toolsBySender` match +4. default (`"*"`) `tools` + +Example (Telegram): + +```json5 +{ + channels: { + telegram: { + groups: { + "*": { tools: { deny: ["exec"] } }, + "-1001234567890": { + tools: { deny: ["exec", "read", "write"] }, + toolsBySender: { + "id:123456789": { alsoAllow: ["exec"] }, + }, + }, + }, + }, + }, +} +``` + +Notes: + +- Group/channel tool restrictions are applied in addition to global/agent tool policy (deny still wins). +- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, MS Teams `teams.*.channels.*`). + +## Group allowlists + +When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior. + +Common intents (copy/paste): + +1. Disable all group replies + +```json5 +{ + channels: { whatsapp: { groupPolicy: "disabled" } }, +} +``` + +2. Allow only specific groups (WhatsApp) + +```json5 +{ + channels: { + whatsapp: { + groups: { + "123@g.us": { requireMention: true }, + "456@g.us": { requireMention: false }, + }, + }, + }, +} +``` + +3. Allow all groups but require mention (explicit) + +```json5 +{ + channels: { + whatsapp: { + groups: { "*": { requireMention: true } }, + }, + }, +} +``` + +4. Only the owner can trigger in groups (WhatsApp) + +```json5 +{ + channels: { + whatsapp: { + groupPolicy: "allowlist", + groupAllowFrom: ["+15551234567"], + groups: { "*": { requireMention: true } }, + }, + }, +} +``` + +## Activation (owner-only) + +Group owners can toggle per-group activation: + +- `/activation mention` +- `/activation always` + +Owner is determined by `channels.whatsapp.allowFrom` (or the bot’s self E.164 when unset). Send the command as a standalone message. Other surfaces currently ignore `/activation`. + +## Context fields + +Group inbound payloads set: + +- `ChatType=group` +- `GroupSubject` (if known) +- `GroupMembers` (if known) +- `WasMentioned` (mention gating result) +- Telegram forum topics also include `MessageThreadId` and `IsForum`. + +The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, avoid Markdown tables, and avoid typing literal `\n` sequences. + +## iMessage specifics + +- Prefer `chat_id:` when routing or allowlisting. +- List chats: `imsg chats --limit 20`. +- Group replies always go back to the same `chat_id`. + +## WhatsApp specifics + +See [Group messages](/channels/group-messages) for WhatsApp-only behavior (history injection, mention handling details). diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/imessage.md b/backend/app/one_person_security_dept/openclaw/docs/channels/imessage.md new file mode 100644 index 00000000..5720da17 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/imessage.md @@ -0,0 +1,367 @@ +--- +summary: "Legacy iMessage support via imsg (JSON-RPC over stdio). New setups should use BlueBubbles." +read_when: + - Setting up iMessage support + - Debugging iMessage send/receive +title: "iMessage" +--- + +# iMessage (legacy: imsg) + + +For new iMessage deployments, use BlueBubbles. + +The `imsg` integration is legacy and may be removed in a future release. + + +Status: legacy external CLI integration. Gateway spawns `imsg rpc` and communicates over JSON-RPC on stdio (no separate daemon/port). + + + + Preferred iMessage path for new setups. + + + iMessage DMs default to pairing mode. + + + Full iMessage field reference. + + + +## Quick setup + + + + + + +```bash +brew install steipete/tap/imsg +imsg rpc --help +``` + + + + + +```json5 +{ + channels: { + imessage: { + enabled: true, + cliPath: "/usr/local/bin/imsg", + dbPath: "/Users//Library/Messages/chat.db", + }, + }, +} +``` + + + + + +```bash +openclaw gateway +``` + + + + + +```bash +openclaw pairing list imessage +openclaw pairing approve imessage +``` + + Pairing requests expire after 1 hour. + + + + + + + OpenClaw only requires a stdio-compatible `cliPath`, so you can point `cliPath` at a wrapper script that SSHes to a remote Mac and runs `imsg`. + +```bash +#!/usr/bin/env bash +exec ssh -T gateway-host imsg "$@" +``` + + Recommended config when attachments are enabled: + +```json5 +{ + channels: { + imessage: { + enabled: true, + cliPath: "~/.openclaw/scripts/imsg-ssh", + remoteHost: "user@gateway-host", // used for SCP attachment fetches + includeAttachments: true, + // Optional: override allowed attachment roots. + // Defaults include /Users/*/Library/Messages/Attachments + attachmentRoots: ["/Users/*/Library/Messages/Attachments"], + remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"], + }, + }, +} +``` + + If `remoteHost` is not set, OpenClaw attempts to auto-detect it by parsing the SSH wrapper script. + `remoteHost` must be `host` or `user@host` (no spaces or SSH options). + OpenClaw uses strict host-key checking for SCP, so the relay host key must already exist in `~/.ssh/known_hosts`. + Attachment paths are validated against allowed roots (`attachmentRoots` / `remoteAttachmentRoots`). + + + + +## Requirements and permissions (macOS) + +- Messages must be signed in on the Mac running `imsg`. +- Full Disk Access is required for the process context running OpenClaw/`imsg` (Messages DB access). +- Automation permission is required to send messages through Messages.app. + + +Permissions are granted per process context. If gateway runs headless (LaunchAgent/SSH), run a one-time interactive command in that same context to trigger prompts: + +```bash +imsg chats --limit 1 +# or +imsg send "test" +``` + + + +## Access control and routing + + + + `channels.imessage.dmPolicy` controls direct messages: + + - `pairing` (default) + - `allowlist` + - `open` (requires `allowFrom` to include `"*"`) + - `disabled` + + Allowlist field: `channels.imessage.allowFrom`. + + Allowlist entries can be handles or chat targets (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`). + + + + + `channels.imessage.groupPolicy` controls group handling: + + - `allowlist` (default when configured) + - `open` + - `disabled` + + Group sender allowlist: `channels.imessage.groupAllowFrom`. + + Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available. + Runtime note: if `channels.imessage` is completely missing, runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). + + Mention gating for groups: + + - iMessage has no native mention metadata + - mention detection uses regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) + - with no configured patterns, mention gating cannot be enforced + + Control commands from authorized senders can bypass mention gating in groups. + + + + + - DMs use direct routing; groups use group routing. + - With default `session.dmScope=main`, iMessage DMs collapse into the agent main session. + - Group sessions are isolated (`agent::imessage:group:`). + - Replies route back to iMessage using originating channel/target metadata. + + Group-ish thread behavior: + + Some multi-participant iMessage threads can arrive with `is_group=false`. + If that `chat_id` is explicitly configured under `channels.imessage.groups`, OpenClaw treats it as group traffic (group gating + group session isolation). + + + + +## Deployment patterns + + + + Use a dedicated Apple ID and macOS user so bot traffic is isolated from your personal Messages profile. + + Typical flow: + + 1. Create/sign in a dedicated macOS user. + 2. Sign into Messages with the bot Apple ID in that user. + 3. Install `imsg` in that user. + 4. Create SSH wrapper so OpenClaw can run `imsg` in that user context. + 5. Point `channels.imessage.accounts..cliPath` and `.dbPath` to that user profile. + + First run may require GUI approvals (Automation + Full Disk Access) in that bot user session. + + + + + Common topology: + + - gateway runs on Linux/VM + - iMessage + `imsg` runs on a Mac in your tailnet + - `cliPath` wrapper uses SSH to run `imsg` + - `remoteHost` enables SCP attachment fetches + + Example: + +```json5 +{ + channels: { + imessage: { + enabled: true, + cliPath: "~/.openclaw/scripts/imsg-ssh", + remoteHost: "bot@mac-mini.tailnet-1234.ts.net", + includeAttachments: true, + dbPath: "/Users/bot/Library/Messages/chat.db", + }, + }, +} +``` + +```bash +#!/usr/bin/env bash +exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@" +``` + + Use SSH keys so both SSH and SCP are non-interactive. + Ensure the host key is trusted first (for example `ssh bot@mac-mini.tailnet-1234.ts.net`) so `known_hosts` is populated. + + + + + iMessage supports per-account config under `channels.imessage.accounts`. + + Each account can override fields such as `cliPath`, `dbPath`, `allowFrom`, `groupPolicy`, `mediaMaxMb`, history settings, and attachment root allowlists. + + + + +## Media, chunking, and delivery targets + + + + - inbound attachment ingestion is optional: `channels.imessage.includeAttachments` + - remote attachment paths can be fetched via SCP when `remoteHost` is set + - attachment paths must match allowed roots: + - `channels.imessage.attachmentRoots` (local) + - `channels.imessage.remoteAttachmentRoots` (remote SCP mode) + - default root pattern: `/Users/*/Library/Messages/Attachments` + - SCP uses strict host-key checking (`StrictHostKeyChecking=yes`) + - outbound media size uses `channels.imessage.mediaMaxMb` (default 16 MB) + + + + - text chunk limit: `channels.imessage.textChunkLimit` (default 4000) + - chunk mode: `channels.imessage.chunkMode` + - `length` (default) + - `newline` (paragraph-first splitting) + + + + Preferred explicit targets: + + - `chat_id:123` (recommended for stable routing) + - `chat_guid:...` + - `chat_identifier:...` + + Handle targets are also supported: + + - `imessage:+1555...` + - `sms:+1555...` + - `user@example.com` + +```bash +imsg chats --limit 20 +``` + + + + +## Config writes + +iMessage allows channel-initiated config writes by default (for `/config set|unset` when `commands.config: true`). + +Disable: + +```json5 +{ + channels: { + imessage: { + configWrites: false, + }, + }, +} +``` + +## Troubleshooting + + + + Validate the binary and RPC support: + +```bash +imsg rpc --help +openclaw channels status --probe +``` + + If probe reports RPC unsupported, update `imsg`. + + + + + Check: + + - `channels.imessage.dmPolicy` + - `channels.imessage.allowFrom` + - pairing approvals (`openclaw pairing list imessage`) + + + + + Check: + + - `channels.imessage.groupPolicy` + - `channels.imessage.groupAllowFrom` + - `channels.imessage.groups` allowlist behavior + - mention pattern configuration (`agents.list[].groupChat.mentionPatterns`) + + + + + Check: + + - `channels.imessage.remoteHost` + - `channels.imessage.remoteAttachmentRoots` + - SSH/SCP key auth from the gateway host + - host key exists in `~/.ssh/known_hosts` on the gateway host + - remote path readability on the Mac running Messages + + + + + Re-run in an interactive GUI terminal in the same user/session context and approve prompts: + +```bash +imsg chats --limit 1 +imsg send "test" +``` + + Confirm Full Disk Access + Automation are granted for the process context that runs OpenClaw/`imsg`. + + + + +## Configuration reference pointers + +- [Configuration reference - iMessage](/gateway/configuration-reference#imessage) +- [Gateway configuration](/gateway/configuration) +- [Pairing](/channels/pairing) +- [BlueBubbles](/channels/bluebubbles) diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/index.md b/backend/app/one_person_security_dept/openclaw/docs/channels/index.md new file mode 100644 index 00000000..f5ae8761 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/index.md @@ -0,0 +1,48 @@ +--- +summary: "Messaging platforms OpenClaw can connect to" +read_when: + - You want to choose a chat channel for OpenClaw + - You need a quick overview of supported messaging platforms +title: "Chat Channels" +--- + +# Chat Channels + +OpenClaw can talk to you on any chat app you already use. Each channel connects via the Gateway. +Text is supported everywhere; media and reactions vary by channel. + +## Supported channels + +- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing. +- [Telegram](/channels/telegram) — Bot API via grammY; supports groups. +- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs. +- [IRC](/channels/irc) — Classic IRC servers; channels + DMs with pairing/allowlist controls. +- [Slack](/channels/slack) — Bolt SDK; workspace apps. +- [Feishu](/channels/feishu) — Feishu/Lark bot via WebSocket (plugin, installed separately). +- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook. +- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately). +- [Signal](/channels/signal) — signal-cli; privacy-focused. +- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe). +- [iMessage (legacy)](/channels/imessage) — Legacy macOS integration via imsg CLI (deprecated, use BlueBubbles for new setups). +- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). +- [Synology Chat](/channels/synology-chat) — Synology NAS Chat via outgoing+incoming webhooks (plugin, installed separately). +- [LINE](/channels/line) — LINE Messaging API bot (plugin, installed separately). +- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately). +- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). +- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately). +- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately). +- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately). +- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). +- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). +- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket. + +## Notes + +- Channels can run simultaneously; configure multiple and OpenClaw will route per chat. +- Fastest setup is usually **Telegram** (simple bot token). WhatsApp requires QR pairing and + stores more state on disk. +- Group behavior varies by channel; see [Groups](/channels/groups). +- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security). +- Telegram internals: [grammY notes](/channels/grammy). +- Troubleshooting: [Channel troubleshooting](/channels/troubleshooting). +- Model providers are documented separately; see [Model Providers](/providers/models). diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/irc.md b/backend/app/one_person_security_dept/openclaw/docs/channels/irc.md new file mode 100644 index 00000000..00403b6f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/irc.md @@ -0,0 +1,241 @@ +--- +title: IRC +description: Connect OpenClaw to IRC channels and direct messages. +summary: "IRC plugin setup, access controls, and troubleshooting" +read_when: + - You want to connect OpenClaw to IRC channels or DMs + - You are configuring IRC allowlists, group policy, or mention gating +--- + +Use IRC when you want OpenClaw in classic channels (`#room`) and direct messages. +IRC ships as an extension plugin, but it is configured in the main config under `channels.irc`. + +## Quick start + +1. Enable IRC config in `~/.openclaw/openclaw.json`. +2. Set at least: + +```json +{ + "channels": { + "irc": { + "enabled": true, + "host": "irc.libera.chat", + "port": 6697, + "tls": true, + "nick": "openclaw-bot", + "channels": ["#openclaw"] + } + } +} +``` + +3. Start/restart gateway: + +```bash +openclaw gateway run +``` + +## Security defaults + +- `channels.irc.dmPolicy` defaults to `"pairing"`. +- `channels.irc.groupPolicy` defaults to `"allowlist"`. +- With `groupPolicy="allowlist"`, set `channels.irc.groups` to define allowed channels. +- Use TLS (`channels.irc.tls=true`) unless you intentionally accept plaintext transport. + +## Access control + +There are two separate “gates” for IRC channels: + +1. **Channel access** (`groupPolicy` + `groups`): whether the bot accepts messages from a channel at all. +2. **Sender access** (`groupAllowFrom` / per-channel `groups["#channel"].allowFrom`): who is allowed to trigger the bot inside that channel. + +Config keys: + +- DM allowlist (DM sender access): `channels.irc.allowFrom` +- Group sender allowlist (channel sender access): `channels.irc.groupAllowFrom` +- Per-channel controls (channel + sender + mention rules): `channels.irc.groups["#channel"]` +- `channels.irc.groupPolicy="open"` allows unconfigured channels (**still mention-gated by default**) + +Allowlist entries should use stable sender identities (`nick!user@host`). +Bare nick matching is mutable and only enabled when `channels.irc.dangerouslyAllowNameMatching: true`. + +### Common gotcha: `allowFrom` is for DMs, not channels + +If you see logs like: + +- `irc: drop group sender alice!ident@host (policy=allowlist)` + +…it means the sender wasn’t allowed for **group/channel** messages. Fix it by either: + +- setting `channels.irc.groupAllowFrom` (global for all channels), or +- setting per-channel sender allowlists: `channels.irc.groups["#channel"].allowFrom` + +Example (allow anyone in `#tuirc-dev` to talk to the bot): + +```json5 +{ + channels: { + irc: { + groupPolicy: "allowlist", + groups: { + "#tuirc-dev": { allowFrom: ["*"] }, + }, + }, + }, +} +``` + +## Reply triggering (mentions) + +Even if a channel is allowed (via `groupPolicy` + `groups`) and the sender is allowed, OpenClaw defaults to **mention-gating** in group contexts. + +That means you may see logs like `drop channel … (missing-mention)` unless the message includes a mention pattern that matches the bot. + +To make the bot reply in an IRC channel **without needing a mention**, disable mention gating for that channel: + +```json5 +{ + channels: { + irc: { + groupPolicy: "allowlist", + groups: { + "#tuirc-dev": { + requireMention: false, + allowFrom: ["*"], + }, + }, + }, + }, +} +``` + +Or to allow **all** IRC channels (no per-channel allowlist) and still reply without mentions: + +```json5 +{ + channels: { + irc: { + groupPolicy: "open", + groups: { + "*": { requireMention: false, allowFrom: ["*"] }, + }, + }, + }, +} +``` + +## Security note (recommended for public channels) + +If you allow `allowFrom: ["*"]` in a public channel, anyone can prompt the bot. +To reduce risk, restrict tools for that channel. + +### Same tools for everyone in the channel + +```json5 +{ + channels: { + irc: { + groups: { + "#tuirc-dev": { + allowFrom: ["*"], + tools: { + deny: ["group:runtime", "group:fs", "gateway", "nodes", "cron", "browser"], + }, + }, + }, + }, + }, +} +``` + +### Different tools per sender (owner gets more power) + +Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your nick: + +```json5 +{ + channels: { + irc: { + groups: { + "#tuirc-dev": { + allowFrom: ["*"], + toolsBySender: { + "*": { + deny: ["group:runtime", "group:fs", "gateway", "nodes", "cron", "browser"], + }, + "id:eigen": { + deny: ["gateway", "nodes", "cron"], + }, + }, + }, + }, + }, + }, +} +``` + +Notes: + +- `toolsBySender` keys should use `id:` for IRC sender identity values: + `id:eigen` or `id:eigen!~eigen@174.127.248.171` for stronger matching. +- Legacy unprefixed keys are still accepted and matched as `id:` only. +- The first matching sender policy wins; `"*"` is the wildcard fallback. + +For more on group access vs mention-gating (and how they interact), see: [/channels/groups](/channels/groups). + +## NickServ + +To identify with NickServ after connect: + +```json +{ + "channels": { + "irc": { + "nickserv": { + "enabled": true, + "service": "NickServ", + "password": "your-nickserv-password" + } + } + } +} +``` + +Optional one-time registration on connect: + +```json +{ + "channels": { + "irc": { + "nickserv": { + "register": true, + "registerEmail": "bot@example.com" + } + } + } +} +``` + +Disable `register` after the nick is registered to avoid repeated REGISTER attempts. + +## Environment variables + +Default account supports: + +- `IRC_HOST` +- `IRC_PORT` +- `IRC_TLS` +- `IRC_NICK` +- `IRC_USERNAME` +- `IRC_REALNAME` +- `IRC_PASSWORD` +- `IRC_CHANNELS` (comma-separated) +- `IRC_NICKSERV_PASSWORD` +- `IRC_NICKSERV_REGISTER_EMAIL` + +## Troubleshooting + +- If the bot connects but never replies in channels, verify `channels.irc.groups` **and** whether mention-gating is dropping messages (`missing-mention`). If you want it to reply without pings, set `requireMention:false` for the channel. +- If login fails, verify nick availability and server password. +- If TLS fails on a custom network, verify host/port and certificate setup. diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/line.md b/backend/app/one_person_security_dept/openclaw/docs/channels/line.md new file mode 100644 index 00000000..b87cbd3f --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/line.md @@ -0,0 +1,187 @@ +--- +summary: "LINE Messaging API plugin setup, config, and usage" +read_when: + - You want to connect OpenClaw to LINE + - You need LINE webhook + credential setup + - You want LINE-specific message options +title: LINE +--- + +# LINE (plugin) + +LINE connects to OpenClaw via the LINE Messaging API. The plugin runs as a webhook +receiver on the gateway and uses your channel access token + channel secret for +authentication. + +Status: supported via plugin. Direct messages, group chats, media, locations, Flex +messages, template messages, and quick replies are supported. Reactions and threads +are not supported. + +## Plugin required + +Install the LINE plugin: + +```bash +openclaw plugins install @openclaw/line +``` + +Local checkout (when running from a git repo): + +```bash +openclaw plugins install ./extensions/line +``` + +## Setup + +1. Create a LINE Developers account and open the Console: + [https://developers.line.biz/console/](https://developers.line.biz/console/) +2. Create (or pick) a Provider and add a **Messaging API** channel. +3. Copy the **Channel access token** and **Channel secret** from the channel settings. +4. Enable **Use webhook** in the Messaging API settings. +5. Set the webhook URL to your gateway endpoint (HTTPS required): + +``` +https://gateway-host/line/webhook +``` + +The gateway responds to LINE’s webhook verification (GET) and inbound events (POST). +If you need a custom path, set `channels.line.webhookPath` or +`channels.line.accounts..webhookPath` and update the URL accordingly. + +## Configure + +Minimal config: + +```json5 +{ + channels: { + line: { + enabled: true, + channelAccessToken: "LINE_CHANNEL_ACCESS_TOKEN", + channelSecret: "LINE_CHANNEL_SECRET", + dmPolicy: "pairing", + }, + }, +} +``` + +Env vars (default account only): + +- `LINE_CHANNEL_ACCESS_TOKEN` +- `LINE_CHANNEL_SECRET` + +Token/secret files: + +```json5 +{ + channels: { + line: { + tokenFile: "/path/to/line-token.txt", + secretFile: "/path/to/line-secret.txt", + }, + }, +} +``` + +Multiple accounts: + +```json5 +{ + channels: { + line: { + accounts: { + marketing: { + channelAccessToken: "...", + channelSecret: "...", + webhookPath: "/line/marketing", + }, + }, + }, + }, +} +``` + +## Access control + +Direct messages default to pairing. Unknown senders get a pairing code and their +messages are ignored until approved. + +```bash +openclaw pairing list line +openclaw pairing approve line +``` + +Allowlists and policies: + +- `channels.line.dmPolicy`: `pairing | allowlist | open | disabled` +- `channels.line.allowFrom`: allowlisted LINE user IDs for DMs +- `channels.line.groupPolicy`: `allowlist | open | disabled` +- `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups +- Per-group overrides: `channels.line.groups..allowFrom` +- Runtime note: if `channels.line` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). + +LINE IDs are case-sensitive. Valid IDs look like: + +- User: `U` + 32 hex chars +- Group: `C` + 32 hex chars +- Room: `R` + 32 hex chars + +## Message behavior + +- Text is chunked at 5000 characters. +- Markdown formatting is stripped; code blocks and tables are converted into Flex + cards when possible. +- Streaming responses are buffered; LINE receives full chunks with a loading + animation while the agent works. +- Media downloads are capped by `channels.line.mediaMaxMb` (default 10). + +## Channel data (rich messages) + +Use `channelData.line` to send quick replies, locations, Flex cards, or template +messages. + +```json5 +{ + text: "Here you go", + channelData: { + line: { + quickReplies: ["Status", "Help"], + location: { + title: "Office", + address: "123 Main St", + latitude: 35.681236, + longitude: 139.767125, + }, + flexMessage: { + altText: "Status card", + contents: { + /* Flex payload */ + }, + }, + templateMessage: { + type: "confirm", + text: "Proceed?", + confirmLabel: "Yes", + confirmData: "yes", + cancelLabel: "No", + cancelData: "no", + }, + }, + }, +} +``` + +The LINE plugin also ships a `/card` command for Flex message presets: + +``` +/card info "Welcome" "Thanks for joining!" +``` + +## Troubleshooting + +- **Webhook verification fails:** ensure the webhook URL is HTTPS and the + `channelSecret` matches the LINE console. +- **No inbound events:** confirm the webhook path matches `channels.line.webhookPath` + and that the gateway is reachable from LINE. +- **Media download errors:** raise `channels.line.mediaMaxMb` if media exceeds the + default limit. diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/location.md b/backend/app/one_person_security_dept/openclaw/docs/channels/location.md new file mode 100644 index 00000000..103f5766 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/location.md @@ -0,0 +1,56 @@ +--- +summary: "Inbound channel location parsing (Telegram + WhatsApp) and context fields" +read_when: + - Adding or modifying channel location parsing + - Using location context fields in agent prompts or tools +title: "Channel Location Parsing" +--- + +# Channel location parsing + +OpenClaw normalizes shared locations from chat channels into: + +- human-readable text appended to the inbound body, and +- structured fields in the auto-reply context payload. + +Currently supported: + +- **Telegram** (location pins + venues + live locations) +- **WhatsApp** (locationMessage + liveLocationMessage) +- **Matrix** (`m.location` with `geo_uri`) + +## Text formatting + +Locations are rendered as friendly lines without brackets: + +- Pin: + - `📍 48.858844, 2.294351 ±12m` +- Named place: + - `📍 Eiffel Tower — Champ de Mars, Paris (48.858844, 2.294351 ±12m)` +- Live share: + - `🛰 Live location: 48.858844, 2.294351 ±12m` + +If the channel includes a caption/comment, it is appended on the next line: + +``` +📍 48.858844, 2.294351 ±12m +Meet here +``` + +## Context fields + +When a location is present, these fields are added to `ctx`: + +- `LocationLat` (number) +- `LocationLon` (number) +- `LocationAccuracy` (number, meters; optional) +- `LocationName` (string; optional) +- `LocationAddress` (string; optional) +- `LocationSource` (`pin | place | live`) +- `LocationIsLive` (boolean) + +## Channel notes + +- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`. +- **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line. +- **Matrix**: `geo_uri` is parsed as a pin location; altitude is ignored and `LocationIsLive` is always false. diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/matrix.md b/backend/app/one_person_security_dept/openclaw/docs/channels/matrix.md new file mode 100644 index 00000000..9bb56d1d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/matrix.md @@ -0,0 +1,303 @@ +--- +summary: "Matrix support status, capabilities, and configuration" +read_when: + - Working on Matrix channel features +title: "Matrix" +--- + +# Matrix (plugin) + +Matrix is an open, decentralized messaging protocol. OpenClaw connects as a Matrix **user** +on any homeserver, so you need a Matrix account for the bot. Once it is logged in, you can DM +the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too, +but it requires E2EE to be enabled. + +Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions, +polls (send + poll-start as text), location, and E2EE (with crypto support). + +## Plugin required + +Matrix ships as a plugin and is not bundled with the core install. + +Install via CLI (npm registry): + +```bash +openclaw plugins install @openclaw/matrix +``` + +Local checkout (when running from a git repo): + +```bash +openclaw plugins install ./extensions/matrix +``` + +If you choose Matrix during configure/onboarding and a git checkout is detected, +OpenClaw will offer the local install path automatically. + +Details: [Plugins](/tools/plugin) + +## Setup + +1. Install the Matrix plugin: + - From npm: `openclaw plugins install @openclaw/matrix` + - From a local checkout: `openclaw plugins install ./extensions/matrix` +2. Create a Matrix account on a homeserver: + - Browse hosting options at [https://matrix.org/ecosystem/hosting/](https://matrix.org/ecosystem/hosting/) + - Or host it yourself. +3. Get an access token for the bot account: + - Use the Matrix login API with `curl` at your home server: + + ```bash + curl --request POST \ + --url https://matrix.example.org/_matrix/client/v3/login \ + --header 'Content-Type: application/json' \ + --data '{ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "your-user-name" + }, + "password": "your-password" + }' + ``` + + - Replace `matrix.example.org` with your homeserver URL. + - Or set `channels.matrix.userId` + `channels.matrix.password`: OpenClaw calls the same + login endpoint, stores the access token in `~/.openclaw/credentials/matrix/credentials.json`, + and reuses it on next start. + +4. Configure credentials: + - Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`) + - Or config: `channels.matrix.*` + - If both are set, config takes precedence. + - With access token: user ID is fetched automatically via `/whoami`. + - When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`). +5. Restart the gateway (or finish onboarding). +6. Start a DM with the bot or invite it to a room from any Matrix client + (Element, Beeper, etc.; see [https://matrix.org/ecosystem/clients/](https://matrix.org/ecosystem/clients/)). Beeper requires E2EE, + so set `channels.matrix.encryption: true` and verify the device. + +Minimal config (access token, user ID auto-fetched): + +```json5 +{ + channels: { + matrix: { + enabled: true, + homeserver: "https://matrix.example.org", + accessToken: "syt_***", + dm: { policy: "pairing" }, + }, + }, +} +``` + +E2EE config (end to end encryption enabled): + +```json5 +{ + channels: { + matrix: { + enabled: true, + homeserver: "https://matrix.example.org", + accessToken: "syt_***", + encryption: true, + dm: { policy: "pairing" }, + }, + }, +} +``` + +## Encryption (E2EE) + +End-to-end encryption is **supported** via the Rust crypto SDK. + +Enable with `channels.matrix.encryption: true`: + +- If the crypto module loads, encrypted rooms are decrypted automatically. +- Outbound media is encrypted when sending to encrypted rooms. +- On first connection, OpenClaw requests device verification from your other sessions. +- Verify the device in another Matrix client (Element, etc.) to enable key sharing. +- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt; + OpenClaw logs a warning. +- If you see missing crypto module errors (for example, `@matrix-org/matrix-sdk-crypto-nodejs-*`), + allow build scripts for `@matrix-org/matrix-sdk-crypto-nodejs` and run + `pnpm rebuild @matrix-org/matrix-sdk-crypto-nodejs` or fetch the binary with + `node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`. + +Crypto state is stored per account + access token in +`~/.openclaw/matrix/accounts//__//crypto/` +(SQLite database). Sync state lives alongside it in `bot-storage.json`. +If the access token (device) changes, a new store is created and the bot must be +re-verified for encrypted rooms. + +**Device verification:** +When E2EE is enabled, the bot will request verification from your other sessions on startup. +Open Element (or another client) and approve the verification request to establish trust. +Once verified, the bot can decrypt messages in encrypted rooms. + +## Multi-account + +Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. + +Each account runs as a separate Matrix user on any homeserver. Per-account config +inherits from the top-level `channels.matrix` settings and can override any option +(DM policy, groups, encryption, etc.). + +```json5 +{ + channels: { + matrix: { + enabled: true, + dm: { policy: "pairing" }, + accounts: { + assistant: { + name: "Main assistant", + homeserver: "https://matrix.example.org", + accessToken: "syt_assistant_***", + encryption: true, + }, + alerts: { + name: "Alerts bot", + homeserver: "https://matrix.example.org", + accessToken: "syt_alerts_***", + dm: { policy: "allowlist", allowFrom: ["@admin:example.org"] }, + }, + }, + }, + }, +} +``` + +Notes: + +- Account startup is serialized to avoid race conditions with concurrent module imports. +- Env variables (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, etc.) only apply to the **default** account. +- Base channel settings (DM policy, group policy, mention gating, etc.) apply to all accounts unless overridden per account. +- Use `bindings[].match.accountId` to route each account to a different agent. +- Crypto state is stored per account + access token (separate key stores per account). + +## Routing model + +- Replies always go back to Matrix. +- DMs share the agent's main session; rooms map to group sessions. + +## Access control (DMs) + +- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code. +- Approve via: + - `openclaw pairing list matrix` + - `openclaw pairing approve matrix ` +- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`. +- `channels.matrix.dm.allowFrom` accepts full Matrix user IDs (example: `@user:server`). The wizard resolves display names to user IDs when directory search finds a single exact match. +- Do not use display names or bare localparts (example: `"Alice"` or `"alice"`). They are ambiguous and are ignored for allowlist matching. Use full `@user:server` IDs. + +## Rooms (groups) + +- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. +- Runtime note: if `channels.matrix` is completely missing, runtime falls back to `groupPolicy="allowlist"` for room checks (even if `channels.defaults.groupPolicy` is set). +- Allowlist rooms with `channels.matrix.groups` (room IDs or aliases; names are resolved to IDs when directory search finds a single exact match): + +```json5 +{ + channels: { + matrix: { + groupPolicy: "allowlist", + groups: { + "!roomId:example.org": { allow: true }, + "#alias:example.org": { allow: true }, + }, + groupAllowFrom: ["@owner:example.org"], + }, + }, +} +``` + +- `requireMention: false` enables auto-reply in that room. +- `groups."*"` can set defaults for mention gating across rooms. +- `groupAllowFrom` restricts which senders can trigger the bot in rooms (full Matrix user IDs). +- Per-room `users` allowlists can further restrict senders inside a specific room (use full Matrix user IDs). +- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names only on an exact, unique match. +- On startup, OpenClaw resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are ignored for allowlist matching. +- Invites are auto-joined by default; control with `channels.matrix.autoJoin` and `channels.matrix.autoJoinAllowlist`. +- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist). +- Legacy key: `channels.matrix.rooms` (same shape as `groups`). + +## Threads + +- Reply threading is supported. +- `channels.matrix.threadReplies` controls whether replies stay in threads: + - `off`, `inbound` (default), `always` +- `channels.matrix.replyToMode` controls reply-to metadata when not replying in a thread: + - `off` (default), `first`, `all` + +## Capabilities + +| Feature | Status | +| --------------- | ------------------------------------------------------------------------------------- | +| Direct messages | ✅ Supported | +| Rooms | ✅ Supported | +| Threads | ✅ Supported | +| Media | ✅ Supported | +| E2EE | ✅ Supported (crypto module required) | +| Reactions | ✅ Supported (send/read via tools) | +| Polls | ✅ Send supported; inbound poll starts are converted to text (responses/ends ignored) | +| Location | ✅ Supported (geo URI; altitude ignored) | +| Native commands | ✅ Supported | + +## Troubleshooting + +Run this ladder first: + +```bash +openclaw status +openclaw gateway status +openclaw logs --follow +openclaw doctor +openclaw channels status --probe +``` + +Then confirm DM pairing state if needed: + +```bash +openclaw pairing list matrix +``` + +Common failures: + +- Logged in but room messages ignored: room blocked by `groupPolicy` or room allowlist. +- DMs ignored: sender pending approval when `channels.matrix.dm.policy="pairing"`. +- Encrypted rooms fail: crypto support or encryption settings mismatch. + +For triage flow: [/channels/troubleshooting](/channels/troubleshooting). + +## Configuration reference (Matrix) + +Full configuration: [Configuration](/gateway/configuration) + +Provider options: + +- `channels.matrix.enabled`: enable/disable channel startup. +- `channels.matrix.homeserver`: homeserver URL. +- `channels.matrix.userId`: Matrix user ID (optional with access token). +- `channels.matrix.accessToken`: access token. +- `channels.matrix.password`: password for login (token stored). +- `channels.matrix.deviceName`: device display name. +- `channels.matrix.encryption`: enable E2EE (default: false). +- `channels.matrix.initialSyncLimit`: initial sync limit. +- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound). +- `channels.matrix.textChunkLimit`: outbound text chunk size (chars). +- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. +- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing). +- `channels.matrix.dm.allowFrom`: DM allowlist (full Matrix user IDs). `open` requires `"*"`. The wizard resolves names to IDs when possible. +- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist). +- `channels.matrix.groupAllowFrom`: allowlisted senders for group messages (full Matrix user IDs). +- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms. +- `channels.matrix.groups`: group allowlist + per-room settings map. +- `channels.matrix.rooms`: legacy group allowlist/config. +- `channels.matrix.replyToMode`: reply-to mode for threads/tags. +- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB). +- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always). +- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join. +- `channels.matrix.accounts`: multi-account configuration keyed by account ID (each account inherits top-level settings). +- `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo). diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/mattermost.md b/backend/app/one_person_security_dept/openclaw/docs/channels/mattermost.md new file mode 100644 index 00000000..702f72cc --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/mattermost.md @@ -0,0 +1,160 @@ +--- +summary: "Mattermost bot setup and OpenClaw config" +read_when: + - Setting up Mattermost + - Debugging Mattermost routing +title: "Mattermost" +--- + +# Mattermost (plugin) + +Status: supported via plugin (bot token + WebSocket events). Channels, groups, and DMs are supported. +Mattermost is a self-hostable team messaging platform; see the official site at +[mattermost.com](https://mattermost.com) for product details and downloads. + +## Plugin required + +Mattermost ships as a plugin and is not bundled with the core install. + +Install via CLI (npm registry): + +```bash +openclaw plugins install @openclaw/mattermost +``` + +Local checkout (when running from a git repo): + +```bash +openclaw plugins install ./extensions/mattermost +``` + +If you choose Mattermost during configure/onboarding and a git checkout is detected, +OpenClaw will offer the local install path automatically. + +Details: [Plugins](/tools/plugin) + +## Quick setup + +1. Install the Mattermost plugin. +2. Create a Mattermost bot account and copy the **bot token**. +3. Copy the Mattermost **base URL** (e.g., `https://chat.example.com`). +4. Configure OpenClaw and start the gateway. + +Minimal config: + +```json5 +{ + channels: { + mattermost: { + enabled: true, + botToken: "mm-token", + baseUrl: "https://chat.example.com", + dmPolicy: "pairing", + }, + }, +} +``` + +## Environment variables (default account) + +Set these on the gateway host if you prefer env vars: + +- `MATTERMOST_BOT_TOKEN=...` +- `MATTERMOST_URL=https://chat.example.com` + +Env vars apply only to the **default** account (`default`). Other accounts must use config values. + +## Chat modes + +Mattermost responds to DMs automatically. Channel behavior is controlled by `chatmode`: + +- `oncall` (default): respond only when @mentioned in channels. +- `onmessage`: respond to every channel message. +- `onchar`: respond when a message starts with a trigger prefix. + +Config example: + +```json5 +{ + channels: { + mattermost: { + chatmode: "onchar", + oncharPrefixes: [">", "!"], + }, + }, +} +``` + +Notes: + +- `onchar` still responds to explicit @mentions. +- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred. + +## Access control (DMs) + +- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code). +- Approve via: + - `openclaw pairing list mattermost` + - `openclaw pairing approve mattermost ` +- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`. + +## Channels (groups) + +- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated). +- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs recommended). +- `@username` matching is mutable and only enabled when `channels.mattermost.dangerouslyAllowNameMatching: true`. +- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated). +- Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). + +## Targets for outbound delivery + +Use these target formats with `openclaw message send` or cron/webhooks: + +- `channel:` for a channel +- `user:` for a DM +- `@username` for a DM (resolved via the Mattermost API) + +Bare IDs are treated as channels. + +## Reactions (message tool) + +- Use `message action=react` with `channel=mattermost`. +- `messageId` is the Mattermost post id. +- `emoji` accepts names like `thumbsup` or `:+1:` (colons are optional). +- Set `remove=true` (boolean) to remove a reaction. +- Reaction add/remove events are forwarded as system events to the routed agent session. + +Examples: + +``` +message action=react channel=mattermost target=channel: messageId= emoji=thumbsup +message action=react channel=mattermost target=channel: messageId= emoji=thumbsup remove=true +``` + +Config: + +- `channels.mattermost.actions.reactions`: enable/disable reaction actions (default true). +- Per-account override: `channels.mattermost.accounts..actions.reactions`. + +## Multi-account + +Mattermost supports multiple accounts under `channels.mattermost.accounts`: + +```json5 +{ + channels: { + mattermost: { + accounts: { + default: { name: "Primary", botToken: "mm-token", baseUrl: "https://chat.example.com" }, + alerts: { name: "Alerts", botToken: "mm-token-2", baseUrl: "https://alerts.example.com" }, + }, + }, + }, +} +``` + +## Troubleshooting + +- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`. +- Auth errors: check the bot token, base URL, and whether the account is enabled. +- Multi-account issues: env vars only apply to the `default` account. diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/msteams.md b/backend/app/one_person_security_dept/openclaw/docs/channels/msteams.md new file mode 100644 index 00000000..9c4a583e --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/msteams.md @@ -0,0 +1,776 @@ +--- +summary: "Microsoft Teams bot support status, capabilities, and configuration" +read_when: + - Working on MS Teams channel features +title: "Microsoft Teams" +--- + +# Microsoft Teams (plugin) + +> "Abandon all hope, ye who enter here." + +Updated: 2026-01-21 + +Status: text + DM attachments are supported; channel/group file sending requires `sharePointSiteId` + Graph permissions (see [Sending files in group chats](#sending-files-in-group-chats)). Polls are sent via Adaptive Cards. + +## Plugin required + +Microsoft Teams ships as a plugin and is not bundled with the core install. + +**Breaking change (2026.1.15):** MS Teams moved out of core. If you use it, you must install the plugin. + +Explainable: keeps core installs lighter and lets MS Teams dependencies update independently. + +Install via CLI (npm registry): + +```bash +openclaw plugins install @openclaw/msteams +``` + +Local checkout (when running from a git repo): + +```bash +openclaw plugins install ./extensions/msteams +``` + +If you choose Teams during configure/onboarding and a git checkout is detected, +OpenClaw will offer the local install path automatically. + +Details: [Plugins](/tools/plugin) + +## Quick setup (beginner) + +1. Install the Microsoft Teams plugin. +2. Create an **Azure Bot** (App ID + client secret + tenant ID). +3. Configure OpenClaw with those credentials. +4. Expose `/api/messages` (port 3978 by default) via a public URL or tunnel. +5. Install the Teams app package and start the gateway. + +Minimal config: + +```json5 +{ + channels: { + msteams: { + enabled: true, + appId: "", + appPassword: "", + tenantId: "", + webhook: { port: 3978, path: "/api/messages" }, + }, + }, +} +``` + +Note: group chats are blocked by default (`channels.msteams.groupPolicy: "allowlist"`). To allow group replies, set `channels.msteams.groupAllowFrom` (or use `groupPolicy: "open"` to allow any member, mention-gated). + +## Goals + +- Talk to OpenClaw via Teams DMs, group chats, or channels. +- Keep routing deterministic: replies always go back to the channel they arrived on. +- Default to safe channel behavior (mentions required unless configured otherwise). + +## Config writes + +By default, Microsoft Teams is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`). + +Disable with: + +```json5 +{ + channels: { msteams: { configWrites: false } }, +} +``` + +## Access control (DMs + groups) + +**DM access** + +- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved. +- `channels.msteams.allowFrom` should use stable AAD object IDs. +- UPNs/display names are mutable; direct matching is disabled by default and only enabled with `channels.msteams.dangerouslyAllowNameMatching: true`. +- The wizard can resolve names to IDs via Microsoft Graph when credentials allow. + +**Group access** + +- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset. +- `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`). +- Set `groupPolicy: "open"` to allow any member (still mention‑gated by default). +- To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`. + +Example: + +```json5 +{ + channels: { + msteams: { + groupPolicy: "allowlist", + groupAllowFrom: ["user@org.com"], + }, + }, +} +``` + +**Teams + channel allowlist** + +- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`. +- Keys can be team IDs or names; channel keys can be conversation IDs or names. +- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated). +- The configure wizard accepts `Team/Channel` entries and stores them for you. +- On startup, OpenClaw resolves team/channel and user allowlist names to IDs (when Graph permissions allow) + and logs the mapping; unresolved entries are kept as typed. + +Example: + +```json5 +{ + channels: { + msteams: { + groupPolicy: "allowlist", + teams: { + "My Team": { + channels: { + General: { requireMention: true }, + }, + }, + }, + }, + }, +} +``` + +## How it works + +1. Install the Microsoft Teams plugin. +2. Create an **Azure Bot** (App ID + secret + tenant ID). +3. Build a **Teams app package** that references the bot and includes the RSC permissions below. +4. Upload/install the Teams app into a team (or personal scope for DMs). +5. Configure `msteams` in `~/.openclaw/openclaw.json` (or env vars) and start the gateway. +6. The gateway listens for Bot Framework webhook traffic on `/api/messages` by default. + +## Azure Bot Setup (Prerequisites) + +Before configuring OpenClaw, you need to create an Azure Bot resource. + +### Step 1: Create Azure Bot + +1. Go to [Create Azure Bot](https://portal.azure.com/#create/Microsoft.AzureBot) +2. Fill in the **Basics** tab: + + | Field | Value | + | ------------------ | -------------------------------------------------------- | + | **Bot handle** | Your bot name, e.g., `openclaw-msteams` (must be unique) | + | **Subscription** | Select your Azure subscription | + | **Resource group** | Create new or use existing | + | **Pricing tier** | **Free** for dev/testing | + | **Type of App** | **Single Tenant** (recommended - see note below) | + | **Creation type** | **Create new Microsoft App ID** | + +> **Deprecation notice:** Creation of new multi-tenant bots was deprecated after 2025-07-31. Use **Single Tenant** for new bots. + +3. Click **Review + create** → **Create** (wait ~1-2 minutes) + +### Step 2: Get Credentials + +1. Go to your Azure Bot resource → **Configuration** +2. Copy **Microsoft App ID** → this is your `appId` +3. Click **Manage Password** → go to the App Registration +4. Under **Certificates & secrets** → **New client secret** → copy the **Value** → this is your `appPassword` +5. Go to **Overview** → copy **Directory (tenant) ID** → this is your `tenantId` + +### Step 3: Configure Messaging Endpoint + +1. In Azure Bot → **Configuration** +2. Set **Messaging endpoint** to your webhook URL: + - Production: `https://your-domain.com/api/messages` + - Local dev: Use a tunnel (see [Local Development](#local-development-tunneling) below) + +### Step 4: Enable Teams Channel + +1. In Azure Bot → **Channels** +2. Click **Microsoft Teams** → Configure → Save +3. Accept the Terms of Service + +## Local Development (Tunneling) + +Teams can't reach `localhost`. Use a tunnel for local development: + +**Option A: ngrok** + +```bash +ngrok http 3978 +# Copy the https URL, e.g., https://abc123.ngrok.io +# Set messaging endpoint to: https://abc123.ngrok.io/api/messages +``` + +**Option B: Tailscale Funnel** + +```bash +tailscale funnel 3978 +# Use your Tailscale funnel URL as the messaging endpoint +``` + +## Teams Developer Portal (Alternative) + +Instead of manually creating a manifest ZIP, you can use the [Teams Developer Portal](https://dev.teams.microsoft.com/apps): + +1. Click **+ New app** +2. Fill in basic info (name, description, developer info) +3. Go to **App features** → **Bot** +4. Select **Enter a bot ID manually** and paste your Azure Bot App ID +5. Check scopes: **Personal**, **Team**, **Group Chat** +6. Click **Distribute** → **Download app package** +7. In Teams: **Apps** → **Manage your apps** → **Upload a custom app** → select the ZIP + +This is often easier than hand-editing JSON manifests. + +## Testing the Bot + +**Option A: Azure Web Chat (verify webhook first)** + +1. In Azure Portal → your Azure Bot resource → **Test in Web Chat** +2. Send a message - you should see a response +3. This confirms your webhook endpoint works before Teams setup + +**Option B: Teams (after app installation)** + +1. Install the Teams app (sideload or org catalog) +2. Find the bot in Teams and send a DM +3. Check gateway logs for incoming activity + +## Setup (minimal text-only) + +1. **Install the Microsoft Teams plugin** + - From npm: `openclaw plugins install @openclaw/msteams` + - From a local checkout: `openclaw plugins install ./extensions/msteams` + +2. **Bot registration** + - Create an Azure Bot (see above) and note: + - App ID + - Client secret (App password) + - Tenant ID (single-tenant) + +3. **Teams app manifest** + - Include a `bot` entry with `botId = `. + - Scopes: `personal`, `team`, `groupChat`. + - `supportsFiles: true` (required for personal scope file handling). + - Add RSC permissions (below). + - Create icons: `outline.png` (32x32) and `color.png` (192x192). + - Zip all three files together: `manifest.json`, `outline.png`, `color.png`. + +4. **Configure OpenClaw** + + ```json + { + "msteams": { + "enabled": true, + "appId": "", + "appPassword": "", + "tenantId": "", + "webhook": { "port": 3978, "path": "/api/messages" } + } + } + ``` + + You can also use environment variables instead of config keys: + - `MSTEAMS_APP_ID` + - `MSTEAMS_APP_PASSWORD` + - `MSTEAMS_TENANT_ID` + +5. **Bot endpoint** + - Set the Azure Bot Messaging Endpoint to: + - `https://:3978/api/messages` (or your chosen path/port). + +6. **Run the gateway** + - The Teams channel starts automatically when the plugin is installed and `msteams` config exists with credentials. + +## History context + +- `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt. +- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). +- DM history can be limited with `channels.msteams.dmHistoryLimit` (user turns). Per-user overrides: `channels.msteams.dms[""].historyLimit`. + +## Current Teams RSC Permissions (Manifest) + +These are the **existing resourceSpecific permissions** in our Teams app manifest. They only apply inside the team/chat where the app is installed. + +**For channels (team scope):** + +- `ChannelMessage.Read.Group` (Application) - receive all channel messages without @mention +- `ChannelMessage.Send.Group` (Application) +- `Member.Read.Group` (Application) +- `Owner.Read.Group` (Application) +- `ChannelSettings.Read.Group` (Application) +- `TeamMember.Read.Group` (Application) +- `TeamSettings.Read.Group` (Application) + +**For group chats:** + +- `ChatMessage.Read.Chat` (Application) - receive all group chat messages without @mention + +## Example Teams Manifest (redacted) + +Minimal, valid example with the required fields. Replace IDs and URLs. + +```json +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.23/MicrosoftTeams.schema.json", + "manifestVersion": "1.23", + "version": "1.0.0", + "id": "00000000-0000-0000-0000-000000000000", + "name": { "short": "OpenClaw" }, + "developer": { + "name": "Your Org", + "websiteUrl": "https://example.com", + "privacyUrl": "https://example.com/privacy", + "termsOfUseUrl": "https://example.com/terms" + }, + "description": { "short": "OpenClaw in Teams", "full": "OpenClaw in Teams" }, + "icons": { "outline": "outline.png", "color": "color.png" }, + "accentColor": "#5B6DEF", + "bots": [ + { + "botId": "11111111-1111-1111-1111-111111111111", + "scopes": ["personal", "team", "groupChat"], + "isNotificationOnly": false, + "supportsCalling": false, + "supportsVideo": false, + "supportsFiles": true + } + ], + "webApplicationInfo": { + "id": "11111111-1111-1111-1111-111111111111" + }, + "authorization": { + "permissions": { + "resourceSpecific": [ + { "name": "ChannelMessage.Read.Group", "type": "Application" }, + { "name": "ChannelMessage.Send.Group", "type": "Application" }, + { "name": "Member.Read.Group", "type": "Application" }, + { "name": "Owner.Read.Group", "type": "Application" }, + { "name": "ChannelSettings.Read.Group", "type": "Application" }, + { "name": "TeamMember.Read.Group", "type": "Application" }, + { "name": "TeamSettings.Read.Group", "type": "Application" }, + { "name": "ChatMessage.Read.Chat", "type": "Application" } + ] + } + } +} +``` + +### Manifest caveats (must-have fields) + +- `bots[].botId` **must** match the Azure Bot App ID. +- `webApplicationInfo.id` **must** match the Azure Bot App ID. +- `bots[].scopes` must include the surfaces you plan to use (`personal`, `team`, `groupChat`). +- `bots[].supportsFiles: true` is required for file handling in personal scope. +- `authorization.permissions.resourceSpecific` must include channel read/send if you want channel traffic. + +### Updating an existing app + +To update an already-installed Teams app (e.g., to add RSC permissions): + +1. Update your `manifest.json` with the new settings +2. **Increment the `version` field** (e.g., `1.0.0` → `1.1.0`) +3. **Re-zip** the manifest with icons (`manifest.json`, `outline.png`, `color.png`) +4. Upload the new zip: + - **Option A (Teams Admin Center):** Teams Admin Center → Teams apps → Manage apps → find your app → Upload new version + - **Option B (Sideload):** In Teams → Apps → Manage your apps → Upload a custom app +5. **For team channels:** Reinstall the app in each team for new permissions to take effect +6. **Fully quit and relaunch Teams** (not just close the window) to clear cached app metadata + +## Capabilities: RSC only vs Graph + +### With **Teams RSC only** (app installed, no Graph API permissions) + +Works: + +- Read channel message **text** content. +- Send channel message **text** content. +- Receive **personal (DM)** file attachments. + +Does NOT work: + +- Channel/group **image or file contents** (payload only includes HTML stub). +- Downloading attachments stored in SharePoint/OneDrive. +- Reading message history (beyond the live webhook event). + +### With **Teams RSC + Microsoft Graph Application permissions** + +Adds: + +- Downloading hosted contents (images pasted into messages). +- Downloading file attachments stored in SharePoint/OneDrive. +- Reading channel/chat message history via Graph. + +### RSC vs Graph API + +| Capability | RSC Permissions | Graph API | +| ----------------------- | -------------------- | ----------------------------------- | +| **Real-time messages** | Yes (via webhook) | No (polling only) | +| **Historical messages** | No | Yes (can query history) | +| **Setup complexity** | App manifest only | Requires admin consent + token flow | +| **Works offline** | No (must be running) | Yes (query anytime) | + +**Bottom line:** RSC is for real-time listening; Graph API is for historical access. For catching up on missed messages while offline, you need Graph API with `ChannelMessage.Read.All` (requires admin consent). + +## Graph-enabled media + history (required for channels) + +If you need images/files in **channels** or want to fetch **message history**, you must enable Microsoft Graph permissions and grant admin consent. + +1. In Entra ID (Azure AD) **App Registration**, add Microsoft Graph **Application permissions**: + - `ChannelMessage.Read.All` (channel attachments + history) + - `Chat.Read.All` or `ChatMessage.Read.All` (group chats) +2. **Grant admin consent** for the tenant. +3. Bump the Teams app **manifest version**, re-upload, and **reinstall the app in Teams**. +4. **Fully quit and relaunch Teams** to clear cached app metadata. + +**Additional permission for user mentions:** User @mentions work out of the box for users in the conversation. However, if you want to dynamically search and mention users who are **not in the current conversation**, add `User.Read.All` (Application) permission and grant admin consent. + +## Known Limitations + +### Webhook timeouts + +Teams delivers messages via HTTP webhook. If processing takes too long (e.g., slow LLM responses), you may see: + +- Gateway timeouts +- Teams retrying the message (causing duplicates) +- Dropped replies + +OpenClaw handles this by returning quickly and sending replies proactively, but very slow responses may still cause issues. + +### Formatting + +Teams markdown is more limited than Slack or Discord: + +- Basic formatting works: **bold**, _italic_, `code`, links +- Complex markdown (tables, nested lists) may not render correctly +- Adaptive Cards are supported for polls and arbitrary card sends (see below) + +## Configuration + +Key settings (see `/gateway/configuration` for shared channel patterns): + +- `channels.msteams.enabled`: enable/disable the channel. +- `channels.msteams.appId`, `channels.msteams.appPassword`, `channels.msteams.tenantId`: bot credentials. +- `channels.msteams.webhook.port` (default `3978`) +- `channels.msteams.webhook.path` (default `/api/messages`) +- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) +- `channels.msteams.allowFrom`: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available. +- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching. +- `channels.msteams.textChunkLimit`: outbound text chunk size. +- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. +- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). +- `channels.msteams.mediaAuthAllowHosts`: allowlist for attaching Authorization headers on media retries (defaults to Graph + Bot Framework hosts). +- `channels.msteams.requireMention`: require @mention in channels/groups (default true). +- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)). +- `channels.msteams.teams..replyStyle`: per-team override. +- `channels.msteams.teams..requireMention`: per-team override. +- `channels.msteams.teams..tools`: default per-team tool policy overrides (`allow`/`deny`/`alsoAllow`) used when a channel override is missing. +- `channels.msteams.teams..toolsBySender`: default per-team per-sender tool policy overrides (`"*"` wildcard supported). +- `channels.msteams.teams..channels..replyStyle`: per-channel override. +- `channels.msteams.teams..channels..requireMention`: per-channel override. +- `channels.msteams.teams..channels..tools`: per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`). +- `channels.msteams.teams..channels..toolsBySender`: per-channel per-sender tool policy overrides (`"*"` wildcard supported). +- `toolsBySender` keys should use explicit prefixes: + `id:`, `e164:`, `username:`, `name:` (legacy unprefixed keys still map to `id:` only). +- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)). + +## Routing & Sessions + +- Session keys follow the standard agent format (see [/concepts/session](/concepts/session)): + - Direct messages share the main session (`agent::`). + - Channel/group messages use conversation id: + - `agent::msteams:channel:` + - `agent::msteams:group:` + +## Reply Style: Threads vs Posts + +Teams recently introduced two channel UI styles over the same underlying data model: + +| Style | Description | Recommended `replyStyle` | +| ------------------------ | --------------------------------------------------------- | ------------------------ | +| **Posts** (classic) | Messages appear as cards with threaded replies underneath | `thread` (default) | +| **Threads** (Slack-like) | Messages flow linearly, more like Slack | `top-level` | + +**The problem:** The Teams API does not expose which UI style a channel uses. If you use the wrong `replyStyle`: + +- `thread` in a Threads-style channel → replies appear nested awkwardly +- `top-level` in a Posts-style channel → replies appear as separate top-level posts instead of in-thread + +**Solution:** Configure `replyStyle` per-channel based on how the channel is set up: + +```json +{ + "msteams": { + "replyStyle": "thread", + "teams": { + "19:abc...@thread.tacv2": { + "channels": { + "19:xyz...@thread.tacv2": { + "replyStyle": "top-level" + } + } + } + } + } +} +``` + +## Attachments & Images + +**Current limitations:** + +- **DMs:** Images and file attachments work via Teams bot file APIs. +- **Channels/groups:** Attachments live in M365 storage (SharePoint/OneDrive). The webhook payload only includes an HTML stub, not the actual file bytes. **Graph API permissions are required** to download channel attachments. + +Without Graph permissions, channel messages with images will be received as text-only (the image content is not accessible to the bot). +By default, OpenClaw only downloads media from Microsoft/Teams hostnames. Override with `channels.msteams.mediaAllowHosts` (use `["*"]` to allow any host). +Authorization headers are only attached for hosts in `channels.msteams.mediaAuthAllowHosts` (defaults to Graph + Bot Framework hosts). Keep this list strict (avoid multi-tenant suffixes). + +## Sending files in group chats + +Bots can send files in DMs using the FileConsentCard flow (built-in). However, **sending files in group chats/channels** requires additional setup: + +| Context | How files are sent | Setup needed | +| ------------------------ | -------------------------------------------- | ----------------------------------------------- | +| **DMs** | FileConsentCard → user accepts → bot uploads | Works out of the box | +| **Group chats/channels** | Upload to SharePoint → share link | Requires `sharePointSiteId` + Graph permissions | +| **Images (any context)** | Base64-encoded inline | Works out of the box | + +### Why group chats need SharePoint + +Bots don't have a personal OneDrive drive (the `/me/drive` Graph API endpoint doesn't work for application identities). To send files in group chats/channels, the bot uploads to a **SharePoint site** and creates a sharing link. + +### Setup + +1. **Add Graph API permissions** in Entra ID (Azure AD) → App Registration: + - `Sites.ReadWrite.All` (Application) - upload files to SharePoint + - `Chat.Read.All` (Application) - optional, enables per-user sharing links + +2. **Grant admin consent** for the tenant. + +3. **Get your SharePoint site ID:** + + ```bash + # Via Graph Explorer or curl with a valid token: + curl -H "Authorization: Bearer $TOKEN" \ + "https://graph.microsoft.com/v1.0/sites/{hostname}:/{site-path}" + + # Example: for a site at "contoso.sharepoint.com/sites/BotFiles" + curl -H "Authorization: Bearer $TOKEN" \ + "https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/BotFiles" + + # Response includes: "id": "contoso.sharepoint.com,guid1,guid2" + ``` + +4. **Configure OpenClaw:** + + ```json5 + { + channels: { + msteams: { + // ... other config ... + sharePointSiteId: "contoso.sharepoint.com,guid1,guid2", + }, + }, + } + ``` + +### Sharing behavior + +| Permission | Sharing behavior | +| --------------------------------------- | --------------------------------------------------------- | +| `Sites.ReadWrite.All` only | Organization-wide sharing link (anyone in org can access) | +| `Sites.ReadWrite.All` + `Chat.Read.All` | Per-user sharing link (only chat members can access) | + +Per-user sharing is more secure as only the chat participants can access the file. If `Chat.Read.All` permission is missing, the bot falls back to organization-wide sharing. + +### Fallback behavior + +| Scenario | Result | +| ------------------------------------------------- | -------------------------------------------------- | +| Group chat + file + `sharePointSiteId` configured | Upload to SharePoint, send sharing link | +| Group chat + file + no `sharePointSiteId` | Attempt OneDrive upload (may fail), send text only | +| Personal chat + file | FileConsentCard flow (works without SharePoint) | +| Any context + image | Base64-encoded inline (works without SharePoint) | + +### Files stored location + +Uploaded files are stored in a `/OpenClawShared/` folder in the configured SharePoint site's default document library. + +## Polls (Adaptive Cards) + +OpenClaw sends Teams polls as Adaptive Cards (there is no native Teams poll API). + +- CLI: `openclaw message poll --channel msteams --target conversation: ...` +- Votes are recorded by the gateway in `~/.openclaw/msteams-polls.json`. +- The gateway must stay online to record votes. +- Polls do not auto-post result summaries yet (inspect the store file if needed). + +## Adaptive Cards (arbitrary) + +Send any Adaptive Card JSON to Teams users or conversations using the `message` tool or CLI. + +The `card` parameter accepts an Adaptive Card JSON object. When `card` is provided, the message text is optional. + +**Agent tool:** + +```json +{ + "action": "send", + "channel": "msteams", + "target": "user:", + "card": { + "type": "AdaptiveCard", + "version": "1.5", + "body": [{ "type": "TextBlock", "text": "Hello!" }] + } +} +``` + +**CLI:** + +```bash +openclaw message send --channel msteams \ + --target "conversation:19:abc...@thread.tacv2" \ + --card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello!"}]}' +``` + +See [Adaptive Cards documentation](https://adaptivecards.io/) for card schema and examples. For target format details, see [Target formats](#target-formats) below. + +## Target formats + +MSTeams targets use prefixes to distinguish between users and conversations: + +| Target type | Format | Example | +| ------------------- | -------------------------------- | --------------------------------------------------- | +| User (by ID) | `user:` | `user:40a1a0ed-4ff2-4164-a219-55518990c197` | +| User (by name) | `user:` | `user:John Smith` (requires Graph API) | +| Group/channel | `conversation:` | `conversation:19:abc123...@thread.tacv2` | +| Group/channel (raw) | `` | `19:abc123...@thread.tacv2` (if contains `@thread`) | + +**CLI examples:** + +```bash +# Send to a user by ID +openclaw message send --channel msteams --target "user:40a1a0ed-..." --message "Hello" + +# Send to a user by display name (triggers Graph API lookup) +openclaw message send --channel msteams --target "user:John Smith" --message "Hello" + +# Send to a group chat or channel +openclaw message send --channel msteams --target "conversation:19:abc...@thread.tacv2" --message "Hello" + +# Send an Adaptive Card to a conversation +openclaw message send --channel msteams --target "conversation:19:abc...@thread.tacv2" \ + --card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello"}]}' +``` + +**Agent tool examples:** + +```json +{ + "action": "send", + "channel": "msteams", + "target": "user:John Smith", + "message": "Hello!" +} +``` + +```json +{ + "action": "send", + "channel": "msteams", + "target": "conversation:19:abc...@thread.tacv2", + "card": { + "type": "AdaptiveCard", + "version": "1.5", + "body": [{ "type": "TextBlock", "text": "Hello" }] + } +} +``` + +Note: Without the `user:` prefix, names default to group/team resolution. Always use `user:` when targeting people by display name. + +## Proactive messaging + +- Proactive messages are only possible **after** a user has interacted, because we store conversation references at that point. +- See `/gateway/configuration` for `dmPolicy` and allowlist gating. + +## Team and Channel IDs (Common Gotcha) + +The `groupId` query parameter in Teams URLs is **NOT** the team ID used for configuration. Extract IDs from the URL path instead: + +**Team URL:** + +``` +https://teams.microsoft.com/l/team/19%3ABk4j...%40thread.tacv2/conversations?groupId=... + └────────────────────────────┘ + Team ID (URL-decode this) +``` + +**Channel URL:** + +``` +https://teams.microsoft.com/l/channel/19%3A15bc...%40thread.tacv2/ChannelName?groupId=... + └─────────────────────────┘ + Channel ID (URL-decode this) +``` + +**For config:** + +- Team ID = path segment after `/team/` (URL-decoded, e.g., `19:Bk4j...@thread.tacv2`) +- Channel ID = path segment after `/channel/` (URL-decoded) +- **Ignore** the `groupId` query parameter + +## Private Channels + +Bots have limited support in private channels: + +| Feature | Standard Channels | Private Channels | +| ---------------------------- | ----------------- | ---------------------- | +| Bot installation | Yes | Limited | +| Real-time messages (webhook) | Yes | May not work | +| RSC permissions | Yes | May behave differently | +| @mentions | Yes | If bot is accessible | +| Graph API history | Yes | Yes (with permissions) | + +**Workarounds if private channels don't work:** + +1. Use standard channels for bot interactions +2. Use DMs - users can always message the bot directly +3. Use Graph API for historical access (requires `ChannelMessage.Read.All`) + +## Troubleshooting + +### Common issues + +- **Images not showing in channels:** Graph permissions or admin consent missing. Reinstall the Teams app and fully quit/reopen Teams. +- **No responses in channel:** mentions are required by default; set `channels.msteams.requireMention=false` or configure per team/channel. +- **Version mismatch (Teams still shows old manifest):** remove + re-add the app and fully quit Teams to refresh. +- **401 Unauthorized from webhook:** Expected when testing manually without Azure JWT - means endpoint is reachable but auth failed. Use Azure Web Chat to test properly. + +### Manifest upload errors + +- **"Icon file cannot be empty":** The manifest references icon files that are 0 bytes. Create valid PNG icons (32x32 for `outline.png`, 192x192 for `color.png`). +- **"webApplicationInfo.Id already in use":** The app is still installed in another team/chat. Find and uninstall it first, or wait 5-10 minutes for propagation. +- **"Something went wrong" on upload:** Upload via [https://admin.teams.microsoft.com](https://admin.teams.microsoft.com) instead, open browser DevTools (F12) → Network tab, and check the response body for the actual error. +- **Sideload failing:** Try "Upload an app to your org's app catalog" instead of "Upload a custom app" - this often bypasses sideload restrictions. + +### RSC permissions not working + +1. Verify `webApplicationInfo.id` matches your bot's App ID exactly +2. Re-upload the app and reinstall in the team/chat +3. Check if your org admin has blocked RSC permissions +4. Confirm you're using the right scope: `ChannelMessage.Read.Group` for teams, `ChatMessage.Read.Chat` for group chats + +## References + +- [Create Azure Bot](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration) - Azure Bot setup guide +- [Teams Developer Portal](https://dev.teams.microsoft.com/apps) - create/manage Teams apps +- [Teams app manifest schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema) +- [Receive channel messages with RSC](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-messages-with-rsc) +- [RSC permissions reference](https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/resource-specific-consent) +- [Teams bot file handling](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4) (channel/group requires Graph) +- [Proactive messaging](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/nextcloud-talk.md b/backend/app/one_person_security_dept/openclaw/docs/channels/nextcloud-talk.md new file mode 100644 index 00000000..d4ab9e2c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/nextcloud-talk.md @@ -0,0 +1,138 @@ +--- +summary: "Nextcloud Talk support status, capabilities, and configuration" +read_when: + - Working on Nextcloud Talk channel features +title: "Nextcloud Talk" +--- + +# Nextcloud Talk (plugin) + +Status: supported via plugin (webhook bot). Direct messages, rooms, reactions, and markdown messages are supported. + +## Plugin required + +Nextcloud Talk ships as a plugin and is not bundled with the core install. + +Install via CLI (npm registry): + +```bash +openclaw plugins install @openclaw/nextcloud-talk +``` + +Local checkout (when running from a git repo): + +```bash +openclaw plugins install ./extensions/nextcloud-talk +``` + +If you choose Nextcloud Talk during configure/onboarding and a git checkout is detected, +OpenClaw will offer the local install path automatically. + +Details: [Plugins](/tools/plugin) + +## Quick setup (beginner) + +1. Install the Nextcloud Talk plugin. +2. On your Nextcloud server, create a bot: + + ```bash + ./occ talk:bot:install "OpenClaw" "" "" --feature reaction + ``` + +3. Enable the bot in the target room settings. +4. Configure OpenClaw: + - Config: `channels.nextcloud-talk.baseUrl` + `channels.nextcloud-talk.botSecret` + - Or env: `NEXTCLOUD_TALK_BOT_SECRET` (default account only) +5. Restart the gateway (or finish onboarding). + +Minimal config: + +```json5 +{ + channels: { + "nextcloud-talk": { + enabled: true, + baseUrl: "https://cloud.example.com", + botSecret: "shared-secret", + dmPolicy: "pairing", + }, + }, +} +``` + +## Notes + +- Bots cannot initiate DMs. The user must message the bot first. +- Webhook URL must be reachable by the Gateway; set `webhookPublicUrl` if behind a proxy. +- Media uploads are not supported by the bot API; media is sent as URLs. +- The webhook payload does not distinguish DMs vs rooms; set `apiUser` + `apiPassword` to enable room-type lookups (otherwise DMs are treated as rooms). + +## Access control (DMs) + +- Default: `channels.nextcloud-talk.dmPolicy = "pairing"`. Unknown senders get a pairing code. +- Approve via: + - `openclaw pairing list nextcloud-talk` + - `openclaw pairing approve nextcloud-talk ` +- Public DMs: `channels.nextcloud-talk.dmPolicy="open"` plus `channels.nextcloud-talk.allowFrom=["*"]`. +- `allowFrom` matches Nextcloud user IDs only; display names are ignored. + +## Rooms (groups) + +- Default: `channels.nextcloud-talk.groupPolicy = "allowlist"` (mention-gated). +- Allowlist rooms with `channels.nextcloud-talk.rooms`: + +```json5 +{ + channels: { + "nextcloud-talk": { + rooms: { + "room-token": { requireMention: true }, + }, + }, + }, +} +``` + +- To allow no rooms, keep the allowlist empty or set `channels.nextcloud-talk.groupPolicy="disabled"`. + +## Capabilities + +| Feature | Status | +| --------------- | ------------- | +| Direct messages | Supported | +| Rooms | Supported | +| Threads | Not supported | +| Media | URL-only | +| Reactions | Supported | +| Native commands | Not supported | + +## Configuration reference (Nextcloud Talk) + +Full configuration: [Configuration](/gateway/configuration) + +Provider options: + +- `channels.nextcloud-talk.enabled`: enable/disable channel startup. +- `channels.nextcloud-talk.baseUrl`: Nextcloud instance URL. +- `channels.nextcloud-talk.botSecret`: bot shared secret. +- `channels.nextcloud-talk.botSecretFile`: secret file path. +- `channels.nextcloud-talk.apiUser`: API user for room lookups (DM detection). +- `channels.nextcloud-talk.apiPassword`: API/app password for room lookups. +- `channels.nextcloud-talk.apiPasswordFile`: API password file path. +- `channels.nextcloud-talk.webhookPort`: webhook listener port (default: 8788). +- `channels.nextcloud-talk.webhookHost`: webhook host (default: 0.0.0.0). +- `channels.nextcloud-talk.webhookPath`: webhook path (default: /nextcloud-talk-webhook). +- `channels.nextcloud-talk.webhookPublicUrl`: externally reachable webhook URL. +- `channels.nextcloud-talk.dmPolicy`: `pairing | allowlist | open | disabled`. +- `channels.nextcloud-talk.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. +- `channels.nextcloud-talk.groupPolicy`: `allowlist | open | disabled`. +- `channels.nextcloud-talk.groupAllowFrom`: group allowlist (user IDs). +- `channels.nextcloud-talk.rooms`: per-room settings and allowlist. +- `channels.nextcloud-talk.historyLimit`: group history limit (0 disables). +- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables). +- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit). +- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars). +- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. +- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel. +- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning. +- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB). diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/nostr.md b/backend/app/one_person_security_dept/openclaw/docs/channels/nostr.md new file mode 100644 index 00000000..3368933d --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/nostr.md @@ -0,0 +1,233 @@ +--- +summary: "Nostr DM channel via NIP-04 encrypted messages" +read_when: + - You want OpenClaw to receive DMs via Nostr + - You're setting up decentralized messaging +title: "Nostr" +--- + +# Nostr + +**Status:** Optional plugin (disabled by default). + +Nostr is a decentralized protocol for social networking. This channel enables OpenClaw to receive and respond to encrypted direct messages (DMs) via NIP-04. + +## Install (on demand) + +### Onboarding (recommended) + +- The onboarding wizard (`openclaw onboard`) and `openclaw channels add` list optional channel plugins. +- Selecting Nostr prompts you to install the plugin on demand. + +Install defaults: + +- **Dev channel + git checkout available:** uses the local plugin path. +- **Stable/Beta:** downloads from npm. + +You can always override the choice in the prompt. + +### Manual install + +```bash +openclaw plugins install @openclaw/nostr +``` + +Use a local checkout (dev workflows): + +```bash +openclaw plugins install --link /extensions/nostr +``` + +Restart the Gateway after installing or enabling plugins. + +## Quick setup + +1. Generate a Nostr keypair (if needed): + +```bash +# Using nak +nak key generate +``` + +2. Add to config: + +```json +{ + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}" + } + } +} +``` + +3. Export the key: + +```bash +export NOSTR_PRIVATE_KEY="nsec1..." +``` + +4. Restart the Gateway. + +## Configuration reference + +| Key | Type | Default | Description | +| ------------ | -------- | ------------------------------------------- | ----------------------------------- | +| `privateKey` | string | required | Private key in `nsec` or hex format | +| `relays` | string[] | `['wss://relay.damus.io', 'wss://nos.lol']` | Relay URLs (WebSocket) | +| `dmPolicy` | string | `pairing` | DM access policy | +| `allowFrom` | string[] | `[]` | Allowed sender pubkeys | +| `enabled` | boolean | `true` | Enable/disable channel | +| `name` | string | - | Display name | +| `profile` | object | - | NIP-01 profile metadata | + +## Profile metadata + +Profile data is published as a NIP-01 `kind:0` event. You can manage it from the Control UI (Channels -> Nostr -> Profile) or set it directly in config. + +Example: + +```json +{ + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}", + "profile": { + "name": "openclaw", + "displayName": "OpenClaw", + "about": "Personal assistant DM bot", + "picture": "https://example.com/avatar.png", + "banner": "https://example.com/banner.png", + "website": "https://example.com", + "nip05": "openclaw@example.com", + "lud16": "openclaw@example.com" + } + } + } +} +``` + +Notes: + +- Profile URLs must use `https://`. +- Importing from relays merges fields and preserves local overrides. + +## Access control + +### DM policies + +- **pairing** (default): unknown senders get a pairing code. +- **allowlist**: only pubkeys in `allowFrom` can DM. +- **open**: public inbound DMs (requires `allowFrom: ["*"]`). +- **disabled**: ignore inbound DMs. + +### Allowlist example + +```json +{ + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}", + "dmPolicy": "allowlist", + "allowFrom": ["npub1abc...", "npub1xyz..."] + } + } +} +``` + +## Key formats + +Accepted formats: + +- **Private key:** `nsec...` or 64-char hex +- **Pubkeys (`allowFrom`):** `npub...` or hex + +## Relays + +Defaults: `relay.damus.io` and `nos.lol`. + +```json +{ + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}", + "relays": ["wss://relay.damus.io", "wss://relay.primal.net", "wss://nostr.wine"] + } + } +} +``` + +Tips: + +- Use 2-3 relays for redundancy. +- Avoid too many relays (latency, duplication). +- Paid relays can improve reliability. +- Local relays are fine for testing (`ws://localhost:7777`). + +## Protocol support + +| NIP | Status | Description | +| ------ | --------- | ------------------------------------- | +| NIP-01 | Supported | Basic event format + profile metadata | +| NIP-04 | Supported | Encrypted DMs (`kind:4`) | +| NIP-17 | Planned | Gift-wrapped DMs | +| NIP-44 | Planned | Versioned encryption | + +## Testing + +### Local relay + +```bash +# Start strfry +docker run -p 7777:7777 ghcr.io/hoytech/strfry +``` + +```json +{ + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}", + "relays": ["ws://localhost:7777"] + } + } +} +``` + +### Manual test + +1. Note the bot pubkey (npub) from logs. +2. Open a Nostr client (Damus, Amethyst, etc.). +3. DM the bot pubkey. +4. Verify the response. + +## Troubleshooting + +### Not receiving messages + +- Verify the private key is valid. +- Ensure relay URLs are reachable and use `wss://` (or `ws://` for local). +- Confirm `enabled` is not `false`. +- Check Gateway logs for relay connection errors. + +### Not sending responses + +- Check relay accepts writes. +- Verify outbound connectivity. +- Watch for relay rate limits. + +### Duplicate responses + +- Expected when using multiple relays. +- Messages are deduplicated by event ID; only the first delivery triggers a response. + +## Security + +- Never commit private keys. +- Use environment variables for keys. +- Consider `allowlist` for production bots. + +## Limitations (MVP) + +- Direct messages only (no group chats). +- No media attachments. +- NIP-04 only (NIP-17 gift-wrap planned). diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/pairing.md b/backend/app/one_person_security_dept/openclaw/docs/channels/pairing.md new file mode 100644 index 00000000..4b575eb8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/pairing.md @@ -0,0 +1,103 @@ +--- +summary: "Pairing overview: approve who can DM you + which nodes can join" +read_when: + - Setting up DM access control + - Pairing a new iOS/Android node + - Reviewing OpenClaw security posture +title: "Pairing" +--- + +# Pairing + +“Pairing” is OpenClaw’s explicit **owner approval** step. +It is used in two places: + +1. **DM pairing** (who is allowed to talk to the bot) +2. **Node pairing** (which devices/nodes are allowed to join the gateway network) + +Security context: [Security](/gateway/security) + +## 1) DM pairing (inbound chat access) + +When a channel is configured with DM policy `pairing`, unknown senders get a short code and their message is **not processed** until you approve. + +Default DM policies are documented in: [Security](/gateway/security) + +Pairing codes: + +- 8 characters, uppercase, no ambiguous chars (`0O1I`). +- **Expire after 1 hour**. The bot only sends the pairing message when a new request is created (roughly once per hour per sender). +- Pending DM pairing requests are capped at **3 per channel** by default; additional requests are ignored until one expires or is approved. + +### Approve a sender + +```bash +openclaw pairing list telegram +openclaw pairing approve telegram +``` + +Supported channels: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `slack`, `feishu`. + +### Where the state lives + +Stored under `~/.openclaw/credentials/`: + +- Pending requests: `-pairing.json` +- Approved allowlist store: `-allowFrom.json` + +Treat these as sensitive (they gate access to your assistant). + +## 2) Node device pairing (iOS/Android/macOS/headless nodes) + +Nodes connect to the Gateway as **devices** with `role: node`. The Gateway +creates a device pairing request that must be approved. + +### Pair via Telegram (recommended for iOS) + +If you use the `device-pair` plugin, you can do first-time device pairing entirely from Telegram: + +1. In Telegram, message your bot: `/pair` +2. The bot replies with two messages: an instruction message and a separate **setup code** message (easy to copy/paste in Telegram). +3. On your phone, open the OpenClaw iOS app → Settings → Gateway. +4. Paste the setup code and connect. +5. Back in Telegram: `/pair approve` + +The setup code is a base64-encoded JSON payload that contains: + +- `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`) +- `token`: a short-lived pairing token + +Treat the setup code like a password while it is valid. + +### Approve a node device + +```bash +openclaw devices list +openclaw devices approve +openclaw devices reject +``` + +### Node pairing state storage + +Stored under `~/.openclaw/devices/`: + +- `pending.json` (short-lived; pending requests expire) +- `paired.json` (paired devices + tokens) + +### Notes + +- The legacy `node.pair.*` API (CLI: `openclaw nodes pending/approve`) is a + separate gateway-owned pairing store. WS nodes still require device pairing. + +## Related docs + +- Security model + prompt injection: [Security](/gateway/security) +- Updating safely (run doctor): [Updating](/install/updating) +- Channel configs: + - Telegram: [Telegram](/channels/telegram) + - WhatsApp: [WhatsApp](/channels/whatsapp) + - Signal: [Signal](/channels/signal) + - BlueBubbles (iMessage): [BlueBubbles](/channels/bluebubbles) + - iMessage (legacy): [iMessage](/channels/imessage) + - Discord: [Discord](/channels/discord) + - Slack: [Slack](/channels/slack) diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/signal.md b/backend/app/one_person_security_dept/openclaw/docs/channels/signal.md new file mode 100644 index 00000000..b216af12 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/signal.md @@ -0,0 +1,325 @@ +--- +summary: "Signal support via signal-cli (JSON-RPC + SSE), setup paths, and number model" +read_when: + - Setting up Signal support + - Debugging Signal send/receive +title: "Signal" +--- + +# Signal (signal-cli) + +Status: external CLI integration. Gateway talks to `signal-cli` over HTTP JSON-RPC + SSE. + +## Prerequisites + +- OpenClaw installed on your server (Linux flow below tested on Ubuntu 24). +- `signal-cli` available on the host where the gateway runs. +- A phone number that can receive one verification SMS (for SMS registration path). +- Browser access for Signal captcha (`signalcaptchas.org`) during registration. + +## Quick setup (beginner) + +1. Use a **separate Signal number** for the bot (recommended). +2. Install `signal-cli` (Java required if you use the JVM build). +3. Choose one setup path: + - **Path A (QR link):** `signal-cli link -n "OpenClaw"` and scan with Signal. + - **Path B (SMS register):** register a dedicated number with captcha + SMS verification. +4. Configure OpenClaw and restart the gateway. +5. Send a first DM and approve pairing (`openclaw pairing approve signal `). + +Minimal config: + +```json5 +{ + channels: { + signal: { + enabled: true, + account: "+15551234567", + cliPath: "signal-cli", + dmPolicy: "pairing", + allowFrom: ["+15557654321"], + }, + }, +} +``` + +Field reference: + +| Field | Description | +| ----------- | ------------------------------------------------- | +| `account` | Bot phone number in E.164 format (`+15551234567`) | +| `cliPath` | Path to `signal-cli` (`signal-cli` if on `PATH`) | +| `dmPolicy` | DM access policy (`pairing` recommended) | +| `allowFrom` | Phone numbers or `uuid:` values allowed to DM | + +## What it is + +- Signal channel via `signal-cli` (not embedded libsignal). +- Deterministic routing: replies always go back to Signal. +- DMs share the agent's main session; groups are isolated (`agent::signal:group:`). + +## Config writes + +By default, Signal is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`). + +Disable with: + +```json5 +{ + channels: { signal: { configWrites: false } }, +} +``` + +## The number model (important) + +- The gateway connects to a **Signal device** (the `signal-cli` account). +- If you run the bot on **your personal Signal account**, it will ignore your own messages (loop protection). +- For "I text the bot and it replies," use a **separate bot number**. + +## Setup path A: link existing Signal account (QR) + +1. Install `signal-cli` (JVM or native build). +2. Link a bot account: + - `signal-cli link -n "OpenClaw"` then scan the QR in Signal. +3. Configure Signal and start the gateway. + +Example: + +```json5 +{ + channels: { + signal: { + enabled: true, + account: "+15551234567", + cliPath: "signal-cli", + dmPolicy: "pairing", + allowFrom: ["+15557654321"], + }, + }, +} +``` + +Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. + +## Setup path B: register dedicated bot number (SMS, Linux) + +Use this when you want a dedicated bot number instead of linking an existing Signal app account. + +1. Get a number that can receive SMS (or voice verification for landlines). + - Use a dedicated bot number to avoid account/session conflicts. +2. Install `signal-cli` on the gateway host: + +```bash +VERSION=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/AsamK/signal-cli/releases/latest | sed -e 's/^.*\/v//') +curl -L -O "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz" +sudo tar xf "signal-cli-${VERSION}-Linux-native.tar.gz" -C /opt +sudo ln -sf /opt/signal-cli /usr/local/bin/ +signal-cli --version +``` + +If you use the JVM build (`signal-cli-${VERSION}.tar.gz`), install JRE 25+ first. +Keep `signal-cli` updated; upstream notes that old releases can break as Signal server APIs change. + +3. Register and verify the number: + +```bash +signal-cli -a + register +``` + +If captcha is required: + +1. Open `https://signalcaptchas.org/registration/generate.html`. +2. Complete captcha, copy the `signalcaptcha://...` link target from "Open Signal". +3. Run from the same external IP as the browser session when possible. +4. Run registration again immediately (captcha tokens expire quickly): + +```bash +signal-cli -a + register --captcha '' +signal-cli -a + verify +``` + +4. Configure OpenClaw, restart gateway, verify channel: + +```bash +# If you run the gateway as a user systemd service: +systemctl --user restart openclaw-gateway + +# Then verify: +openclaw doctor +openclaw channels status --probe +``` + +5. Pair your DM sender: + - Send any message to the bot number. + - Approve code on the server: `openclaw pairing approve signal `. + - Save the bot number as a contact on your phone to avoid "Unknown contact". + +Important: registering a phone number account with `signal-cli` can de-authenticate the main Signal app session for that number. Prefer a dedicated bot number, or use QR link mode if you need to keep your existing phone app setup. + +Upstream references: + +- `signal-cli` README: `https://github.com/AsamK/signal-cli` +- Captcha flow: `https://github.com/AsamK/signal-cli/wiki/Registration-with-captcha` +- Linking flow: `https://github.com/AsamK/signal-cli/wiki/Linking-other-devices-(Provisioning)` + +## External daemon mode (httpUrl) + +If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point OpenClaw at it: + +```json5 +{ + channels: { + signal: { + httpUrl: "http://127.0.0.1:8080", + autoStart: false, + }, + }, +} +``` + +This skips auto-spawn and the startup wait inside OpenClaw. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`. + +## Access control (DMs + groups) + +DMs: + +- Default: `channels.signal.dmPolicy = "pairing"`. +- Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour). +- Approve via: + - `openclaw pairing list signal` + - `openclaw pairing approve signal ` +- Pairing is the default token exchange for Signal DMs. Details: [Pairing](/channels/pairing) +- UUID-only senders (from `sourceUuid`) are stored as `uuid:` in `channels.signal.allowFrom`. + +Groups: + +- `channels.signal.groupPolicy = open | allowlist | disabled`. +- `channels.signal.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. +- Runtime note: if `channels.signal` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). + +## How it works (behavior) + +- `signal-cli` runs as a daemon; the gateway reads events via SSE. +- Inbound messages are normalized into the shared channel envelope. +- Replies always route back to the same number or group. + +## Media + limits + +- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000). +- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. +- Attachments supported (base64 fetched from `signal-cli`). +- Default media cap: `channels.signal.mediaMaxMb` (default 8). +- Use `channels.signal.ignoreAttachments` to skip downloading media. +- Group history context uses `channels.signal.historyLimit` (or `channels.signal.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). + +## Typing + read receipts + +- **Typing indicators**: OpenClaw sends typing signals via `signal-cli sendTyping` and refreshes them while a reply is running. +- **Read receipts**: when `channels.signal.sendReadReceipts` is true, OpenClaw forwards read receipts for allowed DMs. +- Signal-cli does not expose read receipts for groups. + +## Reactions (message tool) + +- Use `message action=react` with `channel=signal`. +- Targets: sender E.164 or UUID (use `uuid:` from pairing output; bare UUID works too). +- `messageId` is the Signal timestamp for the message you’re reacting to. +- Group reactions require `targetAuthor` or `targetAuthorUuid`. + +Examples: + +``` +message action=react channel=signal target=uuid:123e4567-e89b-12d3-a456-426614174000 messageId=1737630212345 emoji=🔥 +message action=react channel=signal target=+15551234567 messageId=1737630212345 emoji=🔥 remove=true +message action=react channel=signal target=signal:group: targetAuthor=uuid: messageId=1737630212345 emoji=✅ +``` + +Config: + +- `channels.signal.actions.reactions`: enable/disable reaction actions (default true). +- `channels.signal.reactionLevel`: `off | ack | minimal | extensive`. + - `off`/`ack` disables agent reactions (message tool `react` will error). + - `minimal`/`extensive` enables agent reactions and sets the guidance level. +- Per-account overrides: `channels.signal.accounts..actions.reactions`, `channels.signal.accounts..reactionLevel`. + +## Delivery targets (CLI/cron) + +- DMs: `signal:+15551234567` (or plain E.164). +- UUID DMs: `uuid:` (or bare UUID). +- Groups: `signal:group:`. +- Usernames: `username:` (if supported by your Signal account). + +## Troubleshooting + +Run this ladder first: + +```bash +openclaw status +openclaw gateway status +openclaw logs --follow +openclaw doctor +openclaw channels status --probe +``` + +Then confirm DM pairing state if needed: + +```bash +openclaw pairing list signal +``` + +Common failures: + +- Daemon reachable but no replies: verify account/daemon settings (`httpUrl`, `account`) and receive mode. +- DMs ignored: sender is pending pairing approval. +- Group messages ignored: group sender/mention gating blocks delivery. +- Config validation errors after edits: run `openclaw doctor --fix`. +- Signal missing from diagnostics: confirm `channels.signal.enabled: true`. + +Extra checks: + +```bash +openclaw pairing list signal +pgrep -af signal-cli +grep -i "signal" "/tmp/openclaw/openclaw-$(date +%Y-%m-%d).log" | tail -20 +``` + +For triage flow: [/channels/troubleshooting](/channels/troubleshooting). + +## Security notes + +- `signal-cli` stores account keys locally (typically `~/.local/share/signal-cli/data/`). +- Back up Signal account state before server migration or rebuild. +- Keep `channels.signal.dmPolicy: "pairing"` unless you explicitly want broader DM access. +- SMS verification is only needed for registration or recovery flows, but losing control of the number/account can complicate re-registration. + +## Configuration reference (Signal) + +Full configuration: [Configuration](/gateway/configuration) + +Provider options: + +- `channels.signal.enabled`: enable/disable channel startup. +- `channels.signal.account`: E.164 for the bot account. +- `channels.signal.cliPath`: path to `signal-cli`. +- `channels.signal.httpUrl`: full daemon URL (overrides host/port). +- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080). +- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset). +- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000). +- `channels.signal.receiveMode`: `on-start | manual`. +- `channels.signal.ignoreAttachments`: skip attachment downloads. +- `channels.signal.ignoreStories`: ignore stories from the daemon. +- `channels.signal.sendReadReceipts`: forward read receipts. +- `channels.signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). +- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:`). `open` requires `"*"`. Signal has no usernames; use phone/UUID ids. +- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist). +- `channels.signal.groupAllowFrom`: group sender allowlist. +- `channels.signal.historyLimit`: max group messages to include as context (0 disables). +- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms[""].historyLimit`. +- `channels.signal.textChunkLimit`: outbound chunk size (chars). +- `channels.signal.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. +- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB). + +Related global options: + +- `agents.list[].groupChat.mentionPatterns` (Signal does not support native mentions). +- `messages.groupChat.mentionPatterns` (global fallback). +- `messages.responsePrefix`. diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/slack.md b/backend/app/one_person_security_dept/openclaw/docs/channels/slack.md new file mode 100644 index 00000000..869df30a --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/slack.md @@ -0,0 +1,529 @@ +--- +summary: "Slack setup and runtime behavior (Socket Mode + HTTP Events API)" +read_when: + - Setting up Slack or debugging Slack socket/HTTP mode +title: "Slack" +--- + +# Slack + +Status: production-ready for DMs + channels via Slack app integrations. Default mode is Socket Mode; HTTP Events API mode is also supported. + + + + Slack DMs default to pairing mode. + + + Native command behavior and command catalog. + + + Cross-channel diagnostics and repair playbooks. + + + +## Quick setup + + + + + + In Slack app settings: + + - enable **Socket Mode** + - create **App Token** (`xapp-...`) with `connections:write` + - install app and copy **Bot Token** (`xoxb-...`) + + + + +```json5 +{ + channels: { + slack: { + enabled: true, + mode: "socket", + appToken: "xapp-...", + botToken: "xoxb-...", + }, + }, +} +``` + + Env fallback (default account only): + +```bash +SLACK_APP_TOKEN=xapp-... +SLACK_BOT_TOKEN=xoxb-... +``` + + + + + Subscribe bot events for: + + - `app_mention` + - `message.channels`, `message.groups`, `message.im`, `message.mpim` + - `reaction_added`, `reaction_removed` + - `member_joined_channel`, `member_left_channel` + - `channel_rename` + - `pin_added`, `pin_removed` + + Also enable App Home **Messages Tab** for DMs. + + + + +```bash +openclaw gateway +``` + + + + + + + + + + + - set mode to HTTP (`channels.slack.mode="http"`) + - copy Slack **Signing Secret** + - set Event Subscriptions + Interactivity + Slash command Request URL to the same webhook path (default `/slack/events`) + + + + + +```json5 +{ + channels: { + slack: { + enabled: true, + mode: "http", + botToken: "xoxb-...", + signingSecret: "your-signing-secret", + webhookPath: "/slack/events", + }, + }, +} +``` + + + + + Per-account HTTP mode is supported. + + Give each account a distinct `webhookPath` so registrations do not collide. + + + + + + +## Token model + +- `botToken` + `appToken` are required for Socket Mode. +- HTTP mode requires `botToken` + `signingSecret`. +- Config tokens override env fallback. +- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account. +- `userToken` (`xoxp-...`) is config-only (no env fallback) and defaults to read-only behavior (`userTokenReadOnly: true`). +- Optional: add `chat:write.customize` if you want outgoing messages to use the active agent identity (custom `username` and icon). `icon_emoji` uses `:emoji_name:` syntax. + + +For actions/directory reads, user token can be preferred when configured. For writes, bot token remains preferred; user-token writes are only allowed when `userTokenReadOnly: false` and bot token is unavailable. + + +## Access control and routing + + + + `channels.slack.dmPolicy` controls DM access (legacy: `channels.slack.dm.policy`): + + - `pairing` (default) + - `allowlist` + - `open` (requires `channels.slack.allowFrom` to include `"*"`; legacy: `channels.slack.dm.allowFrom`) + - `disabled` + + DM flags: + + - `dm.enabled` (default true) + - `channels.slack.allowFrom` (preferred) + - `dm.allowFrom` (legacy) + - `dm.groupEnabled` (group DMs default false) + - `dm.groupChannels` (optional MPIM allowlist) + + Pairing in DMs uses `openclaw pairing approve slack `. + + + + + `channels.slack.groupPolicy` controls channel handling: + + - `open` + - `allowlist` + - `disabled` + + Channel allowlist lives under `channels.slack.channels`. + + Runtime note: if `channels.slack` is completely missing (env-only setup), runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). + + Name/ID resolution: + + - channel allowlist entries and DM allowlist entries are resolved at startup when token access allows + - unresolved entries are kept as configured + - inbound authorization matching is ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true` + + + + + Channel messages are mention-gated by default. + + Mention sources: + + - explicit app mention (`<@botId>`) + - mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) + - implicit reply-to-bot thread behavior + + Per-channel controls (`channels.slack.channels.`): + + - `requireMention` + - `users` (allowlist) + - `allowBots` + - `skills` + - `systemPrompt` + - `tools`, `toolsBySender` + - `toolsBySender` key format: `id:`, `e164:`, `username:`, `name:`, or `"*"` wildcard + (legacy unprefixed keys still map to `id:` only) + + + + +## Commands and slash behavior + +- Native command auto-mode is **off** for Slack (`commands.native: "auto"` does not enable Slack native commands). +- Enable native Slack command handlers with `channels.slack.commands.native: true` (or global `commands.native: true`). +- When native commands are enabled, register matching slash commands in Slack (`/` names). +- If native commands are not enabled, you can run a single configured slash command via `channels.slack.slashCommand`. +- Native arg menus now adapt their rendering strategy: + - up to 5 options: button blocks + - 6-100 options: static select menu + - more than 100 options: external select with async option filtering when interactivity options handlers are available + - if encoded option values exceed Slack limits, the flow falls back to buttons +- For long option payloads, Slash command argument menus use a confirm dialog before dispatching a selected value. + +Default slash command settings: + +- `enabled: false` +- `name: "openclaw"` +- `sessionPrefix: "slack:slash"` +- `ephemeral: true` + +Slash sessions use isolated keys: + +- `agent::slack:slash:` + +and still route command execution against the target conversation session (`CommandTargetSessionKey`). + +## Threading, sessions, and reply tags + +- DMs route as `direct`; channels as `channel`; MPIMs as `group`. +- With default `session.dmScope=main`, Slack DMs collapse to agent main session. +- Channel sessions: `agent::slack:channel:`. +- Thread replies can create thread session suffixes (`:thread:`) when applicable. +- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`. +- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable). + +Reply threading controls: + +- `channels.slack.replyToMode`: `off|first|all` (default `off`) +- `channels.slack.replyToModeByChatType`: per `direct|group|channel` +- legacy fallback for direct chats: `channels.slack.dm.replyToMode` + +Manual reply tags are supported: + +- `[[reply_to_current]]` +- `[[reply_to:]]` + +Note: `replyToMode="off"` disables **all** reply threading in Slack, including explicit `[[reply_to_*]]` tags. This differs from Telegram, where explicit tags are still honored in `"off"` mode. The difference reflects the platform threading models: Slack threads hide messages from the channel, while Telegram replies remain visible in the main chat flow. + +## Media, chunking, and delivery + + + + Slack file attachments are downloaded from Slack-hosted private URLs (token-authenticated request flow) and written to the media store when fetch succeeds and size limits permit. + + Runtime inbound size cap defaults to `20MB` unless overridden by `channels.slack.mediaMaxMb`. + + + + + - text chunks use `channels.slack.textChunkLimit` (default 4000) + - `channels.slack.chunkMode="newline"` enables paragraph-first splitting + - file sends use Slack upload APIs and can include thread replies (`thread_ts`) + - outbound media cap follows `channels.slack.mediaMaxMb` when configured; otherwise channel sends use MIME-kind defaults from media pipeline + + + + Preferred explicit targets: + + - `user:` for DMs + - `channel:` for channels + + Slack DMs are opened via Slack conversation APIs when sending to user targets. + + + + +## Actions and gates + +Slack actions are controlled by `channels.slack.actions.*`. + +Available action groups in current Slack tooling: + +| Group | Default | +| ---------- | ------- | +| messages | enabled | +| reactions | enabled | +| pins | enabled | +| memberInfo | enabled | +| emojiList | enabled | + +## Events and operational behavior + +- Message edits/deletes/thread broadcasts are mapped into system events. +- Reaction add/remove events are mapped into system events. +- Member join/leave, channel created/renamed, and pin add/remove events are mapped into system events. +- Assistant thread status updates (for "is typing..." indicators in threads) use `assistant.threads.setStatus` and require bot scope `assistant:write`. +- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled. +- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context. +- Block actions and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields: + - block actions: selected values, labels, picker values, and `workflow_*` metadata + - modal `view_submission` and `view_closed` events with routed channel metadata and form inputs + +## Ack reactions + +`ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message. + +Resolution order: + +- `channels.slack.accounts..ackReaction` +- `channels.slack.ackReaction` +- `messages.ackReaction` +- agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀") + +Notes: + +- Slack expects shortcodes (for example `"eyes"`). +- Use `""` to disable the reaction for a channel or account. + +## Manifest and scope checklist + + + + +```json +{ + "display_information": { + "name": "OpenClaw", + "description": "Slack connector for OpenClaw" + }, + "features": { + "bot_user": { + "display_name": "OpenClaw", + "always_online": false + }, + "app_home": { + "messages_tab_enabled": true, + "messages_tab_read_only_enabled": false + }, + "slash_commands": [ + { + "command": "/openclaw", + "description": "Send a message to OpenClaw", + "should_escape": false + } + ] + }, + "oauth_config": { + "scopes": { + "bot": [ + "chat:write", + "channels:history", + "channels:read", + "groups:history", + "im:history", + "mpim:history", + "users:read", + "app_mentions:read", + "assistant:write", + "reactions:read", + "reactions:write", + "pins:read", + "pins:write", + "emoji:read", + "commands", + "files:read", + "files:write" + ] + } + }, + "settings": { + "socket_mode_enabled": true, + "event_subscriptions": { + "bot_events": [ + "app_mention", + "message.channels", + "message.groups", + "message.im", + "message.mpim", + "reaction_added", + "reaction_removed", + "member_joined_channel", + "member_left_channel", + "channel_rename", + "pin_added", + "pin_removed" + ] + } + } +} +``` + + + + + If you configure `channels.slack.userToken`, typical read scopes are: + + - `channels:history`, `groups:history`, `im:history`, `mpim:history` + - `channels:read`, `groups:read`, `im:read`, `mpim:read` + - `users:read` + - `reactions:read` + - `pins:read` + - `emoji:read` + - `search:read` (if you depend on Slack search reads) + + + + +## Troubleshooting + + + + Check, in order: + + - `groupPolicy` + - channel allowlist (`channels.slack.channels`) + - `requireMention` + - per-channel `users` allowlist + + Useful commands: + +```bash +openclaw channels status --probe +openclaw logs --follow +openclaw doctor +``` + + + + + Check: + + - `channels.slack.dm.enabled` + - `channels.slack.dmPolicy` (or legacy `channels.slack.dm.policy`) + - pairing approvals / allowlist entries + +```bash +openclaw pairing list slack +``` + + + + + Validate bot + app tokens and Socket Mode enablement in Slack app settings. + + + + Validate: + + - signing secret + - webhook path + - Slack Request URLs (Events + Interactivity + Slash Commands) + - unique `webhookPath` per HTTP account + + + + + Verify whether you intended: + + - native command mode (`channels.slack.commands.native: true`) with matching slash commands registered in Slack + - or single slash command mode (`channels.slack.slashCommand.enabled: true`) + + Also check `commands.useAccessGroups` and channel/user allowlists. + + + + +## Text streaming + +OpenClaw supports Slack native text streaming via the Agents and AI Apps API. + +`channels.slack.streaming` controls live preview behavior: + +- `off`: disable live preview streaming. +- `partial` (default): replace preview text with the latest partial output. +- `block`: append chunked preview updates. +- `progress`: show progress status text while generating, then send final text. + +`channels.slack.nativeStreaming` controls Slack's native streaming API (`chat.startStream` / `chat.appendStream` / `chat.stopStream`) when `streaming` is `partial` (default: `true`). + +Disable native Slack streaming (keep draft preview behavior): + +```yaml +channels: + slack: + streaming: partial + nativeStreaming: false +``` + +Legacy keys: + +- `channels.slack.streamMode` (`replace | status_final | append`) is auto-migrated to `channels.slack.streaming`. +- boolean `channels.slack.streaming` is auto-migrated to `channels.slack.nativeStreaming`. + +### Requirements + +1. Enable **Agents and AI Apps** in your Slack app settings. +2. Ensure the app has the `assistant:write` scope. +3. A reply thread must be available for that message. Thread selection still follows `replyToMode`. + +### Behavior + +- First text chunk starts a stream (`chat.startStream`). +- Later text chunks append to the same stream (`chat.appendStream`). +- End of reply finalizes stream (`chat.stopStream`). +- Media and non-text payloads fall back to normal delivery. +- If streaming fails mid-reply, OpenClaw falls back to normal delivery for remaining payloads. + +## Configuration reference pointers + +Primary reference: + +- [Configuration reference - Slack](/gateway/configuration-reference#slack) + + High-signal Slack fields: + - mode/auth: `mode`, `botToken`, `appToken`, `signingSecret`, `webhookPath`, `accounts.*` + - DM access: `dm.enabled`, `dmPolicy`, `allowFrom` (legacy: `dm.policy`, `dm.allowFrom`), `dm.groupEnabled`, `dm.groupChannels` + - compatibility toggle: `dangerouslyAllowNameMatching` (break-glass; keep off unless needed) + - channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention` + - threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit` + - delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `streaming`, `nativeStreaming` + - ops/features: `configWrites`, `commands.native`, `slashCommand.*`, `actions.*`, `userToken`, `userTokenReadOnly` + +## Related + +- [Pairing](/channels/pairing) +- [Channel routing](/channels/channel-routing) +- [Troubleshooting](/channels/troubleshooting) +- [Configuration](/gateway/configuration) +- [Slash commands](/tools/slash-commands) diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/synology-chat.md b/backend/app/one_person_security_dept/openclaw/docs/channels/synology-chat.md new file mode 100644 index 00000000..89e96b31 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/synology-chat.md @@ -0,0 +1,128 @@ +--- +summary: "Synology Chat webhook setup and OpenClaw config" +read_when: + - Setting up Synology Chat with OpenClaw + - Debugging Synology Chat webhook routing +title: "Synology Chat" +--- + +# Synology Chat (plugin) + +Status: supported via plugin as a direct-message channel using Synology Chat webhooks. +The plugin accepts inbound messages from Synology Chat outgoing webhooks and sends replies +through a Synology Chat incoming webhook. + +## Plugin required + +Synology Chat is plugin-based and not part of the default core channel install. + +Install from a local checkout: + +```bash +openclaw plugins install ./extensions/synology-chat +``` + +Details: [Plugins](/tools/plugin) + +## Quick setup + +1. Install and enable the Synology Chat plugin. +2. In Synology Chat integrations: + - Create an incoming webhook and copy its URL. + - Create an outgoing webhook with your secret token. +3. Point the outgoing webhook URL to your OpenClaw gateway: + - `https://gateway-host/webhook/synology` by default. + - Or your custom `channels.synology-chat.webhookPath`. +4. Configure `channels.synology-chat` in OpenClaw. +5. Restart gateway and send a DM to the Synology Chat bot. + +Minimal config: + +```json5 +{ + channels: { + "synology-chat": { + enabled: true, + token: "synology-outgoing-token", + incomingUrl: "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=...", + webhookPath: "/webhook/synology", + dmPolicy: "allowlist", + allowedUserIds: ["123456"], + rateLimitPerMinute: 30, + allowInsecureSsl: false, + }, + }, +} +``` + +## Environment variables + +For the default account, you can use env vars: + +- `SYNOLOGY_CHAT_TOKEN` +- `SYNOLOGY_CHAT_INCOMING_URL` +- `SYNOLOGY_NAS_HOST` +- `SYNOLOGY_ALLOWED_USER_IDS` (comma-separated) +- `SYNOLOGY_RATE_LIMIT` +- `OPENCLAW_BOT_NAME` + +Config values override env vars. + +## DM policy and access control + +- `dmPolicy: "allowlist"` is the recommended default. +- `allowedUserIds` accepts a list (or comma-separated string) of Synology user IDs. +- In `allowlist` mode, an empty `allowedUserIds` list is treated as misconfiguration and the webhook route will not start (use `dmPolicy: "open"` for allow-all). +- `dmPolicy: "open"` allows any sender. +- `dmPolicy: "disabled"` blocks DMs. +- Pairing approvals work with: + - `openclaw pairing list synology-chat` + - `openclaw pairing approve synology-chat ` + +## Outbound delivery + +Use numeric Synology Chat user IDs as targets. + +Examples: + +```bash +openclaw message send --channel synology-chat --target 123456 --text "Hello from OpenClaw" +openclaw message send --channel synology-chat --target synology-chat:123456 --text "Hello again" +``` + +Media sends are supported by URL-based file delivery. + +## Multi-account + +Multiple Synology Chat accounts are supported under `channels.synology-chat.accounts`. +Each account can override token, incoming URL, webhook path, DM policy, and limits. + +```json5 +{ + channels: { + "synology-chat": { + enabled: true, + accounts: { + default: { + token: "token-a", + incomingUrl: "https://nas-a.example.com/...token=...", + }, + alerts: { + token: "token-b", + incomingUrl: "https://nas-b.example.com/...token=...", + webhookPath: "/webhook/synology-alerts", + dmPolicy: "allowlist", + allowedUserIds: ["987654"], + }, + }, + }, + }, +} +``` + +## Security notes + +- Keep `token` secret and rotate it if leaked. +- Keep `allowInsecureSsl: false` unless you explicitly trust a self-signed local NAS cert. +- Inbound webhook requests are token-verified and rate-limited per sender. +- Prefer `dmPolicy: "allowlist"` for production. diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/telegram.md b/backend/app/one_person_security_dept/openclaw/docs/channels/telegram.md new file mode 100644 index 00000000..6a454bd8 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/telegram.md @@ -0,0 +1,775 @@ +--- +summary: "Telegram bot support status, capabilities, and configuration" +read_when: + - Working on Telegram features or webhooks +title: "Telegram" +--- + +# Telegram (Bot API) + +Status: production-ready for bot DMs + groups via grammY. Long polling is the default mode; webhook mode is optional. + + + + Default DM policy for Telegram is pairing. + + + Cross-channel diagnostics and repair playbooks. + + + Full channel config patterns and examples. + + + +## Quick setup + + + + Open Telegram and chat with **@BotFather** (confirm the handle is exactly `@BotFather`). + + Run `/newbot`, follow prompts, and save the token. + + + + + +```json5 +{ + channels: { + telegram: { + enabled: true, + botToken: "123:abc", + dmPolicy: "pairing", + groups: { "*": { requireMention: true } }, + }, + }, +} +``` + + Env fallback: `TELEGRAM_BOT_TOKEN=...` (default account only). + Telegram does **not** use `openclaw channels login telegram`; configure token in config/env, then start gateway. + + + + + +```bash +openclaw gateway +openclaw pairing list telegram +openclaw pairing approve telegram +``` + + Pairing codes expire after 1 hour. + + + + + Add the bot to your group, then set `channels.telegram.groups` and `groupPolicy` to match your access model. + + + + +Token resolution order is account-aware. In practice, config values win over env fallback, and `TELEGRAM_BOT_TOKEN` only applies to the default account. + + +## Telegram side settings + + + + Telegram bots default to **Privacy Mode**, which limits what group messages they receive. + + If the bot must see all group messages, either: + + - disable privacy mode via `/setprivacy`, or + - make the bot a group admin. + + When toggling privacy mode, remove + re-add the bot in each group so Telegram applies the change. + + + + + Admin status is controlled in Telegram group settings. + + Admin bots receive all group messages, which is useful for always-on group behavior. + + + + + + - `/setjoingroups` to allow/deny group adds + - `/setprivacy` for group visibility behavior + + + + +## Access control and activation + + + + `channels.telegram.dmPolicy` controls direct message access: + + - `pairing` (default) + - `allowlist` + - `open` (requires `allowFrom` to include `"*"`) + - `disabled` + + `channels.telegram.allowFrom` accepts numeric Telegram user IDs. `telegram:` / `tg:` prefixes are accepted and normalized. + The onboarding wizard accepts `@username` input and resolves it to numeric IDs. + If you upgraded and your config contains `@username` allowlist entries, run `openclaw doctor --fix` to resolve them (best-effort; requires a Telegram bot token). + + ### Finding your Telegram user ID + + Safer (no third-party bot): + + 1. DM your bot. + 2. Run `openclaw logs --follow`. + 3. Read `from.id`. + + Official Bot API method: + +```bash +curl "https://api.telegram.org/bot/getUpdates" +``` + + Third-party method (less private): `@userinfobot` or `@getidsbot`. + + + + + There are two independent controls: + + 1. **Which groups are allowed** (`channels.telegram.groups`) + - no `groups` config: all groups allowed + - `groups` configured: acts as allowlist (explicit IDs or `"*"`) + + 2. **Which senders are allowed in groups** (`channels.telegram.groupPolicy`) + - `open` + - `allowlist` (default) + - `disabled` + + `groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`. + `groupAllowFrom` entries must be numeric Telegram user IDs. + Runtime note: if `channels.telegram` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group policy evaluation (even if `channels.defaults.groupPolicy` is set). + + Example: allow any member in one specific group: + +```json5 +{ + channels: { + telegram: { + groups: { + "-1001234567890": { + groupPolicy: "open", + requireMention: false, + }, + }, + }, + }, +} +``` + + + + + Group replies require mention by default. + + Mention can come from: + + - native `@botusername` mention, or + - mention patterns in: + - `agents.list[].groupChat.mentionPatterns` + - `messages.groupChat.mentionPatterns` + + Session-level command toggles: + + - `/activation always` + - `/activation mention` + + These update session state only. Use config for persistence. + + Persistent config example: + +```json5 +{ + channels: { + telegram: { + groups: { + "*": { requireMention: false }, + }, + }, + }, +} +``` + + Getting the group chat ID: + + - forward a group message to `@userinfobot` / `@getidsbot` + - or read `chat.id` from `openclaw logs --follow` + - or inspect Bot API `getUpdates` + + + + +## Runtime behavior + +- Telegram is owned by the gateway process. +- Routing is deterministic: Telegram inbound replies back to Telegram (the model does not pick channels). +- Inbound messages normalize into the shared channel envelope with reply metadata and media placeholders. +- Group sessions are isolated by group ID. Forum topics append `:topic:` to keep topics isolated. +- DM messages can carry `message_thread_id`; OpenClaw routes them with thread-aware session keys and preserves thread ID for replies. +- Long polling uses grammY runner with per-chat/per-thread sequencing. Overall runner sink concurrency uses `agents.defaults.maxConcurrent`. +- Telegram Bot API has no read-receipt support (`sendReadReceipts` does not apply). + +## Feature reference + + + + OpenClaw can stream partial replies by sending a temporary Telegram message and editing it as text arrives. + + Requirement: + + - `channels.telegram.streaming` is `off | partial | block | progress` (default: `off`) + - `progress` maps to `partial` on Telegram (compat with cross-channel naming) + - legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped + + This works in direct chats and groups/topics. + + For text-only replies, OpenClaw keeps the same preview message and performs a final edit in place (no second message). + + For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message. + + Preview streaming is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming. + + Telegram-only reasoning stream: + + - `/reasoning stream` sends reasoning to the live preview while generating + - final answer is sent without reasoning text + + + + + Outbound text uses Telegram `parse_mode: "HTML"`. + + - Markdown-ish text is rendered to Telegram-safe HTML. + - Raw model HTML is escaped to reduce Telegram parse failures. + - If Telegram rejects parsed HTML, OpenClaw retries as plain text. + + Link previews are enabled by default and can be disabled with `channels.telegram.linkPreview: false`. + + + + + Telegram command menu registration is handled at startup with `setMyCommands`. + + Native command defaults: + + - `commands.native: "auto"` enables native commands for Telegram + + Add custom command menu entries: + +```json5 +{ + channels: { + telegram: { + customCommands: [ + { command: "backup", description: "Git backup" }, + { command: "generate", description: "Create an image" }, + ], + }, + }, +} +``` + + Rules: + + - names are normalized (strip leading `/`, lowercase) + - valid pattern: `a-z`, `0-9`, `_`, length `1..32` + - custom commands cannot override native commands + - conflicts/duplicates are skipped and logged + + Notes: + + - custom commands are menu entries only; they do not auto-implement behavior + - plugin/skill commands can still work when typed even if not shown in Telegram menu + + If native commands are disabled, built-ins are removed. Custom/plugin commands may still register if configured. + + Common setup failure: + + - `setMyCommands failed` usually means outbound DNS/HTTPS to `api.telegram.org` is blocked. + + ### Device pairing commands (`device-pair` plugin) + + When the `device-pair` plugin is installed: + + 1. `/pair` generates setup code + 2. paste code in iOS app + 3. `/pair approve` approves latest pending request + + More details: [Pairing](/channels/pairing#pair-via-telegram-recommended-for-ios). + + + + + Configure inline keyboard scope: + +```json5 +{ + channels: { + telegram: { + capabilities: { + inlineButtons: "allowlist", + }, + }, + }, +} +``` + + Per-account override: + +```json5 +{ + channels: { + telegram: { + accounts: { + main: { + capabilities: { + inlineButtons: "allowlist", + }, + }, + }, + }, + }, +} +``` + + Scopes: + + - `off` + - `dm` + - `group` + - `all` + - `allowlist` (default) + + Legacy `capabilities: ["inlineButtons"]` maps to `inlineButtons: "all"`. + + Message action example: + +```json5 +{ + action: "send", + channel: "telegram", + to: "123456789", + message: "Choose an option:", + buttons: [ + [ + { text: "Yes", callback_data: "yes" }, + { text: "No", callback_data: "no" }, + ], + [{ text: "Cancel", callback_data: "cancel" }], + ], +} +``` + + Callback clicks are passed to the agent as text: + `callback_data: ` + + + + + Telegram tool actions include: + + - `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`) + - `react` (`chatId`, `messageId`, `emoji`) + - `deleteMessage` (`chatId`, `messageId`) + - `editMessage` (`chatId`, `messageId`, `content`) + + Channel message actions expose ergonomic aliases (`send`, `react`, `delete`, `edit`, `sticker`, `sticker-search`). + + Gating controls: + + - `channels.telegram.actions.sendMessage` + - `channels.telegram.actions.editMessage` + - `channels.telegram.actions.deleteMessage` + - `channels.telegram.actions.reactions` + - `channels.telegram.actions.sticker` (default: disabled) + + Reaction removal semantics: [/tools/reactions](/tools/reactions) + + + + + Telegram supports explicit reply threading tags in generated output: + + - `[[reply_to_current]]` replies to the triggering message + - `[[reply_to:]]` replies to a specific Telegram message ID + + `channels.telegram.replyToMode` controls handling: + + - `off` (default) + - `first` + - `all` + + Note: `off` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored. + + + + + Forum supergroups: + + - topic session keys append `:topic:` + - replies and typing target the topic thread + - topic config path: + `channels.telegram.groups..topics.` + + General topic (`threadId=1`) special-case: + + - message sends omit `message_thread_id` (Telegram rejects `sendMessage(...thread_id=1)`) + - typing actions still include `message_thread_id` + + Topic inheritance: topic entries inherit group settings unless overridden (`requireMention`, `allowFrom`, `skills`, `systemPrompt`, `enabled`, `groupPolicy`). + + Template context includes: + + - `MessageThreadId` + - `IsForum` + + DM thread behavior: + + - private chats with `message_thread_id` keep DM routing but use thread-aware session keys/reply targets. + + + + + ### Audio messages + + Telegram distinguishes voice notes vs audio files. + + - default: audio file behavior + - tag `[[audio_as_voice]]` in agent reply to force voice-note send + + Message action example: + +```json5 +{ + action: "send", + channel: "telegram", + to: "123456789", + media: "https://example.com/voice.ogg", + asVoice: true, +} +``` + + ### Video messages + + Telegram distinguishes video files vs video notes. + + Message action example: + +```json5 +{ + action: "send", + channel: "telegram", + to: "123456789", + media: "https://example.com/video.mp4", + asVideoNote: true, +} +``` + + Video notes do not support captions; provided message text is sent separately. + + ### Stickers + + Inbound sticker handling: + + - static WEBP: downloaded and processed (placeholder ``) + - animated TGS: skipped + - video WEBM: skipped + + Sticker context fields: + + - `Sticker.emoji` + - `Sticker.setName` + - `Sticker.fileId` + - `Sticker.fileUniqueId` + - `Sticker.cachedDescription` + + Sticker cache file: + + - `~/.openclaw/telegram/sticker-cache.json` + + Stickers are described once (when possible) and cached to reduce repeated vision calls. + + Enable sticker actions: + +```json5 +{ + channels: { + telegram: { + actions: { + sticker: true, + }, + }, + }, +} +``` + + Send sticker action: + +```json5 +{ + action: "sticker", + channel: "telegram", + to: "123456789", + fileId: "CAACAgIAAxkBAAI...", +} +``` + + Search cached stickers: + +```json5 +{ + action: "sticker-search", + channel: "telegram", + query: "cat waving", + limit: 5, +} +``` + + + + + Telegram reactions arrive as `message_reaction` updates (separate from message payloads). + + When enabled, OpenClaw enqueues system events like: + + - `Telegram reaction added: 👍 by Alice (@alice) on msg 42` + + Config: + + - `channels.telegram.reactionNotifications`: `off | own | all` (default: `own`) + - `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` (default: `minimal`) + + Notes: + + - `own` means user reactions to bot-sent messages only (best-effort via sent-message cache). + - Telegram does not provide thread IDs in reaction updates. + - non-forum groups route to group chat session + - forum groups route to the group general-topic session (`:topic:1`), not the exact originating topic + + `allowed_updates` for polling/webhook include `message_reaction` automatically. + + + + + `ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message. + + Resolution order: + + - `channels.telegram.accounts..ackReaction` + - `channels.telegram.ackReaction` + - `messages.ackReaction` + - agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀") + + Notes: + + - Telegram expects unicode emoji (for example "👀"). + - Use `""` to disable the reaction for a channel or account. + + + + + Channel config writes are enabled by default (`configWrites !== false`). + + Telegram-triggered writes include: + + - group migration events (`migrate_to_chat_id`) to update `channels.telegram.groups` + - `/config set` and `/config unset` (requires command enablement) + + Disable: + +```json5 +{ + channels: { + telegram: { + configWrites: false, + }, + }, +} +``` + + + + + Default: long polling. + + Webhook mode: + + - set `channels.telegram.webhookUrl` + - set `channels.telegram.webhookSecret` (required when webhook URL is set) + - optional `channels.telegram.webhookPath` (default `/telegram-webhook`) + - optional `channels.telegram.webhookHost` (default `127.0.0.1`) + + Default local listener for webhook mode binds to `127.0.0.1:8787`. + + If your public endpoint differs, place a reverse proxy in front and point `webhookUrl` at the public URL. + Set `webhookHost` (for example `0.0.0.0`) when you intentionally need external ingress. + + + + + - `channels.telegram.textChunkLimit` default is 4000. + - `channels.telegram.chunkMode="newline"` prefers paragraph boundaries (blank lines) before length splitting. + - `channels.telegram.mediaMaxMb` (default 5) caps inbound Telegram media download/processing size. + - `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies). + - group context history uses `channels.telegram.historyLimit` or `messages.groupChat.historyLimit` (default 50); `0` disables. + - DM history controls: + - `channels.telegram.dmHistoryLimit` + - `channels.telegram.dms[""].historyLimit` + - outbound Telegram API retries are configurable via `channels.telegram.retry`. + + CLI send target can be numeric chat ID or username: + +```bash +openclaw message send --channel telegram --target 123456789 --message "hi" +openclaw message send --channel telegram --target @name --message "hi" +``` + + + + +## Troubleshooting + + + + + - If `requireMention=false`, Telegram privacy mode must allow full visibility. + - BotFather: `/setprivacy` -> Disable + - then remove + re-add bot to group + - `openclaw channels status` warns when config expects unmentioned group messages. + - `openclaw channels status --probe` can check explicit numeric group IDs; wildcard `"*"` cannot be membership-probed. + - quick session test: `/activation always`. + + + + + + - when `channels.telegram.groups` exists, group must be listed (or include `"*"`) + - verify bot membership in group + - review logs: `openclaw logs --follow` for skip reasons + + + + + + - authorize your sender identity (pairing and/or numeric `allowFrom`) + - command authorization still applies even when group policy is `open` + - `setMyCommands failed` usually indicates DNS/HTTPS reachability issues to `api.telegram.org` + + + + + + - Node 22+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch. + - Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures. + - If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors. + - On VPS hosts with unstable direct egress/TLS, route Telegram API calls through `channels.telegram.proxy`: + +```yaml +channels: + telegram: + proxy: socks5://user:pass@proxy-host:1080 +``` + + - Node 22+ defaults to `autoSelectFamily=true` (except WSL2) and `dnsResultOrder=ipv4first`. + - If your host is WSL2 or explicitly works better with IPv4-only behavior, force family selection: + +```yaml +channels: + telegram: + network: + autoSelectFamily: false +``` + + - Environment overrides (temporary): + - `OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY=1` + - `OPENCLAW_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY=1` + - `OPENCLAW_TELEGRAM_DNS_RESULT_ORDER=ipv4first` + - Validate DNS answers: + +```bash +dig +short api.telegram.org A +dig +short api.telegram.org AAAA +``` + + + + +More help: [Channel troubleshooting](/channels/troubleshooting). + +## Telegram config reference pointers + +Primary reference: + +- `channels.telegram.enabled`: enable/disable channel startup. +- `channels.telegram.botToken`: bot token (BotFather). +- `channels.telegram.tokenFile`: read token from file path. +- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). +- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. +- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist). +- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. +- `channels.telegram.groups`: per-group defaults + allowlist (use `"*"` for global defaults). + - `channels.telegram.groups..groupPolicy`: per-group override for groupPolicy (`open | allowlist | disabled`). + - `channels.telegram.groups..requireMention`: mention gating default. + - `channels.telegram.groups..skills`: skill filter (omit = all skills, empty = none). + - `channels.telegram.groups..allowFrom`: per-group sender allowlist override. + - `channels.telegram.groups..systemPrompt`: extra system prompt for the group. + - `channels.telegram.groups..enabled`: disable the group when `false`. + - `channels.telegram.groups..topics..*`: per-topic overrides (same fields as group). + - `channels.telegram.groups..topics..groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`). + - `channels.telegram.groups..topics..requireMention`: per-topic mention gating override. +- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist). +- `channels.telegram.accounts..capabilities.inlineButtons`: per-account override. +- `channels.telegram.replyToMode`: `off | first | all` (default: `off`). +- `channels.telegram.textChunkLimit`: outbound chunk size (chars). +- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. +- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true). +- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`). +- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). +- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter). +- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled. +- `channels.telegram.network.dnsResultOrder`: override DNS result order (`ipv4first` or `verbatim`). Defaults to `ipv4first` on Node 22+. +- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP). +- `channels.telegram.webhookUrl`: enable webhook mode (requires `channels.telegram.webhookSecret`). +- `channels.telegram.webhookSecret`: webhook secret (required when webhookUrl is set). +- `channels.telegram.webhookPath`: local webhook path (default `/telegram-webhook`). +- `channels.telegram.webhookHost`: local webhook bind host (default `127.0.0.1`). +- `channels.telegram.actions.reactions`: gate Telegram tool reactions. +- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends. +- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes. +- `channels.telegram.actions.sticker`: gate Telegram sticker actions — send and search (default: false). +- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set). +- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set). + +- [Configuration reference - Telegram](/gateway/configuration-reference#telegram) + +Telegram-specific high-signal fields: + +- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*` +- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*` +- command/menu: `commands.native`, `customCommands` +- threading/replies: `replyToMode` +- streaming: `streaming` (preview), `blockStreaming` +- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix` +- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy` +- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost` +- actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker` +- reactions: `reactionNotifications`, `reactionLevel` +- writes/history: `configWrites`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit` + +## Related + +- [Pairing](/channels/pairing) +- [Channel routing](/channels/channel-routing) +- [Multi-agent routing](/concepts/multi-agent) +- [Troubleshooting](/channels/troubleshooting) diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/tlon.md b/backend/app/one_person_security_dept/openclaw/docs/channels/tlon.md new file mode 100644 index 00000000..dbd2015c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/tlon.md @@ -0,0 +1,148 @@ +--- +summary: "Tlon/Urbit support status, capabilities, and configuration" +read_when: + - Working on Tlon/Urbit channel features +title: "Tlon" +--- + +# Tlon (plugin) + +Tlon is a decentralized messenger built on Urbit. OpenClaw connects to your Urbit ship and can +respond to DMs and group chat messages. Group replies require an @ mention by default and can +be further restricted via allowlists. + +Status: supported via plugin. DMs, group mentions, thread replies, and text-only media fallback +(URL appended to caption). Reactions, polls, and native media uploads are not supported. + +## Plugin required + +Tlon ships as a plugin and is not bundled with the core install. + +Install via CLI (npm registry): + +```bash +openclaw plugins install @openclaw/tlon +``` + +Local checkout (when running from a git repo): + +```bash +openclaw plugins install ./extensions/tlon +``` + +Details: [Plugins](/tools/plugin) + +## Setup + +1. Install the Tlon plugin. +2. Gather your ship URL and login code. +3. Configure `channels.tlon`. +4. Restart the gateway. +5. DM the bot or mention it in a group channel. + +Minimal config (single account): + +```json5 +{ + channels: { + tlon: { + enabled: true, + ship: "~sampel-palnet", + url: "https://your-ship-host", + code: "lidlut-tabwed-pillex-ridrup", + }, + }, +} +``` + +Private/LAN ship URLs (advanced): + +By default, OpenClaw blocks private/internal hostnames and IP ranges for this plugin (SSRF hardening). +If your ship URL is on a private network (for example `http://192.168.1.50:8080` or `http://localhost:8080`), +you must explicitly opt in: + +```json5 +{ + channels: { + tlon: { + allowPrivateNetwork: true, + }, + }, +} +``` + +## Group channels + +Auto-discovery is enabled by default. You can also pin channels manually: + +```json5 +{ + channels: { + tlon: { + groupChannels: ["chat/~host-ship/general", "chat/~host-ship/support"], + }, + }, +} +``` + +Disable auto-discovery: + +```json5 +{ + channels: { + tlon: { + autoDiscoverChannels: false, + }, + }, +} +``` + +## Access control + +DM allowlist (empty = allow all): + +```json5 +{ + channels: { + tlon: { + dmAllowlist: ["~zod", "~nec"], + }, + }, +} +``` + +Group authorization (restricted by default): + +```json5 +{ + channels: { + tlon: { + defaultAuthorizedShips: ["~zod"], + authorization: { + channelRules: { + "chat/~host-ship/general": { + mode: "restricted", + allowedShips: ["~zod", "~nec"], + }, + "chat/~host-ship/announcements": { + mode: "open", + }, + }, + }, + }, + }, +} +``` + +## Delivery targets (CLI/cron) + +Use these with `openclaw message send` or cron delivery: + +- DM: `~sampel-palnet` or `dm/~sampel-palnet` +- Group: `chat/~host-ship/channel` or `group:~host-ship/channel` + +## Notes + +- Group replies require a mention (e.g. `~your-bot-ship`) to respond. +- Thread replies: if the inbound message is in a thread, OpenClaw replies in-thread. +- Media: `sendMedia` falls back to text + URL (no native upload). diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/troubleshooting.md b/backend/app/one_person_security_dept/openclaw/docs/channels/troubleshooting.md new file mode 100644 index 00000000..2848947c --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/troubleshooting.md @@ -0,0 +1,117 @@ +--- +summary: "Fast channel level troubleshooting with per channel failure signatures and fixes" +read_when: + - Channel transport says connected but replies fail + - You need channel specific checks before deep provider docs +title: "Channel Troubleshooting" +--- + +# Channel troubleshooting + +Use this page when a channel connects but behavior is wrong. + +## Command ladder + +Run these in order first: + +```bash +openclaw status +openclaw gateway status +openclaw logs --follow +openclaw doctor +openclaw channels status --probe +``` + +Healthy baseline: + +- `Runtime: running` +- `RPC probe: ok` +- Channel probe shows connected/ready + +## WhatsApp + +### WhatsApp failure signatures + +| Symptom | Fastest check | Fix | +| ------------------------------- | --------------------------------------------------- | ------------------------------------------------------- | +| Connected but no DM replies | `openclaw pairing list whatsapp` | Approve sender or switch DM policy/allowlist. | +| Group messages ignored | Check `requireMention` + mention patterns in config | Mention the bot or relax mention policy for that group. | +| Random disconnect/relogin loops | `openclaw channels status --probe` + logs | Re-login and verify credentials directory is healthy. | + +Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whatsapp#troubleshooting-quick) + +## Telegram + +### Telegram failure signatures + +| Symptom | Fastest check | Fix | +| --------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- | +| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. | +| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. | +| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. | +| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. | + +Full troubleshooting: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting) + +## Discord + +### Discord failure signatures + +| Symptom | Fastest check | Fix | +| ------------------------------- | ----------------------------------- | --------------------------------------------------------- | +| Bot online but no guild replies | `openclaw channels status --probe` | Allow guild/channel and verify message content intent. | +| Group messages ignored | Check logs for mention gating drops | Mention bot or set guild/channel `requireMention: false`. | +| DM replies missing | `openclaw pairing list discord` | Approve DM pairing or adjust DM policy. | + +Full troubleshooting: [/channels/discord#troubleshooting](/channels/discord#troubleshooting) + +## Slack + +### Slack failure signatures + +| Symptom | Fastest check | Fix | +| -------------------------------------- | ----------------------------------------- | ------------------------------------------------- | +| Socket mode connected but no responses | `openclaw channels status --probe` | Verify app token + bot token and required scopes. | +| DMs blocked | `openclaw pairing list slack` | Approve pairing or relax DM policy. | +| Channel message ignored | Check `groupPolicy` and channel allowlist | Allow the channel or switch policy to `open`. | + +Full troubleshooting: [/channels/slack#troubleshooting](/channels/slack#troubleshooting) + +## iMessage and BlueBubbles + +### iMessage and BlueBubbles failure signatures + +| Symptom | Fastest check | Fix | +| -------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------- | +| No inbound events | Verify webhook/server reachability and app permissions | Fix webhook URL or BlueBubbles server state. | +| Can send but no receive on macOS | Check macOS privacy permissions for Messages automation | Re-grant TCC permissions and restart channel process. | +| DM sender blocked | `openclaw pairing list imessage` or `openclaw pairing list bluebubbles` | Approve pairing or update allowlist. | + +Full troubleshooting: + +- [/channels/imessage#troubleshooting-macos-privacy-and-security-tcc](/channels/imessage#troubleshooting-macos-privacy-and-security-tcc) +- [/channels/bluebubbles#troubleshooting](/channels/bluebubbles#troubleshooting) + +## Signal + +### Signal failure signatures + +| Symptom | Fastest check | Fix | +| ------------------------------- | ------------------------------------------ | -------------------------------------------------------- | +| Daemon reachable but bot silent | `openclaw channels status --probe` | Verify `signal-cli` daemon URL/account and receive mode. | +| DM blocked | `openclaw pairing list signal` | Approve sender or adjust DM policy. | +| Group replies do not trigger | Check group allowlist and mention patterns | Add sender/group or loosen gating. | + +Full troubleshooting: [/channels/signal#troubleshooting](/channels/signal#troubleshooting) + +## Matrix + +### Matrix failure signatures + +| Symptom | Fastest check | Fix | +| ----------------------------------- | -------------------------------------------- | ----------------------------------------------- | +| Logged in but ignores room messages | `openclaw channels status --probe` | Check `groupPolicy` and room allowlist. | +| DMs do not process | `openclaw pairing list matrix` | Approve sender or adjust DM policy. | +| Encrypted rooms fail | Verify crypto module and encryption settings | Enable encryption support and rejoin/sync room. | + +Full troubleshooting: [/channels/matrix#troubleshooting](/channels/matrix#troubleshooting) diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/twitch.md b/backend/app/one_person_security_dept/openclaw/docs/channels/twitch.md new file mode 100644 index 00000000..32670f31 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/twitch.md @@ -0,0 +1,379 @@ +--- +summary: "Twitch chat bot configuration and setup" +read_when: + - Setting up Twitch chat integration for OpenClaw +title: "Twitch" +--- + +# Twitch (plugin) + +Twitch chat support via IRC connection. OpenClaw connects as a Twitch user (bot account) to receive and send messages in channels. + +## Plugin required + +Twitch ships as a plugin and is not bundled with the core install. + +Install via CLI (npm registry): + +```bash +openclaw plugins install @openclaw/twitch +``` + +Local checkout (when running from a git repo): + +```bash +openclaw plugins install ./extensions/twitch +``` + +Details: [Plugins](/tools/plugin) + +## Quick setup (beginner) + +1. Create a dedicated Twitch account for the bot (or use an existing account). +2. Generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/) + - Select **Bot Token** + - Verify scopes `chat:read` and `chat:write` are selected + - Copy the **Client ID** and **Access Token** +3. Find your Twitch user ID: [https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/) +4. Configure the token: + - Env: `OPENCLAW_TWITCH_ACCESS_TOKEN=...` (default account only) + - Or config: `channels.twitch.accessToken` + - If both are set, config takes precedence (env fallback is default-account only). +5. Start the gateway. + +**⚠️ Important:** Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot. `requireMention` defaults to `true`. + +Minimal config: + +```json5 +{ + channels: { + twitch: { + enabled: true, + username: "openclaw", // Bot's Twitch account + accessToken: "oauth:abc123...", // OAuth Access Token (or use OPENCLAW_TWITCH_ACCESS_TOKEN env var) + clientId: "xyz789...", // Client ID from Token Generator + channel: "vevisk", // Which Twitch channel's chat to join (required) + allowFrom: ["123456789"], // (recommended) Your Twitch user ID only - get it from https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/ + }, + }, +} +``` + +## What it is + +- A Twitch channel owned by the Gateway. +- Deterministic routing: replies always go back to Twitch. +- Each account maps to an isolated session key `agent::twitch:`. +- `username` is the bot's account (who authenticates), `channel` is which chat room to join. + +## Setup (detailed) + +### Generate credentials + +Use [Twitch Token Generator](https://twitchtokengenerator.com/): + +- Select **Bot Token** +- Verify scopes `chat:read` and `chat:write` are selected +- Copy the **Client ID** and **Access Token** + +No manual app registration needed. Tokens expire after several hours. + +### Configure the bot + +**Env var (default account only):** + +```bash +OPENCLAW_TWITCH_ACCESS_TOKEN=oauth:abc123... +``` + +**Or config:** + +```json5 +{ + channels: { + twitch: { + enabled: true, + username: "openclaw", + accessToken: "oauth:abc123...", + clientId: "xyz789...", + channel: "vevisk", + }, + }, +} +``` + +If both env and config are set, config takes precedence. + +### Access control (recommended) + +```json5 +{ + channels: { + twitch: { + allowFrom: ["123456789"], // (recommended) Your Twitch user ID only + }, + }, +} +``` + +Prefer `allowFrom` for a hard allowlist. Use `allowedRoles` instead if you want role-based access. + +**Available roles:** `"moderator"`, `"owner"`, `"vip"`, `"subscriber"`, `"all"`. + +**Why user IDs?** Usernames can change, allowing impersonation. User IDs are permanent. + +Find your Twitch user ID: [https://www.streamweasels.com/tools/convert-twitch-username-%20to-user-id/](https://www.streamweasels.com/tools/convert-twitch-username-%20to-user-id/) (Convert your Twitch username to ID) + +## Token refresh (optional) + +Tokens from [Twitch Token Generator](https://twitchtokengenerator.com/) cannot be automatically refreshed - regenerate when expired. + +For automatic token refresh, create your own Twitch application at [Twitch Developer Console](https://dev.twitch.tv/console) and add to config: + +```json5 +{ + channels: { + twitch: { + clientSecret: "your_client_secret", + refreshToken: "your_refresh_token", + }, + }, +} +``` + +The bot automatically refreshes tokens before expiration and logs refresh events. + +## Multi-account support + +Use `channels.twitch.accounts` with per-account tokens. See [`gateway/configuration`](/gateway/configuration) for the shared pattern. + +Example (one bot account in two channels): + +```json5 +{ + channels: { + twitch: { + accounts: { + channel1: { + username: "openclaw", + accessToken: "oauth:abc123...", + clientId: "xyz789...", + channel: "vevisk", + }, + channel2: { + username: "openclaw", + accessToken: "oauth:def456...", + clientId: "uvw012...", + channel: "secondchannel", + }, + }, + }, + }, +} +``` + +**Note:** Each account needs its own token (one token per channel). + +## Access control + +### Role-based restrictions + +```json5 +{ + channels: { + twitch: { + accounts: { + default: { + allowedRoles: ["moderator", "vip"], + }, + }, + }, + }, +} +``` + +### Allowlist by User ID (most secure) + +```json5 +{ + channels: { + twitch: { + accounts: { + default: { + allowFrom: ["123456789", "987654321"], + }, + }, + }, + }, +} +``` + +### Role-based access (alternative) + +`allowFrom` is a hard allowlist. When set, only those user IDs are allowed. +If you want role-based access, leave `allowFrom` unset and configure `allowedRoles` instead: + +```json5 +{ + channels: { + twitch: { + accounts: { + default: { + allowedRoles: ["moderator"], + }, + }, + }, + }, +} +``` + +### Disable @mention requirement + +By default, `requireMention` is `true`. To disable and respond to all messages: + +```json5 +{ + channels: { + twitch: { + accounts: { + default: { + requireMention: false, + }, + }, + }, + }, +} +``` + +## Troubleshooting + +First, run diagnostic commands: + +```bash +openclaw doctor +openclaw channels status --probe +``` + +### Bot doesn't respond to messages + +**Check access control:** Ensure your user ID is in `allowFrom`, or temporarily remove +`allowFrom` and set `allowedRoles: ["all"]` to test. + +**Check the bot is in the channel:** The bot must join the channel specified in `channel`. + +### Token issues + +**"Failed to connect" or authentication errors:** + +- Verify `accessToken` is the OAuth access token value (typically starts with `oauth:` prefix) +- Check token has `chat:read` and `chat:write` scopes +- If using token refresh, verify `clientSecret` and `refreshToken` are set + +### Token refresh not working + +**Check logs for refresh events:** + +``` +Using env token source for mybot +Access token refreshed for user 123456 (expires in 14400s) +``` + +If you see "token refresh disabled (no refresh token)": + +- Ensure `clientSecret` is provided +- Ensure `refreshToken` is provided + +## Config + +**Account config:** + +- `username` - Bot username +- `accessToken` - OAuth access token with `chat:read` and `chat:write` +- `clientId` - Twitch Client ID (from Token Generator or your app) +- `channel` - Channel to join (required) +- `enabled` - Enable this account (default: `true`) +- `clientSecret` - Optional: For automatic token refresh +- `refreshToken` - Optional: For automatic token refresh +- `expiresIn` - Token expiry in seconds +- `obtainmentTimestamp` - Token obtained timestamp +- `allowFrom` - User ID allowlist +- `allowedRoles` - Role-based access control (`"moderator" | "owner" | "vip" | "subscriber" | "all"`) +- `requireMention` - Require @mention (default: `true`) + +**Provider options:** + +- `channels.twitch.enabled` - Enable/disable channel startup +- `channels.twitch.username` - Bot username (simplified single-account config) +- `channels.twitch.accessToken` - OAuth access token (simplified single-account config) +- `channels.twitch.clientId` - Twitch Client ID (simplified single-account config) +- `channels.twitch.channel` - Channel to join (simplified single-account config) +- `channels.twitch.accounts.` - Multi-account config (all account fields above) + +Full example: + +```json5 +{ + channels: { + twitch: { + enabled: true, + username: "openclaw", + accessToken: "oauth:abc123...", + clientId: "xyz789...", + channel: "vevisk", + clientSecret: "secret123...", + refreshToken: "refresh456...", + allowFrom: ["123456789"], + allowedRoles: ["moderator", "vip"], + accounts: { + default: { + username: "mybot", + accessToken: "oauth:abc123...", + clientId: "xyz789...", + channel: "your_channel", + enabled: true, + clientSecret: "secret123...", + refreshToken: "refresh456...", + expiresIn: 14400, + obtainmentTimestamp: 1706092800000, + allowFrom: ["123456789", "987654321"], + allowedRoles: ["moderator"], + }, + }, + }, + }, +} +``` + +## Tool actions + +The agent can call `twitch` with action: + +- `send` - Send a message to a channel + +Example: + +```json5 +{ + action: "twitch", + params: { + message: "Hello Twitch!", + to: "#mychannel", + }, +} +``` + +## Safety & ops + +- **Treat tokens like passwords** - Never commit tokens to git +- **Use automatic token refresh** for long-running bots +- **Use user ID allowlists** instead of usernames for access control +- **Monitor logs** for token refresh events and connection status +- **Scope tokens minimally** - Only request `chat:read` and `chat:write` +- **If stuck**: Restart the gateway after confirming no other process owns the session + +## Limits + +- **500 characters** per message (auto-chunked at word boundaries) +- Markdown is stripped before chunking +- No rate limiting (uses Twitch's built-in rate limits) diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/whatsapp.md b/backend/app/one_person_security_dept/openclaw/docs/channels/whatsapp.md new file mode 100644 index 00000000..d92dfda9 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/whatsapp.md @@ -0,0 +1,444 @@ +--- +summary: "WhatsApp channel support, access controls, delivery behavior, and operations" +read_when: + - Working on WhatsApp/web channel behavior or inbox routing +title: "WhatsApp" +--- + +# WhatsApp (Web channel) + +Status: production-ready via WhatsApp Web (Baileys). Gateway owns linked session(s). + + + + Default DM policy is pairing for unknown senders. + + + Cross-channel diagnostics and repair playbooks. + + + Full channel config patterns and examples. + + + +## Quick setup + + + + +```json5 +{ + channels: { + whatsapp: { + dmPolicy: "pairing", + allowFrom: ["+15551234567"], + groupPolicy: "allowlist", + groupAllowFrom: ["+15551234567"], + }, + }, +} +``` + + + + + +```bash +openclaw channels login --channel whatsapp +``` + + For a specific account: + +```bash +openclaw channels login --channel whatsapp --account work +``` + + + + + +```bash +openclaw gateway +``` + + + + + +```bash +openclaw pairing list whatsapp +openclaw pairing approve whatsapp +``` + + Pairing requests expire after 1 hour. Pending requests are capped at 3 per channel. + + + + + +OpenClaw recommends running WhatsApp on a separate number when possible. (The channel metadata and onboarding flow are optimized for that setup, but personal-number setups are also supported.) + + +## Deployment patterns + + + + This is the cleanest operational mode: + + - separate WhatsApp identity for OpenClaw + - clearer DM allowlists and routing boundaries + - lower chance of self-chat confusion + + Minimal policy pattern: + + ```json5 + { + channels: { + whatsapp: { + dmPolicy: "allowlist", + allowFrom: ["+15551234567"], + }, + }, + } + ``` + + + + + Onboarding supports personal-number mode and writes a self-chat-friendly baseline: + + - `dmPolicy: "allowlist"` + - `allowFrom` includes your personal number + - `selfChatMode: true` + + In runtime, self-chat protections key off the linked self number and `allowFrom`. + + + + + The messaging platform channel is WhatsApp Web-based (`Baileys`) in current OpenClaw channel architecture. + + There is no separate Twilio WhatsApp messaging channel in the built-in chat-channel registry. + + + + +## Runtime model + +- Gateway owns the WhatsApp socket and reconnect loop. +- Outbound sends require an active WhatsApp listener for the target account. +- Status and broadcast chats are ignored (`@status`, `@broadcast`). +- Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session). +- Group sessions are isolated (`agent::whatsapp:group:`). + +## Access control and activation + + + + `channels.whatsapp.dmPolicy` controls direct chat access: + + - `pairing` (default) + - `allowlist` + - `open` (requires `allowFrom` to include `"*"`) + - `disabled` + + `allowFrom` accepts E.164-style numbers (normalized internally). + + Multi-account override: `channels.whatsapp.accounts..dmPolicy` (and `allowFrom`) take precedence over channel-level defaults for that account. + + Runtime behavior details: + + - pairings are persisted in channel allow-store and merged with configured `allowFrom` + - if no allowlist is configured, the linked self number is allowed by default + - outbound `fromMe` DMs are never auto-paired + + + + + Group access has two layers: + + 1. **Group membership allowlist** (`channels.whatsapp.groups`) + - if `groups` is omitted, all groups are eligible + - if `groups` is present, it acts as a group allowlist (`"*"` allowed) + + 2. **Group sender policy** (`channels.whatsapp.groupPolicy` + `groupAllowFrom`) + - `open`: sender allowlist bypassed + - `allowlist`: sender must match `groupAllowFrom` (or `*`) + - `disabled`: block all group inbound + + Sender allowlist fallback: + + - if `groupAllowFrom` is unset, runtime falls back to `allowFrom` when available + - sender allowlists are evaluated before mention/reply activation + + Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is `allowlist` (with a warning log), even if `channels.defaults.groupPolicy` is set. + + + + + Group replies require mention by default. + + Mention detection includes: + + - explicit WhatsApp mentions of the bot identity + - configured mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) + - implicit reply-to-bot detection (reply sender matches bot identity) + + Security note: + + - quote/reply only satisfies mention gating; it does **not** grant sender authorization + - with `groupPolicy: "allowlist"`, non-allowlisted senders are still blocked even if they reply to an allowlisted user's message + + Session-level activation command: + + - `/activation mention` + - `/activation always` + + `activation` updates session state (not global config). It is owner-gated. + + + + +## Personal-number and self-chat behavior + +When the linked self number is also present in `allowFrom`, WhatsApp self-chat safeguards activate: + +- skip read receipts for self-chat turns +- ignore mention-JID auto-trigger behavior that would otherwise ping yourself +- if `messages.responsePrefix` is unset, self-chat replies default to `[{identity.name}]` or `[openclaw]` + +## Message normalization and context + + + + Incoming WhatsApp messages are wrapped in the shared inbound envelope. + + If a quoted reply exists, context is appended in this form: + + ```text + [Replying to id:] + + [/Replying] + ``` + + Reply metadata fields are also populated when available (`ReplyToId`, `ReplyToBody`, `ReplyToSender`, sender JID/E.164). + + + + + Media-only inbound messages are normalized with placeholders such as: + + - `` + - `` + - `` + - `` + - `` + + Location and contact payloads are normalized into textual context before routing. + + + + + For groups, unprocessed messages can be buffered and injected as context when the bot is finally triggered. + + - default limit: `50` + - config: `channels.whatsapp.historyLimit` + - fallback: `messages.groupChat.historyLimit` + - `0` disables + + Injection markers: + + - `[Chat messages since your last reply - for context]` + - `[Current message - respond to this]` + + + + + Read receipts are enabled by default for accepted inbound WhatsApp messages. + + Disable globally: + + ```json5 + { + channels: { + whatsapp: { + sendReadReceipts: false, + }, + }, + } + ``` + + Per-account override: + + ```json5 + { + channels: { + whatsapp: { + accounts: { + work: { + sendReadReceipts: false, + }, + }, + }, + }, + } + ``` + + Self-chat turns skip read receipts even when globally enabled. + + + + +## Delivery, chunking, and media + + + + - default chunk limit: `channels.whatsapp.textChunkLimit = 4000` + - `channels.whatsapp.chunkMode = "length" | "newline"` + - `newline` mode prefers paragraph boundaries (blank lines), then falls back to length-safe chunking + + + + - supports image, video, audio (PTT voice-note), and document payloads + - `audio/ogg` is rewritten to `audio/ogg; codecs=opus` for voice-note compatibility + - animated GIF playback is supported via `gifPlayback: true` on video sends + - captions are applied to the first media item when sending multi-media reply payloads + - media source can be HTTP(S), `file://`, or local paths + + + + - inbound media save cap: `channels.whatsapp.mediaMaxMb` (default `50`) + - outbound media cap for auto-replies: `agents.defaults.mediaMaxMb` (default `5MB`) + - images are auto-optimized (resize/quality sweep) to fit limits + - on media send failure, first-item fallback sends text warning instead of dropping the response silently + + + +## Acknowledgment reactions + +WhatsApp supports immediate ack reactions on inbound receipt via `channels.whatsapp.ackReaction`. + +```json5 +{ + channels: { + whatsapp: { + ackReaction: { + emoji: "👀", + direct: true, + group: "mentions", // always | mentions | never + }, + }, + }, +} +``` + +Behavior notes: + +- sent immediately after inbound is accepted (pre-reply) +- failures are logged but do not block normal reply delivery +- group mode `mentions` reacts on mention-triggered turns; group activation `always` acts as bypass for this check +- WhatsApp uses `channels.whatsapp.ackReaction` (legacy `messages.ackReaction` is not used here) + +## Multi-account and credentials + + + + - account ids come from `channels.whatsapp.accounts` + - default account selection: `default` if present, otherwise first configured account id (sorted) + - account ids are normalized internally for lookup + + + + - current auth path: `~/.openclaw/credentials/whatsapp//creds.json` + - backup file: `creds.json.bak` + - legacy default auth in `~/.openclaw/credentials/` is still recognized/migrated for default-account flows + + + + `openclaw channels logout --channel whatsapp [--account ]` clears WhatsApp auth state for that account. + + In legacy auth directories, `oauth.json` is preserved while Baileys auth files are removed. + + + + +## Tools, actions, and config writes + +- Agent tool support includes WhatsApp reaction action (`react`). +- Action gates: + - `channels.whatsapp.actions.reactions` + - `channels.whatsapp.actions.polls` +- Channel-initiated config writes are enabled by default (disable via `channels.whatsapp.configWrites=false`). + +## Troubleshooting + + + + Symptom: channel status reports not linked. + + Fix: + + ```bash + openclaw channels login --channel whatsapp + openclaw channels status + ``` + + + + + Symptom: linked account with repeated disconnects or reconnect attempts. + + Fix: + + ```bash + openclaw doctor + openclaw logs --follow + ``` + + If needed, re-link with `channels login`. + + + + + Outbound sends fail fast when no active gateway listener exists for the target account. + + Make sure gateway is running and the account is linked. + + + + + Check in this order: + + - `groupPolicy` + - `groupAllowFrom` / `allowFrom` + - `groups` allowlist entries + - mention gating (`requireMention` + mention patterns) + - duplicate keys in `openclaw.json` (JSON5): later entries override earlier ones, so keep a single `groupPolicy` per scope + + + + + WhatsApp gateway runtime should use Node. Bun is flagged as incompatible for stable WhatsApp/Telegram gateway operation. + + + +## Configuration reference pointers + +Primary reference: + +- [Configuration reference - WhatsApp](/gateway/configuration-reference#whatsapp) + +High-signal WhatsApp fields: + +- access: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups` +- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction` +- multi-account: `accounts..enabled`, `accounts..authDir`, account-level overrides +- operations: `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*` +- session behavior: `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms..historyLimit` + +## Related + +- [Pairing](/channels/pairing) +- [Channel routing](/channels/channel-routing) +- [Multi-agent routing](/concepts/multi-agent) +- [Troubleshooting](/channels/troubleshooting) diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/zalo.md b/backend/app/one_person_security_dept/openclaw/docs/channels/zalo.md new file mode 100644 index 00000000..8e5d8ab0 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/zalo.md @@ -0,0 +1,206 @@ +--- +summary: "Zalo bot support status, capabilities, and configuration" +read_when: + - Working on Zalo features or webhooks +title: "Zalo" +--- + +# Zalo (Bot API) + +Status: experimental. DMs are supported; group handling is available with explicit group policy controls. + +## Plugin required + +Zalo ships as a plugin and is not bundled with the core install. + +- Install via CLI: `openclaw plugins install @openclaw/zalo` +- Or select **Zalo** during onboarding and confirm the install prompt +- Details: [Plugins](/tools/plugin) + +## Quick setup (beginner) + +1. Install the Zalo plugin: + - From a source checkout: `openclaw plugins install ./extensions/zalo` + - From npm (if published): `openclaw plugins install @openclaw/zalo` + - Or pick **Zalo** in onboarding and confirm the install prompt +2. Set the token: + - Env: `ZALO_BOT_TOKEN=...` + - Or config: `channels.zalo.botToken: "..."`. +3. Restart the gateway (or finish onboarding). +4. DM access is pairing by default; approve the pairing code on first contact. + +Minimal config: + +```json5 +{ + channels: { + zalo: { + enabled: true, + botToken: "12345689:abc-xyz", + dmPolicy: "pairing", + }, + }, +} +``` + +## What it is + +Zalo is a Vietnam-focused messaging app; its Bot API lets the Gateway run a bot for 1:1 conversations. +It is a good fit for support or notifications where you want deterministic routing back to Zalo. + +- A Zalo Bot API channel owned by the Gateway. +- Deterministic routing: replies go back to Zalo; the model never chooses channels. +- DMs share the agent's main session. +- Groups are supported with policy controls (`groupPolicy` + `groupAllowFrom`) and default to fail-closed allowlist behavior. + +## Setup (fast path) + +### 1) Create a bot token (Zalo Bot Platform) + +1. Go to [https://bot.zaloplatforms.com](https://bot.zaloplatforms.com) and sign in. +2. Create a new bot and configure its settings. +3. Copy the bot token (format: `12345689:abc-xyz`). + +### 2) Configure the token (env or config) + +Example: + +```json5 +{ + channels: { + zalo: { + enabled: true, + botToken: "12345689:abc-xyz", + dmPolicy: "pairing", + }, + }, +} +``` + +Env option: `ZALO_BOT_TOKEN=...` (works for the default account only). + +Multi-account support: use `channels.zalo.accounts` with per-account tokens and optional `name`. + +3. Restart the gateway. Zalo starts when a token is resolved (env or config). +4. DM access defaults to pairing. Approve the code when the bot is first contacted. + +## How it works (behavior) + +- Inbound messages are normalized into the shared channel envelope with media placeholders. +- Replies always route back to the same Zalo chat. +- Long-polling by default; webhook mode available with `channels.zalo.webhookUrl`. + +## Limits + +- Outbound text is chunked to 2000 characters (Zalo API limit). +- Media downloads/uploads are capped by `channels.zalo.mediaMaxMb` (default 5). +- Streaming is blocked by default due to the 2000 char limit making streaming less useful. + +## Access control (DMs) + +### DM access + +- Default: `channels.zalo.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour). +- Approve via: + - `openclaw pairing list zalo` + - `openclaw pairing approve zalo ` +- Pairing is the default token exchange. Details: [Pairing](/channels/pairing) +- `channels.zalo.allowFrom` accepts numeric user IDs (no username lookup available). + +## Access control (Groups) + +- `channels.zalo.groupPolicy` controls group inbound handling: `open | allowlist | disabled`. +- Default behavior is fail-closed: `allowlist`. +- `channels.zalo.groupAllowFrom` restricts which sender IDs can trigger the bot in groups. +- If `groupAllowFrom` is unset, Zalo falls back to `allowFrom` for sender checks. +- `groupPolicy: "disabled"` blocks all group messages. +- `groupPolicy: "open"` allows any group member (mention-gated). +- Runtime note: if `channels.zalo` is missing entirely, runtime still falls back to `groupPolicy="allowlist"` for safety. + +## Long-polling vs webhook + +- Default: long-polling (no public URL required). +- Webhook mode: set `channels.zalo.webhookUrl` and `channels.zalo.webhookSecret`. + - The webhook secret must be 8-256 characters. + - Webhook URL must use HTTPS. + - Zalo sends events with `X-Bot-Api-Secret-Token` header for verification. + - Gateway HTTP handles webhook requests at `channels.zalo.webhookPath` (defaults to the webhook URL path). + - Requests must use `Content-Type: application/json` (or `+json` media types). + - Duplicate events (`event_name + message_id`) are ignored for a short replay window. + - Burst traffic is rate-limited per path/source and may return HTTP 429. + +**Note:** getUpdates (polling) and webhook are mutually exclusive per Zalo API docs. + +## Supported message types + +- **Text messages**: Full support with 2000 character chunking. +- **Image messages**: Download and process inbound images; send images via `sendPhoto`. +- **Stickers**: Logged but not fully processed (no agent response). +- **Unsupported types**: Logged (e.g., messages from protected users). + +## Capabilities + +| Feature | Status | +| --------------- | -------------------------------------------------------- | +| Direct messages | ✅ Supported | +| Groups | ⚠️ Supported with policy controls (allowlist by default) | +| Media (images) | ✅ Supported | +| Reactions | ❌ Not supported | +| Threads | ❌ Not supported | +| Polls | ❌ Not supported | +| Native commands | ❌ Not supported | +| Streaming | ⚠️ Blocked (2000 char limit) | + +## Delivery targets (CLI/cron) + +- Use a chat id as the target. +- Example: `openclaw message send --channel zalo --target 123456789 --message "hi"`. + +## Troubleshooting + +**Bot doesn't respond:** + +- Check that the token is valid: `openclaw channels status --probe` +- Verify the sender is approved (pairing or allowFrom) +- Check gateway logs: `openclaw logs --follow` + +**Webhook not receiving events:** + +- Ensure webhook URL uses HTTPS +- Verify secret token is 8-256 characters +- Confirm the gateway HTTP endpoint is reachable on the configured path +- Check that getUpdates polling is not running (they're mutually exclusive) + +## Configuration reference (Zalo) + +Full configuration: [Configuration](/gateway/configuration) + +Provider options: + +- `channels.zalo.enabled`: enable/disable channel startup. +- `channels.zalo.botToken`: bot token from Zalo Bot Platform. +- `channels.zalo.tokenFile`: read token from file path. +- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). +- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs. +- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). +- `channels.zalo.groupAllowFrom`: group sender allowlist (user IDs). Falls back to `allowFrom` when unset. +- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5). +- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required). +- `channels.zalo.webhookSecret`: webhook secret (8-256 chars). +- `channels.zalo.webhookPath`: webhook path on the gateway HTTP server. +- `channels.zalo.proxy`: proxy URL for API requests. + +Multi-account options: + +- `channels.zalo.accounts..botToken`: per-account token. +- `channels.zalo.accounts..tokenFile`: per-account token file. +- `channels.zalo.accounts..name`: display name. +- `channels.zalo.accounts..enabled`: enable/disable account. +- `channels.zalo.accounts..dmPolicy`: per-account DM policy. +- `channels.zalo.accounts..allowFrom`: per-account allowlist. +- `channels.zalo.accounts..groupPolicy`: per-account group policy. +- `channels.zalo.accounts..groupAllowFrom`: per-account group sender allowlist. +- `channels.zalo.accounts..webhookUrl`: per-account webhook URL. +- `channels.zalo.accounts..webhookSecret`: per-account webhook secret. +- `channels.zalo.accounts..webhookPath`: per-account webhook path. +- `channels.zalo.accounts..proxy`: per-account proxy URL. diff --git a/backend/app/one_person_security_dept/openclaw/docs/channels/zalouser.md b/backend/app/one_person_security_dept/openclaw/docs/channels/zalouser.md new file mode 100644 index 00000000..e93e71a6 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/channels/zalouser.md @@ -0,0 +1,140 @@ +--- +summary: "Zalo personal account support via zca-cli (QR login), capabilities, and configuration" +read_when: + - Setting up Zalo Personal for OpenClaw + - Debugging Zalo Personal login or message flow +title: "Zalo Personal" +--- + +# Zalo Personal (unofficial) + +Status: experimental. This integration automates a **personal Zalo account** via `zca-cli`. + +> **Warning:** This is an unofficial integration and may result in account suspension/ban. Use at your own risk. + +## Plugin required + +Zalo Personal ships as a plugin and is not bundled with the core install. + +- Install via CLI: `openclaw plugins install @openclaw/zalouser` +- Or from a source checkout: `openclaw plugins install ./extensions/zalouser` +- Details: [Plugins](/tools/plugin) + +## Prerequisite: zca-cli + +The Gateway machine must have the `zca` binary available in `PATH`. + +- Verify: `zca --version` +- If missing, install zca-cli (see `extensions/zalouser/README.md` or the upstream zca-cli docs). + +## Quick setup (beginner) + +1. Install the plugin (see above). +2. Login (QR, on the Gateway machine): + - `openclaw channels login --channel zalouser` + - Scan the QR code in the terminal with the Zalo mobile app. +3. Enable the channel: + +```json5 +{ + channels: { + zalouser: { + enabled: true, + dmPolicy: "pairing", + }, + }, +} +``` + +4. Restart the Gateway (or finish onboarding). +5. DM access defaults to pairing; approve the pairing code on first contact. + +## What it is + +- Uses `zca listen` to receive inbound messages. +- Uses `zca msg ...` to send replies (text/media/link). +- Designed for “personal account” use cases where Zalo Bot API is not available. + +## Naming + +Channel id is `zalouser` to make it explicit this automates a **personal Zalo user account** (unofficial). We keep `zalo` reserved for a potential future official Zalo API integration. + +## Finding IDs (directory) + +Use the directory CLI to discover peers/groups and their IDs: + +```bash +openclaw directory self --channel zalouser +openclaw directory peers list --channel zalouser --query "name" +openclaw directory groups list --channel zalouser --query "work" +``` + +## Limits + +- Outbound text is chunked to ~2000 characters (Zalo client limits). +- Streaming is blocked by default. + +## Access control (DMs) + +`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`). +`channels.zalouser.allowFrom` accepts user IDs or names. The wizard resolves names to IDs via `zca friend find` when available. + +Approve via: + +- `openclaw pairing list zalouser` +- `openclaw pairing approve zalouser ` + +## Group access (optional) + +- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset. +- Restrict to an allowlist with: + - `channels.zalouser.groupPolicy = "allowlist"` + - `channels.zalouser.groups` (keys are group IDs or names) +- Block all groups: `channels.zalouser.groupPolicy = "disabled"`. +- The configure wizard can prompt for group allowlists. +- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed. + +Example: + +```json5 +{ + channels: { + zalouser: { + groupPolicy: "allowlist", + groups: { + "123456789": { allow: true }, + "Work Chat": { allow: true }, + }, + }, + }, +} +``` + +## Multi-account + +Accounts map to zca profiles. Example: + +```json5 +{ + channels: { + zalouser: { + enabled: true, + defaultAccount: "default", + accounts: { + work: { enabled: true, profile: "work" }, + }, + }, + }, +} +``` + +## Troubleshooting + +**`zca` not found:** + +- Install zca-cli and ensure it’s on `PATH` for the Gateway process. + +**Login doesn’t stick:** + +- `openclaw channels status --probe` +- Re-login: `openclaw channels logout --channel zalouser && openclaw channels login --channel zalouser` diff --git a/backend/app/one_person_security_dept/openclaw/docs/ci.md b/backend/app/one_person_security_dept/openclaw/docs/ci.md new file mode 100644 index 00000000..51643c87 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/ci.md @@ -0,0 +1,54 @@ +--- +title: CI Pipeline +description: How the OpenClaw CI pipeline works +summary: "CI job graph, scope gates, and local command equivalents" +read_when: + - You need to understand why a CI job did or did not run + - You are debugging failing GitHub Actions checks +--- + +# CI Pipeline + +The CI runs on every push to `main` and every pull request. It uses smart scoping to skip expensive jobs when only docs or native code changed. + +## Job Overview + +| Job | Purpose | When it runs | +| ----------------- | ----------------------------------------------- | ------------------------- | +| `docs-scope` | Detect docs-only changes | Always | +| `changed-scope` | Detect which areas changed (node/macos/android) | Non-docs PRs | +| `check` | TypeScript types, lint, format | Non-docs changes | +| `check-docs` | Markdown lint + broken link check | Docs changed | +| `code-analysis` | LOC threshold check (1000 lines) | PRs only | +| `secrets` | Detect leaked secrets | Always | +| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes | +| `release-check` | Validate npm pack contents | After build | +| `checks` | Node/Bun tests + protocol check | Non-docs, node changes | +| `checks-windows` | Windows-specific tests | Non-docs, node changes | +| `macos` | Swift lint/build/test + TS tests | PRs with macos changes | +| `android` | Gradle build + tests | Non-docs, android changes | + +## Fail-Fast Order + +Jobs are ordered so cheap checks fail before expensive ones run: + +1. `docs-scope` + `code-analysis` + `check` (parallel, ~1-2 min) +2. `build-artifacts` (blocked on above) +3. `checks`, `checks-windows`, `macos`, `android` (blocked on build) + +## Runners + +| Runner | Jobs | +| -------------------------------- | ------------------------------------------ | +| `blacksmith-16vcpu-ubuntu-2404` | Most Linux jobs, including scope detection | +| `blacksmith-16vcpu-windows-2025` | `checks-windows` | +| `macos-latest` | `macos`, `ios` | + +## Local Equivalents + +```bash +pnpm check # types + lint + format +pnpm test # vitest tests +pnpm check:docs # docs format + lint + broken links +pnpm release:check # validate npm pack +``` diff --git a/backend/app/one_person_security_dept/openclaw/docs/cli/acp.md b/backend/app/one_person_security_dept/openclaw/docs/cli/acp.md new file mode 100644 index 00000000..1b198139 --- /dev/null +++ b/backend/app/one_person_security_dept/openclaw/docs/cli/acp.md @@ -0,0 +1,189 @@ +--- +summary: "Run the ACP bridge for IDE integrations" +read_when: + - Setting up ACP-based IDE integrations + - Debugging ACP session routing to the Gateway +title: "acp" +--- + +# acp + +Run the ACP (Agent Client Protocol) bridge that talks to a OpenClaw Gateway. + +This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway +over WebSocket. It keeps ACP sessions mapped to Gateway session keys. + +## Usage + +```bash +openclaw acp + +# Remote Gateway +openclaw acp --url wss://gateway-host:18789 --token + +# Remote Gateway (token from file) +openclaw acp --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token + +# Attach to an existing session key +openclaw acp --session agent:main:main + +# Attach by label (must already exist) +openclaw acp --session-label "support inbox" + +# Reset the session key before the first prompt +openclaw acp --session agent:main:main --reset-session +``` + +## ACP client (debug) + +Use the built-in ACP client to sanity-check the bridge without an IDE. +It spawns the ACP bridge and lets you type prompts interactively. + +```bash +openclaw acp client + +# Point the spawned bridge at a remote Gateway +openclaw acp client --server-args --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token + +# Override the server command (default: openclaw) +openclaw acp client --server "node" --server-args openclaw.mjs acp --url ws://127.0.0.1:19001 +``` + +Permission model (client debug mode): + +- Auto-approval is allowlist-based and only applies to trusted core tool IDs. +- `read` auto-approval is scoped to the current working directory (`--cwd` when set). +- Unknown/non-core tool names, out-of-scope reads, and dangerous tools always require explicit prompt approval. +- Server-provided `toolCall.kind` is treated as untrusted metadata (not an authorization source). + +## How to use this + +Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want +it to drive a OpenClaw Gateway session. + +1. Ensure the Gateway is running (local or remote). +2. Configure the Gateway target (config or flags). +3. Point your IDE to run `openclaw acp` over stdio. + +Example config (persisted): + +```bash +openclaw config set gateway.remote.url wss://gateway-host:18789 +openclaw config set gateway.remote.token +``` + +Example direct run (no config write): + +```bash +openclaw acp --url wss://gateway-host:18789 --token +# preferred for local process safety +openclaw acp --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token +``` + +## Selecting agents + +ACP does not pick agents directly. It routes by the Gateway session key. + +Use agent-scoped session keys to target a specific agent: + +```bash +openclaw acp --session agent:main:main +openclaw acp --session agent:design:main +openclaw acp --session agent:qa:bug-123 +``` + +Each ACP session maps to a single Gateway session key. One agent can have many +sessions; ACP defaults to an isolated `acp:` session unless you override +the key or label. + +## Zed editor setup + +Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI): + +```json +{ + "agent_servers": { + "OpenClaw ACP": { + "type": "custom", + "command": "openclaw", + "args": ["acp"], + "env": {} + } + } +} +``` + +To target a specific Gateway or agent: + +```json +{ + "agent_servers": { + "OpenClaw ACP": { + "type": "custom", + "command": "openclaw", + "args": [ + "acp", + "--url", + "wss://gateway-host:18789", + "--token", + "", + "--session", + "agent:design:main" + ], + "env": {} + } + } +} +``` + +In Zed, open the Agent panel and select “OpenClaw ACP” to start a thread. + +## Session mapping + +By default, ACP sessions get an isolated Gateway session key with an `acp:` prefix. +To reuse a known session, pass a session key or label: + +- `--session `: use a specific Gateway session key. +- `--session-label