Skip to content

Commit 0a90717

Browse files
authored
feat(monkeyai): 实现单进程端点 WebSocket 桥接 (#1295)
1 parent 08aaa3d commit 0a90717

40 files changed

Lines changed: 3695 additions & 39 deletions

monkeyai/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ MONKEYAI_INITIAL_ADMIN_PASSWORD=change-this-admin-password
55
# 修改对外端口时,同时修改统一对外访问地址。
66
# MONKEYAI_ADMIN_PORT=8080
77
# MONKEYAI_PUBLIC_URL=http://localhost:8080
8+
# 桥接仅支持一个后端进程;按内存预算设置连接上限(默认 1000)。
9+
# MONKEYAI_ENDPOINT_MAX_CONNECTIONS=1000
810

911
# 自动域名证书:默认关闭;启用表示同意所配置 CA 的服务条款。
1012
# 域名须指向当前服务器,公网 80 和 443 端口可达;仅支持单域名 HTTP-01。

monkeyai/admin/nginx/app.conf

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
root /usr/share/nginx/html;
22
index index.html;
33

4+
location = /api/v1/endpoints/connect {
5+
proxy_pass http://monkeyai_backend;
6+
proxy_http_version 1.1;
7+
proxy_set_header Upgrade $http_upgrade;
8+
proxy_set_header Connection "upgrade";
9+
proxy_set_header Host $http_host;
10+
proxy_set_header X-Real-IP $remote_addr;
11+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
12+
proxy_set_header X-Forwarded-Host $http_host;
13+
proxy_set_header X-Forwarded-Proto $scheme;
14+
proxy_buffering off;
15+
}
16+
417
location ~ ^/(?:api|oauth|v1|mcp)(?:/|$) {
518
proxy_pass http://monkeyai_backend;
619
proxy_http_version 1.1;

monkeyai/admin/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export default defineConfig({
1010
plugins: [react(), tailwindcss()],
1111
server: {
1212
proxy: {
13+
"^/api/v1/endpoints/connect$": { target: backend, ws: true },
1314
"/api": backend,
1415
"/oauth": backend,
1516
"/mcp": backend,

monkeyai/backend/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,20 @@ go run ./cmd/server
144144

145145
服务提供 `/healthz` 存活检查和 `/readyz` 数据库及 RustFS Bucket 就绪检查。pprof 默认单独监听 `127.0.0.1:6060`,入口为 `/debug/pprof/`,不对业务端口暴露。
146146

147+
## 端点桥接
148+
149+
`/api/v1/endpoints` 提供当前用户的端点管理,`/api/v1/endpoints/connect` 使用原生客户端 OAuth Bearer 升级为 WebSocket;不接受 Cookie、调用密钥或 URL token。完整协议见 [单进程端点桥接设计](../design/endpoint-bridge-design.md),HTTP 契约见 `api/agent.yaml`
150+
151+
只支持一个后端进程:PostgreSQL 保存端点资料,在线连接、队列与路由仅在内存中;不引入 Redis。发布须先停止旧进程再启动新进程,禁止双进程滚动接入。进程重启不恢复在线状态和消息,客户端重新握手并查询业务状态;超时或断线的请求结果未知,不自动重发。
152+
153+
生产使用 WSS,后端端口只对可信入口开放。非空 Origin 必须匹配 `MONKEYAI_PUBLIC_URL`,原生客户端可以省略。Nginx 和 Vite 已对桥接入口启用 Upgrade,代理空闲超时需大于心跳及等待窗口,默认 90 秒。应用退出时主动关闭桥接连接并使 `/readyz` 返回不可用。
154+
155+
`MONKEYAI_ENDPOINT_MAX_CONNECTIONS`(或 `-endpoint-max-connections`)默认 1000,包含等待 hello 的连接;每连接业务队列最多 64 条/2 MiB,需按内存预算下调连接数。用户最多 20 个 active 端点,离线仍计入。停用端点不是撤销设备登录授权;持有有效账号凭据可显式恢复。
156+
157+
access token 刷新后旧连接凭据失效,客户端应通过统一 OAuth 管理器轮换并重新连接。服务端每 30 秒复验一次,超时 5 秒;已知到期时间直接限制路由,数据库撤销或用户停用最多约 35 秒收敛。桥接不定义会话控制、文件操作等 Agent 业务方法。
158+
159+
纯协议测试运行 `go test ./internal/endpoint/...`;设置隔离的 `MONKEYAI_TEST_DATABASE_URL` 后,同一命令还覆盖真实数据库与 WebSocket,并可加 `-race` 验证并发路径。
160+
147161
## 资源功能验证
148162

149163
`go test ./...``go vet ./...` 运行常规检查。真实 PostgreSQL / RustFS 集成验证需要:

monkeyai/backend/api/agent.yaml

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,112 @@ servers:
77
security:
88
- OAuthAccessToken: []
99
paths:
10+
/api/v1/endpoints:
11+
get:
12+
summary: 分页读取当前用户端点
13+
description: 包含已停用端点;online 仅代表当前单进程连接。响应不缓存。
14+
parameters:
15+
- {name: page, in: query, schema: {type: integer, minimum: 1, maximum: 1000000, default: 1}}
16+
- {name: page_size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
17+
responses:
18+
'200':
19+
description: 端点分页
20+
content:
21+
application/json:
22+
schema:
23+
type: object
24+
required: [items, total, page, page_size]
25+
properties:
26+
items: {type: array, items: {$ref: '#/components/schemas/Endpoint'}}
27+
total: {type: integer}
28+
page: {type: integer}
29+
page_size: {type: integer}
30+
'400': {$ref: '#/components/responses/ResourceError400'}
31+
'401': {$ref: '#/components/responses/Unauthorized'}
32+
'503': {$ref: '#/components/responses/EndpointUnavailable'}
33+
/api/v1/endpoints/connect:
34+
get:
35+
summary: 建立原生客户端端点桥接 WebSocket
36+
description: >-
37+
仅接受 OAuth Bearer access token,不接受 Cookie 或 URL token。
38+
非空 Origin 必须匹配 MONKEYAI_PUBLIC_URL;生产使用 WSS。
39+
Upgrade 后 5 秒内发送 hello,服务端依次发送 welcome、directory.snapshot。
40+
使用协议主版本 1,UTF-8 JSON 文本消息最大 256 KiB(含分片累计),禁用压缩。
41+
request/response/event 信封及关闭码详见 design/endpoint-bridge-design.md。
42+
4003 需恢复 OAuth 凭据,4001/4002 不自动重连;断线不得自动重发业务消息。
43+
responses:
44+
'101': {description: WebSocket 升级成功}
45+
'400': {$ref: '#/components/responses/ResourceError400'}
46+
'401': {$ref: '#/components/responses/Unauthorized'}
47+
'403': {$ref: '#/components/responses/ResourceError403'}
48+
'429':
49+
description: 当前用户建连过于频繁,遵循 Retry-After 退避
50+
headers:
51+
Retry-After: {schema: {type: integer}}
52+
content:
53+
application/json:
54+
schema: {$ref: '#/components/schemas/ResourceError'}
55+
'503': {$ref: '#/components/responses/EndpointUnavailable'}
56+
/api/v1/endpoints/{machine_id}:
57+
parameters:
58+
- {$ref: '#/components/parameters/EndpointMachine'}
59+
get:
60+
summary: 读取当前用户的单个端点
61+
responses:
62+
'200': {$ref: '#/components/responses/EndpointResult'}
63+
'400': {$ref: '#/components/responses/ResourceError400'}
64+
'401': {$ref: '#/components/responses/Unauthorized'}
65+
'404': {$ref: '#/components/responses/ResourceError404'}
66+
'503': {$ref: '#/components/responses/EndpointUnavailable'}
67+
patch:
68+
summary: 修改端点别名
69+
requestBody:
70+
required: true
71+
content:
72+
application/json:
73+
schema:
74+
type: object
75+
additionalProperties: false
76+
required: [alias]
77+
properties:
78+
alias:
79+
type: [string, 'null']
80+
description: null 清除别名;非空白文本最多 128 UTF-8 字节,拒绝控制字符
81+
responses:
82+
'200': {$ref: '#/components/responses/EndpointResult'}
83+
'400': {$ref: '#/components/responses/ResourceError400'}
84+
'401': {$ref: '#/components/responses/Unauthorized'}
85+
'404': {$ref: '#/components/responses/ResourceError404'}
86+
'503': {$ref: '#/components/responses/EndpointUnavailable'}
87+
/api/v1/endpoints/{machine_id}/revoke:
88+
parameters:
89+
- {$ref: '#/components/parameters/EndpointMachine'}
90+
post:
91+
summary: 停用端点并断开连接
92+
description: 幂等,不接受请求体。不等于撤销设备登录授权,持有有效账号凭据仍可显式恢复。
93+
responses:
94+
'200': {$ref: '#/components/responses/EndpointResult'}
95+
'400': {$ref: '#/components/responses/ResourceError400'}
96+
'401': {$ref: '#/components/responses/Unauthorized'}
97+
'404': {$ref: '#/components/responses/ResourceError404'}
98+
'503': {$ref: '#/components/responses/EndpointUnavailable'}
99+
/api/v1/endpoints/{machine_id}/restore:
100+
parameters:
101+
- {$ref: '#/components/parameters/EndpointMachine'}
102+
post:
103+
summary: 恢复已停用端点
104+
description: 幂等,不接受请求体。不主动建立连接;active 端点最多 20 个,离线也计入。
105+
responses:
106+
'200': {$ref: '#/components/responses/EndpointResult'}
107+
'400': {$ref: '#/components/responses/ResourceError400'}
108+
'401': {$ref: '#/components/responses/Unauthorized'}
109+
'404': {$ref: '#/components/responses/ResourceError404'}
110+
'409':
111+
description: active 端点达到上限,错误码 endpoint_limit_exceeded
112+
content:
113+
application/json:
114+
schema: {$ref: '#/components/schemas/ResourceError'}
115+
'503': {$ref: '#/components/responses/EndpointUnavailable'}
10116
/oauth/connectors/{id}/callback:
11117
get:
12118
summary: 处理指定 MCP 的 OAuth 回调
@@ -2540,6 +2646,12 @@ components:
25402646
bearerFormat: OAuth 2.0 access token
25412647
description: Agent 登录授权流程签发的短期 access token。
25422648
parameters:
2649+
EndpointMachine:
2650+
name: machine_id
2651+
in: path
2652+
required: true
2653+
description: 规范小写、带连字符的 UUIDv4,仅限当前用户端点
2654+
schema: {type: string, format: uuid}
25432655
ResourceID:
25442656
name: id
25452657
in: path
@@ -2583,6 +2695,18 @@ components:
25832695
type: string
25842696
const: private, no-cache
25852697
responses:
2698+
EndpointResult:
2699+
description: 当前端点资料,时间为 Unix 毫秒
2700+
headers:
2701+
Cache-Control: {schema: {type: string, const: 'private, no-store'}}
2702+
content:
2703+
application/json:
2704+
schema: {$ref: '#/components/schemas/Endpoint'}
2705+
EndpointUnavailable:
2706+
description: 认证或存储暂不可用、连接数超限,或服务正在停止
2707+
content:
2708+
application/json:
2709+
schema: {$ref: '#/components/schemas/ResourceError'}
25862710
NotModified:
25872711
description: 当前接口内容未变化,无响应体。
25882712
headers:
@@ -2669,6 +2793,25 @@ components:
26692793
schema:
26702794
$ref: "#/components/schemas/ErrorResponse"
26712795
schemas:
2796+
Endpoint:
2797+
type: object
2798+
required: [machine_id, device_name, alias, display_name, platform, os_version, arch, client_version, protocol_version, status, online, created_at, updated_at, last_seen_at, revoked_at]
2799+
properties:
2800+
machine_id: {type: string, format: uuid}
2801+
device_name: {type: string}
2802+
alias: {type: [string, 'null']}
2803+
display_name: {type: string}
2804+
platform: {type: string, enum: [macos, windows, linux, ios, android]}
2805+
os_version: {type: string}
2806+
arch: {type: string}
2807+
client_version: {type: string}
2808+
protocol_version: {type: integer, const: 1}
2809+
status: {type: string, enum: [active, revoked]}
2810+
online: {type: boolean}
2811+
created_at: {type: integer, format: int64}
2812+
updated_at: {type: integer, format: int64}
2813+
last_seen_at: {type: [integer, 'null'], format: int64}
2814+
revoked_at: {type: [integer, 'null'], format: int64}
26722815
UserRuleInput:
26732816
type: object
26742817
properties:

monkeyai/backend/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/aws/aws-sdk-go-v2/config v1.33.3
99
github.com/aws/aws-sdk-go-v2/credentials v1.20.3
1010
github.com/aws/aws-sdk-go-v2/service/s3 v1.111.0
11+
github.com/coder/websocket v1.8.14
1112
github.com/go-chi/chi/v5 v5.3.2
1213
github.com/jackc/pgx/v5 v5.10.0
1314
gopkg.in/yaml.v3 v3.0.1

monkeyai/backend/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.49.0 h1:N7Ey8obY3uSui+cxl0OUzFlFmkxS
3636
github.com/aws/aws-sdk-go-v2/service/sts v1.49.0/go.mod h1:zMBwjSf4Pt8a1OHYiZ5rPD0PJRK1kQrUaxhS/Dbld8E=
3737
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
3838
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
39+
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
40+
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
3941
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
4042
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4143
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=

monkeyai/backend/internal/app/app.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/billing"
1717
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/config"
1818
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/database"
19+
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/endpoint"
1920
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/expert"
2021
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/group"
2122
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/httpapi"
@@ -39,6 +40,7 @@ type App struct {
3940
shutdownTimeout time.Duration
4041
billing *billing.Service
4142
proxy *proxy.Proxy
43+
endpoints *endpoint.Service
4244
}
4345

4446
func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
@@ -73,6 +75,7 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
7375
},
7476
billing: handler.(*applicationHandler).billing,
7577
proxy: handler.(*applicationHandler).proxy,
78+
endpoints: handler.(*applicationHandler).endpoints,
7679
database: pool,
7780
shutdownTimeout: cfg.ShutdownTimeout,
7881
}, nil
@@ -145,6 +148,8 @@ func newApplicationHandler(ctx context.Context, logger *slog.Logger, pool *pgxpo
145148

146149
agent := chi.NewRouter()
147150
agent.Use(identities.RequireAgent)
151+
endpoints := endpoint.NewService(endpoint.NewPostgres(pool), endpointAuth{identities}, logger, cfg.PublicURL).WithMaxConnections(cfg.EndpointMaxConnections)
152+
endpoints.RegisterAgent(agent)
148153
identities.RegisterAgent(agent)
149154
keys.RegisterAgent(agent)
150155
models.RegisterAgent(agent)
@@ -177,8 +182,8 @@ func newApplicationHandler(ctx context.Context, logger *slog.Logger, pool *pgxpo
177182
return audit.Actor{}
178183
}
179184
})(identities.AuthRouter())
180-
router.Mount("/", httpapi.New(logger, readiness{pool: pool, storage: storage}, admin, agent, auth))
181-
return &applicationHandler{Handler: router, billing: charges, proxy: modelProxy}, nil
185+
router.Mount("/", httpapi.New(logger, readiness{pool: pool, storage: storage, endpoints: endpoints}, admin, agent, auth))
186+
return &applicationHandler{Handler: router, billing: charges, proxy: modelProxy, endpoints: endpoints}, nil
182187
}
183188

184189
type modelResolver struct {
@@ -205,7 +210,9 @@ func (a *App) Run(ctx context.Context) error {
205210
workerCtx, stopWorker := context.WithCancel(context.Background())
206211
workerDone := make(chan struct{})
207212
go func() { defer close(workerDone); a.billing.Run(workerCtx) }()
208-
defer func() { stopWorker(); <-workerDone }()
213+
observeDone := make(chan struct{})
214+
go func() { defer close(observeDone); a.endpoints.Observe(workerCtx) }()
215+
defer func() { stopWorker(); <-workerDone; <-observeDone }()
209216

210217
result := make(chan error, len(a.servers))
211218
for _, server := range a.servers {
@@ -231,6 +238,11 @@ func (a *App) Run(ctx context.Context) error {
231238

232239
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), a.shutdownTimeout)
233240
defer cancel()
241+
bridgeCtx, bridgeCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
242+
if err := a.endpoints.Shutdown(bridgeCtx); err != nil {
243+
runErrors = append(runErrors, fmt.Errorf("关闭端点桥接: %w", err))
244+
}
245+
bridgeCancel()
234246
for _, server := range a.servers {
235247
if err := server.Shutdown(shutdownCtx); err != nil {
236248
runErrors = append(runErrors, fmt.Errorf("关闭 %s: %w", server.Addr, err))
@@ -248,11 +260,15 @@ func (a *App) Run(ctx context.Context) error {
248260
}
249261

250262
type readiness struct {
251-
pool *pgxpool.Pool
252-
storage resource.Storage
263+
pool *pgxpool.Pool
264+
storage resource.Storage
265+
endpoints *endpoint.Service
253266
}
254267

255268
func (r readiness) Ping(ctx context.Context) error {
269+
if r.endpoints != nil && !r.endpoints.Ready() {
270+
return errors.New("端点桥接正在停止")
271+
}
256272
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
257273
defer cancel()
258274
if err := r.pool.Ping(ctx); err != nil {

monkeyai/backend/internal/app/billing.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@ import (
77
"net/http"
88

99
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/billing"
10+
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/endpoint"
1011
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/mcp"
1112
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/proxy"
1213
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/resource"
1314
)
1415

1516
type applicationHandler struct {
1617
http.Handler
17-
billing *billing.Service
18-
proxy *proxy.Proxy
18+
billing *billing.Service
19+
proxy *proxy.Proxy
20+
endpoints *endpoint.Service
1921
}
2022
type modelBilling struct{ service *billing.Service }
2123

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"net/http"
6+
7+
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/endpoint"
8+
"github.com/chaitin/MonkeyCode/monkeyai/backend/internal/identity"
9+
)
10+
11+
type endpointAuth struct{ service *identity.Service }
12+
13+
func (a endpointAuth) Credential(r *http.Request) (endpoint.Credential, bool) {
14+
c, ok := identity.CredentialFromContext(r.Context())
15+
return endpoint.Credential{UserID: c.UserID, Reference: c.Reference, ExpiresAt: c.ExpiresAt}, ok
16+
}
17+
18+
func (a endpointAuth) Valid(ctx context.Context, c endpoint.Credential) (bool, error) {
19+
return a.service.ValidCredential(ctx, identity.AccessCredential{UserID: c.UserID, Reference: c.Reference, ExpiresAt: c.ExpiresAt})
20+
}

0 commit comments

Comments
 (0)