Commit eb27fafe by DigHuang Committed by GitHub

fix(sandbox): resolve inotify infinite loops & proxy loopback connection (#7129)

* Update repository.ts

* refactor(fastgpt): update default fastgpt-ide-agent image to ghcr.io
parent a1674a5f
...@@ -9,12 +9,12 @@ ...@@ -9,12 +9,12 @@
# - fastgpt-mcp-server: 3003 # - fastgpt-mcp-server: 3003
# - fastgpt-plugin: 3004 # - fastgpt-plugin: 3004
# - fastgpt-volume-manager: 3005 # - fastgpt-volume-manager: 3005
# - fastgpt-agent-sandbox-proxy: 1006
# - opensandbox-server: 8090 # - opensandbox-server: 8090
# - fastgpt-aiproxy: 3010 # - fastgpt-aiproxy: 3010
# - fastgpt-aiproxy-pg: 5432 # - fastgpt-aiproxy-pg: 5432
# - 使用 pgvector 作为默认的向量库 # - 使用 pgvector 作为默认的向量库
# - 配置 opensandbox-config 的 network_mode 为 docker 网络,如 dev_fastgpt # - fastgpt-agent-sandbox-proxy 通过端口映射暴露给浏览器,内部回源宿主机服务
# - 配置 opensandbox-config 的 host_ip 为宿主机 LAN IP,如 192.168.1.100
# plugin auth token # plugin auth token
x-plugin-auth-token: &x-plugin-auth-token 'token' x-plugin-auth-token: &x-plugin-auth-token 'token'
...@@ -22,6 +22,8 @@ x-plugin-auth-token: &x-plugin-auth-token 'token' ...@@ -22,6 +22,8 @@ x-plugin-auth-token: &x-plugin-auth-token 'token'
x-code-sandbox-token: &x-code-sandbox-token 'codesandbox' x-code-sandbox-token: &x-code-sandbox-token 'codesandbox'
# volume manager auth token # volume manager auth token
x-volume-manager-auth-token: &x-volume-manager-auth-token 'vmtoken' x-volume-manager-auth-token: &x-volume-manager-auth-token 'vmtoken'
# agent sandbox proxy secret, must match AGENT_SANDBOX_PROXY_SECRET in local FastGPT app
x-agent-sandbox-proxy-secret: &x-agent-sandbox-proxy-secret 'default_fastgpt_agent_sandbox_proxy_secret'
# aiproxy token # aiproxy token
x-aiproxy-token: &x-aiproxy-token 'token' x-aiproxy-token: &x-aiproxy-token 'token'
# 数据库连接相关配置 # 数据库连接相关配置
...@@ -50,8 +52,8 @@ x-log-config: &x-log-config ...@@ -50,8 +52,8 @@ x-log-config: &x-log-config
# 容器运行环境可能会自动注入 HTTP_PROXY/HTTPS_PROXY。 # 容器运行环境可能会自动注入 HTTP_PROXY/HTTPS_PROXY。
# 明确绕过 compose 内部服务,避免内部请求被代理劫持。 # 明确绕过 compose 内部服务,避免内部请求被代理劫持。
x-no-proxy-config: &x-no-proxy-config x-no-proxy-config: &x-no-proxy-config
NO_PROXY: localhost,127.0.0.1,::1,fastgpt,fastgpt-plugin,fastgpt-code-sandbox,fastgpt-aiproxy,fastgpt-aiproxy-pg,fastgpt-minio,fastgpt-mongo,fastgpt-redis,fastgpt-pg,fastgpt-mcp-server,opensandbox-server,fastgpt-volume-manager,host.docker.internal,*.orb.internal,*.orb.local NO_PROXY: localhost,127.0.0.1,::1,fastgpt,fastgpt-plugin,fastgpt-code-sandbox,fastgpt-agent-sandbox-proxy,fastgpt-aiproxy,fastgpt-aiproxy-pg,fastgpt-minio,fastgpt-mongo,fastgpt-redis,fastgpt-pg,fastgpt-mcp-server,opensandbox-server,fastgpt-volume-manager,host.docker.internal,*.orb.internal,*.orb.local
no_proxy: localhost,127.0.0.1,::1,fastgpt,fastgpt-plugin,fastgpt-code-sandbox,fastgpt-aiproxy,fastgpt-aiproxy-pg,fastgpt-minio,fastgpt-mongo,fastgpt-redis,fastgpt-pg,fastgpt-mcp-server,opensandbox-server,fastgpt-volume-manager,host.docker.internal,*.orb.internal,*.orb.local no_proxy: localhost,127.0.0.1,::1,fastgpt,fastgpt-plugin,fastgpt-code-sandbox,fastgpt-agent-sandbox-proxy,fastgpt-aiproxy,fastgpt-aiproxy-pg,fastgpt-minio,fastgpt-mongo,fastgpt-redis,fastgpt-pg,fastgpt-mcp-server,opensandbox-server,fastgpt-volume-manager,host.docker.internal,*.orb.internal,*.orb.local
services: services:
# Vector DB # Vector DB
...@@ -233,6 +235,24 @@ services: ...@@ -233,6 +235,24 @@ services:
interval: 30s interval: 30s
timeout: 20s timeout: 20s
retries: 3 retries: 3
fastgpt-agent-sandbox-proxy:
container_name: fastgpt-agent-sandbox-proxy
image: ghcr.io/labring/fastgpt-agent-sandbox-proxy:v0.2.0-beta1
ports:
- 1006:1006
networks:
- fastgpt
restart: always
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
<<: [*x-no-proxy-config]
PORT: 1006
# dev compose 不包含 FastGPT 主站,默认回调宿主机本地启动的 app。
FASTGPT_APP_URL: http://host.docker.internal:3000
# 主站返回 localhost upstream endpoint 时,proxy 容器需要改写为宿主机别名再连接。
AGENT_SANDBOX_PROXY_REWRITE_HOST: host.docker.internal
AGENT_SANDBOX_PROXY_SECRET: *x-agent-sandbox-proxy-secret
fastgpt-mcp-server: fastgpt-mcp-server:
container_name: fastgpt-mcp-server container_name: fastgpt-mcp-server
image: registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-mcp_server:v4.15.0-beta1 image: registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-mcp_server:v4.15.0-beta1
...@@ -298,7 +318,7 @@ services: ...@@ -298,7 +318,7 @@ services:
retries: 5 retries: 5
# Pre-pull only: not started by `docker compose up` (uses profile `prepull`). # Pre-pull only: not started by `docker compose up` (uses profile `prepull`).
opensandbox-agent-sandbox-image: opensandbox-agent-sandbox-image:
image: registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox:v0.2.0 image: registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox:v0.2.0-beta1
profiles: profiles:
- prepull - prepull
opensandbox-execd-image: opensandbox-execd-image:
......
...@@ -9,12 +9,12 @@ ...@@ -9,12 +9,12 @@
# - fastgpt-mcp-server: 3003 # - fastgpt-mcp-server: 3003
# - fastgpt-plugin: 3004 # - fastgpt-plugin: 3004
# - fastgpt-volume-manager: 3005 # - fastgpt-volume-manager: 3005
# - fastgpt-agent-sandbox-proxy: 1006
# - opensandbox-server: 8090 # - opensandbox-server: 8090
# - fastgpt-aiproxy: 3010 # - fastgpt-aiproxy: 3010
# - fastgpt-aiproxy-pg: 5432 # - fastgpt-aiproxy-pg: 5432
# - 使用 pgvector 作为默认的向量库 # - 使用 pgvector 作为默认的向量库
# - 配置 opensandbox-config 的 network_mode 为 docker 网络,如 dev_fastgpt # - fastgpt-agent-sandbox-proxy 通过端口映射暴露给浏览器,内部回源宿主机服务
# - 配置 opensandbox-config 的 host_ip 为宿主机 LAN IP,如 192.168.1.100
# plugin auth token # plugin auth token
x-plugin-auth-token: &x-plugin-auth-token 'token' x-plugin-auth-token: &x-plugin-auth-token 'token'
...@@ -22,6 +22,8 @@ x-plugin-auth-token: &x-plugin-auth-token 'token' ...@@ -22,6 +22,8 @@ x-plugin-auth-token: &x-plugin-auth-token 'token'
x-code-sandbox-token: &x-code-sandbox-token 'codesandbox' x-code-sandbox-token: &x-code-sandbox-token 'codesandbox'
# volume manager auth token # volume manager auth token
x-volume-manager-auth-token: &x-volume-manager-auth-token 'vmtoken' x-volume-manager-auth-token: &x-volume-manager-auth-token 'vmtoken'
# agent sandbox proxy secret, must match AGENT_SANDBOX_PROXY_SECRET in local FastGPT app
x-agent-sandbox-proxy-secret: &x-agent-sandbox-proxy-secret 'default_fastgpt_agent_sandbox_proxy_secret'
# aiproxy token # aiproxy token
x-aiproxy-token: &x-aiproxy-token 'token' x-aiproxy-token: &x-aiproxy-token 'token'
# 数据库连接相关配置 # 数据库连接相关配置
...@@ -50,8 +52,8 @@ x-log-config: &x-log-config ...@@ -50,8 +52,8 @@ x-log-config: &x-log-config
# 容器运行环境可能会自动注入 HTTP_PROXY/HTTPS_PROXY。 # 容器运行环境可能会自动注入 HTTP_PROXY/HTTPS_PROXY。
# 明确绕过 compose 内部服务,避免内部请求被代理劫持。 # 明确绕过 compose 内部服务,避免内部请求被代理劫持。
x-no-proxy-config: &x-no-proxy-config x-no-proxy-config: &x-no-proxy-config
NO_PROXY: localhost,127.0.0.1,::1,fastgpt,fastgpt-plugin,fastgpt-code-sandbox,fastgpt-aiproxy,fastgpt-aiproxy-pg,fastgpt-minio,fastgpt-mongo,fastgpt-redis,fastgpt-pg,fastgpt-mcp-server,opensandbox-server,fastgpt-volume-manager,host.docker.internal,*.orb.internal,*.orb.local NO_PROXY: localhost,127.0.0.1,::1,fastgpt,fastgpt-plugin,fastgpt-code-sandbox,fastgpt-agent-sandbox-proxy,fastgpt-aiproxy,fastgpt-aiproxy-pg,fastgpt-minio,fastgpt-mongo,fastgpt-redis,fastgpt-pg,fastgpt-mcp-server,opensandbox-server,fastgpt-volume-manager,host.docker.internal,*.orb.internal,*.orb.local
no_proxy: localhost,127.0.0.1,::1,fastgpt,fastgpt-plugin,fastgpt-code-sandbox,fastgpt-aiproxy,fastgpt-aiproxy-pg,fastgpt-minio,fastgpt-mongo,fastgpt-redis,fastgpt-pg,fastgpt-mcp-server,opensandbox-server,fastgpt-volume-manager,host.docker.internal,*.orb.internal,*.orb.local no_proxy: localhost,127.0.0.1,::1,fastgpt,fastgpt-plugin,fastgpt-code-sandbox,fastgpt-agent-sandbox-proxy,fastgpt-aiproxy,fastgpt-aiproxy-pg,fastgpt-minio,fastgpt-mongo,fastgpt-redis,fastgpt-pg,fastgpt-mcp-server,opensandbox-server,fastgpt-volume-manager,host.docker.internal,*.orb.internal,*.orb.local
services: services:
# Vector DB # Vector DB
...@@ -233,6 +235,24 @@ services: ...@@ -233,6 +235,24 @@ services:
interval: 30s interval: 30s
timeout: 20s timeout: 20s
retries: 3 retries: 3
fastgpt-agent-sandbox-proxy:
container_name: fastgpt-agent-sandbox-proxy
image: ghcr.io/labring/fastgpt-agent-sandbox-proxy:v0.2.0-beta1
ports:
- 1006:1006
networks:
- fastgpt
restart: always
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
<<: [*x-no-proxy-config]
PORT: 1006
# dev compose 不包含 FastGPT 主站,默认回调宿主机本地启动的 app。
FASTGPT_APP_URL: http://host.docker.internal:3000
# 主站返回 localhost upstream endpoint 时,proxy 容器需要改写为宿主机别名再连接。
AGENT_SANDBOX_PROXY_REWRITE_HOST: host.docker.internal
AGENT_SANDBOX_PROXY_SECRET: *x-agent-sandbox-proxy-secret
fastgpt-mcp-server: fastgpt-mcp-server:
container_name: fastgpt-mcp-server container_name: fastgpt-mcp-server
image: ghcr.io/labring/fastgpt-mcp_server:v4.15.0-beta1 image: ghcr.io/labring/fastgpt-mcp_server:v4.15.0-beta1
...@@ -298,7 +318,7 @@ services: ...@@ -298,7 +318,7 @@ services:
retries: 5 retries: 5
# Pre-pull only: not started by `docker compose up` (uses profile `prepull`). # Pre-pull only: not started by `docker compose up` (uses profile `prepull`).
opensandbox-agent-sandbox-image: opensandbox-agent-sandbox-image:
image: ghcr.io/labring/fastgpt-agent-sandbox:v0.2.0 image: ghcr.io/labring/fastgpt-agent-sandbox:v0.2.0-beta1
profiles: profiles:
- prepull - prepull
opensandbox-execd-image: opensandbox-execd-image:
......
...@@ -49,7 +49,7 @@ const buildSandboxResourcesToArchiveQuery = (params: { ...@@ -49,7 +49,7 @@ const buildSandboxResourcesToArchiveQuery = (params: {
status: SandboxStatusEnum.stopped, status: SandboxStatusEnum.stopped,
lastActiveAt: { $lt: inactiveBefore }, lastActiveAt: { $lt: inactiveBefore },
'metadata.archive.state': { $exists: false } 'metadata.archive.state': { $exists: false }
}).sort({ lastActiveAt: 1 }); }).sort({ lastActiveAt: -1 });
}; };
/** /**
......
...@@ -449,6 +449,65 @@ describe('sandbox instance helpers', () => { ...@@ -449,6 +449,65 @@ describe('sandbox instance helpers', () => {
expect(stored?.storage).toBeUndefined(); expect(stored?.storage).toBeUndefined();
}); });
it('streams archive candidates by lastActiveAt descending', async () => {
const inactiveBefore = new Date('2026-02-01T00:00:00.000Z');
const appId = `instance-helper-${getNanoid()}`;
const [olderDoc, newerDoc] = await MongoSandboxInstance.create([
{
provider: 'opensandbox',
sandboxId: `instance-helper-${getNanoid()}`,
appId,
userId: 'user-1',
chatId: 'archive-old-chat',
type: SandboxTypeEnum.sessionRuntime,
status: SandboxStatusEnum.stopped,
lastActiveAt: new Date('2026-01-01T00:00:00.000Z'),
createdAt: new Date(),
metadata: {
image: { repository: 'image' }
}
},
{
provider: 'opensandbox',
sandboxId: `instance-helper-${getNanoid()}`,
appId,
userId: 'user-1',
chatId: 'archive-new-chat',
type: SandboxTypeEnum.sessionRuntime,
status: SandboxStatusEnum.stopped,
lastActiveAt: new Date('2026-01-20T00:00:00.000Z'),
createdAt: new Date(),
metadata: {
image: { repository: 'image' }
}
},
{
provider: 'opensandbox',
sandboxId: `instance-helper-${getNanoid()}`,
appId,
userId: 'user-1',
chatId: 'archive-active-chat',
type: SandboxTypeEnum.sessionRuntime,
status: SandboxStatusEnum.stopped,
lastActiveAt: new Date('2026-02-20T00:00:00.000Z'),
createdAt: new Date(),
metadata: {
image: { repository: 'image' }
}
}
]);
const resources = await collectArchiveCursor({
inactiveBefore,
providers: ['opensandbox']
});
expect(resources.map((item) => item.sandboxId)).toEqual([
newerDoc.sandboxId,
olderDoc.sandboxId
]);
});
it('supports repository optional provider and update branches', async () => { it('supports repository optional provider and update branches', async () => {
const appId = `instance-helper-${getNanoid()}`; const appId = `instance-helper-${getNanoid()}`;
const chatId = `chat-${getNanoid()}`; const chatId = `chat-${getNanoid()}`;
......
...@@ -17,3 +17,7 @@ FASTGPT_APP_URL=http://localhost:3000 ...@@ -17,3 +17,7 @@ FASTGPT_APP_URL=http://localhost:3000
# 5. Proxy 回源主站请求超时时间(秒)。需覆盖主站冷启动沙盒、读取 agent password 和 endpoint 查询。 # 5. Proxy 回源主站请求超时时间(秒)。需覆盖主站冷启动沙盒、读取 agent password 和 endpoint 查询。
FASTGPT_APP_REQUEST_TIMEOUT_SECS=10 FASTGPT_APP_REQUEST_TIMEOUT_SECS=10
# 6. 将主站返回的 localhost 上游地址改写为容器可访问的 Host。
# dev compose 通常填 host.docker.internal;生产环境一般留空。
AGENT_SANDBOX_PROXY_REWRITE_HOST=
use axum::extract::ws::{Message as AxumMsg, WebSocket as AxumWs}; use axum::extract::ws::{Message as AxumMsg, WebSocket as AxumWs};
use futures_util::{SinkExt, StreamExt, stream::SplitSink}; use futures_util::{SinkExt, StreamExt, stream::SplitSink};
use std::time::Duration; use reqwest::Url;
use std::{env, time::Duration};
use tokio_tungstenite::{ use tokio_tungstenite::{
connect_async_with_config, connect_async_with_config,
tungstenite::{ tungstenite::{
...@@ -27,6 +28,7 @@ enum UpstreamControl { ...@@ -27,6 +28,7 @@ enum UpstreamControl {
const UPSTREAM_CONNECT_MAX_ATTEMPTS: u8 = 10; const UPSTREAM_CONNECT_MAX_ATTEMPTS: u8 = 10;
const UPSTREAM_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(300); const UPSTREAM_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(300);
const LOOPBACK_REWRITE_HOST_ENV: &str = "AGENT_SANDBOX_PROXY_REWRITE_HOST";
fn upstream_ws_config() -> WebSocketConfig { fn upstream_ws_config() -> WebSocketConfig {
WebSocketConfig::default() WebSocketConfig::default()
...@@ -34,6 +36,146 @@ fn upstream_ws_config() -> WebSocketConfig { ...@@ -34,6 +36,146 @@ fn upstream_ws_config() -> WebSocketConfig {
.max_frame_size(Some(MAX_WS_FRAME_SIZE)) .max_frame_size(Some(MAX_WS_FRAME_SIZE))
} }
fn is_loopback_host(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}
/// 构建 proxy 连接上游 sandbox endpoint 的 WebSocket base URL。
/// 如果 endpoint 是回环地址,按 AGENT_SANDBOX_PROXY_REWRITE_HOST 改写 host。
fn build_ws_upstream_base_url(raw_endpoint: &str) -> Result<String, String> {
let rewrite_host = env::var(LOOPBACK_REWRITE_HOST_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
build_ws_upstream_base_url_with_rewrite(raw_endpoint, rewrite_host.as_deref())
}
fn build_ws_upstream_base_url_with_rewrite(
raw_endpoint: &str,
rewrite_host: Option<&str>,
) -> Result<String, String> {
let mut endpoint = parse_sandbox_endpoint(raw_endpoint)?;
rewrite_loopback_host(&mut endpoint, rewrite_host)?;
use_websocket_scheme(&mut endpoint)?;
Ok(endpoint.as_str().trim_end_matches('/').to_string())
}
fn parse_sandbox_endpoint(raw_endpoint: &str) -> Result<Url, String> {
let endpoint = raw_endpoint.trim().trim_end_matches('/');
if endpoint.is_empty() {
return Err("Sandbox endpoint url is empty.".to_string());
}
let endpoint = if endpoint.contains("://") {
endpoint.to_string()
} else {
format!("http://{}", endpoint)
};
Url::parse(&endpoint).map_err(|err| format!("Invalid sandbox endpoint url: {}", err))
}
fn rewrite_loopback_host(endpoint: &mut Url, rewrite_host: Option<&str>) -> Result<(), String> {
let Some(rewrite_host) = rewrite_host else {
return Ok(());
};
if !endpoint.host_str().is_some_and(is_loopback_host) {
return Ok(());
}
endpoint
.set_host(Some(rewrite_host))
.map_err(|_| format!("Invalid loopback rewrite host: {}", rewrite_host))?;
info!(
"[WSProxy] Rewrote loopback sandbox endpoint host to {}.",
rewrite_host
);
Ok(())
}
fn use_websocket_scheme(endpoint: &mut Url) -> Result<(), String> {
let ws_scheme = match endpoint.scheme() {
"http" | "ws" => "ws",
"https" | "wss" => "wss",
scheme => return Err(format!("Unsupported sandbox endpoint scheme: {}", scheme)),
};
endpoint
.set_scheme(ws_scheme)
.map_err(|_| format!("Failed to set sandbox endpoint scheme to {}", ws_scheme))
}
#[cfg(test)]
mod tests {
use super::build_ws_upstream_base_url_with_rewrite;
#[test]
fn rewrites_scheme_less_loopback_endpoint() {
let url = build_ws_upstream_base_url_with_rewrite(
"localhost:8090/sandboxes/demo/proxy/1318",
Some("host.docker.internal"),
)
.unwrap();
assert_eq!(
url,
"ws://host.docker.internal:8090/sandboxes/demo/proxy/1318"
);
}
#[test]
fn rewrites_http_loopback_endpoint() {
let url = build_ws_upstream_base_url_with_rewrite(
"http://127.0.0.1:8090/sandboxes/demo/proxy/1318/",
Some("host.docker.internal"),
)
.unwrap();
assert_eq!(
url,
"ws://host.docker.internal:8090/sandboxes/demo/proxy/1318"
);
}
#[test]
fn preserves_non_loopback_host() {
let url = build_ws_upstream_base_url_with_rewrite(
"http://opensandbox-server:8090/sandboxes/demo/proxy/1318",
Some("host.docker.internal"),
)
.unwrap();
assert_eq!(
url,
"ws://opensandbox-server:8090/sandboxes/demo/proxy/1318"
);
}
#[test]
fn preserves_secure_websocket_scheme() {
let url = build_ws_upstream_base_url_with_rewrite(
"https://sandbox.example.com/sandboxes/demo/proxy/1318",
None,
)
.unwrap();
assert_eq!(url, "wss://sandbox.example.com/sandboxes/demo/proxy/1318");
}
#[test]
fn rejects_unsupported_scheme() {
let err = build_ws_upstream_base_url_with_rewrite("ftp://localhost/sandboxes/demo", None)
.unwrap_err();
assert!(err.contains("Unsupported sandbox endpoint scheme"));
}
}
/// 连接沙盒内的 IDE Agent,允许 agent 冷启动时出现短暂端口不可用。 /// 连接沙盒内的 IDE Agent,允许 agent 冷启动时出现短暂端口不可用。
async fn connect_upstream_with_retry(target_url: String) -> Result<UpstreamWsStream, String> { async fn connect_upstream_with_retry(target_url: String) -> Result<UpstreamWsStream, String> {
let mut attempts = 0; let mut attempts = 0;
...@@ -91,15 +233,13 @@ pub async fn handle_relay( ...@@ -91,15 +233,13 @@ pub async fn handle_relay(
let target_url = match address.sandbox_url.as_deref().filter(|url| !url.is_empty()) { let target_url = match address.sandbox_url.as_deref().filter(|url| !url.is_empty()) {
Some(url) => { Some(url) => {
let ws_base = url let ws_base = match build_ws_upstream_base_url(url) {
.trim() Ok(ws_base) => ws_base,
.trim_end_matches('/') Err(err) => {
.replacen("http://", "ws://", 1) error!("[WSProxy] {}", err);
.replacen("https://", "wss://", 1); close_client_ws(&mut client_sink, 1008, "Invalid sandbox endpoint").await;
let ws_base = if ws_base.starts_with("ws://") || ws_base.starts_with("wss://") { return;
ws_base }
} else {
format!("ws://{}", ws_base)
}; };
format!( format!(
......
...@@ -70,7 +70,7 @@ AGENT_SANDBOX_SKILL_MAX_SIZE=10 ...@@ -70,7 +70,7 @@ AGENT_SANDBOX_SKILL_MAX_SIZE=10
# ==================== 沙盒代理 (agent-sandbox-proxy) 与网络配置 ==================== # ==================== 沙盒代理 (agent-sandbox-proxy) 与网络配置 ====================
# JWT 验签与内网安全物理阻断密钥 (必须与 Rust Proxy 的 AGENT_SANDBOX_PROXY_SECRET 环境变量保持完全一致) # JWT 验签与内网安全物理阻断密钥 (必须与 Rust Proxy 的 AGENT_SANDBOX_PROXY_SECRET 环境变量保持完全一致)
# 生产环境必须配置为至少 32 字节的高强度随机值,不能使用示例占位。 # 生产环境必须配置为至少 32 字节的高强度随机值,不能使用示例占位。
AGENT_SANDBOX_PROXY_SECRET= AGENT_SANDBOX_PROXY_SECRET=default_fastgpt_agent_sandbox_proxy_secret
# 浏览器客户端连接沙盒代理的对外 WebSocket 地址。 # 浏览器客户端连接沙盒代理的对外 WebSocket 地址。
# 启用 Agent Sandbox(show_agent_sandbox)时必填;未启用时可留空。 # 启用 Agent Sandbox(show_agent_sandbox)时必填;未启用时可留空。
# 开发环境建议配置为 ws://localhost:1006 (指向本地运行的 Rust 代理)。 # 开发环境建议配置为 ws://localhost:1006 (指向本地运行的 Rust 代理)。
......
...@@ -67,7 +67,6 @@ const SandboxEditor = ({ ...@@ -67,7 +67,6 @@ const SandboxEditor = ({
setExpandedDirs, setExpandedDirs,
loadingDirs, loadingDirs,
loadingRoot, loadingRoot,
loadingFile,
downloadingFile, downloadingFile,
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
...@@ -244,7 +243,6 @@ const SandboxEditor = ({ ...@@ -244,7 +243,6 @@ const SandboxEditor = ({
saveFile={saveFile} saveFile={saveFile}
editorRef={editorRef} editorRef={editorRef}
filteredTree={filteredTree} filteredTree={filteredTree}
loadingFile={loadingFile}
showTerminalBtn={showTerminal} showTerminalBtn={showTerminal}
canWrite={showFileOps} canWrite={showFileOps}
headerRight={headerRight} headerRight={headerRight}
......
...@@ -30,7 +30,6 @@ type Props = { ...@@ -30,7 +30,6 @@ type Props = {
saveFile: (path?: string) => void; saveFile: (path?: string) => void;
editorRef: React.MutableRefObject<SandboxEditorInstance | undefined>; editorRef: React.MutableRefObject<SandboxEditorInstance | undefined>;
filteredTree: TreeNode[]; filteredTree: TreeNode[];
loadingFile: boolean;
showTerminalBtn?: boolean; showTerminalBtn?: boolean;
canWrite?: boolean; canWrite?: boolean;
headerRight?: React.ReactNode; headerRight?: React.ReactNode;
...@@ -57,7 +56,6 @@ const EditorWorkspace = ({ ...@@ -57,7 +56,6 @@ const EditorWorkspace = ({
saveFile, saveFile,
editorRef, editorRef,
filteredTree, filteredTree,
loadingFile,
showTerminalBtn = false, showTerminalBtn = false,
canWrite = true, canWrite = true,
headerRight, headerRight,
...@@ -132,22 +130,6 @@ const EditorWorkspace = ({ ...@@ -132,22 +130,6 @@ const EditorWorkspace = ({
[terminalHeight, workspaceHeight] [terminalHeight, workspaceHeight]
); );
const [delayedLoadingFile, setDelayedLoadingFile] = useState(false);
useEffect(() => {
if (loadingFile) {
const startTimer = setTimeout(() => {
setDelayedLoadingFile(true);
}, 100);
return () => clearTimeout(startTimer);
} else {
const endTimer = setTimeout(() => {
setDelayedLoadingFile(false);
}, 200);
return () => clearTimeout(endTimer);
}
}, [loadingFile]);
const loadingLines = useMemo(() => { const loadingLines = useMemo(() => {
const lines: string[] = []; const lines: string[] = [];
const isStage1 = isPreparing; const isStage1 = isPreparing;
...@@ -166,16 +148,7 @@ const EditorWorkspace = ({ ...@@ -166,16 +148,7 @@ const EditorWorkspace = ({
const showWorkspaceHeader = openedFiles.length > 0 || !!headerRight; const showWorkspaceHeader = openedFiles.length > 0 || !!headerRight;
const mainEditorNode = ( const mainEditorNode = (
<MyBox <MyBox flex={1} display="flex" flexDirection="column" minH={0} w="100%" bg="white">
isLoading={delayedLoadingFile}
loadingVariant="particle"
flex={1}
display="flex"
flexDirection="column"
minH={0}
w="100%"
bg="white"
>
{loadingLines.length > 0 ? ( {loadingLines.length > 0 ? (
<Flex <Flex
flex={1} flex={1}
......
...@@ -325,7 +325,6 @@ export const useSandboxFileStore = ({ ...@@ -325,7 +325,6 @@ export const useSandboxFileStore = ({
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set([])); const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set([]));
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set()); const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
const [loadingRoot, setLoadingRoot] = useState(false); const [loadingRoot, setLoadingRoot] = useState(false);
const [loadingFile, setLoadingFile] = useState(false);
const [downloadingFile, setDownloadingFile] = useState(false); const [downloadingFile, setDownloadingFile] = useState(false);
const [isWsConnected, setIsWsConnected] = useState(false); const [isWsConnected, setIsWsConnected] = useState(false);
const reconnectAttemptsRef = useRef(0); const reconnectAttemptsRef = useRef(0);
...@@ -340,7 +339,6 @@ export const useSandboxFileStore = ({ ...@@ -340,7 +339,6 @@ export const useSandboxFileStore = ({
const expandedDirsRef = useLatest(expandedDirs); const expandedDirsRef = useLatest(expandedDirs);
const onErrorRef = useLatest(onError); const onErrorRef = useLatest(onError);
const loadingFilePathsRef = useRef<Set<string>>(new Set()); const loadingFilePathsRef = useRef<Set<string>>(new Set());
const loadingFileCountRef = useRef(0);
const isRefreshingWorkspaceRef = useRef(false); const isRefreshingWorkspaceRef = useRef(false);
const hasPendingWorkspaceRefreshRef = useRef(false); const hasPendingWorkspaceRefreshRef = useRef(false);
...@@ -443,9 +441,6 @@ export const useSandboxFileStore = ({ ...@@ -443,9 +441,6 @@ export const useSandboxFileStore = ({
filePath: string, filePath: string,
language: string language: string
): Promise<{ content: string; isUnknown: boolean; mtime?: number }> => { ): Promise<{ content: string; isUnknown: boolean; mtime?: number }> => {
loadingFileCountRef.current += 1;
setLoadingFile(true);
try {
const isBinary = getIsBinaryByLanguage(language); const isBinary = getIsBinaryByLanguage(language);
const res = await rpcCall<SandboxReadFileResponse>('fs/read_file', { path: filePath }); const res = await rpcCall<SandboxReadFileResponse>('fs/read_file', { path: filePath });
const rawBytes = Uint8Array.from(window.atob(res.content), (c) => c.charCodeAt(0)); const rawBytes = Uint8Array.from(window.atob(res.content), (c) => c.charCodeAt(0));
...@@ -462,10 +457,6 @@ export const useSandboxFileStore = ({ ...@@ -462,10 +457,6 @@ export const useSandboxFileStore = ({
const blob = new Blob([rawBytes], { type: mimeType }); const blob = new Blob([rawBytes], { type: mimeType });
return { content: URL.createObjectURL(blob), isUnknown: false, mtime: res.mtime }; return { content: URL.createObjectURL(blob), isUnknown: false, mtime: res.mtime };
} }
} finally {
loadingFileCountRef.current = Math.max(0, loadingFileCountRef.current - 1);
setLoadingFile(loadingFileCountRef.current > 0);
}
}, },
[rpcCall] [rpcCall]
); );
...@@ -482,7 +473,10 @@ export const useSandboxFileStore = ({ ...@@ -482,7 +473,10 @@ export const useSandboxFileStore = ({
let refreshOptions = options; let refreshOptions = options;
do { do {
hasPendingWorkspaceRefreshRef.current = false; hasPendingWorkspaceRefreshRef.current = false;
const shouldShowLoadingRoot = fileTree.length === 0;
if (shouldShowLoadingRoot) {
setLoadingRoot(true); setLoadingRoot(true);
}
let latestFileTree: TreeNode[] | null = null; let latestFileTree: TreeNode[] | null = null;
try { try {
...@@ -503,8 +497,10 @@ export const useSandboxFileStore = ({ ...@@ -503,8 +497,10 @@ export const useSandboxFileStore = ({
} catch (error) { } catch (error) {
console.error('Failed to refresh workspace via WS:', error); console.error('Failed to refresh workspace via WS:', error);
} finally { } finally {
if (shouldShowLoadingRoot) {
setLoadingRoot(false); setLoadingRoot(false);
} }
}
if (!hasPendingWorkspaceRefreshRef.current) { if (!hasPendingWorkspaceRefreshRef.current) {
const filesToReload = const filesToReload =
...@@ -577,6 +573,7 @@ export const useSandboxFileStore = ({ ...@@ -577,6 +573,7 @@ export const useSandboxFileStore = ({
}, },
[ [
expandedDirsRef, expandedDirsRef,
fileTree.length,
rpcCall, rpcCall,
setExpandedDirs, setExpandedDirs,
setFileTree, setFileTree,
...@@ -1455,7 +1452,6 @@ export const useSandboxFileStore = ({ ...@@ -1455,7 +1452,6 @@ export const useSandboxFileStore = ({
setExpandedDirs, setExpandedDirs,
loadingDirs, loadingDirs,
loadingRoot, loadingRoot,
loadingFile,
downloadingFile, downloadingFile,
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
......
...@@ -160,15 +160,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" ...@@ -160,15 +160,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
...@@ -409,32 +400,35 @@ dependencies = [ ...@@ -409,32 +400,35 @@ dependencies = [
[[package]] [[package]]
name = "notify" name = "notify"
version = "8.2.0" version = "9.0.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" checksum = "b44b771d4dd781ef14c84078693e67495da6b47f609f72e8a4da8420a861240e"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"fsevent-sys",
"inotify", "inotify",
"kqueue", "kqueue",
"libc", "libc",
"log", "log",
"mio", "mio",
"notify-types", "notify-types",
"objc2-core-foundation",
"objc2-core-services",
"walkdir", "walkdir",
"windows-sys 0.60.2", "windows-sys 0.61.2",
"xxhash-rust",
] ]
[[package]] [[package]]
name = "notify-debouncer-full" name = "notify-debouncer-full"
version = "0.7.0" version = "0.8.0-rc.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302" checksum = "9c9b3ca8dc01d924371a57d93be72fb7ca2c937f861f2f128926d967dab25182"
dependencies = [ dependencies = [
"file-id", "file-id",
"log", "log",
"notify", "notify",
"notify-types", "notify-types",
"rustc-hash",
"walkdir", "walkdir",
] ]
...@@ -448,6 +442,25 @@ dependencies = [ ...@@ -448,6 +442,25 @@ dependencies = [
] ]
[[package]] [[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "objc2-core-services"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b"
dependencies = [
"libc",
"objc2-core-foundation",
]
[[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
...@@ -568,6 +581,12 @@ dependencies = [ ...@@ -568,6 +581,12 @@ dependencies = [
] ]
[[package]] [[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
...@@ -1126,6 +1145,12 @@ dependencies = [ ...@@ -1126,6 +1145,12 @@ dependencies = [
] ]
[[package]] [[package]]
name = "xxhash-rust"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
[[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.50" version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
......
...@@ -13,8 +13,8 @@ serde_json = "1.0.150" ...@@ -13,8 +13,8 @@ serde_json = "1.0.150"
base64 = "0.22.1" base64 = "0.22.1"
futures-util = "0.3.32" futures-util = "0.3.32"
path-security = "0.2.0" path-security = "0.2.0"
notify = "8.2.0" notify = "9.0.0-rc.4"
notify-debouncer-full = "0.7.0" notify-debouncer-full = "0.8.0-rc.2"
[dev-dependencies] [dev-dependencies]
tempfile = "3.27.0" tempfile = "3.27.0"
...@@ -6,8 +6,10 @@ use std::time::{Duration, SystemTime}; ...@@ -6,8 +6,10 @@ use std::time::{Duration, SystemTime};
use base64::Engine; use base64::Engine;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use notify::event::ModifyKind; use notify::event::ModifyKind;
use notify::{Event, EventKind, RecursiveMode}; use notify::{Config, Event, EventKind, EventKindMask, RecommendedWatcher, RecursiveMode};
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, new_debouncer}; use notify_debouncer_full::{
DebounceEventResult, DebouncedEvent, RecommendedCache, new_debouncer_opt,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
...@@ -78,6 +80,10 @@ fn collect_debounced_event_paths(events: &[DebouncedEvent]) -> (Vec<String>, boo ...@@ -78,6 +80,10 @@ fn collect_debounced_event_paths(events: &[DebouncedEvent]) -> (Vec<String>, boo
let mut overflow = false; let mut overflow = false;
for event in events { for event in events {
if get_fs_event_kind(&event.event.kind).is_none() {
continue;
}
for path in collect_fs_event_paths(&event.event) { for path in collect_fs_event_paths(&event.event) {
if !paths.contains(&path) && paths.len() >= FS_CHANGE_MAX_PATHS { if !paths.contains(&path) && paths.len() >= FS_CHANGE_MAX_PATHS {
overflow = true; overflow = true;
...@@ -142,12 +148,16 @@ fn start_workspace_watcher(tx: broadcast::Sender<FsChangeBatch>) { ...@@ -142,12 +148,16 @@ fn start_workspace_watcher(tx: broadcast::Sender<FsChangeBatch>) {
let root = get_workspace_root().to_path_buf(); let root = get_workspace_root().to_path_buf();
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<DebounceEventResult>(); let (event_tx, mut event_rx) = mpsc::unbounded_channel::<DebounceEventResult>();
let mut debouncer = match new_debouncer( // 只订阅真实文件变更,避免 read_dir/read metadata 在 inotify 下触发 Access 事件后自激刷新。
let watcher_config = Config::default().with_event_kinds(EventKindMask::CORE);
let mut debouncer = match new_debouncer_opt::<_, RecommendedWatcher, _>(
Duration::from_millis(FS_CHANGE_DEBOUNCE_MS), Duration::from_millis(FS_CHANGE_DEBOUNCE_MS),
None, None,
move |res| { move |res| {
let _ = event_tx.send(res); let _ = event_tx.send(res);
}, },
RecommendedCache::new(),
watcher_config,
) { ) {
Ok(debouncer) => debouncer, Ok(debouncer) => debouncer,
Err(err) => { Err(err) => {
...@@ -803,6 +813,7 @@ mod tests { ...@@ -803,6 +813,7 @@ mod tests {
use notify::event::{CreateKind, EventAttributes, EventKind}; use notify::event::{CreateKind, EventAttributes, EventKind};
use serde_json::json; use serde_json::json;
use std::fs; use std::fs;
use std::time::Instant;
#[tokio::test] #[tokio::test]
async fn test_handle_fs_request_jsonrpc_workflow() { async fn test_handle_fs_request_jsonrpc_workflow() {
...@@ -1068,6 +1079,26 @@ mod tests { ...@@ -1068,6 +1079,26 @@ mod tests {
} }
#[test] #[test]
fn test_collect_debounced_event_paths_ignores_access_events() {
let temp_workspace = init_test_workspace();
let events = vec![DebouncedEvent::new(
Event {
kind: EventKind::Access(notify::event::AccessKind::Open(
notify::event::AccessMode::Read,
)),
paths: vec![temp_workspace.join("skills")],
attrs: EventAttributes::new(),
},
Instant::now(),
)];
let (paths, overflow) = collect_debounced_event_paths(&events);
assert!(paths.is_empty());
assert!(!overflow);
}
#[test]
fn test_build_fs_change_notification_uses_jsonrpc_notification_shape() { fn test_build_fs_change_notification_uses_jsonrpc_notification_shape() {
let notification = build_fs_change_notification( let notification = build_fs_change_notification(
vec!["src/index.ts".to_string()], vec!["src/index.ts".to_string()],
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment