diff --git a/cmd/anna/commands.go b/cmd/anna/commands.go index 0835e852..8e3e9d48 100644 --- a/cmd/anna/commands.go +++ b/cmd/anna/commands.go @@ -385,7 +385,7 @@ func modelSwitcher(base *config.Snapshot, store config.Store, pool *agent.Pool, snap.Providers = providers } - factory, err := agent.NewRunnerFactory(&snap, builtinTools, pluginToolsBuilder, providerRegistryBuilder, promptToolsFn, promptSectionsFn, toolLifecycle, skillStore, nil, nil) + factory, err := agent.NewRunnerFactory(&snap, builtinTools, pluginToolsBuilder, providerRegistryBuilder, promptToolsFn, promptSectionsFn, toolLifecycle, skillStore, nil, nil, nil) if err != nil { return err } diff --git a/cmd/anna/commands_test.go b/cmd/anna/commands_test.go index dd906759..86d8ce05 100644 --- a/cmd/anna/commands_test.go +++ b/cmd/anna/commands_test.go @@ -162,7 +162,7 @@ func TestNewRunnerFactoryGo(t *testing.T) { } snap.Workspace = t.TempDir() - factory, err := agent.NewRunnerFactory(snap, nil, nil, testProviderRegistryBuilder, nil, nil, nil, nil, nil, nil) + factory, err := agent.NewRunnerFactory(snap, nil, nil, testProviderRegistryBuilder, nil, nil, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("NewRunnerFactory: %v", err) } @@ -182,7 +182,7 @@ func TestNewRunnerFactoryUnknown(t *testing.T) { Runner: config.RunnerConfig{Type: "invalid"}, } - _, err := agent.NewRunnerFactory(snap, nil, nil, testProviderRegistryBuilder, nil, nil, nil, nil, nil, nil) + _, err := agent.NewRunnerFactory(snap, nil, nil, testProviderRegistryBuilder, nil, nil, nil, nil, nil, nil, nil) if err == nil { t.Fatal("expected error for unknown runner type") } @@ -290,7 +290,7 @@ func TestModelSwitcherPreservesPromptBuilders(t *testing.T) { } snap.Workspace = t.TempDir() - initialFactory, err := agent.NewRunnerFactory(snap, nil, nil, testProviderRegistryBuilder, nil, nil, nil, nil, nil, nil) + initialFactory, err := agent.NewRunnerFactory(snap, nil, nil, testProviderRegistryBuilder, nil, nil, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("NewRunnerFactory: %v", err) } diff --git a/cmd/anna/plugins_imports.go b/cmd/anna/plugins_imports.go index bf178af9..ad66c6d0 100644 --- a/cmd/anna/plugins_imports.go +++ b/cmd/anna/plugins_imports.go @@ -1,6 +1,10 @@ package main import ( + // Plugin auth. + _ "github.com/vaayne/anna/plugins/auth/github" + _ "github.com/vaayne/anna/plugins/auth/lark" + // Plugin channels. _ "github.com/vaayne/anna/plugins/channels/feishu" _ "github.com/vaayne/anna/plugins/channels/qq" diff --git a/docs/content/docs/channels/feishu.md b/docs/content/docs/channels/feishu.md index bd31f2d3..2dd7c049 100644 --- a/docs/content/docs/channels/feishu.md +++ b/docs/content/docs/channels/feishu.md @@ -30,8 +30,7 @@ anna now ships a generated builtin `lark` system skill, and release builds embed Typical setup: ```bash -command -v lark-cli -npm install -g @larksuite/cli +command -v lark-cli || npm install -g @larksuite/cli lark-cli config init --new lark-cli auth login --recommend lark-cli auth status diff --git a/docs/content/docs/channels/feishu.zh.md b/docs/content/docs/channels/feishu.zh.md index 2c51c9e8..b336fce5 100644 --- a/docs/content/docs/channels/feishu.zh.md +++ b/docs/content/docs/channels/feishu.zh.md @@ -28,8 +28,7 @@ anna 现在会内置生成好的 `lark` system skill,发布构建也会自动 常见初始化流程: ```bash -command -v lark-cli -npm install -g @larksuite/cli +command -v lark-cli || npm install -g @larksuite/cli lark-cli config init --new lark-cli auth login --recommend lark-cli auth status diff --git a/docs/content/docs/core/sandbox-backend-abstraction.md b/docs/content/docs/core/sandbox-backend-abstraction.md index 56d4c77c..7f2b1516 100644 --- a/docs/content/docs/core/sandbox-backend-abstraction.md +++ b/docs/content/docs/core/sandbox-backend-abstraction.md @@ -4,11 +4,11 @@ title: Sandbox Backend Abstraction ## Status -Implemented. Docker is the recommended sandbox backend. A local backend is also available for Docker-free environments with OS-level hardening. Anna's execution boundary is described by `pkg/sandbox` contracts, with runner-facing registry wiring in `internal/sandbox`. +Implemented. Docker is the recommended sandbox backend. A local backend is also available for Docker-free environments; Linux keeps OS-level hardening, while macOS currently runs local commands directly on the host without additional sandboxing. Anna's execution boundary is described by `pkg/sandbox` contracts, with runner-facing registry wiring in `internal/sandbox`. ## Purpose -The sandbox abstraction exists so runner code, plugin wiring, and tool execution do not depend on concrete backend types. All sandboxed execution runs through a Docker container. Docker is required; Anna fails closed when the Docker daemon is unavailable at session-create time. +The sandbox abstraction exists so runner code, plugin wiring, and tool execution do not depend on concrete backend types. Execution always runs through the active backend selected by the runner. Docker provides the strongest isolation; the local backend is a host-execution fallback for environments where Docker is unavailable or undesirable. The top-level model is: @@ -58,9 +58,9 @@ The local backend runs commands directly on the host OS. It is intended for envi | Process group kill + rlimits | All Unix | `SIGKILL` on process group; `RLIMIT_FSIZE`, `RLIMIT_NOFILE`, `RLIMIT_CPU` via `prlimit(2)` | | Filesystem + network isolation | Linux (bwrap available) | `bwrap` — workspace bind-mounted to `/workspace`; rest of FS read-only; optional `--unshare-net` | | Network isolation only | Linux (no bwrap) | `unshare --net` — new network namespace, no outbound connectivity | -| Filesystem + network policy | macOS | `sandbox-exec` Seatbelt profile — write restricted to workspace; network per policy | +| No additional local isolation | macOS | Commands run directly on the host OS; filesystem and network policy are not enforced | -The local backend uses a **fail-closed** strategy: if a required isolation tool is missing, session creation fails with an actionable error message rather than running unconfined. +The local backend uses a **fail-closed** strategy on Linux: if a required isolation tool is missing, session creation fails with an actionable error message rather than running unconfined. On macOS, no extra sandboxing tool is currently applied. #### Installing dependencies @@ -78,8 +78,8 @@ pacman -S bubblewrap `unshare` (network-only fallback) is part of `util-linux` and is present on virtually all Linux systems. -**macOS — sandbox-exec:** -Built into macOS. No installation required. Note: `sandbox-exec` has been informally deprecated since macOS 10.15 (Catalina). It remains functional but may be removed in a future macOS release. +**macOS:** +No additional dependency is required. The current local backend runs commands directly on the host OS without applying a macOS-specific sandbox. **Windows:** Not supported. Use the Docker backend. @@ -91,14 +91,14 @@ On Linux with `bwrap`, the agent always sees its workspace at `/workspace` regar Per-agent sandbox configuration is limited to network policy (mode and allowlist). Each agent independently controls whether its sandbox allows outbound network access and which hosts are reachable. -Network modes supported by the Docker backend: +Network modes supported by Docker and by the Linux local backend: | Mode | Description | | ----------- | ------------------------------------ | | `disabled` | No outbound network access (default) | | `allow_all` | Unrestricted outbound access | -The `whitelist` mode is removed. Anna validates the configured mode at session-create time and fails closed if the backend cannot enforce it. +The `whitelist` mode is removed. Docker and the Linux local backend validate the configured mode at session-create time and fail closed if the backend cannot enforce it. The macOS local backend currently ignores network policy and runs with host network access. ## Current Architecture @@ -108,7 +108,7 @@ The runner creates a `sandbox.Session` for each run and keeps ownership of its l ### Backend resolution -The Docker backend self-registers in `internal/sandbox` via `init()`. `pkg/sandbox.Registry.CreateSession` iterates registered factories in registration order and uses the first available factory that supports the policy. With only one factory registered, this always selects Docker or fails. +The runner resolves the active backend from plugin state and dispatches to a backend-specific factory. Built-in factories currently support `docker` and `local`. ### Execution-time mediation @@ -122,7 +122,7 @@ All local execution paths that must obey sandbox policy are mediated through the ### stdio-MCP benefit -`Session.StartProcess` is fully supported by the Docker backend on every run. This means MCP stdio transports work uniformly across all platforms without requiring platform-specific subprocess handling. +`Session.StartProcess` is supported by both built-in backends. Docker gives stdio MCP servers a dedicated container process namespace; the local backend starts them directly on the host OS. ### Non-runner filesystem access diff --git a/docs/content/docs/core/sandbox-backend-abstraction.zh.md b/docs/content/docs/core/sandbox-backend-abstraction.zh.md index aa121965..94f822c2 100644 --- a/docs/content/docs/core/sandbox-backend-abstraction.zh.md +++ b/docs/content/docs/core/sandbox-backend-abstraction.zh.md @@ -4,11 +4,11 @@ title: 沙箱后端抽象 ## 状态 -已实现。Docker 是推荐的沙箱后端。本地后端也可用于无 Docker 环境,并应用了操作系统级加固措施。Anna 的执行边界由 `pkg/sandbox` 契约描述,runner 侧注册配置位于 `internal/sandbox`。 +已实现。Docker 是推荐的沙箱后端。本地后端也可用于无 Docker 环境;Linux 保留操作系统级加固,而 macOS 当前会直接在宿主机上运行本地命令,不再附加额外沙箱。Anna 的执行边界由 `pkg/sandbox` 契约描述,runner 侧注册配置位于 `internal/sandbox`。 ## 目的 -沙箱抽象的目的是使 runner 代码、插件配置和工具执行不依赖于具体的后端类型。所有沙箱化执行都在 Docker 容器中运行。Docker 是必要条件;当 Docker 守护进程在会话创建时不可用时,Anna 会拒绝失败(fail closed)。 +沙箱抽象的目的是使 runner 代码、插件配置和工具执行不依赖于具体的后端类型。执行总是通过 runner 选中的活动后端进行。Docker 提供最强隔离;本地后端则是在 Docker 不可用或不想使用时的宿主机执行回退方案。 顶层模型: @@ -58,9 +58,9 @@ Docker 提供完整的容器级进程、文件系统和网络隔离。Docker 守 | 进程组终止 + 资源限制 | 所有 Unix | 对进程组发送 `SIGKILL`;通过 `prlimit(2)` 设置 `RLIMIT_FSIZE`、`RLIMIT_NOFILE`、`RLIMIT_CPU` | | 文件系统 + 网络隔离 | Linux(bwrap 可用) | `bwrap` — 工作区绑定挂载到 `/workspace`;其余文件系统只读;可选 `--unshare-net` | | 仅网络隔离 | Linux(无 bwrap) | `unshare --net` — 新建网络命名空间,无出站连接 | -| 文件系统 + 网络策略 | macOS | `sandbox-exec` Seatbelt 配置文件 — 写入限制在工作区;网络按策略控制 | +| 无额外本地隔离 | macOS | 命令直接在宿主机 OS 上运行;不强制执行文件系统和网络策略 | -本地后端采用**拒绝失败**策略:如果所需的隔离工具缺失,会话创建失败并返回包含操作建议的错误信息,而非在无隔离状态下运行。 +本地后端在 Linux 上采用**拒绝失败**策略:如果所需的隔离工具缺失,会话创建失败并返回包含操作建议的错误信息,而非在无隔离状态下运行。macOS 当前不再附加额外沙箱工具。 #### 安装依赖 @@ -78,8 +78,8 @@ pacman -S bubblewrap `unshare`(仅网络隔离的备用方案)是 `util-linux` 的一部分,在几乎所有 Linux 系统上均已存在。 -**macOS — sandbox-exec:** -macOS 内置,无需安装。注意:`sandbox-exec` 自 macOS 10.15(Catalina)起已被非正式弃用,仍可使用,但可能在未来的 macOS 版本中被移除。 +**macOS:** +无需额外依赖。当前本地后端直接在宿主机 OS 上运行命令,不应用特定于 macOS 的沙箱。 **Windows:** 不支持,请使用 Docker 后端。 @@ -91,14 +91,14 @@ macOS 内置,无需安装。注意:`sandbox-exec` 自 macOS 10.15(Catalina 每个代理的沙箱配置仅限于网络策略(模式和允许列表)。每个代理独立控制其沙箱是否允许出站网络访问以及哪些主机可达。 -Docker 后端支持的网络模式: +Docker 后端以及 Linux 本地后端支持的网络模式: | 模式 | 描述 | | ----------- | ---------------------------- | | `disabled` | 禁止所有出站网络访问(默认) | | `allow_all` | 不受限制的出站访问 | -`whitelist` 模式已移除。Anna 在会话创建时验证已配置的模式,如果后端无法强制执行则拒绝失败。 +`whitelist` 模式已移除。Docker 和 Linux 本地后端会在会话创建时验证已配置的模式,如果后端无法强制执行则拒绝失败。macOS 本地后端当前会忽略网络策略并使用宿主机网络访问。 ## 当前架构 @@ -108,7 +108,7 @@ runner 为每次运行创建一个 `sandbox.Session` 并持有其生命周期所 ### 后端解析 -Docker 后端通过 `init()` 在 `internal/sandbox` 中自注册。`pkg/sandbox.Registry.CreateSession` 按注册顺序迭代已注册的工厂,使用第一个可用且支持该策略的工厂。由于只注册了一个工厂,这总是选择 Docker 或失败。 +runner 会根据插件状态解析当前活动后端,并分派到对应的后端工厂。内置工厂当前支持 `docker` 和 `local`。 ### 执行时中介 @@ -122,7 +122,7 @@ Docker 后端通过 `init()` 在 `internal/sandbox` 中自注册。`pkg/sandbox. ### stdio-MCP 优势 -Docker 后端在每次运行时完全支持 `Session.StartProcess`。这意味着 MCP stdio 传输在所有平台上均可统一工作,无需特定于平台的子进程处理。 +两个内置后端都支持 `Session.StartProcess`。Docker 为 stdio MCP 服务器提供独立的容器进程命名空间;本地后端则直接在宿主机上启动这些进程。 ### 非 runner 文件系统访问 diff --git a/docs/content/docs/features/agent-templates.md b/docs/content/docs/features/agent-templates.md index 0e3288c9..a6f27a4a 100644 --- a/docs/content/docs/features/agent-templates.md +++ b/docs/content/docs/features/agent-templates.md @@ -21,7 +21,7 @@ A template is a complete starting point for a new agent. When you click **Add ag - **Model** — provider/model pair the template recommends - **System prompt** — copied from the template's referenced soul -- **Enabled builtin skills** — shown as chips on the form; togglable before save +- **Builtin skill metadata** — retained for compatibility with older templates, but system-scope builtin skills are now always available to every agent User-supplied fields always win. You can edit every field on the form before saving, and after save the agent has no persistent link back to the template — upgrading a template does not touch existing agents. @@ -46,22 +46,22 @@ Sub-agent presets describe tool-restricted workers for the `agent` delegation to Shipped sub-agents: `coder`, `researcher`, `reviewer`, `writer`. -## Skills and per-agent enablement +## Skills -Every skill marked `scope='system'` is universal by design, which means naive growth of the builtin catalog would drop every skill into every agent's prompt — fast prompt bloat. +Every skill synced into the database with `scope='system'` is available to every agent automatically. -The fix: `settings_agents.enabled_builtin_skills` (JSON array of skill names). An agent's skill catalog in the prompt is: +An agent's skill catalog in the prompt is: ``` -{always-on builtins: anna} - ∪ {enabled_builtin_skills} +{all system-scope builtin skills} ∪ {agent-scope DB skills} ∪ {user-scope DB skills} + ∪ {project skills from .agents/skills} ``` -`anna` (the self-knowledge skill) is always on. Every other builtin skill must be opted in — either via the template you picked (which sets the list for you) or by toggling chips on the agent form. +The legacy `settings_agents.enabled_builtin_skills` field is still stored for backward compatibility with older templates and agent rows, but it no longer filters prompt visibility. -Shipped skills: `anna`, `code-review`, `docs-writing`, `implementation`, `research`, `task-planning`. +Shipped system skills live under `internal/resources/skills/system/` and are synced into `skills(scope='system')` on startup. Startup sync is authoritative: skills removed from the embedded system catalog are deleted from the database on the next sync. ## Adding a new builtin resource diff --git a/docs/content/docs/features/agent-templates.zh.md b/docs/content/docs/features/agent-templates.zh.md index c0445075..52e5e3d5 100644 --- a/docs/content/docs/features/agent-templates.zh.md +++ b/docs/content/docs/features/agent-templates.zh.md @@ -21,7 +21,7 @@ Anna 自带一套**内置资源目录**,让全新安装即开即用,无需 - **模型** — 模板推荐的 provider/model 组合 - **系统提示** — 复制自模板引用的 soul -- **已启用的内置技能** — 以芯片形式显示在表单上,保存前可自由切换 +- **内置技能元数据** — 为兼容旧模板而保留,但 `scope='system'` 的内置技能现已自动对所有 agent 可用 用户手动输入始终优先。所有字段在保存前都可以编辑;保存之后 agent 与模板没有任何持久关联 — 更新模板不会影响已有 agent。 @@ -46,22 +46,22 @@ Sub-agent 预设定义了 `agent` 委派工具使用的受限工作者(调研 附带的 sub-agent:`coder`、`researcher`、`reviewer`、`writer`。 -## Skill 与按 agent 开关 +## Skill -每个 `scope='system'` 的技能天生对所有 agent 可见,因此朴素地增长内置技能目录会把所有技能同时塞进每个 agent 的提示 — 提示体积会迅速膨胀。 +所有同步到数据库且 `scope='system'` 的技能,都会自动对所有 agent 可用。 -解决方式:`settings_agents.enabled_builtin_skills`(JSON 字符串数组)。agent 看到的技能目录为: +agent 在提示里看到的技能目录为: ``` -{常驻内置:anna} - ∪ {enabled_builtin_skills 中列出的} +{全部 system 范围内置技能} ∪ {agent 范围的数据库技能} ∪ {user 范围的数据库技能} + ∪ {来自 .agents/skills 的 project 技能} ``` -`anna`(自我知识技能)永远启用。其他内置技能必须显式开启 — 通过你选择的模板(模板会替你设好)或手动切换表单上的芯片。 +历史遗留的 `settings_agents.enabled_builtin_skills` 字段仍会保留,以兼容旧模板和旧 agent 行,但它已不再参与提示可见性的过滤。 -附带的技能:`anna`、`code-review`、`docs-writing`、`implementation`、`research`、`task-planning`。 +附带的 system skill 位于 `internal/resources/skills/system/`,启动时会同步到 `skills(scope='system')`。启动同步是权威来源:如果某个嵌入式 system skill 已从目录中移除,那么下一次同步时也会从数据库中删除。 ## 新增一个内置资源 diff --git a/docs/content/docs/features/cli-oauth.md b/docs/content/docs/features/cli-oauth.md new file mode 100644 index 00000000..734a59c7 --- /dev/null +++ b/docs/content/docs/features/cli-oauth.md @@ -0,0 +1,71 @@ +--- +title: CLI OAuth +--- + +## Overview + +The CLI OAuth feature lets agents use `gh` (GitHub CLI) and `lark-cli` directly from +sandbox sessions without manual authentication. Anna handles the OAuth device flow on +the host, stores a versioned token bundle in your personal vault, and injects a fresh +runtime token into each sandbox environment automatically. + +## Prerequisites + +An admin must configure the relevant auth plugin with application credentials before +any user can connect: + +- **GitHub**: The admin must set a GitHub OAuth app's client ID and secret in the + GitHub auth plugin settings. +- **Lark / Feishu**: The admin must set a Lark app ID, app secret, and brand + (`lark` or `feishu`) in the Lark auth plugin settings. + +## Connecting + +1. Open the Anna admin panel and navigate to your **Profile** page. +2. Find the **OAuth CLI Credentials** section. +3. Click **Connect** next to the provider you want to link. +4. Anna starts a device flow and displays a verification URL and user code. +5. Open the URL in a browser, enter the code, and authorize. +6. Anna polls for completion. Once authorized, the token bundle is saved to your vault. + +You can disconnect at any time by clicking **Disconnect** next to the provider. + +## Using the CLIs + +After connecting, raw `gh` and `lark-cli` commands work inside agent sandbox sessions +without any additional configuration. Anna prepends a wrapper directory to `PATH` so +that every `gh` or `lark-cli` invocation automatically receives the correct credentials. +For `lark-cli`, Anna injects `LARKSUITE_CLI_USER_ACCESS_TOKEN`, +`LARKSUITE_CLI_APP_ID`, and `LARKSUITE_CLI_BRAND`, so no per-session `config init` +is required. + +Example (issued by the agent inside a bash tool call): + +```sh +gh issue list --repo owner/repo +lark-cli message send --chat-id --text "Hello" +``` + +## Known limitations + +### Lark token expiry + +Lark user access tokens expire after approximately **2 hours**. Anna refreshes them +at session start only. If an agent session outlives the token, `lark-cli` calls will +fail with an authentication error. Starting a new Anna session will pick up a freshly +refreshed token automatically. + +### Restart loses in-flight device flows + +Pending device flows (started but not yet authorized) are held in memory. An Anna +process restart discards them. If Anna restarts while you are completing authorization +in a browser, you will need to start the flow again from the profile page. + +## Security model + +OAuth token bundles (`GH_OAUTH`, `LARK_CLI_OAUTH`) are stored encrypted at rest in +your vault using the same age-based encryption as other vault entries. They are +treated as host-only data: the raw JSON bundles are never forwarded into the sandbox +process environment. Only the derived runtime token (e.g., `GH_TOKEN` for GitHub) is +injected, so sandbox processes never have access to refresh credentials or OAuth app +secrets. diff --git a/docs/content/docs/features/cli-oauth.zh.md b/docs/content/docs/features/cli-oauth.zh.md new file mode 100644 index 00000000..881d651a --- /dev/null +++ b/docs/content/docs/features/cli-oauth.zh.md @@ -0,0 +1,50 @@ +--- +title: CLI OAuth 认证 +--- + +## 概述 + +CLI OAuth 功能允许 Agent 在沙盒会话中直接使用 `gh`(GitHub CLI)和 `lark-cli`,无需手动认证。Anna 在宿主机上完成 OAuth 设备流程,将版本化的令牌包存储到个人密钥库中,并在每次沙盒会话启动时自动注入最新的运行时令牌。 + +## 前提条件 + +用户连接前,管理员须在对应的认证插件中配置好应用凭据: + +- **GitHub**:管理员需在 GitHub 认证插件设置中填入 OAuth 应用的 Client ID 和 Client Secret。 +- **Lark / 飞书**:管理员需在 Lark 认证插件设置中填入 App ID、App Secret 以及品牌标识(`lark` 或 `feishu`)。 + +## 连接步骤 + +1. 打开 Anna 管理面板,进入**个人资料**页面。 +2. 找到 **OAuth CLI 凭据**区域。 +3. 点击要连接的服务商旁边的**连接**按钮。 +4. Anna 启动设备流程,并显示验证 URL 和用户码。 +5. 在浏览器中打开该 URL,输入用户码并完成授权。 +6. Anna 轮询授权结果。授权成功后,令牌包将保存至您的密钥库。 + +随时可点击服务商旁的**断开连接**取消绑定。 + +## 使用 CLI 工具 + +连接成功后,Agent 在沙盒会话中可以直接运行 `gh` 和 `lark-cli` 命令,无需任何额外配置。Anna 会将包装脚本目录添加到 `PATH` 的最前面,使每次调用都自动获得正确的认证凭据。对于 `lark-cli`,Anna 会注入 `LARKSUITE_CLI_USER_ACCESS_TOKEN`、`LARKSUITE_CLI_APP_ID` 和 `LARKSUITE_CLI_BRAND`,因此不需要在每个会话里再执行 `config init`。 + +示例(由 Agent 在 bash 工具调用中执行): + +```sh +gh issue list --repo owner/repo +lark-cli message send --chat-id --text "Hello" +``` + +## 已知限制 + +### Lark 令牌有效期 + +Lark 用户访问令牌有效期约为 **2 小时**。Anna 仅在会话启动时刷新令牌。若 Agent 会话时长超过令牌有效期,`lark-cli` 调用将因认证失败而报错。重新开启一个 Anna 会话,即可自动获取刷新后的令牌。 + +### 重启会丢失进行中的设备流程 + +未完成授权的设备流程状态保存在内存中。Anna 进程重启后,这些状态将丢失。如果您正在浏览器中完成授权时 Anna 发生重启,需要在个人资料页面重新发起授权流程。 + +## 安全模型 + +OAuth 令牌包(`GH_OAUTH`、`LARK_CLI_OAUTH`)使用与其他密钥库条目相同的 age 加密方式加密存储。它们仅在宿主机上使用:原始 JSON 包不会传入沙盒进程的环境变量。沙盒进程只能获取派生的运行时令牌(例如 GitHub 的 `GH_TOKEN`),无法访问刷新凭据或 OAuth 应用密钥。 diff --git a/docs/content/docs/features/meta.json b/docs/content/docs/features/meta.json index 485b71ce..4d396450 100644 --- a/docs/content/docs/features/meta.json +++ b/docs/content/docs/features/meta.json @@ -1,4 +1,4 @@ { "title": "Features", - "pages": ["plugin-system", "scheduler-system", "notification-system", "agent-templates", "vault"] + "pages": ["plugin-system", "scheduler-system", "notification-system", "agent-templates", "vault", "cli-oauth"] } diff --git a/docs/content/docs/features/meta.zh.json b/docs/content/docs/features/meta.zh.json index e977c0ec..48e95a85 100644 --- a/docs/content/docs/features/meta.zh.json +++ b/docs/content/docs/features/meta.zh.json @@ -1,4 +1,4 @@ { "title": "功能", - "pages": ["plugin-system", "scheduler-system", "notification-system", "agent-templates", "vault"] + "pages": ["plugin-system", "scheduler-system", "notification-system", "agent-templates", "vault", "cli-oauth"] } diff --git a/internal/admin/middleware.go b/internal/admin/middleware.go index d24fc6c5..13629795 100644 --- a/internal/admin/middleware.go +++ b/internal/admin/middleware.go @@ -41,12 +41,13 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path - // Exempt paths: login page, static assets, auth login/register/logout. + // Exempt paths: login page, static assets, auth endpoints, OAuth callbacks. if path == "/login" || strings.HasPrefix(path, "/static/") || path == "/api/auth/login" || path == "/api/auth/register" || - path == "/api/auth/logout" { + path == "/api/auth/logout" || + path == "/api/auth/profile/oauth/lark/callback" { next.ServeHTTP(w, r) return } diff --git a/internal/admin/oauth.go b/internal/admin/oauth.go new file mode 100644 index 00000000..f2707689 --- /dev/null +++ b/internal/admin/oauth.go @@ -0,0 +1,345 @@ +package admin + +import ( + "context" + "fmt" + "net/http" + + "github.com/vaayne/anna/internal/oauthcli" +) + +const ( + pluginIDGitHub = "auth/github" + pluginIDLark = "auth/lark" +) + +// larkCallbackPath is the path Lark redirects back to after user authorization. +// It must match the redirect_uri configured in the Lark app. +const larkCallbackPath = "/api/auth/profile/oauth/lark/callback" + +// ghCallbackPath is the path GitHub redirects back to after user authorization. +const ghCallbackPath = "/api/auth/profile/oauth/github/callback" + +// getGitHubBroker returns the cached GitHubBroker, lazily constructing it from +// the current plugin config. Returns an error if the plugin is not configured. +func (s *Server) getGitHubBroker(ctx context.Context) (*oauthcli.GitHubBroker, error) { + state, err := s.pluginHost.Config().Get(ctx, pluginIDGitHub) + if err != nil { + return nil, fmt.Errorf("github plugin config unavailable: %w", err) + } + clientID, _ := state.Config["client_id"].(string) + clientSecret, _ := state.Config["client_secret"].(string) + if clientID == "" || clientSecret == "" { + return nil, fmt.Errorf("github OAuth app is not configured (set client_id and client_secret in auth/github plugin)") + } + redirectURI, _ := state.Config["redirect_url"].(string) + if redirectURI == "" { + redirectURI = s.corsOriginV + ghCallbackPath + } + + s.oauthMu.Lock() + defer s.oauthMu.Unlock() + if s.ghBroker == nil || s.ghBrokerClientID != clientID || s.ghBrokerRedirectURI != redirectURI { + s.ghBroker = oauthcli.NewGitHubBroker(oauthcli.GitHubConfig{ + ClientID: clientID, + ClientSecret: clientSecret, + }, s.flowStore).WithRedirectURI(redirectURI) + s.ghBrokerClientID = clientID + s.ghBrokerRedirectURI = redirectURI + } + return s.ghBroker, nil +} + +// getLarkBroker returns the cached LarkBroker, lazily constructing it from the +// current plugin config. Returns an error if the plugin is not configured. +func (s *Server) getLarkBroker(ctx context.Context) (*oauthcli.LarkBroker, error) { + state, err := s.pluginHost.Config().Get(ctx, pluginIDLark) + if err != nil { + return nil, fmt.Errorf("lark plugin config unavailable: %w", err) + } + appID, _ := state.Config["app_id"].(string) + appSecret, _ := state.Config["app_secret"].(string) + brand, _ := state.Config["brand"].(string) + if appID == "" || appSecret == "" { + return nil, fmt.Errorf("lark OAuth app is not configured (set app_id and app_secret in auth/lark plugin)") + } + if brand == "" { + brand = "lark" + } + + redirectURI, _ := state.Config["redirect_url"].(string) + if redirectURI == "" { + redirectURI = s.corsOriginV + larkCallbackPath + } + + s.oauthMu.Lock() + defer s.oauthMu.Unlock() + if s.larkBroker == nil || s.larkBrokerAppID != appID || s.larkBrokerRedirectURI != redirectURI { + s.larkBroker = oauthcli.NewLarkBroker(oauthcli.LarkConfig{ + AppID: appID, + AppSecret: appSecret, + Brand: brand, + }, s.flowStore).WithRedirectURI(redirectURI) + s.larkBrokerAppID = appID + s.larkBrokerRedirectURI = redirectURI + } + return s.larkBroker, nil +} + +// flowStatusJSON is the wire representation of an in-flight OAuth flow. +type flowStatusJSON struct { + Provider string `json:"provider"` + FlowID string `json:"flow_id"` + VerificationURI string `json:"verification_uri"` + UserCode string `json:"user_code,omitempty"` + ExpiresAt string `json:"expires_at"` + State string `json:"state"` +} + +func toFlowStatusJSON(fs oauthcli.FlowStatus) flowStatusJSON { + return flowStatusJSON{ + Provider: string(fs.Provider), + FlowID: fs.FlowID, + VerificationURI: fs.VerificationURI, + UserCode: fs.UserCode, + ExpiresAt: fs.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z"), + State: string(fs.State), + } +} + +// startOAuthFlow handles POST /api/auth/profile/oauth/{provider}/start. +func (s *Server) startOAuthFlow(w http.ResponseWriter, r *http.Request) { + if s.vaultSvc == nil { + writeError(w, http.StatusServiceUnavailable, "vault not configured") + return + } + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + provider := r.PathValue("provider") + ctx := r.Context() + + switch provider { + case "github": + broker, err := s.getGitHubBroker(ctx) + if err != nil { + writeError(w, http.StatusServiceUnavailable, err.Error()) + return + } + status, err := broker.StartDeviceFlow(ctx, info.UserID) + if err != nil { + s.log.Error("start github device flow", "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "failed to start GitHub device flow") + return + } + writeData(w, http.StatusOK, toFlowStatusJSON(status)) + + case "lark": + broker, err := s.getLarkBroker(ctx) + if err != nil { + writeError(w, http.StatusServiceUnavailable, err.Error()) + return + } + status, err := broker.StartDeviceFlow(ctx, info.UserID) + if err != nil { + s.log.Error("start lark device flow", "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "failed to start Lark device flow") + return + } + writeData(w, http.StatusOK, toFlowStatusJSON(status)) + + default: + writeError(w, http.StatusBadRequest, "unsupported provider: "+provider) + } +} + +// pollOAuthFlow handles GET /api/auth/profile/oauth/{provider}/status/{flowID}. +// For GitHub, if the flow is authorized this handler also calls Complete to +// persist the token bundle to vault. +func (s *Server) pollOAuthFlow(w http.ResponseWriter, r *http.Request) { + if s.vaultSvc == nil { + writeError(w, http.StatusServiceUnavailable, "vault not configured") + return + } + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + provider := r.PathValue("provider") + flowID := r.PathValue("flowID") + ctx := r.Context() + + switch provider { + case "github": + broker, err := s.getGitHubBroker(ctx) + if err != nil { + writeError(w, http.StatusServiceUnavailable, err.Error()) + return + } + status, err := broker.Poll(ctx, flowID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + // Persist token as soon as authorized. + if status.State == oauthcli.FlowStateAuthorized { + if cerr := broker.Complete(ctx, s.vaultSvc, info.UserID, flowID); cerr != nil { + s.log.Error("complete github flow", "user_id", info.UserID, "flow_id", flowID, "error", cerr) + writeError(w, http.StatusInternalServerError, "failed to save GitHub credentials") + return + } + } + writeData(w, http.StatusOK, toFlowStatusJSON(status)) + + case "lark": + broker, err := s.getLarkBroker(ctx) + if err != nil { + writeError(w, http.StatusServiceUnavailable, err.Error()) + return + } + status, err := broker.Poll(ctx, flowID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeData(w, http.StatusOK, toFlowStatusJSON(status)) + + default: + writeError(w, http.StatusBadRequest, "unsupported provider: "+provider) + } +} + +// getOAuthConnected handles GET /api/auth/profile/oauth/{provider}/connected. +func (s *Server) getOAuthConnected(w http.ResponseWriter, r *http.Request) { + if s.vaultSvc == nil { + writeError(w, http.StatusServiceUnavailable, "vault not configured") + return + } + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + provider := r.PathValue("provider") + ctx := r.Context() + + type connectedResp struct { + Connected bool `json:"connected"` + Username string `json:"username,omitempty"` + } + + switch provider { + case "github": + bundle, err := oauthcli.LoadGHBundle(ctx, s.vaultSvc, info.UserID) + if err != nil { + s.log.Error("load gh bundle", "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if bundle == nil { + writeData(w, http.StatusOK, connectedResp{Connected: false}) + return + } + writeData(w, http.StatusOK, connectedResp{Connected: true}) + + case "lark": + bundle, err := oauthcli.LoadLarkBundle(ctx, s.vaultSvc, info.UserID) + if err != nil { + s.log.Error("load lark bundle", "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if bundle == nil { + writeData(w, http.StatusOK, connectedResp{Connected: false}) + return + } + label := bundle.AppID + if bundle.Brand != "" { + label = bundle.Brand + ":" + bundle.AppID + } + writeData(w, http.StatusOK, connectedResp{Connected: true, Username: label}) + + default: + writeError(w, http.StatusBadRequest, "unsupported provider: "+provider) + } +} + +// disconnectOAuth handles DELETE /api/auth/profile/oauth/{provider}. +func (s *Server) disconnectOAuth(w http.ResponseWriter, r *http.Request) { + if s.vaultSvc == nil { + writeError(w, http.StatusServiceUnavailable, "vault not configured") + return + } + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + provider := r.PathValue("provider") + ctx := r.Context() + + var key string + switch provider { + case "github": + key = oauthcli.VaultKeyGitHub + case "lark": + key = oauthcli.VaultKeyLark + default: + writeError(w, http.StatusBadRequest, "unsupported provider: "+provider) + return + } + + if err := oauthcli.DeleteBundle(ctx, s.vaultSvc, info.UserID, key); err != nil { + s.log.Error("disconnect oauth", "provider", provider, "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "failed to disconnect") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// larkOAuthCallback handles GET /api/auth/profile/oauth/lark/callback. +// Lark redirects the browser here after the user authorizes the app. +// Query params: code=&state= +// This handler is exempt from authMiddleware; userID is resolved from the +// flow store via the state param so no session cookie is required. +func (s *Server) larkOAuthCallback(w http.ResponseWriter, r *http.Request) { + if s.vaultSvc == nil { + http.Error(w, "vault not configured", http.StatusServiceUnavailable) + return + } + + code := r.URL.Query().Get("code") + flowID := r.URL.Query().Get("state") + if code == "" || flowID == "" { + http.Error(w, "missing code or state", http.StatusBadRequest) + return + } + + flow, ok := s.flowStore.Get(flowID) + if !ok { + http.Error(w, "unknown or expired flow", http.StatusBadRequest) + return + } + + ctx := r.Context() + broker, err := s.getLarkBroker(ctx) + if err != nil { + http.Error(w, "lark not configured", http.StatusServiceUnavailable) + return + } + + if err := broker.Complete(ctx, s.vaultSvc, flow.UserID, flowID, code); err != nil { + s.log.Error("lark oauth complete", "user_id", flow.UserID, "flow_id", flowID, "error", err) + http.Error(w, "failed to complete Lark authorization: "+err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/profile", http.StatusFound) +} diff --git a/internal/admin/routes.go b/internal/admin/routes.go index 0d29fee0..52f3e5ee 100644 --- a/internal/admin/routes.go +++ b/internal/admin/routes.go @@ -49,6 +49,13 @@ func (s *Server) registerProfileRoutes() { s.mux.HandleFunc("PUT /api/auth/profile/vault/{name}", s.setVaultEntry) s.mux.HandleFunc("DELETE /api/auth/profile/vault/{name}", s.deleteVaultEntry) + // OAuth CLI device-flow (connect/disconnect GitHub and Lark credentials). + s.mux.HandleFunc("POST /api/auth/profile/oauth/{provider}/start", s.startOAuthFlow) + s.mux.HandleFunc("GET /api/auth/profile/oauth/{provider}/status/{flowID}", s.pollOAuthFlow) + s.mux.HandleFunc("GET /api/auth/profile/oauth/{provider}/connected", s.getOAuthConnected) + s.mux.HandleFunc("DELETE /api/auth/profile/oauth/{provider}", s.disconnectOAuth) + s.mux.HandleFunc("GET /api/auth/profile/oauth/lark/callback", s.larkOAuthCallback) + // Self-service user skills. s.mux.HandleFunc("GET /api/auth/profile/skills", s.listProfileSkills) s.mux.HandleFunc("POST /api/auth/profile/skills/install", s.installProfileSkill) @@ -65,6 +72,7 @@ func (s *Server) registerPageRoutes() { s.mux.HandleFunc("GET /channels", s.pageChannels) s.mux.Handle("GET /users", s.adminOnlyMiddleware(http.HandlerFunc(s.pageUsers))) s.mux.HandleFunc("GET /sessions", s.pageSessions) + s.mux.HandleFunc("GET /sessions/{sessionID}", s.pageSessions) s.mux.HandleFunc("GET /scheduler", s.pageScheduler) s.mux.Handle("GET /plugins", s.adminOnlyMiddleware(http.HandlerFunc(s.pagePlugins))) s.mux.HandleFunc("GET /profile", s.pageProfile) diff --git a/internal/admin/server.go b/internal/admin/server.go index d971bbe3..b9c4e5ff 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -5,12 +5,14 @@ import ( "database/sql" "log/slog" "net/http" + "sync" "filippo.io/age" "github.com/vaayne/anna/internal/agent" "github.com/vaayne/anna/internal/auth" "github.com/vaayne/anna/internal/config" + "github.com/vaayne/anna/internal/oauthcli" "github.com/vaayne/anna/internal/pluginhost" "github.com/vaayne/anna/internal/vault" "github.com/vaayne/anna/pkg/db/sqlc" @@ -34,6 +36,16 @@ type Server struct { corsOriginV string // cached CORS origin vaultRecipient *age.X25519Recipient // optional; if set, age keys are generated for new users vaultSvc *vault.Service // optional; if nil, vault endpoints return 503 + + // OAuth CLI device-flow state. + flowStore *oauthcli.FlowStore + oauthMu sync.Mutex + ghBroker *oauthcli.GitHubBroker // lazily initialised; guarded by oauthMu + ghBrokerClientID string // tracks which client_id ghBroker was built with + ghBrokerRedirectURI string // tracks which redirect_url ghBroker was built with + larkBroker *oauthcli.LarkBroker // lazily initialised; guarded by oauthMu + larkBrokerAppID string // tracks which app_id larkBroker was built with + larkBrokerRedirectURI string // tracks which redirect_url larkBroker was built with } // New creates an admin server with all API routes mounted. @@ -64,6 +76,7 @@ func New(store config.Store, authStore auth.AuthStore, engine *auth.PolicyEngine mux: http.NewServeMux(), log: slog.With("component", "admin"), corsOriginV: corsOrigin, + flowStore: oauthcli.NewFlowStore(), } s.registerRoutes() diff --git a/internal/admin/ui/pages/agents.templ b/internal/admin/ui/pages/agents.templ index 17d769e5..b0f1a858 100644 --- a/internal/admin/ui/pages/agents.templ +++ b/internal/admin/ui/pages/agents.templ @@ -11,6 +11,7 @@ templ AgentsPage() { @agentTemplateModal() @agentSkillInstallModal() @agentConfirmDialog() + @ui.SkillsDrawer() } diff --git a/internal/admin/ui/pages/agents_form.templ b/internal/admin/ui/pages/agents_form.templ index e5be7e90..d3a2e6b3 100644 --- a/internal/admin/ui/pages/agents_form.templ +++ b/internal/admin/ui/pages/agents_form.templ @@ -148,14 +148,13 @@ templ agentSkillsTab() {

Builtin skills

-

Anna's self-knowledge is always on. Toggle extras for this agent.

+

System-scope builtin skills are always available to every agent.