From 5bbb88c6b80eabaacc9c348761cecbf5e0c0464d Mon Sep 17 00:00:00 2001 From: Jazzen Chen Date: Thu, 23 Jul 2026 17:19:33 +0800 Subject: [PATCH 01/11] fix(auth): scope local agent API credentials --- docs/architecture/local-api-and-bridge.md | 4 +- docs/architecture/security-model.md | 2 +- docs/internals/modules/auth.md | 4 +- docs/reference/api-surfaces.md | 9 +- docs/reference/configuration.md | 4 + docs/zh/architecture/local-api-and-bridge.md | 4 +- docs/zh/architecture/security-model.md | 2 +- docs/zh/internals/modules/auth.md | 4 +- docs/zh/reference/api-surfaces.md | 9 +- docs/zh/reference/configuration.md | 4 + src/core/src/auth/mod.rs | 6 +- src/core/src/auth/token.rs | 23 ++++- .../src/Launch/LocalAgentApiPanel.tsx | 27 ++++-- src/desktop-ui/src/lib/api.ts | 10 ++ src/desktop/src/main.rs | 7 ++ src/server/src/lib.rs | 10 +- src/server/src/web_server/api_bridge.rs | 93 ++++++++++++++++++- .../src/web_server/api_bridge/local_agent.rs | 10 +- src/server/src/web_server/mod.rs | 17 +++- 19 files changed, 210 insertions(+), 39 deletions(-) diff --git a/docs/architecture/local-api-and-bridge.md b/docs/architecture/local-api-and-bridge.md index 98579ad5..0620d78d 100644 --- a/docs/architecture/local-api-and-bridge.md +++ b/docs/architecture/local-api-and-bridge.md @@ -37,6 +37,8 @@ Each family exposes the standard sub-paths clients expect: `/v1/chat/completions **Agent-as-API (`local-agent`).** Turns a hosted coding agent itself into an OpenAI/Anthropic-compatible endpoint: requests become prompts to a real agent (with its tools and workspace), responses stream back in the requested dialect. This lets any OpenAI-compatible tool drive a full coding agent. +The primary profile bridge and agent-as-API families use separate daemon-lifetime credentials. A client configured with `local-api-auth.json` can call `/local-api` but cannot start an agent; agent-as-API clients use `local-agent-api-auth.json`. + ## What happens to a request 1. **Decode** from the client dialect into the universal request. @@ -54,7 +56,7 @@ Authentication to upstream providers is static API key per profile. OAuth/subscr ## Trust boundary -The bridge binds to the loopback interface and requires the local bridge auth gate; it is not reachable through tunnels. Request bodies up to 64 MB are accepted to accommodate large context payloads. Credentials never appear in rendered client configs — only local URLs do; the daemon injects real keys upstream. +The bridge binds to the loopback interface and requires the local bridge gate; the primary `/local-api` and `/local-agent` paths additionally require their scoped credentials. It is not reachable through tunnels. Request bodies up to 64 MB are accepted to accommodate large context payloads. Provider credentials never appear in rendered client configs; the daemon injects real keys upstream. --- diff --git a/docs/architecture/security-model.md b/docs/architecture/security-model.md index cf722359..06290f3a 100644 --- a/docs/architecture/security-model.md +++ b/docs/architecture/security-model.md @@ -52,7 +52,7 @@ Share links are the only intentionally unauthenticated surface, scoped to one pr ## Credential handling - Provider API keys are stored in the profile store under `~/.vibearound/` and injected into upstream requests **by the daemon**. Rendered agent configs contain only local bridge URLs — a leaked agent config exposes no provider key. -- The bridge and agent-as-API endpoints are loopback-only and sit behind a local-bridge gate; they are not reachable through tunnels. +- The bridge and agent-as-API endpoints are loopback-only and sit behind a local-bridge gate; they are not reachable through tunnels. The primary `/local-api` and `/local-agent` families have separate rotating scoped tokens, so a model bridge client cannot launch an agent. - The daemon's own auth token file is plaintext in your home directory (same trust level as `~/.ssh`); treat backups accordingly. - Bridge request/response recording for the launch popup is held in memory only and never written to disk. diff --git a/docs/internals/modules/auth.md b/docs/internals/modules/auth.md index b41c0968..2e33b962 100644 --- a/docs/internals/modules/auth.md +++ b/docs/internals/modules/auth.md @@ -1,6 +1,6 @@ # Module: auth -`src/core/src/auth/` (current location) — credentials that gate server surfaces: the daemon token and short-lived pairing codes. Policy discussion: [security model](../../architecture/security-model.md). +`src/core/src/auth/` (current location) — credentials that gate server surfaces: the owner token, local API scoped tokens, and short-lived pairing codes. Policy discussion: [security model](../../architecture/security-model.md). ## Responsibility @@ -12,6 +12,8 @@ Generate and persist the daemon auth token, and manage the pairing-code table th |---|---|---| | `AuthToken` | `token.rs` | Random per-daemon-start bearer token | | `write_token_file` | `mod.rs` | Persists `{port, token}` to `~/.vibearound/auth.json` for out-of-process consumers (tray, CLI, desktop-ui) | +| `write_local_api_token_file` | `mod.rs` | Persists the credential accepted only by the profile-backed model bridge | +| `write_local_agent_api_token_file` | `mod.rs` | Persists the credential accepted only by the agent-as-API surface | | `pair` | `pair.rs` | 6-digit codes, 60 s TTL, verified via a trusted surface; `validate(code)` returns the token on success | ## Interactions diff --git a/docs/reference/api-surfaces.md b/docs/reference/api-surfaces.md index 97614b32..69ee9a63 100644 --- a/docs/reference/api-surfaces.md +++ b/docs/reference/api-surfaces.md @@ -20,7 +20,7 @@ Companion skills installed per agent (`skill_auto_install`): `vibearound` (hando ## Local API route families -Loopback-only, gated by the local-bridge check; bodies up to 64 MB. Mechanism: [Local API and bridge](../architecture/local-api-and-bridge.md). +Loopback-only and gated by the local-bridge check; the primary `/local-api` and `/local-agent` families also require their own credentials. Bodies up to 64 MB. Mechanism: [Local API and bridge](../architecture/local-api-and-bridge.md). ```text /va/local-api/{profile}/{scope}/{api_type}/v1/{responses | chat/completions | messages | models} @@ -32,18 +32,20 @@ Loopback-only, gated by the local-bridge check; bodies up to 64 MB. Mechanism: [ ### Copy-paste examples -Local-bridge routes need **no Authorization header** — the gate is loopback peer + loopback Host (they are unreachable through tunnels). Substitute your profile id and a model id the profile exposes. +Set `LOCAL_API_KEY` to the `token` in `~/.vibearound/local-api-auth.json`, and `LOCAL_AGENT_API_KEY` to the `token` in `~/.vibearound/local-agent-api-auth.json`. Both rotate on daemon restart. The desktop Local API panel exposes the agent-as-API key for copying. List the models a profile serves: ```bash -curl http://127.0.0.1:12358/va/local-api/moonshot/curl-test/openai-chat/v1/models +curl http://127.0.0.1:12358/va/local-api/moonshot/curl-test/openai-chat/v1/models \ + -H "Authorization: Bearer $LOCAL_API_KEY" ``` Chat completion through the bridge (client speaks OpenAI Chat; the daemon translates to whatever the profile's provider speaks): ```bash curl http://127.0.0.1:12358/va/local-api/moonshot/curl-test/openai-chat/v1/chat/completions \ + -H "Authorization: Bearer $LOCAL_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"model": "kimi-k2.7-code", "messages": [{"role": "user", "content": "hello"}]}' ``` @@ -52,6 +54,7 @@ Agent-as-API — the same request shape, but executed by a hosted coding agent ( ```bash curl http://127.0.0.1:12358/va/local-agent/claude/direct/v1/chat/completions \ + -H "Authorization: Bearer $LOCAL_AGENT_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"model": "claude", "messages": [{"role": "user", "content": "what does this repo do?"}]}' ``` diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 16a8f6f6..09897cf9 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -12,6 +12,8 @@ Everything lives under `~/.vibearound/` (override with `VIBEAROUND_DATA_DIR`): | `agents.json` | desktop Launch UI, `va-launch` (executable discovery) | Per-agent launch preferences — [schema below](#agentsjson) | Yes, carefully | | `launch/profiles/.json` | you, desktop (temp materialized copies) | Saved native-launch profiles — [schema below](#launch-profile-json-schema-v1) | **Yes** (that is the point) | | `auth.json` | daemon, every start | `{port, token}` for out-of-process clients | No — rewritten each start | +| `local-api-auth.json` | daemon, every start | Scoped `{port, token}` for profile-backed model bridge clients | No — rewritten each start | +| `local-agent-api-auth.json` | daemon, every start | Scoped `{port, token}` for agent-as-API clients | No — rewritten each start | | `profiles/.json` | desktop/dashboard profile UI | Saved model profiles (provider, endpoint, key, model routes) | Prefer the UI; hand-edits are read on reload | | `profile-state//` | profile rendering | Rendered per-profile agent config files (settings overlays); env pointers reference these ([launch internals](../internals/launch.md#environment-assembly-layer-by-layer)) | No — regenerated per render | | `plugins//` | desktop plugin manager | Installed channel plugins + manifests | Only during plugin development | @@ -160,6 +162,8 @@ Two "profile" concepts meet here and must not be confused: a **provider profile* ~/.vibearound/ ├── settings.json # configuration (this page) ├── auth.json # dashboard token, rewritten each daemon start +├── local-api-auth.json # profile bridge token, rewritten each daemon start +├── local-agent-api-auth.json # agent-as-API token, rewritten each daemon start ├── agents.json # resolved agent executables (va-launch cache) ├── plugins// # installed channel plugins ├── launch/profiles/ # saved launch profile JSON (schema v1) diff --git a/docs/zh/architecture/local-api-and-bridge.md b/docs/zh/architecture/local-api-and-bridge.md index fadd305e..99d7bf33 100644 --- a/docs/zh/architecture/local-api-and-bridge.md +++ b/docs/zh/architecture/local-api-and-bridge.md @@ -37,6 +37,8 @@ Bridge 把两者解耦。它在本地端点上接受**客户端**方言的请求 **Agent-as-API(`local-agent`)。** 把一个托管的编程 Agent 本身变成 OpenAI/Anthropic 兼容端点:请求变成发给真实 Agent(带工具和 Workspace)的提示,响应以所请求的方言流回。任何 OpenAI 兼容工具都能借此驱动一个完整的编程 Agent。 +主路径 Profile Bridge 与 Agent-as-API 使用彼此独立、随守护进程轮换的凭证。拿到 `local-api-auth.json` 的客户端只能调用 `/local-api`,不能启动 Agent;Agent-as-API 客户端使用 `local-agent-api-auth.json`。 + ## 一个请求经历了什么 1. **解码**:从客户端方言解码为统一请求。 @@ -54,7 +56,7 @@ Bridge 把两者解耦。它在本地端点上接受**客户端**方言的请求 ## 信任边界 -Bridge 绑定回环接口,且要求本地 bridge 认证门;隧道无法触达它。请求体最大接受 64 MB,以容纳大上下文负载。凭据永远不出现在渲染给客户端的配置里 —— 里面只有本地 URL;真正的 key 由守护进程注入上游请求。 +Bridge 绑定回环接口并要求本地 bridge 门禁;主路径 `/local-api` 和 `/local-agent` 还分别要求自己的 scoped credential。隧道无法触达它。请求体最大接受 64 MB,以容纳大上下文负载。供应商凭据不会出现在渲染给客户端的配置里;真正的 key 由守护进程注入上游请求。 --- diff --git a/docs/zh/architecture/security-model.md b/docs/zh/architecture/security-model.md index d561710e..92d088aa 100644 --- a/docs/zh/architecture/security-model.md +++ b/docs/zh/architecture/security-model.md @@ -52,7 +52,7 @@ dev server 实时预览和 Markdown 渲染有两种 URL 形式: ## 凭据处理 - 供应商 API key 存在 `~/.vibearound/` 下的 Profile 存储里,**由守护进程**注入上游请求。渲染给 Agent 的配置只含本地 Bridge URL —— Agent 配置泄露不会暴露任何供应商 key。 -- Bridge 和 agent-as-API 端点仅监听回环地址,且有本地 bridge 门;隧道无法触达。 +- Bridge 和 agent-as-API 端点仅监听回环地址,且有本地 bridge 门;隧道无法触达。主路径 `/local-api` 和 `/local-agent` 各有独立、随守护进程轮换的 scoped token,因此模型 Bridge 客户端不能启动 Agent。 - 守护进程自己的 token 文件是家目录里的明文(信任级别等同 `~/.ssh`);备份时相应对待。 - 启动弹窗的 Bridge 请求/响应记录只在内存里,从不落盘。 diff --git a/docs/zh/internals/modules/auth.md b/docs/zh/internals/modules/auth.md index 13bb3e48..202a90a2 100644 --- a/docs/zh/internals/modules/auth.md +++ b/docs/zh/internals/modules/auth.md @@ -1,6 +1,6 @@ # Module: auth -`src/core/src/auth/`(当前位置):server surfaces 使用的 daemon token 与短寿命 pairing codes。策略讨论见[安全模型](../../architecture/security-model.md)。 +`src/core/src/auth/`(当前位置):server surfaces 使用的 owner token、本地 API scoped token 与短寿命 pairing codes。策略讨论见[安全模型](../../architecture/security-model.md)。 ## 职责 @@ -12,6 +12,8 @@ |---|---|---| | `AuthToken` | `token.rs` | 每次 daemon start 都随机生成的 bearer token | | `write_token_file` | `mod.rs` | 把 `{port, token}` 持久化到 `~/.vibearound/auth.json`,供进程外消费者使用(tray、CLI、desktop-ui) | +| `write_local_api_token_file` | `mod.rs` | 持久化仅由 Profile 模型 Bridge 接受的凭据 | +| `write_local_agent_api_token_file` | `mod.rs` | 持久化仅由 Agent-as-API 接受的凭据 | | `pair` | `pair.rs` | 6 位 codes,60 秒 TTL,通过 trusted surface 验证;`validate(code)` 成功时返回 token | ## 交互 diff --git a/docs/zh/reference/api-surfaces.md b/docs/zh/reference/api-surfaces.md index bfea1a6d..019e5090 100644 --- a/docs/zh/reference/api-surfaces.md +++ b/docs/zh/reference/api-surfaces.md @@ -20,7 +20,7 @@ ## 本地 API 路由族 -仅回环地址,由本地 bridge 检查把守;请求体最大 64 MB。机制见[本地 API 与 Bridge](../architecture/local-api-and-bridge.md)。 +仅回环地址,并由本地 bridge 检查把守;主路径 `/local-api` 和 `/local-agent` 还分别要求自己的凭证。请求体最大 64 MB。机制见[本地 API 与 Bridge](../architecture/local-api-and-bridge.md)。 ```text /va/local-api/{profile}/{scope}/{api_type}/v1/{responses | chat/completions | messages | models} @@ -32,18 +32,20 @@ ### 可直接复制的示例 -本地 bridge 路由**不需要 Authorization 头** —— 门禁是回环对端 + 回环 Host(隧道无法触达它们)。把 profile id 和模型 id 换成你的 Profile 暴露的值。 +把 `LOCAL_API_KEY` 设为 `~/.vibearound/local-api-auth.json` 中的 `token`,把 `LOCAL_AGENT_API_KEY` 设为 `~/.vibearound/local-agent-api-auth.json` 中的 `token`。两者都会在守护进程重启时轮换。桌面端的本地 API 面板也可直接复制 Agent-as-API key。 列出某个 Profile 提供的模型: ```bash -curl http://127.0.0.1:12358/va/local-api/moonshot/curl-test/openai-chat/v1/models +curl http://127.0.0.1:12358/va/local-api/moonshot/curl-test/openai-chat/v1/models \ + -H "Authorization: Bearer $LOCAL_API_KEY" ``` 经 Bridge 的 chat completion(客户端说 OpenAI Chat;守护进程翻译成该 Profile 供应商说的方言): ```bash curl http://127.0.0.1:12358/va/local-api/moonshot/curl-test/openai-chat/v1/chat/completions \ + -H "Authorization: Bearer $LOCAL_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"model": "kimi-k2.7-code", "messages": [{"role": "user", "content": "hello"}]}' ``` @@ -52,6 +54,7 @@ Agent-as-API —— 同样的请求形状,但由托管的编程 Agent(带工 ```bash curl http://127.0.0.1:12358/va/local-agent/claude/direct/v1/chat/completions \ + -H "Authorization: Bearer $LOCAL_AGENT_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"model": "claude", "messages": [{"role": "user", "content": "what does this repo do?"}]}' ``` diff --git a/docs/zh/reference/configuration.md b/docs/zh/reference/configuration.md index d9710d2d..d1842550 100644 --- a/docs/zh/reference/configuration.md +++ b/docs/zh/reference/configuration.md @@ -12,6 +12,8 @@ VibeAround 读写的每一个文件,可手动编辑的都附完整 schema。 | `agents.json` | 桌面 Launch UI、`va-launch`(可执行文件发现) | 按 Agent 的启动偏好 —— [schema 见下](#agentsjson) | 可以,小心改 | | `launch/profiles/.json` | 你、桌面(临时物化副本) | 保存的原生启动配置 —— [schema 见下](#launch-profile-json-schema-v1) | **可以**(本来就是给你编辑的) | | `auth.json` | 守护进程,每次启动 | 供进程外客户端使用的 `{port, token}` | 不行 —— 每次启动重写 | +| `local-api-auth.json` | 守护进程,每次启动 | Profile 模型 Bridge 客户端专用的 `{port, token}` | 不行 —— 每次启动重写 | +| `local-agent-api-auth.json` | 守护进程,每次启动 | Agent-as-API 客户端专用的 `{port, token}` | 不行 —— 每次启动重写 | | `profiles/.json` | 桌面/控制台 Profile UI | 保存的模型 Profile(供应商、端点、key、模型路由) | 优先用 UI;手动改动会在重载时读取 | | `profile-state//` | Profile 渲染 | 渲染出的按 Profile 的 Agent 配置文件(设置覆盖层);环境指针引用这些([启动内幕](../internals/launch.md#environment-assembly-layer-by-layer)) | 不行 —— 每次渲染重新生成 | | `plugins//` | 桌面插件管理器 | 已安装的渠道插件 + 清单 | 仅插件开发期间 | @@ -160,6 +162,8 @@ VibeAround 还会写**每个已启用 Agent 自己的全局配置**(MCP server ~/.vibearound/ ├── settings.json # 配置(本页) ├── auth.json # 控制台 token,每次守护进程启动重写 +├── local-api-auth.json # Profile Bridge token,每次守护进程启动重写 +├── local-agent-api-auth.json # Agent-as-API token,每次守护进程启动重写 ├── agents.json # 解析出的 Agent 可执行文件(va-launch 缓存) ├── plugins// # 已安装的渠道插件 ├── launch/profiles/ # 保存的启动配置 JSON(schema v1) diff --git a/src/core/src/auth/mod.rs b/src/core/src/auth/mod.rs index 2e4105e7..31f18ff2 100644 --- a/src/core/src/auth/mod.rs +++ b/src/core/src/auth/mod.rs @@ -9,6 +9,8 @@ pub mod token; // Re-export the most commonly used items so existing `use common::auth::*` // call sites keep working without changes. pub use token::{ - local_api_token_file_path, read_local_api_token_file, read_token_file, set_owner_only, - token_file_path, write_local_api_token_file, write_token_file, AuthFile, AuthToken, + local_agent_api_token_file_path, local_api_token_file_path, read_local_agent_api_token_file, + read_local_api_token_file, read_token_file, set_owner_only, token_file_path, + write_local_agent_api_token_file, write_local_api_token_file, write_token_file, AuthFile, + AuthToken, }; diff --git a/src/core/src/auth/token.rs b/src/core/src/auth/token.rs index 47054a8f..c047d633 100644 --- a/src/core/src/auth/token.rs +++ b/src/core/src/auth/token.rs @@ -14,9 +14,11 @@ //! - On every daemon start we generate a fresh 32-byte token from `OsRng`. //! - The dashboard token is hex-encoded (64 chars) and written to //! `~/.vibearound/auth.json` with mode `0600` on Unix. -//! - A separate daemon-lifetime token in `~/.vibearound/local-api-auth.json` -//! authorizes only the local API bridge. Giving an agent that credential -//! does not grant access to dashboard or control routes. +//! - Separate daemon-lifetime tokens authorize the provider bridge +//! (`~/.vibearound/local-api-auth.json`) and agent-as-API +//! (`~/.vibearound/local-agent-api-auth.json`) route families. Giving an +//! agent a bridge credential does not grant access to dashboard/control +//! routes or permission to launch another agent. //! - `auth.json` stores `{ "port": , "token": "" }` so the Tauri //! tray and desktop-ui can discover both values without a side channel. //! - The HTTP layer enforces the token on every protected route via a @@ -81,6 +83,11 @@ pub fn local_api_token_file_path() -> PathBuf { config::data_dir().join("local-api-auth.json") } +/// Path of the local agent API token file. +pub fn local_agent_api_token_file_path() -> PathBuf { + config::data_dir().join("local-agent-api-auth.json") +} + /// Write the auth token file with owner-only permissions on Unix. /// /// Overwrites any prior file. Callers should invoke this once at daemon @@ -94,6 +101,11 @@ pub fn write_local_api_token_file(port: u16, token: &AuthToken) -> std::io::Resu write_auth_file(&local_api_token_file_path(), port, token) } +/// Write the scoped local agent API token file. +pub fn write_local_agent_api_token_file(port: u16, token: &AuthToken) -> std::io::Result<()> { + write_auth_file(&local_agent_api_token_file_path(), port, token) +} + fn write_auth_file(path: &std::path::Path, port: u16, token: &AuthToken) -> std::io::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; @@ -118,6 +130,11 @@ pub fn read_local_api_token_file() -> Option { read_auth_file(&local_api_token_file_path()) } +/// Read the scoped local agent API token file, if present and well-formed. +pub fn read_local_agent_api_token_file() -> Option { + read_auth_file(&local_agent_api_token_file_path()) +} + fn read_auth_file(path: &std::path::Path) -> Option { let body = fs::read_to_string(path).ok()?; serde_json::from_str(&body).ok() diff --git a/src/desktop-ui/src/Launch/LocalAgentApiPanel.tsx b/src/desktop-ui/src/Launch/LocalAgentApiPanel.tsx index f38f67c7..98e682c5 100644 --- a/src/desktop-ui/src/Launch/LocalAgentApiPanel.tsx +++ b/src/desktop-ui/src/Launch/LocalAgentApiPanel.tsx @@ -21,7 +21,7 @@ import { useI18n } from "@va/i18n"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { API_BASE } from "@/lib/api"; +import { API_BASE, apiFetch, getLocalAgentApiToken } from "@/lib/api"; import { cn } from "@/lib/utils"; import { LOCAL_API_PROTOCOLS, @@ -79,6 +79,7 @@ export function LocalAgentApiPanel({ const [dragDepth, setDragDepth] = useState(0); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); + const [clientKey, setClientKey] = useState(""); const basePath = localAgentBasePath(target); const baseUrl = `${API_BASE}${basePath}`; @@ -98,6 +99,7 @@ export function LocalAgentApiPanel({ useEffect(() => { if (!target) return; + let cancelled = false; setProtocol("openai-responses"); setPrompt(DEFAULT_TEST_PROMPT); setModel(""); @@ -109,13 +111,18 @@ export function LocalAgentApiPanel({ setAttachmentError(null); setDragDepth(0); setTestResult(null); + setClientKey(""); + void getLocalAgentApiToken().then((token) => { + if (!cancelled && token) setClientKey(token); + }); if (!serviceEnabled) { setModelLoading(false); setModelLoadError(true); - return; + return () => { + cancelled = true; + }; } - let cancelled = false; - void fetch(`${API_BASE}${localAgentBasePath(target)}/models`, { + void apiFetch(`${localAgentBasePath(target)}/models`, { headers: { "x-vibearound-cwd": target.workspacePath, }, @@ -296,8 +303,8 @@ export function LocalAgentApiPanel({ setTesting(true); setTestResult(null); try { - const response = await fetch( - `${API_BASE}${basePath}/${selectedProtocol.endpoint}`, + const response = await apiFetch( + `${basePath}/${selectedProtocol.endpoint}`, { method: "POST", headers: { @@ -374,6 +381,14 @@ export function LocalAgentApiPanel({ copied={copiedKey === "models"} onCopy={() => copyValue("models", modelsUrl)} /> + {clientKey && ( + copyValue("api-key", clientKey)} + /> + )} { return fetchToken(); } +export async function getLocalAgentApiToken(): Promise { + try { + const file = await invoke("get_local_agent_api_token"); + return file?.token ?? null; + } catch (e) { + console.warn("[desktop-ui] get_local_agent_api_token failed:", e); + return null; + } +} + /** * Authenticated fetch against the daemon. Transparently re-fetches the * token on a 401 (daemon restart invalidates the previous token). diff --git a/src/desktop/src/main.rs b/src/desktop/src/main.rs index 6a7489f0..fd71c209 100644 --- a/src/desktop/src/main.rs +++ b/src/desktop/src/main.rs @@ -109,6 +109,12 @@ fn get_auth_token() -> Option { common::auth::read_token_file() } +/// Return the scoped credential for external agent-as-API clients. +#[tauri::command] +fn get_local_agent_api_token() -> Option { + common::auth::read_local_agent_api_token_file() +} + #[tauri::command] fn get_app_info() -> AppInfo { AppInfo { @@ -255,6 +261,7 @@ fn main() { .manage(StartkitRunState::default()) .invoke_handler(tauri::generate_handler![ get_auth_token, + get_local_agent_api_token, get_app_info, rescan_agent_entries, rescan_desktop_app_entries, diff --git a/src/server/src/lib.rs b/src/server/src/lib.rs index 419bf4e9..9c61b873 100644 --- a/src/server/src/lib.rs +++ b/src/server/src/lib.rs @@ -42,6 +42,8 @@ pub struct ServerDaemon { pub auth_token: Arc, /// Scoped credential accepted only by the local API bridge. local_api_token: Arc, + /// Scoped credential accepted only by the agent-as-API surface. + local_agent_api_token: Arc, } pub struct RunningDaemon { @@ -239,6 +241,7 @@ impl ServerDaemon { port, auth_token: Arc::new(AuthToken::generate()), local_api_token: Arc::new(AuthToken::generate()), + local_agent_api_token: Arc::new(AuthToken::generate()), } } @@ -252,7 +255,7 @@ impl ServerDaemon { Arc::clone(&self.auth_token) } - /// Write the dashboard and local API token files so their respective + /// Write daemon-lifetime token files so their respective /// out-of-process clients can authenticate without an IPC round-trip. /// /// Safe to call before `start_background()` — the file will be @@ -261,7 +264,8 @@ impl ServerDaemon { /// the daemon's start path has finished persisting it. pub fn persist_auth_tokens(&self) -> std::io::Result<()> { auth::write_token_file(self.port, &self.auth_token)?; - auth::write_local_api_token_file(self.port, &self.local_api_token) + auth::write_local_api_token_file(self.port, &self.local_api_token)?; + auth::write_local_agent_api_token_file(self.port, &self.local_agent_api_token) } pub async fn start_background(&self, dist_path: PathBuf) -> anyhow::Result { @@ -356,6 +360,7 @@ impl ServerDaemon { let web_channel_manager = Arc::clone(&web_channel); let web_auth_token = Arc::clone(&self.auth_token); let web_local_api_token = Arc::clone(&self.local_api_token); + let web_local_agent_api_token = Arc::clone(&self.local_agent_api_token); let web_search_runtime = search_runtime.clone(); let web_search_available = host_search_available; let web_replace_provider_search = replace_provider_web_search; @@ -371,6 +376,7 @@ impl ServerDaemon { web_channel_manager, web_auth_token, web_local_api_token, + web_local_agent_api_token, web_search_available, web_replace_provider_search, web_search_runtime, diff --git a/src/server/src/web_server/api_bridge.rs b/src/server/src/web_server/api_bridge.rs index dac10786..9b4f7e3d 100644 --- a/src/server/src/web_server/api_bridge.rs +++ b/src/server/src/web_server/api_bridge.rs @@ -1,4 +1,7 @@ -use axum::http::{HeaderMap, StatusCode}; +use axum::body::Body; +use axum::extract::State; +use axum::http::{HeaderMap, Request, StatusCode}; +use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use axum::Json; use common::config; @@ -8,6 +11,7 @@ use common::profiles::schema::ProfileDef; use common::profiles::{catalog, connections}; use serde_json::{json, Value}; use std::collections::BTreeMap; +use std::sync::Arc; use va_ai_api_bridge::{ DeepSeekBridgeSettings, ProviderBridgeAdapter, ProviderBridgeAdapterConfig, UniversalRequest, UniversalResponse, @@ -1209,6 +1213,54 @@ fn validate_manual_scope(scope: &str) -> Result<(), (StatusCode, String)> { Ok(()) } +#[derive(Clone)] +pub(crate) struct LocalAgentCredential { + token: Arc, + owner_token: Arc, +} + +impl LocalAgentCredential { + pub(crate) fn new( + token: Arc, + owner_token: Arc, + ) -> Self { + Self { token, owner_token } + } +} + +pub(crate) async fn require_local_agent_credential( + State(state): State, + request: Request, + next: Next, +) -> Response { + if let Some(response) = + validate_local_agent_client_token(&state.token, &state.owner_token, request.headers()) + { + return response; + } + next.run(request).await +} + +fn validate_local_agent_client_token( + auth_token: &common::auth::AuthToken, + owner_token: &common::auth::AuthToken, + headers: &HeaderMap, +) -> Option { + let Some(candidate) = upstream::inbound_api_key(headers) else { + return Some(json_error( + StatusCode::UNAUTHORIZED, + "missing local agent API client key", + )); + }; + if auth_token.matches(&candidate) || owner_token.matches(&candidate) { + return None; + } + Some(json_error( + StatusCode::UNAUTHORIZED, + "invalid local agent API client key", + )) +} + fn validate_local_bridge_client(state: &AppState, headers: &HeaderMap) -> Option { validate_local_bridge_client_token(&state.local_api_token, headers) } @@ -1293,7 +1345,8 @@ mod tests { use super::{ client_api_type_from_scope, has_auth_header, proxy_http_client, render_bridge_headers, - validate_local_bridge_client_token, validate_manual_scope, + validate_local_agent_client_token, validate_local_bridge_client_token, + validate_manual_scope, }; #[test] @@ -1365,6 +1418,42 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[test] + fn local_agent_api_rejects_bridge_token() { + let local_agent_api_token = AuthToken::generate(); + let bridge_token = AuthToken::generate(); + let owner_token = AuthToken::generate(); + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", bridge_token.as_str())).unwrap(), + ); + + let response = + validate_local_agent_client_token(&local_agent_api_token, &owner_token, &headers) + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn local_agent_api_accepts_scoped_and_owner_tokens() { + let local_agent_api_token = AuthToken::generate(); + let owner_token = AuthToken::generate(); + for token in [&local_agent_api_token, &owner_token] { + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", token.as_str())).unwrap(), + ); + assert!(validate_local_agent_client_token( + &local_agent_api_token, + &owner_token, + &headers, + ) + .is_none()); + } + } + #[test] fn local_bridge_accepts_google_api_key_header() { let token = AuthToken::generate(); diff --git a/src/server/src/web_server/api_bridge/local_agent.rs b/src/server/src/web_server/api_bridge/local_agent.rs index aca6c38f..f0129c49 100644 --- a/src/server/src/web_server/api_bridge/local_agent.rs +++ b/src/server/src/web_server/api_bridge/local_agent.rs @@ -1,7 +1,7 @@ use std::path::{Path as StdPath, PathBuf}; use axum::body::Bytes; -use axum::extract::{Path, State}; +use axum::extract::Path; use axum::http::{HeaderMap, StatusCode}; use axum::response::Response; use serde_json::Value; @@ -15,14 +15,12 @@ mod turn; pub use models::local_agent_models_handler; use super::{json_error, record_json_error, BridgeProtocol}; -use crate::web_server::AppState; use prompt::universal_request_to_acp_prompt; pub(super) const LOCAL_AGENT_CHANNEL_KIND: &str = "api"; const HEADER_WORKSPACE: &str = "x-vibearound-cwd"; pub async fn local_agent_responses_handler( - State(state): State, Path((agent_id, profile_id)): Path<(String, String)>, headers: HeaderMap, body: Bytes, @@ -31,7 +29,6 @@ pub async fn local_agent_responses_handler( return local_agent_api_disabled_response(); } handle_local_agent_request( - state, agent_id, profile_id, BridgeProtocol::OpenAiResponses, @@ -42,7 +39,6 @@ pub async fn local_agent_responses_handler( } pub async fn local_agent_chat_completions_handler( - State(state): State, Path((agent_id, profile_id)): Path<(String, String)>, headers: HeaderMap, body: Bytes, @@ -51,7 +47,6 @@ pub async fn local_agent_chat_completions_handler( return local_agent_api_disabled_response(); } handle_local_agent_request( - state, agent_id, profile_id, BridgeProtocol::OpenAiChat, @@ -62,7 +57,6 @@ pub async fn local_agent_chat_completions_handler( } pub async fn local_agent_messages_handler( - State(state): State, Path((agent_id, profile_id)): Path<(String, String)>, headers: HeaderMap, body: Bytes, @@ -71,7 +65,6 @@ pub async fn local_agent_messages_handler( return local_agent_api_disabled_response(); } handle_local_agent_request( - state, agent_id, profile_id, BridgeProtocol::AnthropicMessages, @@ -93,7 +86,6 @@ pub(super) fn local_agent_api_disabled_response() -> Response { } async fn handle_local_agent_request( - _state: AppState, agent_id: String, profile_id: String, protocol: BridgeProtocol, diff --git a/src/server/src/web_server/mod.rs b/src/server/src/web_server/mod.rs index 9d8e5bb0..e4d9175c 100644 --- a/src/server/src/web_server/mod.rs +++ b/src/server/src/web_server/mod.rs @@ -192,6 +192,7 @@ pub async fn run_web_server( web_channel: Arc, auth_token: Arc, local_api_token: Arc, + local_agent_api_token: Arc, host_search_available: bool, replace_provider_web_search: bool, search_runtime: Option>, @@ -400,7 +401,7 @@ pub async fn run_web_server( // Preview routes are also un-authed — the 8-char slug itself acts as a // short-lived authentication token (10-min TTL, cryptographically random; // single source of truth: `common::previews::SHARE_TTL_SECS`). - let bridge_routes = Router::new() + let local_agent_routes = Router::new() .route( "/local-agent/{agent_id}/{profile_id}/v1/responses", post(api_bridge::local_agent_responses_handler), @@ -417,6 +418,12 @@ pub async fn run_web_server( "/local-agent/{agent_id}/{profile_id}/v1/models", get(api_bridge::local_agent_models_handler), ) + .route_layer(axum::middleware::from_fn_with_state( + api_bridge::LocalAgentCredential::new(local_agent_api_token, Arc::clone(&auth_token)), + api_bridge::require_local_agent_credential, + )); + + let bridge_routes = Router::new() .route( "/bridge/{profile_id}/{target_api_type}/v1/responses", post(api_bridge::legacy_responses_handler), @@ -458,12 +465,16 @@ pub async fn run_web_server( .route( bridge_url::LOCAL_API_GEMINI_MODELS_ACTION_ROUTE, post(api_bridge::local_gemini_generate_content_handler), - ) + ); + + let local_api_routes = Router::new() + .merge(local_agent_routes) + .merge(bridge_routes) .route_layer(axum::middleware::from_fn(require_local_bridge)) .layer(DefaultBodyLimit::max(LOCAL_BRIDGE_BODY_LIMIT_BYTES)); let public = Router::new() - .merge(bridge_routes) + .merge(local_api_routes) .route("/api/service/health", get(api::health_handler)) // Pairing API: no auth required (pairing IS the auth flow). .route("/api/pair/start", post(pair::start_handler)) From 8fbc6c3b75d71f5d410a620d7794d164fbb083d7 Mon Sep 17 00:00:00 2001 From: Jazzen Chen Date: Thu, 23 Jul 2026 17:20:37 +0800 Subject: [PATCH 02/11] fix(desktop): stop daemon before exit on all platforms --- src/desktop/src/main.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/desktop/src/main.rs b/src/desktop/src/main.rs index fd71c209..5b6707db 100644 --- a/src/desktop/src/main.rs +++ b/src/desktop/src/main.rs @@ -224,7 +224,6 @@ fn main() { let port = common::config::DEFAULT_PORT; let daemon = Arc::new(server::ServerDaemon::new(port)); let tunnels = daemon.tunnels(); - #[cfg(windows)] let graceful_exit_started = Arc::new(std::sync::atomic::AtomicBool::new(false)); // Persist both daemon-lifetime tokens before the desktop-ui starts @@ -407,16 +406,14 @@ fn main() { .build(tauri::generate_context!()) .expect("error while building VibeAround") .run({ - #[cfg(windows)] let graceful_exit_started = Arc::clone(&graceful_exit_started); - move |_app, event| { - #[cfg(windows)] + move |app, event| { if let tauri::RunEvent::ExitRequested { api, code, .. } = &event { if *code != Some(tauri::RESTART_EXIT_CODE) && !graceful_exit_started.swap(true, std::sync::atomic::Ordering::SeqCst) { api.prevent_exit(); - let app_handle = _app.clone(); + let app_handle = app.clone(); let exit_code = code.unwrap_or(0); tauri::async_runtime::spawn(async move { if let Err(error) = stop_daemon(&app_handle).await { From 18193d623114a9c3549f6ccb5edaf17b746c1b13 Mon Sep 17 00:00:00 2001 From: Jazzen Chen Date: Thu, 23 Jul 2026 17:22:11 +0800 Subject: [PATCH 03/11] fix(pty): release naturally exited sessions --- src/core/src/pty/manager.rs | 47 +++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/src/core/src/pty/manager.rs b/src/core/src/pty/manager.rs index 7bb38b01..307d6f39 100644 --- a/src/core/src/pty/manager.rs +++ b/src/core/src/pty/manager.rs @@ -149,7 +149,7 @@ impl PtySessionManager { let buf_clone = Arc::clone(&buffer); let tx_clone = live_tx.clone(); - tokio::spawn(async move { + let output_task = tokio::spawn(async move { while let Some(data) = pty_rx.recv().await { buf_clone.push(&data); let _ = tx_clone.send(Bytes::from(data)); @@ -158,7 +158,7 @@ impl PtySessionManager { let rs = Arc::clone(&run_state); let changes_tx = self.changes_tx.clone(); - tokio::spawn(async move { + let state_task = tokio::spawn(async move { while let Some(new_state) = state_rx.recv().await { if let Ok(mut g) = rs.write() { *g = new_state; @@ -167,6 +167,16 @@ impl PtySessionManager { } }); + let registry = Arc::clone(&self.registry); + let changes_tx = self.changes_tx.clone(); + tokio::spawn(async move { + let _ = output_task.await; + let _ = state_task.await; + if registry.remove(&session_id).is_some() { + let _ = changes_tx.send(()); + } + }); + created } @@ -280,3 +290,36 @@ impl Default for PtySessionManager { Self::new() } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[tokio::test] + async fn natural_exit_removes_session() { + let manager = PtySessionManager::new(); + manager + .create_command_session( + PtyTool::Generic, + "exit 0".to_string(), + Vec::new(), + None, + None, + None, + None, + None, + None, + ) + .unwrap(); + + tokio::time::timeout(Duration::from_secs(3), async { + while !manager.list_sessions().is_empty() { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("naturally exited PTY session should be removed"); + } +} From be44e325af8b04e2ec7e3723372bb04f4fa643f3 Mon Sep 17 00:00:00 2001 From: Jazzen Chen Date: Thu, 23 Jul 2026 17:23:39 +0800 Subject: [PATCH 04/11] fix(storage): serialize workspace event store IO --- src/core/src/workspace/store.rs | 36 +++++++++++++++++++++- src/core/src/workspace/threads/store.rs | 41 ++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/core/src/workspace/store.rs b/src/core/src/workspace/store.rs index 541632bf..26acfe1e 100644 --- a/src/core/src/workspace/store.rs +++ b/src/core/src/workspace/store.rs @@ -1,7 +1,9 @@ use std::path::{Path, PathBuf}; +use std::sync::Arc; use chrono::{SecondsFormat, Utc}; use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; use uuid::Uuid; use crate::storage::jsonl; @@ -61,6 +63,7 @@ impl WorkspaceEvent { #[derive(Debug, Clone)] pub struct WorkspaceEventStore { path: PathBuf, + io_lock: Arc>, } impl WorkspaceEventStore { @@ -69,7 +72,10 @@ impl WorkspaceEventStore { } pub fn new(path: impl Into) -> Self { - Self { path: path.into() } + Self { + path: path.into(), + io_lock: Arc::new(Mutex::new(())), + } } pub fn path(&self) -> &Path { @@ -77,10 +83,12 @@ impl WorkspaceEventStore { } pub async fn append(&self, event: &WorkspaceEvent) -> jsonl::Result<()> { + let _guard = self.io_lock.lock().await; jsonl::append(&self.path, event).await } pub async fn read_events(&self) -> jsonl::Result> { + let _guard = self.io_lock.lock().await; jsonl::read_all(&self.path).await } @@ -138,4 +146,30 @@ mod tests { let _ = tokio::fs::remove_dir_all(path.parent().unwrap()).await; } + + #[tokio::test] + async fn cloned_stores_serialize_appends() { + let path = temp_jsonl_path(); + let store = WorkspaceEventStore::new(path.clone()); + let mut tasks = tokio::task::JoinSet::new(); + for index in 0..16 { + let store = store.clone(); + tasks.spawn(async move { + store + .append(&WorkspaceEvent::registered( + format!("ws_{index}"), + PathBuf::from(format!("/tmp/ws-{index}")), + format!("Workspace {index}"), + false, + )) + .await + }); + } + while let Some(result) = tasks.join_next().await { + result.unwrap().unwrap(); + } + + assert_eq!(store.read_events().await.unwrap().len(), 16); + let _ = tokio::fs::remove_dir_all(path.parent().unwrap()).await; + } } diff --git a/src/core/src/workspace/threads/store.rs b/src/core/src/workspace/threads/store.rs index bef7cd62..c738742c 100644 --- a/src/core/src/workspace/threads/store.rs +++ b/src/core/src/workspace/threads/store.rs @@ -1,8 +1,10 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::sync::Arc; use serde::{Deserialize, Serialize}; use thiserror::Error; +use tokio::sync::Mutex; use uuid::Uuid; use crate::storage::jsonl; @@ -23,6 +25,7 @@ pub use projection::*; #[derive(Debug, Clone)] pub struct ThreadEventStore { path: PathBuf, + io_lock: Arc>, } impl ThreadEventStore { @@ -31,7 +34,10 @@ impl ThreadEventStore { } pub fn new(path: impl Into) -> Self { - Self { path: path.into() } + Self { + path: path.into(), + io_lock: Arc::new(Mutex::new(())), + } } pub fn path(&self) -> &Path { @@ -39,10 +45,12 @@ impl ThreadEventStore { } pub async fn append(&self, event: &ThreadEvent) -> jsonl::Result<()> { + let _guard = self.io_lock.lock().await; jsonl::append(&self.path, event).await } pub async fn read_events(&self) -> jsonl::Result> { + let _guard = self.io_lock.lock().await; jsonl::read_all(&self.path).await } @@ -64,6 +72,37 @@ pub enum ThreadStoreLoadError { mod tests { use super::*; + fn temp_jsonl_path() -> PathBuf { + std::env::temp_dir() + .join(format!("vibearound-thread-store-{}", Uuid::new_v4())) + .join("workspace-threads.jsonl") + } + + #[tokio::test] + async fn cloned_stores_serialize_appends() { + let path = temp_jsonl_path(); + let store = ThreadEventStore::new(path.clone()); + let mut tasks = tokio::task::JoinSet::new(); + for index in 0..16 { + let store = store.clone(); + tasks.spawn(async move { + store + .append(&ThreadEvent::created( + format!("wt_{index}"), + format!("ws_{index}"), + HostBinding::new("codex", None), + )) + .await + }); + } + while let Some(result) = tasks.join_next().await { + result.unwrap().unwrap(); + } + + assert_eq!(store.read_events().await.unwrap().len(), 16); + let _ = tokio::fs::remove_dir_all(path.parent().unwrap()).await; + } + #[test] fn projection_tracks_thread_lifecycle() { let thread_id = WorkspaceThreadId::from("wt_a"); From 85536448030f655665650ec5d0f9a08173f4ec08 Mon Sep 17 00:00:00 2001 From: Jazzen Chen Date: Thu, 23 Jul 2026 21:22:51 +0800 Subject: [PATCH 05/11] chore(acp): update protocol SDKs to 1.3.0 --- src/Cargo.lock | 38 ++++++++++++++++++++------------------ src/Cargo.toml | 2 +- src/bun.lock | 18 +++++++++--------- src/core/Cargo.toml | 2 +- src/desktop/Cargo.toml | 2 +- src/launcher/Cargo.toml | 2 +- src/server/Cargo.toml | 2 +- src/web/package.json | 2 +- 8 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 365dbda1..d2e9b225 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -10,17 +10,19 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agent-client-protocol" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882b815ffa67b023e1b40e7ea3bab3f05869654e8565d6811870c5545285e1d3" +checksum = "1d386d6a58f4dbe0ebfa98e915caa54005dabdefd419de3d4678b28cd5752444" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", + "async-io", "async-process", "blocking", "futures", "futures-concurrency", "rustc-hash", + "rustix 1.1.4", "schemars 1.2.1", "serde", "serde_json", @@ -32,9 +34,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5ca63f112bd2459bcaf9eda0683b9ba95fc3b5e5fdd9036ca941c6a09345b1" +checksum = "97b94934d118a69e921d14e94d4af5b3076563318c52662c89a0ecb3f139e1da" dependencies = [ "quote", "syn 2.0.117", @@ -143,7 +145,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -154,7 +156,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1211,7 +1213,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1424,7 +1426,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2274,7 +2276,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.61.2", ] [[package]] @@ -2918,7 +2920,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3046,7 +3048,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3351,7 +3353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -4135,7 +4137,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4765,7 +4767,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5378,7 +5380,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5874,7 +5876,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5921,7 +5923,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6521,7 +6523,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src/Cargo.toml b/src/Cargo.toml index f13033e9..31df6094 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.dependencies] -agent-client-protocol = { version = "=1.2.0", features = ["unstable_end_turn_token_usage"] } +agent-client-protocol = { version = "=1.3.0", features = ["unstable_end_turn_token_usage"] } anyhow = "1" dashmap = "6.0" reqwest = { version = "0.12", default-features = false } diff --git a/src/bun.lock b/src/bun.lock index c129c652..f0b7e6dc 100644 --- a/src/bun.lock +++ b/src/bun.lock @@ -7,14 +7,14 @@ }, "desktop": { "name": "@vibearound/tauri", - "version": "0.7.18", + "version": "0.7.19", "devDependencies": { "@tauri-apps/cli": "^2.11.2", }, }, "desktop-ui": { "name": "@vibearound/desktop-ui", - "version": "0.7.18", + "version": "0.7.19", "dependencies": { "@dnd-kit/react": "^0.4.0", "@tauri-apps/api": "^2.11.0", @@ -43,21 +43,21 @@ }, "launcher": { "name": "@va/launcher", - "version": "0.7.18", + "version": "0.7.19", "bin": { "va-launch": "bin/va-launch.mjs", }, }, "shared/client-ts": { "name": "@vibearound/client", - "version": "0.7.18", + "version": "0.7.19", "dependencies": { "zod": "^4.4.3", }, }, "shared/i18n": { "name": "@va/i18n", - "version": "0.7.18", + "version": "0.7.19", "dependencies": { "react": "^19.2.6", }, @@ -67,7 +67,7 @@ }, "shared/ui": { "name": "@va/ui", - "version": "0.7.18", + "version": "0.7.19", "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -82,9 +82,9 @@ }, "web": { "name": "@vibearound/web", - "version": "0.7.18", + "version": "0.7.19", "dependencies": { - "@agentclientprotocol/sdk": "^1.2.1", + "@agentclientprotocol/sdk": "^1.3.0", "@streamdown/cjk": "^1.0.3", "@streamdown/code": "^1.1.1", "@xterm/addon-canvas": "^0.7.0", @@ -132,7 +132,7 @@ "rollup": "4.60.4", }, "packages": { - "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="], + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.3.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-i3h/efaeuMUFAO1HSfo97QZQnnvMd7wWBYtBsdL6UMZg3a78sk3Ffya5Xu7C7tYsXomXoDXJBAzQF2PcFKAhIQ=="], "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], diff --git a/src/core/Cargo.toml b/src/core/Cargo.toml index da19971e..f955c7ad 100644 --- a/src/core/Cargo.toml +++ b/src/core/Cargo.toml @@ -3,7 +3,7 @@ name = "common" version = "0.7.19" description = "VibeAround shared logic: PTY, session registry, tunnel, headless CLI, IM (Telegram)" edition = "2021" -rust-version = "1.82" +rust-version = "1.88" [dependencies] portable-pty = "0.9" diff --git a/src/desktop/Cargo.toml b/src/desktop/Cargo.toml index b0b89c97..4b5096af 100644 --- a/src/desktop/Cargo.toml +++ b/src/desktop/Cargo.toml @@ -3,7 +3,7 @@ name = "vibearound-desktop" version = "0.7.19" description = "VibeAround Desktop — Tray-first AI coding orchestrator" edition = "2021" -rust-version = "1.82" +rust-version = "1.88" [build-dependencies] tauri-build = { version = "2.5", features = [] } diff --git a/src/launcher/Cargo.toml b/src/launcher/Cargo.toml index 18b1867b..57dce981 100644 --- a/src/launcher/Cargo.toml +++ b/src/launcher/Cargo.toml @@ -3,7 +3,7 @@ name = "va-launcher" version = "0.7.19" description = "VibeAround native launch implementation crate for @va/launcher" edition = "2021" -rust-version = "1.82" +rust-version = "1.88" [lib] name = "va_launcher" diff --git a/src/server/Cargo.toml b/src/server/Cargo.toml index 8bfef97e..6f725b8b 100644 --- a/src/server/Cargo.toml +++ b/src/server/Cargo.toml @@ -3,7 +3,7 @@ name = "server" version = "0.7.19" description = "VibeAround HTTP/WebSocket server: SPA, /ws, /ws/chat, /mcp" edition = "2021" -rust-version = "1.82" +rust-version = "1.88" [[bin]] name = "vibearound-server" diff --git a/src/web/package.json b/src/web/package.json index 2e822d3c..14bbe1ff 100644 --- a/src/web/package.json +++ b/src/web/package.json @@ -9,7 +9,7 @@ "preview": "vite preview" }, "dependencies": { - "@agentclientprotocol/sdk": "^1.2.1", + "@agentclientprotocol/sdk": "^1.3.0", "@streamdown/cjk": "^1.0.3", "@streamdown/code": "^1.1.1", "@xterm/addon-canvas": "^0.7.0", From a5420856c2f02db478bcb770223dc94a7dc8f0cc Mon Sep 17 00:00:00 2001 From: Jazzen Chen Date: Thu, 23 Jul 2026 21:29:12 +0800 Subject: [PATCH 06/11] chore(agents): update Codex and Claude ACP adapters --- src/core/src/agent/install.rs | 10 +++++----- src/resources/agents.json | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/core/src/agent/install.rs b/src/core/src/agent/install.rs index 564b7702..05729751 100644 --- a/src/core/src/agent/install.rs +++ b/src/core/src/agent/install.rs @@ -524,17 +524,17 @@ mod tests { #[test] fn parses_scoped_npm_package_specs() { assert_eq!( - npm_package_spec("@agentclientprotocol/codex-acp@1.1.0"), + npm_package_spec("@agentclientprotocol/codex-acp@1.1.7"), NpmPackageSpec { package_name: "@agentclientprotocol/codex-acp", - requested_version: Some("1.1.0"), + requested_version: Some("1.1.7"), } ); assert_eq!( - npm_package_spec("@agentclientprotocol/claude-agent-acp@0.57.0"), + npm_package_spec("@agentclientprotocol/claude-agent-acp@0.61.0"), NpmPackageSpec { package_name: "@agentclientprotocol/claude-agent-acp", - requested_version: Some("0.57.0"), + requested_version: Some("0.61.0"), } ); } @@ -542,7 +542,7 @@ mod tests { #[test] fn derives_default_bin_name_from_package_name() { assert_eq!( - npm_package_bin_name("@agentclientprotocol/codex-acp@1.1.0"), + npm_package_bin_name("@agentclientprotocol/codex-acp@1.1.7"), "codex-acp" ); assert_eq!(npm_package_bin_name("plain-agent@1.2.3"), "plain-agent"); diff --git a/src/resources/agents.json b/src/resources/agents.json index dffd43cb..b24e47e7 100644 --- a/src/resources/agents.json +++ b/src/resources/agents.json @@ -7,7 +7,7 @@ "install": { "type": "npm" }, "acp": { "program": "node", - "npm_package": "@agentclientprotocol/claude-agent-acp@0.57.0", + "npm_package": "@agentclientprotocol/claude-agent-acp@0.61.0", "bin_name": "claude-agent-acp" }, "pty": { @@ -48,7 +48,7 @@ "install": { "type": "npm" }, "acp": { "program": "node", - "npm_package": "@agentclientprotocol/codex-acp@1.1.0", + "npm_package": "@agentclientprotocol/codex-acp@1.1.7", "bin_name": "codex-acp" }, "pty": { From 2183fa88628871a7e478429fc08669ebc4653d2e Mon Sep 17 00:00:00 2001 From: Jazzen Chen Date: Thu, 23 Jul 2026 21:34:57 +0800 Subject: [PATCH 07/11] test(matrix): align owned runtime fixtures --- src/scripts/ws-matrix.mjs | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/scripts/ws-matrix.mjs b/src/scripts/ws-matrix.mjs index e54b319c..8f4c5502 100644 --- a/src/scripts/ws-matrix.mjs +++ b/src/scripts/ws-matrix.mjs @@ -857,9 +857,6 @@ async function writeMatrixHome(home, workspace, upstreamUrl) { }); await writeJson(path.join(dataDir, "agents.json"), { agents: fakeAgentPreferences(home), - profileConnections: Object.fromEntries( - PROVIDER_TARGETS.map((providerDef) => [providerDef.profile, launchPreferences(providerDef)]), - ), }); const profiles = PROVIDER_TARGETS.map((providerDef) => { @@ -872,15 +869,18 @@ async function writeMatrixHome(home, workspace, upstreamUrl) { .filter((target) => target.endpointId) .map((target) => [target.api, target.endpointId]), ); - return profile( - providerDef.profile, - providerDef.label, - providerDef.provider, - apiTypes, - upstreamUrl, - models, - endpointIds, - ); + return { + ...profile( + providerDef.profile, + providerDef.label, + providerDef.provider, + apiTypes, + upstreamUrl, + models, + endpointIds, + ), + connections: launchPreferences(providerDef), + }; }); for (const item of profiles) { @@ -978,9 +978,9 @@ async function writeFakeAgents(home) { await linkBin(bin, "gemini", fakeAgent); await linkBin(bin, "opencode", fakeAgent); - await writePackage(nodeModules, "@agentclientprotocol/codex-acp", "1.1.0"); + await writePackage(nodeModules, "@agentclientprotocol/codex-acp", "1.1.7"); await writePackage(nodeModules, "pi-acp", "0.0.27"); - await writePackage(nodeModules, "@agentclientprotocol/claude-agent-acp", "0.0.0"); + await writePackage(nodeModules, "@agentclientprotocol/claude-agent-acp", "0.61.0"); } async function linkBin(bin, name, target) { @@ -1273,7 +1273,7 @@ class BridgeConversation { } bridgeClientKey() { - const authPath = path.join(process.env.HOME, ".vibearound", "auth.json"); + const authPath = path.join(process.env.HOME, ".vibearound", "local-api-auth.json"); const key = JSON.parse(readFileSync(authPath, "utf8")).token; if (!key) throw new Error("missing local bridge client key"); return key; From 135aea49f3f7cce4d9962327f11d0cfd625872b4 Mon Sep 17 00:00:00 2001 From: Jazzen Chen Date: Fri, 24 Jul 2026 02:25:14 +0800 Subject: [PATCH 08/11] fix(desktop): build web assets before dev launch --- src/desktop/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/desktop/tauri.conf.json b/src/desktop/tauri.conf.json index aff44fce..b7fc4615 100644 --- a/src/desktop/tauri.conf.json +++ b/src/desktop/tauri.conf.json @@ -4,7 +4,7 @@ "version": "0.7.19", "identifier": "com.vibearound.app", "build": { - "beforeDevCommand": "cargo build -p va-launcher && node ../scripts/prepare-va-launch.mjs --profile debug --desktop && node ../scripts/build-project-plugins.mjs && bun run --cwd ../desktop-ui dev", + "beforeDevCommand": "cargo build -p va-launcher && node ../scripts/prepare-va-launch.mjs --profile debug --desktop && node ../scripts/build-project-plugins.mjs && bun run --cwd ../web build && bun run --cwd ../desktop-ui dev", "devUrl": "http://localhost:5181", "beforeBuildCommand": "cargo build --release -p va-launcher && node ../scripts/prepare-va-launch.mjs --profile release --desktop && bun run --cwd ../desktop-ui build && bun run --cwd ../web build", "frontendDist": "../desktop-ui/dist" From 21b95765f5e3f22766e778401d1a0162d1336c42 Mon Sep 17 00:00:00 2001 From: Jazzen Chen Date: Fri, 24 Jul 2026 02:39:39 +0800 Subject: [PATCH 09/11] test(matrix): use launcher-owned executables --- src/scripts/ws-matrix.mjs | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/scripts/ws-matrix.mjs b/src/scripts/ws-matrix.mjs index 8f4c5502..9badd433 100644 --- a/src/scripts/ws-matrix.mjs +++ b/src/scripts/ws-matrix.mjs @@ -293,6 +293,7 @@ async function main() { const results = []; for (const testCase of CASES) { + console.log(`[matrix] ${testCase.name}`); results.push(await runCase({ testCase, token, workspace, baseUrl, upstream })); } @@ -412,7 +413,16 @@ async function sendMatrixTurn(ws, testCase, workspace, baseUrl, turn, newSession }; const startedAt = Date.now(); while (Date.now() - startedAt < MATRIX_TIMEOUT_MS) { - const event = await ws.next(MATRIX_TIMEOUT_MS); + const remainingMs = MATRIX_TIMEOUT_MS - (Date.now() - startedAt); + let event; + try { + event = await ws.next(remainingMs); + } catch (error) { + throw new Error( + `${testCase.name}: ${error.message} waiting for turn ${turn}`, + { cause: error }, + ); + } seen.events.push(event); if (event.kind === "error") { throw new Error(`${testCase.name}: websocket error: ${event.error}`); @@ -431,6 +441,13 @@ async function sendMatrixTurn(ws, testCase, workspace, baseUrl, turn, newSession if (update?.sessionUpdate === "tool_call") seen.toolCall = true; if (update?.sessionUpdate === "tool_call_update") seen.toolUpdate = true; const text = update?.content?.text; + if ( + update?.sessionUpdate === "agent_message_chunk" && + typeof text === "string" && + text.startsWith("MATRIX_ERROR ") + ) { + throw new Error(`${testCase.name}: fake agent failed: ${text.slice("MATRIX_ERROR ".length)}`); + } if ( update?.sessionUpdate === "agent_message_chunk" && typeof text === "string" && @@ -544,7 +561,13 @@ async function openChatSocket(token) { async function waitForEvent(ws, predicate, label) { const startedAt = Date.now(); while (Date.now() - startedAt < MATRIX_TIMEOUT_MS) { - const event = await ws.next(MATRIX_TIMEOUT_MS); + const remainingMs = MATRIX_TIMEOUT_MS - (Date.now() - startedAt); + let event; + try { + event = await ws.next(remainingMs); + } catch (error) { + throw new Error(`${error.message} waiting for ${label}`, { cause: error }); + } if (predicate(event)) return event; if (event.kind === "error") throw new Error(`waiting for ${label}: ${event.error}`); } @@ -854,9 +877,9 @@ async function writeMatrixHome(home, workspace, upstreamUrl) { mcp_auto_install: false, skill_auto_install: false, }, - }); - await writeJson(path.join(dataDir, "agents.json"), { - agents: fakeAgentPreferences(home), + launcher: { + agents: fakeAgentPreferences(home), + }, }); const profiles = PROVIDER_TARGETS.map((providerDef) => { From 3a2ba5f5d2a888054f058668a16489e70988b10d Mon Sep 17 00:00:00 2001 From: Jazzen Chen Date: Fri, 24 Jul 2026 03:09:13 +0800 Subject: [PATCH 10/11] feat(channels): send workspace files from active turns --- src/Cargo.lock | 1 + src/resources/mcp-tools.json | 18 +++ src/server/Cargo.toml | 1 + src/server/src/web_server/mcp/mod.rs | 3 +- src/server/src/web_server/mcp/tools.rs | 159 ++++++++++++++++++++++++- 5 files changed, 180 insertions(+), 2 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index d2e9b225..df22ac5b 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -4580,6 +4580,7 @@ dependencies = [ "tokio", "tower-http", "tracing", + "url", "uuid", "va-ai-api-bridge", "windows-sys 0.61.2", diff --git a/src/resources/mcp-tools.json b/src/resources/mcp-tools.json index d54d81c5..abaf9f77 100644 --- a/src/resources/mcp-tools.json +++ b/src/resources/mcp-tools.json @@ -40,6 +40,24 @@ } } }, + { + "name": "send_file", + "description": "Send a file from the active VibeAround workspace to the conversation that owns the current agent turn. Use this when the user explicitly asks you to send or attach a generated workspace file. Pass $VIBEAROUND_THREAD_ID as thread_id. The file must exist inside that thread's workspace.", + "inputSchema": { + "type": "object", + "properties": { + "thread_id": { + "type": "string", + "description": "Value of $VIBEAROUND_THREAD_ID for the current VibeAround-managed workspace thread." + }, + "file": { + "type": "string", + "description": "Absolute file path, or a path relative to the active workspace." + } + }, + "required": ["thread_id", "file"] + } + }, { "name": "prepare_handover", "description": "Prepare a session handover. Returns a /pickup command that the user can send in any IM channel connected to VibeAround to resume the session there.", diff --git a/src/server/Cargo.toml b/src/server/Cargo.toml index 6f725b8b..97cf9669 100644 --- a/src/server/Cargo.toml +++ b/src/server/Cargo.toml @@ -27,6 +27,7 @@ agent-client-protocol = { workspace = true } async-trait = "0.1" anyhow = { workspace = true } tracing = "0.1" +url = "2.5" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation"] } diff --git a/src/server/src/web_server/mcp/mod.rs b/src/server/src/web_server/mcp/mod.rs index ea689074..53ae2525 100644 --- a/src/server/src/web_server/mcp/mod.rs +++ b/src/server/src/web_server/mcp/mod.rs @@ -13,7 +13,7 @@ //! ## Module layout //! //! - [`jsonrpc`] — JSON-RPC 2.0 envelope + MCP content helpers -//! - [`tools`] — the five `tools/call` implementations +//! - [`tools`] — the `tools/call` implementations //! - [`sessions`] — per-agent on-disk session auto-discovery //! - [`ports`] — deny-list of well-known service ports @@ -136,6 +136,7 @@ async fn mcp_tools_call( "get_session_id" => { tools::mcp_get_session_id(id, arguments, params.get("_meta"), state).await } + "send_file" => tools::mcp_send_file(id, arguments, state).await, "prepare_handover" => tools::mcp_prepare_handover(id, arguments).await, "register_workspace" => tools::mcp_register_workspace(id, arguments).await, "initialize_subagents" => tools::mcp_initialize_subagents(id, arguments, state).await, diff --git a/src/server/src/web_server/mcp/tools.rs b/src/server/src/web_server/mcp/tools.rs index 3948ab67..0d8021c1 100644 --- a/src/server/src/web_server/mcp/tools.rs +++ b/src/server/src/web_server/mcp/tools.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; +use agent_client_protocol::schema::v1 as acp; use anyhow::{anyhow, Context}; use axum::Json; use serde::Deserialize; @@ -197,6 +198,135 @@ fn argument_string(arguments: &Value, field: &str) -> Option { string_field(arguments, field) } +// --------------------------------------------------------------------------- +// send_file — deliver one workspace file to the current turn target +// --------------------------------------------------------------------------- + +pub(super) async fn mcp_send_file( + id: Option, + arguments: &serde_json::Value, + state: &AppState, +) -> Json { + let Some(thread_id) = argument_string(arguments, "thread_id") else { + return jsonrpc_err(id, -32602, "Missing required argument: thread_id"); + }; + let thread_id = common::workspace::threads::WorkspaceThreadId::from(thread_id.as_str()); + let Some(file) = argument_string(arguments, "file") else { + return jsonrpc_err(id, -32602, "Missing required argument: file"); + }; + + let runtime = match state + .channel_hub + .workspace_thread_manager() + .runtime_for_thread_id(&thread_id) + .await + { + Ok(runtime) => runtime, + Err(error) => { + return mcp_error_text( + id, + &format!("Failed to load thread runtime {}: {:#}", thread_id, error), + ); + } + }; + let Some(target) = runtime.active_turn_target().current() else { + return mcp_error_text( + id, + "send_file can only be called during an active VibeAround turn.", + ); + }; + let snapshot = runtime.state().await; + let Some(session_id) = snapshot.session_id else { + return mcp_error_text(id, "The active thread does not have an agent session yet."); + }; + let file_path = match resolve_workspace_file(&snapshot.workspace, &file) { + Ok(path) => path, + Err(error) => return mcp_error_text(id, &error.to_string()), + }; + let file_name = file_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "attachment".to_string()); + let file_url = match url::Url::from_file_path(&file_path) { + Ok(url) => url.to_string(), + Err(()) => { + return mcp_error_text( + id, + &format!("Failed to create a file URI for {}", file_path.display()), + ); + } + }; + let size = std::fs::metadata(&file_path) + .ok() + .and_then(|metadata| i64::try_from(metadata.len()).ok()); + let mut link = acp::ResourceLink::new(file_name.clone(), file_url); + link.size = size; + let notification = acp::SessionNotification::new( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + acp::ContentBlock::ResourceLink(link), + )), + ); + let notification = match serde_json::to_value(notification) { + Ok(notification) => notification, + Err(error) => { + return mcp_error_text( + id, + &format!("Failed to encode the file notification: {}", error), + ); + } + }; + + state + .channel_hub + .send_output(common::channels::ChannelOutput::ThreadReply { + route: target.route, + reply_to: target.reply_to, + reply: common::channels::types::ThreadReply { + workspace_id: snapshot.workspace_id.to_string(), + thread_id: snapshot.thread_id.to_string(), + agent: common::channels::types::ThreadReplyAgent { + id: snapshot.host_binding.agent_id, + profile: snapshot.host_binding.profile_id, + session_id, + }, + payload: common::channels::types::ThreadReplyPayload::AcpSessionNotification { + notification, + }, + }, + }); + + mcp_text( + id, + &format!( + "Queued `{}` for delivery to the current VibeAround conversation.", + file_name + ), + ) +} + +fn resolve_workspace_file(workspace: &Path, file: &str) -> anyhow::Result { + let workspace = workspace + .canonicalize() + .with_context(|| format!("Failed to resolve workspace {}", workspace.display()))?; + let requested = PathBuf::from(file); + let requested = if requested.is_relative() { + workspace.join(requested) + } else { + requested + }; + let resolved = requested + .canonicalize() + .with_context(|| format!("File not found: {}", requested.display()))?; + if !resolved.starts_with(&workspace) { + return Err(anyhow!("File must be inside the active workspace.")); + } + if !resolved.is_file() { + return Err(anyhow!("Path is not a file: {}", resolved.display())); + } + Ok(resolved) +} + // --------------------------------------------------------------------------- // prepare_handover — issue a short-lived code consumed by /pickup // --------------------------------------------------------------------------- @@ -1190,7 +1320,9 @@ fn build_preview_url(state: &AppState, route: &str, slug: &str) -> String { mod tests { use serde_json::json; - use super::{codex_session_id_from_mcp_metadata, workspace_is_registered}; + use super::{ + codex_session_id_from_mcp_metadata, resolve_workspace_file, workspace_is_registered, + }; #[test] fn codex_metadata_prefers_turn_thread_id() { @@ -1269,4 +1401,29 @@ mod tests { std::path::Path::new("/tmp/missing") )); } + + #[test] + fn outbound_file_must_exist_inside_the_active_workspace() { + let nonce = uuid::Uuid::new_v4().simple().to_string(); + let root = std::env::temp_dir().join(format!("vibearound-send-file-{nonce}")); + let workspace = root.join("workspace"); + let outside = root.join("outside.txt"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write(workspace.join("report.txt"), "report").unwrap(); + std::fs::write(&outside, "outside").unwrap(); + + let resolved = resolve_workspace_file(&workspace, "report.txt").unwrap(); + assert_eq!( + resolved, + workspace.join("report.txt").canonicalize().unwrap() + ); + assert!( + resolve_workspace_file(&workspace, outside.to_str().unwrap()) + .unwrap_err() + .to_string() + .contains("inside the active workspace") + ); + + std::fs::remove_dir_all(root).unwrap(); + } } From 9ef0b5a225dce7f3eddfee5be4cc73e1e1a2000b Mon Sep 17 00:00:00 2001 From: Jazzen Chen Date: Fri, 24 Jul 2026 03:36:41 +0800 Subject: [PATCH 11/11] docs(slack): require file upload scope --- docs/guides/channels/slack.md | 3 ++- docs/zh/guides/channels/slack.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/guides/channels/slack.md b/docs/guides/channels/slack.md index 10cca3d1..850963c8 100644 --- a/docs/guides/channels/slack.md +++ b/docs/guides/channels/slack.md @@ -28,7 +28,7 @@ Slack runs over Socket Mode — no public callback URL needed. The whole app def "oauth_config": { "scopes": { "bot": [ - "files:read", "app_mentions:read", "chat:write", "commands", + "files:read", "files:write", "app_mentions:read", "chat:write", "commands", "im:history", "im:read", "im:write" ] }, @@ -69,6 +69,7 @@ Required fields: `bot_token` (xoxb) and `app_token` (xapp). - Slack reserves bare slash commands, so VibeAround commands use the `/va` prefix: `/va new`, `/va switch claude`, `/va status` ([command reference](../im-usage.md#command-reference) — every command works behind the prefix). - Connection dies immediately? Check that Socket Mode is enabled and `app_token` is the `xapp-` token, not the bot token. +- Sending workspace files requires the bot scope `files:write`. After adding the scope, reinstall the Slack app so the existing bot token receives it. --- diff --git a/docs/zh/guides/channels/slack.md b/docs/zh/guides/channels/slack.md index fc607d3d..a84160b9 100644 --- a/docs/zh/guides/channels/slack.md +++ b/docs/zh/guides/channels/slack.md @@ -28,7 +28,7 @@ Slack 走 Socket Mode —— 不需要公网回调 URL。整个应用定义一 "oauth_config": { "scopes": { "bot": [ - "files:read", "app_mentions:read", "chat:write", "commands", + "files:read", "files:write", "app_mentions:read", "chat:write", "commands", "im:history", "im:read", "im:write" ] }, @@ -69,6 +69,7 @@ Slack 走 Socket Mode —— 不需要公网回调 URL。整个应用定义一 - Slack 保留了裸的斜杠命令,所以 VibeAround 命令用 `/va` 前缀:`/va new`、`/va switch claude`、`/va status`([命令参考](../im-usage.md#命令参考) —— 每条命令都能加前缀使用)。 - 连接一建立就断?确认 Socket Mode 已启用,且 `app_token` 是 `xapp-` 开头的 token,不是 bot token。 +- 发送 workspace 文件需要 bot scope `files:write`。添加 scope 后需重新安装 Slack App,现有 bot token 才会获得该权限。 ---