diff --git a/.cursor/skills/publish/SKILL.md b/.cursor/skills/publish/SKILL.md
index 9b0039bca6..ab79f9511f 100644
--- a/.cursor/skills/publish/SKILL.md
+++ b/.cursor/skills/publish/SKILL.md
@@ -23,7 +23,7 @@ disable-model-invocation: true
| `CHANGELOG_FILE` | `CHANGELOG.md` | 相对于仓库根目录的路径,文件不存在则跳过 |
| `VERSION_FILE` | `pyproject.toml` | 包含版本号的文件 |
| `VERSION_PATTERN` | `^\s*version\s*=\s*"[^"]+"` | 匹配版本行的正则表达式 |
-| `README_FILE` | `README.md` | 含 shields.io 版本徽标的文件(badge 版本号同步升级) |
+| `README_GLOB` | `README*.md` | 含 shields.io 版本徽标的 README(含多语言变体,如 `README.md`、`README_CN.md`);全部同步升级 |
| `INIT_VERSION_FILE` | `src/octop/__init__.py` | 含运行时常量 `__version__` 的文件(缺失则跳过并提示) |
| `TAG_PREFIX` | `v` | Git tag 前缀;工作流监听 `v*`,生成 `v0.1.14` 风格标签 |
| `REMOTE` | `origin` | Git 远程仓库名 |
@@ -175,27 +175,76 @@ git checkout -B {RELEASE_BRANCH_PREFIX}{version} {REMOTE}/{INTEGRATION_BRANCH}
如果 `## [Unreleased]` 标题不存在,在 `# Changelog` 标题行之后插入新条目(若无标题则插入到文件顶部)。
-**4b. 升级版本号(同步所有版本来源):**
+**4b. 升级版本号(把旧号 X.Y.Z 全部换成当前号 A.B.C):**
-发布版本号必须保持多文件一致。依次升级以下位置:
+表示「当前发布版本」的钉死号必须一致,**不得只改 `pyproject.toml`**。
+把旧版本 `X.Y.Z` 换成目标版本 `A.B.C`。缺文件则提示并继续(不中止)。
+
+必改清单(按顺序,用 Edit 工具精确替换,勿用会误伤 CHANGELOG 历史条目的全局 replace):
1. `VERSION_FILE`(`pyproject.toml`)— wheel / PyPI 的唯一版本源:
```bash
grep -n '^\s*version\s*=\s*"[^"]+"' pyproject.toml
- # 用 Edit 工具将该行的 "X.Y.Z" 替换为 "A.B.C"
+ # "X.Y.Z" → "A.B.C"
```
-2. `README_FILE`(`README.md`)— shields.io 版本徽标:
+2. 所有匹配 `README_GLOB` 且含 shields.io 版本徽标的 README(含多语言):
```bash
- grep -n 'shields.io/badge/version-' README.md
- # 用 Edit 工具将 `version-X.Y.Z-orange` 替换为 `version-A.B.C-orange`
+ grep -l 'shields.io/badge/version-' README.md README_*.md 2>/dev/null
+ grep -n 'shields.io/badge/version-' {file}
+ # `version-X.Y.Z-orange` → `version-A.B.C-orange`
```
- 文件不存在则跳过并提示(不中止)。
+ 当前仓库至少包括 `README.md` 与 `README_CN.md`。全部未命中则提示并继续。
3. `INIT_VERSION_FILE`(`src/octop/__init__.py`)— 运行时常量 `__version__`:
```bash
grep -n '__version__' src/octop/__init__.py
- # 用 Edit 工具将 `__version__ = "X.Y.Z"` 替换为 `"A.B.C"`
+ # `__version__ = "X.Y.Z"` → `"A.B.C"`
+ ```
+4. 飞牛 FnOS 两个 manifest 的 `version=`(仓库源文件与 pyproject 保持同一 PEP 440 字符串。`scripts/build-fpk.sh` 打包时会改写成商店可比版本,例如 `1.0.2b8` → `1.0.208`,不要把源文件改成这个编码):
+ ```bash
+ grep -n '^version=' fnos/docker/manifest fnos/native/manifest
+ # `version=X.Y.Z` → `version=A.B.C`(两个文件都要改,仍是 PEP 440)
+ ```
+5. 飞牛 Docker compose 镜像标签(与本包版本相同,供 FPK 拉取 GHCR):
+ ```bash
+ grep -n 'ghcr.io/tencentcloud/octop:' fnos/docker/app/docker/docker-compose.yaml
+ # `ghcr.io/tencentcloud/octop:X.Y.Z` → `:A.B.C`
+ # 不得改成 `:latest`(测试禁止 latest)
```
- 文件不存在则跳过并提示(不中止)。
+6. `uv.lock` 里可编辑包 `octop` 的版本(漏改会导致 lock 与 pyproject 不一致):
+ ```bash
+ grep -n -A2 'name = "octop"' uv.lock | head -5
+ # 将该包的 `version = "X.Y.Z"` 改为 `"A.B.C"`
+ # 或在改完 pyproject.toml 后执行 `uv lock`,只接受 octop 版本行变化
+ ```
+
+**扫尾(必做):** 全库搜索旧号,把漏网的「当前版本钉死」一并改掉:
+
+```bash
+git grep -n --fixed-strings "X.Y.Z"
+```
+
+对每一处命中:
+
+| 类型 | 处理 |
+|------|------|
+| 当前版本钉死(compose / badge / manifest / lock / `__version__` / 文档里「当前版本」) | 换成 `A.B.C` |
+| 测试里断言「等于当前发布号」的硬编码 | 改成读 `_pyproject_version()`(或同步为 `A.B.C`) |
+| `CHANGELOG.md` 已发布章节(`## [X.Y.Z]` 及正文) | **保留**,不要改历史 |
+| 测试夹具里拿旧号做比较(如 `1.0.2b4` vs `1.0.2b5`、解析示例) | **保留** |
+| workflow / 注释里的示例旧号(如 `0.9.28`) | **保留**(不是当前钉死) |
+
+提交前再扫必改清单,必须已经没有旧号:
+
+```bash
+git grep -n --fixed-strings "X.Y.Z" -- \
+ pyproject.toml src/octop/__init__.py \
+ README.md README_CN.md \
+ fnos/docker/manifest fnos/native/manifest \
+ fnos/docker/app/docker/docker-compose.yaml \
+ uv.lock
+```
+
+任一文件仍命中旧号:**补改后再提交**,不得带着半套版本号推 release。
**4c. 提交:**
@@ -317,4 +366,6 @@ git checkout {original_branch}
- 发版后删除 `release/*`;`main → develop` 由 `sync-main-to-develop.yml` 自动同步(失败时再手动补)
- 中止前展示完整错误输出
- 插入新版本条目后保持 `[Unreleased]` 为空
+- 把旧版本号 `X.Y.Z` 换成 `A.B.C`:`pyproject.toml`、`__version__`、多语言 README 徽标、`fnos/*/manifest`、`fnos/docker/app/docker/docker-compose.yaml` 镜像标签、`uv.lock` 的 octop 包版本;提交前 `git grep` 必改清单确认无旧号
+- 勿改 CHANGELOG 历史章节、测试夹具里的旧版本比较、workflow 示例号
- 推送 tag 后提示用户关注 GitHub Actions 的发布结果
diff --git a/.env.example b/.env.example
index 9599c7a65e..78fcd6d729 100644
--- a/.env.example
+++ b/.env.example
@@ -8,11 +8,16 @@ OCTOP_PORT=8088
OCTOP_LOG_LEVEL=info
OCTOP_BIND_HOST=0.0.0.0
+# Docker Compose only: host directory bind-mounted to /data/.octop
+# OCTOP_DATA=~/.octop
+
# Docker first-run bootstrap (only used when ~/.octop/octop.db is absent)
# Password must be ≥8 chars with letters and digits (same policy as change-password).
-# Future: first boot may randomize this and write it to credential.txt instead.
+# Set a valid password to create the admin via `octop init`. Leave empty (or set a
+# password the policy rejects) to start the web setup wizard instead; the wizard
+# unlock password is written to ~/.octop/octop-login.txt.
OCTOP_ADMIN_USERNAME=admin
-# First-run admin password; leave empty to auto-generate a random one (written to ~/.octop/credential.txt)
+# First-run admin password; empty / rejected → setup wizard (not a silent random admin)
OCTOP_DEFAULT_PASSWORD=
OCTOP_ADMIN_DISPLAY_NAME=Admin
@@ -34,7 +39,12 @@ OPENAI_API_KEY=
DASHSCOPE_API_KEY=
# Langfuse observability (optional)
-OCTOP_LANGFUSE_ENABLED=false
+# Octop's on/off switch is the admin setting in Dashboard → Settings →
+# Observability (stored as the settings key `observability_langfuse_enabled`);
+# there is no OCTOP_LANGFUSE_ENABLED variable. LANGFUSE_TRACING_ENABLED=false
+# is the SDK-level kill switch, honoured by the langfuse client along with the
+# public / secret key and base URL below.
+LANGFUSE_TRACING_ENABLED=true
# Optional login captcha (boot snapshot; restart after changing).
# Default slider is dashboard-only. Strong providers need both keys.
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 1157b8f1f4..db9d4b2c02 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -15,6 +15,22 @@ fi
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
+# Documentation-only commits do not need code checks or a dashboard build.
+# Include deletions and both sides of renames when deciding what changed.
+DOCS_ONLY=1
+HAS_CHANGES=0
+while IFS= read -r -d '' path; do
+ HAS_CHANGES=1
+ case "$path" in
+ README.md|README.*.md) ;;
+ *) DOCS_ONLY=0 ;;
+ esac
+done < <(git diff --cached --name-only --no-renames -z)
+if [[ "$HAS_CHANGES" == "1" && "$DOCS_ONLY" == "1" ]]; then
+ echo "[pre-commit] Documentation-only changes — skipping code checks and dashboard build"
+ exit 0
+fi
+
# Remember staged paths so we can re-add them after auto-format rewrites the worktree.
# Portable (bash 3.2+): avoid `mapfile` (bash 4+ only).
STAGED_FILES=()
diff --git a/.github/workflows/auto-tag-on-release.yml b/.github/workflows/auto-tag-on-release.yml
index f95bd076e0..13f9030281 100644
--- a/.github/workflows/auto-tag-on-release.yml
+++ b/.github/workflows/auto-tag-on-release.yml
@@ -68,7 +68,8 @@ jobs:
# is exempt). FnOS FPK is cascaded from Release after the GitHub Release
# exists (reuses Release wheel + waits for GHCR image from Docker Publish).
# Desktop and FnOS upsert their packages onto the same v* GitHub Release
- # after it exists (FnOS is dispatched from Release.yml).
+ # after it exists (FnOS is dispatched from Release.yml). Each of those
+ # workflows then uploads its own assets to COS octop/release/
@@ -41,29 +41,32 @@
**Octop** is an open-source, self-hosted AI assistant. It's not just a tool — it's a digital life form that can operate in parallel. Through its multi-agent architecture, it builds an intelligent environment that is both independent and collaborative for teams, families, and individuals. Best of all, it runs entirely on your machine — the fully self-hosted design means privacy is never a compromise, while single-process startup makes the powerful web console, CLI, and IM integrations readily accessible.
-Chat through the Web Dashboard, Feishu, DingTalk, QQ, Discord, WeCom, or programmatic HTTP/SSE/WebSocket. Extend capabilities with the **expert library**, **Connectors** (OAuth + MCP), and **ACP** integration for IDE workflows.
+Chat through the Web Dashboard, Feishu, DingTalk, QQ, WeChat, Telegram, Discord, WeCom, or programmatic HTTP/SSE/WebSocket. Extend capabilities with the **expert library**, **Connectors** (OAuth + MCP), and **ACP** integration for IDE workflows.
## ✨ Highlights
| | Feature | Description |
|---|---------|-------------|
-| 👥 | **Multi-user expert team** | One admin, shared household; built-in expert library — switch specialists per scenario |
+| 👥 | **Multi-user expert team** | One admin, shared household; built-in expert library and expert market — switch specialists per scenario |
+| 🤝 | **Cloud collaboration** | Link this Octop to another instance to browse peer experts and chat across instances; history stays on the expert's side |
| 🎭 | **MBTI personas** | 16 personality templates plus an interactive quiz — give each agent a distinct character |
+| 🎯 | **AgentTeams** *(Beta)* | A coordinator schedules multiple experts on multi-step work; [details](docs/expert-teams.md) |
| 🔒 | **Security built-in** | JWT multi-user isolation, tool approval, shell command guardrails, and PII redaction — data stays local |
-| 🔌 | **Connector ecosystem** | Tencent suite (Docs, Weibo trends, News, …); OAuth and MCP gateway extend resource boundaries |
-| 💾 | **Pluggable backends** | Local disk, Docker containers, PostgreSQL, or COS/S3 — AI operates inside isolated boundaries |
-| 🧠 | **Portable memory** | Powered by harness-memory; memory migrates with the workspace |
-| 📚 | **Knowledge base** | RAG over your documents; semantic retrieval grounds agent answers in your private corpus |
+| 🔌 | **Connector ecosystem** | Tencent suite (Docs, Meeting, News, …); OAuth and MCP gateway extend resource boundaries — [supported connectors](docs/connectors.md) |
+| 💾 | **Pluggable workspace backends** | Local disk, Docker sandbox, PostgreSQL, or COS/S3 for agent files — separate from the control-plane DB |
+| 🧠 | **Portable memory** | Powered by [Octop Memory](https://github.com/TencentCloud/octop-memory); memory migrates with the workspace |
+| 📚 | **Knowledge base** | RAG over your documents; share corpora within a deployment and ground answers in your private data |
| 🧩 | **Plugins** | Extend Octop with third-party plugins; bundled plugins are seeded and toggled on demand |
| ↔️ | **ACP bidirectional** | `octop acp` for IDE/terminal AI; delegate to OpenCode / Claude Code with permission gates |
| 💻 | **Terminal AI+** | Interactive shell in the browser — AI-assisted command execution and troubleshooting |
| 🌐 | **Browser AI+** | Headless Chromium sessions for web automation, screenshots, and remote browsing |
| 🖥️ | **Remote desktop** | Live screen and input from the dashboard on Linux, Windows, and macOS — remote office work and GUI apps; one-click isolated desktop on headless Linux |
+| 🪟 | **Desktop client** | Native Windows / macOS / Linux apps (and FnOS packages) alongside the web dashboard |
| 🏠 | **Self-hosted** | Dashboard, CLI, IM channels, and cron in one `octop run` — all data under `~/.octop/` |
## 📌 Overview
-Octop is a self-hosted AI assistant platform for households and small teams. It runs a single process that serves a web dashboard, a CLI, IM channels (Feishu, DingTalk, QQ, Discord, WeCom, and more), and cron automation — all sharing one control-plane database under `~/.octop/` (SQLite by default; PostgreSQL optional).
+Octop is a self-hosted AI assistant platform for households and small teams. It runs a single process that serves a web dashboard, a CLI, IM channels (Feishu, DingTalk, QQ, WeChat, Telegram, Discord, WeCom, and more), and cron automation — all sharing one control-plane database under `~/.octop/` (SQLite by default; PostgreSQL optional).
> Octop's design goal: keep every conversation, workspace, and credential on your own machine, while giving each user a personal team of specialized agents they can switch between per task.
@@ -71,10 +74,10 @@ Octop is a self-hosted AI assistant platform for households and small teams. It
- 概述 ·
亮点 ·
+ 概述 ·
核心技术 ·
功能特性 ·
规划 ·
- 快速开始·
+ 快速开始 ·
目录
` in JSON. `OctopError.to_envelope(locale=…)` and the global exception handler localize `message`. Dashboard mirrors codes under `apiErrors.*` in `dashboard/src/locales/{en,zh}.json` — tests require backend and frontend keys to match.
-**IM channels:** Tool names and hint lines must be localized in `stream_project.py` — pass localized `tool_name` and pre-formatted `tool_hint_text` on `MessageEvent.tool_start/end` (harness-gateway reads `tool_hint_text` when present). Do not rely on English `ChannelConstraints.tool_hint_template` alone.
+**IM channels:** Tool names and hint lines must be localized in `stream_project.py` — pass localized `tool_name` and pre-formatted `tool_hint_text` on `MessageEvent.tool_start/end` (octop-gateway reads `tool_hint_text` when present). Do not rely on English `ChannelConstraints.tool_hint_template` alone.
**Frontend split**
@@ -351,12 +355,13 @@ Boundary rules are in [§5](#5-module-boundaries). Additionally:
| Question | Location |
|----------|----------|
-| How does auth work? | `api/jwt_tokens.py`, `api/deps.py`, `api/middleware/jwt_auth.py`, `api/routers/auth.py` |
+| How does auth work? | `api/deps.py`, `api/middleware/jwt_auth.py`, `api/routers/auth.py` |
+| LDAP (directory) login | `infra/auth/ldap/`, `api/routers/auth_ldap.py`; config row is `sso_providers.kind='ldap'`; guide: `docs/ldap.md` |
| Setup wizard (password file, tokens) | `infra/setup/`, `api/routers/setup.py` |
| TLS / Let's Encrypt | `infra/setup/tls/`, `api/routers/tls.py` |
| `octop run` boot sequence | `launch.py`, `cli/run_cmd.py` |
| How is a message processed? | `infra/gateway/processor.py` → harness agent |
-| How are agents started/stopped? | `infra/agents/manager.py`, `infra/agents/runtime.py` |
+| How are agents started/stopped? | `infra/agents/manager.py` |
| How does cron work? | `infra/cron/manager.py`, `infra/cron/job.py` |
| What DB tables exist? | `infra/db/migrations/` + `infra/db/repos/` |
| What env vars are supported? | `config.py` |
@@ -368,7 +373,7 @@ Boundary rules are in [§5](#5-module-boundaries). Additionally:
| Pre-commit hooks | `make install-hooks` → `.githooks/pre-commit` (`make all` incl. `format-all` + dashboard build) |
| What is a Thread? | `infra/gateway/threads.py`, `infra/db/repos/threads.py` |
| Workspace backend resolution | `infra/backend/resolver.py`, `infra/backend/adapter.py` |
-| Connectors & OAuth | `infra/connectors/`, `api/routers/connectors.py` |
+| Connectors & OAuth | `infra/connectors/catalog.json` (built-in metadata + i18n), `infra/connectors/`, `api/routers/connectors.py`; human list: `docs/connectors.md` |
| OpenAPI tags and API intro | `api/openapi_meta.py` |
| Human-readable API reference | `docs/api.md` |
| SharedServices / RepoBundle | `infra/db/services.py` |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 69db2e90bb..3e73091c39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,255 @@
格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本号遵循 [语义化版本规范](https://semver.org/spec/v2.0.0.html)。
## [Unreleased]
+- Linux 桌面客户端无边框窗口支持拖拽边缘与角落调整大小。
+
+### 新增
+- 技能包支持「是否共享」:默认仅创建者可见,开启后其他用户可查看与挂载;创建时可同时设置复制策略与是否允许下载
+- 技能包查看/编辑技能时展示左侧文件树(与个性化技能一致),并支持预览同级文件
+
+### 修复
+- 模型页保存提供商默认模型时会真正写入(排到模型列表首位);把默认模型留空再保存会清除该默认值,再次打开不再回填列表中的第一个模型(#1396,#2031)
+- 浏览器工作台支持中文等输入法组合输入与整段粘贴,恢复退格、删除、方向键和全选等编辑操作;提高预览刷新频率并减少重复的标签状态查询
+- 回复还在生成时删除会话,会提示先停止再删,会话留在原地。不再在这一轮还在写记录时把会话删掉,从而把外键错误显示成「模型调用失败」
+- 未登录、令牌无效或过期时,API 错误信息会按请求的 `Accept-Language` 语言返回(如中文返回「认证失败。」),不再固定英文;无请求头时保持原默认回退语言(#2187)
+- `SKILL.md` 带 UTF-8 BOM(如 Windows 记事本 / PowerShell 5.1 保存)时不再丢失 YAML frontmatter;技能路由改为复用统一的 `parse_frontmatter`(#2215)
+- `OCTOP_HOME` 迁移数据目录后,首次配置向导密码文件随之写入 `
🐾 你能用 Octop 做什么
- **个人助理** — 让专属 Agent 帮你写周报、整理资料、定日程,记忆随工作区长期保留。
-- **家庭共享** — 一个管理员账号,全家共用;按成员分配不同 Agent 与专家角色。
-- **团队助手** — 多 Agent 并行协作,对接飞书 / 钉钉 / 企业微信,把任务自动分发到群里。
+- **家庭共享** — 一个管理员账号,全家共用;按成员分配不同 Agent 与专家角色,也可共享专家与知识库。
+- **团队助手** — AgentTeams 或多 Agent 并行协作,对接飞书 / 钉钉 / 企业微信 / 微信,把任务自动分发到群里。
- **开发者增效** — 通过 ACP 把编码任务委派给 OpenCode / Claude Code,或在终端用 AI 辅助排障。
-- **网页自动化** — 用浏览器 AI+ 自动填表、截图、采集公开信息。
+- **网页自动化** — 用浏览器 AI+ 与远程桌面自动填表、截图、操作 GUI 软件。
- **定时任务** — 用自然语言配置 Cron,让 Agent 每天按时推送或执行任务。
{success_message}
+{escape(success_message)}
""" return HTMLResponse(html) diff --git a/src/octop/api/routers/cron.py b/src/octop/api/routers/cron.py index 8b8237eb93..6e7bc2c299 100644 --- a/src/octop/api/routers/cron.py +++ b/src/octop/api/routers/cron.py @@ -9,10 +9,7 @@ from octop.api.common.agent import assert_agent_access, require_agent_row, user_owns_agent from octop.api.deps import current_user, get_server -from octop.infra.agents.experts.catalog import ( - normalize_task_examples_for_display, - read_workspace_manifest_task_examples, -) +from octop.infra.agents.experts.catalog import resolve_agent_display_task_examples from octop.infra.cron.task_type import ( normalize_cron_task_type, require_cron_name, @@ -27,7 +24,9 @@ class CronCreateBody(BaseModel): name: str | None = None - trigger: str + trigger: str = Field( + description="Examples: cron:0 9 * * *, interval:3600, date:2030-01-01T08:00:00, or agently:INSTANCE_ID for new mail events." + ) prompt: str session_key: str | None = None fresh_thread: bool = False @@ -39,7 +38,10 @@ class CronCreateBody(BaseModel): class CronPatchBody(BaseModel): name: str | None = None - trigger: str | None = None + trigger: str | None = Field( + default=None, + description="Time schedule or agently:INSTANCE_ID for new mail events.", + ) prompt: str | None = None session_key: str | None = None fresh_thread: bool | None = None @@ -50,11 +52,7 @@ class CronPatchBody(BaseModel): class CronExamplesResponse(BaseModel): - """Empty-state prompts for the tasks page. - - ``task_examples`` is ``null`` when the workspace manifest has no such field, - so the dashboard can keep its built-in default cards. - """ + """Compat mirror of welcome ``task_examples`` (workspace → catalog → name).""" task_examples: dict[str, list[str]] | None = None @@ -96,17 +94,14 @@ async def cron_examples( user: Any = Depends(current_user), server: Any = Depends(get_server), ) -> CronExamplesResponse: - """Return ``task_examples`` from the agent workspace ``.octop/manifest.json``. - - Missing field / missing manifest → ``task_examples: null`` (dashboard defaults). - """ + """Same resolution as ``GET …/chat/welcome`` ``task_examples`` (API compat).""" assert_agent_access(server, agent_id, user) assert server.app_runtime is not None - workspace = server.app_runtime.agent_registry.workspace_for_agent(agent_id) - if workspace is None: - return CronExamplesResponse(task_examples=None) - examples = normalize_task_examples_for_display( - await read_workspace_manifest_task_examples(workspace) + registry = server.app_runtime.agent_registry + examples = await resolve_agent_display_task_examples( + workspace=registry.workspace_for_agent(agent_id), + row=registry.get_row(agent_id), + catalog=getattr(server, "expert_catalog", None), ) return CronExamplesResponse(task_examples=examples) @@ -134,7 +129,7 @@ async def create_cron( user: Any = Depends(current_user), server: Any = Depends(get_server), ) -> dict[str, Any]: - """Schedule a recurring prompt. `trigger` uses cron syntax or natural `@every` aliases.""" + """Schedule a prompt on a timer or on new mail from a visible Agent Mail instance.""" from octop.api.common.validators import validate_chat_mcp_servers # noqa: PLC0415 from octop.infra.cron.manager import CronCreateSpec # noqa: PLC0415 diff --git a/src/octop/api/routers/decision.py b/src/octop/api/routers/decision.py new file mode 100644 index 0000000000..7b09eb38c6 --- /dev/null +++ b/src/octop/api/routers/decision.py @@ -0,0 +1,198 @@ +"""Decision-model switch (Settings → Models → Decision).""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any, Literal, cast + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from octop.api.deps import get_server, require_permission +from octop.infra.agents.settings.decision import DecisionSettings +from octop.infra.agents.settings.laya_runtime import get_laya_runtime +from octop.infra.utils.locale import resolve_request_locale + +router = APIRouter() + + +class DecisionServiceResponse(BaseModel): + provider: Literal["laya", "jev", "microsoft", "custom"] + endpoint: str + model: str + api_key_set: bool + + +class DecisionSettingsResponse(BaseModel): + enabled: bool + provider: Literal["laya", "jev", "microsoft", "custom"] + endpoint: str + model: str + services: list[DecisionServiceResponse] + skills: bool + tools: bool + team: bool + threshold: float + tool_min_count: int + api_key_set: bool + active: bool + + +class DecisionServiceBody(BaseModel): + provider: Literal["laya", "jev", "microsoft", "custom"] + endpoint: str = "" + model: str = "" + api_key: str | None = Field( + default=None, + description="Omit to keep this service's key. Send an empty string to clear it.", + ) + + +class DecisionSettingsBody(BaseModel): + enabled: bool = False + provider: Literal["laya", "jev", "microsoft", "custom"] = "laya" + endpoint: str = "" + model: str = "" + services: list[DecisionServiceBody] | None = Field( + default=None, + description="Every saved service. The provider field is the default.", + ) + skills: bool = True + tools: bool = True + team: bool = True + threshold: float = Field(default=0.6, gt=0, lt=1) + tool_min_count: int = Field(default=8, ge=1, le=100) + api_key: str | None = Field( + default=None, + description="Omit to keep the stored key. Send an empty string to clear it.", + ) + + +class DecisionTestBody(BaseModel): + endpoint: str | None = None + api_key: str | None = None + model: str | None = None + provider: Literal["laya", "jev", "microsoft", "custom"] | None = None + + +def _view(settings: DecisionSettings) -> DecisionSettingsResponse: + return DecisionSettingsResponse( + enabled=settings.enabled, + provider=settings.provider, # type: ignore[arg-type] + endpoint=settings.endpoint, + model=settings.model, + skills=settings.skills, + tools=settings.tools, + team=settings.team, + threshold=settings.threshold, + tool_min_count=settings.tool_min_count, + api_key_set=settings.api_key_set, + active=settings.active, + services=[ + DecisionServiceResponse( + provider=item.provider, # type: ignore[arg-type] + endpoint=item.endpoint, + model=item.model, + api_key_set=item.api_key_set, + ) + for item in settings.services + ], + ) + + +@router.get("/settings/decision", summary="Get the decision-model switch") +async def get_decision_settings( + _: Any = Depends(require_permission("providers")), + server: Any = Depends(get_server), +) -> DecisionSettingsResponse: + """Return the decision-model settings. The API key itself is not included.""" + return _view(server.app_runtime.agent_registry.decision.load()) + + +@router.put("/settings/decision", summary="Update the decision-model switch") +async def put_decision_settings( + body: DecisionSettingsBody, + _: Any = Depends(require_permission("providers")), + server: Any = Depends(get_server), +) -> DecisionSettingsResponse: + """Save the switch and rebuild running agents so the middleware mounts or unmounts.""" + saved = await server.app_runtime.agent_registry.save_decision( + enabled=body.enabled, + provider=body.provider, + endpoint=body.endpoint, + model=body.model, + skills=body.skills, + tools=body.tools, + team=body.team, + threshold=body.threshold, + tool_min_count=body.tool_min_count, + api_key=body.api_key, + services=( + [item.model_dump() for item in body.services] if body.services is not None else None + ), + ) + return _view(saved) + + +@router.post("/settings/decision/test", summary="Probe the decision-model endpoint") +async def test_decision_settings( + body: DecisionTestBody, + _: Any = Depends(require_permission("providers")), + server: Any = Depends(get_server), +) -> dict[str, Any]: + """Send one yes/no question. A probability in the reply means the endpoint is reachable.""" + return cast( + dict[str, Any], + await server.app_runtime.agent_registry.probe_decision( + endpoint=body.endpoint, + api_key=body.api_key, + model=body.model, + provider=body.provider, + ), + ) + + +@router.get("/settings/decision/laya", summary="Get the local Laya install status") +async def get_laya_status( + _: Any = Depends(require_permission("providers")), +) -> dict[str, Any]: + """Report whether the managed Laya process is installed and responding.""" + return await get_laya_runtime().status() + + +@router.post("/settings/decision/laya/install", summary="Install Laya on this machine") +async def install_laya( + request: Request, + _: Any = Depends(require_permission("providers")), +) -> StreamingResponse: + """Install Laya into Octop's environment and stream the log. + + A second request attaches to an install that is already running. + Closing the page does not stop the install. + """ + locale = resolve_request_locale(request) + + async def _gen() -> AsyncIterator[str]: + async for event in get_laya_runtime().install_events(locale): + yield event + + return StreamingResponse(_gen(), media_type="text/event-stream") + + +@router.post("/settings/decision/laya/start", summary="Start the local Laya service") +async def start_laya( + request: Request, + _: Any = Depends(require_permission("providers")), +) -> dict[str, Any]: + """Start the already installed laya-serve process.""" + return await get_laya_runtime().start(resolve_request_locale(request)) + + +@router.post("/settings/decision/laya/stop", summary="Stop the local Laya service") +async def stop_laya( + request: Request, + _: Any = Depends(require_permission("providers")), +) -> dict[str, Any]: + """Stop only the Laya process Octop started.""" + return await get_laya_runtime().stop(resolve_request_locale(request)) diff --git a/src/octop/api/routers/desktop/install.py b/src/octop/api/routers/desktop/install.py index e4ba1db846..fc665a4b16 100644 --- a/src/octop/api/routers/desktop/install.py +++ b/src/octop/api/routers/desktop/install.py @@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse from octop.api.deps import require_permission -from octop.infra.desktop.setup import install_desktop_stream +from octop.infra.control.desktop.setup import install_desktop_stream from octop.infra.users.identity import User from octop.infra.utils.locale import resolve_request_locale diff --git a/src/octop/api/routers/desktop/settings.py b/src/octop/api/routers/desktop/settings.py index 8ccbca6825..cbe9a6e8bf 100644 --- a/src/octop/api/routers/desktop/settings.py +++ b/src/octop/api/routers/desktop/settings.py @@ -9,7 +9,12 @@ from pydantic import BaseModel, Field, field_validator from octop.api.deps import require_permission -from octop.infra.desktop.setup import apply_geometry, desktop_status, parse_geometry, read_geometry +from octop.infra.control.desktop.setup import ( + apply_geometry, + desktop_status, + parse_geometry, + read_geometry, +) from octop.infra.users.identity import User router = APIRouter() diff --git a/src/octop/api/routers/desktop/status.py b/src/octop/api/routers/desktop/status.py index f0bf6167b4..ffb20dc407 100644 --- a/src/octop/api/routers/desktop/status.py +++ b/src/octop/api/routers/desktop/status.py @@ -5,8 +5,8 @@ from fastapi import APIRouter, Depends, Request from octop.api.deps import require_permission -from octop.infra.desktop.session import active_session_count, session_limit -from octop.infra.desktop.setup import desktop_status +from octop.infra.control.desktop.session import active_session_count, session_limit +from octop.infra.control.desktop.setup import desktop_status from octop.infra.users.identity import User from octop.infra.utils.locale import resolve_request_locale diff --git a/src/octop/api/routers/desktop/stream.py b/src/octop/api/routers/desktop/stream.py index 7f75eb6f0c..20e4e297ff 100644 --- a/src/octop/api/routers/desktop/stream.py +++ b/src/octop/api/routers/desktop/stream.py @@ -14,8 +14,8 @@ from starlette.websockets import WebSocketState from octop.api.deps import resolve_user_from_token -from octop.infra.desktop.input import canvas_to_screen, run_desktop_action -from octop.infra.desktop.session import ( +from octop.infra.control.desktop.input import canvas_to_screen, run_desktop_action +from octop.infra.control.desktop.session import ( DesktopSession, DesktopSessionLimitError, acquire_session, @@ -25,7 +25,7 @@ release_session, supersede_user_stream, ) -from octop.infra.desktop.setup import desktop_status +from octop.infra.control.desktop.setup import desktop_status from octop.infra.errors import ErrorCode, OctopError from octop.infra.users.identity import User from octop.infra.users.permissions import user_has_permission diff --git a/src/octop/api/routers/desktop/uninstall.py b/src/octop/api/routers/desktop/uninstall.py index 94c40ea8e0..0d19208cb9 100644 --- a/src/octop/api/routers/desktop/uninstall.py +++ b/src/octop/api/routers/desktop/uninstall.py @@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse from octop.api.deps import require_permission -from octop.infra.desktop.setup import uninstall_desktop_stream +from octop.infra.control.desktop.setup import uninstall_desktop_stream from octop.infra.users.identity import User from octop.infra.utils.locale import resolve_request_locale diff --git a/src/octop/api/routers/experts.py b/src/octop/api/routers/experts.py index 3c5d8a0675..2c20d1fbf5 100644 --- a/src/octop/api/routers/experts.py +++ b/src/octop/api/routers/experts.py @@ -12,27 +12,37 @@ from __future__ import annotations import asyncio -from typing import Any +import json +from typing import Any, Literal from fastapi import APIRouter, Depends, Response from pydantic import BaseModel, Field -from octop.api.common.agent import require_agent_owner_row, user_owns_agent +from octop.api.common.agent import apply_agent_audience, require_agent_owner_row, user_owns_agent from octop.api.common.agent_runtime import AgentRuntimeFields, runtime_field_updates from octop.api.common.validators import assert_user_backend_root_dirs from octop.api.deps import current_user, get_server -from octop.infra.agents.avatar import ( +from octop.infra.agents.experts.avatar import ( display_published_expert_icon_url, + public_portrait_icon_url, read_snapshot_avatar, ) from octop.infra.agents.experts.catalog import ( MANIFEST_FILENAME, + apply_workspace_quick_prompts, build_create_spec_from_expert, discover_seed_paths, preview_file_paths, preview_paths_from_expert_dir, read_text_file_contents, ) +from octop.infra.agents.experts.composer_files import ( + ComposerApplyReport, + ComposerSkillCopy, + ComposerWorkspacePatch, + apply_composer_workspace_patch, + composer_plan_from_payload, +) from octop.infra.agents.experts.market_creation import ( SkillHubMarketAgentCreateOptions, ) @@ -42,7 +52,7 @@ from octop.infra.agents.experts.published_creation import ( PublishedExpertInstallOptions, require_published_expert, - snapshot_welcome_message, + snapshot_welcome_payload, ) from octop.infra.agents.experts.published_creation import ( install_published_expert as install_published_expert_agent, @@ -63,7 +73,7 @@ fetch_skillset, ) from octop.infra.errors import ErrorCode, OctopError -from octop.infra.trajectory.settings import apply_enable_trajectory +from octop.infra.history.trajectory.settings import apply_enable_trajectory from octop.infra.utils.locale import resolve_user_locale router = APIRouter() @@ -99,6 +109,24 @@ def _validated_session_defaults( } +class ComposerFileOverrideBody(BaseModel): + name: str + content: str = "" + + +class ComposerHubSkillBody(BaseModel): + skill_name: str + display_name: str = "" + icon_url: str = "" + label: dict[str, str] | None = None + summary: dict[str, str] | None = None + + +class ComposerCopySkillBody(BaseModel): + agent_id: str + slug: str + + class FromExpertBody(AgentRuntimeFields): name: str | None = None description: str | None = None @@ -116,6 +144,17 @@ class FromExpertBody(AgentRuntimeFields): ) welcome_message: str | None = None enable_trajectory: bool = True + conversation_mode: Literal["ask", "plan", "craft"] | None = Field( + default=None, + description="Default Ask/Plan/Craft mode for new conversations; craft when omitted", + ) + file_overrides: list[ComposerFileOverrideBody] | None = None + omit_files: list[str] | None = None + hub_skills: list[ComposerHubSkillBody] | None = None + copy_skills: list[ComposerCopySkillBody] | None = None + quick_prompts: list[QuickPromptResponse] | None = None + visibility: Literal["owner", "all", "groups"] | None = None + group_ids: list[str] | None = None class PublishExpertBody(BaseModel): @@ -152,6 +191,92 @@ class InstallPublishedExpertBody(AgentRuntimeFields): ) welcome_message: str | None = None enable_trajectory: bool = True + conversation_mode: Literal["ask", "plan", "craft"] | None = Field( + default=None, + description="Default Ask/Plan/Craft mode for new conversations; craft when omitted", + ) + file_overrides: list[ComposerFileOverrideBody] | None = None + omit_files: list[str] | None = None + hub_skills: list[ComposerHubSkillBody] | None = None + copy_skills: list[ComposerCopySkillBody] | None = None + quick_prompts: list[QuickPromptResponse] | None = None + visibility: Literal["owner", "all", "groups"] | None = None + group_ids: list[str] | None = None + + +def _composer_plan_from_body( + body: FromExpertBody | InstallPublishedExpertBody, +) -> tuple[ComposerWorkspacePatch, tuple[ComposerSkillCopy, ...]]: + return composer_plan_from_payload( + file_overrides=[item.model_dump() for item in (body.file_overrides or [])], + omit_files=body.omit_files, + hub_skills=[item.model_dump() for item in (body.hub_skills or [])], + copy_skills=[item.model_dump() for item in (body.copy_skills or [])], + ) + + +def _resolve_composer_copies( + copies: tuple[ComposerSkillCopy, ...], + *, + user: Any, + server: Any, + report: ComposerApplyReport, +) -> tuple[tuple[str, Any], ...]: + resolved: list[tuple[str, Any]] = [] + seen: set[str] = set() + for item in copies: + if item.slug in seen: + continue + seen.add(item.slug) + try: + require_agent_owner_row(item.agent_id, user=user, as_user=None, server=server) + except OctopError: + report.copy_skill_errors.append(item.slug) + continue + src = server.app_runtime.agent_registry.workspace_for_agent(item.agent_id) + if src is None: + report.copy_skill_errors.append(item.slug) + continue + resolved.append((item.slug, src)) + return tuple(resolved) + + +def _composer_apply( + body: FromExpertBody | InstallPublishedExpertBody, + *, + user: Any, + server: Any, +) -> tuple[ComposerWorkspacePatch, tuple[tuple[str, Any], ...], ComposerApplyReport]: + patch, copies = _composer_plan_from_body(body) + report = ComposerApplyReport() + resolved = _resolve_composer_copies(copies, user=user, server=server, report=report) + return patch, resolved, report + + +def _quick_prompts_from_body( + body: FromExpertBody | InstallPublishedExpertBody, +) -> list[dict[str, Any]] | None: + if body.quick_prompts is None: + return None + return [_quick_prompt_body_dict(item) for item in body.quick_prompts] + + +def _composer_initializer( + patch: ComposerWorkspacePatch, + copies: tuple[tuple[str, Any], ...] = (), + report: ComposerApplyReport | None = None, + quick_prompts: list[dict[str, Any]] | None = None, +) -> Any: + if patch.is_empty() and not copies and quick_prompts is None: + return None + + async def _apply(_row: Any, workspace: Any) -> None: + if not patch.is_empty() or copies: + await apply_composer_workspace_patch(workspace, patch, copies=copies, report=report) + if quick_prompts is not None: + await apply_workspace_quick_prompts(workspace, quick_prompts) + + return _apply class LocalizedTextResponse(BaseModel): @@ -211,6 +336,8 @@ class MarketCreateResponse(BaseModel): color: str | None = None market: MarketCreateSourceResponse bootstrap_pending: bool + hub_skill_errors: list[str] = Field(default_factory=list) + copy_skill_errors: list[str] = Field(default_factory=list) def _quick_prompt_dict(p: Any) -> dict[str, Any]: @@ -314,6 +441,21 @@ def _published_creator_username(server: Any, created_by: str) -> str | None: return user.username if user is not None else None +def _published_manifest_icon_url(snapshot_dir: Any) -> str | None: + manifest_path = snapshot_dir / MANIFEST_FILENAME + if not manifest_path.is_file(): + return None + try: + data = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(data, dict): + return None + return public_portrait_icon_url( + data.get("icon_url") if isinstance(data.get("icon_url"), str) else None + ) + + def _published_summary_dict(row: Any, server: Any) -> dict[str, Any]: snapshot_dir = _published_snapshot_dir(server, row.id) return { @@ -329,6 +471,7 @@ def _published_summary_dict(row: Any, server: Any) -> dict[str, Any]: expert_id=row.id, snapshot_dir=snapshot_dir, updated_at=row.updated_at, + fallback_icon_url=_published_manifest_icon_url(snapshot_dir), ), "color": row.color or None, "created_at": row.created_at, @@ -384,10 +527,11 @@ async def get_published_expert( files = await asyncio.to_thread(discover_seed_paths, snapshot_dir) if (snapshot_dir / MANIFEST_FILENAME).is_file(): files.insert(0, MANIFEST_FILENAME) - welcome_zh, welcome_en = await asyncio.to_thread(snapshot_welcome_message, snapshot_dir) + welcome_payload = await asyncio.to_thread(snapshot_welcome_payload, snapshot_dir) return { **_published_summary_dict(row, server), - "welcome_message": {"zh": welcome_zh, "en": welcome_en}, + "welcome_message": welcome_payload["welcome_message"], + "quick_prompts": welcome_payload["quick_prompts"], "files": files, "file_contents": await asyncio.to_thread( read_text_file_contents, @@ -519,7 +663,12 @@ async def install_published_expert( knowledge_base_ids=body.knowledge_base_ids, mcp_servers=body.mcp_servers, ) - return await install_published_expert_agent( + patch, copies, report = _composer_apply(body, user=user, server=server) + if body.visibility is not None: + from octop.infra.users.groups import GroupAccess # noqa: PLC0415 + + GroupAccess(server.services.repos).validate_visibility(body.visibility, body.group_ids) + result = await install_published_expert_agent( services=server.services, registry=server.app_runtime.agent_registry, user=user, @@ -538,8 +687,23 @@ async def install_published_expert( welcome_message=body.welcome_message, runtime_config=runtime_field_updates(body, exclude_unset=True), enable_trajectory=body.enable_trajectory, + conversation_mode=body.conversation_mode, + workspace_patch=patch, + composer_copies=copies, + composer_report=report, + quick_prompts=_quick_prompts_from_body(body), ), ) + if body.visibility is not None: + await apply_agent_audience( + server, + str(result["agent_id"]), + visibility=body.visibility, + group_ids=body.group_ids, + shared=None, + ) + result.update(report.as_api_fields()) + return result @router.get("/experts") @@ -623,6 +787,11 @@ async def install_expert_hub_item( knowledge_base_ids=body.knowledge_base_ids, mcp_servers=body.mcp_servers, ) + patch, copies, report = _composer_apply(body, user=user, server=server) + if body.visibility is not None: + from octop.infra.users.groups import GroupAccess # noqa: PLC0415 + + GroupAccess(server.services.repos).validate_visibility(body.visibility, body.group_ids) try: result = await create_skillhub_market_agent( server=server, @@ -641,6 +810,11 @@ async def install_expert_hub_item( knowledge_base_ids=kb_ids, mcp_servers=servers, enable_trajectory=body.enable_trajectory, + conversation_mode=body.conversation_mode, + workspace_patch=patch, + composer_copies=copies, + composer_report=report, + quick_prompts=_quick_prompts_from_body(body), **runtime_field_updates(body, exclude_unset=False), ), ) @@ -648,6 +822,15 @@ async def install_expert_hub_item( raise _map_skillhub_error(exc) from exc row = result.row + if body.visibility is not None: + await apply_agent_audience( + server, + row.agent_id, + visibility=body.visibility, + group_ids=body.group_ids, + shared=None, + ) + row = server.app_runtime.agent_registry.get_row(row.agent_id) or row return { "id": row.id, "agent_id": row.agent_id, @@ -667,6 +850,7 @@ async def install_expert_hub_item( "welcome_enrichment": result.welcome_enrichment, }, "bootstrap_pending": not server.app_runtime.agent_registry.is_bootstrapped(row.agent_id), + **report.as_api_fields(), } @@ -721,6 +905,8 @@ async def create_agent_from_expert( if body.backend: config_extra["backend"] = body.backend apply_enable_trajectory(config_extra, body.enable_trajectory) + if body.conversation_mode: + config_extra["conversation_mode"] = body.conversation_mode locale = resolve_user_locale( user_repo=server.services.user_repo, @@ -743,7 +929,29 @@ async def create_agent_from_expert( knowledge_base_ids=kb_ids, mcp_servers=servers, ) - row = await server.app_runtime.agent_registry.create(spec, defer_bootstrap=True) + patch, copies, report = _composer_apply(body, user=user, server=server) + if body.visibility is not None: + from octop.infra.users.groups import GroupAccess # noqa: PLC0415 + + GroupAccess(server.services.repos).validate_visibility(body.visibility, body.group_ids) + row = await server.app_runtime.agent_registry.create( + spec, + defer_bootstrap=True, + workspace_initializer=_composer_initializer( + patch, + copies, + report, + quick_prompts=_quick_prompts_from_body(body), + ), + ) + if body.visibility is not None: + row = await apply_agent_audience( + server, + row.agent_id, + visibility=body.visibility, + group_ids=body.group_ids, + shared=None, + ) return { "id": row.id, "agent_id": row.agent_id, @@ -757,4 +965,5 @@ async def create_agent_from_expert( "icon_url": row.icon_url, "color": row.color or expert.summary.color, "bootstrap_pending": not server.app_runtime.agent_registry.is_bootstrapped(row.agent_id), + **report.as_api_fields(), } diff --git a/src/octop/api/routers/filesystem.py b/src/octop/api/routers/filesystem.py index b497436f73..abc84059f0 100644 --- a/src/octop/api/routers/filesystem.py +++ b/src/octop/api/routers/filesystem.py @@ -8,8 +8,8 @@ ``/etc``, ``/root`` on POSIX). The process home is never denied (so uid 0 with home ``/root`` can use the default picker path). - All authenticated users may browse from host root ``/`` (denylist still applies). - The UI default ``root_dir`` is the process home on bare metal, or host ``/`` - when Octop runs inside a container (override with ``OCTOP_IN_CONTAINER``). + The UI default ``root_dir`` is host filesystem root (POSIX ``/``), unless the + user has a ``workspace_root_dir`` policy jail. - Directory listing is capped and skips unreadable entries. - Write probe creates a short-lived dotfile only for non-``/`` selections. - mkdir / rename only allow basename-safe names under already-browsable parents. @@ -34,9 +34,9 @@ from octop.infra.utils.docker_env import docker_status, ensure_docker from octop.infra.utils.host_dirs import ( assert_safe_host_path, - default_host_root_dir, + host_browse_roots, host_fs_tree_root, - host_home_dir, + host_jail_enforced, host_path_text, list_host_subdirs, mkdir_host_subdir, @@ -79,31 +79,55 @@ async def filesystem_defaults( user: User = Depends(current_user), server: Any = Depends(get_server), ) -> dict[str, Any]: - """Return the process home path and browse-tree root (host ``/`` on POSIX). + """Return browse-tree / default root_dir for the current user. - When Octop runs inside a container, ``default_root_dir`` is filesystem - root (not home) unless the user has a workspace-root policy jail. + Unrestricted users get filesystem root; a workspace-root policy jail sets + both ``default_root_dir`` and ``tree_root`` to that path. + + ``browse_roots`` is the *list* of tree roots the picker may show — every + ready drive on Windows, not just the one holding the server's home + directory. ``jail_enforced`` reports whether a non-root ``root_dir`` really + gets OS-level confinement (Linux + bubblewrap) or only bounds the agent's + tool paths, so the UI does not promise a sandbox it cannot deliver. """ - home = host_path_text(host_home_dir()) in_container = running_in_container() allowed = _user_workspace_root(server, user) if allowed: return { - "home": home, "default_root_dir": allowed, - "allow_outside_home": False, "tree_root": allowed, + "browse_roots": [allowed], + "jail_enforced": host_jail_enforced(), "in_container": in_container, } + root = host_fs_tree_root() return { - "home": home, - "default_root_dir": default_host_root_dir(allow_outside_home=True), - "allow_outside_home": True, - "tree_root": host_fs_tree_root(allow_outside_home=True), + "default_root_dir": root, + "tree_root": root, + "browse_roots": host_browse_roots(), + "jail_enforced": host_jail_enforced(), "in_container": in_container, } +@router.get( + "/roots", + summary="Browse-tree roots available to the current user", +) +async def filesystem_roots( + user: User = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + """List the roots the root_dir picker may browse. + + A workspace-root policy jail collapses this to that single path; otherwise + unrestricted users get host ``/`` (POSIX) or every ready Windows drive. + """ + allowed = _user_workspace_root(server, user) + roots = [allowed] if allowed else host_browse_roots() + return {"roots": roots} + + @router.get("/dirs") async def list_host_dirs( path: str = Query("/", description="Absolute host directory to list"), diff --git a/src/octop/api/routers/internal_mcp.py b/src/octop/api/routers/internal_mcp.py index 5013d17c5a..bfff7e767a 100644 --- a/src/octop/api/routers/internal_mcp.py +++ b/src/octop/api/routers/internal_mcp.py @@ -56,7 +56,7 @@ async def internal_mcp_post( server: Any = Depends(get_server), ) -> Response: inst = server.services.repos.connector_repo.get(instance_id) - if inst is None or inst.kind != kind: + if inst is None or inst.kind != kind or inst.status != "active": raise OctopError(ErrorCode.CONNECTOR_NOT_FOUND, "instance not found") svc = _service(server) @@ -79,7 +79,10 @@ async def internal_mcp_post( if method == "initialize": session_id = secrets.token_urlsafe(16) - resp = await asyncio.to_thread(handle_mcp_request, kind=kind, creds=creds, body=body) + if kind == "qcc": + resp = await svc.handle_qcc_request(instance_id, body) + else: + resp = await asyncio.to_thread(handle_mcp_request, kind=kind, creds=creds, body=body) if not resp: return Response(status_code=202) diff --git a/src/octop/api/routers/invites.py b/src/octop/api/routers/invites.py index 05101b9af4..ee91326135 100644 --- a/src/octop/api/routers/invites.py +++ b/src/octop/api/routers/invites.py @@ -7,8 +7,10 @@ from fastapi import APIRouter, Depends, Request from pydantic import BaseModel, Field +from octop.api.common.client_ip import resolve_client_ip from octop.api.deps import get_server, require_permission, sign_token from octop.api.routers.auth import _user_json +from octop.infra.errors import ErrorCode, OctopError from octop.infra.users.identity import User from octop.infra.users.invites import ( DEFAULT_EXPIRES_DAYS, @@ -30,12 +32,8 @@ def _service(server: Any) -> InviteService: def _client_id(request: Request) -> str: - forwarded = request.headers.get("x-forwarded-for") - if forwarded: - return forwarded.split(",", 1)[0].strip() or "unknown" - if request.client and request.client.host: - return request.client.host - return "unknown" + """Trusted client address for invite rate limiting (see ``resolve_client_ip``).""" + return resolve_client_ip(request) def _public_base(request: Request) -> str: @@ -58,6 +56,10 @@ class InviteCreateBody(BaseModel): ge=MIN_EXPIRES_DAYS, le=MAX_EXPIRES_DAYS, ) + role: str | None = Field( + default=None, + description="Role-template public id stored on the invite. Applied at redeem time.", + ) class InviteValidateBody(BaseModel): @@ -89,11 +91,31 @@ async def create_invite( actor: User = Depends(require_permission("users")), server: Any = Depends(get_server), ) -> dict[str, Any]: + role_name: str | None = None + role_id: str | None = None + if body.role is not None: + from octop.api.routers.users import _assert_can_assign + from octop.infra.db.repos.user_roles import UserRoleRepo + + role = UserRoleRepo(server.services.db).get(body.role) + if role is None: + raise OctopError(ErrorCode.NOT_FOUND, "role not found") + if role.is_admin and not actor.is_admin: + raise OctopError( + ErrorCode.FORBIDDEN, + "only an administrator can invite the administrator role", + ) + if not role.is_admin: + _assert_can_assign(actor, list(role.permissions)) + role_name = role.user_role_name + role_id = role.user_role_id row = _service(server).create( created_by=actor.id, actor_username=actor.username, note=body.note, expires_in_days=body.expires_in_days, + role_name=role_name, + role=role_id, ) from octop.infra.db.repos.invites import invite_status_payload @@ -142,14 +164,12 @@ async def redeem_invite( email=body.email, locale=locale, ) - from octop.infra.agents.default_agent import try_bootstrap_default_agent + from octop.infra.agents.experts.default_agent import try_bootstrap_default_agent await try_bootstrap_default_agent(server, user_id=user.id, locale=user.locale) secret = server.services.secret_repo.get("jwt") ttl = server.services.config.access_token_ttl_seconds - token = sign_token( - secret, sub=user.id, uname=user.username, role=user.role.value, ttl_seconds=ttl - ) + token = sign_token(secret, sub=user.id, uname=user.username, role=user.role, ttl_seconds=ttl) return { "access_token": token, "token_type": "Bearer", diff --git a/src/octop/api/routers/knowledge_bases.py b/src/octop/api/routers/knowledge_bases.py index 231dfdf82e..7d412b3860 100644 --- a/src/octop/api/routers/knowledge_bases.py +++ b/src/octop/api/routers/knowledge_bases.py @@ -5,7 +5,7 @@ import logging from contextlib import suppress from dataclasses import asdict -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status from fastapi.responses import FileResponse @@ -56,6 +56,7 @@ KnowledgeService, ) from octop.infra.server import OctopServer +from octop.infra.users.groups import RESOURCE_KNOWLEDGE_BASE, GroupAccess from octop.infra.users.identity import User from octop.infra.utils.locale import resolve_request_locale @@ -91,7 +92,10 @@ class CreateBaseBody(BaseModel): description: str = Field(default="", max_length=2000) default_open: bool = False shared: bool = False + visibility: Literal["all", "groups", "owner"] | None = None + group_ids: list[str] | None = None icon_name: str = Field(default="", max_length=64) + icon_url: str = Field(default="", max_length=2048) max_documents: int | None = Field( default=None, ge=0, @@ -126,7 +130,10 @@ class UpdateBaseBody(BaseModel): description: str | None = Field(default=None, max_length=2000) default_open: bool | None = None shared: bool | None = None + visibility: Literal["all", "groups", "owner"] | None = None + group_ids: list[str] | None = None icon_name: str | None = Field(default=None, max_length=64) + icon_url: str | None = Field(default=None, max_length=2048) max_documents: int | None = Field( default=None, ge=0, @@ -172,9 +179,41 @@ def _owner_fields(server: OctopServer, owner_user_id: int) -> dict[str, str | No } +def _group_access(server: OctopServer) -> GroupAccess | None: + services = server.services + repos = getattr(services, "repos", None) if services is not None else None + if repos is None: + return None + return GroupAccess(repos) + + +def _kb_avatar_api_path(kb_id: str) -> str: + return f"/api/knowledge-bases/{kb_id}/avatar" + + +def _display_kb_icon_url(server: OctopServer, row: Any) -> str: + from octop.infra.users.profile_avatar import profile_avatar_url # noqa: PLC0415 + + text = str(getattr(row, "icon_url", "") or "").strip() + api = _kb_avatar_api_path(row.id) + if text.split("?", 1)[0] == api: + return profile_avatar_url(server.paths.knowledge_base_avatars_dir, row.id, api) or api + return text + + def _base_payload(server: OctopServer, row: Any) -> dict[str, Any]: payload = asdict(row) payload["knowledge_base_id"] = row.id + payload["icon_url"] = _display_kb_icon_url(server, row) + access = _group_access(server) + if access is not None: + payload.update( + access.visibility( + RESOURCE_KNOWLEDGE_BASE, + row.id, + shared=bool(row.shared), + ) + ) return {**payload, **_owner_fields(server, row.owner_user_id)} @@ -211,6 +250,12 @@ def _map_knowledge_error( if isinstance(exc, RuntimeError): if "disabled" in text: return OctopError.localized(ErrorCode.KNOWLEDGE_FEATURE_DISABLED, locale) + # Packages installed into a directory the running interpreter did not have on + # sys.path at startup (pip's user site) are importable only after a restart. + # Report that instead of the generic prerequisite error, which told the user + # nothing they could act on and sent them into a retry loop. + if "restart the server" in text: + return OctopError.localized(ErrorCode.KNOWLEDGE_PREREQUISITES_RESTART_REQUIRED, locale) if any( kw in text for kw in ( @@ -538,15 +583,32 @@ async def create_base( locale = resolve_request_locale(request) try: _require_usable(server, request) + access = _group_access(server) + if body.visibility is not None and access is not None: + access.validate_visibility(body.visibility, body.group_ids) base = _knowledge_service(server).create_base( owner_user_id=user.id, name=body.name.strip(), description=body.description.strip(), default_open=body.default_open, - shared=body.shared, + shared=( + False + if body.visibility == "owner" + else True + if body.visibility in {"all", "groups"} + else body.shared + ), icon_name=body.icon_name.strip(), + icon_url=body.icon_url.strip(), max_documents=body.max_documents if body.max_documents is not None else MAX_DOCS_PER_KB, ) + if body.visibility is not None and access is not None: + access.set_visibility( + RESOURCE_KNOWLEDGE_BASE, + base.id, + body.visibility, + body.group_ids, + ) return _base_payload(server, base) except Exception as exc: raise _map_knowledge_error(exc, locale=locale, server=server) from exc @@ -598,20 +660,34 @@ async def update_base( user: User = Depends(require_permission("knowledge_bases")), ) -> dict[str, Any]: try: - return _base_payload( - server, - _knowledge_service(server).update_base( - kb_id, - actor_user_id=user.id, - name=body.name.strip() if body.name is not None else None, - description=body.description.strip() if body.description is not None else None, - default_open=body.default_open, - shared=body.shared, - icon_name=body.icon_name.strip() if body.icon_name is not None else None, - max_documents=body.max_documents, - is_admin=_is_admin(user), - ), + access = _group_access(server) + if body.visibility is not None and access is not None: + access.validate_visibility(body.visibility, body.group_ids) + shared = body.shared + if body.visibility == "owner": + shared = False + elif body.visibility in {"all", "groups"}: + shared = True + updated = _knowledge_service(server).update_base( + kb_id, + actor_user_id=user.id, + name=body.name.strip() if body.name is not None else None, + description=body.description.strip() if body.description is not None else None, + default_open=body.default_open, + shared=shared, + icon_name=body.icon_name.strip() if body.icon_name is not None else None, + icon_url=body.icon_url.strip() if body.icon_url is not None else None, + max_documents=body.max_documents, + is_admin=_is_admin(user), ) + if body.visibility is not None and access is not None: + access.set_visibility( + RESOURCE_KNOWLEDGE_BASE, + kb_id, + body.visibility, + body.group_ids, + ) + return _base_payload(server, updated) except Exception as exc: raise _map_knowledge_error( exc, locale=resolve_request_locale(request), server=server @@ -631,6 +707,103 @@ async def delete_base( _knowledge_service(server).delete_base( kb_id, actor_user_id=user.id, is_admin=_is_admin(user) ) + from octop.infra.users.profile_avatar import delete_profile_avatar # noqa: PLC0415 + + delete_profile_avatar(server.paths.knowledge_base_avatars_dir, kb_id) + except Exception as exc: + raise _map_knowledge_error( + exc, locale=resolve_request_locale(request), server=server + ) from exc + + +@router.post("/{kb_id}/avatar", status_code=201, summary="Upload a knowledge-base portrait") +async def upload_kb_avatar( + kb_id: str, + request: Request, + file: UploadFile = File(...), # noqa: B008 + server: OctopServer = Depends(get_server), + user: User = Depends(require_permission("knowledge_bases")), +) -> dict[str, str]: + from octop.infra.users.profile_avatar import ( # noqa: PLC0415 + profile_avatar_url, + write_profile_avatar, + ) + + try: + _knowledge_service(server).require_owner( + kb_id, actor_user_id=user.id, is_admin=_is_admin(user) + ) + write_profile_avatar( + server.paths.knowledge_base_avatars_dir, + kb_id, + await file.read(), + ) + icon_url = _kb_avatar_api_path(kb_id) + updated = _knowledge_service(server).update_base( + kb_id, + actor_user_id=user.id, + icon_url=icon_url, + is_admin=_is_admin(user), + ) + display = ( + profile_avatar_url( + server.paths.knowledge_base_avatars_dir, + kb_id, + icon_url, + ) + or icon_url + ) + return {"icon_url": display or updated.icon_url} + except Exception as exc: + raise _map_knowledge_error( + exc, locale=resolve_request_locale(request), server=server + ) from exc + + +@router.get("/{kb_id}/avatar", summary="Read a knowledge-base portrait") +async def get_kb_avatar( + kb_id: str, + request: Request, + server: OctopServer = Depends(get_server), + user: User = Depends(current_user), +) -> Any: + from octop.infra.users.profile_avatar import avatar_response # noqa: PLC0415 + + try: + _knowledge_service(server).get_readable_base( + kb_id, actor_user_id=user.id, is_admin=_is_admin(user) + ) + return avatar_response(server.paths.knowledge_base_avatars_dir, kb_id) + except Exception as exc: + raise _map_knowledge_error( + exc, locale=resolve_request_locale(request), server=server + ) from exc + + +@router.delete( + "/{kb_id}/avatar", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a knowledge-base portrait", +) +async def delete_kb_avatar( + kb_id: str, + request: Request, + server: OctopServer = Depends(get_server), + user: User = Depends(require_permission("knowledge_bases")), +) -> None: + from octop.infra.users.profile_avatar import delete_profile_avatar # noqa: PLC0415 + + try: + _knowledge_service(server).require_owner( + kb_id, actor_user_id=user.id, is_admin=_is_admin(user) + ) + delete_profile_avatar(server.paths.knowledge_base_avatars_dir, kb_id) + _knowledge_service(server).update_base( + kb_id, + actor_user_id=user.id, + icon_url="", + is_admin=_is_admin(user), + ) except Exception as exc: raise _map_knowledge_error( exc, locale=resolve_request_locale(request), server=server @@ -721,7 +894,10 @@ async def create_text_document( async def upload_document( kb_id: str, request: Request, - upload: UploadFile = File(..., description="A supported text, PDF, DOCX, or PPTX document."), + upload: UploadFile = File( + ..., + description="A supported text, PDF, DOC/WPS, DOCX, or PPTX document.", + ), path: str | None = Form( default=None, description="Optional relative path including filename (for nested folders).", diff --git a/src/octop/api/routers/mbti.py b/src/octop/api/routers/mbti.py index 75933e1bec..1c5ee7a497 100644 --- a/src/octop/api/routers/mbti.py +++ b/src/octop/api/routers/mbti.py @@ -15,8 +15,8 @@ from pydantic import BaseModel, Field from octop.api.common.agent import assert_agent_owner -from octop.api.deps import current_user, get_server -from octop.infra.agents.mbti_profiles import ( +from octop.api.deps import current_user, get_server, require_permission +from octop.infra.agents.persona.mbti_profiles import ( MBTIProfile, get_all_profiles, get_profile, @@ -25,7 +25,11 @@ logger = logging.getLogger(__name__) -router = APIRouter(prefix="/mbti", tags=["mbti"]) +router = APIRouter( + prefix="/mbti", + tags=["mbti"], + dependencies=[Depends(require_permission("mbti"))], +) # --------------------------------------------------------------------------- diff --git a/src/octop/api/routers/media_generation.py b/src/octop/api/routers/media_generation.py index 089f2e7d88..5ac85fd92c 100644 --- a/src/octop/api/routers/media_generation.py +++ b/src/octop/api/routers/media_generation.py @@ -8,19 +8,30 @@ from pydantic import BaseModel, Field from octop.api.deps import get_server, require_permission -from octop.infra.agents.media_generation import ( - DEFAULT_ARK_BASE_URL, - DEFAULT_IMAGE_MODEL, - DEFAULT_VIDEO_MODEL, +from octop.infra.agents.settings.media_generation import ( + MEDIA_PROVIDER_PRESETS, + MediaGenerationSettings, + MediaProviderName, + MediaProviderSettings, + MediaProviderUpdate, ) -from octop.infra.errors import ErrorCode, OctopError router = APIRouter() -class MediaGenerationSettingsResponse(BaseModel): +class MediaProviderPresetResponse(BaseModel): + provider: MediaProviderName + display_name: str + base_url: str + image_models: list[str] + video_models: list[str] + + +class MediaProviderResponse(BaseModel): + id: str + provider: MediaProviderName + display_name: str enabled: bool - provider: str base_url: str image_enabled: bool video_enabled: bool @@ -30,26 +41,47 @@ class MediaGenerationSettingsResponse(BaseModel): configured: bool -class MediaGenerationSettingsBody(BaseModel): - enabled: bool = False +class MediaGenerationSettingsResponse(BaseModel): + enabled: bool + providers: list[MediaProviderResponse] + default_image_provider: str | None + default_video_provider: str | None + configured: bool + + +class MediaProviderBody(BaseModel): + id: str = Field(description="Stable provider-instance routing ID.") + provider: MediaProviderName + display_name: str + enabled: bool = True + base_url: str image_enabled: bool = True video_enabled: bool = True - image_model: str = DEFAULT_IMAGE_MODEL - video_model: str = DEFAULT_VIDEO_MODEL + image_model: str + video_model: str api_key: str | None = Field( default=None, - description="Write-only Ark inference API key; omit to keep the stored key.", + description="Write-only API key; omit to keep the stored value.", + ) + clear_api_key: bool = Field( + default=False, + description="Delete the stored API key for this provider instance.", ) + def to_domain(self) -> MediaProviderUpdate: + return MediaProviderUpdate(**self.model_dump()) + + +class MediaGenerationSettingsBody(BaseModel): + enabled: bool = False + providers: list[MediaProviderBody] = Field(default_factory=list, max_length=8) + default_image_provider: str | None = None + default_video_provider: str | None = None + class MediaGenerationTestBody(BaseModel): kind: Literal["credentials", "image", "video"] = "credentials" - api_key: str | None = Field( - default=None, - description="Draft Ark API key; omit to test the stored key.", - ) - image_model: str = DEFAULT_IMAGE_MODEL - video_model: str = DEFAULT_VIDEO_MODEL + provider: MediaProviderBody class MediaGenerationTestResponse(BaseModel): @@ -57,10 +89,12 @@ class MediaGenerationTestResponse(BaseModel): error: str | None = None -def _response(view: Any) -> MediaGenerationSettingsResponse: - return MediaGenerationSettingsResponse( - enabled=view.enabled, +def _provider_response(view: MediaProviderSettings) -> MediaProviderResponse: + return MediaProviderResponse( + id=view.id, provider=view.provider, + display_name=view.display_name, + enabled=view.enabled, base_url=view.base_url, image_enabled=view.image_enabled, video_enabled=view.video_enabled, @@ -71,10 +105,44 @@ def _response(view: Any) -> MediaGenerationSettingsResponse: ) +def _response(view: MediaGenerationSettings) -> MediaGenerationSettingsResponse: + return MediaGenerationSettingsResponse( + enabled=view.enabled, + providers=[_provider_response(item) for item in view.providers], + default_image_provider=view.default_image_provider, + default_video_provider=view.default_video_provider, + configured=view.configured, + ) + + +@router.get( + "/presets", + summary="List built-in media provider presets", + description="Return supported provider adapters and their default image and video models.", + response_model=list[MediaProviderPresetResponse], +) +async def list_media_provider_presets( + _: Any = Depends(require_permission("providers")), +) -> list[MediaProviderPresetResponse]: + return [ + MediaProviderPresetResponse( + provider=item.provider, + display_name=item.display_name, + base_url=item.base_url, + image_models=list(item.image_models), + video_models=list(item.video_models), + ) + for item in MEDIA_PROVIDER_PRESETS.values() + ] + + @router.get( "", summary="Get media generation settings", - description="Return instance-wide Volcengine Ark generation settings. The stored API key is never returned.", + description=( + "Return instance-wide provider instances and default image/video routes. " + "Stored API keys are never returned." + ), response_model=MediaGenerationSettingsResponse, ) async def get_media_generation_settings( @@ -87,7 +155,10 @@ async def get_media_generation_settings( @router.put( "", summary="Update media generation settings", - description="Verify a new Ark API key when supplied, persist the settings, and reload running agents.", + description=( + "Persist provider instances and deterministic default routes, then reload " + "running agents. API keys are write-only; omit them to retain stored values." + ), response_model=MediaGenerationSettingsResponse, ) async def put_media_generation_settings( @@ -95,51 +166,39 @@ async def put_media_generation_settings( _: Any = Depends(require_permission("providers")), server: Any = Depends(get_server), ) -> MediaGenerationSettingsResponse: - api_key = (body.api_key or "").strip() or None - store = server.app_runtime.agent_registry.media_generation - if api_key is not None: - result = await store.test_connection(api_key=api_key) - if not result.get("ok"): - raise OctopError( - ErrorCode.SLASH_BAD_ARGS, - str(result.get("error") or "Ark API key verification failed"), - ) view = await server.app_runtime.agent_registry.save_media_generation( enabled=body.enabled, - image_enabled=body.image_enabled, - video_enabled=body.video_enabled, - image_model=body.image_model, - video_model=body.video_model, - api_key=api_key, + providers=[item.to_domain() for item in body.providers], + default_image_provider=body.default_image_provider, + default_video_provider=body.default_video_provider, ) return _response(view) @router.post( "/test", - summary="Test Ark media generation credentials", - description="Test credentials or submit a real image/video model request. Model tests may incur provider charges.", + summary="Test a media provider configuration", + description=( + "Test credentials without generation, or submit a real image/video model " + "request. Model tests may incur provider charges." + ), response_model=MediaGenerationTestResponse, ) -async def test_media_generation_credentials( +async def test_media_generation_provider( body: MediaGenerationTestBody, _: Any = Depends(require_permission("providers")), server: Any = Depends(get_server), ) -> MediaGenerationTestResponse: store = server.app_runtime.agent_registry.media_generation - api_key = (body.api_key or "").strip() or None + provider = body.provider.to_domain() if body.kind == "credentials": - result = await store.test_connection(api_key=api_key) + result = await store.test_connection(provider) else: - result = await store.test_model( - kind=body.kind, - model=body.image_model if body.kind == "image" else body.video_model, - api_key=api_key, - ) + result = await store.test_model(provider, kind=body.kind) return MediaGenerationTestResponse( ok=bool(result.get("ok")), error=str(result["error"]) if result.get("error") else None, ) -__all__ = ["DEFAULT_ARK_BASE_URL", "router"] +__all__ = ["router"] diff --git a/src/octop/api/routers/memory.py b/src/octop/api/routers/memory.py index fc35996767..ddf345bcff 100644 --- a/src/octop/api/routers/memory.py +++ b/src/octop/api/routers/memory.py @@ -2,7 +2,7 @@ Mounts at ``/api/agents/{agent_id}/memory/*`` and forwards each endpoint to a single JSON-RPC method on the agent's -``harness_memory.Bridge``. The router itself contains no business +``octop_memory.Bridge``. The router itself contains no business logic — every handler is one ``call_memory_rpc(...)`` call. Surface (mirrors the design doc §6.2): @@ -36,21 +36,22 @@ import json import logging -from typing import Any, cast +from typing import Any, Literal, cast from fastapi import APIRouter, Body, Depends, HTTPException, Query from pydantic import BaseModel, Field from octop.api.common.agent import assert_agent_owner, require_agent_owner_row from octop.api.common.memory_client import call_memory_rpc -from octop.api.deps import current_user, get_server +from octop.api.deps import current_user, get_server, require_permission +from octop.infra.agents.manager import memory_extract_supported_options from octop.infra.errors import ErrorCode, OctopError logger = logging.getLogger(__name__) -router = APIRouter() +router = APIRouter(dependencies=[Depends(require_permission("memory"))]) -# Defaults mirror ``HarnessAgentConfig`` (harness_agent.config). Kept in sync +# Defaults mirror ``HarnessAgentConfig`` (octop_harness.config). Kept in sync # manually — the dashboard writes these into the agent's ``config_json`` under # the ``memory`` section, and ``AgentManager._build_harness_config`` reads them # back when constructing the agent. @@ -63,11 +64,24 @@ # Model ref ("provider/model") used for extraction / promotion; None → AUTO # (follow the agent's effective chat model). "aux_model": None, + # Aux-call options (TencentCloud/Octop#1360): how long a memory call may + # wait and how much it may generate. None → harness defaults. These are + # independent of the idle/interval trigger above, which only decides when + # extraction fires. + "extract_light_timeout_s": None, + "extract_heavy_timeout_s": None, + "extract_max_tokens": None, + "extract_extra_body": None, + "extract_thinking": "auto", + "extract_max_retries": 2, + "extract_retry_backoff_s": 1.0, } # Guard rails so a bad UI value can't schedule a hot loop or a never-firing timer. _MIN_IDLE_SECONDS = 60.0 # UI minimum: one minute; zero must not disable extraction _MIN_INTERVAL_SECONDS = 300.0 # 5 min floor for the fixed-interval sweep _MAX_SECONDS = 7 * 24 * 3600.0 # 7 days +_MIN_AUX_TIMEOUT_SECONDS = 30.0 # below this even a warm-up cannot finish +_MAX_AUX_TOKENS = 65536 # --------------------------------------------------------------------------- @@ -191,6 +205,57 @@ class _ExtractConfigBody(BaseModel): default=None, description="'provider/model' ref for extraction; '' resets to AUTO", ) + extract_light_timeout_s: float | None = Field( + default=None, + allow_inf_nan=False, + description=( + "Read timeout in seconds for light memory calls (extraction); " + "0 resets to the harness default. Distinct from the idle trigger." + ), + ) + extract_heavy_timeout_s: float | None = Field( + default=None, + allow_inf_nan=False, + description=( + "Read timeout in seconds for heavy memory calls (promotion / " + "page regen); 0 resets to the harness default." + ), + ) + extract_max_tokens: int | None = Field( + default=None, + description=( + "Output budget for candidate and episode extraction (128..65536). " + "0 resets to the built-in 2048 tokens." + ), + ) + extract_extra_body: dict[str, Any] | None = Field( + default=None, + description=( + "Vendor-specific request extras for OpenAI-compatible endpoints " + "(e.g. thinking controls or reasoning_effort); applied only to the " + "first selected model, not fallback models. {} resets to unset." + ), + ) + + extract_thinking: Literal["auto", "off", "on"] | None = Field( + default=None, + description="Memory-only thinking: auto/off for supported models; on keeps the model default.", + ) + extract_max_retries: int | None = Field( + default=None, + ge=0, + le=10, + strict=True, + description="Extra attempts per model for transient failures. 0 disables retries; default 2.", + ) + extract_retry_backoff_s: float | None = Field( + default=None, + ge=0, + le=60, + strict=True, + allow_inf_nan=False, + description="Base retry delay in seconds; doubles per retry, capped at 60s. Default 1.", + ) class _RejectCandidateBody(BaseModel): @@ -798,6 +863,29 @@ def _validated_aux_model(value: Any, providers: Any) -> str | None: return ref +def _coerce_aux_timeout(value: Any) -> float | None: + """Clamp an aux read timeout; ``<= 0`` resets to the harness default.""" + seconds = float(value) + if seconds <= 0: + return None + return min(3600.0, _coerce_seconds(seconds, minimum=_MIN_AUX_TIMEOUT_SECONDS)) + + +def _coerce_aux_max_tokens(value: Any) -> int | None: + """Clamp the aux completion budget; ``<= 0`` resets to automatic.""" + tokens = int(value) + if tokens <= 0: + return None + return max(128, min(tokens, _MAX_AUX_TOKENS)) + + +def _coerce_aux_extra_body(value: Any) -> dict[str, Any] | None: + """Normalize the aux ``extra_body``; empty object resets to unset.""" + if not isinstance(value, dict): + raise HTTPException(status_code=400, detail="extract_extra_body must be a JSON object") + return dict(value) or None + + @router.get("/agents/{agent_id}/memory/extract-config") async def get_extract_config( agent_id: str, @@ -805,9 +893,12 @@ async def get_extract_config( user: Any = Depends(current_user), server: Any = Depends(get_server), ) -> dict[str, Any]: - """Return the agent's extraction-trigger configuration (with defaults).""" + """Return memory extraction timing and independent model call options.""" row = require_agent_owner_row(agent_id, user=user, as_user=as_user, server=server) - return _read_extract_config(row) + return { + **_read_extract_config(row), + "supported_call_options": memory_extract_supported_options(), + } @router.put("/agents/{agent_id}/memory/extract-config") @@ -852,6 +943,17 @@ async def put_extract_config( ) if "aux_model" in patch: merged["aux_model"] = _validated_aux_model(patch["aux_model"], registry.providers) + if "extract_light_timeout_s" in patch: + merged["extract_light_timeout_s"] = _coerce_aux_timeout(patch["extract_light_timeout_s"]) + if "extract_heavy_timeout_s" in patch: + merged["extract_heavy_timeout_s"] = _coerce_aux_timeout(patch["extract_heavy_timeout_s"]) + if "extract_max_tokens" in patch: + merged["extract_max_tokens"] = _coerce_aux_max_tokens(patch["extract_max_tokens"]) + if "extract_extra_body" in patch: + merged["extract_extra_body"] = _coerce_aux_extra_body(patch["extract_extra_body"]) + for key in ("extract_thinking", "extract_max_retries", "extract_retry_backoff_s"): + if key in patch: + merged[key] = patch[key] try: cfg = json.loads(row.config_json or "{}") @@ -865,7 +967,7 @@ async def put_extract_config( cfg["memory"] = mem_section await registry.update(agent_id, config_json=json.dumps(cfg)) - return merged + return {**merged, "supported_call_options": memory_extract_supported_options()} __all__ = ["router"] diff --git a/src/octop/api/routers/memory_portable.py b/src/octop/api/routers/memory_portable.py index ab8f0076c5..ba8b5a0c08 100644 --- a/src/octop/api/routers/memory_portable.py +++ b/src/octop/api/routers/memory_portable.py @@ -22,13 +22,13 @@ from octop.api.common.agent_workspace import resolve_agent_workspace_dir from octop.api.common.content_disposition import content_disposition from octop.api.common.memory_client import memory_db_path_for_cfg, memory_namespace -from octop.api.deps import current_user, get_server -from octop.infra.agents.memory_backend import open_memory_kwargs +from octop.api.deps import current_user, get_server, require_permission +from octop.infra.agents.memory.backend import open_memory_kwargs from octop.infra.errors import ErrorCode, OctopError logger = logging.getLogger(__name__) -router = APIRouter() +router = APIRouter(dependencies=[Depends(require_permission("memory"))]) def _agent_config_dict(server: Any, agent_id: str) -> dict[str, Any]: @@ -51,7 +51,7 @@ def _refuse_postgres_portable(server: Any, agent_id: str) -> None: SQLite: one file per agent — pack/adopt moves that file's contents between hosts (Octop / OpenClaw / hermes). - PostgreSQL: all agents share fixed tables in the ``harness_memory`` schema, + PostgreSQL: all agents share fixed tables in the ``octop_memory`` schema, isolated by a ``namespace`` column. There is no per-agent file to pack, and ``pg_dump`` of the schema must NOT be suggested as a substitute — it would export every agent's memory, not just this one. The supported way to give @@ -102,13 +102,13 @@ async def list_portable_sources( ) -> JSONResponse: """List all migratable memory stores on this host.""" try: - from harness_memory.operations.migration.portable import list_sources + from octop_memory.operations.migration.portable import list_sources sources = list_sources() return JSONResponse(content={"sources": [s.to_dict() for s in sources]}) except ImportError: raise OctopError( - ErrorCode.INTERNAL_ERROR, "harness-memory 未安装,无法使用记忆迁移功能" + ErrorCode.INTERNAL_ERROR, "octop-memory 未安装,无法使用记忆迁移功能" ) from None except Exception as exc: logger.exception("list_portable_sources failed") @@ -132,9 +132,9 @@ async def pack_agent_memory( _refuse_postgres_portable(server, agent_id) try: - from harness_memory.operations.migration.portable import pack - from harness_memory.operations.migration.portable.models import SourceInfo - from harness_memory.operations.migration.portable.sources import _probe_db + from octop_memory.operations.migration.portable import pack + from octop_memory.operations.migration.portable.models import SourceInfo + from octop_memory.operations.migration.portable.sources import _probe_db # Resolve the agent's db path and namespace workspace = resolve_agent_workspace_dir(server, agent_id) @@ -182,7 +182,7 @@ def _iter_file() -> Any: ) except ImportError: - raise OctopError(ErrorCode.INTERNAL_ERROR, "harness-memory 未安装") from None + raise OctopError(ErrorCode.INTERNAL_ERROR, "octop-memory 未安装") from None except ValueError as exc: raise OctopError(ErrorCode.INTERNAL_ERROR, str(exc), status=400) from exc except Exception as exc: @@ -213,7 +213,7 @@ async def adopt_agent_memory( _refuse_postgres_portable(server, agent_id) try: - from harness_memory.operations.migration.portable import adopt + from octop_memory.operations.migration.portable import adopt # Write the uploaded file to a temp file with tempfile.NamedTemporaryFile(suffix=".hmpkg", delete=False) as tmp: @@ -237,7 +237,7 @@ async def adopt_agent_memory( return JSONResponse(content=summary.to_dict()) except ImportError: - raise OctopError(ErrorCode.INTERNAL_ERROR, "harness-memory 未安装") from None + raise OctopError(ErrorCode.INTERNAL_ERROR, "octop-memory 未安装") from None except ValueError as exc: raise OctopError(ErrorCode.INTERNAL_ERROR, str(exc), status=400) from exc except Exception as exc: @@ -264,7 +264,7 @@ async def doctor_agent_memory( _refuse_postgres_portable(server, agent_id) try: - from harness_memory.operations.migration.portable import doctor + from octop_memory.operations.migration.portable import doctor # Resolve the agent's db path workspace = resolve_agent_workspace_dir(server, agent_id) @@ -294,7 +294,7 @@ async def doctor_agent_memory( return JSONResponse(content=report.to_dict()) except ImportError: - raise OctopError(ErrorCode.INTERNAL_ERROR, "harness-memory 未安装") from None + raise OctopError(ErrorCode.INTERNAL_ERROR, "octop-memory 未安装") from None except Exception as exc: logger.exception("doctor_agent_memory failed for agent_id=%s", agent_id) raise OctopError(ErrorCode.INTERNAL_ERROR, str(exc)) from exc diff --git a/src/octop/api/routers/memory_storage.py b/src/octop/api/routers/memory_storage.py new file mode 100644 index 0000000000..7aa4f5f93f --- /dev/null +++ b/src/octop/api/routers/memory_storage.py @@ -0,0 +1,254 @@ +"""Owner-authorized storage inspection and host-coordinated expert maintenance.""" + +from __future__ import annotations + +from typing import Any, Literal + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel, Field + +from octop.api.common.agent import require_agent_owner_row +from octop.api.deps import current_user, get_server, require_permission +from octop.infra.utils.locale import resolve_request_locale + +router = APIRouter( + prefix="/agents/{agent_id}/memory/storage", + dependencies=[Depends(require_permission("memory"))], +) + + +class StorageTableCheck(BaseModel): + table: str + exists: bool + live_tuples: int | None = None + dead_tuples: int | None = None + last_vacuum: str | None = None + last_autovacuum: str | None = None + + +class NamespaceTableCheck(BaseModel): + table: str + rows: int + row_bytes: int + + +class CleanupPreview(BaseModel): + candidates: int + journal: int + total: int + limit_per_table: int + candidate_days: int + journal_days: int + + +class SqliteCountCheck(BaseModel): + table: str = Field(description="Logical data category, excluding full-text index duplicates.") + rows: int | None = Field(description="Exact snapshot count; null when missing or timed out.") + status: Literal["ok", "missing", "timed_out"] + elapsed_ms: float = Field(description="Server-side count query duration in milliseconds.") + + +class StorageInspection(BaseModel): + backend: str + scope: Literal["agent", "shared_database"] + maintenance_supported: bool + blocked_reason: ( + Literal["postgres", "shared_sqlite", "shared_namespace", "incompatible"] | None + ) = None + checked_at: float + elapsed_ms: float | None = Field( + default=None, description="Server inspection time, not a benchmark or network latency." + ) + file_check_ms: float | None = None + checkpoints_available: bool | None = Field( + default=None, + description="False for a shared SQLite file: unprefixed checkpoints cannot be attributed safely.", + ) + memory_counts: list[SqliteCountCheck] = Field(default_factory=list) + checkpoint_counts: list[SqliteCountCheck] = Field(default_factory=list) + namespace: str | None = None + namespace_tables: list[NamespaceTableCheck] = Field(default_factory=list) + cleanup_preview: CleanupPreview | None = None + file_size: int | None = None + wal_size: int | None = None + reclaimable_bytes: int | None = None + auto_vacuum_enabled: bool | None = None + page_size: int | None = None + total_pages: int | None = None + freelist_pages: int | None = None + would_reclaim_pages: int | None = None + tables: list[StorageTableCheck] = Field(default_factory=list) + + +class StorageJob(BaseModel): + job_id: str + backend: str | None = None + operation: str | None = None + restored_backup_id: str | None = None + scope: Literal["agent"] + phase: str + started_at: float + updated_at: float + backup_path: str | None = None + file_bytes: int | None = None + scanned: int | None = None + total: int | None = None + error: str | None = None + report: dict[str, Any] | None = Field( + default=None, + description="SQLite file statistics plus inspection_before/inspection_after snapshots, or PostgreSQL namespace cleanup counts and backup details.", + ) + + +class StorageMaintenanceStatus(BaseModel): + busy: bool = Field(description="A manual memory job is active on this host.") + job: StorageJob | None = Field( + default=None, + description="This expert's latest page maintenance job; retained until host restart.", + ) + + +class StartStorageMaintenance(BaseModel): + confirmed: Literal[True] = Field( + description="Confirm backup, temporary pause of new turns, and maintenance of this expert only." + ) + + +class SqliteBackup(BaseModel): + backup_id: str = Field( + description="Server-generated sibling filename; arbitrary paths are rejected." + ) + created_at: float + size_bytes: int + kind: Literal["before_restore", "before_maintenance"] + + +class StorageBackupSelection(BaseModel): + confirmed: Literal[True] + backup_id: str = Field(min_length=1, max_length=255) + + +@router.get( + "/backups", + response_model=list[SqliteBackup], + summary="List this expert's SQLite backups, including before-restore snapshots", +) +async def backups( + agent_id: str, + request: Request, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> Any: + require_agent_owner_row(agent_id, user=user, as_user=None, server=server) + try: + return await server.app_runtime.agent_registry.memory_slim.backups( + agent_id, locale=resolve_request_locale(request) + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.delete( + "/backups", + status_code=204, + summary="Permanently delete one backup belonging to this expert", + description="Requires explicit confirmation. Rejects shared stores, arbitrary paths and deletion during maintenance or restore. Does not change the active memory database.", +) +async def delete_backup( + agent_id: str, + body: StorageBackupSelection, + request: Request, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> Response: + row = require_agent_owner_row(agent_id, user=user, as_user=None, server=server) + try: + await server.app_runtime.agent_registry.memory_slim.delete_backup( + agent_id, row.user_id or 0, body.backup_id, locale=resolve_request_locale(request) + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return Response(status_code=204) + + +@router.post( + "/restore", + response_model=StorageMaintenanceStatus, + status_code=202, + summary="Back up the current state and restore this expert's SQLite backup", + description="Pauses only this expert, validates the selected backup, creates a full safety backup, restores memory/checkpoints and recreates the runtime. Other experts and the control-plane database are not restored.", +) +async def restore( + agent_id: str, + body: StorageBackupSelection, + request: Request, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> Any: + from octop.api.common.memory_client import invalidate_cached_memory + + row = require_agent_owner_row(agent_id, user=user, as_user=None, server=server) + try: + result = server.app_runtime.agent_registry.memory_slim.start_restore( + agent_id, row.user_id or 0, body.backup_id, locale=resolve_request_locale(request) + ) + invalidate_cached_memory(agent_id) + return result + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.get( + "/check", + response_model=StorageInspection, + summary="Inspect memory storage without changing data", +) +async def check_storage( + agent_id: str, + request: Request, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> Any: + require_agent_owner_row(agent_id, user=user, as_user=None, server=server) + try: + return await server.app_runtime.agent_registry.memory_slim.inspect_storage( + agent_id, locale=resolve_request_locale(request) + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.get( + "/maintenance", + response_model=StorageMaintenanceStatus, + summary="Read this expert's maintenance progress and result", +) +async def maintenance_status( + agent_id: str, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> Any: + require_agent_owner_row(agent_id, user=user, as_user=None, server=server) + return server.app_runtime.agent_registry.memory_slim.storage_status(agent_id) + + +@router.post( + "/maintenance", + response_model=StorageMaintenanceStatus, + status_code=202, + summary="Back up and maintain only this expert's memory store", +) +async def start_maintenance( + agent_id: str, + body: StartStorageMaintenance, + request: Request, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> Any: + row = require_agent_owner_row(agent_id, user=user, as_user=None, server=server) + try: + return server.app_runtime.agent_registry.memory_slim.start_current( + agent_id, row.user_id or 0, locale=resolve_request_locale(request) + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc diff --git a/src/octop/api/routers/mobile/install.py b/src/octop/api/routers/mobile/install.py index 4ed1545e34..16bbd16b25 100644 --- a/src/octop/api/routers/mobile/install.py +++ b/src/octop/api/routers/mobile/install.py @@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse from octop.api.deps import require_permission -from octop.infra.mobile.setup import install_mobile_stream +from octop.infra.control.mobile.setup import install_mobile_stream from octop.infra.users.identity import User from octop.infra.utils.locale import resolve_request_locale diff --git a/src/octop/api/routers/mobile/shell_ws.py b/src/octop/api/routers/mobile/shell_ws.py index bf579c5ea2..52566fa9a1 100644 --- a/src/octop/api/routers/mobile/shell_ws.py +++ b/src/octop/api/routers/mobile/shell_ws.py @@ -17,7 +17,7 @@ from starlette.websockets import WebSocketState from octop.api.deps import resolve_user_from_token -from octop.infra.mobile.adb import find_adb, list_devices +from octop.infra.control.mobile.adb import find_adb, list_devices from octop.infra.users.identity import User from octop.infra.users.permissions import user_has_permission from octop.infra.utils import posix_compat diff --git a/src/octop/api/routers/mobile/status.py b/src/octop/api/routers/mobile/status.py index 22cd64317c..a834cc0863 100644 --- a/src/octop/api/routers/mobile/status.py +++ b/src/octop/api/routers/mobile/status.py @@ -9,12 +9,12 @@ from pydantic import BaseModel, Field from octop.api.deps import get_server, require_permission -from octop.infra.mobile.adb import device_info, list_devices -from octop.infra.mobile.agent_control import ( +from octop.infra.control.mobile.adb import device_info, list_devices +from octop.infra.control.mobile.agent_control import ( get_mobile_agent_control, set_mobile_agent_control, ) -from octop.infra.mobile.setup import mobile_status +from octop.infra.control.mobile.setup import mobile_status from octop.infra.users.identity import User from octop.infra.utils.locale import resolve_request_locale diff --git a/src/octop/api/routers/mobile/stream.py b/src/octop/api/routers/mobile/stream.py index 9c81700500..843d3c1dd1 100644 --- a/src/octop/api/routers/mobile/stream.py +++ b/src/octop/api/routers/mobile/stream.py @@ -14,7 +14,7 @@ from starlette.websockets import WebSocketState from octop.api.deps import resolve_user_from_token -from octop.infra.mobile.adb import ( +from octop.infra.control.mobile.adb import ( KEYCODE_APP_SWITCH, KEYCODE_BACK, KEYCODE_HOME, @@ -32,11 +32,11 @@ toggle_portrait_landscape, wm_size, ) -from octop.infra.mobile.agent_control import ( +from octop.infra.control.mobile.agent_control import ( clear_mobile_agent_control_if_device, set_mobile_agent_control, ) -from octop.infra.mobile.h264 import ( +from octop.infra.control.mobile.h264 import ( NAL_IDR, NAL_PPS, NAL_SLICE, @@ -47,7 +47,7 @@ avcc_sample, nal_type, ) -from octop.infra.mobile.setup import mobile_status +from octop.infra.control.mobile.setup import mobile_status from octop.infra.users.identity import User from octop.infra.users.permissions import user_has_permission from octop.infra.utils.locale import resolve_request_locale @@ -62,6 +62,11 @@ _MIN_FPS = 1.0 _MAX_FPS = 20.0 _CAPTURE_TIMEOUT_S = 8.0 +# Consecutive frames that may fail before the stream gives up, matching the +# desktop loop (``api/routers/desktop/stream.py``). At 10 fps that is ~3 s of +# retries — long enough to ride out a hiccup, short enough that an unplugged +# phone nets an error frame instead of a silent freeze. +_CAPTURE_MISS_LIMIT = 30 _INPUT_TIMEOUT_S = 25.0 _START_TIMEOUT_S = 15.0 _H264_PROBE_S = 2.0 @@ -157,9 +162,10 @@ async def _stream_frames( ) -> None: interval = 1.0 / max_fps adb = find_adb() + miss_streak = 0 while ws.application_state == WebSocketState.CONNECTED: loop = asyncio.get_running_loop() - t0 = asyncio.get_running_loop().time() + t0 = loop.time() try: captured = await asyncio.wait_for( loop.run_in_executor( @@ -175,12 +181,26 @@ async def _stream_frames( timeout=_CAPTURE_TIMEOUT_S, ) except TimeoutError: - logger.warning("mobile capture timed out (device=%s)", device) - await asyncio.sleep(interval) - continue + captured = None if captured is None: + miss_streak += 1 + if miss_streak == 1: + logger.warning("mobile capture returned no frame (device=%s)", device) + if miss_streak >= _CAPTURE_MISS_LIMIT: + await _send_json( + ws, + { + "type": "error", + "message": ( + "screen capture failed repeatedly; " + "the device may have been disconnected" + ), + }, + ) + return await asyncio.sleep(interval) continue + miss_streak = 0 jpeg, _stream_w, _stream_h, device_w, device_h = captured # Tap/swipe mapping must use the real device resolution, not the # downscaled JPEG bitmap the canvas displays. @@ -195,7 +215,7 @@ async def _stream_frames( "height": _stream_h, }, ) - elapsed = asyncio.get_running_loop().time() - t0 + elapsed = loop.time() - t0 await asyncio.sleep(max(0.0, interval - elapsed)) diff --git a/src/octop/api/routers/ollama_models.py b/src/octop/api/routers/ollama_models.py index b987a8049a..d2fd034c81 100644 --- a/src/octop/api/routers/ollama_models.py +++ b/src/octop/api/routers/ollama_models.py @@ -79,39 +79,70 @@ async def list_ollama_models( server: Any = Depends(get_server), _: Any = Depends(require_permission("ollama_models")), ) -> list[OllamaModelResponse]: - """Return the current Ollama model list via the SDK.""" - if not _ollama_service_enabled(server): - raise HTTPException( - status_code=503, - detail="Ollama service is disabled. Enable it from the provider card.", - ) - try: - from octop.infra.utils.ollama_manager import OllamaModelManager + """Return models from the Ollama daemon and/or the configured models dir.""" + from octop.infra.utils.ollama_manager import ( + OllamaModelInfo, + OllamaModelManager, + apply_models_dir, + is_ollama_reachable, + list_models_from_dir, + merge_model_lists, + ) - models = OllamaModelManager.list_models() - except (OSError, ImportError) as exc: - logger.warning("Ollama bootstrap failed: %s", exc) - raise HTTPException( - status_code=503, - detail=str(exc), - ) from exc - except Exception as exc: - if _is_ollama_connection_error(exc): - logger.warning( - "Failed to connect to Ollama while listing models: %s", - exc, - ) + models_dir = _ollama_models_dir(server) + apply_models_dir(models_dir or None) + disk_models = list_models_from_dir(models_dir) if models_dir else [] + + api_models: list[OllamaModelInfo] = [] + api_exc: Exception | None = None + service_on = _ollama_service_enabled(server) + daemon_up = False + if not service_on: + try: + daemon_up = is_ollama_reachable() + except Exception: + daemon_up = False + if service_on or daemon_up: + try: + api_models = OllamaModelManager.list_models(start_if_needed=service_on) + except (OSError, ImportError) as exc: + api_exc = exc + logger.warning("Ollama bootstrap failed: %s", exc) + except Exception as exc: + api_exc = exc + if _is_ollama_connection_error(exc): + logger.warning( + "Failed to connect to Ollama while listing models: %s", + exc, + ) + else: + logger.exception("Failed to list Ollama models") + + models = merge_model_lists(api_models, disk_models) + if models: + return [OllamaModelResponse(**m.model_dump()) for m in models] + + if api_exc is not None: + if isinstance(api_exc, OSError | ImportError) or _is_ollama_connection_error(api_exc): raise HTTPException( status_code=503, - detail="Failed to connect to Ollama. Please ensure Ollama is installed and running.", - ) from exc - logger.exception("Failed to list Ollama models") + detail=( + str(api_exc) + if isinstance(api_exc, OSError | ImportError) + else "Failed to connect to Ollama. Please ensure Ollama is installed and running." + ), + ) from api_exc raise HTTPException( status_code=500, - detail=f"Failed to list Ollama models: {exc}", - ) from exc + detail=f"Failed to list Ollama models: {api_exc}", + ) from api_exc - return [OllamaModelResponse(**m.model_dump()) for m in models] + if not _ollama_service_enabled(server): + raise HTTPException( + status_code=503, + detail="Ollama service is disabled. Enable it from the provider card.", + ) + return [] @router.post( @@ -121,6 +152,7 @@ async def list_ollama_models( ) async def download_ollama_model( body: OllamaDownloadRequest, + server: Any = Depends(get_server), _: Any = Depends(require_permission("ollama_models")), ) -> OllamaDownloadTaskResponse: """Start a background pull via Ollama SDK. @@ -128,6 +160,9 @@ async def download_ollama_model( Returns a task_id immediately; the frontend can poll /download-status to track progress. """ + from octop.infra.utils.ollama_manager import apply_models_dir + + apply_models_dir(_ollama_models_dir(server) or None) await clear_completed(backend="ollama") task = await create_task( @@ -213,11 +248,14 @@ async def cancel_ollama_download( ) async def delete_ollama_model( name: str, + server: Any = Depends(get_server), _: Any = Depends(require_permission("ollama_models")), ) -> dict[str, Any]: """Delete an Ollama model via the SDK.""" try: - from octop.infra.utils.ollama_manager import OllamaModelManager + from octop.infra.utils.ollama_manager import OllamaModelManager, apply_models_dir + + apply_models_dir(_ollama_models_dir(server) or None) except (OSError, ImportError) as exc: raise HTTPException( status_code=503, @@ -237,12 +275,23 @@ async def delete_ollama_model( class OllamaServiceBody(BaseModel): - enabled: bool = Field(..., description="Whether Octop should keep the Ollama service running") + enabled: bool | None = Field( + default=None, + description="Whether Octop should keep the Ollama service running. Omit to leave unchanged.", + ) + models_dir: str | None = Field( + default=None, + description=( + "Absolute Ollama models directory (OLLAMA_MODELS). " + "Omit to leave unchanged; empty string clears the override." + ), + ) class OllamaServiceStatus(BaseModel): enabled: bool running: bool + models_dir: str = Field("", description="Configured Ollama models directory, empty if unset") def _ollama_service_enabled(server: Any) -> bool: @@ -252,6 +301,21 @@ def _ollama_service_enabled(server: Any) -> bool: return raw.strip().lower() in {"1", "true", "yes", "on"} +def _ollama_models_dir(server: Any) -> str: + from octop.infra.utils.ollama_manager import SETTINGS_KEY_MODELS_DIR + + raw = server.services.settings_repo.get(SETTINGS_KEY_MODELS_DIR) + return (raw or "").strip() + + +def _service_status(server: Any, *, enabled: bool, running: bool) -> OllamaServiceStatus: + return OllamaServiceStatus( + enabled=enabled, + running=running, + models_dir=_ollama_models_dir(server), + ) + + @router.get( "/service", response_model=OllamaServiceStatus, @@ -269,13 +333,17 @@ async def get_ollama_service( running = is_ollama_reachable() except Exception: running = False - return OllamaServiceStatus(enabled=enabled, running=running) + return _service_status(server, enabled=enabled, running=running) @router.put( "/service", response_model=OllamaServiceStatus, - summary="Enable/disable Ollama local service", + summary="Update Ollama local service", + description=( + "Toggle the managed daemon and/or set the models directory. " + "Omit enabled to change models_dir without stopping a running service." + ), ) async def put_ollama_service( body: OllamaServiceBody, @@ -283,23 +351,53 @@ async def put_ollama_service( _: Any = Depends(require_permission("ollama_models")), ) -> OllamaServiceStatus: from octop.infra.utils.ollama_manager import ( + SETTINGS_KEY_MODELS_DIR, + apply_models_dir, is_ollama_reachable, + normalize_models_dir, start_ollama_service, stop_ollama_service, ) - server.services.settings_repo.set( - _SETTINGS_KEY_OLLAMA_SERVICE, - "true" if body.enabled else "false", - ) - if body.enabled: + previous_dir = _ollama_models_dir(server) + models_dir_changed = False + if body.models_dir is not None: + try: + normalized = normalize_models_dir(body.models_dir) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + server.services.settings_repo.set(SETTINGS_KEY_MODELS_DIR, normalized) + models_dir_changed = normalized != previous_dir + + apply_models_dir(_ollama_models_dir(server) or None) + + enabled = _ollama_service_enabled(server) + if body.enabled is not None: + enabled = body.enabled + server.services.settings_repo.set( + _SETTINGS_KEY_OLLAMA_SERVICE, + "true" if enabled else "false", + ) + + if body.enabled is None: + # Directory-only update: restart only if the daemon is already managed on. + if models_dir_changed and enabled: + stop_ollama_service() + try: + start_ollama_service() + except Exception as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + elif enabled: + if models_dir_changed: + stop_ollama_service() try: start_ollama_service() except Exception as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc else: stop_ollama_service() - return OllamaServiceStatus( - enabled=body.enabled, + return _service_status( + server, + enabled=enabled, running=is_ollama_reachable(), ) diff --git a/src/octop/api/routers/plugins.py b/src/octop/api/routers/plugins.py index bece8f8561..65a0f5b4c6 100644 --- a/src/octop/api/routers/plugins.py +++ b/src/octop/api/routers/plugins.py @@ -2,23 +2,25 @@ from __future__ import annotations +import asyncio import mimetypes import tempfile from pathlib import Path -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Depends, File, Form, UploadFile from fastapi.responses import FileResponse, Response from pydantic import BaseModel, Field from octop.api.common.agent import assert_agent_owner as _assert_agent_owner +from octop.api.common.upload_limit import read_upload_capped from octop.api.deps import current_user, get_server, require_permission -from octop.infra.agents.plugin_tool_defaults import ( +from octop.infra.agents.plugins.manager import PluginManager +from octop.infra.agents.plugins.plugin_tool_defaults import ( agent_plugin_enabled, merge_plugins_enabled_settings, merge_plugins_tool_settings, ) -from octop.infra.agents.plugins.manager import PluginManager from octop.infra.errors import ErrorCode, OctopError from octop.infra.server import OctopServer @@ -68,6 +70,7 @@ class AgentPluginItem(BaseModel): kind: str | None = None description: str | None = None icon: str | None = None + group: str | None = None loaded: bool = False global_enabled: bool = True agent_enabled: bool = True @@ -99,7 +102,9 @@ async def list_plugins( server: OctopServer = Depends(get_server), _user: Any = Depends(current_user), ) -> list[dict[str, Any]]: - return _plugin_manager(server).list_installed() + return [ + _with_plugin_visibility(server, item) for item in _plugin_manager(server).list_installed() + ] @router.post("/reload", summary="Reload plugins from disk (admin)") @@ -115,6 +120,7 @@ async def reload_plugins( mgr = _plugin_manager(server) loaded = mgr.load_installed(install_deps=True) if server.app_runtime is not None: + await server.refresh_plugin_channels() await server.app_runtime.agent_registry.reload_all() return { "status": "ok", @@ -148,6 +154,7 @@ async def install_plugin( ) from exc if server.app_runtime is not None: mgr.load_installed(install_deps=False) + await server.refresh_plugin_channels() await server.app_runtime.agent_registry.reload_all() return { "id": loaded.manifest.id, @@ -169,7 +176,16 @@ async def upload_plugin( ``force=True`` overwrites an already-installed plugin with the same id. """ mgr = _plugin_manager(server) - raw = await file.read() + max_bytes = ( + int(server.services.config.max_upload_bytes) + if server.services is not None + else 100 * 1024 * 1024 + ) + raw = await read_upload_capped( + file, + max_bytes=max_bytes, + code=ErrorCode.PLUGIN_INVALID_ARCHIVE, + ) if not raw: raise OctopError(ErrorCode.PLUGIN_INVALID_ARCHIVE, "empty plugin archive") try: @@ -190,6 +206,54 @@ async def upload_plugin( ) from exc if server.app_runtime is not None: mgr.load_installed(install_deps=False) + await server.refresh_plugin_channels() + await server.app_runtime.agent_registry.reload_all() + return { + "id": loaded.manifest.id, + "version": loaded.manifest.version, + "name": loaded.manifest.name, + "kind": loaded.manifest.kind, + } + + +@router.get("/market", summary="List marketplace catalog plugins") +async def list_market_plugins( + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> list[dict[str, Any]]: + """Return the in-tree marketplace catalog (future: remote index API).""" + return _plugin_manager(server).list_market() + + +@router.post( + "/market/{plugin_id}/install", + summary="Install a plugin from the marketplace (admin)", +) +async def install_market_plugin( + plugin_id: str, + force: bool = False, + server: OctopServer = Depends(get_server), + _user: Any = Depends(require_permission("plugins")), +) -> dict[str, Any]: + """Copy a catalog plugin into ``~/.octop/plugins`` (future: download ZIP).""" + mgr = _plugin_manager(server) + loop = asyncio.get_running_loop() + try: + loaded = await loop.run_in_executor( + None, + lambda: mgr.install_from_market(plugin_id, force=force), + ) + except OctopError: + raise + except Exception as exc: + raise OctopError( + ErrorCode.PLUGIN_INSTALL_FAILED, + f"plugin install failed: {exc}", + details={"reason": str(exc)}, + ) from exc + # install_from_market already loads the plugin into the process registry. + if server.app_runtime is not None: + await server.refresh_plugin_channels() await server.app_runtime.agent_registry.reload_all() return { "id": loaded.manifest.id, @@ -199,8 +263,54 @@ async def upload_plugin( } +@router.get( + "/market/{plugin_id}/ui/{file_path:path}", + summary="Serve a marketplace catalog UI asset", + response_model=None, +) +async def get_market_plugin_ui_asset( + plugin_id: str, + file_path: str, + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> Response: + """Static files from the in-tree catalog (icons before install).""" + target = _plugin_manager(server).resolve_market_ui_file(plugin_id, file_path) + suffix = target.suffix.lower() + media_type = _UI_CONTENT_TYPES.get(suffix) or mimetypes.guess_type(target.name)[0] + return FileResponse( + path=target, + media_type=media_type or "application/octet-stream", + filename=target.name, + content_disposition_type="inline", + ) + + class PluginPatchBody(BaseModel): - enabled: bool = Field(..., description="Global enable switch for this plugin") + enabled: bool | None = Field(default=None, description="Global enable switch") + visibility: Literal["all", "groups"] | None = None + group_ids: list[str] | None = None + + +def _plugin_group_access(server: OctopServer) -> Any: + from octop.infra.users.groups import GroupAccess + + services = server.services + if services is None: + raise OctopError(ErrorCode.INTERNAL_ERROR, "services unavailable") + return GroupAccess(services.repos) + + +def _with_plugin_visibility(server: OctopServer, item: dict[str, Any]) -> dict[str, Any]: + from octop.infra.users.groups import RESOURCE_PLUGIN + + plugin_id = str(item.get("id") or "") + if not plugin_id: + return item + access = _plugin_group_access(server) + out = dict(item) + out.update(access.visibility(RESOURCE_PLUGIN, plugin_id, shared=None)) + return out @router.patch("/{plugin_id}", summary="Update plugin settings (admin)") @@ -210,16 +320,37 @@ async def patch_plugin( server: OctopServer = Depends(get_server), _user: Any = Depends(require_permission("plugins")), ) -> dict[str, Any]: - """Enable or disable an installed plugin server-wide. + """Enable/disable a plugin and/or set who may use it on agents. Disabled plugins are unloaded from the process registry so their tools / skills / hooks are unavailable to all agents until re-enabled. """ + if body.enabled is None and body.visibility is None: + raise OctopError(ErrorCode.FORBIDDEN, "provide enabled and/or visibility", status=400) mgr = _plugin_manager(server) - item = mgr.set_enabled(plugin_id, body.enabled) - if server.app_runtime is not None: - await server.app_runtime.agent_registry.reload_all() - return item + item: dict[str, Any] | None = None + if body.enabled is not None: + item = mgr.set_enabled(plugin_id, body.enabled) + if server.app_runtime is not None: + await server.refresh_plugin_channels() + await server.app_runtime.agent_registry.reload_all() + if body.visibility is not None: + from octop.infra.users.groups import RESOURCE_PLUGIN + + _plugin_group_access(server).set_visibility( + RESOURCE_PLUGIN, + plugin_id, + body.visibility, + body.group_ids, + ) + if item is None: + for row in mgr.list_installed(): + if str(row.get("id")) == plugin_id: + item = row + break + if item is None: + raise OctopError(ErrorCode.NOT_FOUND, "plugin not found") + return _with_plugin_visibility(server, item) @router.delete("/{plugin_id}", summary="Uninstall plugin (admin)") @@ -228,8 +359,12 @@ async def uninstall_plugin( server: OctopServer = Depends(get_server), _user: Any = Depends(require_permission("plugins")), ) -> dict[str, str]: + from octop.infra.users.groups import RESOURCE_PLUGIN + _plugin_manager(server).uninstall(plugin_id) + _plugin_group_access(server).clear(RESOURCE_PLUGIN, plugin_id) if server.app_runtime is not None: + await server.refresh_plugin_channels() await server.app_runtime.agent_registry.reload_all() return {"status": "ok", "id": plugin_id} @@ -278,14 +413,28 @@ def _agent_row_and_config( def _agent_plugins_response( server: OctopServer, agent_cfg: dict[str, Any], + *, + user: Any, ) -> AgentPluginsResponse: + from octop.infra.users.groups import RESOURCE_PLUGIN + raw_plugins = agent_cfg.get("plugins") plugins_cfg = raw_plugins if isinstance(raw_plugins, dict) else {} + access = _plugin_group_access(server) + is_admin = bool(getattr(user, "is_admin", False) or getattr(user, "role", "") == "admin") items: list[AgentPluginItem] = [] for plugin in _plugin_manager(server).list_installed(): if plugin.get("error"): continue plugin_id = str(plugin["id"]) + if not access.allows( + user_id=int(user.id), + is_admin=is_admin, + resource_type=RESOURCE_PLUGIN, + resource_key=plugin_id, + is_owner=False, + ): + continue global_enabled = plugin.get("enabled", True) is not False per_agent_enabled = agent_plugin_enabled(plugins_cfg, plugin_id) items.append( @@ -296,6 +445,7 @@ def _agent_plugins_response( kind=plugin.get("kind"), description=plugin.get("description"), icon=plugin.get("icon"), + group=plugin.get("group"), loaded=bool(plugin.get("loaded")), global_enabled=global_enabled, agent_enabled=per_agent_enabled, @@ -318,7 +468,7 @@ async def list_agent_plugins( ) -> AgentPluginsResponse: """List global and per-agent plugin state. Missing agent switches default on.""" _, cfg = _agent_row_and_config(server, agent_id, user) - return _agent_plugins_response(server, cfg) + return _agent_plugins_response(server, cfg, user=user) @router.patch( @@ -330,7 +480,7 @@ async def patch_agent_plugins( agent_id: str, body: AgentPluginsPatch, server: OctopServer = Depends(get_server), - user: Any = Depends(current_user), + user: Any = Depends(require_permission("agent_plugins")), ) -> AgentPluginsResponse: """Merge plugin switches, preserve tool settings, and reload only this agent.""" _, cfg = _agent_row_and_config(server, agent_id, user) @@ -352,7 +502,7 @@ async def patch_agent_plugins( ) await registry.persist_plugin_tools_config(agent_id, merged) await registry.reload(agent_id) - return _agent_plugins_response(server, registry.get_config(agent_id)) + return _agent_plugins_response(server, registry.get_config(agent_id), user=user) @router.get("/agents/{agent_id}/tools", summary="List plugin tools for an agent") @@ -386,7 +536,7 @@ async def list_agent_plugin_tools( if isinstance(raw_tool, dict): tool_cfg = raw_tool # Default on when the agent has no explicit override (matches - # harness_agent.plugins.tools._tool_enabled). + # octop_harness.plugins.tools._tool_enabled). if tool_cfg and "enabled" in tool_cfg: tool_enabled = bool(tool_cfg.get("enabled")) else: @@ -411,7 +561,7 @@ async def patch_agent_plugin_tools( agent_id: str, body: AgentPluginToolsPatch, server: OctopServer = Depends(get_server), - user: Any = Depends(current_user), + user: Any = Depends(require_permission("tools")), ) -> dict[str, str]: """Persist per-agent plugin tool enable flags and hot-sync the denylist. diff --git a/src/octop/api/routers/preferences.py b/src/octop/api/routers/preferences.py index cec040f6d0..2459fd1ceb 100644 --- a/src/octop/api/routers/preferences.py +++ b/src/octop/api/routers/preferences.py @@ -18,6 +18,8 @@ get_model_reasoning_from_json, get_preferred_model_from_json, get_remote_browser_bookmarks_from_json, + get_sidebar_nav_from_json, + merge_sidebar_nav_json, parse_preferences_json, ) from octop.infra.utils.locale import normalize_locale @@ -35,6 +37,25 @@ class ModelReasoningPreferenceModel(BaseModel): effort: str | None = None +class SidebarNavGroupModel(BaseModel): + id: str = Field(description="Built-in id (`settings`, `control`, `admin`) or `c_` + hex.") + name: str | None = Field( + default=None, + description="Custom title. Omit to keep a built-in group's translated label.", + ) + + +class SidebarNavItemModel(BaseModel): + key: str = Field(description="Nav item key.") + group: str | None = Field(default=None, description="Group id. Omit when ungrouped or hidden.") + hidden: bool = Field(default=False, description="When true, the item stays out of the sidebar.") + + +class SidebarNavLayoutModel(BaseModel): + groups: list[SidebarNavGroupModel] = Field(default_factory=list) + items: list[SidebarNavItemModel] = Field(default_factory=list) + + class PreferencesResponse(BaseModel): locale: str = Field(description="UI locale: `zh` or `en`.") remote_browser_bookmarks: list[RemoteBrowserBookmarkModel] = Field( @@ -50,6 +71,10 @@ class PreferencesResponse(BaseModel): description="Per-model reasoning defaults for this user.", ) timezone: str | None = Field(default=None, description="Preferred IANA timezone.") + sidebar_nav: SidebarNavLayoutModel | None = Field( + default=None, + description="Custom sidebar layout. Null means the user has not customized navigation.", + ) class PatchPreferencesBody(BaseModel): @@ -61,11 +86,22 @@ class PatchPreferencesBody(BaseModel): preferred_model: str | None = None model_reasoning: dict[str, ModelReasoningPreferenceModel] | None = None timezone: str | None = None + sidebar_nav: SidebarNavLayoutModel | None = Field( + default=None, + description="Replace the sidebar layout. Null clears it and restores the default nav.", + ) @model_validator(mode="after") def at_least_one_field(self) -> Self: if not self.model_fields_set.intersection( - {"locale", "remote_browser_bookmarks", "preferred_model", "model_reasoning", "timezone"} + { + "locale", + "remote_browser_bookmarks", + "preferred_model", + "model_reasoning", + "timezone", + "sidebar_nav", + } ): raise ValueError("at least one preference field is required") if ( @@ -97,9 +133,17 @@ def _response(row: Any) -> PreferencesResponse: for ref, pref in get_model_reasoning_from_json(raw).items() }, timezone=parse_preferences_json(raw).get(PREFERENCES_KEY_TIMEZONE), + sidebar_nav=_sidebar_nav_response(raw), ) +def _sidebar_nav_response(raw: str | None) -> SidebarNavLayoutModel | None: + payload = get_sidebar_nav_from_json(raw) + if payload is None: + return None + return SidebarNavLayoutModel.model_validate(payload) + + @router.get("/preferences", summary="Current user preferences", response_model=PreferencesResponse) async def get_preferences( user: Any = Depends(current_user), @@ -132,6 +176,17 @@ async def patch_preferences( ErrorCode.SLASH_BAD_ARGS, "preferred_model must reference an enabled model", ) + if ( + preferred is not None + and not server.app_runtime.agent_registry.model_ref_allowed_for_user( + user.id, + preferred, + ) + ): + raise OctopError( + ErrorCode.RESOURCE_NOT_ALLOWED, + "resource is not visible to this user", + ) reasoning = None if body.model_reasoning is not None: reasoning = { @@ -162,5 +217,10 @@ async def patch_preferences( else: data.pop(PREFERENCES_KEY_TIMEZONE, None) server.services.user_repo.set_preferences_json(user.id, json.dumps(data)) + if "sidebar_nav" in body.model_fields_set: + row = server.services.user_repo.get(user.id) + layout = None if body.sidebar_nav is None else body.sidebar_nav.model_dump() + merged = merge_sidebar_nav_json(row.preferences_json if row else None, layout) + server.services.user_repo.set_preferences_json(user.id, merged) row = server.services.user_repo.get(user.id) return _response(row) diff --git a/src/octop/api/routers/proactive_care.py b/src/octop/api/routers/proactive_care.py index 35b459baf0..0eff7bd866 100644 --- a/src/octop/api/routers/proactive_care.py +++ b/src/octop/api/routers/proactive_care.py @@ -49,15 +49,15 @@ def validate_hhmm(cls, v: str) -> str: @model_validator(mode="after") def validate_time_range(self) -> ProactiveCareConfigBody: - """Validate the active hours: start must be earlier than end.""" + """Reject an empty window. An earlier end means the window crosses midnight.""" from octop.infra.proactive.scheduler import _parse_hhmm # noqa: PLC0415 start_t = _parse_hhmm(self.active_hours_start) end_t = _parse_hhmm(self.active_hours_end) - if start_t >= end_t: + if start_t == end_t: raise ValueError( - f"active_hours_start ({self.active_hours_start}) 必须早于 " - f"active_hours_end ({self.active_hours_end})" + f"active_hours_start ({self.active_hours_start}) 与 " + f"active_hours_end ({self.active_hours_end}) 不能相同" ) return self @@ -122,7 +122,7 @@ async def put_proactive_care_config( """Save the agent's proactive care push configuration and immediately update the scheduler. Parameter validation: - - active_hours_start < active_hours_end + - active_hours_start != active_hours_end (an earlier end crosses midnight) - min_interval_hours <= max_interval_hours - min_interval_hours >= 1 """ diff --git a/src/octop/api/routers/providers.py b/src/octop/api/routers/providers.py index c9c4963453..53be106440 100644 --- a/src/octop/api/routers/providers.py +++ b/src/octop/api/routers/providers.py @@ -6,36 +6,46 @@ import json import logging from types import SimpleNamespace -from typing import Any, cast +from typing import Any, Literal, cast from fastapi import APIRouter, Depends, Request from pydantic import BaseModel from octop.api.deps import current_user, get_server, require_permission +from octop.infra.agents.providers.codex_apply import ( + CODEX_PROVIDER_NAME, + apply_codex_credentials, + sync_refreshed_codex_api_key, +) +from octop.infra.agents.providers.codex_oauth import ( + DEVICE_POLL_TIMEOUT_S, + CodexOAuthDeviceCodeError, + exchange_device_code, + get_valid_access_token, + poll_device_token, + request_device_code, +) from octop.infra.agents.providers.model_flags import is_local_runtime_provider from octop.infra.agents.providers.presets import load_provider_presets from octop.infra.agents.providers.probe import ( fetch_openai_compatible_models, make_probe_provider_row, + parse_provider_extra, probe_provider_row, provider_headers, ) from octop.infra.agents.providers.reasoning import reasoning_capability from octop.infra.agents.providers.resolved import list_resolved_models as _list_resolved_models -from octop.infra.agents.providers.store import clear_stale_pins_for_provider -from octop.infra.errors import ErrorCode, OctopError -from octop.infra.providers.codex_apply import ( - CODEX_PROVIDER_NAME, - apply_codex_credentials, - sync_refreshed_codex_api_key, -) -from octop.infra.providers.codex_oauth import ( - DEVICE_POLL_TIMEOUT_S, - exchange_device_code, - get_valid_access_token, - poll_device_token, - request_device_code, +from octop.infra.agents.providers.store import ( + clear_stale_pins_for_provider, + merge_provider_purpose_extra, + merge_stream_usage_extra, + resolve_provider_purpose, + resolve_provider_stream_usage, + stamp_embedding_models, ) +from octop.infra.errors import ErrorCode, OctopError +from octop.infra.users.groups import RESOURCE_MODEL, GroupAccess, model_resource_key from octop.infra.utils.locale import resolve_request_locale from octop.infra.utils.ulid import new_ulid @@ -50,23 +60,52 @@ class ProviderCreateBody(BaseModel): base_url: str | None = None api_key: str | None = None extra_json: str | None = None + stream_usage: bool | None = None + purpose: Literal["chat", "embedding"] | None = None models: list[dict[str, Any]] | None = None note: str | None = None +def _with_default_model_first(models: list[dict[str, Any]], model: str) -> list[dict[str, Any]]: + """Return *models* with *model* moved to the front, which is how the UI reads the default. + + Leaves the list untouched when the id is not part of it. + """ + ids = [m.get("id") for m in models] + if model not in ids: + return models + idx = ids.index(model) + return [models[idx], *models[:idx], *models[idx + 1 :]] + + class ProviderPatchBody(BaseModel): kind: str | None = None base_url: str | None = None api_key: str | None = None extra_json: str | None = None + stream_usage: bool | None = None + purpose: Literal["chat", "embedding"] | None = None models: list[dict[str, Any]] | None = None note: str | None = None enabled: bool | None = None + # Dashboard default-model field. A non-empty id becomes the default. + # ``""`` or ``null`` clears it; omitting the key leaves it unchanged. + model: str | None = None # Fields that affect harness factory / agent runtime when patched. _PROVIDER_REHYDRATE_FIELDS = frozenset( - {"kind", "base_url", "api_key", "extra_json", "models", "enabled"} + { + "kind", + "base_url", + "api_key", + "extra_json", + "stream_usage", + "purpose", + "models", + "model", + "enabled", + } ) @@ -75,8 +114,187 @@ def _patch_requires_provider_rehydrate(body: ProviderPatchBody) -> bool: return bool(body.model_fields_set & _PROVIDER_REHYDRATE_FIELDS) +def _annotate_provider_models(server: Any, payload: dict[str, Any]) -> dict[str, Any]: + access = GroupAccess(server.services.repos) + provider_name = str(payload.get("name") or "") + for model in payload.get("models") or []: + if not isinstance(model, dict): + continue + model_id = str(model.get("id") or "") + model.update( + access.visibility( + RESOURCE_MODEL, + model_resource_key(provider_name, model_id), + shared=None, + ) + ) + return payload + + +def _sync_model_visibility( + server: Any, + provider_name: str, + models: list[dict[str, Any]], +) -> list[dict[str, Any]]: + access = GroupAccess(server.services.repos) + existing = server.services.provider_repo.get_by_name(provider_name) + previous = ( + {str(model.get("id") or "") for model in existing.get_models()} + if existing is not None + else set() + ) + cleaned: list[dict[str, Any]] = [] + kept: set[str] = set() + for model in models: + item = dict(model) + model_id = str(item.get("id") or "").strip() + visibility = item.pop("visibility", None) + group_ids = item.pop("group_ids", None) + if model_id: + kept.add(model_id) + if visibility is not None and model_id: + access.set_visibility( + RESOURCE_MODEL, + model_resource_key(provider_name, model_id), + str(visibility), + group_ids if isinstance(group_ids, list) else [], + ) + cleaned.append(item) + for model_id in previous - kept: + if model_id: + access.clear(RESOURCE_MODEL, model_resource_key(provider_name, model_id)) + return cleaned + + +def _blank_to_none(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + +def _parse_extra_object(extra_json: str | None) -> dict[str, Any]: + if not extra_json: + return {} + try: + parsed = json.loads(extra_json) + except json.JSONDecodeError: + return {} + return dict(parsed) if isinstance(parsed, dict) else {} + + +def _extra_with_default_model(extra_json: str | None, model: str | None) -> str: + """Store an explicit default. ``None`` keeps the key so a clear is visible.""" + extra = _parse_extra_object(extra_json) + extra["default_model"] = model + return json.dumps(extra) + + +def _resolved_default_model( + extra: dict[str, Any], + models: list[dict[str, Any]], +) -> str | None: + """Return the saved default, or the first catalog id when none was saved. + + An explicit ``default_model: null`` stays empty instead of falling back to + the first model — that is how a cleared default reopens as a blank field. + """ + if "default_model" in extra: + raw = extra.get("default_model") + if isinstance(raw, str) and raw.strip(): + return raw.strip() + return None + for model in models: + model_id = model.get("id") + if isinstance(model_id, str) and model_id.strip(): + return model_id.strip() + return None + + +def _provider_patch_updates( + body: ProviderPatchBody, + *, + current_extra_json: str | None, + current_models: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Map an explicit JSON patch onto repo kwargs. + + Omitted fields are left out so they stay unchanged. ``null`` (and a blank + API key) is forwarded as ``None``, which clears the stored value. Revoke + sends ``{"api_key": null}``; treating that the same as "field absent" left + the key in place and the provider still looked authorized. + + ``stream_usage`` / ``purpose`` are stored inside ``extra_json``. A patch + that only sets those flags rewrites the current blob and must not clear + the API key. + + ``model`` is the dashboard's default-model choice. A non-empty id that is + in the catalog is moved to the front (the slot legacy clients read) and + recorded on ``extra_json.default_model``. ``""`` or ``null`` records a + cleared default without reordering the catalog. Omitting ``model`` leaves + both the order and the saved default alone. + """ + fields = body.model_fields_set + updates: dict[str, Any] = {} + if "kind" in fields: + updates["kind"] = body.kind + if "base_url" in fields: + updates["base_url"] = _blank_to_none(body.base_url) + if "api_key" in fields: + updates["api_key"] = _blank_to_none(body.api_key) + if "extra_json" in fields or "stream_usage" in fields or "purpose" in fields: + base = body.extra_json if "extra_json" in fields else current_extra_json + if "stream_usage" in fields: + base = merge_stream_usage_extra(base, body.stream_usage) + if "purpose" in fields: + base = merge_provider_purpose_extra(base, body.purpose) + updates["extra_json"] = base + models_to_store: list[dict[str, Any]] | None = None + write_models = False + if "models" in fields: + write_models = True + models_to_store = list(body.models) if body.models is not None else None + if models_to_store is not None: + purpose = None + if "purpose" in fields: + purpose = body.purpose + else: + raw_extra = current_extra_json if isinstance(current_extra_json, str) else "" + try: + parsed = json.loads(raw_extra) if raw_extra else {} + except json.JSONDecodeError: + parsed = {} + if isinstance(parsed, dict) and parsed.get("purpose") == "embedding": + purpose = "embedding" + if purpose == "embedding": + models_to_store = stamp_embedding_models(models_to_store) + if "model" in fields: + chosen = body.model.strip() if isinstance(body.model, str) else "" + extra_source = updates.get("extra_json", current_extra_json) + catalog = models_to_store if write_models else list(current_models or []) + if chosen and catalog is not None and any(item.get("id") == chosen for item in catalog): + reordered = _with_default_model_first(catalog, chosen) + if write_models or reordered != catalog: + models_to_store = reordered + write_models = True + updates["extra_json"] = _extra_with_default_model(extra_source, chosen) + elif not chosen: + updates["extra_json"] = _extra_with_default_model(extra_source, None) + if write_models: + updates["models_json"] = ( + json.dumps(models_to_store) if models_to_store is not None else None + ) + if "note" in fields: + updates["note"] = _blank_to_none(body.note) + if "enabled" in fields: + updates["enabled"] = body.enabled + return updates + + class ProviderTestBody(BaseModel): model_id: str | None = None + api_mode: Literal["chat_completions", "responses"] | None = None + api_fallback: Literal["none", "chat_completions"] | None = None embedding: bool = False @@ -86,6 +304,8 @@ class ProviderTestDraftBody(BaseModel): api_key: str | None = None base_url: str | None = None model_id: str + api_mode: Literal["chat_completions", "responses"] = "chat_completions" + api_fallback: Literal["none", "chat_completions"] = "none" extra_json: str | None = None embedding: bool = False @@ -95,6 +315,7 @@ class ProviderFetchModelsBody(BaseModel): api_key: str | None = None base_url: str | None = None extra_json: str | None = None + name: str | None = None def _is_codex_base_url(base_url: str | None) -> bool: @@ -124,6 +345,7 @@ def _row_to_dict(r: Any) -> dict[str, Any]: model["reasoning"] = True model["reasoning_config"] = capability models.append(model) + extra = parse_provider_extra(r) return { "id": r.id, "name": r.name, @@ -133,6 +355,14 @@ def _row_to_dict(r: Any) -> dict[str, Any]: "models": models, "note": r.note, "enabled": bool(r.enabled), + "stream_usage": resolve_provider_stream_usage(provider_name=r.name, extra=extra), + "purpose": resolve_provider_purpose( + provider_name=r.name, + provider_api_key=r.api_key, + extra=extra, + models=models, + ), + "default_model": _resolved_default_model(extra, models), } @@ -140,20 +370,25 @@ def _row_to_dict(r: Any) -> dict[str, Any]: async def list_provider_presets( _: Any = Depends(current_user), ) -> list[dict[str, Any]]: - """Return built-in provider presets from harness-agent.""" + """Return built-in provider presets from octop-harness.""" return load_provider_presets() @router.get("/resolved") async def list_resolved_models( - _: Any = Depends(current_user), + user: Any = Depends(current_user), server: Any = Depends(get_server), ) -> list[dict[str, Any]]: - """Return all enabled models across all providers. - - Used by the Agent model selector to show available candidates. - """ - return _list_resolved_models(server.services.provider_repo.list_all()) + """Return enabled chat models the caller may select.""" + models = _list_resolved_models(server.services.provider_repo.list_all()) + return GroupAccess(server.services.repos).filter_rows( + models, + user_id=user.id, + is_admin=bool(user.is_admin), + resource_type=RESOURCE_MODEL, + key_of=lambda item: model_resource_key(str(item["provider_name"]), str(item["model"])), + is_owner=lambda _item: False, + ) class ActiveModelBody(BaseModel): @@ -203,7 +438,10 @@ async def admin_list_providers( _: Any = Depends(require_permission("providers")), server: Any = Depends(get_server), ) -> list[dict[str, Any]]: - return [_row_to_dict(r) for r in server.services.provider_repo.list_all()] + return [ + _annotate_provider_models(server, _row_to_dict(row)) + for row in server.services.provider_repo.list_all() + ] @admin_router.post("", status_code=201) @@ -214,19 +452,26 @@ async def admin_create_provider( ) -> dict[str, Any]: import json as _json - models_json = _json.dumps(body.models) if body.models is not None else None + models = ( + _sync_model_visibility(server, body.name, body.models) if body.models is not None else None + ) + if body.purpose == "embedding": + models = stamp_embedding_models(models) + models_json = _json.dumps(models) if models is not None else None + extra_json = merge_stream_usage_extra(body.extra_json, body.stream_usage) + extra_json = merge_provider_purpose_extra(extra_json, body.purpose) pid = server.services.provider_repo.create( name=body.name, kind=body.kind, base_url=body.base_url, api_key=body.api_key, - extra_json=body.extra_json, + extra_json=extra_json, models_json=models_json, note=body.note, ) if server.app_runtime: await server.app_runtime.agent_registry.on_provider_changed(provider_name=body.name) - return _row_to_dict(server.services.provider_repo.get(pid)) + return _annotate_provider_models(server, _row_to_dict(server.services.provider_repo.get(pid))) @admin_router.patch("/{provider_id}") @@ -239,18 +484,15 @@ async def admin_patch_provider( row = server.services.provider_repo.get(provider_id) if row is None: raise OctopError(ErrorCode.NOT_FOUND, "provider not found") - import json as _json - - models_json = _json.dumps(body.models) if body.models is not None else None + if body.models is not None: + body.models = _sync_model_visibility(server, row.name, body.models) server.services.provider_repo.update( provider_id, - kind=body.kind, - base_url=body.base_url, - api_key=body.api_key, - extra_json=body.extra_json, - models_json=models_json, - note=body.note, - enabled=body.enabled, + **_provider_patch_updates( + body, + current_extra_json=row.extra_json, + current_models=cast("list[dict[str, Any]]", row.get_models()), + ), ) if body.models is not None: clear_stale_pins_for_provider( @@ -261,7 +503,9 @@ async def admin_patch_provider( ) if server.app_runtime and _patch_requires_provider_rehydrate(body): await server.app_runtime.agent_registry.on_provider_changed(provider_name=row.name) - return _row_to_dict(server.services.provider_repo.get(provider_id)) + return _annotate_provider_models( + server, _row_to_dict(server.services.provider_repo.get(provider_id)) + ) @admin_router.delete("/{provider_id}", status_code=204) @@ -291,6 +535,7 @@ async def admin_delete_provider( ) name = row.name server.services.provider_repo.delete(provider_id) + server.services.repos.resource_group_grant_repo.delete_prefix(RESOURCE_MODEL, name) if server.app_runtime: await server.app_runtime.agent_registry.on_provider_changed(provider_name=name) @@ -314,12 +559,16 @@ async def admin_test_provider_draft( api_key=api_key, base_url=(body.base_url or "").strip() or None, model_id=model_id, + api_mode=body.api_mode, + api_fallback=body.api_fallback, extra_json=body.extra_json, embedding=body.embedding, ) return await probe_provider_row( row, model_id=model_id, + api_mode=body.api_mode, + api_fallback=body.api_fallback, embedding=body.embedding, locale=resolve_request_locale(request), ) @@ -346,6 +595,7 @@ async def admin_fetch_provider_models( api_key=api_key, extra_headers=provider_headers(draft) or None, locale=resolve_request_locale(request), + provider_name=(body.name or "").strip() or None, ) @@ -403,7 +653,18 @@ async def codex_oauth_start( user: Any = Depends(require_permission("providers")), server: Any = Depends(get_server), ) -> dict[str, Any]: - info = await asyncio.to_thread(request_device_code) + try: + info = await asyncio.to_thread(request_device_code) + except CodexOAuthDeviceCodeError as exc: + logger.warning("Codex device OAuth start failed: %s", exc.reason) + details: dict[str, Any] = {"reason": exc.reason} + if exc.upstream_status is not None: + details["upstream_status"] = exc.upstream_status + raise OctopError( + ErrorCode.CODEX_OAUTH_START_FAILED, + "codex device OAuth start failed", + details=details, + ) from exc state_id = new_ulid() server.services.settings_repo.set( f"codex_oauth.pending.{state_id}", @@ -450,7 +711,7 @@ async def codex_oauth_logout( _: Any = Depends(require_permission("providers")), server: Any = Depends(get_server), ) -> None: - from octop.infra.providers.codex_oauth import delete_codex_token + from octop.infra.agents.providers.codex_oauth import delete_codex_token delete_codex_token(server.services.paths) row = server.services.provider_repo.get_by_name(CODEX_PROVIDER_NAME) @@ -476,10 +737,14 @@ async def admin_test_provider( raise OctopError(ErrorCode.NOT_FOUND, "provider not found") row = await _maybe_refresh_codex_row(server, row) model_id = body.model_id if body else None + api_mode = body.api_mode if body else None + api_fallback = body.api_fallback if body else None embedding = body.embedding if body else None return await probe_provider_row( row, model_id=model_id, + api_mode=api_mode, + api_fallback=api_fallback, embedding=embedding, locale=resolve_request_locale(request), ) diff --git a/src/octop/api/routers/search.py b/src/octop/api/routers/search.py index 87044d1727..e1857f6f02 100644 --- a/src/octop/api/routers/search.py +++ b/src/octop/api/routers/search.py @@ -29,7 +29,10 @@ class TestSearchResponse(BaseModel): error: str | None = None error_type: str | None = Field( None, - description="auth_error | timeout | network_error | invalid_config | unknown", + description=( + "auth_error | insufficient_balance | rate_limit | timeout | " + "network_error | invalid_config | unknown" + ), ) @@ -39,8 +42,9 @@ class TestSearchResponse(BaseModel): summary="Test search provider API key", description=( "Run a one-shot query against a web-search provider using the supplied " - "env vars (TAVILY_API_KEY, BRAVE_API_KEY, GOOGLE_API_KEY + GOOGLE_CSE_ID, " - "or MOONSHOT_API_KEY). Credentials are not written to ~/.octop/env." + "env vars (TAVILY_API_KEY, BRAVE_API_KEY, DEEPSEEK_API_KEY, " + "GOOGLE_API_KEY + GOOGLE_CSE_ID, MOONSHOT_API_KEY, or " + "TENCENTCLOUD_WSA_APIKEY). Credentials are not written to ~/.octop/env." ), ) async def test_search_provider( diff --git a/src/octop/api/routers/security.py b/src/octop/api/routers/security.py index bccc3a1416..b7a1551e0b 100644 --- a/src/octop/api/routers/security.py +++ b/src/octop/api/routers/security.py @@ -5,7 +5,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException -from harness_agent.security.models import DEFAULT_HITL_TOOLS, SecurityPolicy +from octop_harness.security.models import DEFAULT_HITL_TOOLS, SecurityPolicy from pydantic import BaseModel, Field from octop.api.deps import get_server, require_permission @@ -160,7 +160,7 @@ async def put_tool_guard_rules_raw( count, errors = store.save_text(body.content) if errors: raise HTTPException(status_code=400, detail={"errors": errors}) - registry.reload_harness_agents() + registry.reload_octop_harnesss() server.services.audit_repo.write( actor=ACTOR_ADMIN, action="security.tool_guard_rules.update", @@ -178,7 +178,7 @@ async def post_tool_guard_rules_reset( store = _rules_store(server) registry = server.app_runtime.agent_registry content = store.reset_to_bundled() - registry.reload_harness_agents() + registry.reload_octop_harnesss() server.services.audit_repo.write( actor=ACTOR_ADMIN, action="security.tool_guard_rules.reset", diff --git a/src/octop/api/routers/settings.py b/src/octop/api/routers/settings.py index a7c336fd7b..2b08ade1a9 100644 --- a/src/octop/api/routers/settings.py +++ b/src/octop/api/routers/settings.py @@ -2,15 +2,28 @@ from __future__ import annotations +import asyncio from typing import Any, Literal -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, File, Request, UploadFile +from fastapi.responses import FileResponse from pydantic import BaseModel, Field +from octop.api.common.content_disposition import content_disposition +from octop.api.common.upload_limit import read_upload_capped from octop.api.deps import current_user, get_server, require_permission from octop.config import OctopConfig +from octop.infra.agents.security import tool_execution_may_pause from octop.infra.auth.captcha import current_env, load_view, save_settings +from octop.infra.errors import ErrorCode, OctopError from octop.infra.users.identity import User +from octop.infra.utils.download_links import DownloadLinkStore +from octop.infra.utils.user_downloads import ( + is_local_dashboard_request, + reveal_download, + sanitize_download_filename, + save_user_download, +) router = APIRouter() @@ -24,6 +37,16 @@ class UploadSettingsResponse(BaseModel): max_upload_bytes: int = Field(description="Max upload size in bytes.") +class HitlSettingsResponse(BaseModel): + enabled: bool = Field(description="Whether global tool human-approval (HITL) is enabled.") + tool_guard_require_approval: bool = Field( + description="Whether command guard pauses risky shell calls for approval." + ) + show_approval_ui: bool = Field( + description="Whether composer, slash help, and CLI should show tool-approval surfaces." + ) + + @router.get( "/settings/timezone", summary="Server default timezone", @@ -55,6 +78,114 @@ async def get_upload_settings( ) +@router.get( + "/settings/hitl", + summary="Tool human-approval switch", + response_model=HitlSettingsResponse, +) +async def get_hitl_settings( + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> HitlSettingsResponse: + """Return HITL and command-guard flags that drive approval UI.""" + _ = user + assert server.app_runtime is not None + policy = server.app_runtime.agent_registry.security.load() + guard_require = bool(policy.tool_guard.enabled and policy.tool_guard.mode == "require_approval") + return HitlSettingsResponse( + enabled=bool(policy.hitl.enabled), + tool_guard_require_approval=guard_require, + show_approval_ui=tool_execution_may_pause(policy), + ) + + +class LocalDownloadResponse(BaseModel): + path: str = Field(description="Absolute path written under the host Downloads folder.") + filename: str = Field(description="Final filename, unique if the name already existed.") + + +@router.post( + "/downloads/local", + summary="Save a file to the host Downloads folder", + response_model=LocalDownloadResponse, +) +async def save_local_download( + request: Request, + file: UploadFile = File(...), # noqa: B008 + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> LocalDownloadResponse: + """Write an already-fetched blob to ~/Downloads. Desktop WebViews cannot use .""" + _ = user + if not is_local_dashboard_request(request): + raise OctopError( + ErrorCode.LOCAL_DOWNLOAD_UNAVAILABLE, + "local download is only available on this computer", + ) + data = await file.read() + filename = sanitize_download_filename(file.filename) + path = await asyncio.to_thread(save_user_download, filename, data) + await asyncio.to_thread(reveal_download, path) + return LocalDownloadResponse(path=str(path), filename=path.name) + + +_download_links = DownloadLinkStore() + + +class DownloadLinkResponse(BaseModel): + url: str = Field(description="Relative URL that serves the file without credentials.") + filename: str = Field(description="Filename the browser will save the file as.") + expires_in: int = Field(description="Seconds until the link stops working.") + + +@router.post( + "/downloads/link", + summary="Create a short-lived download link", + response_model=DownloadLinkResponse, +) +async def create_download_link( + file: UploadFile = File(...), # noqa: B008 + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> DownloadLinkResponse: + """Keep an already-fetched blob behind a credential-free link for a few minutes. + + In-app browsers such as WeChat cannot save ``blob:`` downloads, and the + system browser they hand off to has no Octop session. + """ + data = await read_upload_capped(file, max_bytes=server.services.config.max_upload_bytes) + filename = sanitize_download_filename(file.filename) + link = await asyncio.to_thread(_download_links.create, filename, data, owner_id=user.id) + return DownloadLinkResponse( + url=f"/api/downloads/link/{link.token}", + filename=filename, + expires_in=_download_links.ttl_seconds, + ) + + +@router.get( + "/downloads/link/{token}", + summary="Download a file through a short-lived link", + response_class=FileResponse, +) +async def open_download_link( + token: str, + server: Any = Depends(get_server), +) -> FileResponse: + """Serve a file created by ``POST /downloads/link``. The token is the only credential.""" + link = await asyncio.to_thread(_download_links.get, token) + if link is None or server.user_manager.get_by_id(link.owner_id) is None: + raise OctopError(ErrorCode.NOT_FOUND, "download link expired or not found") + return FileResponse( + link.path, + media_type="application/octet-stream", + headers={ + "Content-Disposition": content_disposition(link.filename), + "Cache-Control": "no-store", + }, + ) + + class MobileCapabilitiesResponse(BaseModel): enabled: bool = Field(description="Whether Remote Android is enabled on this host.") backend: str = Field(description="Host backend: physical, redroid, emulator, or none.") diff --git a/src/octop/api/routers/setup.py b/src/octop/api/routers/setup.py index 8a7f7ddaa3..7d46c5e3d3 100644 --- a/src/octop/api/routers/setup.py +++ b/src/octop/api/routers/setup.py @@ -154,9 +154,10 @@ def _authorize_setup_mid_wizard(authorization: str | None, server: Any) -> str | async def _bootstrap_default_agent(server: Any, *, user_id: int, locale: str = "zh") -> None: """Create the first default agent for a fresh install (pinned id ``main``).""" - from octop.infra.agents.default_agent import ( + from octop.infra.agents.experts.default_agent import ( SETUP_DEFAULT_AGENT_ID, bootstrap_default_agent, + user_policy_workspace_root, ) if server.app_runtime is None: @@ -167,6 +168,7 @@ async def _bootstrap_default_agent(server: Any, *, user_id: int, locale: str = " user_id=user_id, locale=locale, agent_id=SETUP_DEFAULT_AGENT_ID, + root_dir=user_policy_workspace_root(server, user_id), ) @@ -232,7 +234,7 @@ async def validate_wizard_token( @router.get("/setup/status", summary="Setup wizard status") async def status(server: Any = Depends(get_server)) -> dict[str, Any]: """Whether initial admin creation is still required and wizard password file state.""" - wizard_path = str(Path.home() / _wizard.WIZARD_FILE_NAME) + wizard_path = str(_wizard.resolve_password_path(server.paths.root, legacy_home=Path.home())) password_required = _setup_password_required(server) um = server.user_manager setup_required = um is None or um.count() == 0 @@ -243,7 +245,8 @@ async def status(server: Any = Depends(get_server)) -> dict[str, Any]: "setup_required": setup_required, "wizard_password_required": password_required, "wizard_password_exists": ( - password_required and _wizard.read_password(Path.home()) is not None + password_required + and _wizard.read_password(server.paths.root, legacy_home=Path.home()) is not None ), "wizard_password_path": wizard_path if password_required else None, "database_driver": database_driver, @@ -331,7 +334,7 @@ async def verify_password( server.wizard_tokens.record_attempt(client_ip) except RateLimited: raise OctopError(ErrorCode.SETUP_RATE_LIMITED, "too many attempts") from None - expected = _wizard.read_password(Path.home()) + expected = _wizard.read_password(server.paths.root, legacy_home=Path.home()) if expected is None or body.password != expected: raise OctopError(ErrorCode.SETUP_PASSWORD_WRONG, "wrong password") token, ttl = server.wizard_tokens.issue() @@ -352,6 +355,9 @@ async def initial_admin( locale = normalize_locale(body.locale or resolve_request_locale(request)) assert server.user_manager is not None assert server.services is not None + from octop.infra.db.repos.user_roles import ADMIN_USER_ROLE_ID, UserRoleRepo + + admin_role = UserRoleRepo(server.services.db).get(ADMIN_USER_ROLE_ID) user = await server.user_manager.create( username=body.username, password=body.password, @@ -359,34 +365,23 @@ async def initial_admin( display_name=body.display_name, email=body.email, locale=locale, + role_name=admin_role.user_role_name if admin_role is not None else "管理员", ) secret = server.services.secret_repo.get("jwt") ttl = server.services.config.access_token_ttl_seconds access_token = sign_token( - secret, sub=user.id, uname=user.username, role=user.role.value, ttl_seconds=ttl + secret, sub=user.id, uname=user.username, role=user.role, ttl_seconds=ttl ) return { "id": user.id, "username": user.username, - "role": user.role.value, + "role": user.role, "locale": user.locale, "access_token": access_token, "expires_in": ttl, } -@router.post("/setup/resume-wizard", summary="Issue a fresh wizard token mid-setup") -async def resume_wizard(server: Any = Depends(get_server)) -> dict[str, Any]: - """Issue a new wizard token after the admin exists but before finish.""" - _enforce_wizard_token_phase(server) - require_database(server) - assert server.user_manager is not None - if server.user_manager.count() == 0: - raise OctopError(ErrorCode.SETUP_TOKEN_INVALID, "admin not created yet", status=400) - token, ttl = server.wizard_tokens.issue() - return {"wizard_token": token, "expires_in": ttl} - - @router.post("/setup/test-provider", summary="Test provider draft connectivity") async def test_provider_draft( body: ProviderTestBody, diff --git a/src/octop/api/routers/skill_packages.py b/src/octop/api/routers/skill_packages.py index c55992fd41..dabc3cf7ff 100644 --- a/src/octop/api/routers/skill_packages.py +++ b/src/octop/api/routers/skill_packages.py @@ -5,11 +5,14 @@ import asyncio import base64 from dataclasses import asdict -from typing import Any +from typing import Any, Literal -from fastapi import APIRouter, Depends, Request, status -from pydantic import BaseModel +from fastapi import APIRouter, Depends, File, Query, Request, UploadFile, status +from fastapi.responses import Response +from pydantic import BaseModel, Field +from octop.api.common.content_disposition import content_disposition +from octop.api.common.workspace import coerce_read_content from octop.api.deps import get_server, require_permission from octop.infra.agents.experts.skillhub_market import ( SkillHubMarketError, @@ -19,13 +22,19 @@ from octop.infra.errors import ErrorCode, OctopError from octop.infra.server import OctopServer from octop.infra.skills.install import valid_skillhub_icon_url +from octop.infra.skills.skill_archive import build_skills_zip from octop.infra.skills.skill_package_from_skillhub import create_package_from_skillhub from octop.infra.skills.skill_package_store import ( SkillPackageStore, is_skill_package_name_conflict, raise_skill_package_name_taken, ) +from octop.infra.skills.skill_packages import SkillPackageError, read_skill_directory from octop.infra.skills.skillhub_market import SkillHubMarketError as SkillHubDownloadError +from octop.infra.users.groups import ( + RESOURCE_SKILL_PACKAGE, + GroupAccess, +) from octop.infra.users.identity import User from octop.infra.utils.frontmatter import parse_frontmatter from octop.infra.utils.locale import Locale, resolve_request_locale @@ -49,6 +58,11 @@ class CreateSkillPackageBody(BaseModel): description: str = "" icon_name: str = "" icon_url: str = "" + shared: bool = False + visibility: Literal["all", "groups", "owner"] | None = None + group_ids: list[str] | None = None + copy_policy: Literal["snapshot", "lock", "deny"] = "snapshot" + allow_download: bool = True class UpdateSkillPackageBody(BaseModel): @@ -56,6 +70,20 @@ class UpdateSkillPackageBody(BaseModel): description: str | None = None icon_name: str | None = None icon_url: str | None = None + shared: bool | None = None + copy_policy: Literal["snapshot", "lock", "deny"] | None = Field( + default=None, + description=( + "Controls copying package skills into an agent workspace for users who " + "can already view the package (including the owner's other experts)." + ), + ) + allow_download: bool | None = Field( + default=None, + description="When false, non-owner users who can view the package cannot export a ZIP.", + ) + visibility: Literal["all", "groups", "owner"] | None = None + group_ids: list[str] | None = None class FromSkillHubBody(BaseModel): @@ -64,6 +92,7 @@ class FromSkillHubBody(BaseModel): description: str | None = None icon_name: str | None = None icon_url: str | None = None + shared: bool = False class SkillFilePart(BaseModel): @@ -149,9 +178,31 @@ def _package_or_404( return row -def _row_public_dict(row: SkillPackageRow) -> dict[str, Any]: +def _package_avatar_api_path(package_id: str) -> str: + return f"/api/skill-packages/{package_id}/avatar" + + +def _is_package_avatar_url(value: str, package_id: str | None = None) -> bool: + path = value.strip().split("?", 1)[0] + if package_id: + return path == _package_avatar_api_path(package_id) + return path.startswith("/api/skill-packages/") and path.endswith("/avatar") + + +def _display_package_icon_url(server: OctopServer, row: SkillPackageRow) -> str: + from octop.infra.users.profile_avatar import profile_avatar_url # noqa: PLC0415 + + text = str(row.icon_url or "").strip() + api = _package_avatar_api_path(row.id) + if text.split("?", 1)[0] == api: + return profile_avatar_url(server.paths.skill_package_avatars_dir, row.id, api) or api + return text + + +def _row_public_dict(server: OctopServer, row: SkillPackageRow) -> dict[str, Any]: payload = asdict(row) payload.pop("pk", None) + payload["icon_url"] = _display_package_icon_url(server, row) return payload @@ -160,13 +211,28 @@ def _package_payload( row: SkillPackageRow, *, locale: Locale | None = None, + server: OctopServer | None = None, ) -> dict[str, Any]: + if server is None: + payload = asdict(row) + payload.pop("pk", None) + else: + payload = _row_public_dict(server, row) return { - **_row_public_dict(row), + **payload, "skills": store.list_skill_summaries(row.id, locale=locale), } +def _package_access(server: OctopServer) -> GroupAccess: + assert server.services is not None + return GroupAccess(server.services.repos) + + +def _package_owner(user: User, row: SkillPackageRow) -> bool: + return str(user.id) == str(row.created_by) + + def _creator_fields(server: OctopServer, created_by: str) -> dict[str, str | None]: raw = str(created_by or "").strip() if not raw.isdigit(): @@ -190,7 +256,7 @@ def _package_payload_with_creator( locale: Locale | None = None, ) -> dict[str, Any]: return { - **_package_payload(store, row, locale=locale), + **_package_payload(store, row, locale=locale, server=server), **_creator_fields(server, row.created_by), } @@ -202,13 +268,61 @@ def _required(value: str, field: str) -> str: return value -def _icon_url(value: str) -> str: +def _icon_url(value: str, *, package_id: str | None = None) -> str: value = value.strip() - if value and not valid_skillhub_icon_url(value): - raise OctopError(ErrorCode.SLASH_BAD_ARGS, "invalid SkillHub icon URL") + if len(value) > 2048: + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "icon_url is too long") + if ( + value + and not valid_skillhub_icon_url(value) + and not _is_package_avatar_url(value, package_id) + ): + raise OctopError( + ErrorCode.SLASH_BAD_ARGS, + "icon_url must be an HTTP(S) URL or package avatar path", + ) return value +def _visible_packages(store: SkillPackageStore, user: User) -> list[SkillPackageRow]: + if user.is_admin: + return store.repo.list_all() + return store.repo.list_visible(user.id) + + +def _assert_group_visible( + server: OctopServer, + user: User, + row: SkillPackageRow, + *, + locale: str, +) -> None: + """User groups only narrow packages the shared flag already exposes.""" + if _package_access(server).allows( + user_id=user.id, + is_admin=user.is_admin, + resource_type=RESOURCE_SKILL_PACKAGE, + resource_key=row.id, + is_owner=_package_owner(user, row), + ): + return + raise OctopError.localized(ErrorCode.SKILL_PACKAGE_NOT_FOUND, locale) + + +def _package_visible_or_404( + store: SkillPackageStore, + package_id: str, + user: User, + server: OctopServer, + *, + locale: str, +) -> SkillPackageRow: + row = _package_or_404(store, package_id, locale=locale) + store.assert_can_view(row, user) + _assert_group_visible(server, user, row, locale=locale) + return row + + def _map_skillhub_error(exc: BaseException) -> OctopError: kind = getattr(exc, "kind", SkillHubMarketErrorKind.UPSTREAM_FAILED) if kind in ( @@ -256,13 +370,25 @@ async def list_skill_packages( user: User = Depends(require_permission("skill_packages")), ) -> list[dict[str, Any]]: store = _store(server) + access = _package_access(server) + rows = access.filter_rows( + _visible_packages(store, user), + user_id=user.id, + is_admin=user.is_admin, + resource_type=RESOURCE_SKILL_PACKAGE, + key_of=lambda row: row.id, + is_owner=lambda row: _package_owner(user, row), + ) return [ { - **_row_public_dict(row), + **_row_public_dict(server, row), **_creator_fields(server, row.created_by), + **access.visibility(RESOURCE_SKILL_PACKAGE, row.id, shared=bool(row.shared)), "can_write": store.can_mutate(row, user), + "can_copy": store.can_copy(row, user), + "can_download": store.can_download(row, user), } - for row in store.repo.list_all() + for row in rows if not writable_only or store.can_mutate(row, user) ] @@ -319,19 +445,41 @@ async def create_skill_package( ) -> dict[str, Any]: store = _store(server) name = _required(body.name, "name") + if body.visibility is not None: + _package_access(server).validate_visibility(body.visibility, body.group_ids) try: + shared = bool(body.shared) + if body.visibility == "owner": + shared = False + elif body.visibility in {"all", "groups"}: + shared = True row = store.create( name=name, description=body.description.strip(), created_by=str(user.id), icon_name=body.icon_name.strip(), icon_url=_icon_url(body.icon_url), + shared=shared, + copy_policy=body.copy_policy, + allow_download=bool(body.allow_download), ) except Exception as exc: if is_skill_package_name_conflict(exc): raise_skill_package_name_taken(name) raise - return _package_payload_with_creator(server, store, row) + if body.visibility is not None: + _package_access(server).set_visibility( + RESOURCE_SKILL_PACKAGE, + row.id, + body.visibility, + body.group_ids, + ) + return { + **_package_payload_with_creator(server, store, row), + **_package_access(server).visibility( + RESOURCE_SKILL_PACKAGE, row.id, shared=bool(row.shared) + ), + } @router.post( @@ -355,10 +503,14 @@ async def create_from_skillhub( description=body.description.strip() if body.description is not None else None, icon_name=body.icon_name.strip() if body.icon_name is not None else None, icon_url=_icon_url(body.icon_url) if body.icon_url is not None else None, + shared=bool(body.shared), ) except (SkillHubMarketError, SkillHubDownloadError) as exc: raise _map_skillhub_error(exc) from exc - return _package_payload_with_creator(server, store, result.row) + payload = _package_payload_with_creator(server, store, result.row) + if result.skipped_slugs: + payload["skipped_skills"] = list(result.skipped_slugs) + return payload @router.get("/{package_id}", summary="Get a global skill package and its skills") @@ -369,15 +521,21 @@ async def get_skill_package( user: User = Depends(require_permission("skill_packages")), ) -> dict[str, Any]: store = _store(server) - row = _package_or_404(store, package_id, locale=resolve_request_locale(request)) + locale = resolve_request_locale(request) + row = _package_visible_or_404(store, package_id, user, server, locale=locale) return { **_package_payload_with_creator( server, store, row, - locale=resolve_request_locale(request), + locale=locale, + ), + **_package_access(server).visibility( + RESOURCE_SKILL_PACKAGE, row.id, shared=bool(row.shared) ), "can_write": store.can_mutate(row, user), + "can_copy": store.can_copy(row, user), + "can_download": store.can_download(row, user), } @@ -390,12 +548,22 @@ async def update_skill_package( user: User = Depends(require_permission("skill_packages")), ) -> dict[str, Any]: store = _store(server) - row = _package_or_404(store, package_id, locale=resolve_request_locale(request)) + locale = resolve_request_locale(request) + row = _package_or_404(store, package_id, locale=locale) store.assert_can_mutate(row, user) name = _required(body.name, "name") if body.name is not None else None description = body.description.strip() if body.description is not None else None icon_name = body.icon_name.strip() if body.icon_name is not None else None - icon_url = _icon_url(body.icon_url) if body.icon_url is not None else None + icon_url = ( + _icon_url(body.icon_url, package_id=package_id) if body.icon_url is not None else None + ) + if body.visibility is not None: + _package_access(server).validate_visibility(body.visibility, body.group_ids) + shared_value = None if body.shared is None else int(body.shared) + if body.visibility == "owner": + shared_value = 0 + elif body.visibility in {"all", "groups"}: + shared_value = 1 try: store.repo.update( package_id, @@ -403,18 +571,37 @@ async def update_skill_package( description=description, icon_name=icon_name, icon_url=icon_url, + copy_policy=body.copy_policy, + allow_download=None if body.allow_download is None else int(body.allow_download), + shared=shared_value, ) except Exception as exc: if name is not None and is_skill_package_name_conflict(exc): raise_skill_package_name_taken(name) raise - updated = _package_or_404(store, package_id, locale=resolve_request_locale(request)) - return _package_payload_with_creator( - server, - store, - updated, - locale=resolve_request_locale(request), - ) + if icon_url is not None and not _is_package_avatar_url(icon_url, package_id): + from octop.infra.users.profile_avatar import delete_profile_avatar # noqa: PLC0415 + + delete_profile_avatar(server.paths.skill_package_avatars_dir, package_id) + if body.visibility is not None: + _package_access(server).set_visibility( + RESOURCE_SKILL_PACKAGE, + package_id, + body.visibility, + body.group_ids, + ) + updated = _package_or_404(store, package_id, locale=locale) + return { + **_package_payload_with_creator( + server, + store, + updated, + locale=locale, + ), + **_package_access(server).visibility( + RESOURCE_SKILL_PACKAGE, package_id, shared=bool(updated.shared) + ), + } @router.delete( @@ -430,23 +617,95 @@ async def delete_skill_package( row = _package_or_404(store, package_id, locale=resolve_request_locale(request)) store.assert_can_mutate(row, user) store.delete_package(package_id) + from octop.infra.users.profile_avatar import delete_profile_avatar # noqa: PLC0415 + + delete_profile_avatar(server.paths.skill_package_avatars_dir, package_id) + _package_access(server).clear(RESOURCE_SKILL_PACKAGE, package_id) if server.app_runtime is not None: await server.app_runtime.agent_registry.strip_skill_package_id(package_id) +@router.post( + "/{package_id}/avatar", + status_code=201, + summary="Upload a skill-package portrait", +) +async def upload_skill_package_avatar( + package_id: str, + request: Request, + file: UploadFile = File(...), # noqa: B008 + server: OctopServer = Depends(get_server), + user: User = Depends(require_permission("skill_packages")), +) -> dict[str, str]: + from octop.infra.users.profile_avatar import ( # noqa: PLC0415 + profile_avatar_url, + write_profile_avatar, + ) + + store = _store(server) + locale = resolve_request_locale(request) + row = _package_or_404(store, package_id, locale=locale) + store.assert_can_mutate(row, user) + write_profile_avatar( + server.paths.skill_package_avatars_dir, + package_id, + await file.read(), + ) + icon_url = _package_avatar_api_path(package_id) + store.repo.update(package_id, icon_url=icon_url) + display = ( + profile_avatar_url(server.paths.skill_package_avatars_dir, package_id, icon_url) or icon_url + ) + return {"icon_url": display} + + +@router.get("/{package_id}/avatar", summary="Read a skill-package portrait") +async def get_skill_package_avatar( + package_id: str, + request: Request, + server: OctopServer = Depends(get_server), + user: User = Depends(require_permission("skill_packages")), +) -> Response: + from octop.infra.users.profile_avatar import avatar_response # noqa: PLC0415 + + store = _store(server) + locale = resolve_request_locale(request) + _package_visible_or_404(store, package_id, user, server, locale=locale) + return avatar_response(server.paths.skill_package_avatars_dir, package_id) + + +@router.delete( + "/{package_id}/avatar", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a skill-package portrait", +) +async def delete_skill_package_avatar( + package_id: str, + request: Request, + server: OctopServer = Depends(get_server), + user: User = Depends(require_permission("skill_packages")), +) -> None: + from octop.infra.users.profile_avatar import delete_profile_avatar # noqa: PLC0415 + + store = _store(server) + locale = resolve_request_locale(request) + row = _package_or_404(store, package_id, locale=locale) + store.assert_can_mutate(row, user) + delete_profile_avatar(server.paths.skill_package_avatars_dir, package_id) + store.repo.update(package_id, icon_url="") + + @router.get("/{package_id}/skills", summary="List skills in a global skill package") async def list_package_skills( package_id: str, request: Request, server: OctopServer = Depends(get_server), - _user: User = Depends(require_permission("skill_packages")), + user: User = Depends(require_permission("skill_packages")), ) -> list[dict[str, Any]]: store = _store(server) - _package_or_404(store, package_id, locale=resolve_request_locale(request)) - return store.list_skill_summaries( - package_id, - locale=resolve_request_locale(request), - ) + locale = resolve_request_locale(request) + _package_visible_or_404(store, package_id, user, server, locale=locale) + return store.list_skill_summaries(package_id, locale=locale) @router.get("/{package_id}/skills/{slug}", summary="Get a global package skill") @@ -455,21 +714,132 @@ async def get_package_skill( slug: str, request: Request, server: OctopServer = Depends(get_server), - _user: User = Depends(require_permission("skill_packages")), + user: User = Depends(require_permission("skill_packages")), ) -> dict[str, Any]: store = _store(server) - _package_or_404(store, package_id, locale=resolve_request_locale(request)) - skill = _package_skill_or_404( - store, - package_id, - slug, - locale=resolve_request_locale(request), - ) + locale = resolve_request_locale(request) + _package_visible_or_404(store, package_id, user, server, locale=locale) + skill = _package_skill_or_404(store, package_id, slug, locale=locale) raw = (store.package_skills_dir(package_id) / slug / "SKILL.md").read_text(encoding="utf-8") frontmatter, body = parse_frontmatter(raw) return {**skill, "frontmatter": frontmatter, "body": body, "raw": raw} +@router.get( + "/{package_id}/skills/{slug}/tree", + summary="List files under a global package skill path", +) +async def list_package_skill_tree( + package_id: str, + slug: str, + request: Request, + path: str = Query(default="", description="Path relative to the skill root"), + server: OctopServer = Depends(get_server), + user: User = Depends(require_permission("skill_packages")), +) -> list[dict[str, Any]]: + store = _store(server) + locale = resolve_request_locale(request) + _package_visible_or_404(store, package_id, user, server, locale=locale) + _package_skill_or_404(store, package_id, slug, locale=locale) + return await asyncio.to_thread(store.list_skill_tree, package_id, slug, path) + + +@router.get( + "/{package_id}/skills/{slug}/file", + summary="Read a UTF-8 text file from a global package skill", +) +async def read_package_skill_file( + package_id: str, + slug: str, + path: str, + request: Request, + server: OctopServer = Depends(get_server), + user: User = Depends(require_permission("skill_packages")), +) -> dict[str, Any]: + store = _store(server) + locale = resolve_request_locale(request) + _package_visible_or_404(store, package_id, user, server, locale=locale) + _package_skill_or_404(store, package_id, slug, locale=locale) + content = await asyncio.to_thread(store.read_skill_file_text, package_id, slug, path) + return {"path": path, "content": coerce_read_content(content)} + + +@router.get( + "/{package_id}/skills/{slug}/download", + summary="Download a file from a global package skill", + response_class=Response, + responses={200: {"content": {"application/octet-stream": {}}}}, +) +async def download_package_skill_file( + package_id: str, + slug: str, + path: str, + request: Request, + server: OctopServer = Depends(get_server), + user: User = Depends(require_permission("skill_packages")), +) -> Response: + store = _store(server) + locale = resolve_request_locale(request) + _package_visible_or_404(store, package_id, user, server, locale=locale) + _package_skill_or_404(store, package_id, slug, locale=locale) + payload = await asyncio.to_thread(store.read_skill_file_bytes, package_id, slug, path) + fname = path.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1] or "download.bin" + return Response( + content=payload, + media_type="application/octet-stream", + headers={"Content-Disposition": content_disposition(fname)}, + ) + + +def _read_package_skill_files( + store: SkillPackageStore, package_id: str +) -> list[tuple[str, list[tuple[str, bytes]]]]: + packed: list[tuple[str, list[tuple[str, bytes]]]] = [] + root = store.package_skills_dir(package_id) + for summary in store.list_skill_summaries(package_id): + if summary.get("corrupt"): + continue + slug = str(summary.get("slug") or "") + packed.append((slug, read_skill_directory(root / slug))) + if not packed: + raise SkillPackageError("skill package has no skills") + return packed + + +@router.get( + "/{package_id}/export", + summary="Download a skill package as a ZIP", + description=( + "Pack every skill in the package into one ZIP. Each skill is a top-level " + "folder containing SKILL.md, matching the dashboard ZIP importer." + ), + response_class=Response, + responses={200: {"content": {"application/zip": {}}}}, +) +async def export_skill_package( + package_id: str, + request: Request, + server: OctopServer = Depends(get_server), + _user: User = Depends(require_permission("skill_packages")), +) -> Response: + store = _store(server) + locale = resolve_request_locale(request) + row = _package_or_404(store, package_id, locale=locale) + store.assert_can_download(row, _user) + _assert_group_visible(server, _user, row, locale=locale) + try: + packed = await asyncio.to_thread(_read_package_skill_files, store, package_id) + payload = await asyncio.to_thread(build_skills_zip, packed) + except SkillPackageError as exc: + raise OctopError(ErrorCode.SLASH_BAD_ARGS, str(exc)) from exc + filename = f"{(row.name or package_id).strip() or package_id}.zip" + return Response( + content=payload, + media_type="application/zip", + headers={"Content-Disposition": content_disposition(filename)}, + ) + + @router.post("/{package_id}/skills", summary="Add a skill to a global skill package") async def create_package_skill( package_id: str, @@ -507,15 +877,27 @@ async def update_package_skill( server: OctopServer = Depends(get_server), user: User = Depends(require_permission("skill_packages")), ) -> dict[str, Any]: + """Update a package skill. + + A ``content``-only body (dashboard SKILL.md editor) rewrites just the + manifest in place so sibling files and directories are preserved. A + ``files`` payload replaces the whole skill directory. + """ store = _store(server) row = _package_or_404(store, package_id, locale=resolve_request_locale(request)) store.assert_can_mutate(row, user) _package_skill_or_404(store, package_id, slug) - store.write_skill( - package_id, - slug, - _files_from_package_skill_body(content=body.content, files=body.files), - ) + # Truthiness mirrors ``_files_from_package_skill_body`` / workspace skills. + if body.files: + store.write_skill( + package_id, + slug, + _files_from_package_skill_body(content=body.content, files=body.files), + ) + else: + if not body.content: + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "content or files is required") + store.write_skill_manifest(package_id, slug, body.content) if server.app_runtime is not None: await server.app_runtime.agent_registry.refresh_agents_for_package(package_id) return _package_skill_or_404(store, package_id, slug) diff --git a/src/octop/api/routers/skills.py b/src/octop/api/routers/skills.py index 11d4dd1502..165a0a8522 100644 --- a/src/octop/api/routers/skills.py +++ b/src/octop/api/routers/skills.py @@ -7,6 +7,8 @@ GET /api/agents/{aid}/skills → summaries GET /api/agents/{aid}/skills/{name} → full detail (frontmatter + body) + GET /api/agents/{aid}/skills/{name}/export → ZIP of one workspace skill + POST /api/agents/{aid}/skills/export → ZIP of selected workspace skills POST /api/agents/{aid}/skills → body { name, content } DELETE /api/agents/{aid}/skills/{name} → remove SKILL.md POST /api/agents/{aid}/skills/{name}/enable @@ -40,11 +42,12 @@ from pathlib import Path from typing import Any, cast -import yaml from fastapi import APIRouter, Depends, Request -from pydantic import BaseModel +from fastapi.responses import Response +from pydantic import BaseModel, Field from octop.api.common.agent import require_agent_owner_row, require_agent_row +from octop.api.common.content_disposition import content_disposition from octop.api.deps import current_user, get_server, require_permission from octop.infra.agents.manager import ( skill_package_ids_list, @@ -54,7 +57,8 @@ ) from octop.infra.errors import ErrorCode, OctopError from octop.infra.skills.presentation import apply_skill_presentation -from octop.infra.skills.skill_package_store import SkillPackageStore +from octop.infra.skills.skill_archive import build_skill_zip, build_skills_zip +from octop.infra.skills.skill_package_store import SkillPackageStore, normalize_copy_policy from octop.infra.skills.skill_packages import ( SkillPackageError, SkillPackageTooLarge, @@ -67,7 +71,10 @@ SkillTransferNotFound, copy_package_skills_to_workspace, copy_workspace_skill_to_package, + copy_workspace_skill_to_workspace, + read_workspace_skill_files, ) +from octop.infra.utils.frontmatter import parse_frontmatter from octop.infra.utils.locale import Locale, resolve_request_locale logger = logging.getLogger(__name__) @@ -106,31 +113,6 @@ async def _ctx( return _AgentCtx(runtime=row, workspace=agent.workspace, config=cfg) -def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]: - """Split a markdown file with optional YAML frontmatter. - - Accepts both ``---``-delimited and ``+++`` (TOML) blocks; we only - handle YAML for simplicity since finnie's skills all use YAML. - Returns ``(metadata_dict, body)``. Malformed frontmatter is treated - as no-frontmatter (the file is its own body). - """ - if not text.startswith("---\n"): - return {}, text - # Find the closing delimiter - end = text.find("\n---", 4) - if end == -1: - return {}, text - raw = text[4:end] - body = text[end + 4 :].lstrip("\n") - try: - meta = yaml.safe_load(raw) or {} - if not isinstance(meta, dict): - return {}, text - return meta, body - except yaml.YAMLError: - return {}, text - - async def _aread_text(workspace: Any, path: str) -> str | None: return cast(str | None, await workspace.aread_text(path)) @@ -191,7 +173,7 @@ async def _resolve_skill( manifest = await _aread_text(workspace, manifest_path) if manifest is None: continue - meta, body = _parse_frontmatter(manifest) + meta, body = parse_frontmatter(manifest) if meta.get("removed"): continue return manifest_path, kind, body @@ -203,12 +185,37 @@ async def _guard_package_only_skill_write( config: dict[str, Any], server: Any, slug: str, + user: Any = None, ) -> None: - """Reject writes that would alter a skill supplied only by a mounted package.""" + """Reject writes that would alter a skill supplied only by a mounted package. + + Also rejects writes to workspace copies stamped ``locked`` by a + ``copy_policy="lock"`` package unless the requester created that package + (or is admin) — the copy is meant to be used, not rewritten (#770). + """ workspace_manifest = await _aread_text(workspace, f"{_SKILLS_ROOT}/{slug}/SKILL.md") if workspace_manifest is not None: - metadata, _body = _parse_frontmatter(workspace_manifest) + metadata, _body = parse_frontmatter(workspace_manifest) if not metadata.get("removed"): + origin = str(metadata.get("origin") or "").strip() + if metadata.get("locked") and origin and server.services is not None: + store = SkillPackageStore( + repo=server.services.skill_package_repo, + root=server.paths.skill_packages_dir, + ) + origin_row = store.repo.get(origin) + # Unknown origin stays locked for regular users (fail-safe) + # but admins keep an escape hatch for orphaned copies. + allowed = bool(getattr(user, "is_admin", False)) or ( + user is not None + and origin_row is not None + and str(getattr(user, "id", "")) == origin_row.created_by + ) + if not allowed: + raise OctopError( + ErrorCode.SKILL_PACKAGE_LOCKED, + f"skill {slug!r} was copied from a locked skill package", + ) return assert server.services is not None @@ -478,6 +485,12 @@ class CopyPackageSkillsBody(BaseModel): overwrite: bool = False +class CopyWorkspaceSkillBody(BaseModel): + source_agent_id: str + slug: str + overwrite: bool = True + + class PushSkillToPackageBody(BaseModel): package_id: str overwrite: bool = False @@ -526,6 +539,7 @@ async def list_skill_package_mounts( async def replace_skill_package_mounts( agent_id: str, body: SkillPackageMountBody, + request: Request, as_user: int | None = None, user: Any = Depends(current_user), server: Any = Depends(get_server), @@ -533,10 +547,36 @@ async def replace_skill_package_mounts( require_agent_owner_row(agent_id, user=user, as_user=as_user, server=server) assert server.app_runtime is not None package_ids = skill_package_ids_list({"skill_package_ids": body.package_ids}) + store = _skill_package_store(server) + locale = resolve_request_locale(request) + for package_id in package_ids: + package_row = store.repo.get(package_id) + if package_row is None: + raise OctopError.localized(ErrorCode.SKILL_PACKAGE_NOT_FOUND, locale) + store.assert_can_view(package_row, user) await server.app_runtime.agent_registry.persist_skill_package_ids(agent_id, package_ids) return {"package_ids": package_ids} +def _assert_skill_package_group(server: Any, user: Any, row: Any, locale: str) -> None: + """Groups narrow a shared package. They do not open a private one.""" + services = getattr(server, "services", None) + repos = getattr(services, "repos", None) if services is not None else None + if repos is None: + return + from octop.infra.users.groups import RESOURCE_SKILL_PACKAGE, GroupAccess + + if GroupAccess(repos).allows( + user_id=user.id, + is_admin=bool(getattr(user, "is_admin", False)), + resource_type=RESOURCE_SKILL_PACKAGE, + resource_key=row.id, + is_owner=str(user.id) == str(row.created_by), + ): + return + raise OctopError.localized(ErrorCode.SKILL_PACKAGE_NOT_FOUND, locale) + + def _skill_package_store(server: Any) -> SkillPackageStore: if server.services is None: raise OctopError(ErrorCode.INTERNAL_ERROR, "skill package store not initialized") @@ -573,11 +613,14 @@ async def copy_skill_package_to_workspace( ) -> dict[str, list[str]]: ctx = await _ctx(agent_id, user=user, as_user=as_user, server=server) store = _skill_package_store(server) - if store.repo.get(package_id) is None: + package_row = store.repo.get(package_id) + if package_row is None: raise OctopError.localized( ErrorCode.SKILL_PACKAGE_NOT_FOUND, resolve_request_locale(request), ) + store.assert_can_copy(package_row, user) + _assert_skill_package_group(server, user, package_row, resolve_request_locale(request)) try: requested_slugs = list( dict.fromkeys(validate_skill_slug(slug) for slug in body.skill_slugs) @@ -591,6 +634,7 @@ async def copy_skill_package_to_workspace( slugs=requested_slugs, workspace=ctx.workspace, overwrite=body.overwrite, + copy_policy=normalize_copy_policy(package_row.copy_policy), ) except SkillPackageError as exc: raise _skill_transfer_error( @@ -604,6 +648,13 @@ async def copy_skill_package_to_workspace( if disabled.intersection(copied_identity_keys): disabled.difference_update(copied_identity_keys) await _persist_disabled(server, agent_id, disabled) + if server.services is not None: + server.services.audit_repo.write( + actor=user.username, + action="skill_package.copied", + target=package_id, + payload=",".join(copied)[:200], + ) return {"copied": copied} @@ -629,7 +680,7 @@ async def push_workspace_skill_to_package( resolve_request_locale(request), ) store.assert_can_mutate(row, user) - await _guard_package_only_skill_write(ctx.workspace, ctx.config, server, name) + await _guard_package_only_skill_write(ctx.workspace, ctx.config, server, name, user) try: slug = await copy_workspace_skill_to_package( workspace=ctx.workspace, @@ -671,7 +722,7 @@ async def get_skill( manifest_path, kind, _body = resolved manifest = await _aread_text(ctx.workspace, manifest_path) assert manifest is not None - meta, body = _parse_frontmatter(manifest) + meta, body = parse_frontmatter(manifest) disabled = _disabled_set(ctx.config) return { **_summary_dict( @@ -687,6 +738,83 @@ async def get_skill( } +@router.get( + "/agents/{agent_id}/skills/{name}/export", + summary="Download a workspace skill as a ZIP", + description=( + "Pack one self-built workspace skill (SKILL.md and sibling files) into a ZIP " + "whose top-level folder is the skill slug. Built-in skills are not included. " + "The archive matches the dashboard ZIP importer, so it can be imported onto " + "another agent or into a skill package." + ), + response_class=Response, + responses={200: {"content": {"application/zip": {}}}}, +) +async def export_skill_zip( + agent_id: str, + name: str, + request: Request, + as_user: int | None = None, + user: Any = Depends(require_permission("skills")), + server: Any = Depends(get_server), +) -> Response: + ctx = await _ctx(agent_id, user=user, as_user=as_user, server=server) + locale = resolve_request_locale(request) + try: + files = await read_workspace_skill_files(ctx.workspace, name) + safe_slug = validate_skill_slug(name) + payload = await asyncio.to_thread(build_skill_zip, safe_slug, files) + except SkillPackageError as exc: + raise _skill_transfer_error(exc, locale=locale) from exc + return Response( + content=payload, + media_type="application/zip", + headers={"Content-Disposition": content_disposition(f"{safe_slug}.zip")}, + ) + + +class ExportSkillsBody(BaseModel): + slugs: list[str] = Field(min_length=1) + + +@router.post( + "/agents/{agent_id}/skills/export", + summary="Download workspace skills as one ZIP", + description=( + "Pack the selected workspace skills into one ZIP. Each skill is a top-level " + "folder containing SKILL.md, which is the layout the dashboard ZIP importer " + "already accepts." + ), + response_class=Response, + responses={200: {"content": {"application/zip": {}}}}, +) +async def export_skills_zip( + agent_id: str, + body: ExportSkillsBody, + request: Request, + as_user: int | None = None, + user: Any = Depends(require_permission("skills")), + server: Any = Depends(get_server), +) -> Response: + ctx = await _ctx(agent_id, user=user, as_user=as_user, server=server) + locale = resolve_request_locale(request) + slugs = list(dict.fromkeys(body.slugs)) + try: + packed: list[tuple[str, list[tuple[str, bytes]]]] = [] + for slug in slugs: + files = await read_workspace_skill_files(ctx.workspace, slug) + packed.append((validate_skill_slug(slug), files)) + payload = await asyncio.to_thread(build_skills_zip, packed) + except SkillPackageError as exc: + raise _skill_transfer_error(exc, locale=locale) from exc + filename = f"{packed[0][0]}.zip" if len(packed) == 1 else "skills.zip" + return Response( + content=payload, + media_type="application/zip", + headers={"Content-Disposition": content_disposition(filename)}, + ) + + # --- write endpoints -------------------------------------------------------- @@ -756,7 +884,7 @@ async def create_skill( agent_id: str, body: CreateSkillBody, as_user: int | None = None, - user: Any = Depends(current_user), + user: Any = Depends(require_permission("skills")), server: Any = Depends(get_server), ) -> dict[str, Any]: ctx = await _ctx(agent_id, user=user, as_user=as_user, server=server) @@ -771,12 +899,12 @@ async def create_skill( raise OctopError(ErrorCode.SLASH_BAD_ARGS, str(exc)) from exc except SkillPackageError: raise OctopError(ErrorCode.NOT_FOUND, "invalid skill name") from None - await _guard_package_only_skill_write(ctx.workspace, ctx.config, server, name) + await _guard_package_only_skill_write(ctx.workspace, ctx.config, server, name, user) # Conflict check must use SKILL.md — ZIP payloads often list siblings first, # and soft-delete only marks the manifest (leaving sibling files behind). existing = await _aread_text(ctx.workspace, f"skills/{name}/SKILL.md") if existing is not None: - meta, _ = _parse_frontmatter(existing) + meta, _ = parse_frontmatter(existing) if not meta.get("removed") and not body.overwrite: raise OctopError(ErrorCode.SKILL_ALREADY_EXISTS, f"skill {name!r} already exists") with contextlib.suppress(Exception): @@ -792,7 +920,7 @@ async def create_skill( (content for path, content in package.files if path == "SKILL.md"), body.content.encode("utf-8"), ) - meta, _body = _parse_frontmatter(skill_md.decode("utf-8", errors="replace")) + meta, _body = parse_frontmatter(skill_md.decode("utf-8", errors="replace")) return _summary_dict(name, meta, enabled=True, kind="workspace") @@ -802,7 +930,7 @@ async def update_skill( name: str, body: UpdateSkillBody, as_user: int | None = None, - user: Any = Depends(current_user), + user: Any = Depends(require_permission("skills")), server: Any = Depends(get_server), ) -> dict[str, Any]: """Overwrite an installed workspace skill via BackendWorkspace only. @@ -825,11 +953,11 @@ async def update_skill( except SkillPackageError: raise OctopError(ErrorCode.NOT_FOUND, "invalid skill name") from None - await _guard_package_only_skill_write(ctx.workspace, ctx.config, server, slug) + await _guard_package_only_skill_write(ctx.workspace, ctx.config, server, slug, user) existing = await _aread_text(ctx.workspace, f"skills/{slug}/SKILL.md") if existing is None: raise OctopError(ErrorCode.NOT_FOUND, f"skill {slug!r} not found") - meta_existing, _ = _parse_frontmatter(existing) + meta_existing, _ = parse_frontmatter(existing) if meta_existing.get("removed"): raise OctopError(ErrorCode.NOT_FOUND, f"skill {slug!r} not found") @@ -848,7 +976,7 @@ async def update_skill( (content for path, content in package.files if path == "SKILL.md"), body.content.encode("utf-8"), ) - meta, _body = _parse_frontmatter(skill_md.decode("utf-8", errors="replace")) + meta, _body = parse_frontmatter(skill_md.decode("utf-8", errors="replace")) disabled = _disabled_set(ctx.config) return _summary_dict( slug, @@ -891,13 +1019,52 @@ async def after_install(self, slug: str, *, enable: bool | None = None) -> None: await _persist_disabled(self._server, self._agent_id, disabled) +@router.post( + "/agents/{agent_id}/skills/copy", + status_code=201, + summary="Copy a workspace skill from another agent", +) +async def copy_skill_from_agent( + agent_id: str, + body: CopyWorkspaceSkillBody, + request: Request, + as_user: int | None = None, + user: Any = Depends(require_permission("skills")), + server: Any = Depends(get_server), +) -> dict[str, Any]: + dest = await _ctx(agent_id, user=user, as_user=as_user, server=server) + require_agent_owner_row(body.source_agent_id, user=user, as_user=as_user, server=server) + assert server.app_runtime is not None + source = server.app_runtime.agent_registry.workspace_for_agent(body.source_agent_id) + if source is None: + raise OctopError(ErrorCode.NOT_FOUND, "source workspace not found") + locale = resolve_request_locale(request) + try: + slug = validate_skill_slug(body.slug) + copied_identity_keys = set(await _skill_disable_keys(dest, slug)) + copied = await copy_workspace_skill_to_workspace( + source=source, + destination=dest.workspace, + slug=slug, + overwrite=body.overwrite, + ) + except SkillPackageError as exc: + raise _skill_transfer_error(exc, locale=locale) from exc + copied_identity_keys.update(await _skill_disable_keys(dest, copied)) + disabled = _disabled_set(dest.config) + if disabled.intersection(copied_identity_keys): + disabled.difference_update(copied_identity_keys) + await _persist_disabled(server, agent_id, disabled) + return {"slug": copied, "copied": True} + + @router.post("/agents/{agent_id}/skills/import", status_code=201) async def import_skill_from_url( agent_id: str, body: ImportSkillBody, request: Request, as_user: int | None = None, - user: Any = Depends(current_user), + user: Any = Depends(require_permission("skills")), server: Any = Depends(get_server), ) -> dict[str, Any]: """Import a skill bundle from a supported external URL into the agent workspace.""" @@ -931,6 +1098,7 @@ async def import_skill_from_url( bundle_url=bundle_url, version=body.version, ) + await _guard_package_only_skill_write(ctx.workspace, ctx.config, server, package.slug, user) await commit_skill_install( target, package, @@ -972,7 +1140,7 @@ async def import_skill_from_url( (content for path, content in package.files if path == "SKILL.md"), b"", ) - meta, _body = _parse_frontmatter(skill_md.decode("utf-8")) + meta, _body = parse_frontmatter(skill_md.decode("utf-8")) return _summary_dict( package.slug, meta, @@ -986,7 +1154,7 @@ async def delete_skill( agent_id: str, name: str, as_user: int | None = None, - user: Any = Depends(current_user), + user: Any = Depends(require_permission("skills")), server: Any = Depends(get_server), ) -> None: """Soft-delete via marker — see module docstring for rationale.""" @@ -995,7 +1163,7 @@ async def delete_skill( slug = validate_skill_slug(name) except SkillPackageError: raise OctopError(ErrorCode.NOT_FOUND, "invalid skill name") from None - await _guard_package_only_skill_write(ctx.workspace, ctx.config, server, slug) + await _guard_package_only_skill_write(ctx.workspace, ctx.config, server, slug, user) resolved = await _resolve_skill(ctx.workspace, slug) if resolved is None: raise OctopError(ErrorCode.NOT_FOUND, f"skill {slug!r} not found") @@ -1022,7 +1190,7 @@ async def _persist_disabled(server: Any, agent_id: str, disabled: set[str]) -> N async def _skill_disable_keys(ctx: _AgentCtx, name: str) -> set[str]: """Return slug / display-name keys to toggle for enable/disable.""" - from harness_agent.skills.catalog import skill_identity_keys + from octop_harness.skills.catalog import skill_identity_keys slug = name.strip() keys = {slug} if slug else set() @@ -1033,7 +1201,7 @@ async def _skill_disable_keys(ctx: _AgentCtx, name: str) -> set[str]: manifest = await _aread_text(ctx.workspace, manifest_path) if manifest is None: return keys - meta, _ = _parse_frontmatter(manifest) + meta, _ = parse_frontmatter(manifest) summary = _summary_dict(slug, meta, enabled=False, kind=kind) keys |= skill_identity_keys( { @@ -1050,7 +1218,7 @@ async def enable_skill( agent_id: str, name: str, as_user: int | None = None, - user: Any = Depends(current_user), + user: Any = Depends(require_permission("skills")), server: Any = Depends(get_server), ) -> None: ctx = await _ctx(agent_id, user=user, as_user=as_user, server=server) @@ -1066,7 +1234,7 @@ async def disable_skill( agent_id: str, name: str, as_user: int | None = None, - user: Any = Depends(current_user), + user: Any = Depends(require_permission("skills")), server: Any = Depends(get_server), ) -> None: ctx = await _ctx(agent_id, user=user, as_user=as_user, server=server) @@ -1210,27 +1378,12 @@ def _parse_skillhub_search_output(text: str) -> list[dict[str, Any]]: return results -@router.get("/agents/{agent_id}/skills/hub/search") -async def hub_search_skills( - agent_id: str, - request: Request, - q: str = "", - limit: int = 50, - as_user: int | None = None, - user: Any = Depends(current_user), - server: Any = Depends(get_server), -) -> list[dict[str, Any]]: - """Search Tencent SkillHub over HTTP, with CLI compatibility fallback. +_RANKING_TYPES = {"all", "hot", "featured", "newest", "recommended", "trending", "paid"} - The agent_id param is accepted for auth/routing symmetry with - the install endpoint but is not used for the search itself. - """ + +async def _hub_search(request: Request, *, q: str, limit: int) -> list[dict[str, Any]]: from fastapi import HTTPException # noqa: PLC0415 - # Verify the agent exists and belongs to this user. The skillhub CLI - # runs globally, so we only need an existence/ownership check here — - # the agent need not be running (unlike chat/workspace endpoints). - require_agent_owner_row(agent_id, user=user, as_user=as_user, server=server) locale = resolve_request_locale(request) query = q.strip() or "a" effective_limit = max(1, min(limit, 100)) @@ -1265,7 +1418,61 @@ async def hub_search_skills( return _parse_skillhub_search_output(stdout) -_RANKING_TYPES = {"all", "hot", "featured", "newest", "recommended", "trending", "paid"} +async def _hub_rankings(ranking_type: str) -> dict[str, Any]: + from fastapi import HTTPException # noqa: PLC0415 + + rtype = ranking_type if ranking_type in _RANKING_TYPES else "all" + from octop.infra.skills.skillhub_market import ( # noqa: PLC0415 + SkillHubMarketError, + SkillHubMarketTimeout, + fetch_skillhub_rankings, + ) + + try: + return await fetch_skillhub_rankings(rtype) + except SkillHubMarketTimeout as exc: + raise HTTPException(status_code=504, detail=str(exc)) from exc + except SkillHubMarketError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@router.get("/skills/hub/search", summary="Search SkillHub without an agent") +async def hub_search_skills_global( + request: Request, + q: str = "", + limit: int = 50, + _user: Any = Depends(current_user), +) -> list[dict[str, Any]]: + """Browse SkillHub while composing a new expert (no agent exists yet).""" + return await _hub_search(request, q=q, limit=limit) + + +@router.get("/skills/hub/rankings", summary="SkillHub rankings without an agent") +async def hub_rankings_global( + type: str = "all", + _user: Any = Depends(current_user), +) -> dict[str, Any]: + """Browse SkillHub rankings while composing a new expert.""" + return await _hub_rankings(type) + + +@router.get("/agents/{agent_id}/skills/hub/search") +async def hub_search_skills( + agent_id: str, + request: Request, + q: str = "", + limit: int = 50, + as_user: int | None = None, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> list[dict[str, Any]]: + """Search Tencent SkillHub over HTTP, with CLI compatibility fallback. + + The agent_id param is accepted for auth/routing symmetry with + the install endpoint but is not used for the search itself. + """ + require_agent_owner_row(agent_id, user=user, as_user=as_user, server=server) + return await _hub_search(request, q=q, limit=limit) @router.get("/agents/{agent_id}/skills/hub/rankings") @@ -1285,23 +1492,8 @@ async def hub_rankings( env var, else https://api.skillhub.cn. The agent_id is accepted for auth/routing symmetry with search/install; rankings are global. """ - from fastapi import HTTPException # noqa: PLC0415 - require_agent_owner_row(agent_id, user=user, as_user=as_user, server=server) - - rtype = type if type in _RANKING_TYPES else "all" - from octop.infra.skills.skillhub_market import ( # noqa: PLC0415 - SkillHubMarketError, - SkillHubMarketTimeout, - fetch_skillhub_rankings, - ) - - try: - return await fetch_skillhub_rankings(rtype) - except SkillHubMarketTimeout as exc: - raise HTTPException(status_code=504, detail=str(exc)) from exc - except SkillHubMarketError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc + return await _hub_rankings(type) @router.post("/agents/{agent_id}/skills/hub/install", status_code=201) @@ -1309,7 +1501,7 @@ async def hub_install_skill( agent_id: str, body: HubInstallBody, as_user: int | None = None, - user: Any = Depends(current_user), + user: Any = Depends(require_permission("skills")), server: Any = Depends(get_server), ) -> dict[str, Any]: """Install a SkillHub skill into the specified agent's workspace. @@ -1387,6 +1579,7 @@ async def hub_install_skill( except (SkillHubPackageError, SkillPackageError) as package_exc: raise HTTPException(status_code=502, detail=str(package_exc)) from package_exc + await _guard_package_only_skill_write(ctx.workspace, ctx.config, server, skill_name, user) try: await install_skill_from_skillhub( target, diff --git a/src/octop/api/routers/slash.py b/src/octop/api/routers/slash.py index 3ad5c42953..ddc52eb9b9 100644 --- a/src/octop/api/routers/slash.py +++ b/src/octop/api/routers/slash.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field from octop.api.deps import current_user, get_server +from octop.infra.agents.security import tool_execution_may_pause router = APIRouter() @@ -52,7 +53,11 @@ async def list_slash_commands( """ assert server.app_runtime is not None dispatcher = server.app_runtime.gateway.slash_dispatcher - specs = dispatcher.list_command_specs(origin=origin) + include_hitl = tool_execution_may_pause(server.app_runtime.agent_registry.security.load()) + specs = dispatcher.list_command_specs( + origin=origin, + include_hitl_approval=include_hitl, + ) commands = [ SlashCommandOut( name=spec.name, diff --git a/src/octop/api/routers/storage_backends.py b/src/octop/api/routers/storage_backends.py index 41b303e165..813d6dba3a 100644 --- a/src/octop/api/routers/storage_backends.py +++ b/src/octop/api/routers/storage_backends.py @@ -2,14 +2,21 @@ from __future__ import annotations -from typing import Any +from typing import Any, Literal -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query +from fastapi.responses import Response from pydantic import BaseModel +from octop.api.common.content_disposition import content_disposition from octop.api.deps import current_user, get_server, require_permission from octop.infra.backend.adapter import row_to_backend_spec, storage_spec_previewable from octop.infra.errors import ErrorCode, OctopError +from octop.infra.users.groups import ( + RESOURCE_STORAGE_BACKEND, + STORAGE_FEATURE_KEY, + GroupAccess, +) admin_router = APIRouter() @@ -35,9 +42,12 @@ class StorageBackendCreateBody(BaseModel): region: str | None = None config_json: str | None = None note: str | None = None + visibility: Literal["all", "groups"] | None = None + group_ids: list[str] | None = None class StorageBackendPatchBody(BaseModel): + name: str | None = None kind: str | None = None endpoint: str | None = None access_key: str | None = None @@ -47,6 +57,8 @@ class StorageBackendPatchBody(BaseModel): config_json: str | None = None note: str | None = None enabled: bool | None = None + visibility: Literal["all", "groups"] | None = None + group_ids: list[str] | None = None class StorageBackendProbeBody(BaseModel): @@ -61,7 +73,7 @@ class StorageBackendProbeBody(BaseModel): backend_id: int | None = None -def _row_to_dict(r: Any) -> dict[str, Any]: +def _row_to_dict(r: Any, *, access: GroupAccess | None = None) -> dict[str, Any]: """Serialize a BackendRow to API response dict. secret_key is intentionally omitted from responses. @@ -70,7 +82,7 @@ def _row_to_dict(r: Any) -> dict[str, Any]: ak = r.access_key or "" masked_ak = f"{ak[:4]}{'*' * 8}" if len(ak) > 4 else "*" * len(ak) spec = row_to_backend_spec(r) - return { + payload = { "id": r.id, "name": r.name, "kind": r.kind, @@ -85,6 +97,24 @@ def _row_to_dict(r: Any) -> dict[str, Any]: "created_at": r.created_at, "updated_at": r.updated_at, } + if access is not None: + payload.update(access.visibility(RESOURCE_STORAGE_BACKEND, r.name, shared=None)) + return payload + + +def _apply_storage_visibility( + server: Any, + backend_name: str, + *, + visibility: str | None, + group_ids: list[str] | None, +) -> None: + if visibility is None: + return + access = GroupAccess(server.services.repos) + # Drop the legacy feature-wide grant if present. + access.clear(RESOURCE_STORAGE_BACKEND, STORAGE_FEATURE_KEY) + access.set_visibility(RESOURCE_STORAGE_BACKEND, backend_name, visibility, group_ids) @admin_router.get("") @@ -92,7 +122,10 @@ async def list_storage_backends( _: Any = Depends(require_permission("storage_backends")), server: Any = Depends(get_server), ) -> list[dict[str, Any]]: - return [_row_to_dict(r) for r in server.services.storage_backend_repo.list_all()] + access = GroupAccess(server.services.repos) + return [ + _row_to_dict(row, access=access) for row in server.services.storage_backend_repo.list_all() + ] @admin_router.post("", status_code=201) @@ -115,7 +148,14 @@ async def create_storage_backend( config_json=body.config_json, note=body.note, ) - return _row_to_dict(server.services.storage_backend_repo.get(bid)) + _apply_storage_visibility( + server, + body.name, + visibility=body.visibility or "all", + group_ids=body.group_ids, + ) + access = GroupAccess(server.services.repos) + return _row_to_dict(server.services.storage_backend_repo.get(bid), access=access) @admin_router.patch("/{backend_id}") @@ -131,8 +171,39 @@ async def patch_storage_backend( next_kind = (body.kind or row.kind or "").lower() if body.enabled is True: _ensure_opensandbox_sdk(next_kind) + + new_name: str | None = None + if body.name is not None: + candidate = body.name.strip() + if candidate and candidate != row.name: + taken = server.services.storage_backend_repo.get_by_name(candidate) + if taken is not None and taken.id != backend_id: + raise OctopError( + ErrorCode.STORAGE_BACKEND_NAME_TAKEN, + f"name {candidate!r} already exists", + ) + new_name = candidate + + old_name = row.name + if new_name is not None: + from octop.infra.backend.resolver import ( # noqa: PLC0415 + rewrite_named_storage_backend_refs, + ) + + assert server.app_runtime is not None + registry = server.app_runtime.agent_registry + for ref in registry.find_agents_using_storage_backend(row.name): + cfg = dict(registry.get_config(ref["agent_id"])) + cfg["backend"] = rewrite_named_storage_backend_refs( + cfg.get("backend"), + old=row.name, + new=new_name, + ) + registry.persist_harness_config(ref["agent_id"], cfg) + server.services.storage_backend_repo.update( backend_id, + name=new_name, kind=body.kind, endpoint=body.endpoint, access_key=body.access_key, @@ -143,7 +214,29 @@ async def patch_storage_backend( note=body.note, enabled=body.enabled, ) - return _row_to_dict(server.services.storage_backend_repo.get(backend_id)) + access = GroupAccess(server.services.repos) + final_name = new_name or old_name + if new_name is not None and new_name != old_name: + vis = access.visibility(RESOURCE_STORAGE_BACKEND, old_name, shared=None) + access.clear(RESOURCE_STORAGE_BACKEND, old_name) + if vis["visibility"] == "groups": + access.set_visibility( + RESOURCE_STORAGE_BACKEND, + new_name, + "groups", + vis["group_ids"], + ) + if body.visibility is not None: + _apply_storage_visibility( + server, + final_name, + visibility=body.visibility, + group_ids=body.group_ids, + ) + from octop.infra.backend.browse import release_browse_session + + await release_browse_session(backend_id) + return _row_to_dict(server.services.storage_backend_repo.get(backend_id), access=access) @admin_router.delete("/{backend_id}", status_code=204) @@ -163,6 +256,10 @@ async def delete_storage_backend( f"storage backend {row.name!r} is referenced by {len(refs)} agent(s)", details={"agents": refs}, ) + from octop.infra.backend.browse import release_browse_session + + await release_browse_session(backend_id) + GroupAccess(server.services.repos).clear(RESOURCE_STORAGE_BACKEND, row.name) server.services.storage_backend_repo.delete(backend_id) @@ -193,6 +290,25 @@ async def probe_storage_backend_config( return probe_storage_backend(row) +def _storage_browse_octop_error(exc: BaseException) -> OctopError: + from octop_harness.backends.storage_errors import classify_storage_error + + from octop.infra.backend.browse import StorageBrowseError + + if isinstance(exc, StorageBrowseError): + classified = exc.classified + else: + classified = classify_storage_error(exc) + details: dict[str, Any] = {"reason": classified.message} + if classified.message_key: + details["message_key"] = classified.message_key + return OctopError( + ErrorCode.STORAGE_BROWSE_FAILED, + classified.message, + details=details, + ) + + @admin_router.get("/{backend_id}/tree") async def list_storage_backend_tree( backend_id: int, @@ -204,12 +320,87 @@ async def list_storage_backend_tree( row = server.services.storage_backend_repo.get(backend_id) if row is None: raise OctopError(ErrorCode.NOT_FOUND, "storage backend not found") - from octop.infra.backend.browse import list_storage_backend_tree as _list_tree + from octop.infra.backend.browse import ( + StorageBrowseError, + ) + from octop.infra.backend.browse import ( + list_storage_backend_tree as _list_tree, + ) try: return await _list_tree(row, path) - except ValueError as exc: - raise OctopError(ErrorCode.WORKSPACE_OP_UNSUPPORTED, str(exc)) from exc + except (StorageBrowseError, ValueError) as exc: + raise _storage_browse_octop_error(exc) from exc + + +@admin_router.get("/{backend_id}/file") +async def read_storage_backend_file( + backend_id: int, + path: str = Query(..., min_length=1), + _: Any = Depends(require_permission("storage_backends")), + server: Any = Depends(get_server), +) -> dict[str, Any]: + """Read a UTF-8 text file for the storage browse preview pane.""" + row = server.services.storage_backend_repo.get(backend_id) + if row is None: + raise OctopError(ErrorCode.NOT_FOUND, "storage backend not found") + from octop.infra.backend.browse import StorageBrowseError, read_storage_backend_text + + try: + content = await read_storage_backend_text(row, path) + except FileNotFoundError as exc: + raise OctopError(ErrorCode.NOT_FOUND, str(exc)) from exc + except (StorageBrowseError, ValueError) as exc: + raise _storage_browse_octop_error(exc) from exc + return {"path": path, "content": content} + + +@admin_router.get("/{backend_id}/download") +async def download_storage_backend_file( + backend_id: int, + path: str = Query(..., min_length=1), + preview: bool = Query(default=False), + _: Any = Depends(require_permission("storage_backends")), + server: Any = Depends(get_server), +) -> Response: + """Return file bytes for media / document preview and download.""" + row = server.services.storage_backend_repo.get(backend_id) + if row is None: + raise OctopError(ErrorCode.NOT_FOUND, "storage backend not found") + from octop.infra.backend.browse import ( + StorageBrowseError, + ) + from octop.infra.backend.browse import ( + download_storage_backend_file as _download_file, + ) + + try: + data = await _download_file(row, path, preview=preview) + except FileNotFoundError as exc: + raise OctopError(ErrorCode.NOT_FOUND, str(exc)) from exc + except (StorageBrowseError, ValueError) as exc: + raise _storage_browse_octop_error(exc) from exc + fname = path.replace("\\", "/").rsplit("/", 1)[-1] or "download.bin" + return Response( + content=data, + media_type="application/octet-stream", + headers={"Content-Disposition": content_disposition(fname)}, + ) + + +@admin_router.delete("/{backend_id}/browse", status_code=204) +async def close_storage_backend_browse( + backend_id: int, + _: Any = Depends(require_permission("storage_backends")), + server: Any = Depends(get_server), +) -> None: + """Release the cached browse backend (Docker container + temp dir).""" + row = server.services.storage_backend_repo.get(backend_id) + if row is None: + raise OctopError(ErrorCode.NOT_FOUND, "storage backend not found") + from octop.infra.backend.browse import release_browse_session + + await release_browse_session(backend_id) @admin_router.post("/{backend_id}/test") @@ -232,8 +423,21 @@ async def test_storage_backend( @user_router.get("") async def list_storage_backends_for_user( - _: Any = Depends(current_user), + user: Any = Depends(current_user), server: Any = Depends(get_server), ) -> list[dict[str, Any]]: """Read-only storage backend list for regular users (secrets masked).""" - return [_row_to_dict(r) for r in server.services.storage_backend_repo.list_all()] + access = GroupAccess(server.services.repos) + is_admin = bool(user.is_admin) + out: list[dict[str, Any]] = [] + for row in server.services.storage_backend_repo.list_all(): + if not access.allows( + user_id=user.id, + is_admin=is_admin, + resource_type=RESOURCE_STORAGE_BACKEND, + resource_key=row.name, + is_owner=False, + ): + continue + out.append(_row_to_dict(row, access=access)) + return out diff --git a/src/octop/api/routers/subagents.py b/src/octop/api/routers/subagents.py index fe9ff99df6..a9aee0fbbb 100644 --- a/src/octop/api/routers/subagents.py +++ b/src/octop/api/routers/subagents.py @@ -28,7 +28,7 @@ from octop.api.common.agent import require_agent_owner_row from octop.api.common.workspace import require_running_workspace -from octop.api.deps import current_user, get_server +from octop.api.deps import current_user, get_server, require_permission from octop.infra.agents.subagents.catalog import SubagentDefinition from octop.infra.errors import ErrorCode, OctopError from octop.infra.utils.locale import ( @@ -173,7 +173,7 @@ async def install_subagent( body: InstallSubagentBody, request: Request, as_user: int | None = None, - user: Any = Depends(current_user), + user: Any = Depends(require_permission("subagents")), server: Any = Depends(get_server), ) -> dict[str, Any]: """Copy a bundled subagent definition into the agent workspace ``agents/`` directory. diff --git a/src/octop/api/routers/teams.py b/src/octop/api/routers/teams.py new file mode 100644 index 0000000000..b080486630 --- /dev/null +++ b/src/octop/api/routers/teams.py @@ -0,0 +1,174 @@ +"""Expert teams — create, list, update roster.""" + +from __future__ import annotations + +from typing import Any, cast + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field + +from octop.api.common.agent import assert_agent_owner +from octop.api.deps import current_user, get_server +from octop.infra.agents.manager import AgentCreateSpec +from octop.infra.agents.teams import ( + TEAM_KIND, + TEAM_TEMPLATE_NAME, + TEMPLATE_DIR, + is_team_agent, + team_icon_url, +) +from octop.infra.errors import ErrorCode, OctopError + +router = APIRouter() + + +class TeamCreateBody(BaseModel): + name: str + description: str | None = None + default_model: str | None = None + color: str | None = None + icon_name: str | None = None + icon_url: str | None = None + welcome_message: str | None = None + member_ids: list[str] = Field(default_factory=list) + config: dict[str, Any] = Field(default_factory=dict) + + +class TeamPatchBody(BaseModel): + name: str | None = None + description: str | None = None + default_model: str | None = None + color: str | None = None + icon_name: str | None = None + welcome_message: str | None = None + member_ids: list[str] | None = None + + +def _teams(server: Any) -> Any: + assert server.app_runtime is not None + return server.app_runtime.agent_registry.teams + + +def _require_owned_team(server: Any, user: Any, team_id: str) -> Any: + assert server.app_runtime is not None + row = server.app_runtime.agent_registry.get_row(team_id) + if row is None or not is_team_agent(row): + raise OctopError(ErrorCode.TEAM_NOT_FOUND, f"team {team_id!r} not found") + assert_agent_owner(row, user) + return row + + +@router.get("/teams", summary="List my expert teams") +async def list_teams( + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> list[dict[str, Any]]: + return cast(list[dict[str, Any]], _teams(server).list_for_user(user.id)) + + +@router.post("/teams", summary="Create an expert team") +async def create_team( + body: TeamCreateBody, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + teams = _teams(server) + member_ids = teams.validate_member_ids(user, body.member_ids) + assert server.app_runtime is not None + spec = AgentCreateSpec( + name=body.name.strip(), + user_id=user.id, + description=body.description, + default_model=body.default_model, + color=body.color, + icon_name=body.icon_name or "users", + icon_url=team_icon_url(body.icon_url), + welcome_message=body.welcome_message, + template_name=TEAM_TEMPLATE_NAME, + kind=TEAM_KIND, + config=body.config, + member_ids=member_ids, + mcp_servers=[], + ) + row = await server.app_runtime.agent_registry.create(spec) + return cast( + dict[str, Any], + teams.team_payload(server.app_runtime.agent_registry.get_row(row.agent_id) or row), + ) + + +@router.get("/teams/template", summary="Preview default team workspace files") +async def team_template_files( + _user: Any = Depends(current_user), +) -> list[dict[str, str]]: + files: list[dict[str, str]] = [] + if TEMPLATE_DIR.is_dir(): + for path in sorted(TEMPLATE_DIR.iterdir()): + if path.is_file() and path.suffix.lower() == ".md" and not path.name.startswith("."): + files.append( + { + "name": path.name, + "content": path.read_text(encoding="utf-8"), + } + ) + return files + + +@router.get("/teams/{team_id}", summary="Get an expert team") +async def get_team( + team_id: str, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + row = _require_owned_team(server, user, team_id) + return cast(dict[str, Any], _teams(server).team_payload(row)) + + +@router.patch("/teams/{team_id}", summary="Update an expert team") +async def patch_team( + team_id: str, + body: TeamPatchBody, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + _require_owned_team(server, user, team_id) + teams = _teams(server) + assert server.app_runtime is not None + roster_changed = False + if body.member_ids is not None: + next_ids = teams.validate_member_ids(user, body.member_ids) + teams.assert_roster_writable(team_id, next_ids) + teams.replace_members(team_id, next_ids) + roster_changed = True + updates: dict[str, Any] = {} + if body.name is not None: + updates["name"] = body.name.strip() + if body.description is not None: + updates["description"] = body.description + if body.default_model is not None: + updates["default_model"] = body.default_model + if body.color is not None: + updates["color"] = body.color + if body.icon_name is not None: + updates["icon_name"] = body.icon_name + if body.welcome_message is not None: + updates["welcome_message"] = body.welcome_message + if updates: + await server.app_runtime.agent_registry.update(team_id, **updates) + if roster_changed: + await server.app_runtime.agent_registry.reload(team_id) + row = server.app_runtime.agent_registry.get_row(team_id) + if row is None: + raise OctopError(ErrorCode.TEAM_NOT_FOUND, f"team {team_id!r} not found") + return cast(dict[str, Any], teams.team_payload(row)) + + +@router.delete("/teams/{team_id}", status_code=204, summary="Delete an expert team") +async def delete_team( + team_id: str, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> None: + _require_owned_team(server, user, team_id) + assert server.app_runtime is not None + await server.app_runtime.agent_registry.delete(team_id) diff --git a/src/octop/api/routers/text_assist.py b/src/octop/api/routers/text_assist.py new file mode 100644 index 0000000000..b4a67a2a69 --- /dev/null +++ b/src/octop/api/routers/text_assist.py @@ -0,0 +1,76 @@ +"""Unified one-shot text assist (polish / generate) for dashboard UI.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, Field + +from octop.api.common.agent import assert_agent_access +from octop.api.deps import current_user, get_server +from octop.infra.text_assist import AssistKind, require_host_models, run_text_assist +from octop.infra.utils.locale import resolve_request_locale + +router = APIRouter() + + +class TextAssistBody(BaseModel): + kind: AssistKind = Field(description="Assist scenario; selects the backend prompt.") + text: str = Field( + default="", + description="Draft to polish. Required for chat.prompt; optional for form kinds.", + ) + context: dict[str, Any] = Field( + default_factory=dict, + description="Scenario context. Form kinds require `name`.", + ) + locale: str | None = Field( + default=None, + description="Output locale (`zh` / `en`). Defaults to the request locale.", + ) + default_model: str | None = Field( + default=None, + description="Model ref override. Omit or `auto` for automatic routing.", + ) + agent_id: str | None = Field( + default=None, + description="Optional agent scope for access checks (e.g. chat polish).", + ) + + +class TextAssistResponse(BaseModel): + text: str + + +@router.post( + "/text/assist", + summary="Polish or generate short copy", + response_model=TextAssistResponse, +) +async def text_assist( + body: TextAssistBody, + request: Request, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> TextAssistResponse: + """One-shot LLM rewrite/generate for form fields and chat composer polish. + + Prompts live in backend i18n (`text_assist.kinds.*`). An explicit + ``default_model`` is forwarded to harness; omit it or pass ``auto`` and + harness selects the model. Does not touch thread history. + """ + agent_id = (body.agent_id or "").strip() + if agent_id: + assert_agent_access(server, agent_id, user) + + locale = (body.locale or "").strip() or resolve_request_locale(request) + text = await run_text_assist( + models=require_host_models(server), + kind=body.kind, + text=body.text, + context=body.context, + locale=locale, + default_model=body.default_model, + ) + return TextAssistResponse(text=text) diff --git a/src/octop/api/routers/update.py b/src/octop/api/routers/update.py index 7cbec757b8..2dcd587a47 100644 --- a/src/octop/api/routers/update.py +++ b/src/octop/api/routers/update.py @@ -9,7 +9,7 @@ import time from typing import Any -from fastapi import APIRouter, BackgroundTasks, Body, Depends, Query +from fastapi import APIRouter, BackgroundTasks, Body, Depends, Query, Request from pydantic import BaseModel, Field from octop.api.deps import current_user, get_server, require_permission @@ -22,6 +22,7 @@ update_task, ) from octop.infra.errors import ErrorCode, OctopError +from octop.infra.server import OctopServer from octop.infra.setup.self_update import ( UpgradeResult, fetch_pypi_info, @@ -32,6 +33,7 @@ is_prerelease, parse_changelog_for_version, run_upgrade, + validate_upgrade_target, ) from octop.infra.setup.service import ( ServiceRuntime, @@ -40,6 +42,7 @@ is_service_installed, restart_service, ) +from octop.infra.utils.locale import DEFAULT_LOCALE, resolve_locale, resolve_request_locale logger = logging.getLogger(__name__) @@ -56,7 +59,8 @@ class UpdateSettingsBody(BaseModel): class UpgradeBody(BaseModel): version: str | None = Field( default=None, - description="Pin this release. Defaults to the channel latest (stable when stable_only).", + description="Pin a newer release. Same/older versions are rejected before installation. " + "Defaults to the channel latest (stable when stable_only).", ) @@ -192,7 +196,7 @@ async def update_status( stable_only = _read_stable_only(server) cached = get_cached_status() if cached is not None: - return _auto_status(cached, stable_only=stable_only) + return await asyncio.to_thread(_auto_status, cached, stable_only=stable_only) return await asyncio.to_thread( _build_status, stable_only=stable_only, @@ -242,8 +246,11 @@ async def update_settings( stable_only=body.stable_only, include_prerelease=not body.stable_only, ) - remapped = _present_status( - cached, stable_only=body.stable_only, include_prerelease=not body.stable_only + remapped = await asyncio.to_thread( + _present_status, + cached, + stable_only=body.stable_only, + include_prerelease=not body.stable_only, ) cache_status(remapped) return _public_status(remapped) @@ -254,6 +261,7 @@ async def _upgrade_worker( *, allow_prerelease: bool = False, version: str | None = None, + locale: str = DEFAULT_LOCALE, ) -> None: await update_task(task_id, stage="downloading", percent=20) upgrade_task = asyncio.create_task( @@ -262,6 +270,7 @@ async def _upgrade_worker( verbose=False, allow_prerelease=allow_prerelease, version=version, + locale=locale, ) ) percent = 20 @@ -312,8 +321,9 @@ async def _upgrade_worker( ) -@router.post("/upgrade") +@router.post("/upgrade", summary="Upgrade Octop") async def trigger_upgrade( + request: Request, body: UpgradeBody = Body(default_factory=UpgradeBody), server: Any = Depends(get_server), _: Any = Depends(require_permission("update")), @@ -332,10 +342,19 @@ async def trigger_upgrade( latest_stable=info.latest_stable if info else None, stable_only=stable_only, ) + validate_upgrade_target(target, await asyncio.to_thread(get_local_version)) allow_prerelease = bool(target and is_prerelease(target)) task = await create_task() asyncio.create_task( - _upgrade_worker(task.task_id, allow_prerelease=allow_prerelease, version=target) + _upgrade_worker( + task.task_id, + allow_prerelease=allow_prerelease, + version=target, + locale=resolve_locale( + user_locale=getattr(_, "locale", None), + explicit=resolve_request_locale(request), + ), + ) ) return {"task_id": task.task_id, "status": "started"} @@ -367,8 +386,19 @@ def _is_desktop_process() -> bool: return raw in {"1", "true", "yes", "on"} -def _restart_desktop_process() -> None: +def _restart_desktop_process(server: OctopServer) -> None: time.sleep(0.4) + + # Flush SQLite WAL before replacing the process image, otherwise uncheckpointed + # writes (common in WAL mode) are lost when the connection FD is closed by execv. + if server.services is not None and getattr(server.services.db, "dialect", None) == "sqlite": + try: + with server.services.db.connect() as conn: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + server.services.db.close() + except Exception: + logger.exception("failed to checkpoint/close database before desktop restart") + argv = list(getattr(sys, "orig_argv", None) or [sys.executable, *sys.argv]) os.execv(argv[0], argv) @@ -383,10 +413,11 @@ def _restart_service_task(runtime: ServiceRuntime) -> None: @router.post("/restart") async def restart_service_endpoint( background_tasks: BackgroundTasks, + server: OctopServer = Depends(get_server), _: Any = Depends(require_permission("update")), ) -> dict[str, Any]: if _is_desktop_process(): - background_tasks.add_task(_restart_desktop_process) + background_tasks.add_task(_restart_desktop_process, server) return {"status": "restarting", "service_mode": "desktop"} mode = detect_service_mode() if mode is None: diff --git a/src/octop/api/routers/usage.py b/src/octop/api/routers/usage.py index 0197316b11..a974f2f191 100644 --- a/src/octop/api/routers/usage.py +++ b/src/octop/api/routers/usage.py @@ -24,10 +24,10 @@ from fastapi.responses import StreamingResponse from octop.api.common.content_disposition import content_disposition +from octop.api.common.usage_xlsx import build_usage_xlsx from octop.api.deps import current_user, get_server from octop.i18n import tr from octop.infra.errors import ErrorCode, OctopError -from octop.infra.usage.xlsx_export import build_usage_xlsx from octop.infra.utils.locale import normalize_locale, resolve_request_locale router = APIRouter() @@ -100,6 +100,27 @@ def _agent_name_map(server: Any) -> dict[str, str]: return {str(row.agent_id): str(row.name or row.agent_id) for row in rows} +def _label_agent_buckets(server: Any, payload: dict[str, Any]) -> dict[str, Any]: + """Replace ``by_agent`` bucket labels with expert names. + + The ledger groups by ``agent_id``. The signed-in user's agent list cannot + name experts owned by other accounts, so the admin table would show ids. + """ + if str(payload.get("granularity") or "") != "by_agent": + return payload + names = _agent_name_map(server) + labeled: list[Any] = [] + for bucket in payload.get("buckets") or []: + if not isinstance(bucket, dict): + labeled.append(bucket) + continue + name = names.get(str(bucket.get("key") or ""), "").strip() + if name: + bucket = {**bucket, "label": name} + labeled.append(bucket) + return {**payload, "buckets": labeled} + + def _username_map(server: Any) -> dict[int, str]: out: dict[int, str] = {} users = server.user_manager.list_all(include_disabled=True) @@ -202,12 +223,15 @@ async def user_summary( ) -> dict[str, Any]: user_id = _resolve_user_scope(user=user, as_user=as_user, server=server) _assert_agent_exists(server, agent_id) - return _summary_or_raise( - server=server, - user_id=user_id, - agent_id=agent_id, - window=window, - granularity=granularity, + return _label_agent_buckets( + server, + _summary_or_raise( + server=server, + user_id=user_id, + agent_id=agent_id, + window=window, + granularity=granularity, + ), ) @@ -253,12 +277,15 @@ async def admin_summary( the scope; absent both fields mean *all rows*. Admin only.""" if not user.is_admin: raise OctopError(ErrorCode.FORBIDDEN, "admin required") - return _summary_or_raise( - server=server, - user_id=user_id, - agent_id=agent_id, - window=window, - granularity=granularity, + return _label_agent_buckets( + server, + _summary_or_raise( + server=server, + user_id=user_id, + agent_id=agent_id, + window=window, + granularity=granularity, + ), ) diff --git a/src/octop/api/routers/user_groups.py b/src/octop/api/routers/user_groups.py new file mode 100644 index 0000000000..60c591033d --- /dev/null +++ b/src/octop/api/routers/user_groups.py @@ -0,0 +1,322 @@ +"""User groups. + +Membership changes require the users permission. Listing group names is also +open to people who can edit a resource that has a visibility picker. +""" + +from __future__ import annotations + +import re +import sqlite3 +from typing import Any + +from fastapi import APIRouter, Depends, File, UploadFile +from pydantic import BaseModel, Field + +from octop.api.deps import current_user, get_server, require_permission +from octop.infra.errors import ErrorCode, OctopError +from octop.infra.users.groups import empty_group_access, group_access_by_group +from octop.infra.users.identity import User +from octop.infra.users.permissions import user_has_permission +from octop.infra.users.profile_avatar import ( + avatar_response, + clean_avatar_icon, + delete_profile_avatar, + profile_avatar_url, + write_profile_avatar, +) + +router = APIRouter() + +_CATALOG_KEYS = ( + "users", + "skill_packages", + "knowledge_bases", + "connectors", + "providers", + "storage_backends", +) + +# Same shape as expert / agent ids (short Crockford ids also match). +_CUSTOM_GROUP_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{1,62}[a-zA-Z0-9]$") + + +class UserGroupBody(BaseModel): + name: str = Field(min_length=1, max_length=64) + user_group_id: str | None = Field( + default=None, + max_length=64, + description=("Public group id. Same rules as expert ids; omit to auto-assign a short id."), + ) + description: str | None = Field(default=None, max_length=500) + enabled: bool = True + avatar_icon: str | None = Field(default=None, max_length=32) + + +class UserGroupPatchBody(BaseModel): + name: str | None = Field(default=None, max_length=64) + description: str | None = Field(default=None, max_length=500) + enabled: bool | None = None + avatar_icon: str | None = Field(default=None, max_length=32) + + +class ReplaceMembersBody(BaseModel): + user_ids: list[int] = Field(default_factory=list) + + +def _clean_name(raw: str) -> str: + name = raw.strip() + if not name: + raise OctopError(ErrorCode.FORBIDDEN, "user group name is required", status=400) + return name + + +def _clean_description(raw: str | None) -> str | None: + if raw is None: + return None + text = raw.strip() + return text or None + + +def _clean_user_group_id(raw: str | None) -> str | None: + """Return a caller-supplied group id, or ``None`` when the server should assign one.""" + if raw is None: + return None + group_id = raw.strip() + if not group_id: + return None + if not _CUSTOM_GROUP_ID_RE.fullmatch(group_id): + raise OctopError( + ErrorCode.USER_GROUP_ID_INVALID, + "group id must be 3-64 chars of letters/digits/underscore/hyphen, " + "starting and ending with a letter or digit", + ) + return group_id + + +def _unique_kind(exc: BaseException) -> str | None: + """Return ``id``, ``name``, or ``None`` when *exc* is not a unique violation.""" + try: + from psycopg import errors as pg_errors + except ImportError: + pg_errors = None # type: ignore[assignment] + is_unique = False + if isinstance(exc, sqlite3.IntegrityError): + is_unique = "unique" in str(exc).lower() + elif pg_errors is not None and isinstance(exc, pg_errors.UniqueViolation): + is_unique = True + if not is_unique: + return None + msg = str(exc).lower() + if "user_group_id" in msg: + return "id" + if "name" in msg: + return "name" + return "name" + + +async def catalog_reader(user: User = Depends(current_user)) -> User: + if user.is_admin or any(user_has_permission(user, key) for key in _CATALOG_KEYS): + return user + raise OctopError(ErrorCode.FORBIDDEN, "user group catalog requires a management permission") + + +def _avatar_url(server: Any, user_group_id: str) -> str | None: + return profile_avatar_url( + server.services.paths.group_avatars_dir, + user_group_id, + f"/api/user-groups/{user_group_id}/avatar", + ) + + +def _public( + server: Any, + row: Any, + *, + member_count: int, + member_ids: list[int] | None = None, +) -> dict[str, Any]: + return { + "user_group_id": row.user_group_id, + "name": row.name, + "description": row.description, + "enabled": bool(row.enabled), + "avatar_icon": row.avatar_icon, + "avatar_url": _avatar_url(server, row.user_group_id), + "member_count": member_count, + "member_ids": member_ids or [], + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + +def _require_group(server: Any, user_group_id: str) -> Any: + row = server.services.repos.user_group_repo.get(user_group_id) + if row is None: + raise OctopError(ErrorCode.NOT_FOUND, "user group not found") + return row + + +@router.get("", summary="List user groups") +async def list_user_groups( + server: Any = Depends(get_server), + _: User = Depends(catalog_reader), +) -> list[dict[str, Any]]: + members = server.services.repos.user_group_member_repo.user_ids_by_group() + access = group_access_by_group(server.services.repos) + payloads = [] + for row in server.services.repos.user_group_repo.list_all(): + payload = _public( + server, + row, + member_count=len(members.get(row.user_group_id, [])), + member_ids=members.get(row.user_group_id, []), + ) + payload["access"] = access.get(row.user_group_id, empty_group_access()) + payloads.append(payload) + return payloads + + +@router.post("", status_code=201, summary="Create a user group") +async def create_user_group( + body: UserGroupBody, + server: Any = Depends(get_server), + _: User = Depends(require_permission("users")), +) -> dict[str, Any]: + group_id = _clean_user_group_id(body.user_group_id) + if group_id and server.services.repos.user_group_repo.get(group_id) is not None: + raise OctopError(ErrorCode.USER_GROUP_ID_TAKEN, "user group id already exists") + try: + row = server.services.repos.user_group_repo.create( + name=_clean_name(body.name), + description=_clean_description(body.description), + enabled=body.enabled, + avatar_icon=clean_avatar_icon(body.avatar_icon), + user_group_id=group_id, + ) + except Exception as exc: + kind = _unique_kind(exc) + if kind == "id": + raise OctopError(ErrorCode.USER_GROUP_ID_TAKEN, "user group id already exists") from exc + if kind == "name": + raise OctopError( + ErrorCode.FORBIDDEN, "user group name is already in use", status=409 + ) from exc + raise + return _public(server, row, member_count=0) + + +@router.patch("/{user_group_id}", summary="Rename a user group") +async def update_user_group( + user_group_id: str, + body: UserGroupPatchBody, + server: Any = Depends(get_server), + _: User = Depends(require_permission("users")), +) -> dict[str, Any]: + _require_group(server, user_group_id) + fields = body.model_fields_set + name = _clean_name(body.name) if body.name is not None else None + description = _clean_description(body.description) if "description" in fields else None + try: + server.services.repos.user_group_repo.update( + user_group_id, + name=name, + description=description, + update_description="description" in fields, + enabled=body.enabled if "enabled" in fields else None, + avatar_icon=clean_avatar_icon(body.avatar_icon) if "avatar_icon" in fields else None, + update_avatar_icon="avatar_icon" in fields, + ) + except Exception as exc: + if _unique_kind(exc) == "name": + raise OctopError( + ErrorCode.FORBIDDEN, "user group name is already in use", status=409 + ) from exc + raise + row = _require_group(server, user_group_id) + member_ids = server.services.repos.user_group_member_repo.user_ids_for_group(user_group_id) + return _public(server, row, member_count=len(member_ids), member_ids=member_ids) + + +@router.delete("/{user_group_id}", status_code=204, summary="Delete a user group") +async def delete_user_group( + user_group_id: str, + server: Any = Depends(get_server), + _: User = Depends(require_permission("users")), +) -> None: + if not server.services.repos.user_group_repo.delete(user_group_id): + raise OctopError(ErrorCode.NOT_FOUND, "user group not found") + delete_profile_avatar(server.services.paths.group_avatars_dir, user_group_id) + + +@router.get("/{user_group_id}/members", summary="List members of a user group") +async def list_members( + user_group_id: str, + server: Any = Depends(get_server), + _: User = Depends(require_permission("users")), +) -> dict[str, list[int]]: + _require_group(server, user_group_id) + return { + "user_ids": server.services.repos.user_group_member_repo.user_ids_for_group(user_group_id) + } + + +@router.put("/{user_group_id}/members", summary="Replace members of a user group") +async def replace_members( + user_group_id: str, + body: ReplaceMembersBody, + server: Any = Depends(get_server), + _: User = Depends(require_permission("users")), +) -> dict[str, list[int]]: + _require_group(server, user_group_id) + user_ids: list[int] = [] + seen: set[int] = set() + for user_id in body.user_ids: + if user_id in seen: + continue + if server.services.user_repo.get(user_id) is None: + raise OctopError(ErrorCode.NOT_FOUND, f"user {user_id} not found") + seen.add(user_id) + user_ids.append(user_id) + server.services.repos.user_group_member_repo.replace(user_group_id, user_ids) + return {"user_ids": user_ids} + + +@router.post("/{user_group_id}/avatar", status_code=201, summary="Upload a user group portrait") +async def upload_group_avatar( + user_group_id: str, + file: UploadFile = File(...), # noqa: B008 + server: Any = Depends(get_server), + _: User = Depends(require_permission("users")), +) -> dict[str, str | None]: + _require_group(server, user_group_id) + write_profile_avatar( + server.services.paths.group_avatars_dir, + user_group_id, + await file.read(), + ) + return {"avatar_url": _avatar_url(server, user_group_id)} + + +@router.get("/{user_group_id}/avatar", summary="Read a user group portrait") +async def get_group_avatar( + user_group_id: str, + server: Any = Depends(get_server), + _: User = Depends(catalog_reader), +) -> Any: + _require_group(server, user_group_id) + return avatar_response(server.services.paths.group_avatars_dir, user_group_id) + + +@router.delete( + "/{user_group_id}/avatar", + status_code=204, + summary="Remove a user group portrait", +) +async def delete_group_avatar( + user_group_id: str, + server: Any = Depends(get_server), + _: User = Depends(require_permission("users")), +) -> None: + _require_group(server, user_group_id) + delete_profile_avatar(server.services.paths.group_avatars_dir, user_group_id) diff --git a/src/octop/api/routers/user_roles.py b/src/octop/api/routers/user_roles.py new file mode 100644 index 0000000000..10b6d0febf --- /dev/null +++ b/src/octop/api/routers/user_roles.py @@ -0,0 +1,399 @@ +"""Admin CRUD for role templates. + +A role stores permission keys and a resource-policy array. Missing policy +names are not enabled. Applying a role copies those defaults onto a user. +Invites store only the role name and read the role when the account is created. +""" + +from __future__ import annotations + +import re +from typing import Any + +from fastapi import APIRouter, Depends, File, UploadFile +from pydantic import BaseModel, Field + +from octop.api.deps import get_server, require_permission +from octop.api.routers.users import _assert_can_assign +from octop.infra.db.repos.user_roles import ( + BUILTIN_USER_ROLE_IDS, + UserRoleRepo, + UserRoleRow, +) +from octop.infra.errors import ErrorCode, OctopError +from octop.infra.users.identity import User +from octop.infra.users.permissions import ALL_PERMISSION_KEYS, validate_permission_keys +from octop.infra.users.resource_policy import ( + POLICY_MAX_AGENTS, + POLICY_TOKEN_QUOTA, + POLICY_WORKSPACE_ROOT_DIR, + max_agents_of, + normalize_max_agents, + normalize_token_quota, + normalize_workspace_root_dir, + token_quota_of, + workspace_root_dir_of, +) + +router = APIRouter() + +_KNOWN_POLICIES = { + POLICY_WORKSPACE_ROOT_DIR, + POLICY_TOKEN_QUOTA, + POLICY_MAX_AGENTS, +} + + +class PolicyItem(BaseModel): + name: str + value: str + + +class UserRoleBody(BaseModel): + user_role_name: str = Field(min_length=1, max_length=64) + user_role_id: str | None = Field( + default=None, + max_length=64, + description=( + "Public role id. 1-64 letters or underscores; cannot start or end " + "with an underscore. Omit to let the server assign one." + ), + ) + description: str | None = Field(default=None, max_length=500) + permissions: list[str] = Field(default_factory=list) + policies: list[PolicyItem] = Field(default_factory=list) + + +class UserRolePatchBody(BaseModel): + user_role_name: str | None = Field(default=None, max_length=64) + description: str | None = Field(default=None, max_length=500) + permissions: list[str] | None = None + policies: list[PolicyItem] | None = None + avatar_icon: str | None = Field(default=None, max_length=32) + + +def _repo(server: Any) -> UserRoleRepo: + return UserRoleRepo(server.services.db) + + +def _public(server: Any, row: UserRoleRow) -> dict[str, Any]: + from octop.infra.users.profile_avatar import profile_avatar_url + + return public_role( + row, + avatar_url=profile_avatar_url( + server.services.paths.role_avatars_dir, + row.user_role_id, + f"/api/users/roles/{row.user_role_id}/avatar", + ), + ) + + +_ROLE_ID_RE = re.compile(r"^[A-Za-z_]+$") +_ROLE_ID_MAX = 64 + + +def _clean_user_role_id(raw: str | None) -> str | None: + """Return a caller-supplied role id, or ``None`` when the server should assign one.""" + if raw is None: + return None + role_id = raw.strip() + if not role_id: + return None + if ( + len(role_id) > _ROLE_ID_MAX + or not _ROLE_ID_RE.fullmatch(role_id) + or role_id.startswith("_") + or role_id.endswith("_") + ): + raise OctopError( + ErrorCode.USER_ROLE_ID_INVALID, + "role id must be 1-64 letters or underscores, and cannot start or end with an underscore", + ) + return role_id + + +def _clean_name(raw: str) -> str: + name = raw.strip() + if not name or len(name) > 64: + raise OctopError(ErrorCode.FORBIDDEN, "role name must be 1-64 characters", status=400) + return name + + +def _clean_description(raw: str | None) -> str | None: + if raw is None: + return None + return raw.strip() or None + + +def _policies_from_fields( + *, + workspace_root_dir: str | None, + token_quota: int | None, + max_agents: int | None, +) -> list[tuple[str, str]]: + items: list[tuple[str, str]] = [] + root = normalize_workspace_root_dir(workspace_root_dir) + if root: + items.append((POLICY_WORKSPACE_ROOT_DIR, root)) + quota = normalize_token_quota(token_quota) + if quota is not None: + items.append((POLICY_TOKEN_QUOTA, str(quota))) + agents = normalize_max_agents(max_agents) + if agents is not None: + items.append((POLICY_MAX_AGENTS, str(agents))) + return items + + +def _policies_from_items(items: list[PolicyItem]) -> list[tuple[str, str]]: + unknown = sorted({item.name for item in items if item.name not in _KNOWN_POLICIES}) + if unknown: + raise OctopError( + ErrorCode.FORBIDDEN, + f"unknown policy names: {unknown}", + status=400, + ) + by_name = {item.name: item.value for item in items} + return _policies_from_fields( + workspace_root_dir=by_name.get(POLICY_WORKSPACE_ROOT_DIR), + token_quota=token_quota_of(by_name.get(POLICY_TOKEN_QUOTA)), + max_agents=max_agents_of(by_name.get(POLICY_MAX_AGENTS)), + ) + + +def _policies_for_write(body: UserRoleBody | UserRolePatchBody) -> list[tuple[str, str]] | None: + """Persist policies only from the ``policies`` array. + + Omitted names stay disabled. A patch that leaves ``policies`` out keeps + the stored array, so a name-only or permission-only update cannot clear + the other policies. + """ + if body.policies is None: + return None + return _policies_from_items(body.policies) + + +def public_role(row: UserRoleRow, *, avatar_url: str | None = None) -> dict[str, Any]: + if row.is_admin: + permissions = sorted(ALL_PERMISSION_KEYS) + system_role = "admin" + policies: list[dict[str, str]] = [] + workspace_root_dir = None + token_quota = None + max_agents = None + else: + permissions = list(row.permissions) + system_role = "user" + policies = [{"name": name, "value": value} for name, value in row.policies] + workspace_root_dir = workspace_root_dir_of(row.policy_value(POLICY_WORKSPACE_ROOT_DIR)) + token_quota = token_quota_of(row.policy_value(POLICY_TOKEN_QUOTA)) + max_agents = max_agents_of(row.policy_value(POLICY_MAX_AGENTS)) + return { + "user_role_id": row.user_role_id, + "user_role_name": row.user_role_name, + "description": row.description, + "system_role": system_role, + "permissions": permissions, + "policies": policies, + "deletable": row.user_role_id not in BUILTIN_USER_ROLE_IDS, + "immutable": row.is_admin, + "workspace_root_dir": workspace_root_dir, + "token_quota": token_quota, + "max_agents": max_agents, + "created_at": row.created_at, + "updated_at": row.updated_at, + "avatar_icon": row.avatar_icon, + "avatar_url": avatar_url, + } + + +def _name_taken(exc: ValueError) -> None: + if str(exc) == "id_taken": + raise OctopError( + ErrorCode.USER_ROLE_ID_TAKEN, "role id already exists", status=409 + ) from exc + if str(exc) != "name_taken": + raise exc + raise OctopError(ErrorCode.FORBIDDEN, "role name already exists", status=409) from exc + + +@router.get("", summary="List role templates") +async def list_user_roles( + _: Any = Depends(require_permission("users")), + server: Any = Depends(get_server), +) -> list[dict[str, Any]]: + return [_public(server, row) for row in _repo(server).list_all()] + + +@router.post("", status_code=201, summary="Create a role template") +async def create_user_role( + body: UserRoleBody, + actor: User = Depends(require_permission("users")), + server: Any = Depends(get_server), +) -> dict[str, Any]: + try: + permissions = validate_permission_keys(body.permissions) + except ValueError as exc: + raise OctopError(ErrorCode.FORBIDDEN, str(exc), status=400) from exc + _assert_can_assign(actor, permissions) + policies = _policies_for_write(body) or [] + try: + row = _repo(server).create( + user_role_name=_clean_name(body.user_role_name), + description=_clean_description(body.description), + permissions=permissions, + policies=policies, + user_role_id=_clean_user_role_id(body.user_role_id), + ) + except ValueError as exc: + _name_taken(exc) + raise + server.services.audit_repo.write( + actor=actor.username, + action="user_role.create", + target=row.user_role_name, + ) + return _public(server, row) + + +@router.patch("/{user_role_id}", summary="Update a role template") +async def patch_user_role( + user_role_id: str, + body: UserRolePatchBody, + actor: User = Depends(require_permission("users")), + server: Any = Depends(get_server), +) -> dict[str, Any]: + current = _repo(server).get(user_role_id) + if current is None: + raise OctopError(ErrorCode.NOT_FOUND, "role not found") + name = _clean_name(body.user_role_name) if body.user_role_name is not None else None + description: str | None | object = ... + if "description" in body.model_fields_set: + description = _clean_description(body.description) + avatar_icon: str | None | object = ... + if "avatar_icon" in body.model_fields_set: + from octop.infra.users.profile_avatar import clean_avatar_icon, delete_profile_avatar + + avatar_icon = clean_avatar_icon(body.avatar_icon) + delete_profile_avatar(server.services.paths.role_avatars_dir, user_role_id) + if current.is_admin: + if name is None and description is ... and avatar_icon is ...: + return _public(server, current) + try: + updated = _repo(server).update( + user_role_id, + user_role_name=name, + description=description, + avatar_icon=avatar_icon, + ) + except ValueError as exc: + _name_taken(exc) + raise + assert updated is not None + server.services.audit_repo.write( + actor=actor.username, + action="user_role.update", + target=updated.user_role_name, + ) + return _public(server, updated) + + permissions: list[str] | None = None + if body.permissions is not None: + try: + permissions = validate_permission_keys(body.permissions) + except ValueError as exc: + raise OctopError(ErrorCode.FORBIDDEN, str(exc), status=400) from exc + _assert_can_assign(actor, permissions) + try: + updated = _repo(server).update( + user_role_id, + user_role_name=name, + description=description, + permissions=permissions, + policies=_policies_for_write(body), + avatar_icon=avatar_icon, + ) + except ValueError as exc: + _name_taken(exc) + raise + assert updated is not None + server.services.audit_repo.write( + actor=actor.username, + action="user_role.update", + target=updated.user_role_name, + ) + return _public(server, updated) + + +@router.delete("/{user_role_id}", status_code=204, summary="Delete a role template") +async def delete_user_role( + user_role_id: str, + actor: User = Depends(require_permission("users")), + server: Any = Depends(get_server), +) -> None: + current = _repo(server).get(user_role_id) + if current is None: + raise OctopError(ErrorCode.NOT_FOUND, "role not found") + if current.user_role_id in BUILTIN_USER_ROLE_IDS: + raise OctopError(ErrorCode.FORBIDDEN, "built-in role cannot be deleted") + in_use = server.services.user_repo.count_by_role(user_role_id) + if in_use > 0: + raise OctopError( + ErrorCode.USER_ROLE_IN_USE, + f"role is assigned to {in_use} user(s); reassign them first", + ) + _repo(server).delete(user_role_id) + from octop.infra.users.profile_avatar import delete_profile_avatar + + delete_profile_avatar(server.services.paths.role_avatars_dir, user_role_id) + server.services.audit_repo.write( + actor=actor.username, + action="user_role.delete", + target=current.user_role_name, + ) + + +@router.post("/{user_role_id}/avatar", status_code=201) +async def upload_role_avatar( + user_role_id: str, + file: UploadFile = File(...), # noqa: B008 + _: Any = Depends(require_permission("users")), + server: Any = Depends(get_server), +) -> dict[str, str | None]: + row = _repo(server).get(user_role_id) + if row is None: + raise OctopError(ErrorCode.NOT_FOUND, "role not found") + from octop.infra.users.profile_avatar import write_profile_avatar + + write_profile_avatar( + server.services.paths.role_avatars_dir, + user_role_id, + await file.read(), + ) + return {"avatar_url": _public(server, row)["avatar_url"]} + + +@router.get("/{user_role_id}/avatar") +async def get_role_avatar( + user_role_id: str, + _: Any = Depends(require_permission("users")), + server: Any = Depends(get_server), +) -> Any: + from octop.infra.users.profile_avatar import avatar_response + + if _repo(server).get(user_role_id) is None: + raise OctopError(ErrorCode.NOT_FOUND, "role not found") + return avatar_response(server.services.paths.role_avatars_dir, user_role_id) + + +@router.delete("/{user_role_id}/avatar", status_code=204) +async def delete_role_avatar( + user_role_id: str, + _: Any = Depends(require_permission("users")), + server: Any = Depends(get_server), +) -> None: + if _repo(server).get(user_role_id) is None: + raise OctopError(ErrorCode.NOT_FOUND, "role not found") + from octop.infra.users.profile_avatar import delete_profile_avatar + + delete_profile_avatar(server.services.paths.role_avatars_dir, user_role_id) diff --git a/src/octop/api/routers/users.py b/src/octop/api/routers/users.py index a335e3eb4e..65688dc145 100644 --- a/src/octop/api/routers/users.py +++ b/src/octop/api/routers/users.py @@ -3,16 +3,17 @@ from __future__ import annotations import time -from typing import Any +from typing import Any, Literal -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, File, Request, UploadFile from pydantic import BaseModel, Field from octop.api.deps import current_user, get_server, require_permission from octop.infra.errors import ErrorCode, OctopError from octop.infra.users.identity import Role, User -from octop.infra.users.permissions import PERMISSIONS +from octop.infra.users.permissions import PERMISSIONS, effective_permissions from octop.infra.users.resource_policy import ( + normalize_max_agents, normalize_token_quota, normalize_workspace_root_dir, public_policy_fields, @@ -21,33 +22,75 @@ router = APIRouter() +UserBatchAction = Literal["enable", "disable", "delete", "set_token_quota", "set_max_agents"] + class UserCreateBody(BaseModel): username: str = Field(min_length=1, max_length=64) password: str = Field(min_length=1, max_length=200) - role: str = "user" + role: str = Field( + default="user", + description="Role-template public id (admin | user | custom ULID).", + ) display_name: str | None = None email: str | None = Field(default=None, max_length=254) permissions: list[str] = Field(default_factory=list) + role_name: str | None = Field(default=None, max_length=64) workspace_root_dir: str | None = None token_quota: int | None = Field(default=None, ge=0) + max_agents: int | None = Field(default=None, ge=0) class UserPatchBody(BaseModel): - role: str | None = None + role: str | None = Field( + default=None, + description="Role-template public id (admin | user | custom ULID).", + ) display_name: str | None = None email: str | None = Field(default=None, max_length=254) disabled: bool | None = None permissions: list[str] | None = None + role_name: str | None = Field(default=None, max_length=64) + avatar_icon: str | None = Field(default=None, max_length=32) workspace_root_dir: str | None = None token_quota: int | None = Field(default=None, ge=0) + max_agents: int | None = Field(default=None, ge=0) class ResetPasswordBody(BaseModel): new_password: str = Field(min_length=1, max_length=200) -def _row_to_dict(r: Any, policy: Any | None = None) -> dict[str, Any]: +class UserBatchBody(BaseModel): + user_ids: list[int] = Field(min_length=1, max_length=500) + action: UserBatchAction + token_quota: int | None = Field( + default=None, + ge=0, + description="Used by set_token_quota. null clears the limit (unlimited).", + ) + max_agents: int | None = Field( + default=None, + ge=0, + description="Used by set_max_agents. null clears the limit (unlimited).", + ) + + +class UserBatchItemResult(BaseModel): + user_id: int + ok: bool + error: str | None = None + code: str | None = None + + +class UserBatchResponse(BaseModel): + action: UserBatchAction + results: list[UserBatchItemResult] + succeeded: int + failed: int + + +def _row_to_dict(r: Any, policy: Any | None = None, server: Any = None) -> dict[str, Any]: now = int(time.time()) locked_until = int(getattr(r, "login_locked_until", 0) or 0) locked = locked_until > now and not bool(r.disabled) @@ -67,16 +110,93 @@ def _row_to_dict(r: Any, policy: Any | None = None) -> dict[str, Any]: "login_retry_after_seconds": retry_after, "created_at": int(r.created_at), "permissions": list(getattr(r, "permissions", None) or []), + "role_name": getattr(r, "role_name", None) or None, + "avatar_icon": getattr(r, "avatar_icon", None) or None, + "avatar_url": _user_avatar_url(server, int(r.id)) if server is not None else None, **public_policy_fields(policy), } +def _user_avatar_url(server: Any, user_id: int) -> str | None: + from octop.infra.users.profile_avatar import profile_avatar_url + + return profile_avatar_url( + server.services.paths.user_avatars_dir, + str(user_id), + f"/api/users/{user_id}/avatar", + ) + + +def _require_admin_to_grant_admin(actor: Any, role_id: str) -> None: + if role_id != Role.ADMIN: + return + if not bool(getattr(actor, "is_admin", False)): + raise OctopError( + ErrorCode.FORBIDDEN, + "only an administrator can assign the administrator account", + ) + + +def _resolve_role_template( + server: Any, role_id: str +) -> tuple[str, str, list[str], list[tuple[str, str]]]: + """Return ``(role_id, role_name, permissions, policies)`` for a template id.""" + from octop.infra.db.repos.user_roles import UserRoleRepo + from octop.infra.users.permissions import PERMISSIONS + + cleaned = (role_id or "").strip() + if not cleaned: + raise OctopError(ErrorCode.FORBIDDEN, "role is required", status=400) + role = UserRoleRepo(server.services.db).get(cleaned) + if role is None: + raise OctopError(ErrorCode.NOT_FOUND, "role not found") + if role.is_admin: + return role.user_role_id, role.user_role_name, [], [] + permissions = [key for key in role.permissions if key in PERMISSIONS] + return role.user_role_id, role.user_role_name, permissions, list(role.policies) + + +def _policy_kwargs_replace_from_pairs(pairs: list[tuple[str, str]]) -> dict[str, Any]: + """Full replace: every known policy is set or cleared. + + Used when applying a role template so limits from a previous role cannot + linger after a switch (including to admin, which has no resource limits). + """ + from octop.infra.users.resource_policy import ( + POLICY_MAX_AGENTS, + POLICY_TOKEN_QUOTA, + POLICY_WORKSPACE_ROOT_DIR, + max_agents_of, + token_quota_of, + ) + + by_name = dict(pairs) + return { + "workspace_root_dir": by_name.get(POLICY_WORKSPACE_ROOT_DIR), + "token_quota": token_quota_of(by_name.get(POLICY_TOKEN_QUOTA)), + "max_agents": max_agents_of(by_name.get(POLICY_MAX_AGENTS)), + } + + +def _clean_role_name(raw: str | None) -> str | None: + if raw is None: + return None + name = raw.strip() + if not name: + return None + if len(name) > 64: + raise OctopError(ErrorCode.FORBIDDEN, "role name must be 1-64 characters", status=400) + return name + + def _policy_kwargs_from_body(body: UserCreateBody | UserPatchBody) -> dict[str, Any]: policy_kwargs: dict[str, Any] = {} if "workspace_root_dir" in body.model_fields_set: policy_kwargs["workspace_root_dir"] = body.workspace_root_dir if "token_quota" in body.model_fields_set: policy_kwargs["token_quota"] = body.token_quota + if "max_agents" in body.model_fields_set: + policy_kwargs["max_agents"] = body.max_agents return policy_kwargs @@ -153,7 +273,7 @@ async def list_users( ) -> list[dict[str, Any]]: rows = server.user_manager.list_all(include_disabled=True) policy_map = server.services.user_policy_repo.list_by_user_ids([r.id for r in rows]) - return [_row_to_dict(r, policy_map.get(r.id)) for r in rows] + return [_row_to_dict(r, policy_map.get(r.id), server) for r in rows] @router.post("", status_code=201) @@ -162,26 +282,151 @@ async def create_user( actor: Any = Depends(require_permission("users")), server: Any = Depends(get_server), ) -> dict[str, Any]: - _assert_can_assign(actor, body.permissions) - policy_kwargs = _policy_kwargs_from_body(body) + role_id, template_name, template_perms, template_policies = _resolve_role_template( + server, body.role + ) + _require_admin_to_grant_admin(actor, role_id) + if role_id == Role.ADMIN: + permissions: list[str] = [] + elif "permissions" in body.model_fields_set: + permissions = list(body.permissions) + else: + permissions = list(template_perms) + _assert_can_assign(actor, permissions) + # Template is a full replace; request body fields overlay on top. + policy_kwargs = _policy_kwargs_replace_from_pairs(template_policies) + policy_kwargs.update(_policy_kwargs_from_body(body)) if "workspace_root_dir" in policy_kwargs: normalize_workspace_root_dir(policy_kwargs["workspace_root_dir"]) if "token_quota" in policy_kwargs: normalize_token_quota(policy_kwargs["token_quota"]) - role = Role(body.role) + if "max_agents" in policy_kwargs: + normalize_max_agents(policy_kwargs["max_agents"]) + role_name = ( + _clean_role_name(body.role_name) if "role_name" in body.model_fields_set else template_name + ) user = await server.user_manager.create( username=body.username, password=body.password, - role=role, + role=role_id, display_name=body.display_name, email=body.email, - permissions=body.permissions, + permissions=permissions, + role_name=role_name, ) - if policy_kwargs: - await server.user_manager.set_resource_policy(user.username, **policy_kwargs) + await server.user_manager.set_resource_policy(user.username, **policy_kwargs) row = server.user_manager.get_row(user.id) assert row is not None - return _row_to_dict(row, server.services.user_policy_repo.list_for_user(row.id)) + return _row_to_dict(row, server.services.user_policy_repo.list_for_user(row.id), server) + + +def _batch_fail(user_id: int, *, code: str, error: str) -> UserBatchItemResult: + return UserBatchItemResult(user_id=user_id, ok=False, code=code, error=error) + + +async def _batch_apply_one( + *, + server: Any, + actor: User, + user_id: int, + action: UserBatchAction, + token_quota: int | None, + max_agents: int | None, +) -> UserBatchItemResult: + if action in ("disable", "delete") and user_id == actor.id: + return _batch_fail( + user_id, + code=ErrorCode.FORBIDDEN.value, + error="cannot apply this action to yourself", + ) + row = server.user_manager.get_row(user_id) + if row is None: + return _batch_fail( + user_id, + code=ErrorCode.NOT_FOUND.value, + error="user not found", + ) + try: + if action == "enable": + await server.user_manager.enable(row.username) + elif action == "disable": + await server.user_manager.disable(row.username) + elif action == "delete": + registry = getattr(getattr(server, "app_runtime", None), "agent_registry", None) + await server.user_manager.remove(row.username, agent_manager=registry) + elif action == "set_token_quota": + await server.user_manager.set_resource_policy( + row.username, + token_quota=token_quota, + ) + elif action == "set_max_agents": + await server.user_manager.set_resource_policy( + row.username, + max_agents=max_agents, + ) + else: + return _batch_fail( + user_id, + code=ErrorCode.SLASH_BAD_ARGS.value, + error=f"unknown action: {action}", + ) + except OctopError as exc: + return _batch_fail( + user_id, + code=exc.code.value, + error=exc.message, + ) + return UserBatchItemResult(user_id=user_id, ok=True) + + +@router.post( + "/batch", + summary="Batch enable, disable, delete, or set resource policies", + description=( + "Apply one action to many users. Per-user failures do not abort the batch. " + "Disable and delete skip the current actor. For set_token_quota / set_max_agents, " + "a null value clears the limit." + ), + response_model=UserBatchResponse, +) +async def batch_users( + body: UserBatchBody, + actor: Any = Depends(require_permission("users")), + server: Any = Depends(get_server), +) -> UserBatchResponse: + if body.action == "set_token_quota": + normalize_token_quota(body.token_quota) + if body.action == "set_max_agents": + normalize_max_agents(body.max_agents) + + # Preserve order while dropping duplicates. + seen: set[int] = set() + user_ids: list[int] = [] + for uid in body.user_ids: + if uid in seen: + continue + seen.add(uid) + user_ids.append(uid) + + results: list[UserBatchItemResult] = [] + for user_id in user_ids: + results.append( + await _batch_apply_one( + server=server, + actor=actor, + user_id=user_id, + action=body.action, + token_quota=body.token_quota, + max_agents=body.max_agents, + ) + ) + succeeded = sum(1 for item in results if item.ok) + return UserBatchResponse( + action=body.action, + results=results, + succeeded=succeeded, + failed=len(results) - succeeded, + ) @router.get("/{user_id}") @@ -193,7 +438,7 @@ async def get_user( row = server.user_manager.get_row(user_id) if row is None: raise OctopError(ErrorCode.NOT_FOUND, "user not found") - return _row_to_dict(row, server.services.user_policy_repo.list_for_user(row.id)) + return _row_to_dict(row, server.services.user_policy_repo.list_for_user(row.id), server) @router.patch("/{user_id}") @@ -208,16 +453,37 @@ async def patch_user( raise OctopError(ErrorCode.NOT_FOUND, "user not found") if body.permissions is not None: _assert_can_assign(actor, body.permissions) - _assert_not_last_user_manager( - server, - actor=actor, - target_user_id=user_id, - new_permissions=body.permissions, + # An administrator account keeps full access. The form stores that as + # an empty permission list, which is not a removal of user management. + stays_admin = ( + body.role == Role.ADMIN if body.role is not None else str(row.role) == Role.ADMIN ) + if not stays_admin: + _assert_not_last_user_manager( + server, + actor=actor, + target_user_id=user_id, + new_permissions=body.permissions, + ) if body.role is not None: - if user_id == actor.id and Role(body.role) is not Role.ADMIN: + role_id, template_name, template_perms, template_policies = _resolve_role_template( + server, body.role + ) + if user_id == actor.id and role_id != Role.ADMIN: raise OctopError(ErrorCode.FORBIDDEN, "cannot demote yourself") - await server.user_manager.set_role(row.username, Role(body.role)) + _require_admin_to_grant_admin(actor, role_id) + await server.user_manager.set_role(row.username, role_id) + if "role_name" not in body.model_fields_set: + server.services.user_repo.set_role_name(user_id, template_name) + if body.permissions is None: + await server.user_manager.set_permissions( + row.username, + [] if role_id == Role.ADMIN else template_perms, + ) + # Always replace policies from the new template; body fields overlay below. + await server.user_manager.set_resource_policy( + row.username, **_policy_kwargs_replace_from_pairs(template_policies) + ) if body.display_name is not None: await server.user_manager.set_display_name(row.username, body.display_name) if "email" in body.model_fields_set: @@ -228,6 +494,13 @@ async def patch_user( await server.user_manager.enable(row.username) if body.permissions is not None: await server.user_manager.set_permissions(row.username, body.permissions) + if "role_name" in body.model_fields_set: + server.services.user_repo.set_role_name(user_id, _clean_role_name(body.role_name)) + if "avatar_icon" in body.model_fields_set: + from octop.infra.users.profile_avatar import clean_avatar_icon, delete_profile_avatar + + server.services.user_repo.set_avatar_icon(user_id, clean_avatar_icon(body.avatar_icon)) + delete_profile_avatar(server.services.paths.user_avatars_dir, str(user_id)) policy_kwargs = _policy_kwargs_from_body(body) if policy_kwargs: await server.user_manager.set_resource_policy(row.username, **policy_kwargs) @@ -236,6 +509,7 @@ async def patch_user( return _row_to_dict( updated, server.services.user_policy_repo.list_for_user(updated.id), + server, ) @@ -276,4 +550,52 @@ async def delete_user( row = server.user_manager.get_row(user_id) if row is None: raise OctopError(ErrorCode.NOT_FOUND, "user not found") - await server.user_manager.remove(row.username) + registry = getattr(getattr(server, "app_runtime", None), "agent_registry", None) + await server.user_manager.remove(row.username, agent_manager=registry) + + +@router.post("/{user_id}/avatar", status_code=201) +async def upload_user_avatar( + user_id: int, + file: UploadFile = File(...), # noqa: B008 + _: Any = Depends(require_permission("users")), + server: Any = Depends(get_server), +) -> dict[str, str | None]: + if server.user_manager.get_row(user_id) is None: + raise OctopError(ErrorCode.NOT_FOUND, "user not found") + from octop.infra.users.profile_avatar import write_profile_avatar + + write_profile_avatar( + server.services.paths.user_avatars_dir, + str(user_id), + await file.read(), + ) + return {"avatar_url": _user_avatar_url(server, user_id)} + + +@router.get("/{user_id}/avatar") +async def get_user_avatar( + user_id: int, + actor: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> Any: + if actor.id != user_id and "users" not in effective_permissions(actor): + raise OctopError(ErrorCode.FORBIDDEN, "not allowed to read this avatar") + from octop.infra.users.profile_avatar import avatar_response + + if server.user_manager.get_row(user_id) is None: + raise OctopError(ErrorCode.NOT_FOUND, "user not found") + return avatar_response(server.services.paths.user_avatars_dir, str(user_id)) + + +@router.delete("/{user_id}/avatar", status_code=204) +async def delete_user_avatar( + user_id: int, + _: Any = Depends(require_permission("users")), + server: Any = Depends(get_server), +) -> None: + if server.user_manager.get_row(user_id) is None: + raise OctopError(ErrorCode.NOT_FOUND, "user not found") + from octop.infra.users.profile_avatar import delete_profile_avatar + + delete_profile_avatar(server.services.paths.user_avatars_dir, str(user_id)) diff --git a/src/octop/api/routers/voice.py b/src/octop/api/routers/voice.py index a6a811caf3..d0095914aa 100644 --- a/src/octop/api/routers/voice.py +++ b/src/octop/api/routers/voice.py @@ -2,16 +2,37 @@ from __future__ import annotations +import asyncio +import contextlib +import json from collections.abc import AsyncIterator from typing import Any, Literal -from fastapi import APIRouter, Depends, File, Form, Request, UploadFile +from fastapi import ( + APIRouter, + Depends, + File, + Form, + Query, + Request, + UploadFile, + WebSocket, + WebSocketDisconnect, +) from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field - -from octop.api.deps import current_user, get_server, require_permission +from starlette.websockets import WebSocketState + +from octop.api.deps import ( + current_user, + get_server, + require_permission, + resolve_user_from_token, +) +from octop.i18n.domains.voice import realtime_error_message from octop.infra.errors import ErrorCode, OctopError -from octop.infra.utils.locale import resolve_request_locale +from octop.infra.utils.locale import Locale, normalize_locale, resolve_request_locale +from octop.infra.voice import realtime from octop.infra.voice.manager import VoiceManager from octop.infra.voice.presets import load_voice_presets @@ -26,6 +47,17 @@ def _voice_manager(server: Any) -> VoiceManager: ) +def _active_payload(mgr: VoiceManager) -> dict[str, Any]: + """Active provider names plus whether STT can stream. + + The dashboard needs ``stt_realtime`` synchronously inside a user gesture to + decide between streaming and the one-shot upload path. + """ + active: dict[str, Any] = dict(mgr.get_active()) + active["stt_realtime"] = mgr.realtime_stt_config() is not None + return active + + def _row_to_dict(r: Any) -> dict[str, Any]: return { "id": r.id, @@ -97,8 +129,8 @@ async def list_voice_providers( async def get_active_voice( _: Any = Depends(current_user), server: Any = Depends(get_server), -) -> dict[str, str]: - return _voice_manager(server).get_active() +) -> dict[str, Any]: + return _active_payload(_voice_manager(server)) @router.put("/active") @@ -106,8 +138,10 @@ async def set_active_voice( body: ActiveVoiceBody, _: Any = Depends(require_permission("voice")), server: Any = Depends(get_server), -) -> dict[str, str]: - return _voice_manager(server).set_active(stt=body.stt, tts=body.tts) +) -> dict[str, Any]: + mgr = _voice_manager(server) + mgr.set_active(stt=body.stt, tts=body.tts) + return _active_payload(mgr) @router.post("/stt") @@ -152,6 +186,152 @@ async def _stream() -> AsyncIterator[bytes]: return StreamingResponse(_stream(), media_type=mgr.media_type(body.provider)) +async def _ws_send_json(websocket: WebSocket, payload: dict[str, Any]) -> None: + if websocket.application_state != WebSocketState.CONNECTED: + return + with contextlib.suppress(WebSocketDisconnect, RuntimeError): + await websocket.send_text(json.dumps(payload, ensure_ascii=False)) + + +async def _ws_error( + websocket: WebSocket, + code: ErrorCode, + locale: Locale, + *, + tencent_code: int | None = None, +) -> None: + message = ( + realtime_error_message(tencent_code, locale) + if tencent_code is not None + else OctopError(code, "").localized_message(locale) + ) + await _ws_send_json( + websocket, + { + "type": "error", + "code": code.value, + "message": message, + "tencent_code": tencent_code, + }, + ) + + +def _is_end_frame(raw: str) -> bool: + try: + control = json.loads(raw) + except json.JSONDecodeError: + return False + return isinstance(control, dict) and control.get("type") == "end" + + +async def _pump_events( + session: realtime.TencentRealtimeSession, websocket: WebSocket, locale: Locale +) -> None: + """Forward upstream recognition events to the browser until the stream ends.""" + async for event in session.events(): + if event.kind == "error": + await _ws_error( + websocket, + ErrorCode.VOICE_REALTIME_UNAVAILABLE, + locale, + tencent_code=event.error_code, + ) + await _ws_send_json(websocket, {"type": "done"}) + return + + if event.kind == "done": + await _ws_send_json(websocket, {"type": "done"}) + return + + await _ws_send_json( + websocket, + { + "type": event.kind, + "text": event.text, + "sentence_id": event.sentence_id, + }, + ) + + +@router.websocket("/stt-stream") +async def stream_transcription( + websocket: WebSocket, + token: str | None = Query(default=None), + locale: str | None = Query(default=None), +) -> None: + """Proxy one realtime ASR session to Tencent Cloud over WebSocket. + + The browser sends 16 kHz PCM16 mono frames as binary messages and + ``{"type": "end"}`` when it is done; the server replies with ``ready`` / + ``interim`` / ``final`` / ``done`` / ``error`` JSON messages. + """ + server = websocket.app.state.octop_server + if not token: + await websocket.close(code=4001, reason="missing token") + return + try: + resolve_user_from_token(server, token) + except OctopError as exc: + await websocket.close(code=4001, reason=f"auth failed: {exc.code.value}") + return + + await websocket.accept() + lang = normalize_locale(locale) if locale else resolve_request_locale(websocket) + + config = _voice_manager(server).realtime_stt_config() + if config is None: + await _ws_error(websocket, ErrorCode.VOICE_REALTIME_UNAVAILABLE, lang) + await websocket.close(code=4004, reason="realtime STT is not configured") + return + + session = realtime.open_session(config) + reader: asyncio.Task[None] | None = None + try: + try: + await session.connect() + except OctopError as exc: + tencent_code = exc.details.get("tencent_code") + await _ws_error( + websocket, + exc.code, + lang, + tencent_code=tencent_code if isinstance(tencent_code, int) else None, + ) + await websocket.close(code=4004, reason="upstream connect failed") + return + + await _ws_send_json(websocket, {"type": "ready"}) + reader = asyncio.create_task(_pump_events(session, websocket, lang)) + + while websocket.application_state == WebSocketState.CONNECTED: + message = await websocket.receive() + if message.get("type") == "websocket.disconnect": + break + chunk = message.get("bytes") + if chunk is not None: + await session.send_audio(chunk) + continue + raw = message.get("text") + if raw and _is_end_frame(raw): + await session.finish() + break + + if reader is not None: + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(reader, timeout=realtime.READER_TIMEOUT_SECONDS) + except WebSocketDisconnect: + pass + finally: + if reader is not None and not reader.done(): + reader.cancel() + with contextlib.suppress(asyncio.CancelledError): + await reader + await session.close() + if websocket.application_state == WebSocketState.CONNECTED: + with contextlib.suppress(RuntimeError, WebSocketDisconnect): + await websocket.close() + + @admin_router.get("") async def admin_list_voice_providers( _: Any = Depends(require_permission("voice")), diff --git a/src/octop/api/routers/workspace.py b/src/octop/api/routers/workspace.py index 875b5e750a..86e24a459d 100644 --- a/src/octop/api/routers/workspace.py +++ b/src/octop/api/routers/workspace.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, File, Query, UploadFile from fastapi.responses import Response, StreamingResponse -from harness_agent.backends.utils import BackendOperationNotSupportedError +from octop_harness.backends.utils import BackendOperationNotSupportedError from pydantic import BaseModel from octop.api.common.agent_workspace import resolve_agent_workspace_dir @@ -17,10 +17,12 @@ coerce_read_content, file_info_to_dict, reanchor_entry_path, + require_agent_workspace, require_running_workspace, workspace_api_path, ) from octop.api.deps import current_user, get_server +from octop.infra.backend.tree_listing import dedupe_tree_rows from octop.infra.backup.workspace_archive import export_workspace_zip, import_workspace_zip from octop.infra.errors import ErrorCode, OctopError from octop.infra.gateway.media.backend_files import ( @@ -138,7 +140,7 @@ async def list_tree( if not is_host_absolute_path(io_path): for row in rows: row["path"] = reanchor_entry_path(str(row.get("path") or ""), parent=io_path) - return rows + return dedupe_tree_rows(rows, parent=io_path) class WriteFileBody(BaseModel): @@ -161,7 +163,7 @@ async def read_file( server: Any = Depends(get_server), ) -> dict[str, Any]: """Read a UTF-8 text file.""" - ws = await require_running_workspace(agent_id, user=user, as_user=as_user, server=server) + ws = await require_agent_workspace(agent_id, user=user, as_user=as_user, server=server) content = await ws.aread_text(_workspace_io_path(path, from_workspace=from_workspace)) if content is None: raise OctopError(ErrorCode.NOT_FOUND, f"cannot read {path!r}") @@ -339,9 +341,9 @@ async def download_file( See ``from_workspace``: workspace UI uses true; chat/tool downloads use false. ``file://`` and other host-absolute paths are allowed for agent/OS tool outputs (Desktop, ``~/.octop/agents/…``, workspace tree) but denied for - sensitive system roots (``/etc``, ``.harness-browser``, Windows system dirs). + sensitive system roots (``/etc``, ``.harness-browser``, ``.octop-browser``, Windows system dirs). """ - ws = await require_running_workspace(agent_id, user=user, as_user=as_user, server=server) + ws = await require_agent_workspace(agent_id, user=user, as_user=as_user, server=server) io_path = _workspace_io_path(path, from_workspace=from_workspace) if is_host_absolute_path(io_path) and not is_allowed_host_download_abs_path( io_path, @@ -375,7 +377,7 @@ async def read_doc( ) -> dict[str, Any]: """Read an editable document (e.g. ``.docx``) as Markdown for online editing.""" converter = _ensure_editable_doc(path) - ws = await require_running_workspace(agent_id, user=user, as_user=as_user, server=server) + ws = await require_agent_workspace(agent_id, user=user, as_user=as_user, server=server) io_path = _workspace_io_path(path, from_workspace=from_workspace) try: blob = await ws.adownload_bytes(io_path) @@ -448,10 +450,18 @@ async def preview_media( raise OctopError(ErrorCode.NOT_FOUND, "preview not available for this source") data, mime = payload + # The source bytes are user-controlled (uploads, tool outputs). Serving them + # inline without a sandbox CSP would let a navigated SVG (any image/* type) + # run scripts on this origin; "sandbox" keeps image/video previews working + # while disabling script execution in the document itself. return StreamingResponse( iter([data]), media_type=mime, - headers={"Content-Disposition": "inline"}, + headers={ + "Content-Disposition": "inline", + "Content-Security-Policy": "sandbox", + "X-Content-Type-Options": "nosniff", + }, ) diff --git a/src/octop/cli/commands/acp.py b/src/octop/cli/commands/acp.py index 870d69df9b..98eab10fc1 100644 --- a/src/octop/cli/commands/acp.py +++ b/src/octop/cli/commands/acp.py @@ -37,7 +37,7 @@ async def _run_acp_server(*, agent_id: str | None, debug: bool) -> None: except Exception as exc: raise click.ClickException(f"agent {aid!r} is not running: {exc}") from exc - from harness_agent.acp.server import run_harness_acp_server + from octop_harness.acp.server import run_harness_acp_server try: await run_harness_acp_server(harness, agent_id=aid) diff --git a/src/octop/cli/commands/agent.py b/src/octop/cli/commands/agent.py index b971dacbf7..b882a3e68e 100644 --- a/src/octop/cli/commands/agent.py +++ b/src/octop/cli/commands/agent.py @@ -112,7 +112,9 @@ def list_agents(as_user: str | None) -> None: from rich.table import Table from octop.cli.support.ctx import json_output_enabled - from octop.cli.support.db import list_agents_offline + from octop.cli.support.db import list_agents_offline, resolve_cli_locale + from octop.cli.support.offline_ops import get_active_model_offline + from octop.i18n import tr try: rows = list_agents_offline(as_user=as_user) @@ -121,6 +123,18 @@ def list_agents(as_user: str | None) -> None: if json_output_enabled(): click.echo(_json.dumps(rows, indent=2)) return + # Agents without their own model run on the global default; say so, so an + # empty cell is not mistaken for "no model" and an override stays visible. + locale = resolve_cli_locale() + active = get_active_model_offline() + if active.get("provider_name") and active.get("model"): + fallback = tr( + "agents.cli.model_global", + locale, + model=f"{active['provider_name']}/{active['model']}", + ) + else: + fallback = tr("agents.cli.model_unset", locale) table = Table(title="Agents") for col in ("id", "name", "template", "model", "state"): table.add_column(col) @@ -130,7 +144,7 @@ def list_agents(as_user: str | None) -> None: str(aid), a.get("name", ""), a.get("template_name", "") or "", - a.get("default_model", "") or "", + a.get("default_model", "") or fallback, a.get("state", "") or a.get("last_state", "") or "", ) Console(file=sys.stdout).print(table) diff --git a/src/octop/cli/commands/bridge.py b/src/octop/cli/commands/bridge.py new file mode 100644 index 0000000000..f86fe70110 --- /dev/null +++ b/src/octop/cli/commands/bridge.py @@ -0,0 +1,405 @@ +"""octop bridge — manage Octop-to-Octop peer connections.""" + +from __future__ import annotations + +import json as _json +import sys +from typing import Any + +import click + +from octop.cli.support.acting import resolve_cli_acting_user_id +from octop.cli.support.ctx import json_output_enabled +from octop.cli.support.errors import fail_octop +from octop.infra.errors import OctopError + + +@click.group() +def bridge() -> None: + """Manage remote Octop bridge peers (add / edit / probe / connect).""" + + +def _resolve_owner(as_user: str | None) -> int: + try: + return resolve_cli_acting_user_id(None, as_user) + except OctopError as exc: + fail_octop(exc) + + +def _prompt_password(password: str | None) -> str: + secret = (password or "").strip() + if secret: + return secret + return str(click.prompt("Peer password", hide_input=True)) + + +def _echo_json(payload: Any) -> None: + click.echo(_json.dumps(payload, indent=2, ensure_ascii=False)) + + +def _peer_column(row: dict[str, Any]) -> str: + """Show the observed client address for inbound rows whose advertise URL is loopback.""" + url = str(row.get("peer_base_url") or "") + host = str(row.get("peer_client_host") or "").strip() + if not row.get("inbound") or not host: + return url + low = url.lower() + loopback = ( + "127.0.0.1" in low or "localhost" in low or "[::1]" in low or low.startswith("http://::1") + ) + if loopback or not url: + return host + if host not in url: + return f"{url} ({host})" + return url + + +def _echo_connection_table(rows: list[dict[str, Any]]) -> None: + from rich.console import Console + from rich.table import Table + + table = Table(title="Bridge connections") + for col in ("id", "name", "peer", "user", "status", "auto"): + table.add_column(col) + for row in rows: + table.add_row( + row.get("connection_id", ""), + row.get("display_name", "") or "", + _peer_column(row), + row.get("peer_username", "") or "", + row.get("status", "") or "", + str(bool(row.get("auto_reconnect"))), + ) + Console(file=sys.stdout).print(table) + + +def _echo_probe(result: dict[str, Any]) -> None: + from rich.console import Console + from rich.table import Table + + name = str(result.get("peer_display_name") or "") + count = int(result.get("agent_count") or 0) + click.echo(f"peer: {name} agents: {count}") + agents = result.get("agents") or [] + if not isinstance(agents, list) or not agents: + return + table = Table(title="Peer experts") + for col in ("agent_id", "name", "kind", "state"): + table.add_column(col) + for item in agents: + if not isinstance(item, dict): + continue + table.add_row( + str(item.get("agent_id") or ""), + str(item.get("name") or ""), + str(item.get("kind") or ""), + str(item.get("state") or ""), + ) + Console(file=sys.stdout).print(table) + + +def _echo_handshake_note(*, connect: bool) -> None: + if json_output_enabled() or not connect: + return + click.echo( + "Peer handshake verified; CLI closed the session. " + "Start `octop run` to keep the bridge linked." + ) + + +@bridge.command("list") +@click.option("--user", "as_user", default=None, help="Local owner (username).") +def list_connections(as_user: str | None) -> None: + """List saved bridge connections for the acting user.""" + from octop.cli.support.embedded_ops import list_bridge_connections + + uid = _resolve_owner(as_user) + rows = list_bridge_connections(uid) + if json_output_enabled(): + _echo_json(rows) + return + _echo_connection_table(rows) + + +@bridge.command("get") +@click.argument("connection_id") +@click.option("--user", "as_user", default=None, help="Local owner (username).") +def get_connection(connection_id: str, as_user: str | None) -> None: + """Show one saved bridge connection.""" + from octop.cli.support.embedded_ops import get_bridge_connection + + uid = _resolve_owner(as_user) + try: + row = get_bridge_connection(connection_id, uid) + except OctopError as exc: + fail_octop(exc) + _echo_json(row) + + +@bridge.command("probe") +@click.argument("connection_id", required=False) +@click.option("--url", "--peer-url", "peer_base_url", default=None, help="Remote Octop base URL.") +@click.option( + "--peer-user", + "--username", + "peer_username", + default=None, + help="Remote username (not the local --user).", +) +@click.option( + "--peer-password", + "password", + default=None, + help="Remote password; prompted if omitted (ad-hoc probe).", +) +@click.option("--user", "as_user", default=None, help="Local owner (username).") +def probe( + connection_id: str | None, + peer_base_url: str | None, + peer_username: str | None, + password: str | None, + as_user: str | None, +) -> None: + """Test peer credentials (login + list experts). Does not open Bridge WS.""" + from octop.cli.support.embedded_ops import probe_bridge_connection, probe_bridge_peer + + try: + if connection_id: + uid = _resolve_owner(as_user) + result = probe_bridge_connection( + connection_id, + uid, + peer_base_url=peer_base_url, + peer_username=peer_username, + password=password, + ) + else: + if not (peer_base_url or "").strip() or not (peer_username or "").strip(): + raise click.UsageError( + "--url and --peer-user are required when CONNECTION_ID is omitted" + ) + result = probe_bridge_peer( + peer_base_url=peer_base_url or "", + peer_username=peer_username or "", + password=_prompt_password(password), + ) + except OctopError as exc: + fail_octop(exc) + if json_output_enabled(): + _echo_json(result) + return + _echo_probe(result) + + +@bridge.command("create") +@click.option("--url", "--peer-url", "peer_base_url", required=True, help="Remote Octop base URL.") +@click.option( + "--peer-user", + "--username", + "peer_username", + required=True, + help="Remote username (not the local --user).", +) +@click.option( + "--peer-password", + "password", + prompt="Peer password", + hide_input=True, + confirmation_prompt=False, + help="Remote password (hidden prompt if omitted).", +) +@click.option("--name", "display_name", required=True, help="Local display name (unique per user).") +@click.option("--notes", default=None, help="Optional local notes.") +@click.option("--icon", "icon_name", default=None, help="Optional Lucide icon key.") +@click.option( + "--connect/--no-connect", + default=True, + help="Verify Bridge WS after login (default: connect).", +) +@click.option("--user", "as_user", default=None, help="Local owner (username).") +def create( + peer_base_url: str, + peer_username: str, + password: str, + display_name: str, + notes: str | None, + icon_name: str | None, + connect: bool, + as_user: str | None, +) -> None: + """Add a bridge peer. With --connect, keep the row only if handshake succeeds.""" + from octop.cli.support.embedded_ops import create_bridge_connection + + uid = _resolve_owner(as_user) + secret = password.strip() + if not secret: + raise click.UsageError("--peer-password is required") + try: + row = create_bridge_connection( + uid, + peer_base_url=peer_base_url, + peer_username=peer_username, + password=secret, + display_name=display_name, + notes=notes, + icon_name=icon_name, + connect=connect, + ) + except OctopError as exc: + fail_octop(exc) + _echo_json(row) + _echo_handshake_note(connect=connect) + + +@bridge.command("patch") +@click.argument("connection_id") +@click.option("--name", "display_name", default=None, help="Local display name.") +@click.option("--notes", default=None, help="Local notes; pass empty string to clear.") +@click.option("--icon", "icon_name", default=None, help="Lucide icon key; empty to clear.") +@click.option("--url", "--peer-url", "peer_base_url", default=None, help="Remote Octop base URL.") +@click.option( + "--peer-user", + "--username", + "peer_username", + default=None, + help="Remote username.", +) +@click.option( + "--peer-password", + "password", + default=None, + help="New remote password; omit to keep the stored secret.", +) +@click.option( + "--auto-reconnect/--no-auto-reconnect", + "auto_reconnect", + default=None, + help="Dial again after unexpected disconnect.", +) +@click.option("--user", "as_user", default=None, help="Local owner (username).") +def patch_connection( + connection_id: str, + display_name: str | None, + notes: str | None, + icon_name: str | None, + peer_base_url: str | None, + peer_username: str | None, + password: str | None, + auto_reconnect: bool | None, + as_user: str | None, +) -> None: + """Update display name, notes, icon, peer credentials, or auto-reconnect.""" + from octop.cli.support.embedded_ops import patch_bridge_connection + + if ( + display_name is None + and notes is None + and icon_name is None + and peer_base_url is None + and peer_username is None + and password is None + and auto_reconnect is None + ): + raise click.UsageError( + "nothing to patch; pass --name, --notes, --icon, --url, " + "--peer-user, --peer-password, or --auto-reconnect/--no-auto-reconnect" + ) + uid = _resolve_owner(as_user) + try: + row = patch_bridge_connection( + connection_id, + uid, + display_name=display_name, + notes=notes, + update_notes=notes is not None, + icon_name=icon_name, + update_icon=icon_name is not None, + peer_base_url=peer_base_url, + peer_username=peer_username, + password=password, + auto_reconnect=auto_reconnect, + ) + except OctopError as exc: + fail_octop(exc) + _echo_json(row) + + +@bridge.command("delete") +@click.argument("connection_id") +@click.option("--yes", is_flag=True, default=False, help="Skip confirmation.") +@click.option("--user", "as_user", default=None, help="Local owner (username).") +def delete_connection(connection_id: str, yes: bool, as_user: str | None) -> None: + """Delete a saved bridge connection.""" + from octop.cli.support.embedded_ops import delete_bridge_connection + + uid = _resolve_owner(as_user) + if not yes: + click.confirm(f"Delete bridge connection {connection_id}?", abort=True) + try: + delete_bridge_connection(connection_id, uid) + except OctopError as exc: + fail_octop(exc) + click.echo("deleted") + + +@bridge.command("connect") +@click.argument("connection_id") +@click.option("--user", "as_user", default=None, help="Local owner (username).") +def connect_connection(connection_id: str, as_user: str | None) -> None: + """Dial the peer and verify Bridge WS, then close the CLI session.""" + from octop.cli.support.embedded_ops import connect_bridge_connection + + uid = _resolve_owner(as_user) + try: + row = connect_bridge_connection(connection_id, uid) + except OctopError as exc: + fail_octop(exc) + _echo_json(row) + _echo_handshake_note(connect=True) + + +@bridge.command("disconnect") +@click.argument("connection_id") +@click.option("--user", "as_user", default=None, help="Local owner (username).") +def disconnect_connection(connection_id: str, as_user: str | None) -> None: + """Mark a connection disconnected in the local DB.""" + from octop.cli.support.embedded_ops import disconnect_bridge_connection + + uid = _resolve_owner(as_user) + try: + row = disconnect_bridge_connection(connection_id, uid) + except OctopError as exc: + fail_octop(exc) + _echo_json(row) + + +@bridge.command("agents") +@click.argument("connection_id") +@click.option("--user", "as_user", default=None, help="Local owner (username).") +def list_agents(connection_id: str, as_user: str | None) -> None: + """Dial the peer, list remote experts via the tunnel, then close.""" + from rich.console import Console + from rich.table import Table + + from octop.cli.support.embedded_ops import list_bridge_remote_agents + + uid = _resolve_owner(as_user) + try: + agents = list_bridge_remote_agents(connection_id, uid) + except OctopError as exc: + fail_octop(exc) + if json_output_enabled(): + _echo_json(agents) + return + table = Table(title="Remote experts") + for col in ("id", "name", "kind", "state"): + table.add_column(col) + for item in agents: + table.add_row( + str(item.get("agent_id") or item.get("id") or ""), + str(item.get("name") or ""), + str(item.get("kind") or ""), + str(item.get("state") or ""), + ) + Console(file=sys.stdout).print(table) + _echo_handshake_note(connect=True) diff --git a/src/octop/cli/commands/chats.py b/src/octop/cli/commands/chats.py index 7cf38e2c06..74d1de0e53 100644 --- a/src/octop/cli/commands/chats.py +++ b/src/octop/cli/commands/chats.py @@ -19,6 +19,7 @@ def _run_turn( session_key: str, thread_id: str | None, model: str | None = None, + conversation_mode: str | None = None, plain: bool = False, as_user: str | None = None, ) -> Any: @@ -45,6 +46,7 @@ def on_chunk(chunk: dict[str, Any]) -> None: session_key=session_key, thread_id=thread_id, model=model, + conversation_mode=conversation_mode, on_chunk=on_chunk, ) ) @@ -80,6 +82,11 @@ def _apply_slash_actions( repl_state.on_new_chat(act.get("thread_id")) elif name == "rebind_thread" and repl_state is not None: repl_state.on_rebind_thread(act.get("thread_id")) + elif name == "set_conversation_mode" and repl_state is not None: + mode = str(act.get("mode") or "").strip() + if mode in ("ask", "plan", "craft"): + repl_state.conversation_mode = mode + click.echo(click.style(f"mode → {mode}", fg="cyan")) return agent_id @@ -206,12 +213,54 @@ def delete_chat(thread_id: str, agent_id: str | None, as_user: str | None, yes: click.echo("deleted") +@chats.command("gc-orphans") +@click.option("--agent", "agent_id", default=None, help="Limit the scan to one agent.") +@click.option("--yes", is_flag=True, default=False, help="Delete the orphan checkpoints.") +def gc_orphans(agent_id: str | None, yes: bool) -> None: + """Report checkpoint rows whose thread was already removed. + + Without ``--yes`` this only prints the orphans. With ``--yes`` it + deletes them from each agent's memory store. + """ + from octop.cli.support.db import open_cli_services + from octop.infra.agents.memory.thread_cleanup import gc_orphan_checkpoints + from octop.infra.errors import OctopError + + try: + with open_cli_services(None) as svc: + orphans = gc_orphan_checkpoints(svc, agent_id=agent_id, apply=yes) + except OctopError as exc: + raise click.ClickException(exc.message) from exc + payload = [ + {"agent_id": item.agent_id, "thread_id": item.thread_id, "bytes": item.nbytes} + for item in orphans + ] + if json_output_enabled(): + click.echo(_json.dumps({"deleted": yes, "orphans": payload}, indent=2)) + return + if not payload: + click.echo("no orphan checkpoints") + return + total = sum(item.nbytes for item in orphans) + for item in orphans: + click.echo(f"{item.agent_id} {item.thread_id} {item.nbytes}") + verb = "deleted" if yes else "would delete" + click.echo(f"{verb} {len(payload)} orphan thread(s), {total} bytes") + + @chats.command("send") @click.argument("prompt") @click.option("--agent", "agent_id", default=None) @click.option("--session-key", default="default") @click.option("--thread-id", default=None, help="Pin this thread for every turn.") @click.option("--model", default=None) +@click.option( + "--mode", + "conversation_mode", + type=click.Choice(["ask", "plan", "craft"], case_sensitive=False), + default=None, + help="Ask (read-only), Plan (write plans/*.md), or Default (craft).", +) @click.option("--plain", is_flag=True, help="Raw token stream (no Markdown finish).") def send_chat( prompt: str, @@ -219,6 +268,7 @@ def send_chat( session_key: str, thread_id: str | None, model: str | None, + conversation_mode: str | None, plain: bool, ) -> None: """Send one message and stream the response (CLI channel).""" @@ -229,6 +279,7 @@ def send_chat( session_key=session_key, thread_id=thread_id, model=model, + conversation_mode=conversation_mode, plain=plain, ) @@ -241,6 +292,13 @@ def send_chat( @click.option("--session-key", default="cli") @click.option("--thread-id", default=None, help="Pin this thread for every turn.") @click.option("--model", default=None) +@click.option( + "--mode", + "conversation_mode", + type=click.Choice(["ask", "plan", "craft"], case_sensitive=False), + default=None, + help="Ask (read-only), Plan (write plans/*.md), or Default (craft).", +) @click.option("--plain", is_flag=True, help="Raw token stream (no Markdown finish).") def repl( agent_id: str | None, @@ -248,6 +306,7 @@ def repl( session_key: str, thread_id: str | None, model: str | None, + conversation_mode: str | None, plain: bool, ) -> None: """Interactive chat REPL (embedded server + CLI gateway channel).""" @@ -267,7 +326,11 @@ def repl( aid = require_agent(agent_id) repl_state = ReplSession( - agent_id=aid, session_key=session_key, model=model, thread_id=thread_id + agent_id=aid, + session_key=session_key, + model=model, + thread_id=thread_id, + conversation_mode=conversation_mode, ) print_welcome( agent_id=aid, @@ -302,6 +365,7 @@ def on_chunk(chunk: dict[str, Any]) -> None: session_key=session_key, thread_id=repl_state.thread_id_for_send(), model=repl_state.model, + conversation_mode=repl_state.conversation_mode, on_chunk=on_chunk, ) renderer.finish_turn() @@ -329,7 +393,21 @@ async def _loop() -> None: if text in ("/exit", "/quit"): break if text == "/help": - print_slash_help() + from octop.cli.support.db import resolve_cli_locale + + include_hitl = False + if server.app_runtime is not None: + from octop.infra.agents.security import ( + tool_execution_may_pause, + ) + + include_hitl = tool_execution_may_pause( + server.app_runtime.agent_registry.security.load() + ) + print_slash_help( + locale=resolve_cli_locale(), + include_hitl_approval=include_hitl, + ) continue if text == "/clear": click.echo() diff --git a/src/octop/cli/commands/cron.py b/src/octop/cli/commands/cron.py index f3522f154a..adfacca1f3 100644 --- a/src/octop/cli/commands/cron.py +++ b/src/octop/cli/commands/cron.py @@ -106,7 +106,7 @@ def delete_job(agent_id: str | None, cron_id: str, as_user: str | None) -> None: @click.option("--agent", "agent_id", default=None) @click.option("--user", "as_user", default=None) def run_now(agent_id: str | None, cron_id: str, as_user: str | None) -> None: - """Trigger a cron job immediately (embedded server).""" + """Run a cron job to completion (embedded server).""" from octop.cli.support.embedded_ops import cron_run_now from octop.cli.support.offline_ops import resolve_cron_user_id @@ -116,4 +116,6 @@ def run_now(agent_id: str | None, cron_id: str, as_user: str | None) -> None: cron_run_now(aid, cron_id) except OctopError as exc: fail_octop(exc) + except Exception as exc: + raise click.ClickException(str(exc) or type(exc).__name__) from exc click.echo("ok") diff --git a/src/octop/cli/commands/init.py b/src/octop/cli/commands/init.py index 78fdaedaea..b2857f8712 100644 --- a/src/octop/cli/commands/init.py +++ b/src/octop/cli/commands/init.py @@ -60,20 +60,26 @@ def init( paths = PathLayout.from_env() home = paths.root + # Sidecar files (octop-login.txt, NAS share metadata) must not block first + # bootstrap. Only an existing control-plane DB counts as an install. + has_db = (home / "octop.db").is_file() if home.exists() and any(home.iterdir()): - if not force: + if has_db and not force: click.echo( f"error: {home} already exists and is not empty. Use --force to reset.", err=True, ) raise SystemExit(1) - if not non_interactive: - from octop.cli.support import prompts as _prompts + if force: + if not non_interactive: + from octop.cli.support import prompts as _prompts - if not _prompts.confirm(f"Wipe {home}? This deletes ALL Octop state.", default=False): - click.echo("aborted", err=True) - raise SystemExit(1) - shutil.rmtree(home) + if not _prompts.confirm( + f"Wipe {home}? This deletes ALL Octop state.", default=False + ): + click.echo("aborted", err=True) + raise SystemExit(1) + shutil.rmtree(home) paths.ensure_root() PluginManager(plugins_dir=paths.plugins_dir, config_path=paths.config).seed_bundled() @@ -106,12 +112,19 @@ def init( click.echo(f"error: {exc.message}", err=True) raise SystemExit(1) from None - UserRepo(db).create( - username=username, - password_hash=hash_password(password or ""), - role="admin", - display_name=display_name, - ) + user_repo = UserRepo(db) + if user_repo.get_by_username(username) is not None: + # 幂等引导(issue #1866):外部数据库(PostgreSQL)不会在 ~/.octop + # 生成 octop.db,容器重启后 entrypoint 会重跑 init;此时跳过创建, + # 避免管理员 INSERT 撞 UNIQUE(username) 直接崩溃。 + click.echo(f" admin user '{username}' already exists, skipping.") + else: + user_repo.create( + username=username, + password_hash=hash_password(password or ""), + role="admin", + display_name=display_name, + ) finally: db.close() diff --git a/src/octop/cli/commands/memory.py b/src/octop/cli/commands/memory.py new file mode 100644 index 0000000000..9a838bb3f0 --- /dev/null +++ b/src/octop/cli/commands/memory.py @@ -0,0 +1,275 @@ +"""Manual memory maintenance coordinated by the running Octop process.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import click + +from octop.cli.support.ctx import json_output_enabled, resolve_agent +from octop.cli.support.db import resolve_cli_locale +from octop.i18n import tr +from octop.infra.agents.memory.slim_control import ( + list_memory_slim_agents, + list_orphan_agent_dirs, + request_memory_slim, +) +from octop.infra.utils.paths import PathLayout + + +@click.group() +def memory() -> None: + """Memory maintenance through the running local Octop server.""" + + +def _print_agents(agents: list[dict[str, str]], locale: str) -> None: + click.echo(tr("memory_slim.agents", locale)) + for number, agent in enumerate(agents, 1): + click.echo(f" {number}. {agent['name']} [{agent['agent_id']}]") + if not agents: + click.echo(tr("memory_slim.no_agents", locale)) + + +@memory.command(name="list") +def list_agents() -> None: + """List eligible running agents without starting maintenance.""" + locale = resolve_cli_locale() + try: + agents = list_memory_slim_agents(PathLayout.from_env().root, locale=locale) + except (OSError, ValueError, RuntimeError) as exc: + raise click.ClickException(tr("memory_slim.unavailable", locale, detail=str(exc))) from exc + if json_output_enabled(): + click.echo(json.dumps({"agents": agents}, ensure_ascii=False)) + else: + _print_agents(agents, locale) + + +class _Progress: + def __init__(self, locale: str) -> None: + self.locale = locale + self.terminal = click.get_text_stream("stdout").isatty() + self.phase = "" + self.elapsed = -5 + self.scanned: object = None + self.width = 0 + + def close(self) -> None: + if self.width: + click.echo() + self.width = 0 + + def show(self, status: dict[str, Any]) -> None: + phase = str(status.get("phase", "waiting")) + elapsed = int(status.get("elapsed_seconds", 0)) + scanned = status.get("scanned") if phase == "deduplicating" else None + changed = phase != self.phase or scanned != self.scanned + if not changed and elapsed < self.elapsed + (1 if self.terminal else 5): + return + if phase != self.phase: + self.close() + line = tr(f"memory_slim.{phase}", self.locale) + line += " " + tr("memory_slim.elapsed", self.locale, seconds=elapsed) + if scanned is not None and status.get("total") is not None: + line += " " + tr( + "memory_slim.scanned", self.locale, scanned=scanned, total=status["total"] + ) + if self.terminal and phase not in {"done", "failed"}: + click.echo("\r" + line.ljust(self.width), nl=False) + self.width = len(line) + else: + click.echo(line) + self.phase, self.elapsed, self.scanned = phase, elapsed, scanned + + +def _slim_agent( + root: Path, + agent_id: str, + locale: str, + *, + index: int, + total: int, + remove_directories: bool, +) -> None: + progress = _Progress(locale) + try: + for status in request_memory_slim( + root, agent_id, locale=locale, remove_directories=remove_directories + ): + if json_output_enabled(): + click.echo( + json.dumps( + {**status, "agent_id": agent_id, "index": index, "total_agents": total}, + ensure_ascii=False, + ) + ) + else: + progress.show(status) + if status.get("phase") == "done": + sweep = status.get("sweep") + if isinstance(sweep, dict): + click.echo( + tr( + "memory_slim.swept", + locale, + namespaces=sweep.get("namespaces", 0), + threads=sweep.get("threads", 0), + directories=sweep.get("directories", 0), + ) + ) + report = status.get("report") + if isinstance(report, dict) and "before" in report: + click.echo( + tr( + "memory_slim.result", + locale, + before=f"{report['before']['file_bytes'] / 1048576:.2f}", + after=f"{report['after']['file_bytes'] / 1048576:.2f}", + backup=report["backup_path"], + ) + ) + if status.get("phase") == "failed": + raise click.ClickException(str(status.get("error", "Maintenance failed"))) + finally: + progress.close() + + +@memory.command() +@click.option( + "--agent", + "agent_id", + default=None, + help="Agent ID; uses the selected agent or prompts from a list.", +) +@click.option( + "--all", "all_agents", is_flag=True, help="Sequentially slim all eligible running agents." +) +@click.option( + "--delete-dirs", + is_flag=True, + help="After listing them, delete directories whose names match no current agent.", +) +def slim(agent_id: str | None, all_agents: bool, delete_dirs: bool) -> None: + """Back up and slim memory, pausing new turns until completion. Keeps all history.""" + locale = resolve_cli_locale() + root = PathLayout.from_env().root + root_agent = (click.get_current_context().find_root().obj or {}).get("agent_id") + if all_agents and (agent_id is not None or root_agent): + raise click.UsageError(tr("memory_slim.all_conflict", locale)) + try: + if all_agents: + targets = list_memory_slim_agents(root, locale=locale) + if not targets: + raise click.ClickException(tr("memory_slim.no_agents", locale)) + else: + agent_id = resolve_agent(agent_id) + if agent_id is None: + agents = list_memory_slim_agents(root, locale=locale) + if json_output_enabled(): + click.echo(json.dumps({"agents": agents}, ensure_ascii=False)) + raise click.ClickException(tr("memory_slim.agent_required", locale)) + if not agents: + raise click.ClickException(tr("memory_slim.no_agents", locale)) + _print_agents(agents, locale) + number = click.prompt( + tr("memory_slim.choose", locale), type=click.IntRange(1, len(agents)) + ) + agent_id = agents[number - 1]["agent_id"] + targets = [{"agent_id": agent_id, "name": agent_id}] + try: + directories = list_orphan_agent_dirs(root) + except (OSError, ValueError, RuntimeError) as exc: + raise click.ClickException( + tr("memory_slim.unavailable", locale, detail=str(exc)) + ) from exc + remove_directories = delete_dirs + if directories: + if json_output_enabled(): + click.echo( + json.dumps( + {"phase": "orphan_dirs", "directories": directories}, + ensure_ascii=False, + ) + ) + else: + click.echo(tr("memory_slim.orphan_dirs", locale)) + for directory in directories: + click.echo(f" {directory}") + if not delete_dirs: + remove_directories = click.confirm( + tr("memory_slim.orphan_dirs_confirm", locale), default=False + ) + total = len(targets) + for index, target in enumerate(targets, 1): + target_id = target["agent_id"] + if all_agents: + if json_output_enabled(): + click.echo( + json.dumps( + { + "phase": "batch_agent", + **target, + "index": index, + "total_agents": total, + }, + ensure_ascii=False, + ) + ) + else: + click.echo( + tr( + "memory_slim.batch_target", + locale, + index=index, + total=total, + name=target["name"], + agent_id=target_id, + ) + ) + elif not json_output_enabled(): + click.echo(tr("memory_slim.target", locale, agent_id=target_id)) + try: + _slim_agent( + root, + target_id, + locale, + index=index, + total=total, + remove_directories=remove_directories, + ) + except (click.ClickException, OSError, ValueError, RuntimeError) as exc: + if not all_agents: + raise + message = tr( + "memory_slim.batch_stopped", + locale, + agent_id=target_id, + completed=index - 1, + remaining=total - index, + detail=str(exc), + ) + if json_output_enabled(): + click.echo( + json.dumps( + { + "phase": "batch_failed", + "agent_id": target_id, + "completed": index - 1, + "remaining": total - index, + "total_agents": total, + "error": message, + }, + ensure_ascii=False, + ) + ) + raise click.ClickException(message) from exc + if all_agents: + if json_output_enabled(): + click.echo( + json.dumps({"phase": "batch_done", "completed": total, "total_agents": total}) + ) + else: + click.echo(tr("memory_slim.batch_done", locale, total=total)) + except (OSError, ValueError, RuntimeError) as exc: + raise click.ClickException(tr("memory_slim.unavailable", locale, detail=str(exc))) from exc diff --git a/src/octop/cli/commands/models.py b/src/octop/cli/commands/models.py index 3d73dabb22..c6199420bb 100644 --- a/src/octop/cli/commands/models.py +++ b/src/octop/cli/commands/models.py @@ -16,7 +16,7 @@ def models() -> None: @models.command("presets") def presets() -> None: - """List built-in provider templates from harness-agent.""" + """List built-in provider templates from octop-harness.""" from octop.cli.support.ctx import json_output_enabled from octop.cli.support.offline_ops import load_provider_presets_offline @@ -68,7 +68,14 @@ def active_model(provider_name: str | None, model_id: str | None) -> None: click.echo(_json.dumps(body, indent=2)) return body = get_active_model_offline() - click.echo(f"{body.get('provider_name', '?')} / {body.get('model', '?')}") + provider, model = body.get("provider_name") or "", body.get("model") or "" + if not provider or not model: + from octop.cli.support.db import resolve_cli_locale + from octop.i18n import tr + + click.echo(tr("agents.cli.active_model_unset", resolve_cli_locale())) + return + click.echo(f"{provider} / {model}") @models.command("config") diff --git a/src/octop/cli/commands/run.py b/src/octop/cli/commands/run.py index 5ce91c7ce4..ea55f79d58 100644 --- a/src/octop/cli/commands/run.py +++ b/src/octop/cli/commands/run.py @@ -136,7 +136,10 @@ def _save_configfile_overrides(host: str | None, port: int | None) -> None: @click.command("run") @click.option("--host", default=None, help="Override bind host") -@click.option("--port", default=None, type=int, help="Override port") +# 0-65535, not 1-65535: ``resolve_bind`` reads port 0 as "let the OS pick a free +# port". Without a range the value is persisted to config.json before uvicorn +# dies in ``socket.bind``, so every later start inherits the unusable port. +@click.option("--port", default=None, type=click.IntRange(0, 65535), help="Override port") @click.option("--reload", is_flag=True, default=False, help="Enable uvicorn auto-reload (dev).") @click.option("--workers", default=1, type=int, help="Worker process count (default 1).") @click.option( diff --git a/src/octop/cli/commands/update.py b/src/octop/cli/commands/update.py index dd8512c5f9..3a04687c37 100644 --- a/src/octop/cli/commands/update.py +++ b/src/octop/cli/commands/update.py @@ -4,6 +4,7 @@ import click +from octop.cli.support.db import resolve_cli_locale from octop.infra.setup.self_update import ( fetch_pypi_info, get_editable_path, @@ -66,6 +67,7 @@ def update(check: bool, yes: bool, verbose: bool, allow_prerelease: bool) -> Non verbose=verbose, allow_prerelease=is_prerelease(latest), version=latest, + locale=resolve_cli_locale(), ) if not result.success: click.echo(f"upgrade failed: {result.error}", err=True) diff --git a/src/octop/cli/registry.py b/src/octop/cli/registry.py index f7e4ef945b..d7d0aa4f36 100644 --- a/src/octop/cli/registry.py +++ b/src/octop/cli/registry.py @@ -4,6 +4,7 @@ # command name -> (relative module path, attribute name, short help) COMMANDS: dict[str, tuple[str, str, str]] = { + "memory": (".commands.memory", "memory", "Live memory maintenance (backup and slim)."), "init": (".commands.init", "init", "Bootstrap an Octop server install."), "run": (".commands.run", "run", "Run octop-server in the foreground."), "service": ( @@ -31,6 +32,7 @@ "update": (".commands.update", "update", "Check for and install a newer Octop release."), "clean": (".commands.clean", "clean", "Remove CLI state or wipe all of ~/.octop."), "backup": (".commands.backup", "backup", "Export and restore Octop backups."), + "bridge": (".commands.bridge", "bridge", "Manage remote Octop bridge peers."), "acp": (".commands.acp", "acp_cmd", "Run Octop agent as ACP server (stdio)."), "plugin": (".commands.plugin", "plugin", "Install and manage plugins."), } diff --git a/src/octop/cli/repl/render.py b/src/octop/cli/repl/render.py index 34d7d24682..8eab8dc718 100644 --- a/src/octop/cli/repl/render.py +++ b/src/octop/cli/repl/render.py @@ -119,6 +119,8 @@ def feed(self, chunk: dict[str, Any]) -> None: detail = chunk.get("agent_id") or chunk.get("thread_id") or "" suffix = f" ({detail})" if detail else "" self._console.print(f" [dim]→ {action}{suffix}[/]", highlight=False) + elif ctype == "hitl_required": + self._print_hitl_pause(chunk) elif ctype == "done": pass @@ -256,6 +258,21 @@ def _print_work_summary(self) -> None: if parts: self._console.print(f" [dim]{' · '.join(parts)}[/]", highlight=False) + def _print_hitl_pause(self, chunk: dict[str, Any]) -> None: + from octop.cli.support.db import resolve_cli_locale + + card = format_cli_hitl_pause(chunk, locale=resolve_cli_locale()) + if not card: + return + self._dismiss_status() + self._console.print() + if self._markdown: + self._console.print(Markdown(card, code_theme="monokai"), highlight=False) + else: + self._console.print(card, highlight=False) + self._console.print() + self._had_output = True + def _print_footer(self) -> None: elapsed = self._last_elapsed or (time.monotonic() - self._turn_start) width = min(self._console.width, 50) @@ -264,13 +281,27 @@ def _print_footer(self) -> None: self._console.print() -def print_slash_help(*, locale: str = "zh") -> None: +def format_cli_hitl_pause(chunk: dict[str, Any], *, locale: str = "zh") -> str: + """Render a ``hitl_required`` chunk as the same card IM users see.""" + from octop.infra.gateway.hitl.format import format_hitl_card, parse_action_requests + + raw = chunk.get("request") + request = raw if isinstance(raw, dict) else {} + pending_id = str(chunk.get("pending_id") or request.get("pending_id") or "") + return format_hitl_card( + parse_action_requests(request), + pending_id=pending_id or "—", + locale=locale, + ) + + +def print_slash_help(*, locale: str = "zh", include_hitl_approval: bool = False) -> None: """Print slash commands visible to the CLI REPL.""" import click from octop.infra.gateway.slash.catalog import list_specs - commands = list_specs(origin="cli") + commands = list_specs(origin="cli", include_hitl_approval=include_hitl_approval) if not commands: click.echo(" (no slash commands)") return diff --git a/src/octop/cli/repl/runtime.py b/src/octop/cli/repl/runtime.py index 2d087bb096..d910b1d326 100644 --- a/src/octop/cli/repl/runtime.py +++ b/src/octop/cli/repl/runtime.py @@ -66,6 +66,7 @@ async def run_turn( session_key: str, thread_id: str | None, model: str | None, + conversation_mode: str | None = None, on_chunk: Callable[[dict[str, Any]], None], ) -> ChatTurnResult: channel_manager = self._gateway.channel_manager @@ -92,6 +93,7 @@ async def run_turn( thread_id=tid, cli_connection_id=self._connection_id, model=model, + conversation_mode=conversation_mode, user_is_admin=user_is_admin, ) @@ -111,6 +113,7 @@ async def run_chat_turn_async( session_key: str, thread_id: str | None, model: str | None, + conversation_mode: str | None = None, on_chunk: Callable[[dict[str, Any]], None], ) -> ChatTurnResult: async with embedded_runtime() as server: @@ -123,6 +126,7 @@ async def run_chat_turn_async( session_key=session_key, thread_id=thread_id, model=model, + conversation_mode=conversation_mode, on_chunk=on_chunk, ) finally: diff --git a/src/octop/cli/repl/session.py b/src/octop/cli/repl/session.py index 5d73a6b3e0..b0a64b1a29 100644 --- a/src/octop/cli/repl/session.py +++ b/src/octop/cli/repl/session.py @@ -17,6 +17,7 @@ class ReplSession: agent_id: str session_key: str model: str | None = None + conversation_mode: str | None = None thread_id: str | None = None pin_thread: bool = False last_elapsed: float = 0.0 diff --git a/src/octop/cli/repl/toolbar.py b/src/octop/cli/repl/toolbar.py index 2530f192e3..509f46dce3 100644 --- a/src/octop/cli/repl/toolbar.py +++ b/src/octop/cli/repl/toolbar.py @@ -10,6 +10,7 @@ def format_repl_toolbar(state: ReplSession) -> str: parts = [ state.agent_id[:12], f"model: {state.model_label}", + f"mode: {state.conversation_mode or 'craft'}", f"session: {state.session_key[:10]}", ] if state.last_elapsed > 0: diff --git a/src/octop/cli/support/embedded_ops.py b/src/octop/cli/support/embedded_ops.py index 394f3c321a..97aa72639d 100644 --- a/src/octop/cli/support/embedded_ops.py +++ b/src/octop/cli/support/embedded_ops.py @@ -3,7 +3,10 @@ from __future__ import annotations import asyncio -from typing import Any +from collections.abc import Awaitable, Callable +from contextlib import suppress +from pathlib import Path +from typing import Any, cast from octop.cli.repl.embedded_session import embedded_runtime from octop.infra.agents.providers.probe import probe_provider_row @@ -17,7 +20,7 @@ async def cron_run_now_async(agent_id: str, cron_id: str) -> None: row = mgr.get(cron_id) if row is None or row.agent_id != agent_id: raise OctopError(ErrorCode.NOT_FOUND, "cron job not found") - await mgr.run_now(cron_id) + await mgr.run_now(cron_id, wait=True) def cron_run_now(agent_id: str, cron_id: str) -> None: @@ -97,3 +100,227 @@ async def test_channel_async( def test_channel(agent_id: str, channel_id: str, *, locale: str = "zh") -> dict[str, Any]: return asyncio.run(test_channel_async(agent_id, channel_id, locale=locale)) + + +# ── Bridge (lightweight manager; no full OctopServer) ──────────────────────── + + +def _make_bridge_manager(svc: Any) -> Any: + from octop.infra.bridge.manager import BridgeManager, public_base_url_from_config + + cfg = svc.config + return BridgeManager( + bridge_repo=svc.bridge_connection_repo, + secret_repo=svc.secret_repo, + user_repo=svc.user_repo, + advertise_base_url=public_base_url_from_config(cfg.bind_host, cfg.port), + ) + + +def _bridge_sync(fn: Callable[[Any], Any], *, home: Path | None = None) -> Any: + from octop.cli.support.db import open_cli_services + + with open_cli_services(home) as svc: + return fn(_make_bridge_manager(svc)) + + +async def _bridge_async(fn: Callable[[Any], Awaitable[Any]], *, home: Path | None = None) -> Any: + from octop.cli.support.db import open_cli_services + + with open_cli_services(home) as svc: + return await fn(_make_bridge_manager(svc)) + + +async def _close_cli_session(mgr: Any, connection_id: str) -> None: + """CLI cannot keep a Bridge WS after the process exits.""" + with suppress(Exception): + await mgr.disconnect(connection_id) + + +def list_bridge_connections( + owner_user_id: int, *, home: Path | None = None +) -> list[dict[str, Any]]: + def _run(mgr: Any) -> Any: + return [mgr.connection_public(r) for r in mgr.list_connections(owner_user_id)] + + return cast(list[dict[str, Any]], _bridge_sync(_run, home=home)) + + +def get_bridge_connection( + connection_id: str, owner_user_id: int, *, home: Path | None = None +) -> dict[str, Any]: + def _run(mgr: Any) -> Any: + return mgr.connection_public(mgr.get_owned(connection_id, owner_user_id)) + + return cast(dict[str, Any], _bridge_sync(_run, home=home)) + + +def probe_bridge_peer( + *, + peer_base_url: str, + peer_username: str, + password: str, + home: Path | None = None, +) -> dict[str, Any]: + async def _run(mgr: Any) -> Any: + return await mgr.probe_peer( + peer_base_url=peer_base_url, + peer_username=peer_username, + password=password, + ) + + return cast(dict[str, Any], asyncio.run(_bridge_async(_run, home=home))) + + +def probe_bridge_connection( + connection_id: str, + owner_user_id: int, + *, + peer_base_url: str | None = None, + peer_username: str | None = None, + password: str | None = None, + home: Path | None = None, +) -> dict[str, Any]: + async def _run(mgr: Any) -> Any: + row = mgr.get_owned(connection_id, owner_user_id) + return await mgr.probe_owned_connection( + connection_id, + owner_user_id=owner_user_id, + peer_base_url=peer_base_url or row.peer_base_url, + peer_username=peer_username or row.peer_username, + password=password, + ) + + return cast(dict[str, Any], asyncio.run(_bridge_async(_run, home=home))) + + +def create_bridge_connection( + owner_user_id: int, + *, + peer_base_url: str, + peer_username: str, + password: str, + display_name: str, + notes: str | None = None, + icon_name: str | None = None, + connect: bool = True, + home: Path | None = None, +) -> dict[str, Any]: + async def _run(mgr: Any) -> Any: + row = await mgr.create_connection( + owner_user_id=owner_user_id, + peer_base_url=peer_base_url, + peer_username=peer_username, + password=password, + display_name=display_name, + notes=notes, + icon_name=icon_name, + connect=connect, + ) + if connect: + await _close_cli_session(mgr, row.connection_id) + row = mgr.get_owned(row.connection_id, owner_user_id) + return mgr.connection_public(row) + + return cast(dict[str, Any], asyncio.run(_bridge_async(_run, home=home))) + + +def patch_bridge_connection( + connection_id: str, + owner_user_id: int, + *, + display_name: str | None = None, + notes: str | None = None, + update_notes: bool = False, + icon_name: str | None = None, + update_icon: bool = False, + peer_base_url: str | None = None, + peer_username: str | None = None, + password: str | None = None, + auto_reconnect: bool | None = None, + home: Path | None = None, +) -> dict[str, Any]: + async def _run(mgr: Any) -> Any: + cred_touch = ( + peer_base_url is not None or peer_username is not None or bool((password or "").strip()) + ) + meta_touch = any( + ( + display_name is not None, + update_notes, + update_icon, + cred_touch, + ) + ) + opened = False + row = mgr.get_owned(connection_id, owner_user_id) + if meta_touch: + row = await mgr.update_connection_meta( + connection_id, + owner_user_id=owner_user_id, + display_name=display_name, + notes=notes, + update_notes=update_notes, + icon_name=icon_name, + update_icon=update_icon, + peer_base_url=peer_base_url, + peer_username=peer_username, + password=password, + ) + # Credential edits re-dial when auto_reconnect is on. + opened = cred_touch and bool(row.auto_reconnect) + if auto_reconnect is not None: + row = await mgr.set_auto_reconnect( + connection_id, owner_user_id=owner_user_id, enabled=auto_reconnect + ) + opened = opened or bool(auto_reconnect) + if opened: + await _close_cli_session(mgr, connection_id) + row = mgr.get_owned(connection_id, owner_user_id) + return mgr.connection_public(row) + + return cast(dict[str, Any], asyncio.run(_bridge_async(_run, home=home))) + + +def delete_bridge_connection( + connection_id: str, owner_user_id: int, *, home: Path | None = None +) -> None: + async def _run(mgr: Any) -> None: + await mgr.delete_connection(connection_id, owner_user_id=owner_user_id) + + asyncio.run(_bridge_async(_run, home=home)) + + +def connect_bridge_connection( + connection_id: str, owner_user_id: int, *, home: Path | None = None +) -> dict[str, Any]: + async def _run(mgr: Any) -> Any: + await mgr.connect(connection_id, owner_user_id=owner_user_id) + await _close_cli_session(mgr, connection_id) + return mgr.connection_public(mgr.get_owned(connection_id, owner_user_id)) + + return cast(dict[str, Any], asyncio.run(_bridge_async(_run, home=home))) + + +def disconnect_bridge_connection( + connection_id: str, owner_user_id: int, *, home: Path | None = None +) -> dict[str, Any]: + async def _run(mgr: Any) -> Any: + mgr.get_owned(connection_id, owner_user_id) + await mgr.disconnect(connection_id) + return mgr.connection_public(mgr.get_owned(connection_id, owner_user_id)) + + return cast(dict[str, Any], asyncio.run(_bridge_async(_run, home=home))) + + +def list_bridge_remote_agents( + connection_id: str, owner_user_id: int, *, home: Path | None = None +) -> list[dict[str, Any]]: + async def _run(mgr: Any) -> Any: + await mgr.connect(connection_id, owner_user_id=owner_user_id) + try: + return await mgr.list_remote_agents(connection_id, owner_user_id=owner_user_id) + finally: + await _close_cli_session(mgr, connection_id) + + return cast(list[dict[str, Any]], asyncio.run(_bridge_async(_run, home=home))) diff --git a/src/octop/cli/support/offline_ops.py b/src/octop/cli/support/offline_ops.py index 6a0c701729..c6bef713a0 100644 --- a/src/octop/cli/support/offline_ops.py +++ b/src/octop/cli/support/offline_ops.py @@ -5,7 +5,6 @@ import asyncio import json import logging -import shutil from pathlib import Path from typing import Any @@ -99,13 +98,26 @@ def create_user_offline( and svc.user_repo.get_by_email(normalized_email) is not None ): raise OctopError(ErrorCode.EMAIL_TAKEN, f"email {normalized_email!r} already exists") + permissions = None + role_name = None + policies: list[tuple[str, str]] = [] + if role == "user": + from octop.infra.db.repos.user_roles import seeded_user_role_assignment + + assignment = seeded_user_role_assignment(svc.db) + if assignment is not None: + role, role_name, permissions, policies = assignment uid = svc.user_repo.create( username=username, password_hash=hash_password(password), role=role, display_name=display_name, email=normalized_email, + permissions=permissions, + role_name=role_name, ) + if policies: + svc.user_policy_repo.merge(uid, dict(policies)) row = svc.user_repo.get(uid) assert row is not None return _user_row_to_dict(row) @@ -150,9 +162,11 @@ def disable_user_offline(username: str, *, home: Path | None = None) -> None: def delete_user_offline(username: str, *, home: Path | None = None) -> None: + from octop.infra.users.manager import UserManager + with open_cli_services(home) as svc: - uid = _require_username(svc, username) - svc.user_repo.delete(uid) + _require_username(svc, username) + asyncio.run(UserManager(svc).remove(username)) # ── Cron ───────────────────────────────────────────────────────────────────── @@ -247,7 +261,11 @@ def resolve_cron_user_id(agent_id: str, as_user: str | None, *, home: Path | Non def delete_agent_offline(agent_id: str, *, home: Path | None = None) -> None: - from octop.infra.agents.workspace_dir import workspace_dir_from_config_json + from octop.infra.agents.memory.thread_cleanup import ( + agent_config_from_row, + delete_agent_memory_and_checkpoints, + ) + from octop.infra.agents.workspace.dir import workspace_dir_from_config_json with open_cli_services(home) as svc: row = svc.agent_repo.get(agent_id) @@ -256,9 +274,19 @@ def delete_agent_offline(agent_id: str, *, home: Path | None = None) -> None: workspace_dir = workspace_dir_from_config_json( row.config_json, paths=svc.paths, agent_id=agent_id ) + cfg = agent_config_from_row(row) + delete_agent_memory_and_checkpoints( + agent_id=agent_id, + thread_ids=svc.thread_repo.list_ids_for_agent(agent_id), + cfg=cfg, + octop_config=svc.config, + workspace_dir=workspace_dir, + paths=svc.paths, + ) try: - if workspace_dir.exists(): - shutil.rmtree(workspace_dir) + from octop.infra.agents.workspace.dir import remove_agent_host_dirs + + remove_agent_host_dirs(cfg, paths=svc.paths, agent_id=agent_id) except OSError: logger.exception("rmtree failed for %s; agent removed from DB anyway", workspace_dir) svc.agent_repo.delete(agent_id) @@ -544,12 +572,35 @@ def update_thread_offline( def delete_thread_offline(agent_id: str, thread_id: str, *, home: Path | None = None) -> None: + from octop.infra.agents.memory.thread_cleanup import ( + agent_config_from_row, + delete_thread_from_agent_stores, + workspace_for_agent_row, + ) + with open_cli_services(home) as svc: row = svc.thread_repo.get(thread_id) if row is None or row.agent_id != agent_id: raise OctopError(ErrorCode.AGENT_NOT_FOUND, f"thread {thread_id!r} not found") + agent = svc.agent_repo.get(agent_id) + if agent is None: + raise OctopError(ErrorCode.AGENT_NOT_FOUND, f"agent {agent_id!r} not found") + cfg = agent_config_from_row(agent) + workspace = workspace_for_agent_row(agent, paths=svc.paths) + delete_thread_from_agent_stores( + agent_id=agent_id, + thread_id=thread_id, + cfg=cfg, + octop_config=svc.config, + workspace_dir=workspace, + paths=svc.paths, + ) try: svc.trajectory_event_repo.delete_for_thread(thread_id) except Exception: logger.exception("trajectory cascade delete failed thread=%s", thread_id) - svc.thread_repo.delete(thread_id) + registry = ThreadRegistry( + session_repo=svc.session_repo, + thread_repo=svc.thread_repo, + ) + registry.delete_thread(thread_id) diff --git a/src/octop/cli/support/state.py b/src/octop/cli/support/state.py index 0c1c20f6d2..ff04f4c1db 100644 --- a/src/octop/cli/support/state.py +++ b/src/octop/cli/support/state.py @@ -2,11 +2,16 @@ from __future__ import annotations -import json from dataclasses import asdict, dataclass, fields from pathlib import Path from typing import Any +from octop.infra.errors import corrupt_config_error +from octop.infra.utils.json_file import ( + JsonFileCorruptError, + read_json_object, + write_json_atomic, +) from octop.infra.utils.paths import PathLayout @@ -22,15 +27,27 @@ class CLIState: def load(path: Path) -> CLIState: - if not path.exists(): + """Read the pinned defaults; an absent file yields an empty state. + + A corrupt file is a typed error, not a bare ``json`` traceback: this file is + read by nearly every CLI command (``config``, ``agent``, ``db``, the REPL), + so one truncated file — the exact shape a torn write leaves behind — would + otherwise abort all of them with an unhandled ``JSONDecodeError``. Same + policy and same helper as every other ``OCTOP_HOME`` JSON reader + (``infra/utils/json_file.py``, issue #730). + """ + try: + raw = read_json_object(path) + except JsonFileCorruptError as exc: + raise corrupt_config_error(exc.path, exc.detail) from exc + if raw is None: return CLIState() - raw: dict[str, Any] = json.loads(path.read_text(encoding="utf-8")) - base = asdict(CLIState()) + base: dict[str, Any] = asdict(CLIState()) base.update(raw) allowed = {f.name for f in fields(CLIState)} return CLIState(**{k: v for k, v in base.items() if k in allowed}) def save(path: Path, state: CLIState) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(asdict(state), indent=2), encoding="utf-8") + """Persist ``state`` atomically so an interrupted write cannot truncate it.""" + write_json_atomic(path, asdict(state)) diff --git a/src/octop/config.py b/src/octop/config.py index 4d61334ae1..92ea14b353 100644 --- a/src/octop/config.py +++ b/src/octop/config.py @@ -406,6 +406,13 @@ def env_bind_overrides() -> tuple[str | None, int | None]: except ValueError: # Do not log the raw env value. logger.warning("env %s is not int; ignoring override", "OCTOP_PORT") + # ``resolve_bind`` reads port 0 as "let the OS pick a free port", so the + # bindable range here is 0-65535. Anything outside it outranks a valid + # config.json port and kills startup inside ``socket.bind`` with a bare + # ``OverflowError``, which names neither the key nor the value. + if port is not None and not 0 <= port <= 65535: + logger.warning("env %s is out of range; ignoring override", "OCTOP_PORT") + port = None return host, port diff --git a/src/octop/i18n/domains/__init__.py b/src/octop/i18n/domains/__init__.py index 9e84cd9315..d85585fc03 100644 --- a/src/octop/i18n/domains/__init__.py +++ b/src/octop/i18n/domains/__init__.py @@ -11,6 +11,7 @@ from octop.i18n.domains.attachment import ( attachment_empty_image, attachment_empty_message, + attachment_extracted_body, attachment_image_unavailable, attachment_path_hint, ) @@ -38,6 +39,7 @@ "agent_state_label", "attachment_empty_image", "attachment_empty_message", + "attachment_extracted_body", "attachment_image_unavailable", "attachment_path_hint", "classify_agent_start_error_message", diff --git a/src/octop/i18n/domains/agents.py b/src/octop/i18n/domains/agents.py index c704d1543b..f43bfba173 100644 --- a/src/octop/i18n/domains/agents.py +++ b/src/octop/i18n/domains/agents.py @@ -8,8 +8,10 @@ _START_ERROR_PREFIX = "octop:" NO_MODELS_CONFIGURED = f"{_START_ERROR_PREFIX}agent_errors.no_models_configured" MODEL_REF_UNAVAILABLE = f"{_START_ERROR_PREFIX}agent_errors.model_ref_unavailable" +AGENT_START_TIMEOUT = f"{_START_ERROR_PREFIX}agent_errors.start_timeout" __all__ = [ + "AGENT_START_TIMEOUT", "MODEL_REF_UNAVAILABLE", "NO_MODELS_CONFIGURED", "agent_error_message", diff --git a/src/octop/i18n/domains/attachment.py b/src/octop/i18n/domains/attachment.py index 12bc5585bd..5440d5a290 100644 --- a/src/octop/i18n/domains/attachment.py +++ b/src/octop/i18n/domains/attachment.py @@ -8,6 +8,7 @@ __all__ = [ "attachment_empty_image", "attachment_empty_message", + "attachment_extracted_body", "attachment_image_unavailable", "attachment_path_hint", ] @@ -32,6 +33,17 @@ def attachment_path_hint( return "\n".join(lines) +def attachment_extracted_body( + *, + filename: str, + body: str, + locale: str | Locale = "en", +) -> str: + """Inline document text extracted for the model (kept separate from path hints).""" + header = tr("attachment.extracted_header", locale, filename=filename) + return f"{header}\n{body.strip()}" + + def attachment_empty_message(locale: str | Locale = "en") -> str: return tr("attachment.empty_message", locale) diff --git a/src/octop/i18n/domains/stream.py b/src/octop/i18n/domains/stream.py index bcac2ac317..d88838eaae 100644 --- a/src/octop/i18n/domains/stream.py +++ b/src/octop/i18n/domains/stream.py @@ -2,9 +2,17 @@ from __future__ import annotations +import re + +from langchain_core.exceptions import ModelAuthenticationError, ModelPermissionDeniedError + from octop.i18n.loader import lookup, tr from octop.infra.utils.locale import Locale +# Keep in sync with octop_harness.messages.MODEL_RETRY_FAILURE_MARK. +# Defined locally so Octop still imports on older harness wheels. +MODEL_RETRY_FAILURE_MARK = "[model_call_failed]" + _PREFIX = "octop:" STREAM_STALL = f"{_PREFIX}stream_errors.stream_stall" @@ -16,12 +24,17 @@ TIMEOUT_NETWORK = f"{_PREFIX}stream_errors.timeout_network" PROVIDER_UNAVAILABLE = f"{_PREFIX}stream_errors.provider_unavailable" MODEL_CALL_FAILED = f"{_PREFIX}stream_errors.model_call_failed" +PATH_OUTSIDE_ROOT = f"{_PREFIX}stream_errors.path_outside_root" +INVALID_REQUEST = f"{_PREFIX}stream_errors.invalid_request" __all__ = [ "AUTH", "CONTEXT_LENGTH", "INSUFFICIENT_BALANCE", + "INVALID_REQUEST", "MODEL_CALL_FAILED", + "MODEL_RETRY_FAILURE_MARK", + "PATH_OUTSIDE_ROOT", "PROVIDER_UNAVAILABLE", "RATE_LIMIT", "RECURSION_LIMIT", @@ -30,7 +43,9 @@ "classify_stream_error_message", "exception_display_message", "format_stream_error", + "model_retry_failure_prompt", "stream_error_message", + "unwrap_model_retry_message", ] _STRIP_PREFIXES = ( @@ -38,6 +53,11 @@ "error:", ) +_RETRY_WRAPPER_RE = re.compile( + r"(?is)^model call failed after \d+ attempts? with\s+", +) +_TECHNICAL_DETAIL_RE = re.compile(r"(?is)technical detail:\s*(.+)$") + def _normalize_message(message: str) -> str: msg = message.strip() @@ -50,14 +70,43 @@ def _normalize_message(message: str) -> str: return msg +def _format_template(key: str, locale: str | Locale, **kwargs: object) -> str: + """Interpolate i18n text without treating exception braces as format fields.""" + escaped = { + name: str(value).replace("{", "{{").replace("}", "}}") for name, value in kwargs.items() + } + return tr(key, locale, **escaped) + + +def _looks_like_send_file_tool_error(message: str) -> bool: + """True for ``send_file_to_user`` path failures that should not look like a model outage.""" + return "send_file_to_user:" in _normalize_message(message).lower() + + +def unwrap_model_retry_message(message: str) -> str: + """Strip retry-wrapper / recovery-prompt chrome so the inner error can be classified.""" + msg = _normalize_message(message) + if MODEL_RETRY_FAILURE_MARK in msg: + msg = msg.replace(MODEL_RETRY_FAILURE_MARK, " ").strip() + tech = _TECHNICAL_DETAIL_RE.search(msg) + if tech: + return tech.group(1).strip() + stripped = _RETRY_WRAPPER_RE.sub("", msg, count=1).strip() + return stripped or msg + + def classify_stream_error_message(message: str) -> str | None: """Return a stable ``octop:stream_errors.*`` key for known model failures.""" - msg = _normalize_message(message) + msg = unwrap_model_retry_message(message) if not msg: return None lower = msg.lower() compact = lower.replace("_", "").replace(" ", "") + # Tool / backend path jail — must not look like a model-provider outage. + if "outside root directory" in lower or "path traversal not allowed" in lower: + return PATH_OUTSIDE_ROOT + if ( "streamchunktimeouterror" in compact or "no streaming chunk received" in lower @@ -100,6 +149,14 @@ def classify_stream_error_message(message: str) -> str | None: ): return AUTH + if ( + "error code: 422" in lower + or "http 422" in lower + or "check open ai req parameter" in lower + or "input validation error" in lower + ): + return INVALID_REQUEST + if ( "context_length_exceeded" in lower or "maximum context length" in lower @@ -141,7 +198,8 @@ def classify_stream_error_message(message: str) -> str | None: ): return TIMEOUT_NETWORK - if "model call failed after" in lower: + original = _normalize_message(message).lower() + if "model call failed after" in original or MODEL_RETRY_FAILURE_MARK in original: return MODEL_CALL_FAILED return None @@ -183,9 +241,50 @@ def stream_error_message(error: str | None, locale: str | Locale = "en") -> str: def format_stream_error(exc: BaseException | str, locale: str | Locale = "en") -> str: - """Classify an exception or raw message; fall back to a generic localized message.""" + """Classify an exception or raw message; keep the actual cause when unknown. + + Tool / path failures (e.g. ``send_file_to_user`` missing file) pass through so + the UI does not mislabel them as a model-call outage. + """ + current = exc if isinstance(exc, BaseException) else None + seen: set[int] = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + if isinstance(current, (ModelAuthenticationError, ModelPermissionDeniedError)) or getattr( + current, "status_code", None + ) in (401, 403): + return tr(AUTH.removeprefix(_PREFIX), locale) + current = current.__cause__ or ( + None if current.__suppress_context__ else current.__context__ + ) message = exception_display_message(exc) - classified = classify_stream_error_message(message) - if classified is not None: + inner = unwrap_model_retry_message(message) + classified = classify_stream_error_message(inner) + if classified is not None and classified != MODEL_CALL_FAILED: return tr(classified.removeprefix(_PREFIX), locale) + if _looks_like_send_file_tool_error(message) or _looks_like_send_file_tool_error(inner): + return message if _looks_like_send_file_tool_error(message) else inner + if inner: + return _format_template("stream_errors.model_call_failed_detail", locale, detail=inner) return tr(MODEL_CALL_FAILED.removeprefix(_PREFIX), locale) + + +def model_retry_failure_prompt(exc: Exception, locale: str | Locale = "en") -> str: + """Return a model-visible recovery prompt after retries are exhausted. + + Retained for explicit legacy recovery-prompt callers. Active agents use + ``on_failure="error"`` and display failures outside model messages. + """ + detail = unwrap_model_retry_message(exception_display_message(exc)) + classified = classify_stream_error_message(detail) + if classified is not None: + guidance = tr(classified.removeprefix(_PREFIX), locale) + else: + guidance = _format_template("stream_errors.model_call_failed_detail", locale, detail=detail) + body = _format_template( + "stream_errors.model_retry_prompt", + locale, + guidance=guidance, + detail=detail, + ) + return f"{MODEL_RETRY_FAILURE_MARK}\n{body}" diff --git a/src/octop/i18n/domains/tools.py b/src/octop/i18n/domains/tools.py index b61bf7e7d7..8e1c836fdf 100644 --- a/src/octop/i18n/domains/tools.py +++ b/src/octop/i18n/domains/tools.py @@ -21,6 +21,7 @@ # Memory "memory_search", "memory_get", + "memory_save", # Knowledge base "search_knowledge", # Cron diff --git a/src/octop/i18n/domains/voice.py b/src/octop/i18n/domains/voice.py index f2fc84b87c..9a7ae11761 100644 --- a/src/octop/i18n/domains/voice.py +++ b/src/octop/i18n/domains/voice.py @@ -1,4 +1,4 @@ -"""``voice.*`` — probe and Tencent provider error copy.""" +"""``voice.*`` — probe, realtime and Tencent provider error copy.""" from __future__ import annotations @@ -36,3 +36,26 @@ def format_voice_probe_error(exc: BaseException, locale: str | Locale) -> str: if sep and lookup(f"voice.tencent.{code}", locale) is not None: return tr(f"voice.tencent.{code}", locale) return raw + + +#: Realtime ASR error codes worth their own copy; anything else falls back to +#: ``voice.realtime.error_unknown``. +_ASR_ERROR_KEYS = { + 4002: "error_4002", + 4003: "error_4003", + 4004: "error_4004", + 4005: "error_4005", + 4006: "error_4006", + 4007: "error_4007", + 4008: "error_4008", + 4009: "error_4009", + 5000: "error_5000", + 5001: "error_5000", + 5002: "error_5000", +} + + +def realtime_error_message(tencent_code: int | None, locale: str | Locale) -> str: + """Localized copy for a realtime ASR failure code (``None`` = unknown).""" + suffix = _ASR_ERROR_KEYS.get(int(tencent_code or 0), "error_unknown") + return tr(f"voice.realtime.{suffix}", locale) diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index affe32a8c6..43337853aa 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -95,6 +95,7 @@ "usage": "Usage: /deleteSelect your TASKS.md file
+ +Select your project folder
+ +